import { NextRequest, NextResponse } from "next/server"; import { db, recipes, recipeIngredients, recipeSteps, recipePhotos, recipeBatchDishes, recipeSnapshots, ratings } from "@epicure/db"; import { eq, and, max, isNotNull } from "@epicure/db"; import { z } from "zod"; import { requireSessionOrApiKey } from "@/lib/api-auth"; import { checkTierLimitInTransaction, TierLimitError } from "@/lib/tiers"; import { deleteObject, isOwnedRecipePhotoKey } from "@/lib/storage"; import { dispatchWebhook } from "@/lib/webhooks"; import { parseQuantity } from "@/lib/parse-quantity"; import { extractIngredientQuantity } from "@/lib/extract-ingredient-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(), recipeType: z.enum(["dish", "drink"]).optional(), visibility: z.enum(["private", "unlisted", "public", "followers"]).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), }).transform((ing) => { const { rawName, quantity, unit } = extractIngredientQuantity(ing.rawName, ing.quantity, ing.unit); return { ...ing, rawName, quantity, unit }; })).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(), appliesTo: z.array(z.string().min(1).max(100)).max(20).default([]), })).max(100).optional(), photos: z.array(z.object({ key: z.string().min(1).max(500), isCover: z.boolean().default(false), sizeMb: z.number().int().min(0).max(50).default(0), })).max(20).optional(), coverIcon: z.string().max(50).nullable().optional(), coverColor: z.string().max(50).nullable().optional(), isBatchCook: z.boolean().optional(), dishes: z.array(z.object({ name: z.string().min(1).max(100), description: z.string().max(500).optional(), fridgeDays: z.number().int().min(1).max(14), freezerFriendly: z.boolean().default(false), freezerNote: z.string().max(300).optional(), dayOfInstructions: z.string().min(1).max(1000), })).max(10).optional(), // Manually-entered per-serving nutrition — set to null to clear it and // re-enable the AI-estimate action on the recipe page. nutrition: z.object({ calories: z.number().min(0).max(10000), proteinG: z.number().min(0).max(1000), carbsG: z.number().min(0).max(1000), fatG: z.number().min(0).max(1000), fiberG: z.number().min(0).max(1000), sodiumMg: z.number().min(0).max(100000), }).nullable().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, batchDishes: { orderBy: (t, { asc }) => asc(t.order) }, }, }); } 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) }, photos: true, batchDishes: { orderBy: (t, { asc }) => asc(t.order) }, }, }); if (!existing) return NextResponse.json({ error: "Not found" }, { status: 404 }); 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; if (data.photos?.some((p) => !isOwnedRecipePhotoKey(p.key, id, session!.user.id))) { return NextResponse.json({ error: "Validation error", issues: [{ path: ["photos"], message: "Photo key not issued for this recipe" }] }, { status: 400 }); } const photosSizeDeltaMb = data.photos !== undefined ? data.photos.reduce((sum, p) => sum + p.sizeMb, 0) - existing.photos.reduce((sum, p) => sum + p.sizeMb, 0) : 0; try { await db.transaction(async (tx) => { if (photosSizeDeltaMb > 0) { await checkTierLimitInTransaction(tx, session!.user.id, session!.user.tier as "free" | "pro" | "family", "storage", photosSizeDeltaMb); } // Create a snapshot of the current state before updating const [maxVersionRow] = await tx .select({ v: max(recipeSnapshots.version) }) .from(recipeSnapshots) .where(eq(recipeSnapshots.recipeId, id)); const nextVersion = (maxVersionRow?.v ?? 0) + 1; await tx.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 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.recipeType !== undefined) updates.recipeType = data.recipeType; 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; if (data.isBatchCook !== undefined) updates.isBatchCook = data.isBatchCook; if (data.coverIcon !== undefined) updates.coverIcon = data.coverIcon; if (data.coverColor !== undefined) updates.coverColor = data.coverColor; if (data.nutrition !== undefined) { updates.nutritionData = data.nutrition ? { perServing: data.nutrition } : null; updates.nutritionManual = !!data.nutrition; } 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, appliesTo: step.appliesTo, })) ); } } if (data.dishes !== undefined) { await tx.delete(recipeBatchDishes).where(eq(recipeBatchDishes.recipeId, id)); if (data.dishes.length > 0) { await tx.insert(recipeBatchDishes).values( data.dishes.map((dish, i) => ({ id: crypto.randomUUID(), recipeId: id, name: dish.name, description: dish.description, order: i, fridgeDays: dish.fridgeDays, freezerFriendly: dish.freezerFriendly, freezerNote: dish.freezerNote, dayOfInstructions: dish.dayOfInstructions, })) ); } } if (data.photos !== undefined) { await tx.delete(recipePhotos).where(eq(recipePhotos.recipeId, id)); if (data.photos.length > 0) { await tx.insert(recipePhotos).values( data.photos.map((photo, i) => ({ id: crypto.randomUUID(), recipeId: id, storageKey: photo.key, order: i, isCover: photo.isCover, sizeMb: photo.sizeMb, })) ); } } }); } catch (err) { if (err instanceof TierLimitError) { return NextResponse.json({ error: "Storage limit reached for your tier" }, { status: 403 }); } throw err; } if (data.photos !== undefined) { const newKeys = new Set(data.photos.map((p) => p.key)); const removedKeys = existing.photos.filter((p) => !newKeys.has(p.storageKey)).map((p) => p.storageKey); await Promise.all(removedKeys.map((key) => deleteObject(key).catch(() => {}))); } const updated = await getOwnedRecipe(id, session!.user.id); void dispatchWebhook(session!.user.id, "recipe.updated", { id, title: updated?.title }); if (existing.visibility === "private" && data.visibility === "public") { void dispatchWebhook(session!.user.id, "recipe.published", { 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)), with: { photos: true }, }); if (!existing) return NextResponse.json({ error: "Not found" }, { status: 404 }); // Collect storage keys before the cascade delete removes the rows. // recipeSteps.photoUrl stores a full URL rather than a storage key, so it // is intentionally not deleted here — only objects this app stored by key. const reviewPhotos = await db .select({ photoKey: ratings.photoKey }) .from(ratings) .where(and(eq(ratings.recipeId, id), isNotNull(ratings.photoKey))); const storageKeys = [ ...existing.photos.map((p) => p.storageKey), ...reviewPhotos.map((r) => r.photoKey).filter((k): k is string => k !== null), ]; await db.delete(recipes).where(eq(recipes.id, id)); // Best-effort object cleanup: a storage failure must not fail the response. for (const key of storageKeys) { try { await deleteObject(key); } catch (err) { console.error(`Failed to delete storage object ${key} for recipe ${id}`, err); } } void dispatchWebhook(session!.user.id, "recipe.deleted", { id }); return new NextResponse(null, { status: 204 }); }