feat: manually create and edit batch-cook recipes

recipe-form.tsx previously had no batch-cook awareness at all — editing an
AI-generated batch-cook recipe and saving silently corrupted it (steps lost
their per-dish `appliesTo` tags, recipeBatchDishes rows went stale/orphaned,
since the create/update API schemas and edit-page query never touched them).

Adds a "batch-cook recipe" toggle to the form, an editable dish list (name,
description, fridge days, freezer-friendly/note, day-of instructions), and
per-step dish tagging (click a dish chip to mark which dish(es) that step
advances; empty = shared prep). Renaming a dish propagates to any step
still tagged with the old name instead of orphaning it. Wired through
Create/Update API schemas and the edit page's query/payload.

Verified locally: created a 2-dish batch recipe from scratch through the
real form, confirmed it renders identically to an AI-generated one (grouped
steps, dishes & storage cards), edited it, renamed a dish, reloaded, and
confirmed the step tag followed the rename rather than orphaning.
This commit is contained in:
Arnaud
2026-07-12 14:45:24 +02:00
parent 20e4ce3597
commit 61014a3224
6 changed files with 341 additions and 32 deletions
+39 -2
View File
@@ -1,5 +1,5 @@
import { NextRequest, NextResponse } from "next/server";
import { db, recipes, recipeIngredients, recipeSteps, recipePhotos, recipeSnapshots, ratings } from "@epicure/db";
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";
@@ -40,11 +40,21 @@ const UpdateRecipeSchema = 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),
})).max(20).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(),
});
type Params = { params: Promise<{ id: string }> };
@@ -52,7 +62,12 @@ 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 },
with: {
ingredients: { orderBy: (t, { asc }) => asc(t.order) },
steps: { orderBy: (t, { asc }) => asc(t.order) },
photos: true,
batchDishes: { orderBy: (t, { asc }) => asc(t.order) },
},
});
}
@@ -78,6 +93,7 @@ export async function PUT(req: NextRequest, { params }: Params) {
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 });
@@ -136,6 +152,7 @@ export async function PUT(req: NextRequest, { params }: Params) {
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;
await tx.update(recipes).set(updates).where(eq(recipes.id, id));
@@ -166,6 +183,26 @@ export async function PUT(req: NextRequest, { params }: Params) {
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,
}))
);
}
+29 -1
View File
@@ -1,5 +1,5 @@
import { NextRequest, NextResponse } from "next/server";
import { db, recipes, recipeIngredients, recipeSteps, recipePhotos } from "@epicure/db";
import { db, recipes, recipeIngredients, recipeSteps, recipePhotos, recipeBatchDishes } from "@epicure/db";
import { eq, desc, and } from "@epicure/db";
import { z } from "zod";
import { requireSessionOrApiKey } from "@/lib/api-auth";
@@ -42,11 +42,21 @@ const CreateRecipeSchema = z.object({
instruction: z.string().min(1).max(2000),
timerSeconds: z.number().int().min(0).max(86400).optional(),
order: z.number().int().optional(),
appliesTo: z.array(z.string().min(1).max(100)).max(20).default([]),
})).max(100).default([]),
photos: z.array(z.object({
key: z.string().min(1).max(500),
isCover: z.boolean().default(false),
})).max(20).default([]),
isBatchCook: z.boolean().default(false),
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).default([]),
});
export async function GET(req: NextRequest) {
@@ -110,6 +120,7 @@ export async function POST(req: NextRequest) {
dietaryTags: data.dietaryTags ?? {},
aiGenerated: data.aiGenerated ?? false,
language: data.language,
isBatchCook: data.isBatchCook,
createdAt: now,
updatedAt: now,
});
@@ -136,6 +147,7 @@ export async function POST(req: NextRequest) {
instruction: step.instruction,
timerSeconds: step.timerSeconds,
order: step.order ?? i,
appliesTo: step.appliesTo,
}))
);
}
@@ -151,6 +163,22 @@ export async function POST(req: NextRequest) {
}))
);
}
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,
}))
);
}
});
const recipe = await db.query.recipes.findFirst({ where: eq(recipes.id, id) });