feat(recipes): full recipe CRUD with photos, print, version history

List, detail, new, edit pages. Server-side pagination, dietary tags, difficulty.
Photo upload to S3-compatible storage. Version history. Multi-select grid with
bulk delete/visibility. Print view. Delete confirmation dialog.
This commit is contained in:
Arnaud
2026-07-01 08:10:11 +02:00
parent fa2d797918
commit 84d6cfeb07
45 changed files with 5300 additions and 0 deletions
@@ -0,0 +1,148 @@
import { NextRequest, NextResponse } from "next/server";
import { db, recipes, recipeIngredients, recipeSteps, recipeSnapshots } from "@epicure/db";
import { eq, and, max, desc } from "@epicure/db";
import { requireSessionOrApiKey } from "@/lib/api-auth";
type Params = { params: Promise<{ id: string; versionId: string }> };
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, versionId } = await params;
// Verify ownership
const recipe = await db.query.recipes.findFirst({
where: and(eq(recipes.id, id), eq(recipes.authorId, session!.user.id)),
});
if (!recipe) return NextResponse.json({ error: "Not found" }, { status: 404 });
const snapshot = await db.query.recipeSnapshots.findFirst({
where: and(
eq(recipeSnapshots.id, versionId),
eq(recipeSnapshots.recipeId, id),
eq(recipeSnapshots.authorId, session!.user.id)
),
});
if (!snapshot) return NextResponse.json({ error: "Not found" }, { status: 404 });
return NextResponse.json(snapshot);
}
export async function POST(req: NextRequest, { params }: Params) {
const { session, response } = await requireSessionOrApiKey(req, { rateLimit: { limit: 60, windowSeconds: 60 } });
if (response) return response;
const { id, versionId } = await params;
const body = await req.json() as { action?: string };
if (body.action !== "restore") {
return NextResponse.json({ error: "Invalid action" }, { status: 400 });
}
// Verify ownership and get current recipe with relations
const currentRecipe = 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 (!currentRecipe) return NextResponse.json({ error: "Not found" }, { status: 404 });
// Get the snapshot to restore
const snapshot = await db.query.recipeSnapshots.findFirst({
where: and(
eq(recipeSnapshots.id, versionId),
eq(recipeSnapshots.recipeId, id),
eq(recipeSnapshots.authorId, session!.user.id)
),
});
if (!snapshot) return NextResponse.json({ error: "Snapshot not found" }, { status: 404 });
// Create a snapshot of the current state before restoring
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: currentRecipe.title,
snapshotData: {
title: currentRecipe.title,
description: currentRecipe.description,
baseServings: currentRecipe.baseServings,
difficulty: currentRecipe.difficulty,
prepMins: currentRecipe.prepMins,
cookMins: currentRecipe.cookMins,
dietaryTags: currentRecipe.dietaryTags ?? {},
ingredients: currentRecipe.ingredients.map((i) => ({
rawName: i.rawName,
quantity: i.quantity,
unit: i.unit,
note: i.note,
order: i.order,
})),
steps: currentRecipe.steps.map((s) => ({
instruction: s.instruction,
timerSeconds: s.timerSeconds,
order: s.order,
})),
},
});
// Restore the recipe from the snapshot
const data = snapshot.snapshotData as {
title: string;
description?: string | null;
baseServings: number;
difficulty?: string | null;
prepMins?: number | null;
cookMins?: number | null;
dietaryTags?: Record<string, boolean>;
ingredients: Array<{ rawName: string; quantity?: string | null; unit?: string | null; note?: string | null; order: number }>;
steps: Array<{ instruction: string; timerSeconds?: number | null; order: number }>;
};
await db.transaction(async (tx) => {
await tx.update(recipes).set({
title: data.title,
description: data.description ?? null,
baseServings: data.baseServings,
difficulty: (data.difficulty as "easy" | "medium" | "hard" | null) ?? null,
prepMins: data.prepMins ?? null,
cookMins: data.cookMins ?? null,
dietaryTags: data.dietaryTags ?? {},
updatedAt: new Date(),
}).where(eq(recipes.id, id));
await tx.delete(recipeIngredients).where(eq(recipeIngredients.recipeId, id));
if (data.ingredients.length > 0) {
await tx.insert(recipeIngredients).values(
data.ingredients.map((ing) => ({
id: crypto.randomUUID(),
recipeId: id,
rawName: ing.rawName,
quantity: ing.quantity ?? undefined,
unit: ing.unit ?? undefined,
note: ing.note ?? undefined,
order: ing.order,
}))
);
}
await tx.delete(recipeSteps).where(eq(recipeSteps.recipeId, id));
if (data.steps.length > 0) {
await tx.insert(recipeSteps).values(
data.steps.map((step) => ({
id: crypto.randomUUID(),
recipeId: id,
instruction: step.instruction,
timerSeconds: step.timerSeconds ?? undefined,
order: step.order,
}))
);
}
});
return NextResponse.json({ ok: true });
}
@@ -0,0 +1,32 @@
import { NextRequest, NextResponse } from "next/server";
import { db, recipes, recipeSnapshots } from "@epicure/db";
import { eq, and, desc } from "@epicure/db";
import { requireSessionOrApiKey } from "@/lib/api-auth";
type Params = { params: Promise<{ id: string }> };
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;
// Verify ownership
const recipe = await db.query.recipes.findFirst({
where: and(eq(recipes.id, id), eq(recipes.authorId, session!.user.id)),
});
if (!recipe) return NextResponse.json({ error: "Not found" }, { status: 404 });
const versions = await db
.select({
id: recipeSnapshots.id,
version: recipeSnapshots.version,
title: recipeSnapshots.title,
createdAt: recipeSnapshots.createdAt,
})
.from(recipeSnapshots)
.where(and(eq(recipeSnapshots.recipeId, id), eq(recipeSnapshots.authorId, session!.user.id)))
.orderBy(desc(recipeSnapshots.version))
.limit(20);
return NextResponse.json(versions);
}