Files
Epicure/apps/web/app/api/v1/recipes/[id]/route.ts
T
Arnaud f6975e98a9 fix: recipe/storage tier limits are lifetime totals, not monthly (v0.58.0)
Recipe count and storage usage shared the monthly user_usage bucket with
AI calls, so both incorrectly reset every month even though nothing was
deleted. Only AI calls should be monthly.

Recipe count and storage are now derived live from real data (recipes,
recipe/review photos, avatar) instead of a counter — deleting a photo or
recipe is itself the "decrement", no extra wiring needed. Storage size is
tracked per-row (recipePhotos.sizeMb, ratings.photoSizeMb, users.avatarSizeMb)
and threaded through presign -> upload -> save.

Also fixes avatar removal silently no-oping (client sent a field the PATCH
schema didn't recognize).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-20 20:32:31 +02:00

304 lines
12 KiB
TypeScript

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 { 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 });
}
await db.transaction(async (tx) => {
// 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<typeof recipes.$inferInsert> = { 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,
}))
);
}
}
});
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 });
}