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
+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) });