diff --git a/apps/web/app/api/v1/api-keys/[id]/route.ts b/apps/web/app/api/v1/api-keys/[id]/route.ts new file mode 100644 index 0000000..ca7fe20 --- /dev/null +++ b/apps/web/app/api/v1/api-keys/[id]/route.ts @@ -0,0 +1,29 @@ +import { NextRequest, NextResponse } from "next/server"; +import { db, apiKeys, eq, and } from "@epicure/db"; +import { requireSession } from "@/lib/api-auth"; + +export async function DELETE( + _req: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + const { session, response } = await requireSession(); + if (response) return response; + + const { id } = await params; + + const existing = await db + .select({ id: apiKeys.id }) + .from(apiKeys) + .where(and(eq(apiKeys.id, id), eq(apiKeys.userId, session!.user.id))) + .limit(1); + + if (existing.length === 0) { + return NextResponse.json({ error: "Not found" }, { status: 404 }); + } + + await db + .delete(apiKeys) + .where(and(eq(apiKeys.id, id), eq(apiKeys.userId, session!.user.id))); + + return new NextResponse(null, { status: 204 }); +} diff --git a/apps/web/app/api/v1/api-keys/route.ts b/apps/web/app/api/v1/api-keys/route.ts new file mode 100644 index 0000000..47db067 --- /dev/null +++ b/apps/web/app/api/v1/api-keys/route.ts @@ -0,0 +1,58 @@ +import { NextRequest, NextResponse } from "next/server"; +import crypto from "node:crypto"; +import { z } from "zod"; +import { db, apiKeys, eq } from "@epicure/db"; +import { requireSession } from "@/lib/api-auth"; + +const CreateApiKeyBody = z.object({ + name: z.string().min(1).max(100), +}); + +export async function GET() { + const { session, response } = await requireSession(); + if (response) return response; + + const rows = await db + .select({ + id: apiKeys.id, + name: apiKeys.name, + lastUsedAt: apiKeys.lastUsedAt, + createdAt: apiKeys.createdAt, + }) + .from(apiKeys) + .where(eq(apiKeys.userId, session!.user.id)); + + return NextResponse.json(rows); +} + +export async function POST(req: NextRequest) { + const { session, response } = await requireSession(); + if (response) return response; + + const body = await req.json() as unknown; + const parsed = CreateApiKeyBody.safeParse(body); + if (!parsed.success) { + return NextResponse.json( + { error: "Validation error", issues: parsed.error.issues }, + { status: 400 } + ); + } + + const rawKey = "ek_" + crypto.randomBytes(32).toString("hex"); + const keyHash = crypto.createHash("sha256").update(rawKey).digest("hex"); + const id = crypto.randomUUID(); + const now = new Date(); + + await db.insert(apiKeys).values({ + id, + userId: session!.user.id, + name: parsed.data.name, + keyHash, + createdAt: now, + }); + + return NextResponse.json( + { id, name: parsed.data.name, key: rawKey, createdAt: now.toISOString() }, + { status: 201 } + ); +} diff --git a/apps/web/app/api/v1/webhooks/[id]/deliveries/route.ts b/apps/web/app/api/v1/webhooks/[id]/deliveries/route.ts new file mode 100644 index 0000000..ad0f00b --- /dev/null +++ b/apps/web/app/api/v1/webhooks/[id]/deliveries/route.ts @@ -0,0 +1,27 @@ +import { NextRequest, NextResponse } from "next/server"; +import { db, webhooks, webhookDeliveries, eq, and, desc } from "@epicure/db"; +import { requireSession } from "@/lib/api-auth"; + +type Params = { params: Promise<{ id: string }> }; + +export async function GET(_req: NextRequest, { params }: Params) { + const { session, response } = await requireSession(); + if (response) return response; + + const { id } = await params; + + const hook = await db.query.webhooks.findFirst({ + where: and(eq(webhooks.id, id), eq(webhooks.userId, session!.user.id)), + columns: { id: true }, + }); + if (!hook) return NextResponse.json({ error: "Not found" }, { status: 404 }); + + const deliveries = await db + .select() + .from(webhookDeliveries) + .where(eq(webhookDeliveries.webhookId, id)) + .orderBy(desc(webhookDeliveries.createdAt)) + .limit(20); + + return NextResponse.json(deliveries); +} diff --git a/apps/web/app/api/v1/webhooks/[id]/redeliver/route.ts b/apps/web/app/api/v1/webhooks/[id]/redeliver/route.ts new file mode 100644 index 0000000..97e929d --- /dev/null +++ b/apps/web/app/api/v1/webhooks/[id]/redeliver/route.ts @@ -0,0 +1,41 @@ +import { NextRequest, NextResponse } from "next/server"; +import { z } from "zod"; +import { db, webhooks, webhookDeliveries, eq, and } from "@epicure/db"; +import { requireSession } from "@/lib/api-auth"; +import { dispatchWebhook, type WebhookEvent } from "@/lib/webhooks"; + +const Schema = z.object({ deliveryId: z.string().uuid() }); + +type Params = { params: Promise<{ id: string }> }; + +export async function POST(req: NextRequest, { params }: Params) { + const { session, response } = await requireSession(); + if (response) return response; + + const { id } = await params; + + const hook = await db.query.webhooks.findFirst({ + where: and(eq(webhooks.id, id), eq(webhooks.userId, session!.user.id)), + columns: { id: true }, + }); + if (!hook) return NextResponse.json({ error: "Not found" }, { status: 404 }); + + const body = Schema.safeParse(await req.json()); + if (!body.success) return NextResponse.json({ error: body.error.flatten() }, { status: 400 }); + + const delivery = await db.query.webhookDeliveries.findFirst({ + where: and( + eq(webhookDeliveries.id, body.data.deliveryId), + eq(webhookDeliveries.webhookId, id) + ), + }); + if (!delivery) return NextResponse.json({ error: "Delivery not found" }, { status: 404 }); + + void dispatchWebhook( + session!.user.id, + delivery.event as WebhookEvent, + (delivery.payload ?? {}) as object + ); + + return NextResponse.json({ ok: true }); +} diff --git a/apps/web/app/api/v1/webhooks/[id]/route.ts b/apps/web/app/api/v1/webhooks/[id]/route.ts new file mode 100644 index 0000000..e36f237 --- /dev/null +++ b/apps/web/app/api/v1/webhooks/[id]/route.ts @@ -0,0 +1,104 @@ +import { NextRequest, NextResponse } from "next/server"; +import { z } from "zod"; +import { db, webhooks, eq, and } from "@epicure/db"; +import { requireSession } from "@/lib/api-auth"; + +const VALID_EVENTS = ["recipe.created", "recipe.updated", "recipe.published", "recipe.deleted"] as const; + +const UpdateWebhookBody = z.object({ + url: z.string().min(1).max(2048).optional(), + events: z.array(z.enum(VALID_EVENTS)).optional(), + active: z.boolean().optional(), +}); + +export async function DELETE( + _req: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + const { session, response } = await requireSession(); + if (response) return response; + + const { id } = await params; + + const existing = await db + .select({ id: webhooks.id }) + .from(webhooks) + .where(and(eq(webhooks.id, id), eq(webhooks.userId, session!.user.id))) + .limit(1); + + if (existing.length === 0) { + return NextResponse.json({ error: "Not found" }, { status: 404 }); + } + + await db + .delete(webhooks) + .where(and(eq(webhooks.id, id), eq(webhooks.userId, session!.user.id))); + + return new NextResponse(null, { status: 204 }); +} + +export async function PATCH( + req: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + const { session, response } = await requireSession(); + if (response) return response; + + const { id } = await params; + + const existing = await db + .select({ id: webhooks.id }) + .from(webhooks) + .where(and(eq(webhooks.id, id), eq(webhooks.userId, session!.user.id))) + .limit(1); + + if (existing.length === 0) { + return NextResponse.json({ error: "Not found" }, { status: 404 }); + } + + const body = await req.json() as unknown; + const parsed = UpdateWebhookBody.safeParse(body); + if (!parsed.success) { + return NextResponse.json( + { error: "Validation error", issues: parsed.error.issues }, + { status: 400 } + ); + } + + if (parsed.data.url) { + try { + new URL(parsed.data.url); + } catch { + return NextResponse.json({ error: "Invalid URL" }, { status: 400 }); + } + } + + const updates: Partial<{ url: string; events: string[]; active: boolean }> = {}; + if (parsed.data.url !== undefined) updates.url = parsed.data.url; + if (parsed.data.events !== undefined) updates.events = parsed.data.events; + if (parsed.data.active !== undefined) updates.active = parsed.data.active; + + if (Object.keys(updates).length === 0) { + return NextResponse.json({ error: "No fields to update" }, { status: 400 }); + } + + await db + .update(webhooks) + .set(updates) + .where(and(eq(webhooks.id, id), eq(webhooks.userId, session!.user.id))); + + const updated = await db + .select({ + id: webhooks.id, + userId: webhooks.userId, + url: webhooks.url, + events: webhooks.events, + active: webhooks.active, + createdAt: webhooks.createdAt, + }) + .from(webhooks) + .where(eq(webhooks.id, id)) + .limit(1); + + return NextResponse.json(updated[0]); +} diff --git a/apps/web/app/api/v1/webhooks/route.ts b/apps/web/app/api/v1/webhooks/route.ts new file mode 100644 index 0000000..cb32b84 --- /dev/null +++ b/apps/web/app/api/v1/webhooks/route.ts @@ -0,0 +1,85 @@ +import { NextRequest, NextResponse } from "next/server"; +import crypto from "node:crypto"; +import { z } from "zod"; +import { db, webhooks, eq } from "@epicure/db"; +import { requireSession } from "@/lib/api-auth"; + +const VALID_EVENTS = ["recipe.created", "recipe.updated", "recipe.published", "recipe.deleted"] as const; + +const CreateWebhookBody = z.object({ + url: z.string().min(1).max(2048), + events: z.array(z.enum(VALID_EVENTS)).default([]), +}); + +const UpdateWebhookBody = z.object({ + url: z.string().min(1).max(2048).optional(), + events: z.array(z.enum(VALID_EVENTS)).optional(), + active: z.boolean().optional(), +}); + +export async function GET() { + const { session, response } = await requireSession(); + if (response) return response; + + const rows = await db + .select({ + id: webhooks.id, + userId: webhooks.userId, + url: webhooks.url, + events: webhooks.events, + active: webhooks.active, + createdAt: webhooks.createdAt, + }) + .from(webhooks) + .where(eq(webhooks.userId, session!.user.id)); + + return NextResponse.json(rows); +} + +export async function POST(req: NextRequest) { + const { session, response } = await requireSession(); + if (response) return response; + + const body = await req.json() as unknown; + const parsed = CreateWebhookBody.safeParse(body); + if (!parsed.success) { + return NextResponse.json( + { error: "Validation error", issues: parsed.error.issues }, + { status: 400 } + ); + } + + // Validate URL format + try { + new URL(parsed.data.url); + } catch { + return NextResponse.json({ error: "Invalid URL" }, { status: 400 }); + } + + const secret = crypto.randomBytes(32).toString("hex"); + const id = crypto.randomUUID(); + const now = new Date(); + + await db.insert(webhooks).values({ + id, + userId: session!.user.id, + url: parsed.data.url, + events: parsed.data.events, + secret, + active: true, + createdAt: now, + }); + + return NextResponse.json( + { + id, + userId: session!.user.id, + url: parsed.data.url, + events: parsed.data.events, + secret, + active: true, + createdAt: now.toISOString(), + }, + { status: 201 } + ); +} diff --git a/apps/web/app/api/webhooks/stripe/route.ts b/apps/web/app/api/webhooks/stripe/route.ts new file mode 100644 index 0000000..224a1de --- /dev/null +++ b/apps/web/app/api/webhooks/stripe/route.ts @@ -0,0 +1,19 @@ +import { NextRequest, NextResponse } from "next/server"; + +// Stripe webhook stub — wire up when adding Stripe +// Verifies stripe-signature header (using raw body), handles: +// - checkout.session.completed → upgrade user to pro +// - customer.subscription.deleted → downgrade user to free +export async function POST(req: NextRequest) { + const body = await req.text(); + const sig = req.headers.get("stripe-signature"); + + if (!sig || !process.env["STRIPE_WEBHOOK_SECRET"]) { + return NextResponse.json({ error: "Stripe not configured" }, { status: 400 }); + } + + // TODO: const event = stripe.webhooks.constructEvent(body, sig, process.env["STRIPE_WEBHOOK_SECRET"]); + // For now just return 200 to acknowledge receipt + console.log("[stripe-webhook] received event, sig:", sig.slice(0, 20)); + return NextResponse.json({ received: true }); +} diff --git a/apps/web/lib/webhooks.ts b/apps/web/lib/webhooks.ts new file mode 100644 index 0000000..848b7c3 --- /dev/null +++ b/apps/web/lib/webhooks.ts @@ -0,0 +1,57 @@ +import crypto from "crypto"; +import { db } from "@epicure/db"; +import { webhooks, webhookDeliveries } from "@epicure/db"; +import { eq, and } from "@epicure/db"; + +export type WebhookEvent = + | "recipe.created" + | "recipe.updated" + | "recipe.published" + | "recipe.deleted" + | "meal_plan.updated" + | "shopping_list.completed" + | "comment.added"; + +export async function dispatchWebhook(userId: string, event: WebhookEvent, payload: object) { + const hooks = await db + .select() + .from(webhooks) + .where(and(eq(webhooks.userId, userId), eq(webhooks.active, true))); + + const filtered = hooks.filter((h) => h.events.length === 0 || h.events.includes(event)); + + await Promise.allSettled( + filtered.map(async (hook) => { + const body = JSON.stringify({ event, payload, timestamp: new Date().toISOString() }); + const sig = crypto.createHmac("sha256", hook.secret).update(body).digest("hex"); + let statusCode = 0; + let success = false; + try { + const res = await fetch(hook.url, { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-Epicure-Signature": `sha256=${sig}`, + "X-Epicure-Event": event, + }, + body, + signal: AbortSignal.timeout(10000), + }); + statusCode = res.status; + success = res.ok; + } catch { + // delivery failed; statusCode stays 0, success stays false + } + await db.insert(webhookDeliveries).values({ + id: crypto.randomUUID(), + webhookId: hook.id, + event, + payload: payload as Record, + statusCode, + success, + attempts: 1, + createdAt: new Date(), + }); + }) + ); +}