f6975e98a9
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>
304 lines
12 KiB
TypeScript
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 });
|
|
}
|