Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
78 changes: 78 additions & 0 deletions src/app/api/webhooks/activity-alerts/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { NextRequest, NextResponse } from "next/server";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
import { resolveAppUser } from "@/lib/resolve-user";
import {
dispatchActivityAlert,
isActivityAlertEvent,
ACTIVITY_ALERT_EVENTS,
} from "@/lib/webhooks";

export const dynamic = "force-dynamic";

interface ActivityAlertBody {
event: string;
data?: Record<string, unknown>;
}

export async function POST(req: NextRequest): Promise<NextResponse> {
const session = await getServerSession(authOptions);

if (!session?.githubId || !session?.githubLogin) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}

const user = await resolveAppUser(session.githubId, session.githubLogin);
if (!user) {
return NextResponse.json({ error: "User not found" }, { status: 404 });
}

let body: ActivityAlertBody;
try {
body = await req.json();
} catch {
return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 });
}

const { event, data = {} } = body;

if (!event || typeof event !== "string") {
return NextResponse.json(
{
error: "Missing required field: event",
validEvents: ACTIVITY_ALERT_EVENTS,
},
{ status: 400 }
);
}

if (!isActivityAlertEvent(event)) {
return NextResponse.json(
{
error: `Invalid event '${event}'. Must be one of: ${ACTIVITY_ALERT_EVENTS.join(", ")}`,
validEvents: ACTIVITY_ALERT_EVENTS,
},
{ status: 400 }
);
}

dispatchActivityAlert(user.id, event, {
...data,
github_login: session.githubLogin,
triggered_at: new Date().toISOString(),
});

return NextResponse.json({
success: true,
event,
message: `Activity alert '${event}' queued for dispatch`,
});
}

export async function GET(): Promise<NextResponse> {
return NextResponse.json({
events: ACTIVITY_ALERT_EVENTS,
description:
"Activity alert events trigger outbound webhooks when subscribed events occur in DevTrack.",
});
}
2 changes: 2 additions & 0 deletions src/app/api/webhooks/custom/[id]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,9 @@ export async function PATCH(
);
}
const validEvents = [
"streak.milestone_reached",
"goal.completed",
"weekly_summary.ready",
"goal.created",
"streak.milestone",
"daily.summary",
Expand Down
16 changes: 14 additions & 2 deletions src/app/api/webhooks/custom/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ interface WebhookInput {
name: string;
url: string;
events: string[];
content_type?: string;
secret_token?: string;
}

async function requireUser(): Promise<{ user: AppUser } | { error: Response }> {
Expand Down Expand Up @@ -65,7 +67,7 @@ export async function POST(req: NextRequest) {
return Response.json({ error: "Invalid JSON" }, { status: 400 });
}

const { name, url, events } = body;
const { name, url, events, content_type, secret_token } = body;

const validatedName = validateTextInput(name, "Webhook name", 100);

Expand Down Expand Up @@ -99,7 +101,9 @@ export async function POST(req: NextRequest) {
}

const validEvents = [
"streak.milestone_reached",
"goal.completed",
"weekly_summary.ready",
"goal.created",
"streak.milestone",
"daily.summary",
Expand Down Expand Up @@ -127,7 +131,11 @@ export async function POST(req: NextRequest) {
);
}

const secretKey = generateSecretKey();
const rawToken =
typeof secret_token === "string" && secret_token.trim()
? secret_token.trim()
: generateSecretKey();
const secretKey = rawToken;
const { encrypted, iv } = encryptSecretKey(secretKey);

const { data: webhook, error } = await supabaseAdmin
Expand All @@ -139,6 +147,10 @@ export async function POST(req: NextRequest) {
events,
secret_key: encrypted,
secret_iv: iv,
content_type:
typeof content_type === "string" && content_type.trim()
? content_type.trim()
: "application/json",
is_enabled: true,
})
.select("id, name, url, events, is_enabled, created_at")
Expand Down
Loading
Loading