diff --git a/src/app/api/goals/[id]/route.ts b/src/app/api/goals/[id]/route.ts index e6a7ff66..e2eb0386 100644 --- a/src/app/api/goals/[id]/route.ts +++ b/src/app/api/goals/[id]/route.ts @@ -2,9 +2,75 @@ import { getServerSession } from "next-auth"; import { authOptions } from "@/lib/auth"; import { supabaseAdmin } from "@/lib/supabase"; import { resolveAppUser } from "@/lib/resolve-user"; +import { dispatchToAllWebhooks } from "@/lib/webhooks"; export const dynamic = "force-dynamic"; +export async function PATCH( + req: Request, + { params }: { params: { id: string } } +) { + const session = await getServerSession(authOptions); + if (!session?.githubId) { + return Response.json({ error: "Unauthorized" }, { status: 401 }); + } + + const user = await resolveAppUser(session.githubId, session.githubLogin); + if (!user) return Response.json({ error: "User not found" }, { status: 404 }); + + const body = await req.json().catch(() => ({})); + const { current } = body; + + if (typeof current !== "number" || current < 0) { + return Response.json( + { error: "Invalid current value" }, + { status: 400 } + ); + } + + const { data: existingGoal } = await supabaseAdmin + .from("goals") + .select("*") + .eq("id", params.id) + .eq("user_id", user.id) + .single(); + + if (!existingGoal) { + return Response.json({ error: "Goal not found" }, { status: 404 }); + } + + const wasCompleted = existingGoal.current >= existingGoal.target; + const { data: updatedGoal, error } = await supabaseAdmin + .from("goals") + .update({ current }) + .eq("id", params.id) + .eq("user_id", user.id) + .select() + .single(); + + if (error) { + return Response.json( + { error: "Failed to update goal" }, + { status: 500 } + ); + } + + const isNowCompleted = updatedGoal.current >= updatedGoal.target; + + if (!wasCompleted && isNowCompleted) { + dispatchToAllWebhooks(user.id, "goal.completed", { + goalId: updatedGoal.id, + title: updatedGoal.title, + target: updatedGoal.target, + unit: updatedGoal.unit, + recurrence: updatedGoal.recurrence, + completedAt: new Date().toISOString(), + }).catch(() => {}); + } + + return Response.json({ goal: updatedGoal }); +} + export async function DELETE( _req: Request, { params }: { params: { id: string } } @@ -17,7 +83,6 @@ export async function DELETE( const user = await resolveAppUser(session.githubId, session.githubLogin); if (!user) return Response.json({ error: "User not found" }, { status: 404 }); - // Only delete if the goal belongs to the authenticated user const { error } = await supabaseAdmin .from("goals") .delete() diff --git a/src/app/api/goals/route.ts b/src/app/api/goals/route.ts index 0a8eb0cd..c25b86a1 100644 --- a/src/app/api/goals/route.ts +++ b/src/app/api/goals/route.ts @@ -2,6 +2,7 @@ import { getServerSession } from "next-auth"; import { authOptions } from "@/lib/auth"; import { supabaseAdmin } from "@/lib/supabase"; import { resolveAppUser } from "@/lib/resolve-user"; +import { dispatchToAllWebhooks } from "@/lib/webhooks"; export const dynamic = "force-dynamic"; @@ -141,5 +142,13 @@ export async function POST(req: Request) { if (error) return Response.json({ error: error.message }, { status: 500 }); + dispatchToAllWebhooks(user.id, "goal.created", { + goalId: goal.id, + title: goal.title, + target: goal.target, + unit: goal.unit, + recurrence: goal.recurrence, + }).catch(() => {}); + return Response.json({ goal }, { status: 201 }); -} \ No newline at end of file +} diff --git a/src/app/api/integrations/jira/credentials/route.ts b/src/app/api/integrations/jira/credentials/route.ts new file mode 100644 index 00000000..17a0f6da --- /dev/null +++ b/src/app/api/integrations/jira/credentials/route.ts @@ -0,0 +1,153 @@ +import { getServerSession } from "next-auth"; +import { NextRequest } from "next/server"; +import { authOptions } from "@/lib/auth"; +import { supabaseAdmin } from "@/lib/supabase"; +import { resolveAppUser, AppUser } from "@/lib/resolve-user"; +import { encryptToken } from "@/lib/crypto"; + +export const dynamic = "force-dynamic"; + +interface JiraCredentialsInput { + jiraDomain: string; + email: string; + apiToken: string; + projectKey?: string; +} + +function validateJiraDomain(domain: string): boolean { + const atlassianDomainRegex = /^[a-zA-Z0-9][-a-zA-Z0-9]*\.atlassian\.net$/; + return atlassianDomainRegex.test(domain); +} + +function validateProjectKey(key: string): boolean { + const projectKeyRegex = /^[A-Z][A-Z0-9]{0,9}$/; + return projectKeyRegex.test(key); +} + +async function testJiraConnection( + domain: string, + email: string, + token: string +): Promise { + const auth = Buffer.from(`${email}:${token}`).toString("base64"); + const response = await fetch(`https://${domain}/rest/api/3/myself`, { + headers: { + Authorization: `Basic ${auth}`, + Accept: "application/json", + }, + cache: "no-store", + }); + return response.ok; +} + +async function requireUser(): Promise<{ user: AppUser } | { error: Response }> { + const session = await getServerSession(authOptions); + + if (!session?.githubId || !session?.githubLogin) { + return { error: Response.json({ error: "Unauthorized" }, { status: 401 }) }; + } + + const userRow = await resolveAppUser(session.githubId, session.githubLogin); + + if (!userRow) { + return { error: Response.json({ error: "User not found" }, { status: 404 }) }; + } + + return { user: userRow }; +} + +export async function GET(req: NextRequest) { + const result = await requireUser(); + if ("error" in result) return result.error; + + const { data: credentials } = await supabaseAdmin + .from("jira_credentials") + .select("id, jira_domain, email, project_key, is_active, created_at") + .eq("user_id", result.user.id); + + return Response.json({ credentials: credentials || [] }); +} + +export async function POST(req: NextRequest) { + const result = await requireUser(); + if ("error" in result) return result.error; + + let body: JiraCredentialsInput; + try { + body = await req.json(); + } catch { + return Response.json({ error: "Invalid JSON" }, { status: 400 }); + } + + const { jiraDomain, email, apiToken, projectKey } = body; + + if (!jiraDomain || !email || !apiToken) { + return Response.json( + { error: "Missing required fields" }, + { status: 400 } + ); + } + + if (!validateJiraDomain(jiraDomain)) { + return Response.json( + { error: "Invalid Jira domain format" }, + { status: 400 } + ); + } + + if (projectKey && !validateProjectKey(projectKey)) { + return Response.json( + { error: "Invalid project key format (use uppercase letters and numbers, e.g. PROJ)" }, + { status: 400 } + ); + } + + const valid = await testJiraConnection(jiraDomain, email, apiToken); + if (!valid) { + return Response.json( + { error: "Could not connect to Jira with provided credentials" }, + { status: 400 } + ); + } + + const { encrypted, iv } = encryptToken(apiToken); + + await supabaseAdmin.from("jira_credentials").upsert( + { + user_id: result.user.id, + jira_domain: jiraDomain, + email, + api_token: encrypted, + token_iv: iv, + project_key: projectKey || null, + is_active: true, + updated_at: new Date().toISOString(), + }, + { onConflict: "user_id" } + ); + + return Response.json({ success: true }); +} + +export async function DELETE(req: NextRequest) { + const result = await requireUser(); + if ("error" in result) return result.error; + + const { searchParams } = new URL(req.url); + const credentialId = searchParams.get("id"); + + if (credentialId) { + await supabaseAdmin + .from("jira_credentials") + .delete() + .eq("id", credentialId) + .eq("user_id", result.user.id); + } else { + await supabaseAdmin + .from("jira_credentials") + .delete() + .eq("user_id", result.user.id); + } + + return Response.json({ success: true }); +} diff --git a/src/app/api/integrations/jira/route.ts b/src/app/api/integrations/jira/route.ts new file mode 100644 index 00000000..cf97117e --- /dev/null +++ b/src/app/api/integrations/jira/route.ts @@ -0,0 +1,148 @@ +import { NextRequest } from "next/server"; +import { getServerSession } from "next-auth"; +import { authOptions } from "@/lib/auth"; +import { supabaseAdmin } from "@/lib/supabase"; +import { resolveAppUser, AppUser } from "@/lib/resolve-user"; +import { decryptToken } from "@/lib/crypto"; +import { JiraIssue, calculateMetrics } from "@/lib/jira-utils"; + +export const dynamic = "force-dynamic"; + +interface JiraCredentials { + id: string; + jira_domain: string; + email: string; + api_token: string; + token_iv: string; + project_key: string | null; +} + +async function requireUser(): Promise<{ user: AppUser } | { error: Response }> { + const session = await getServerSession(authOptions); + + if (!session?.githubId || !session?.githubLogin) { + return { error: Response.json({ error: "Unauthorized" }, { status: 401 }) }; + } + + const userRow = await resolveAppUser(session.githubId, session.githubLogin); + + if (!userRow) { + return { error: Response.json({ error: "User not found" }, { status: 404 }) }; + } + + return { user: userRow }; +} + +function validateProjectKey(key: string): boolean { + const projectKeyRegex = /^[A-Z][A-Z0-9]{0,9}$/; + return projectKeyRegex.test(key); +} + +async function fetchJiraIssues( + domain: string, + email: string, + token: string, + projectKey?: string +): Promise { + const auth = Buffer.from(`${email}:${token}`).toString("base64"); + const headers = { + Authorization: `Basic ${auth}`, + Accept: "application/json", + "Content-Type": "application/json", + }; + + let jql = "project is not EMPTY ORDER BY updated DESC"; + if (projectKey) { + if (!validateProjectKey(projectKey)) { + throw new Error("Invalid project key format"); + } + jql = `project = ${projectKey} ORDER BY updated DESC`; + } + + const searchUrl = `https://${domain}/rest/api/3/search?jql=${encodeURIComponent( + jql + )}&maxResults=50&fields=summary,status,created,updated,resolutiondate,assignee,priority`; + + const response = await fetch(searchUrl, { + headers, + cache: "no-store", + }); + + if (!response.ok) { + throw new Error(`Jira API error: ${response.status}`); + } + + const data = await response.json(); + + return (data.issues || []).map((issue: any) => ({ + key: issue.key, + summary: issue.fields.summary, + status: issue.fields.status.name, + statusCategory: issue.fields.status.statusCategory.key, + created: issue.fields.created, + updated: issue.fields.updated, + resolved: issue.fields.resolutiondate, + assignee: issue.fields.assignee?.displayName || null, + priority: issue.fields.priority?.name || "Medium", + })); +} + +export async function GET(req: NextRequest) { + const result = await requireUser(); + if ("error" in result) return result.error; + + const { data: credentials, error } = await supabaseAdmin + .from("jira_credentials") + .select("*") + .eq("user_id", result.user.id) + .eq("is_active", true) + .limit(1) + .single(); + + if (error || !credentials) { + return Response.json( + { error: "No Jira account connected" }, + { status: 404 } + ); + } + + const cred = credentials as unknown as JiraCredentials; + + let decryptedToken: string; + try { + const result = decryptToken(cred.api_token, cred.token_iv); + if (!result) { + return Response.json( + { error: "Failed to decrypt credentials" }, + { status: 500 } + ); + } + decryptedToken = result; + } catch { + return Response.json( + { error: "Failed to decrypt credentials" }, + { status: 500 } + ); + } + + try { + const issues = await fetchJiraIssues( + cred.jira_domain, + cred.email, + decryptedToken, + cred.project_key || undefined + ); + + const metrics = calculateMetrics(issues); + + return Response.json({ + metrics, + recentIssues: issues.slice(0, 10), + }); + } catch { + return Response.json( + { error: "Failed to fetch Jira data" }, + { status: 502 } + ); + } +} \ No newline at end of file diff --git a/src/app/api/metrics/streak/route.ts b/src/app/api/metrics/streak/route.ts index 3d0045c9..02a8bb72 100644 --- a/src/app/api/metrics/streak/route.ts +++ b/src/app/api/metrics/streak/route.ts @@ -11,6 +11,7 @@ import { } from "@/lib/metrics-cache"; import { supabaseAdmin } from "@/lib/supabase"; import { resolveAppUser } from "@/lib/resolve-user"; +import { dispatchToAllWebhooks } from "@/lib/webhooks"; export const dynamic = "force-dynamic"; @@ -142,6 +143,27 @@ function calculateStreakFromDates( }; } +async function checkAndRecordMilestone( + userId: string, + currentStreak: number +): Promise { + if (currentStreak < 7 || currentStreak % 7 !== 0) return; + + const { error } = await supabaseAdmin + .from("streak_milestones") + .upsert( + { user_id: userId, streak_count: currentStreak }, + { onConflict: "user_id,streak_count" } + ); + + if (!error) { + dispatchToAllWebhooks(userId, "streak.milestone", { + streakCount: currentStreak, + achievedAt: new Date().toISOString(), + }).catch(() => {}); + } +} + export async function GET(req: NextRequest) { const session = await getServerSession(authOptions); if (!session?.accessToken || !session.githubLogin || !session.githubId) { @@ -185,9 +207,13 @@ export async function GET(req: NextRequest) { session.accessToken, { bypass, userId: session.githubId } ); - return Response.json( - calculateStreakFromDates(activeDates, freezeDates) - ); + const streakData = calculateStreakFromDates(activeDates, freezeDates); + + if (appUserId && streakData.current > 0) { + checkAndRecordMilestone(appUserId, streakData.current).catch(() => {}); + } + + return Response.json(streakData); } catch { return Response.json({ error: "GitHub API error" }, { status: 502 }); } @@ -223,7 +249,13 @@ export async function GET(req: NextRequest) { } } - return Response.json(calculateStreakFromDates(unifiedDates, freezeDates)); + const streakData = calculateStreakFromDates(unifiedDates, freezeDates); + + if (streakData.current > 0) { + checkAndRecordMilestone(appUserId, streakData.current).catch(() => {}); + } + + return Response.json(streakData); } let resolvedToken = session.accessToken; @@ -256,7 +288,13 @@ export async function GET(req: NextRequest) { bypass, userId: accountId, }); - return Response.json(calculateStreakFromDates(activeDates, freezeDates)); + const streakData = calculateStreakFromDates(activeDates, freezeDates); + + if (accountId === session.githubId && streakData.current > 0) { + checkAndRecordMilestone(appUserId, streakData.current).catch(() => {}); + } + + return Response.json(streakData); } catch { return Response.json({ error: "GitHub API error" }, { status: 502 }); } diff --git a/src/app/api/webhooks/custom/[id]/deliveries/[deliveryId]/retry/route.ts b/src/app/api/webhooks/custom/[id]/deliveries/[deliveryId]/retry/route.ts new file mode 100644 index 00000000..017e1732 --- /dev/null +++ b/src/app/api/webhooks/custom/[id]/deliveries/[deliveryId]/retry/route.ts @@ -0,0 +1,87 @@ +import { NextRequest } from "next/server"; +import { getServerSession } from "next-auth"; +import { authOptions } from "@/lib/auth"; +import { supabaseAdmin } from "@/lib/supabase"; +import { resolveAppUser, AppUser } from "@/lib/resolve-user"; +import { dispatchWebhook } from "@/lib/webhooks"; + +export const dynamic = "force-dynamic"; + +async function requireUser(): Promise<{ user: AppUser } | { error: Response }> { + const session = await getServerSession(authOptions); + + if (!session?.githubId || !session?.githubLogin) { + return { error: Response.json({ error: "Unauthorized" }, { status: 401 }) }; + } + + const userRow = await resolveAppUser(session.githubId, session.githubLogin); + + if (!userRow) { + return { error: Response.json({ error: "User not found" }, { status: 404 }) }; + } + + return { user: userRow }; +} + +export async function POST( + req: NextRequest, + { params }: { params: Promise<{ id: string; deliveryId: string }> } +) { + const result = await requireUser(); + if ("error" in result) return result.error; + + const { id, deliveryId } = await params; + + const { data: webhook } = await supabaseAdmin + .from("webhook_configs") + .select("id, is_enabled") + .eq("id", id) + .eq("user_id", result.user.id) + .single(); + + if (!webhook) { + return Response.json({ error: "Webhook not found" }, { status: 404 }); + } + + if (!webhook.is_enabled) { + return Response.json({ error: "Webhook is disabled" }, { status: 400 }); + } + + const { data: delivery } = await supabaseAdmin + .from("webhook_deliveries") + .select("id, webhook_id, event, payload") + .eq("id", deliveryId) + .eq("webhook_id", id) + .single(); + + if (!delivery) { + return Response.json({ error: "Delivery not found" }, { status: 404 }); + } + + const payloadData = delivery.payload as Record; + + const { isSafeUrl } = await import("@/lib/ssrf-protection"); + const { data: webhookUrl } = await supabaseAdmin + .from("webhook_configs") + .select("url") + .eq("id", id) + .single(); + + if (!webhookUrl || !(await isSafeUrl(webhookUrl.url))) { + return Response.json( + { error: "Webhook URL is not allowed. Private, loopback, and internal addresses are blocked." }, + { status: 400 } + ); + } + + const result2 = await dispatchWebhook(id, delivery.event, payloadData); + + return Response.json({ + success: result2.success, + statusCode: result2.statusCode, + error: result2.error, + message: result2.success + ? "Webhook re-delivered successfully" + : "Webhook re-delivery failed", + }); +} diff --git a/src/app/api/webhooks/custom/[id]/rotate-secret/route.ts b/src/app/api/webhooks/custom/[id]/rotate-secret/route.ts new file mode 100644 index 00000000..5f0a6973 --- /dev/null +++ b/src/app/api/webhooks/custom/[id]/rotate-secret/route.ts @@ -0,0 +1,74 @@ +import { NextRequest } from "next/server"; +import { getServerSession } from "next-auth"; +import { authOptions } from "@/lib/auth"; +import { supabaseAdmin } from "@/lib/supabase"; +import { resolveAppUser, AppUser } from "@/lib/resolve-user"; +import { generateSecretKey, encryptSecretKey } from "@/lib/webhooks"; + +export const dynamic = "force-dynamic"; + +async function requireUser(): Promise<{ user: AppUser } | { error: Response }> { + const session = await getServerSession(authOptions); + + if (!session?.githubId || !session?.githubLogin) { + return { error: Response.json({ error: "Unauthorized" }, { status: 401 }) }; + } + + const userRow = await resolveAppUser(session.githubId, session.githubLogin); + + if (!userRow) { + return { error: Response.json({ error: "User not found" }, { status: 404 }) }; + } + + return { user: userRow }; +} + +export async function POST( + req: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + const result = await requireUser(); + if ("error" in result) return result.error; + + const { id } = await params; + + const { data: webhook } = await supabaseAdmin + .from("webhook_configs") + .select("id") + .eq("id", id) + .eq("user_id", result.user.id) + .single(); + + if (!webhook) { + return Response.json({ error: "Webhook not found" }, { status: 404 }); + } + + const newSecretKey = generateSecretKey(); + const { encrypted, iv } = encryptSecretKey(newSecretKey); + + const { data: updated, error } = await supabaseAdmin + .from("webhook_configs") + .update({ + secret_key: encrypted, + secret_iv: iv, + updated_at: new Date().toISOString(), + }) + .eq("id", id) + .eq("user_id", result.user.id) + .select("id, name") + .single(); + + if (error) { + console.error("Error rotating secret:", error); + return Response.json( + { error: "Failed to rotate secret key" }, + { status: 500 } + ); + } + + return Response.json({ + webhook: updated, + secretKey: newSecretKey, + message: "Secret key rotated. Store this new key securely. It will not be shown again.", + }); +} diff --git a/src/app/api/webhooks/custom/[id]/route.ts b/src/app/api/webhooks/custom/[id]/route.ts new file mode 100644 index 00000000..83b04d54 --- /dev/null +++ b/src/app/api/webhooks/custom/[id]/route.ts @@ -0,0 +1,198 @@ +import { NextRequest } from "next/server"; +import { getServerSession } from "next-auth"; +import { authOptions } from "@/lib/auth"; +import { supabaseAdmin } from "@/lib/supabase"; +import { resolveAppUser, AppUser } from "@/lib/resolve-user"; +import { isSafeUrl } from "@/lib/ssrf-protection"; + +export const dynamic = "force-dynamic"; + +interface WebhookUpdateInput { + name?: string; + url?: string; + events?: string[]; + is_enabled?: boolean; +} + +async function requireUser(): Promise<{ user: AppUser } | { error: Response }> { + const session = await getServerSession(authOptions); + + if (!session?.githubId || !session?.githubLogin) { + return { error: Response.json({ error: "Unauthorized" }, { status: 401 }) }; + } + + const userRow = await resolveAppUser(session.githubId, session.githubLogin); + + if (!userRow) { + return { error: Response.json({ error: "User not found" }, { status: 404 }) }; + } + + return { user: userRow }; +} + +export async function GET( + req: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + const result = await requireUser(); + if ("error" in result) return result.error; + + const { id } = await params; + + const { data: webhook } = await supabaseAdmin + .from("webhook_configs") + .select("id, name, url, events, is_enabled, created_at, updated_at") + .eq("id", id) + .eq("user_id", result.user.id) + .single(); + + if (!webhook) { + return Response.json({ error: "Webhook not found" }, { status: 404 }); + } + + const { data: recentDeliveries } = await supabaseAdmin + .from("webhook_deliveries") + .select("id, event, status_code, success, error_message, delivered_at") + .eq("webhook_id", id) + .order("delivered_at", { ascending: false }) + .limit(20); + + return Response.json({ + webhook, + recentDeliveries: recentDeliveries || [], + }); +} + +export async function PATCH( + req: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + const result = await requireUser(); + if ("error" in result) return result.error; + + const { id } = await params; + + let body: WebhookUpdateInput; + try { + body = await req.json(); + } catch { + return Response.json({ error: "Invalid JSON" }, { status: 400 }); + } + + const { data: existing } = await supabaseAdmin + .from("webhook_configs") + .select("id") + .eq("id", id) + .eq("user_id", result.user.id) + .single(); + + if (!existing) { + return Response.json({ error: "Webhook not found" }, { status: 404 }); + } + + const updateData: Record = { + updated_at: new Date().toISOString(), + }; + + if (body.name !== undefined) { + if (!body.name.trim()) { + return Response.json({ error: "Name cannot be empty" }, { status: 400 }); + } + updateData.name = body.name.trim(); + } + + if (body.url !== undefined) { + try { + const parsed = new URL(body.url); + if (!["http:", "https:"].includes(parsed.protocol)) { + return Response.json( + { error: "URL must use HTTP or HTTPS protocol" }, + { status: 400 } + ); + } + const safe = await isSafeUrl(body.url); + if (!safe) { + return Response.json( + { error: "Webhook URL is not allowed. Private, loopback, and internal addresses are blocked." }, + { status: 400 } + ); + } + updateData.url = body.url; + } catch { + return Response.json({ error: "Invalid URL format" }, { status: 400 }); + } + } + + if (body.events !== undefined) { + if (!Array.isArray(body.events) || body.events.length === 0) { + return Response.json( + { error: "At least one event must be selected" }, + { status: 400 } + ); + } + const validEvents = [ + "goal.completed", + "goal.created", + "streak.milestone", + "daily.summary", + "weekly.summary", + "metrics.updated", + ]; + const invalidEvents = body.events.filter((e) => !validEvents.includes(e)); + if (invalidEvents.length > 0) { + return Response.json( + { error: `Invalid events: ${invalidEvents.join(", ")}` }, + { status: 400 } + ); + } + updateData.events = body.events; + } + + if (body.is_enabled !== undefined) { + updateData.is_enabled = body.is_enabled; + } + + const { data: updated, error } = await supabaseAdmin + .from("webhook_configs") + .update(updateData) + .eq("id", id) + .eq("user_id", result.user.id) + .select("id, name, url, events, is_enabled, created_at, updated_at") + .single(); + + if (error) { + console.error("Error updating webhook:", error); + return Response.json( + { error: "Failed to update webhook" }, + { status: 500 } + ); + } + + return Response.json({ webhook: updated }); +} + +export async function DELETE( + req: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + const result = await requireUser(); + if ("error" in result) return result.error; + + const { id } = await params; + + const { error } = await supabaseAdmin + .from("webhook_configs") + .delete() + .eq("id", id) + .eq("user_id", result.user.id); + + if (error) { + console.error("Error deleting webhook:", error); + return Response.json( + { error: "Failed to delete webhook" }, + { status: 500 } + ); + } + + return Response.json({ success: true }); +} diff --git a/src/app/api/webhooks/custom/[id]/test/route.ts b/src/app/api/webhooks/custom/[id]/test/route.ts new file mode 100644 index 00000000..ca30086d --- /dev/null +++ b/src/app/api/webhooks/custom/[id]/test/route.ts @@ -0,0 +1,126 @@ +import { NextRequest } from "next/server"; +import { getServerSession } from "next-auth"; +import { authOptions } from "@/lib/auth"; +import { supabaseAdmin } from "@/lib/supabase"; +import { resolveAppUser, AppUser } from "@/lib/resolve-user"; +import { decryptSecretKey, signPayload } from "@/lib/webhooks"; +import { isSafeUrl } from "@/lib/ssrf-protection"; + +export const dynamic = "force-dynamic"; + +async function requireUser(): Promise<{ user: AppUser } | { error: Response }> { + const session = await getServerSession(authOptions); + + if (!session?.githubId || !session?.githubLogin) { + return { error: Response.json({ error: "Unauthorized" }, { status: 401 }) }; + } + + const userRow = await resolveAppUser(session.githubId, session.githubLogin); + + if (!userRow) { + return { error: Response.json({ error: "User not found" }, { status: 404 }) }; + } + + return { user: userRow }; +} + +export async function POST( + req: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + const result = await requireUser(); + if ("error" in result) return result.error; + + const { id } = await params; + + const { data: webhook } = await supabaseAdmin + .from("webhook_configs") + .select("id, url, secret_key, secret_iv") + .eq("id", id) + .eq("user_id", result.user.id) + .single(); + + if (!webhook) { + return Response.json({ error: "Webhook not found" }, { status: 404 }); + } + + const secret = decryptSecretKey(webhook.secret_key, webhook.secret_iv); + if (!secret) { + return Response.json({ error: "Failed to decrypt webhook secret" }, { status: 500 }); + } + + const safe = await isSafeUrl(webhook.url); + if (!safe) { + return Response.json( + { error: "Webhook URL is not allowed. Private, loopback, and internal addresses are blocked." }, + { status: 400 } + ); + } + + const testPayload = { + event: "test", + timestamp: new Date().toISOString(), + data: { + message: "This is a test webhook delivery from DevTrack", + webhookId: webhook.id, + userId: result.user.id, + test: true, + }, + }; + + const payloadString = JSON.stringify(testPayload); + const signature = signPayload(payloadString, secret); + + let statusCode: number | undefined; + let success: boolean; + let errorMessage: string | undefined; + let responseBody: string | undefined; + + try { + const response = await fetch(webhook.url, { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-Webhook-Signature": `sha256=${signature}`, + "X-Webhook-Event": "test", + "X-Webhook-Delivery-Id": webhook.id, + }, + body: payloadString, + signal: AbortSignal.timeout(15000), + }); + + statusCode = response.status; + success = response.ok; + responseBody = await response.text().catch(() => undefined); + + await supabaseAdmin.from("webhook_deliveries").insert({ + webhook_id: id, + event: "test", + payload: testPayload, + status_code: statusCode, + success, + error_message: success ? null : `HTTP ${statusCode}`, + }); + } catch (err) { + success = false; + errorMessage = err instanceof Error ? err.message : "Unknown error"; + + await supabaseAdmin.from("webhook_deliveries").insert({ + webhook_id: id, + event: "test", + payload: testPayload, + success: false, + error_message: errorMessage, + }); + } + + return Response.json({ + success, + statusCode, + error: errorMessage, + responseBody, + message: success + ? "Test webhook delivered successfully" + : "Test webhook delivery failed", + }); +} diff --git a/src/app/api/webhooks/custom/route.ts b/src/app/api/webhooks/custom/route.ts new file mode 100644 index 00000000..a7cc1dcc --- /dev/null +++ b/src/app/api/webhooks/custom/route.ts @@ -0,0 +1,146 @@ +import { NextRequest } from "next/server"; +import { getServerSession } from "next-auth"; +import { authOptions } from "@/lib/auth"; +import { supabaseAdmin } from "@/lib/supabase"; +import { resolveAppUser, AppUser } from "@/lib/resolve-user"; +import { generateSecretKey, encryptSecretKey } from "@/lib/webhooks"; +import { isSafeUrl } from "@/lib/ssrf-protection"; + +export const dynamic = "force-dynamic"; + +const MAX_WEBHOOKS_PER_USER = 5; + +interface WebhookInput { + name: string; + url: string; + events: string[]; +} + +async function requireUser(): Promise<{ user: AppUser } | { error: Response }> { + const session = await getServerSession(authOptions); + + if (!session?.githubId || !session?.githubLogin) { + return { error: Response.json({ error: "Unauthorized" }, { status: 401 }) }; + } + + const userRow = await resolveAppUser(session.githubId, session.githubLogin); + + if (!userRow) { + return { error: Response.json({ error: "User not found" }, { status: 404 }) }; + } + + return { user: userRow }; +} + +export async function GET(req: NextRequest) { + const result = await requireUser(); + if ("error" in result) return result.error; + + const { data: webhooks } = await supabaseAdmin + .from("webhook_configs") + .select("id, name, url, events, is_enabled, created_at, updated_at") + .eq("user_id", result.user.id) + .order("created_at", { ascending: false }); + + return Response.json({ webhooks: webhooks || [] }); +} + +export async function POST(req: NextRequest) { + const result = await requireUser(); + if ("error" in result) return result.error; + + let body: WebhookInput; + try { + body = await req.json(); + } catch { + return Response.json({ error: "Invalid JSON" }, { status: 400 }); + } + + const { name, url, events } = body; + + if (!name || !name.trim()) { + return Response.json({ error: "Webhook name is required" }, { status: 400 }); + } + + if (!url) { + return Response.json( + { error: "Invalid webhook URL. Must be a valid HTTP/HTTPS URL." }, + { status: 400 } + ); + } + + const safe = await isSafeUrl(url); + if (!safe) { + return Response.json( + { error: "Webhook URL is not allowed. Private, loopback, and internal addresses are blocked." }, + { status: 400 } + ); + } + + if (!events || !Array.isArray(events) || events.length === 0) { + return Response.json( + { error: "At least one event must be selected" }, + { status: 400 } + ); + } + + const validEvents = [ + "goal.completed", + "goal.created", + "streak.milestone", + "daily.summary", + "weekly.summary", + "metrics.updated", + ]; + + const invalidEvents = events.filter((e) => !validEvents.includes(e)); + if (invalidEvents.length > 0) { + return Response.json( + { error: `Invalid events: ${invalidEvents.join(", ")}` }, + { status: 400 } + ); + } + + const { data: existingWebhooks } = await supabaseAdmin + .from("webhook_configs") + .select("id") + .eq("user_id", result.user.id); + + if (existingWebhooks && existingWebhooks.length >= MAX_WEBHOOKS_PER_USER) { + return Response.json( + { error: `Webhook limit reached. Maximum ${MAX_WEBHOOKS_PER_USER} webhooks per user.` }, + { status: 400 } + ); + } + + const secretKey = generateSecretKey(); + const { encrypted, iv } = encryptSecretKey(secretKey); + + const { data: webhook, error } = await supabaseAdmin + .from("webhook_configs") + .insert({ + user_id: result.user.id, + name: name.trim(), + url, + events, + secret_key: encrypted, + secret_iv: iv, + is_enabled: true, + }) + .select("id, name, url, events, is_enabled, created_at") + .single(); + + if (error) { + console.error("Error creating webhook:", error); + return Response.json( + { error: "Failed to create webhook" }, + { status: 500 } + ); + } + + return Response.json({ + webhook, + secretKey, + message: "Store this secret key securely. It will not be shown again.", + }); +} diff --git a/src/app/api/webhooks/dispatch/metrics/route.ts b/src/app/api/webhooks/dispatch/metrics/route.ts new file mode 100644 index 00000000..e200a199 --- /dev/null +++ b/src/app/api/webhooks/dispatch/metrics/route.ts @@ -0,0 +1,87 @@ +import { NextResponse } from "next/server"; +import { getServerSession } from "next-auth"; +import { authOptions } from "@/lib/auth"; +import { resolveAppUser } from "@/lib/resolve-user"; +import { dispatchToAllWebhooks } from "@/lib/webhooks"; + +export const dynamic = "force-dynamic"; + +export async function POST(req: Request) { + const authHeader = req.headers.get("authorization"); + const expectedToken = process.env.WEBHOOK_DISPATCH_SECRET; + + if (!expectedToken || authHeader !== `Bearer ${expectedToken}`) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const body = await req.json().catch(() => ({})); + const { userId, metrics } = body; + + if (!userId) { + return NextResponse.json( + { error: "Missing required field: userId" }, + { status: 400 } + ); + } + + try { + await dispatchToAllWebhooks(userId, "metrics.updated", { + timestamp: new Date().toISOString(), + metrics: metrics || {}, + }); + + return NextResponse.json({ + success: true, + event: "metrics.updated", + }); + } catch (err) { + console.error("Failed to dispatch metrics.updated:", err); + return NextResponse.json( + { error: "Failed to dispatch webhook" }, + { status: 500 } + ); + } +} + +export async function GET(req: Request) { + const session = await getServerSession(authOptions); + if (!session?.accessToken || !session.githubId) { + 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 }); + + const { searchParams } = new URL(req.url); + const metricsData = searchParams.get("metrics"); + + let parsedMetrics = {}; + if (metricsData) { + try { + parsedMetrics = JSON.parse(metricsData); + } catch { + return NextResponse.json( + { error: "Invalid metrics JSON" }, + { status: 400 } + ); + } + } + + try { + await dispatchToAllWebhooks(user.id, "metrics.updated", { + timestamp: new Date().toISOString(), + metrics: parsedMetrics, + }); + + return NextResponse.json({ + success: true, + event: "metrics.updated", + }); + } catch (err) { + console.error("Failed to dispatch metrics.updated:", err); + return NextResponse.json( + { error: "Failed to dispatch" }, + { status: 500 } + ); + } +} diff --git a/src/app/api/webhooks/dispatch/route.ts b/src/app/api/webhooks/dispatch/route.ts new file mode 100644 index 00000000..b530f785 --- /dev/null +++ b/src/app/api/webhooks/dispatch/route.ts @@ -0,0 +1,193 @@ +import { NextResponse } from "next/server"; +import { getServerSession } from "next-auth"; +import { authOptions } from "@/lib/auth"; +import { supabaseAdmin } from "@/lib/supabase"; +import { resolveAppUser } from "@/lib/resolve-user"; +import { GITHUB_API } from "@/lib/github"; +import { dispatchToAllWebhooks } from "@/lib/webhooks"; + +export const dynamic = "force-dynamic"; + +interface MetricsData { + commits: number; + prsOpened: number; + prsMerged: number; + activeDays: number; +} + +async function getUserMetrics( + githubLogin: string, + token: string, + days: number +): Promise { + const since = new Date(); + since.setDate(since.getDate() - days); + const sinceStr = since.toISOString().slice(0, 10); + + const commitsRes = await fetch( + `${GITHUB_API}/search/commits?q=author:${githubLogin}+author-date:>=${sinceStr}&per_page=100`, + { + headers: { + Authorization: `Bearer ${token}`, + Accept: "application/vnd.github+json", + }, + cache: "no-store", + } + ); + + const commitsData = commitsRes.ok + ? await commitsRes.json() + : { total_count: 0, items: [] }; + + const activeDaysSet = new Set(); + for (const item of commitsData.items || []) { + activeDaysSet.add(item.commit.author.date.slice(0, 10)); + } + + const prsRes = await fetch( + `${GITHUB_API}/search/issues?q=type:pr+author:${githubLogin}+created:>=${sinceStr}&per_page=100`, + { + headers: { + Authorization: `Bearer ${token}`, + Accept: "application/vnd.github+json", + }, + cache: "no-store", + } + ); + + const prsData = prsRes.ok + ? await prsRes.json() + : { items: [] }; + + let prsOpened = 0; + let prsMerged = 0; + for (const item of prsData.items || []) { + if (item.state === "closed" && item.pull_request?.merged_at) { + prsMerged++; + } else { + prsOpened++; + } + } + + return { + commits: commitsData.total_count || 0, + prsOpened, + prsMerged, + activeDays: activeDaysSet.size, + }; +} + +async function dispatchEventForUser( + userId: string, + githubLogin: string, + accessToken: string, + event: string, + days: number +): Promise { + const metrics = await getUserMetrics(githubLogin, accessToken, days); + + await dispatchToAllWebhooks(userId, event, { + timestamp: new Date().toISOString(), + period: days === 1 ? "daily" : "weekly", + metrics: { + commits: metrics.commits, + prsOpened: metrics.prsOpened, + prsMerged: metrics.prsMerged, + activeDays: metrics.activeDays, + }, + }); +} + +export async function GET(req: Request) { + const session = await getServerSession(authOptions); + if (!session?.accessToken || !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 }); + + const { searchParams } = new URL(req.url); + const period = searchParams.get("period") || "daily"; + + try { + if (period === "daily") { + await dispatchEventForUser( + user.id, + session.githubLogin, + session.accessToken, + "daily.summary", + 1 + ); + } else if (period === "weekly") { + await dispatchEventForUser( + user.id, + session.githubLogin, + session.accessToken, + "weekly.summary", + 7 + ); + } else { + return NextResponse.json( + { error: "Invalid period. Use 'daily' or 'weekly'" }, + { status: 400 } + ); + } + + return NextResponse.json({ + success: true, + event: period === "daily" ? "daily.summary" : "weekly.summary", + message: "Summary webhook dispatched", + }); + } catch (err) { + console.error("Failed to dispatch summary:", err); + return NextResponse.json( + { error: "Failed to dispatch summary" }, + { status: 500 } + ); + } +} + +export async function POST(req: Request) { + const authHeader = req.headers.get("authorization"); + const expectedToken = process.env.WEBHOOK_DISPATCH_SECRET; + + if (!expectedToken || authHeader !== `Bearer ${expectedToken}`) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const body = await req.json().catch(() => ({})); + const { event, userId, githubLogin, accessToken } = body; + + if (!event || !["daily.summary", "weekly.summary", "metrics.updated"].includes(event)) { + return NextResponse.json( + { error: "Invalid event type" }, + { status: 400 } + ); + } + + if (!userId || !githubLogin || !accessToken) { + return NextResponse.json( + { error: "Missing required fields: userId, githubLogin, accessToken" }, + { status: 400 } + ); + } + + const days = event === "daily.summary" ? 1 : 7; + + try { + await dispatchEventForUser(userId, githubLogin, accessToken, event, days); + + return NextResponse.json({ + success: true, + event, + dispatched: 1, + }); + } catch (err) { + console.error("Failed to dispatch:", err); + return NextResponse.json( + { error: "Failed to dispatch webhook" }, + { status: 500 } + ); + } +} diff --git a/src/app/dashboard/page.tsx b/src/app/dashboard/page.tsx index 333e6d73..128c10cd 100644 --- a/src/app/dashboard/page.tsx +++ b/src/app/dashboard/page.tsx @@ -18,6 +18,7 @@ import WeeklySummaryCard from "@/components/WeeklySummaryCard"; import ExportButton from "@/components/ExportButton"; import Link from "next/link"; import PersonalRecords from "@/components/PersonalRecords"; +import ProjectMetrics from "@/components/ProjectMetrics"; import { authOptions } from "@/lib/auth"; import { cookies } from "next/headers"; import { getServerSession } from "next-auth"; @@ -91,12 +92,17 @@ export default async function DashboardPage() { - {/* Row 4: Pinned repositories */} + {/* Row 4: Project tracking (Jira integration) */} +
+ +
+ + {/* Row 5: Pinned repositories */}
- {/* Row 5: Top repos + Language breakdown + Goal tracker */} + {/* Row 6: Top repos + Language breakdown + Goal tracker */}
diff --git a/src/app/dashboard/settings/page.tsx b/src/app/dashboard/settings/page.tsx index ab68ccb5..6d909897 100644 --- a/src/app/dashboard/settings/page.tsx +++ b/src/app/dashboard/settings/page.tsx @@ -4,6 +4,7 @@ import { Suspense, useEffect, useMemo, useState } from "react"; import { useSession } from "next-auth/react"; import { redirect, useSearchParams } from "next/navigation"; import { useHeatmapTheme } from "@/hooks/useHeatmapTheme"; +import WebhookManager from "@/components/webhook/WebhookManager"; interface UserSettings { id: string; @@ -543,6 +544,8 @@ function SettingsPageContent() { )}
+ + ); diff --git a/src/components/GoalTracker.tsx b/src/components/GoalTracker.tsx index ad8407a4..9bd83e15 100644 --- a/src/components/GoalTracker.tsx +++ b/src/components/GoalTracker.tsx @@ -33,6 +33,9 @@ export default function GoalTracker() { const [createError, setCreateError] = useState(null); const [confirmingId, setConfirmingId] = useState(null); const [deletingId, setDeletingId] = useState(null); + const [editingId, setEditingId] = useState(null); + const [editValue, setEditValue] = useState(0); + const [updatingId, setUpdatingId] = useState(null); const [activeConfettiGoalId, setActiveConfettiGoalId] = useState(null); const prevGoalsRef = useRef>(new Map()); @@ -101,6 +104,37 @@ export default function GoalTracker() { } } + function startEditingProgress(goal: Goal) { + setEditingId(goal.id); + setEditValue(goal.current); + } + + async function saveProgress(id: string) { + setUpdatingId(id); + try { + const res = await fetch(`/api/goals/${id}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ current: editValue }), + }); + if (res.ok) { + const data = await res.json(); + setGoals((prev) => + prev.map((g) => (g.id === id ? data.goal : g)) + ); + } + } catch { + } finally { + setEditingId(null); + setUpdatingId(null); + } + } + + function cancelEditing() { + setEditingId(null); + setEditValue(0); + } + function getCompletionLabel(goal: Goal): string { if (goal.current >= goal.target) { if (goal.recurrence === "weekly") return "Completed this week ✓"; @@ -211,9 +245,44 @@ export default function GoalTracker() {
- - {goal.current}/{goal.target} {goal.unit} - + {editingId === goal.id ? ( + + setEditValue(Number(e.target.value))} + disabled={updatingId === goal.id} + className="w-16 rounded border border-[var(--border)] bg-[var(--background)] px-2 py-1 text-sm text-[var(--foreground)] outline-none focus:border-[var(--accent)]" + /> + + + + ) : ( + + )} {isConfirming ? ( diff --git a/src/components/ProjectMetrics.tsx b/src/components/ProjectMetrics.tsx new file mode 100644 index 00000000..6b2e0410 --- /dev/null +++ b/src/components/ProjectMetrics.tsx @@ -0,0 +1,349 @@ +"use client"; + +import { useCallback, useEffect, useState } from "react"; + +interface ProjectData { + metrics: { + total: number; + toDo: number; + inProgress: number; + done: number; + avgTimeToClose: number | null; + }; + recentIssues: Array<{ + key: string; + summary: string; + status: string; + statusCategory: string; + }>; +} + +interface JiraConnectFormData { + jiraDomain: string; + email: string; + apiToken: string; + projectKey: string; +} + +function formatHours(hours: number | null): string { + if (hours === null) return "—"; + if (hours < 24) return `${hours}h`; + return `${Math.round(hours / 24)}d`; +} + +function getStatusColor(status: string): string { + if (status === "Done") return "text-green-400"; + if (status === "In Progress") return "text-yellow-400"; + return "text-gray-400"; +} + +export default function ProjectMetrics() { + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [showForm, setShowForm] = useState(false); + const [formData, setFormData] = useState({ + jiraDomain: "", + email: "", + apiToken: "", + projectKey: "", + }); + const [connecting, setConnecting] = useState(false); + const [connectionError, setConnectionError] = useState(null); + + const fetchData = useCallback(() => { + setLoading(true); + setError(null); + + fetch("/api/integrations/jira") + .then((r) => { + if (!r.ok) { + if (r.status === 404) { + return null; + } + throw new Error("API error"); + } + return r.json(); + }) + .then((result) => { + if (result?.error) { + setError(result.error); + } else if (result) { + setData(result); + } + }) + .catch(() => setError("Failed to load project data")) + .finally(() => setLoading(false)); + }, []); + + useEffect(() => { + fetchData(); + }, [fetchData]); + + async function handleConnect(e: React.FormEvent) { + e.preventDefault(); + setConnecting(true); + setConnectionError(null); + + try { + const res = await fetch("/api/integrations/jira/credentials", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(formData), + }); + + const result = await res.json(); + + if (!res.ok) { + setConnectionError(result.error || "Connection failed"); + return; + } + + setShowForm(false); + setFormData({ jiraDomain: "", email: "", apiToken: "", projectKey: "" }); + fetchData(); + } catch { + setConnectionError("Connection failed"); + } finally { + setConnecting(false); + } + } + + async function handleDisconnect() { + await fetch("/api/integrations/jira/credentials", { + method: "DELETE", + }); + setData(null); + setError(null); + } + + if (loading) { + return ( +
+

+ Project Tracking +

+
+ {[1, 2, 3, 4].map((i) => ( +
+ ))} +
+
+ ); + } + + if (!data && !error) { + return ( +
+
+

+ Project Tracking +

+ +
+
+

+ Connect Jira to track issues alongside your code activity +

+

+ See your issue status, velocity, and time to close +

+
+ {showForm && ( +
+
+

+ Connect Jira +

+
+
+ + + setFormData({ ...formData, jiraDomain: e.target.value }) + } + className="w-full rounded-md border border-[var(--border)] bg-[var(--control)] px-3 py-2 text-[var(--foreground)]" + required + /> +
+
+ + + setFormData({ ...formData, email: e.target.value }) + } + className="w-full rounded-md border border-[var(--border)] bg-[var(--control)] px-3 py-2 text-[var(--foreground)]" + required + /> +
+
+ + + setFormData({ ...formData, apiToken: e.target.value }) + } + className="w-full rounded-md border border-[var(--border)] bg-[var(--control)] px-3 py-2 text-[var(--foreground)]" + required + /> +
+
+ + + setFormData({ ...formData, projectKey: e.target.value }) + } + className="w-full rounded-md border border-[var(--border)] bg-[var(--control)] px-3 py-2 text-[var(--foreground)]" + /> +
+ {connectionError && ( +

{connectionError}

+ )} +
+ + +
+
+
+
+ )} +
+ ); + } + + if (error) { + return ( +
+

+ Project Tracking +

+
+

{error}

+ +
+
+ ); + } + + const stats = [ + { label: "To Do", value: data?.metrics.toDo ?? 0 }, + { label: "In Progress", value: data?.metrics.inProgress ?? 0 }, + { label: "Done", value: data?.metrics.done ?? 0 }, + { + label: "Avg Close Time", + value: formatHours(data?.metrics.avgTimeToClose ?? null), + }, + ]; + + return ( +
+
+

+ Project Tracking +

+ +
+
+ {stats.map((stat) => ( +
+
+ {stat.value} +
+
+ {stat.label} +
+
+ ))} +
+ {data?.recentIssues && data.recentIssues.length > 0 && ( +
+

+ Recent Issues +

+
+ {data.recentIssues.slice(0, 5).map((issue) => ( +
+
+ + {issue.key} + +

+ {issue.summary} +

+
+ + {issue.status} + +
+ ))} +
+
+ )} +
+ ); +} \ No newline at end of file diff --git a/src/components/webhook/WebhookManager.tsx b/src/components/webhook/WebhookManager.tsx new file mode 100644 index 00000000..84142fdf --- /dev/null +++ b/src/components/webhook/WebhookManager.tsx @@ -0,0 +1,547 @@ +"use client"; + +import { useCallback, useEffect, useState } from "react"; + +interface WebhookConfig { + id: string; + name: string; + url: string; + events: string[]; + is_enabled: boolean; + created_at: string; + updated_at: string; +} + +interface WebhookDelivery { + id: string; + event: string; + status_code: number | null; + success: boolean; + error_message: string | null; + delivered_at: string; +} + +const AVAILABLE_EVENTS = [ + { value: "goal.completed", label: "Goal Completed" }, + { value: "goal.created", label: "Goal Created" }, + { value: "streak.milestone", label: "Streak Milestone" }, + { value: "daily.summary", label: "Daily Summary" }, + { value: "weekly.summary", label: "Weekly Summary" }, + { value: "metrics.updated", label: "Metrics Updated" }, +]; + +export default function WebhookManager() { + const [webhooks, setWebhooks] = useState([]); + const [loading, setLoading] = useState(true); + const [showCreateForm, setShowCreateForm] = useState(false); + const [creating, setCreating] = useState(false); + const [creatingError, setCreatingError] = useState(null); + const [newSecret, setNewSecret] = useState(null); + + const [formName, setFormName] = useState(""); + const [formUrl, setFormUrl] = useState(""); + const [formEvents, setFormEvents] = useState([]); + + const [selectedWebhook, setSelectedWebhook] = useState(null); + const [webhookDetails, setWebhookDetails] = useState<{ + config: WebhookConfig; + deliveries: WebhookDelivery[]; + } | null>(null); + const [detailsLoading, setDetailsLoading] = useState(false); + + const [testingId, setTestingId] = useState(null); + const [testResult, setTestResult] = useState<{ + success: boolean; + message: string; + } | null>(null); + + const loadWebhooks = useCallback(async () => { + try { + const res = await fetch("/api/webhooks/custom"); + if (res.ok) { + const data = await res.json(); + setWebhooks(data.webhooks || []); + } + } catch { + setWebhooks([]); + } + }, []); + + useEffect(() => { + loadWebhooks().finally(() => setLoading(false)); + }, [loadWebhooks]); + + async function handleCreate(e: React.FormEvent) { + e.preventDefault(); + setCreating(true); + setCreatingError(null); + setNewSecret(null); + + try { + const res = await fetch("/api/webhooks/custom", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + name: formName, + url: formUrl, + events: formEvents, + }), + }); + + const data = await res.json(); + + if (!res.ok) { + throw new Error(data.error || "Failed to create webhook"); + } + + setNewSecret(data.secretKey); + setFormName(""); + setFormUrl(""); + setFormEvents([]); + await loadWebhooks(); + } catch (err) { + setCreatingError(err instanceof Error ? err.message : "Failed to create webhook"); + } finally { + setCreating(false); + } + } + + async function handleDelete(id: string) { + if (!confirm("Are you sure you want to delete this webhook?")) { + return; + } + + try { + await fetch(`/api/webhooks/custom/${id}`, { method: "DELETE" }); + await loadWebhooks(); + if (selectedWebhook === id) { + setSelectedWebhook(null); + setWebhookDetails(null); + } + } catch { + console.error("Failed to delete webhook"); + } + } + + async function handleToggleEnabled(id: string, currentEnabled: boolean) { + try { + const res = await fetch(`/api/webhooks/custom/${id}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ is_enabled: !currentEnabled }), + }); + + if (res.ok) { + await loadWebhooks(); + if (selectedWebhook === id && webhookDetails) { + const updated = await res.json(); + setWebhookDetails({ + ...webhookDetails, + config: updated.webhook, + }); + } + } + } catch { + console.error("Failed to toggle webhook"); + } + } + + async function viewDetails(id: string) { + setSelectedWebhook(id); + setDetailsLoading(true); + setTestResult(null); + + try { + const res = await fetch(`/api/webhooks/custom/${id}`); + if (res.ok) { + const data = await res.json(); + setWebhookDetails(data); + } + } catch { + setWebhookDetails(null); + } finally { + setDetailsLoading(false); + } + } + + async function handleTest(id: string) { + setTestingId(id); + setTestResult(null); + + try { + const res = await fetch(`/api/webhooks/custom/${id}/test`, { + method: "POST", + }); + + const data = await res.json(); + setTestResult({ + success: data.success, + message: data.success + ? "Test delivery successful" + : data.error || `Failed (HTTP ${data.statusCode})`, + }); + + if (selectedWebhook === id && webhookDetails) { + viewDetails(id); + } + } catch { + setTestResult({ + success: false, + message: "Failed to send test", + }); + } finally { + setTestingId(null); + } + } + + async function handleRotateSecret(id: string) { + if (!confirm("Rotate secret key? Your endpoint will need to use the new key.")) { + return; + } + + try { + const res = await fetch(`/api/webhooks/custom/${id}/rotate-secret`, { + method: "POST", + }); + + if (res.ok) { + const data = await res.json(); + setNewSecret(data.secretKey); + if (selectedWebhook === id) { + viewDetails(id); + } + } + } catch { + console.error("Failed to rotate secret"); + } + } + + function toggleEvent(event: string) { + setFormEvents((current) => + current.includes(event) + ? current.filter((e) => e !== event) + : [...current, event] + ); + } + + function formatDate(dateStr: string): string { + return new Date(dateStr).toLocaleDateString("en-US", { + month: "short", + day: "numeric", + year: "numeric", + hour: "2-digit", + minute: "2-digit", + }); + } + + function getEventLabel(eventValue: string): string { + return AVAILABLE_EVENTS.find((e) => e.value === eventValue)?.label || eventValue; + } + + if (loading) { + return ( +
+
+
+ {[1, 2].map((i) => ( +
+ ))} +
+
+ ); + } + + return ( +
+
+
+

+ Custom Webhooks +

+

+ Trigger external events from your DevTrack metrics +

+
+ + +
+ + {newSecret && ( +
+

Webhook Created!

+

+ Save this secret key - it will not be shown again: +

+ + {newSecret} + +
+ )} + + {showCreateForm && ( +
+
+ + setFormName(e.target.value)} + placeholder="My Dashboard Integration" + required + disabled={creating} + className="w-full rounded-lg border border-[var(--border)] bg-[var(--background)] px-3 py-2 text-sm text-[var(--foreground)] outline-none transition focus:border-[var(--accent)]" + /> +
+ +
+ + setFormUrl(e.target.value)} + placeholder="https://your-server.com/webhook" + required + disabled={creating} + className="w-full rounded-lg border border-[var(--border)] bg-[var(--background)] px-3 py-2 text-sm text-[var(--foreground)] outline-none transition focus:border-[var(--accent)]" + /> +
+ +
+ +
+ {AVAILABLE_EVENTS.map((event) => ( + + ))} +
+
+ + {creatingError && ( +

{creatingError}

+ )} + + +
+ )} + + {webhooks.length === 0 ? ( +
+

+ No webhooks configured yet +

+
+ ) : ( +
+ {webhooks.map((webhook) => ( +
+
+
+
+

+ {webhook.name} +

+ {!webhook.is_enabled && ( + + Disabled + + )} +
+

+ {webhook.url} +

+
+ {webhook.events.map((event) => ( + + {getEventLabel(event)} + + ))} +
+
+ +
+ + + + +
+
+
+ ))} +
+ )} + + {testResult && ( +
+ {testResult.message} +
+ )} + + {detailsLoading && ( +
+
+
+ )} + + {selectedWebhook && webhookDetails && !detailsLoading && ( +
+

+ Webhook Details: {webhookDetails.config.name} +

+ +
+ + +
+ + {webhookDetails.deliveries.length > 0 ? ( +
+

+ Recent Deliveries +

+
+ {webhookDetails.deliveries.map((delivery) => ( +
+ + + {getEventLabel(delivery.event)} + + + {delivery.status_code || "Error"} + + + {formatDate(delivery.delivered_at)} + +
+ ))} +
+
+ ) : ( +

+ No deliveries yet +

+ )} +
+ )} +
+ ); +} \ No newline at end of file diff --git a/src/lib/jira-utils.ts b/src/lib/jira-utils.ts new file mode 100644 index 00000000..e6763da8 --- /dev/null +++ b/src/lib/jira-utils.ts @@ -0,0 +1,51 @@ +export interface JiraIssue { + key: string; + summary: string; + status: string; + statusCategory: string; + created: string; + updated: string; + resolved: string | null; + assignee: string | null; + priority: string; +} + +export function categorizeStatus(issue: JiraIssue): string { + if (issue.statusCategory === "done") { + return "Done"; + } + if (issue.statusCategory === "indeterminate") { + return "In Progress"; + } + return "To Do"; +} + +export function calculateMetrics(issues: JiraIssue[]) { + const toDo = issues.filter( + (i) => categorizeStatus(i) === "To Do" + ).length; + const inProgress = issues.filter( + (i) => categorizeStatus(i) === "In Progress" + ).length; + const done = issues.filter((i) => categorizeStatus(i) === "Done").length; + + const resolvedIssues = issues.filter((i) => i.resolved !== null); + let avgTimeToClose: number | null = null; + + if (resolvedIssues.length > 0) { + const totalHours = resolvedIssues.reduce((sum, issue) => { + const created = new Date(issue.created).getTime(); + const resolved = new Date(issue.resolved!).getTime(); + return sum + (resolved - created); + }, 0); + avgTimeToClose = Math.round(totalHours / resolvedIssues.length / 3600000); + } + + return { + total: issues.length, + toDo, + inProgress, + done, + avgTimeToClose, + }; +} \ No newline at end of file diff --git a/src/lib/ssrf-protection.ts b/src/lib/ssrf-protection.ts new file mode 100644 index 00000000..ff9c024d --- /dev/null +++ b/src/lib/ssrf-protection.ts @@ -0,0 +1,60 @@ +import dns from "dns"; +import { promisify } from "util"; + +const resolve = promisify(dns.resolve); + +const PRIVATE_RANGES = [ + { start: 0x0a000000, end: 0x0affffff }, + { start: 0xac100000, end: 0xac1fffff }, + { start: 0xc0a80000, end: 0xc0a8ffff }, + { start: 0x7f000000, end: 0x7fffffff }, + { start: 0xa9fe0000, end: 0xa9feffff }, +]; + +function ipToNumber(ip: string): number { + const parts = ip.split(".").map(Number); + return (parts[0] << 24) | (parts[1] << 16) | (parts[2] << 8) | parts[3]; +} + +function isPrivateIP(ip: string): boolean { + if (ip === "::1" || ip === "::" || ip.startsWith("fe80:") || ip.startsWith("fc00:") || ip.startsWith("fd00:")) { + return true; + } + + const num = ipToNumber(ip); + return PRIVATE_RANGES.some(({ start, end }) => num >= start && num <= end); +} + +export async function isSafeUrl(url: string): Promise { + try { + const parsed = new URL(url); + if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { + return false; + } + + const hostname = parsed.hostname; + if (hostname === "localhost" || hostname === "0.0.0.0") { + return false; + } + + const addresses = await resolve(hostname, "A"); + for (const addr of addresses) { + if (isPrivateIP(addr)) { + return false; + } + } + + return true; + } catch { + return false; + } +} + +export function validateUrlBasic(url: string): boolean { + try { + const parsed = new URL(url); + return parsed.protocol === "http:" || parsed.protocol === "https:"; + } catch { + return false; + } +} diff --git a/src/lib/webhooks.ts b/src/lib/webhooks.ts new file mode 100644 index 00000000..ee15228a --- /dev/null +++ b/src/lib/webhooks.ts @@ -0,0 +1,160 @@ +import { createHmac, randomBytes } from "crypto"; +import { supabaseAdmin } from "./supabase"; +import { encryptToken, decryptToken } from "./crypto"; + +export interface WebhookPayload { + event: string; + timestamp: string; + data: Record; +} + +export interface WebhookDeliveryResult { + success: boolean; + statusCode?: number; + error?: string; +} + +const WEBHOOK_EVENTS = [ + "goal.completed", + "goal.created", + "streak.milestone", + "daily.summary", + "weekly.summary", + "metrics.updated", +] as const; + +export type WebhookEvent = typeof WEBHOOK_EVENTS[number]; + +export function isValidWebhookEvent(event: string): event is WebhookEvent { + return WEBHOOK_EVENTS.includes(event as WebhookEvent); +} + +export function getAvailableEvents(): readonly string[] { + return WEBHOOK_EVENTS; +} + +export function generateSecretKey(): string { + return randomBytes(32).toString("hex"); +} + +export function encryptSecretKey(secret: string): { encrypted: string; iv: string } { + return encryptToken(secret); +} + +export function decryptSecretKey(encrypted: string, iv: string): string | null { + return decryptToken(encrypted, iv); +} + +export function signPayload(payload: string, secret: string): string { + return createHmac("sha256", secret).update(payload).digest("hex"); +} + +export async function dispatchWebhook( + webhookId: string, + event: string, + data: Record +): Promise { + const { data: webhook, error } = await supabaseAdmin + .from("webhook_configs") + .select("*") + .eq("id", webhookId) + .eq("is_enabled", true) + .single(); + + if (error || !webhook) { + return { success: false, error: "Webhook not found or disabled" }; + } + + const secret = decryptSecretKey(webhook.secret_key, webhook.secret_iv); + if (!secret) { + return { success: false, error: "Failed to decrypt webhook secret" }; + } + + const payload: WebhookPayload = { + event, + timestamp: new Date().toISOString(), + data, + }; + + const payloadString = JSON.stringify(payload); + const signature = signPayload(payloadString, secret); + + const { isSafeUrl } = await import("./ssrf-protection"); + const safe = await isSafeUrl(webhook.url); + if (!safe) { + const errorMessage = "SSRF protection: blocked request to private/internal address"; + await supabaseAdmin.from("webhook_deliveries").insert({ + webhook_id: webhookId, + event, + payload, + success: false, + error_message: errorMessage, + }); + return { success: false, error: errorMessage }; + } + + let statusCode: number | undefined; + let errorMessage: string | undefined; + + try { + const response = await fetch(webhook.url, { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-Webhook-Signature": `sha256=${signature}`, + "X-Webhook-Event": event, + "X-Webhook-Delivery-Id": webhookId, + }, + body: payloadString, + signal: AbortSignal.timeout(10000), + }); + + statusCode = response.status; + const success = response.ok; + + await supabaseAdmin.from("webhook_deliveries").insert({ + webhook_id: webhookId, + event, + payload, + status_code: statusCode, + success, + error_message: success ? null : `HTTP ${statusCode}`, + }); + + return { success, statusCode }; + } catch (err) { + errorMessage = err instanceof Error ? err.message : "Unknown error"; + + await supabaseAdmin.from("webhook_deliveries").insert({ + webhook_id: webhookId, + event, + payload, + success: false, + error_message: errorMessage, + }); + + return { success: false, error: errorMessage }; + } +} + +export async function dispatchToAllWebhooks( + userId: string, + event: string, + data: Record +): Promise { + const MAX_WEBHOOKS_PER_USER = 5; + + const { data: webhooks } = await supabaseAdmin + .from("webhook_configs") + .select("id") + .eq("user_id", userId) + .eq("is_enabled", true) + .contains("events", [event]) + .limit(MAX_WEBHOOKS_PER_USER); + + if (!webhooks) return; + + await Promise.all( + webhooks.map((webhook) => dispatchWebhook(webhook.id, event, data)) + ); +} diff --git a/supabase/migrations/20260520000000_add_webhook_configs.sql b/supabase/migrations/20260520000000_add_webhook_configs.sql new file mode 100644 index 00000000..5b7db162 --- /dev/null +++ b/supabase/migrations/20260520000000_add_webhook_configs.sql @@ -0,0 +1,28 @@ +create table if not exists webhook_configs ( + id text primary key default gen_random_uuid()::text, + user_id text not null references users(id) on delete cascade, + name text not null, + url text not null, + events text[] not null default '{}', + secret_key text not null, + secret_iv text, + is_enabled boolean default true, + created_at timestamptz default now(), + updated_at timestamptz default now() +); + +create index if not exists webhook_configs_user on webhook_configs(user_id); + +create table if not exists webhook_deliveries ( + id text primary key default gen_random_uuid()::text, + webhook_id text not null references webhook_configs(id) on delete cascade, + event text not null, + payload jsonb not null, + status_code integer, + success boolean default false, + error_message text, + delivered_at timestamptz default now() +); + +create index if not exists webhook_deliveries_webhook on webhook_deliveries(webhook_id); +create index if not exists webhook_deliveries_time on webhook_deliveries(delivered_at); diff --git a/supabase/migrations/20260520000001_add_webhook_rls.sql b/supabase/migrations/20260520000001_add_webhook_rls.sql new file mode 100644 index 00000000..aad1719d --- /dev/null +++ b/supabase/migrations/20260520000001_add_webhook_rls.sql @@ -0,0 +1,45 @@ +-- Migration: Add RLS policies for webhook tables +-- Created: 2026-05-20 +-- Description: Enables RLS and adds policies for webhook_configs and webhook_deliveries tables. + +-- ============================================================ +-- WEBHOOK_CONFIGS TABLE +-- ============================================================ +alter table webhook_configs enable row level security; + +create policy "webhook_configs_select_own" + on webhook_configs for select + using (user_id = auth.uid()::text); + +create policy "webhook_configs_insert_own" + on webhook_configs for insert + with check (user_id = auth.uid()::text); + +create policy "webhook_configs_update_own" + on webhook_configs for update + using (user_id = auth.uid()::text); + +create policy "webhook_configs_delete_own" + on webhook_configs for delete + using (user_id = auth.uid()::text); + +-- ============================================================ +-- WEBHOOK_DELIVERIES TABLE +-- ============================================================ +alter table webhook_deliveries enable row level security; + +create policy "webhook_deliveries_select_own" + on webhook_deliveries for select + using ( + webhook_id in ( + select id from webhook_configs where user_id = auth.uid()::text + ) + ); + +create policy "webhook_deliveries_insert_own" + on webhook_deliveries for insert + with check ( + webhook_id in ( + select id from webhook_configs where user_id = auth.uid()::text + ) + ); diff --git a/supabase/migrations/20260520000002_add_streak_milestones.sql b/supabase/migrations/20260520000002_add_streak_milestones.sql new file mode 100644 index 00000000..a5c4f780 --- /dev/null +++ b/supabase/migrations/20260520000002_add_streak_milestones.sql @@ -0,0 +1,9 @@ +create table if not exists streak_milestones ( + id text primary key default gen_random_uuid()::text, + user_id text not null references users(id) on delete cascade, + streak_count integer not null, + achieved_at timestamptz default now(), + unique(user_id, streak_count) +); + +create index if not exists streak_milestones_user on streak_milestones(user_id); diff --git a/supabase/migrations/20260521000000_add_secret_iv_to_webhook_configs.sql b/supabase/migrations/20260521000000_add_secret_iv_to_webhook_configs.sql new file mode 100644 index 00000000..0d6a3967 --- /dev/null +++ b/supabase/migrations/20260521000000_add_secret_iv_to_webhook_configs.sql @@ -0,0 +1,2 @@ +alter table webhook_configs + add column if not exists secret_iv text; diff --git a/supabase/migrations/20260521000001_add_jira_credentials.sql b/supabase/migrations/20260521000001_add_jira_credentials.sql new file mode 100644 index 00000000..b1e81989 --- /dev/null +++ b/supabase/migrations/20260521000001_add_jira_credentials.sql @@ -0,0 +1,14 @@ +create table if not exists jira_credentials ( + id text primary key default gen_random_uuid()::text, + user_id text not null unique references users(id) on delete cascade, + jira_domain text not null, + email text not null, + api_token text not null, + token_iv text not null, + project_key text, + is_active boolean default true, + created_at timestamptz default now(), + updated_at timestamptz default now() +); + +create index if not exists jira_credentials_user on jira_credentials(user_id); diff --git a/supabase/schema.sql b/supabase/schema.sql index 9d337f72..2e838b9b 100644 --- a/supabase/schema.sql +++ b/supabase/schema.sql @@ -35,3 +35,18 @@ create table if not exists metric_snapshots ( ); create index if not exists snapshots_user_time on metric_snapshots(user_id, snapshot_at); + +create table if not exists jira_credentials ( + id text primary key default gen_random_uuid()::text, + user_id text not null unique references users(id) on delete cascade, + jira_domain text not null, + email text not null, + api_token text not null, + token_iv text not null, + project_key text, + is_active boolean default true, + created_at timestamptz default now(), + updated_at timestamptz default now() +); + +create index if not exists jira_credentials_user on jira_credentials(user_id);