import { NextRequest, NextResponse } from "next/server"; import { db, recipes, recipeIngredients, recipeSteps, recipeSnapshots } from "@epicure/db"; import { eq, and, max } from "@epicure/db"; import { z } from "zod"; import { requireSessionOrApiKey } from "@/lib/api-auth"; import { dispatchWebhook } from "@/lib/webhooks"; import { parseQuantity } from "@/lib/parse-quantity"; const UpdateRecipeSchema = z.object({ title: z.string().min(1).max(200).optional(), description: z.string().max(2000).optional(), baseServings: z.number().int().min(1).max(100).optional(), visibility: z.enum(["private", "unlisted", "public"]).optional(), difficulty: z.enum(["easy", "medium", "hard"]).nullable().optional(), prepMins: z.number().int().min(0).max(1440).nullable().optional(), cookMins: z.number().int().min(0).max(1440).nullable().optional(), tags: z.array(z.string().min(1).max(50)).max(20).optional(), dietaryTags: z.object({ vegan: z.boolean().optional(), vegetarian: z.boolean().optional(), glutenFree: z.boolean().optional(), dairyFree: z.boolean().optional(), nutFree: z.boolean().optional(), halal: z.boolean().optional(), kosher: z.boolean().optional(), }).optional(), ingredients: z.array(z.object({ rawName: z.string().min(1).max(200), quantity: z.union([z.number(), z.string().max(50)]).optional().transform(parseQuantity), unit: z.string().max(50).optional(), note: z.string().max(500).optional(), order: z.number().int().default(0), })).max(100).optional(), steps: z.array(z.object({ instruction: z.string().min(1).max(2000), timerSeconds: z.number().int().min(0).max(86400).optional(), order: z.number().int(), })).max(100).optional(), }); type Params = { params: Promise<{ id: string }> }; async function getOwnedRecipe(recipeId: string, userId: string) { return db.query.recipes.findFirst({ where: and(eq(recipes.id, recipeId), eq(recipes.authorId, userId)), with: { ingredients: { orderBy: (t, { asc }) => asc(t.order) }, steps: { orderBy: (t, { asc }) => asc(t.order) }, photos: true }, }); } export async function GET(req: NextRequest, { params }: Params) { const { session, response } = await requireSessionOrApiKey(req, { rateLimit: { limit: 60, windowSeconds: 60 } }); if (response) return response; const { id } = await params; const recipe = await getOwnedRecipe(id, session!.user.id); if (!recipe) return NextResponse.json({ error: "Not found" }, { status: 404 }); return NextResponse.json(recipe); } export async function PUT(req: NextRequest, { params }: Params) { const { session, response } = await requireSessionOrApiKey(req, { rateLimit: { limit: 60, windowSeconds: 60 } }); if (response) return response; const { id } = await params; const existing = await db.query.recipes.findFirst({ where: and(eq(recipes.id, id), eq(recipes.authorId, session!.user.id)), with: { ingredients: { orderBy: (t, { asc }) => asc(t.order) }, steps: { orderBy: (t, { asc }) => asc(t.order) } }, }); if (!existing) return NextResponse.json({ error: "Not found" }, { status: 404 }); // Create a snapshot of the current state before updating const [maxVersionRow] = await db .select({ v: max(recipeSnapshots.version) }) .from(recipeSnapshots) .where(eq(recipeSnapshots.recipeId, id)); const nextVersion = (maxVersionRow?.v ?? 0) + 1; await db.insert(recipeSnapshots).values({ id: crypto.randomUUID(), recipeId: id, authorId: session!.user.id, version: nextVersion, title: existing.title, snapshotData: { title: existing.title, description: existing.description, baseServings: existing.baseServings, difficulty: existing.difficulty, prepMins: existing.prepMins, cookMins: existing.cookMins, dietaryTags: existing.dietaryTags ?? {}, ingredients: existing.ingredients.map((i) => ({ rawName: i.rawName, quantity: i.quantity, unit: i.unit, note: i.note, order: i.order, })), steps: existing.steps.map((s) => ({ instruction: s.instruction, timerSeconds: s.timerSeconds, order: s.order, })), }, }); const body = await req.json() as unknown; const parsed = UpdateRecipeSchema.safeParse(body); if (!parsed.success) { return NextResponse.json({ error: "Validation error", issues: parsed.error.issues }, { status: 400 }); } const data = parsed.data; await db.transaction(async (tx) => { const updates: Partial = { updatedAt: new Date() }; if (data.title !== undefined) updates.title = data.title; if (data.description !== undefined) updates.description = data.description; if (data.baseServings !== undefined) updates.baseServings = data.baseServings; if (data.visibility !== undefined) updates.visibility = data.visibility; if (data.difficulty !== undefined) updates.difficulty = data.difficulty ?? undefined; if (data.prepMins !== undefined) updates.prepMins = data.prepMins ?? undefined; if (data.cookMins !== undefined) updates.cookMins = data.cookMins ?? undefined; if (data.tags !== undefined) updates.tags = data.tags; if (data.dietaryTags !== undefined) updates.dietaryTags = data.dietaryTags; await tx.update(recipes).set(updates).where(eq(recipes.id, id)); if (data.ingredients !== undefined) { await tx.delete(recipeIngredients).where(eq(recipeIngredients.recipeId, id)); if (data.ingredients.length > 0) { await tx.insert(recipeIngredients).values( data.ingredients.map((ing, i) => ({ id: crypto.randomUUID(), recipeId: id, rawName: ing.rawName, quantity: ing.quantity, unit: ing.unit, note: ing.note, order: ing.order ?? i, })) ); } } if (data.steps !== undefined) { await tx.delete(recipeSteps).where(eq(recipeSteps.recipeId, id)); if (data.steps.length > 0) { await tx.insert(recipeSteps).values( data.steps.map((step, i) => ({ id: crypto.randomUUID(), recipeId: id, instruction: step.instruction, timerSeconds: step.timerSeconds, order: step.order ?? i, })) ); } } }); const updated = await getOwnedRecipe(id, session!.user.id); void dispatchWebhook(session!.user.id, "recipe.updated", { id, title: updated?.title }); return NextResponse.json(updated); } export async function DELETE(req: NextRequest, { params }: Params) { const { session, response } = await requireSessionOrApiKey(req, { rateLimit: { limit: 60, windowSeconds: 60 } }); if (response) return response; const { id } = await params; const existing = await db.query.recipes.findFirst({ where: and(eq(recipes.id, id), eq(recipes.authorId, session!.user.id)), }); if (!existing) return NextResponse.json({ error: "Not found" }, { status: 404 }); await db.delete(recipes).where(eq(recipes.id, id)); void dispatchWebhook(session!.user.id, "recipe.deleted", { id }); return new NextResponse(null, { status: 204 }); }