Compare commits
6 commits
feat/admin
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 86636f0ba9 | |||
| 46635cb76f | |||
| e8ac2d1629 | |||
| 6b60c11408 | |||
| 83a12d474d | |||
| 9eadf3b974 |
43 changed files with 2981 additions and 656 deletions
21
.env.example
21
.env.example
|
|
@ -15,27 +15,6 @@ JWT_SECRET=changeme-generate-a-real-random-secret-at-least-32-chars
|
|||
# (docker-compose.yml), serving both the API and the built frontend.
|
||||
# APP_PORT=3000
|
||||
|
||||
# --- Admin application (apps/admin-web + the /admin/* API surface) ---------
|
||||
# All optional: an instance that doesn't run the admin app needs none of
|
||||
# these. `requireAdmin` fails closed when ADMIN_JWT_SECRET is unset, so
|
||||
# leaving it out simply disables every /admin/* route.
|
||||
#
|
||||
# Secret for the admin session JWT — MUST be different from JWT_SECRET so an
|
||||
# end-user token can never be replayed against /admin/*. Generate your own
|
||||
# the same way as JWT_SECRET above.
|
||||
# ADMIN_JWT_SECRET=changeme-generate-a-real-random-secret-at-least-32-chars
|
||||
# Origin apps/admin-web is served from, added to the CORS allow-list.
|
||||
# ADMIN_CORS_ORIGIN=http://localhost:5174
|
||||
# Host port for the Docker `admin-web` service (static nginx serving the
|
||||
# built admin frontend).
|
||||
# ADMIN_WEB_PORT=3001
|
||||
# Optional — read only by `src/scripts/create-admin.ts` when its --email /
|
||||
# --password / --name flags are omitted (e.g. to bootstrap the first admin
|
||||
# from inside the container). Never read by the running server.
|
||||
# ADMIN_INITIAL_EMAIL=ops@example.com
|
||||
# ADMIN_INITIAL_PASSWORD=changeme-at-least-8-chars
|
||||
# ADMIN_INITIAL_NAME=Ops
|
||||
|
||||
# Optional — only set this to false if THIS deployment is served over
|
||||
# plain HTTP (no TLS in front of it). Left unset, the session cookie
|
||||
# requires HTTPS (Secure attribute) as it should for a real deployment;
|
||||
|
|
|
|||
10
.github/workflows/ci.yml
vendored
10
.github/workflows/ci.yml
vendored
|
|
@ -19,10 +19,6 @@ env:
|
|||
# exercise the success path (matching secret), not just the "unset"
|
||||
# rejection every environment that doesn't set this gets by default.
|
||||
INTERNAL_WORKER_SECRET: "ci-only-worker-secret-not-used-anywhere-else-32chars+"
|
||||
# Same reasoning — lets admin-auth.test.ts exercise the admin login
|
||||
# success path + the requireAdmin-guarded routes, not just the "no admin
|
||||
# secret configured -> 401" path.
|
||||
ADMIN_JWT_SECRET: "ci-only-admin-secret-not-used-anywhere-else-32chars+"
|
||||
# Shared between the `test` job's own uvicorn step (below) and apps/api's
|
||||
# IntentServiceClient — see the `test` job for why this can't be a
|
||||
# `services:` container like postgres above (GitHub Actions can only pull
|
||||
|
|
@ -58,14 +54,16 @@ jobs:
|
|||
POSTGRES_USER: ci
|
||||
POSTGRES_PASSWORD: ci
|
||||
POSTGRES_DB: batchcooking_ci
|
||||
ports:
|
||||
- 5433:5432
|
||||
options: >-
|
||||
--health-cmd pg_isready
|
||||
--health-interval 5s
|
||||
--health-timeout 5s
|
||||
--health-retries 10
|
||||
steps:
|
||||
- name: Install curl and dependencies
|
||||
run: |
|
||||
apt-get update && apt-get install -y curl
|
||||
|
||||
- uses: https://github.com/actions/checkout@v4
|
||||
|
||||
- uses: pnpm/action-setup@v4
|
||||
|
|
|
|||
|
|
@ -27,10 +27,3 @@ INTENT_SERVICE_SECRET=changeme-generate-a-real-random-secret-at-least-32-chars
|
|||
# success path (a request with a matching secret); every other test runs
|
||||
# fine without it. Any value at least 32 chars works locally.
|
||||
# INTERNAL_WORKER_SECRET=changeme-generate-a-real-random-secret-at-least-32-chars
|
||||
|
||||
# Optional — set to run admin-auth.test.ts's login success path and the
|
||||
# requireAdmin-guarded routes (any value at least 32 chars). Left unset,
|
||||
# those cases self-skip and only the "no secret configured -> 401" path
|
||||
# runs. Same "optional in test, fail-closed at runtime" posture as
|
||||
# INTERNAL_WORKER_SECRET above.
|
||||
# ADMIN_JWT_SECRET=changeme-generate-a-real-random-secret-at-least-32-chars
|
||||
|
|
|
|||
|
|
@ -1,15 +0,0 @@
|
|||
-- CreateTable
|
||||
CREATE TABLE "admin_users" (
|
||||
"id" SERIAL NOT NULL,
|
||||
"email" TEXT NOT NULL,
|
||||
"password_hash" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"token_version" INTEGER NOT NULL DEFAULT 0,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"last_login_at" TIMESTAMP(3),
|
||||
|
||||
CONSTRAINT "admin_users_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "admin_users_email_key" ON "admin_users"("email");
|
||||
|
|
@ -897,36 +897,3 @@ model TechStepTrainingSuggestion {
|
|||
|
||||
@@map("tech_step_training_suggestion")
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Admin application
|
||||
// A separate operations app (usage metrics, microservice monitoring, tech-step
|
||||
// correction triage) — see specs/backend-architecture.md's "admin" section.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
/// An operator of the admin application (`apps/admin-web` / the `/admin/*`
|
||||
/// API surface). Deliberately its **own** table with **no relation** to
|
||||
/// `UserProfile`: admin access is a completely separate concern from being
|
||||
/// an end user of the recipe app — a person can be one, both or neither,
|
||||
/// and the two auth mechanisms (`requireAdmin` vs `requireAuth`, distinct
|
||||
/// cookies, distinct JWT secrets) never overlap. No self-service signup —
|
||||
/// the first row is created out-of-band by `src/scripts/create-admin.ts`,
|
||||
/// and (for now) there's no in-app admin-management UI.
|
||||
model AdminUser {
|
||||
id Int @id @default(autoincrement())
|
||||
email String @unique
|
||||
/// argon2 hash of the password — same hashing as `UserProfile.passwordHash`
|
||||
/// (`auth.service.ts`'s `hashOptions`, cheaper cost under NODE_ENV=test).
|
||||
passwordHash String @map("password_hash")
|
||||
/// Display name shown in the admin UI's account menu.
|
||||
name String
|
||||
/// Bumped to invalidate previously-issued admin JWTs — same mechanism as
|
||||
/// `UserProfile.tokenVersion`, checked on every request by `requireAdmin`.
|
||||
tokenVersion Int @default(0) @map("token_version")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
/// Stamped on every successful login — a cheap "is this account still in
|
||||
/// use" signal for the operator managing admins by hand.
|
||||
lastLoginAt DateTime? @map("last_login_at")
|
||||
|
||||
@@map("admin_users")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,8 +5,8 @@ import type { Express, Request, Response } from "express";
|
|||
import { env } from "./config/env.js";
|
||||
import { errorLogger } from "./middlewares/error-logger.js";
|
||||
import { requestLogger } from "./middlewares/request-logger.js";
|
||||
import { adminRouter } from "./modules/admin/admin.routes.js";
|
||||
import { authRouter } from "./modules/auth/auth.routes.js";
|
||||
import { cookingSessionRouter } from "./modules/cooking-session/cooking-session.routes.js";
|
||||
import { houseRouter } from "./modules/house/house.routes.js";
|
||||
import { techStepWorkerRouter } from "./modules/internal/tech-step-worker.routes.js";
|
||||
import { planningRouter } from "./modules/planning/planning.routes.js";
|
||||
|
|
@ -33,19 +33,13 @@ export function createServer(): ExpressServer {
|
|||
// pipeline (its "finish" listener still fires for a request that never
|
||||
// makes it past CORS/body-parsing, not just ones that reach a route).
|
||||
server.addMiddleware(requestLogger);
|
||||
// Two allowed origins: the main app (`CORS_ORIGIN`) and the separate
|
||||
// admin app (`ADMIN_CORS_ORIGIN`). The `cors` package matches an incoming
|
||||
// `Origin` against any entry of the list.
|
||||
server.setupCore({ corsOrigin: [env.CORS_ORIGIN, env.ADMIN_CORS_ORIGIN] });
|
||||
server.setupCore({ corsOrigin: env.CORS_ORIGIN });
|
||||
|
||||
server.addRoute("get", "/health", (_req: Request, res: Response) => {
|
||||
res.status(200).json({ status: "ok" });
|
||||
});
|
||||
|
||||
server.mountRouter("/auth", authRouter);
|
||||
// Admin application surface (`apps/admin-web`) — its own auth
|
||||
// (`requireAdmin`, distinct cookie/secret), never the end-user session.
|
||||
server.mountRouter("/admin", adminRouter);
|
||||
server.mountRouter("/house", houseRouter);
|
||||
// Not user-facing — `services/tech-step-llm-worker` only, guarded by
|
||||
// `requireInternalWorker` on every route within (see that router's own
|
||||
|
|
@ -59,6 +53,7 @@ export function createServer(): ExpressServer {
|
|||
server.mountRouter("/recipes", recipeRouter);
|
||||
server.mountRouter("/reference", referenceRouter);
|
||||
server.mountRouter("/shopping-list", shoppingListRouter);
|
||||
server.mountRouter("/cooking-session", cookingSessionRouter);
|
||||
server.mountRouter("/sources", sourcesRouter);
|
||||
|
||||
// Serves the built frontend (production Docker image only — see
|
||||
|
|
|
|||
|
|
@ -90,27 +90,6 @@ const envSchema = z.object({
|
|||
* every technique detection request failing one at a time.
|
||||
*/
|
||||
INTENT_SERVICE_SECRET: z.string().min(32, "INTENT_SERVICE_SECRET must be at least 32 characters"),
|
||||
/**
|
||||
* Secret used to sign/verify the **admin** session JWT (`lib/admin-jwt.ts`)
|
||||
* — entirely separate from `JWT_SECRET`, so an end-user session token can
|
||||
* never be replayed against `/admin/*` and vice versa. `.optional()`
|
||||
* (unlike `JWT_SECRET`): an instance that doesn't run the admin app at
|
||||
* all never needs it — but `requireAdmin` (`middlewares/require-admin.ts`)
|
||||
* rejects every request outright when it's unset, so the surface fails
|
||||
* closed, same posture as `INTERNAL_WORKER_SECRET`.
|
||||
*/
|
||||
ADMIN_JWT_SECRET: z
|
||||
.string()
|
||||
.min(32, "ADMIN_JWT_SECRET must be at least 32 characters")
|
||||
.optional(),
|
||||
/** Name of the httpOnly cookie carrying the admin session JWT — must differ from `AUTH_COOKIE_NAME` so the two sessions coexist in one browser. */
|
||||
ADMIN_COOKIE_NAME: z.string().default("admin_session"),
|
||||
/** Origin `apps/admin-web` is served from — added to the CORS allow-list alongside `CORS_ORIGIN`. */
|
||||
ADMIN_CORS_ORIGIN: z.string().default("http://localhost:5174"),
|
||||
/** Optional seed values read by `src/scripts/create-admin.ts` when its `--email`/`--password`/`--name` flags are omitted — never used by the running server. */
|
||||
ADMIN_INITIAL_EMAIL: z.string().optional(),
|
||||
ADMIN_INITIAL_PASSWORD: z.string().optional(),
|
||||
ADMIN_INITIAL_NAME: z.string().optional(),
|
||||
});
|
||||
|
||||
/** Parsed, validated environment — import this instead of reading `process.env` directly anywhere else. */
|
||||
|
|
|
|||
|
|
@ -1,59 +0,0 @@
|
|||
import jwt from "jsonwebtoken";
|
||||
import { env } from "../config/env.js";
|
||||
|
||||
/**
|
||||
* Decoded contents of an **admin** session JWT, once verified. Deliberately
|
||||
* a separate token type from `lib/jwt.ts`'s `AuthTokenPayload`: the admin
|
||||
* app authenticates against its own `AdminUser` table with its own secret
|
||||
* (`ADMIN_JWT_SECRET`), so an end-user session token and an admin session
|
||||
* token are never interchangeable.
|
||||
*/
|
||||
export interface AdminTokenPayload {
|
||||
/** `AdminUser.id` this token authenticates. */
|
||||
adminUserId: number;
|
||||
/** Snapshot of `AdminUser.tokenVersion` at sign time — re-checked against the DB on every request (see `requireAdmin`) to allow server-side invalidation. */
|
||||
tokenVersion: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* The admin JWT secret, or a thrown error if the instance never configured
|
||||
* one. A misconfigured admin deployment surfaces as a loud 500 on login
|
||||
* rather than a silently-unsigned token; a deployment that doesn't run the
|
||||
* admin app at all never reaches here (nothing calls sign/verify), and
|
||||
* `requireAdmin` independently fails closed on the same unset value.
|
||||
*/
|
||||
function adminSecret(): string {
|
||||
if (env.ADMIN_JWT_SECRET === undefined) {
|
||||
throw new Error("ADMIN_JWT_SECRET is not configured — cannot issue or verify admin sessions");
|
||||
}
|
||||
return env.ADMIN_JWT_SECRET;
|
||||
}
|
||||
|
||||
/** Signs a new admin session JWT, expiring per `JWT_EXPIRES_IN` (shared with the end-user token — same "how long a session lasts" policy). */
|
||||
export function signAdminToken(payload: AdminTokenPayload): string {
|
||||
return jwt.sign(
|
||||
{ sub: String(payload.adminUserId), tokenVersion: payload.tokenVersion },
|
||||
adminSecret(),
|
||||
{ expiresIn: env.JWT_EXPIRES_IN as jwt.SignOptions["expiresIn"] },
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifies an admin session JWT's signature/expiry and decodes it back
|
||||
* into an {@link AdminTokenPayload}.
|
||||
*
|
||||
* @throws {Error} if the token is invalid/expired (from `jwt.verify`) or
|
||||
* structurally malformed (missing/wrong-typed claims).
|
||||
*/
|
||||
export function verifyAdminToken(token: string): AdminTokenPayload {
|
||||
const decoded = jwt.verify(token, adminSecret());
|
||||
const adminUserId = typeof decoded === "object" ? Number(decoded.sub) : Number.NaN;
|
||||
if (
|
||||
typeof decoded !== "object" ||
|
||||
Number.isNaN(adminUserId) ||
|
||||
typeof decoded.tokenVersion !== "number"
|
||||
) {
|
||||
throw new Error("Malformed admin token payload");
|
||||
}
|
||||
return { adminUserId, tokenVersion: decoded.tokenVersion };
|
||||
}
|
||||
511
apps/api/src/lib/recipe-matching/cooking-optimizer.ts
Normal file
511
apps/api/src/lib/recipe-matching/cooking-optimizer.ts
Normal file
|
|
@ -0,0 +1,511 @@
|
|||
import type {
|
||||
CookingBackgroundTaskView,
|
||||
CookingPhaseKind,
|
||||
CookingPhaseView,
|
||||
CookingSessionRecipeRef,
|
||||
CookingTaskIngredientView,
|
||||
CookingTaskView,
|
||||
TechStepView,
|
||||
UtensilView,
|
||||
} from "@batch-cooking/shared";
|
||||
|
||||
/**
|
||||
* The pure core of the "Calcul batch-cooking" module (`specs/batch-cooking-architecture.md`):
|
||||
* takes the week's planned recipes — already resolved to reference views by
|
||||
* `cooking-session.service.ts` — and reorganizes their steps into an ordered
|
||||
* sequence of {@link CookingPhaseView}s that pools shared preparation and
|
||||
* interleaves the recipes so passive cooks (simmer, braise, bake…) run in
|
||||
* the background while the cook does active work from another recipe.
|
||||
*
|
||||
* Pure and synchronous, no database access — same `matchXxx()` pure /
|
||||
* `loadXxx()` DB-backed split as `ingredient-matcher.ts` /
|
||||
* `tech-step-matcher.ts` / `shopping-list.service.ts`'s
|
||||
* `aggregateShoppingList`, so the whole optimization is unit-testable
|
||||
* without a Postgres round-trip.
|
||||
*
|
||||
* v1 scope (see the plan / spec): preparation is the only thing *merged*
|
||||
* across recipes — a `chop`/`peel`/… technique applied to the same
|
||||
* ingredient by two or more recipes, in a step that does nothing but prep,
|
||||
* collapses into a single {@link CookingTaskView} of `kind: "merged-prep"`.
|
||||
* Cooking steps themselves are never merged (no "same oven, same
|
||||
* temperature" reasoning yet); they're only *reordered* for parallelism.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Technique keys (`TechStep.key`, see `reference-seed-data.ts`'s
|
||||
* `TECH_STEPS`) that are pure knife/prep work on an ingredient — the only
|
||||
* techniques v1 pools across recipes. A *step* counts as prep only when
|
||||
* **every** technique it mentions is in here (see {@link isPurePrepStep}):
|
||||
* "émincer les oignons" merges, "faire revenir les oignons émincés" does
|
||||
* not (its `panFry` keeps it a cooking step).
|
||||
*/
|
||||
const PREP_TECHNIQUES: ReadonlySet<string> = new Set([
|
||||
"chop",
|
||||
"peel",
|
||||
"mince",
|
||||
"julienne",
|
||||
"brunoise",
|
||||
"concasse",
|
||||
"paysanne",
|
||||
"mirepoix",
|
||||
"zest",
|
||||
"score",
|
||||
"pod",
|
||||
"shellEgg",
|
||||
"hollowOut",
|
||||
"filet",
|
||||
"disgorge",
|
||||
"sift",
|
||||
"dustWithFlour",
|
||||
"peelBlanch",
|
||||
]);
|
||||
|
||||
/**
|
||||
* How much of the cook's attention a technique needs once it's under way —
|
||||
* the axis that makes parallelism possible.
|
||||
*
|
||||
* - `"SETUP"` — a short active trigger, then it looks after itself: preheat
|
||||
* the oven, bring a pot of water to the boil. Pooled into the first
|
||||
* ("mise en place") phase so it's running before it's needed.
|
||||
* - `"PASSIVE"` — unattended once started (simmer, braise, bake, marinate,
|
||||
* rest…). Scheduled, then floated into every following phase's
|
||||
* `background` until the step that consumes it comes up.
|
||||
* - anything not listed here, or a step with no detected technique at all,
|
||||
* is treated as `"ACTIVE"` — hands-on, occupies the cook.
|
||||
*/
|
||||
const SETUP_TECHNIQUES: ReadonlySet<string> = new Set(["preheat", "boil", "bainMarie"]);
|
||||
|
||||
/** See {@link SETUP_TECHNIQUES}. */
|
||||
const PASSIVE_TECHNIQUES: ReadonlySet<string> = new Set([
|
||||
"simmer",
|
||||
"bake",
|
||||
"roast",
|
||||
"braise",
|
||||
"marinate",
|
||||
"rest",
|
||||
"proof",
|
||||
"confit",
|
||||
"reduce",
|
||||
"blindBake",
|
||||
"compote",
|
||||
"smother",
|
||||
"setGel",
|
||||
"pasteurize",
|
||||
"appertize",
|
||||
"poach",
|
||||
"sweat",
|
||||
"glaze",
|
||||
]);
|
||||
|
||||
/** Attention class of a single step — see {@link SETUP_TECHNIQUES}. */
|
||||
type Attention = "SETUP" | "PASSIVE" | "ACTIVE";
|
||||
|
||||
/** One technique occurrence within a step, already scaled to the planned portions. */
|
||||
interface OptimizerTechStepInput {
|
||||
techStep: TechStepView;
|
||||
order: number;
|
||||
ingredients: CookingTaskIngredientView[];
|
||||
utensils: UtensilView[];
|
||||
}
|
||||
|
||||
/** One recipe step, as handed to {@link optimizeCookingPlan}. */
|
||||
interface OptimizerStepInput {
|
||||
stepId: number;
|
||||
order: number;
|
||||
description: string;
|
||||
techSteps: OptimizerTechStepInput[];
|
||||
}
|
||||
|
||||
/**
|
||||
* One planned recipe, as handed to {@link optimizeCookingPlan}. `portions`
|
||||
* is the planning slot's own count and `recipePortions` the recipe's
|
||||
* as-written yield — quantities are scaled by `portions / recipePortions`
|
||||
* (see {@link scaleOf}). The same recipe planned twice at different portion
|
||||
* counts arrives as two entries with the same `recipeId`; that's
|
||||
* intentional (two real cooking jobs), and merged-prep still pools their
|
||||
* knife work back together.
|
||||
*/
|
||||
interface OptimizerRecipeInput {
|
||||
recipeId: number;
|
||||
name: string;
|
||||
portions: number;
|
||||
recipePortions: number;
|
||||
steps: OptimizerStepInput[];
|
||||
}
|
||||
|
||||
/** {@link optimizeCookingPlan}'s result — the date-range/legend wrapper is added by the service. */
|
||||
interface OptimizeCookingPlanResult {
|
||||
recipes: CookingSessionRecipeRef[];
|
||||
phases: CookingPhaseView[];
|
||||
}
|
||||
|
||||
export type {
|
||||
OptimizeCookingPlanResult,
|
||||
OptimizerRecipeInput,
|
||||
OptimizerStepInput,
|
||||
OptimizerTechStepInput,
|
||||
};
|
||||
|
||||
/** Portion scale factor for a recipe — guards a missing/zero as-written yield (bad data) by falling back to 1× rather than dividing by zero. */
|
||||
function scaleOf(recipe: OptimizerRecipeInput): number {
|
||||
if (!recipe.recipePortions || recipe.recipePortions <= 0) return 1;
|
||||
return recipe.portions / recipe.recipePortions;
|
||||
}
|
||||
|
||||
/** A step normalized for scheduling — techniques scaled, attention resolved, ingredients/utensils unioned across its technique clauses. */
|
||||
interface NormalizedStep {
|
||||
/** Stable within one response: `step:<recipeIndex>:<stepId>` (the index disambiguates the same recipe planned twice). */
|
||||
taskId: string;
|
||||
recipeIndex: number;
|
||||
recipe: CookingSessionRecipeRef;
|
||||
stepId: number;
|
||||
order: number;
|
||||
description: string;
|
||||
techSteps: OptimizerTechStepInput[];
|
||||
attention: Attention;
|
||||
isPurePrep: boolean;
|
||||
dominantTechnique: TechStepView | null;
|
||||
ingredients: CookingTaskIngredientView[];
|
||||
utensils: UtensilView[];
|
||||
/** Set once merged-prep extraction absorbs this step wholesale (all its prep pooled elsewhere) — it then produces no standalone task. */
|
||||
absorbed: boolean;
|
||||
}
|
||||
|
||||
/** Sums two ingredient lines only when it's unambiguous — same unit id and both quantities known; otherwise the pooled line carries no number (see `ShoppingListItemView`'s "don't guess a conversion" rule). */
|
||||
function poolIngredient(lines: CookingTaskIngredientView[]): {
|
||||
quantity: number | null;
|
||||
unit: CookingTaskIngredientView["unit"];
|
||||
} {
|
||||
const first = lines[0];
|
||||
if (!first) return { quantity: null, unit: null };
|
||||
const unitId = first.unit?.id ?? null;
|
||||
let total = 0;
|
||||
for (const line of lines) {
|
||||
if (line.quantity === null || (line.unit?.id ?? null) !== unitId) {
|
||||
return { quantity: null, unit: null };
|
||||
}
|
||||
total += line.quantity;
|
||||
}
|
||||
return { quantity: total, unit: first.unit };
|
||||
}
|
||||
|
||||
/** Unions ingredient lines by `(ingredientId, unitId)`, summing quantities within a group the same careful way as {@link poolIngredient}. */
|
||||
function unionIngredients(lines: CookingTaskIngredientView[]): CookingTaskIngredientView[] {
|
||||
const groups = new Map<string, CookingTaskIngredientView[]>();
|
||||
for (const line of lines) {
|
||||
const key = `${line.ingredient.id}:${line.unit?.id ?? "x"}`;
|
||||
const group = groups.get(key);
|
||||
if (group) group.push(line);
|
||||
else groups.set(key, [line]);
|
||||
}
|
||||
const out: CookingTaskIngredientView[] = [];
|
||||
for (const group of groups.values()) {
|
||||
const head = group[0];
|
||||
if (!head) continue;
|
||||
const pooled = poolIngredient(group);
|
||||
out.push({ ingredient: head.ingredient, quantity: pooled.quantity, unit: pooled.unit });
|
||||
}
|
||||
return out.sort((a, b) => a.ingredient.key.localeCompare(b.ingredient.key));
|
||||
}
|
||||
|
||||
/** Unions utensils by id, keeping a stable order by key. */
|
||||
function unionUtensils(utensils: UtensilView[]): UtensilView[] {
|
||||
const byId = new Map<number, UtensilView>();
|
||||
for (const utensil of utensils) byId.set(utensil.id, utensil);
|
||||
return [...byId.values()].sort((a, b) => a.key.localeCompare(b.key));
|
||||
}
|
||||
|
||||
/** A step is pure prep only if it has techniques and every one of them is in {@link PREP_TECHNIQUES}. */
|
||||
function isPurePrepStep(techSteps: OptimizerTechStepInput[]): boolean {
|
||||
return techSteps.length > 0 && techSteps.every((ts) => PREP_TECHNIQUES.has(ts.techStep.key));
|
||||
}
|
||||
|
||||
/** Resolves a step's {@link Attention} — SETUP wins, then a *trailing* passive technique, else ACTIVE (see {@link SETUP_TECHNIQUES}). */
|
||||
function attentionOf(techSteps: OptimizerTechStepInput[]): Attention {
|
||||
if (techSteps.some((ts) => SETUP_TECHNIQUES.has(ts.techStep.key))) return "SETUP";
|
||||
const last = techSteps[techSteps.length - 1];
|
||||
if (last && PASSIVE_TECHNIQUES.has(last.techStep.key)) return "PASSIVE";
|
||||
return "ACTIVE";
|
||||
}
|
||||
|
||||
/** Turns one recipe's raw steps into {@link NormalizedStep}s — scales quantities, resolves attention, unions per-clause ingredients/utensils up to the step. */
|
||||
function normalizeRecipe(recipe: OptimizerRecipeInput, recipeIndex: number): NormalizedStep[] {
|
||||
const scale = scaleOf(recipe);
|
||||
const recipeRef: CookingSessionRecipeRef = {
|
||||
recipeId: recipe.recipeId,
|
||||
name: recipe.name,
|
||||
portions: recipe.portions,
|
||||
};
|
||||
|
||||
return [...recipe.steps]
|
||||
.sort((a, b) => a.order - b.order)
|
||||
.map((step) => {
|
||||
const techSteps: OptimizerTechStepInput[] = [...step.techSteps]
|
||||
.sort((a, b) => a.order - b.order)
|
||||
.map((ts) => ({
|
||||
techStep: ts.techStep,
|
||||
order: ts.order,
|
||||
ingredients: ts.ingredients.map((line) => ({
|
||||
ingredient: line.ingredient,
|
||||
quantity: line.quantity === null ? null : line.quantity * scale,
|
||||
unit: line.unit,
|
||||
})),
|
||||
utensils: ts.utensils,
|
||||
}));
|
||||
|
||||
const lastTech = techSteps[techSteps.length - 1];
|
||||
return {
|
||||
taskId: `step:${recipeIndex}:${step.stepId}`,
|
||||
recipeIndex,
|
||||
recipe: recipeRef,
|
||||
stepId: step.stepId,
|
||||
order: step.order,
|
||||
description: step.description,
|
||||
techSteps,
|
||||
attention: attentionOf(techSteps),
|
||||
isPurePrep: isPurePrepStep(techSteps),
|
||||
dominantTechnique: lastTech ? lastTech.techStep : null,
|
||||
ingredients: unionIngredients(techSteps.flatMap((ts) => ts.ingredients)),
|
||||
utensils: unionUtensils(techSteps.flatMap((ts) => ts.utensils)),
|
||||
absorbed: false,
|
||||
} satisfies NormalizedStep;
|
||||
});
|
||||
}
|
||||
|
||||
/** The prep signature of a pure-prep step — sorted `<techniqueKey>:<ingredientId>` pairs; two steps with the same signature do identical knife work and can be pooled. */
|
||||
function prepSignature(step: NormalizedStep): string {
|
||||
const pairs: string[] = [];
|
||||
for (const ts of step.techSteps) {
|
||||
for (const line of ts.ingredients) {
|
||||
pairs.push(`${ts.techStep.key}:${line.ingredient.id}`);
|
||||
}
|
||||
}
|
||||
return [...new Set(pairs)].sort().join("+");
|
||||
}
|
||||
|
||||
/** Builds one {@link CookingTaskView} from a normalized step run as written. */
|
||||
function stepToTask(step: NormalizedStep): CookingTaskView {
|
||||
return {
|
||||
id: step.taskId,
|
||||
kind: "step",
|
||||
technique: step.dominantTechnique,
|
||||
description: step.description,
|
||||
ingredients: step.ingredients,
|
||||
utensils: step.utensils,
|
||||
sourceRecipes: [step.recipe],
|
||||
originalSteps: [
|
||||
{
|
||||
recipeId: step.recipe.recipeId,
|
||||
recipeName: step.recipe.name,
|
||||
description: step.description,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
/** Builds the running-in-the-background status line for a passive step already scheduled in an earlier phase. */
|
||||
function stepToBackground(step: NormalizedStep): CookingBackgroundTaskView {
|
||||
return {
|
||||
id: `bg:${step.taskId}`,
|
||||
technique: step.dominantTechnique,
|
||||
description: step.description,
|
||||
recipeId: step.recipe.recipeId,
|
||||
recipeName: step.recipe.name,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Pools pure-prep steps that do the *exact same* knife work (same
|
||||
* {@link prepSignature}) in two or more distinct recipes into one
|
||||
* `merged-prep` {@link CookingTaskView}, and marks every contributing step
|
||||
* `absorbed` so it produces no standalone task. A pure-prep step whose
|
||||
* signature is unique (only one recipe needs it) is left untouched — it
|
||||
* still lands in the mise-en-place phase, just as its own step task.
|
||||
*
|
||||
* Returns the merged tasks in a stable order (by id).
|
||||
*/
|
||||
function extractMergedPrep(steps: NormalizedStep[]): CookingTaskView[] {
|
||||
const bySignature = new Map<string, NormalizedStep[]>();
|
||||
for (const step of steps) {
|
||||
if (!step.isPurePrep) continue;
|
||||
const signature = prepSignature(step);
|
||||
if (signature === "") continue;
|
||||
const group = bySignature.get(signature);
|
||||
if (group) group.push(step);
|
||||
else bySignature.set(signature, [step]);
|
||||
}
|
||||
|
||||
const merged: CookingTaskView[] = [];
|
||||
for (const [signature, group] of bySignature) {
|
||||
const recipeIndexes = new Set(group.map((s) => s.recipeIndex));
|
||||
if (recipeIndexes.size < 2) continue;
|
||||
|
||||
for (const step of group) step.absorbed = true;
|
||||
|
||||
// Every contributing clause's ingredient lines, pooled per ingredient.
|
||||
const allLines = group.flatMap((s) => s.techSteps.flatMap((ts) => ts.ingredients));
|
||||
const ingredients = unionIngredients(allLines);
|
||||
const utensils = unionUtensils(group.flatMap((s) => s.utensils));
|
||||
|
||||
// Dominant technique of the pool = the first pair's technique (v1
|
||||
// signatures are almost always a single `<technique>:<ingredient>`
|
||||
// pair; a multi-pair signature just takes the earliest).
|
||||
const firstTech = group[0]?.techSteps[0]?.techStep ?? null;
|
||||
const firstIngredientKey = ingredients[0]?.ingredient.key ?? signature;
|
||||
|
||||
// Distinct source recipes / original step texts, in input order.
|
||||
const sourceRecipes: CookingSessionRecipeRef[] = [];
|
||||
const seenRecipe = new Set<number>();
|
||||
const originalSteps: CookingTaskView["originalSteps"] = [];
|
||||
for (const step of [...group].sort((a, b) => a.recipeIndex - b.recipeIndex)) {
|
||||
if (!seenRecipe.has(step.recipeIndex)) {
|
||||
seenRecipe.add(step.recipeIndex);
|
||||
sourceRecipes.push(step.recipe);
|
||||
}
|
||||
originalSteps.push({
|
||||
recipeId: step.recipe.recipeId,
|
||||
recipeName: step.recipe.name,
|
||||
description: step.description,
|
||||
});
|
||||
}
|
||||
|
||||
merged.push({
|
||||
id: `prep:${firstTech ? firstTech.key : "prep"}:${firstIngredientKey}`,
|
||||
kind: "merged-prep",
|
||||
technique: firstTech,
|
||||
description: null,
|
||||
ingredients,
|
||||
utensils,
|
||||
sourceRecipes,
|
||||
originalSteps,
|
||||
});
|
||||
}
|
||||
|
||||
return merged.sort((a, b) => a.id.localeCompare(b.id));
|
||||
}
|
||||
|
||||
/** Maps the input recipe list to its display legend, de-duplicating an exact `(recipeId, portions)` repeat. */
|
||||
function toRecipeLegend(recipes: OptimizerRecipeInput[]): CookingSessionRecipeRef[] {
|
||||
const seen = new Set<string>();
|
||||
const out: CookingSessionRecipeRef[] = [];
|
||||
for (const recipe of recipes) {
|
||||
const key = `${recipe.recipeId}:${recipe.portions}`;
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
out.push({ recipeId: recipe.recipeId, name: recipe.name, portions: recipe.portions });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** A phase is `"finishing"` when everything left in it is plating; otherwise it's a normal `"cooking"` phase. */
|
||||
function cookingPhaseKind(tasks: CookingTaskView[]): CookingPhaseKind {
|
||||
return tasks.every((task) => task.technique?.key === "plate") ? "finishing" : "cooking";
|
||||
}
|
||||
|
||||
/**
|
||||
* See the file header. Given the week's planned recipes (already resolved
|
||||
* to reference views), returns the display legend plus the ordered phases:
|
||||
*
|
||||
* 1. **Mise en place** (`"mise-en-place"`) — every `merged-prep` task, then
|
||||
* every leftover pure-prep step, then every `SETUP` step. Omitted
|
||||
* entirely if it would be empty.
|
||||
* 2. **Cooking** (`"cooking"` / `"finishing"`) — the recipes interleaved:
|
||||
* each phase pops the next remaining step of every recipe that still has
|
||||
* one (passive-cook steps first, so long cooks start early). A passive
|
||||
* step scheduled in one phase is echoed in every later phase's
|
||||
* `background` until that recipe's next step is popped.
|
||||
*/
|
||||
export function optimizeCookingPlan(recipes: OptimizerRecipeInput[]): OptimizeCookingPlanResult {
|
||||
const legend = toRecipeLegend(recipes);
|
||||
const normalized = recipes.map((recipe, index) => normalizeRecipe(recipe, index));
|
||||
const allSteps = normalized.flat();
|
||||
|
||||
const mergedPrep = extractMergedPrep(allSteps);
|
||||
|
||||
const phases: CookingPhaseView[] = [];
|
||||
|
||||
// Phase 0 — mise en place.
|
||||
const miseTasks: CookingTaskView[] = [...mergedPrep];
|
||||
for (const step of allSteps) {
|
||||
if (step.absorbed) continue;
|
||||
if (step.isPurePrep || step.attention === "SETUP") {
|
||||
miseTasks.push(stepToTask(step));
|
||||
step.absorbed = true; // consumed here, not again in the cooking loop
|
||||
}
|
||||
}
|
||||
if (miseTasks.length > 0) {
|
||||
phases.push({ index: 0, kind: "mise-en-place", tasks: miseTasks, background: [] });
|
||||
}
|
||||
|
||||
// Cooking phases — one "next step of each recipe" per phase. `hold` keeps
|
||||
// a recipe out of the *next* phase right after it starts a passive cook,
|
||||
// so another recipe's active work fills that phase and the passive cook
|
||||
// shows up as `background` there instead of being immediately followed by
|
||||
// its own next step.
|
||||
const queues = normalized.map((steps) => ({
|
||||
remaining: steps.filter((s) => !s.absorbed),
|
||||
cursor: 0,
|
||||
hold: 0,
|
||||
}));
|
||||
/** Passive steps started in an earlier phase, keyed by recipe index, still "cooking". */
|
||||
const runningPassive = new Map<number, NormalizedStep>();
|
||||
|
||||
while (queues.some((queue) => queue.cursor < queue.remaining.length)) {
|
||||
const phaseSteps: NormalizedStep[] = [];
|
||||
queues.forEach((queue, recipeIndex) => {
|
||||
const next = queue.remaining[queue.cursor];
|
||||
if (!next) return;
|
||||
if (queue.hold > 0) {
|
||||
// Still tending its passive cook this phase — leave it in
|
||||
// `runningPassive` so it renders as background, don't advance.
|
||||
queue.hold--;
|
||||
return;
|
||||
}
|
||||
// This recipe is advancing — whatever passive cook it had going is
|
||||
// now being tended to, so it stops showing as background.
|
||||
runningPassive.delete(recipeIndex);
|
||||
phaseSteps.push(next);
|
||||
queue.cursor++;
|
||||
});
|
||||
|
||||
// Every recipe with steps left is holding on a passive cook — break the
|
||||
// stall by releasing all holds and letting the next iteration advance.
|
||||
if (phaseSteps.length === 0) {
|
||||
for (const queue of queues) queue.hold = 0;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Background = passive cooks from earlier phases not yet resolved above.
|
||||
const background = [...runningPassive.values()].map(stepToBackground);
|
||||
|
||||
// Start the long cooks first within the phase.
|
||||
phaseSteps.sort((a, b) => {
|
||||
const rank = (s: NormalizedStep) => (s.attention === "PASSIVE" ? 0 : 1);
|
||||
return rank(a) - rank(b) || a.recipeIndex - b.recipeIndex;
|
||||
});
|
||||
|
||||
const tasks = phaseSteps.map(stepToTask);
|
||||
phases.push({
|
||||
index: phases.length,
|
||||
kind: cookingPhaseKind(tasks),
|
||||
tasks,
|
||||
background,
|
||||
});
|
||||
|
||||
for (const step of phaseSteps) {
|
||||
if (step.attention === "PASSIVE") {
|
||||
runningPassive.set(step.recipeIndex, step);
|
||||
const queue = queues[step.recipeIndex];
|
||||
if (queue) queue.hold = 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// `index` was set from `phases.length` as we went; re-stamp so it always
|
||||
// matches the final array position even if phase 0 was skipped.
|
||||
phases.forEach((phase, index) => {
|
||||
phase.index = index;
|
||||
});
|
||||
|
||||
return { recipes: legend, phases };
|
||||
}
|
||||
|
|
@ -1,20 +0,0 @@
|
|||
import type { AdminUserView } from "@batch-cooking/shared";
|
||||
import type { AdminUser } from "@prisma/client";
|
||||
|
||||
/**
|
||||
* Shapes a Prisma `AdminUser` into the {@link AdminUserView} sent to the
|
||||
* admin client — drops `passwordHash` **and** `tokenVersion` (an internal
|
||||
* invalidation counter the client never needs, unlike `SafeUserProfile`
|
||||
* which does expose it), and serializes the two dates to ISO strings. The
|
||||
* one place this security-relevant stripping happens, same role as
|
||||
* `toSafeProfile` (`lib/safe-profile.ts`).
|
||||
*/
|
||||
export function toSafeAdmin(admin: AdminUser): AdminUserView {
|
||||
return {
|
||||
id: admin.id,
|
||||
email: admin.email,
|
||||
name: admin.name,
|
||||
createdAt: admin.createdAt.toISOString(),
|
||||
lastLoginAt: admin.lastLoginAt === null ? null : admin.lastLoginAt.toISOString(),
|
||||
};
|
||||
}
|
||||
|
|
@ -1,71 +0,0 @@
|
|||
import { HttpError } from "@batch-cooking/error-tools";
|
||||
import { type AdminUserView, ErrorCode } from "@batch-cooking/shared";
|
||||
import type { NextFunction, Request, Response } from "express";
|
||||
import { env } from "../config/env.js";
|
||||
import { prisma } from "../db/prisma.js";
|
||||
import { verifyAdminToken } from "../lib/admin-jwt.js";
|
||||
import { toSafeAdmin } from "../lib/safe-admin.js";
|
||||
|
||||
/**
|
||||
* Shape of `res.locals` once {@link requireAdmin} has run successfully.
|
||||
* Type a handler's response as `Response<unknown, AdminLocals>` to read
|
||||
* `res.locals.adminUser` fully typed, no cast — same `res.locals` (not
|
||||
* global `Request` augmentation) approach as {@link AuthLocals}
|
||||
* (`require-auth.ts`).
|
||||
*/
|
||||
export interface AdminLocals {
|
||||
/** The authenticated admin operator, resolved from the admin session cookie's JWT. */
|
||||
adminUser: AdminUserView;
|
||||
}
|
||||
|
||||
/**
|
||||
* Express middleware guarding every `/admin/*` route — the operations app
|
||||
* (`apps/admin-web`) authenticating as an `AdminUser`. Reads the admin
|
||||
* session cookie (`ADMIN_COOKIE_NAME`, deliberately **not** the same cookie
|
||||
* as end-user sessions), verifies the JWT against `ADMIN_JWT_SECRET`
|
||||
* (a different secret than `JWT_SECRET`), and re-checks `tokenVersion`
|
||||
* against the database so a stateless JWT can still be invalidated
|
||||
* server-side.
|
||||
*
|
||||
* A completely separate mechanism from {@link requireAuth}, not layered on
|
||||
* it: an end-user session token and an admin session token are never
|
||||
* interchangeable in either direction.
|
||||
*
|
||||
* Fails closed: an unset `ADMIN_JWT_SECRET` (the default for any instance
|
||||
* that doesn't run the admin app) makes {@link verifyAdminToken} throw, so
|
||||
* every request is rejected rather than the surface left open — same
|
||||
* posture as `requireInternalWorker`.
|
||||
*
|
||||
* @throws {HttpError} `401 NOT_AUTHENTICATED` for any failure — missing
|
||||
* cookie, malformed/expired JWT, unknown admin, stale tokenVersion, or
|
||||
* no secret configured. Never distinguishes the reason.
|
||||
*/
|
||||
export async function requireAdmin(
|
||||
req: Request,
|
||||
res: Response<unknown, AdminLocals>,
|
||||
next: NextFunction,
|
||||
) {
|
||||
try {
|
||||
const token = req.cookies?.[env.ADMIN_COOKIE_NAME];
|
||||
if (typeof token !== "string") {
|
||||
throw new HttpError(401, ErrorCode.NOT_AUTHENTICATED, "Not authenticated");
|
||||
}
|
||||
|
||||
const payload = verifyAdminToken(token);
|
||||
const admin = await prisma.adminUser.findUnique({ where: { id: payload.adminUserId } });
|
||||
|
||||
if (!admin || admin.tokenVersion !== payload.tokenVersion) {
|
||||
throw new HttpError(401, ErrorCode.NOT_AUTHENTICATED, "Not authenticated");
|
||||
}
|
||||
|
||||
res.locals.adminUser = toSafeAdmin(admin);
|
||||
next();
|
||||
} catch (err) {
|
||||
if (err instanceof HttpError) {
|
||||
next(err);
|
||||
} else {
|
||||
// Covers jwt.verify failures and the unset-secret throw from verifyAdminToken.
|
||||
next(new HttpError(401, ErrorCode.NOT_AUTHENTICATED, "Not authenticated"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,51 +0,0 @@
|
|||
import { wrapAsyncHandler } from "@batch-cooking/express-tools";
|
||||
import { adminLoginSchema } from "@batch-cooking/shared";
|
||||
import { type CookieOptions, type Response, Router } from "express";
|
||||
import { env } from "../../config/env.js";
|
||||
import { type AdminLocals, requireAdmin } from "../../middlewares/require-admin.js";
|
||||
import { adminLogin } from "./admin-auth.service.js";
|
||||
|
||||
/** Router mounted at `/admin/auth` (via `admin.routes.ts`) — admin login, logout, current-admin. No signup: admins are created out-of-band (`src/scripts/create-admin.ts`). */
|
||||
export const adminAuthRouter = Router();
|
||||
|
||||
const SEVEN_DAYS_MS = 7 * 24 * 60 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* Cookie options for the admin session — same shape as `auth.routes.ts`'s
|
||||
* end-user cookie (httpOnly, `Secure` in production unless `COOKIE_SECURE`
|
||||
* overrides, `SameSite=Lax`), just written under {@link env.ADMIN_COOKIE_NAME}
|
||||
* so the two sessions never collide in one browser.
|
||||
*/
|
||||
const adminCookieOptions: CookieOptions = {
|
||||
httpOnly: true,
|
||||
secure: env.COOKIE_SECURE ?? env.NODE_ENV === "production",
|
||||
sameSite: "lax",
|
||||
maxAge: SEVEN_DAYS_MS,
|
||||
};
|
||||
|
||||
// `res.clearCookie` sets its own expiry — passing `maxAge` alongside is
|
||||
// deprecated as of Express 4.20, so the logout route reuses the options
|
||||
// minus that one field (same trick as `auth.routes.ts`).
|
||||
const { maxAge: _maxAge, ...clearAdminCookieOptions } = adminCookieOptions;
|
||||
|
||||
/** Verifies admin credentials and starts an admin session. */
|
||||
adminAuthRouter.post(
|
||||
"/login",
|
||||
wrapAsyncHandler(async (req, res) => {
|
||||
const input = adminLoginSchema.parse(req.body);
|
||||
const { admin, token } = await adminLogin(input);
|
||||
res.cookie(env.ADMIN_COOKIE_NAME, token, adminCookieOptions);
|
||||
res.status(200).json(admin);
|
||||
}),
|
||||
);
|
||||
|
||||
/** Ends the admin session by clearing the cookie. Stateless JWT — nothing to revoke server-side beyond bumping `tokenVersion` (no UI for that yet). */
|
||||
adminAuthRouter.post("/logout", (_req, res) => {
|
||||
res.clearCookie(env.ADMIN_COOKIE_NAME, clearAdminCookieOptions);
|
||||
res.status(204).end();
|
||||
});
|
||||
|
||||
/** Returns the currently authenticated admin. Behind `requireAdmin` — 401s if there's no valid admin session. */
|
||||
adminAuthRouter.get("/me", requireAdmin, (_req, res: Response<unknown, AdminLocals>) => {
|
||||
res.status(200).json(res.locals.adminUser);
|
||||
});
|
||||
|
|
@ -1,59 +0,0 @@
|
|||
import { HttpError } from "@batch-cooking/error-tools";
|
||||
import { type AdminLoginInput, type AdminUserView, ErrorCode } from "@batch-cooking/shared";
|
||||
import argon2 from "argon2";
|
||||
import { env } from "../../config/env.js";
|
||||
import { prisma } from "../../db/prisma.js";
|
||||
import { signAdminToken } from "../../lib/admin-jwt.js";
|
||||
import { toSafeAdmin } from "../../lib/safe-admin.js";
|
||||
|
||||
/** Result of a successful admin login: the safe admin view plus the signed admin session JWT to set as a cookie. */
|
||||
interface AdminAuthResult {
|
||||
admin: AdminUserView;
|
||||
token: string;
|
||||
}
|
||||
|
||||
// Same reasoning as `auth.service.ts`'s `hashOptions`: argon2's real
|
||||
// defaults are deliberately expensive; the test suite hashes/verifies
|
||||
// against throwaway data many times per run, so a cheaper cost keeps it
|
||||
// fast without weakening anything real. Never applies outside NODE_ENV=test.
|
||||
const testHashOptions = { memoryCost: 8192, timeCost: 2, parallelism: 1 };
|
||||
const hashOptions = env.NODE_ENV === "test" ? testHashOptions : undefined;
|
||||
|
||||
/** Exposed so `src/scripts/create-admin.ts` hashes exactly the same way `login` verifies. */
|
||||
export function hashAdminPassword(password: string): Promise<string> {
|
||||
return argon2.hash(password, hashOptions);
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifies an admin operator's credentials, stamps `lastLoginAt`, and
|
||||
* issues a fresh admin session token.
|
||||
*
|
||||
* @throws {HttpError} `401 INVALID_CREDENTIALS` for either an unknown email
|
||||
* or a wrong password — deliberately indistinguishable, same reasoning as
|
||||
* `auth.service.ts`'s `login`.
|
||||
*/
|
||||
export async function adminLogin(input: AdminLoginInput): Promise<AdminAuthResult> {
|
||||
try {
|
||||
const admin = await prisma.adminUser.findUnique({ where: { email: input.email } });
|
||||
|
||||
if (!admin || !(await argon2.verify(admin.passwordHash, input.password))) {
|
||||
throw new HttpError(401, ErrorCode.INVALID_CREDENTIALS, "Invalid email or password");
|
||||
}
|
||||
|
||||
const updated = await prisma.adminUser.update({
|
||||
where: { id: admin.id },
|
||||
data: { lastLoginAt: new Date() },
|
||||
});
|
||||
|
||||
const token = signAdminToken({
|
||||
adminUserId: updated.id,
|
||||
tokenVersion: updated.tokenVersion,
|
||||
});
|
||||
return { admin: toSafeAdmin(updated), token };
|
||||
} catch (err) {
|
||||
// Rethrown as-is — `wrapAsyncHandler`/the error middleware handles it,
|
||||
// this service layer just isn't allowed a bare `await` per the repo's
|
||||
// async/try-catch convention.
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,13 +0,0 @@
|
|||
import { Router } from "express";
|
||||
import { adminAuthRouter } from "./admin-auth.routes.js";
|
||||
|
||||
/**
|
||||
* Aggregator for the admin application's API surface, mounted at `/admin`
|
||||
* in `app.ts`. Every sub-router here is for `apps/admin-web` only —
|
||||
* `/admin/auth` is public (login), everything added later
|
||||
* (`/admin/metrics`, `/admin/monitoring`, `/admin/tech-steps/*`) sits
|
||||
* behind `requireAdmin` (`middlewares/require-admin.ts`).
|
||||
*/
|
||||
export const adminRouter = Router();
|
||||
|
||||
adminRouter.use("/auth", adminAuthRouter);
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
import { parseDateOnly } from "@batch-cooking/date-tools";
|
||||
import { HttpError } from "@batch-cooking/error-tools";
|
||||
import { wrapAsyncHandler } from "@batch-cooking/express-tools";
|
||||
import { ErrorCode, getCookingSessionSchema } from "@batch-cooking/shared";
|
||||
import { Router } from "express";
|
||||
import { type AuthLocals, requireAuth } from "../../middlewares/require-auth.js";
|
||||
import { getCookingPlanForDate } from "./cooking-session.service.js";
|
||||
|
||||
/** Router mounted at `/cooking-session` in app.ts. */
|
||||
export const cookingSessionRouter = Router();
|
||||
|
||||
/**
|
||||
* Returns the authenticated user's household's optimized cooking plan for
|
||||
* the week covering `?date=` (`YYYY-MM-DD`) — every recipe planned that
|
||||
* week reorganized into ordered phases (see {@link getCookingPlanForDate}).
|
||||
* Always `200`, never `null` — no household or nothing planned that week
|
||||
* both come back as a normal `OptimizedCookingPlanView` with empty
|
||||
* `recipes`/`phases`. Same request contract as `GET /shopping-list`.
|
||||
*/
|
||||
cookingSessionRouter.get(
|
||||
"/",
|
||||
requireAuth,
|
||||
wrapAsyncHandler<unknown, AuthLocals>(async (req, res) => {
|
||||
const input = getCookingSessionSchema.parse(req.query);
|
||||
const date = parseDateOnly(input.date);
|
||||
if (date === null) {
|
||||
throw new HttpError(
|
||||
400,
|
||||
ErrorCode.VALIDATION_ERROR,
|
||||
`Not a real calendar date: ${input.date}`,
|
||||
);
|
||||
}
|
||||
|
||||
const plan = await getCookingPlanForDate(res.locals.userProfile.houseId, date);
|
||||
res.status(200).json(plan);
|
||||
}),
|
||||
);
|
||||
168
apps/api/src/modules/cooking-session/cooking-session.service.ts
Normal file
168
apps/api/src/modules/cooking-session/cooking-session.service.ts
Normal file
|
|
@ -0,0 +1,168 @@
|
|||
import { type DateTime, getWeekStart, toDateOnly } from "@batch-cooking/date-tools";
|
||||
import type { CookingTaskIngredientView, OptimizedCookingPlanView } from "@batch-cooking/shared";
|
||||
import type { Prisma } from "@prisma/client";
|
||||
import { prisma } from "../../db/prisma.js";
|
||||
import {
|
||||
type OptimizerRecipeInput,
|
||||
type OptimizerStepInput,
|
||||
optimizeCookingPlan,
|
||||
} from "../../lib/recipe-matching/cooking-optimizer.js";
|
||||
import { toIngredientView, toUnitView } from "../recipe/recipe.service.js";
|
||||
|
||||
/**
|
||||
* Prisma `include` for a `Planning` query that needs, for every item, its
|
||||
* recipe's ordered steps with the full detected-technique tree — the raw
|
||||
* material the optimizer works on (see `cooking-optimizer.ts`). It's the
|
||||
* `steps` sub-tree of `recipe.service.ts`'s own `recipeInclude`, resolved
|
||||
* the same way so {@link toIngredientView}/{@link toUnitView} can be reused
|
||||
* as-is; deliberately narrower than a full `RecipeView` fetch (no
|
||||
* diets/favorites/recipe-level ingredient list — the optimizer reads
|
||||
* quantities off the technique clauses, not the recipe header).
|
||||
*/
|
||||
function cookingSessionPlanningInclude() {
|
||||
return {
|
||||
items: {
|
||||
include: {
|
||||
recipe: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
portions: true,
|
||||
steps: {
|
||||
orderBy: { order: "asc" },
|
||||
include: {
|
||||
techSteps: {
|
||||
orderBy: { order: "asc" },
|
||||
include: {
|
||||
techStep: true,
|
||||
ingredients: {
|
||||
include: {
|
||||
ingredient: {
|
||||
include: {
|
||||
allergies: { include: { allergy: { include: { category: true } } } },
|
||||
diets: { include: { diet: true } },
|
||||
},
|
||||
},
|
||||
unit: true,
|
||||
},
|
||||
},
|
||||
utensils: { include: { utensil: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} satisfies Prisma.PlanningInclude;
|
||||
}
|
||||
|
||||
type PlanningWithSteps = Prisma.PlanningGetPayload<{
|
||||
include: ReturnType<typeof cookingSessionPlanningInclude>;
|
||||
}>;
|
||||
type PlanningItemWithSteps = PlanningWithSteps["items"][number];
|
||||
|
||||
/**
|
||||
* Maps one planning item's recipe (with {@link cookingSessionPlanningInclude})
|
||||
* to the optimizer's pure input shape — ingredient/unit/technique/utensil
|
||||
* rows resolved to their reference views here so the optimizer itself never
|
||||
* touches Prisma. `Decimal` quantities become plain numbers (same
|
||||
* `Number(...)` conversion as `recipe.service.ts`'s own view mappers); an
|
||||
* unresolved-unit line keeps `unit: null`.
|
||||
*/
|
||||
function toOptimizerRecipe(item: PlanningItemWithSteps): OptimizerRecipeInput {
|
||||
const steps: OptimizerStepInput[] = item.recipe.steps.map((step) => ({
|
||||
stepId: step.id,
|
||||
order: step.order,
|
||||
description: step.description,
|
||||
techSteps: step.techSteps.map((techStep) => {
|
||||
const ingredients: CookingTaskIngredientView[] = techStep.ingredients.map((line) => ({
|
||||
ingredient: toIngredientView(line.ingredient),
|
||||
quantity: line.quantity === null ? null : Number(line.quantity),
|
||||
unit: line.unit === null ? null : toUnitView(line.unit),
|
||||
}));
|
||||
return {
|
||||
techStep: { id: techStep.techStep.id, key: techStep.techStep.key },
|
||||
order: techStep.order,
|
||||
ingredients,
|
||||
utensils: techStep.utensils.map(({ utensil }) => ({ id: utensil.id, key: utensil.key })),
|
||||
};
|
||||
}),
|
||||
}));
|
||||
|
||||
return {
|
||||
recipeId: item.recipe.id,
|
||||
name: item.recipe.name,
|
||||
// The slot's own portion count vs. the recipe's as-written yield — the
|
||||
// optimizer scales technique-clause quantities by the ratio, same
|
||||
// reasoning as `shopping-list.service.ts`'s `aggregateShoppingList`.
|
||||
portions: item.portions,
|
||||
recipePortions: item.recipe.portions,
|
||||
steps,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the household's optimized cooking plan for the week covering
|
||||
* `date` — every recipe planned that week, reorganized into ordered phases
|
||||
* that pool shared prep and float passive cooks into the background (see
|
||||
* `cooking-optimizer.ts`). `date` follows the same convention as
|
||||
* `planning.service.ts`'s `getPlanningForDate` (a caller-parsed `?date=`,
|
||||
* not necessarily a Monday).
|
||||
*
|
||||
* Like `getShoppingListForDate` and unlike `getPlanningForDate`, this
|
||||
* **never** returns `null` — no household and "no planning covers this week
|
||||
* yet" both degrade to an empty `phases`/`recipes` on an otherwise normal
|
||||
* {@link OptimizedCookingPlanView} (the week's date range is always
|
||||
* computable from `date` alone).
|
||||
*/
|
||||
export async function getCookingPlanForDate(
|
||||
houseId: number | null,
|
||||
date: DateTime,
|
||||
): Promise<OptimizedCookingPlanView> {
|
||||
try {
|
||||
const weekStart = getWeekStart(toDateOnly(date));
|
||||
const weekFinish = weekStart.plus({ days: 6 });
|
||||
const emptyPlan: OptimizedCookingPlanView = {
|
||||
startDate: weekStart.toJSDate().toISOString(),
|
||||
finishDate: weekFinish.toJSDate().toISOString(),
|
||||
recipes: [],
|
||||
phases: [],
|
||||
};
|
||||
|
||||
if (houseId === null) {
|
||||
return emptyPlan;
|
||||
}
|
||||
|
||||
// Same "covering range" lookup as getShoppingListForDate — see
|
||||
// getPlanningForDate's doc comment for the UTC-midnight `Date` rationale.
|
||||
const dateOnly = toDateOnly(date).toJSDate();
|
||||
const planning = await prisma.planning.findFirst({
|
||||
where: {
|
||||
houseId,
|
||||
startDate: { lte: dateOnly },
|
||||
finishDate: { gte: dateOnly },
|
||||
},
|
||||
orderBy: { startDate: "desc" },
|
||||
include: cookingSessionPlanningInclude(),
|
||||
});
|
||||
|
||||
if (!planning) {
|
||||
return emptyPlan;
|
||||
}
|
||||
|
||||
const { recipes, phases } = optimizeCookingPlan(planning.items.map(toOptimizerRecipe));
|
||||
return {
|
||||
startDate: planning.startDate.toISOString(),
|
||||
finishDate: planning.finishDate.toISOString(),
|
||||
recipes,
|
||||
phases,
|
||||
};
|
||||
} catch (err) {
|
||||
// Rethrown as-is — `wrapAsyncHandler`/the error middleware handles it,
|
||||
// this service layer just isn't allowed a bare `await` per the repo's
|
||||
// async/try-catch convention.
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,56 +0,0 @@
|
|||
import { env } from "../config/env.js";
|
||||
import { prisma } from "../db/prisma.js";
|
||||
import { hashAdminPassword } from "../modules/admin/admin-auth.service.js";
|
||||
|
||||
/**
|
||||
* Creates the first (or an additional) `AdminUser` for the admin
|
||||
* application — there is no self-service admin signup, on purpose (see the
|
||||
* `AdminUser` model doc comment in schema.prisma).
|
||||
*
|
||||
* pnpm --filter api exec tsx src/scripts/create-admin.ts \
|
||||
* --email=ops@example.com --password='...' --name='Ops'
|
||||
*
|
||||
* Each flag falls back to the matching `ADMIN_INITIAL_*` env var when
|
||||
* omitted, so a deployment can bake the first admin's credentials into its
|
||||
* environment and run this once from the container without passing args.
|
||||
* Refuses (exit 1) if an `AdminUser` with that email already exists —
|
||||
* changing an existing admin's password is a manual DB operation for now,
|
||||
* not something this script does.
|
||||
*/
|
||||
function flag(name: string): string | undefined {
|
||||
const prefix = `--${name}=`;
|
||||
const arg = process.argv.find((value) => value.startsWith(prefix));
|
||||
return arg === undefined ? undefined : arg.slice(prefix.length);
|
||||
}
|
||||
|
||||
async function createAdmin(): Promise<void> {
|
||||
const email = (flag("email") ?? env.ADMIN_INITIAL_EMAIL)?.trim().toLowerCase();
|
||||
const password = flag("password") ?? env.ADMIN_INITIAL_PASSWORD;
|
||||
const name = (flag("name") ?? env.ADMIN_INITIAL_NAME)?.trim();
|
||||
|
||||
if (!email || !password || !name) {
|
||||
throw new Error(
|
||||
"Missing required input. Provide --email, --password and --name (or set ADMIN_INITIAL_EMAIL / ADMIN_INITIAL_PASSWORD / ADMIN_INITIAL_NAME).",
|
||||
);
|
||||
}
|
||||
if (password.length < 8) {
|
||||
throw new Error("Password must be at least 8 characters.");
|
||||
}
|
||||
|
||||
const existing = await prisma.adminUser.findUnique({ where: { email } });
|
||||
if (existing) {
|
||||
throw new Error(`An admin with email "${email}" already exists (id ${existing.id}).`);
|
||||
}
|
||||
|
||||
const passwordHash = await hashAdminPassword(password);
|
||||
const admin = await prisma.adminUser.create({ data: { email, name, passwordHash } });
|
||||
console.info(`Created admin #${admin.id} <${admin.email}> ("${admin.name}").`);
|
||||
}
|
||||
|
||||
createAdmin()
|
||||
.then(() => prisma.$disconnect())
|
||||
.catch(async (err) => {
|
||||
console.error(err instanceof Error ? err.message : err);
|
||||
await prisma.$disconnect();
|
||||
process.exit(1);
|
||||
});
|
||||
|
|
@ -47,8 +47,7 @@ export async function resetDatabase() {
|
|||
"planning_item", "planning",
|
||||
"recipe_ingredient", "step_tech_step", "step", "tech_step",
|
||||
"recipe", "ingredients", "sources", "unit",
|
||||
"user_profiles", "diet", "house",
|
||||
"admin_users"
|
||||
"user_profiles", "diet", "house"
|
||||
RESTART IDENTITY CASCADE;
|
||||
`);
|
||||
await seedReferenceData(prisma);
|
||||
|
|
|
|||
|
|
@ -1,149 +0,0 @@
|
|||
import { ErrorCode, type SignupInput } from "@batch-cooking/shared";
|
||||
import { faker } from "@faker-js/faker";
|
||||
import { expect } from "chai";
|
||||
import request from "supertest";
|
||||
import { createApp } from "../src/app.js";
|
||||
import { env } from "../src/config/env.js";
|
||||
import { prisma } from "../src/db/prisma.js";
|
||||
import { hashAdminPassword } from "../src/modules/admin/admin-auth.service.js";
|
||||
import { resetDatabase } from "../test-support/reset-db.js";
|
||||
|
||||
/** See `auth.test.ts` — same rationale for generating rather than hardcoding. */
|
||||
function buildSignupPayload(): SignupInput {
|
||||
const firstName = faker.person.firstName();
|
||||
const lastName = faker.person.lastName();
|
||||
return {
|
||||
firstName,
|
||||
lastName,
|
||||
email: faker.internet.email({ firstName, lastName }).toLowerCase(),
|
||||
password: faker.internet.password({ length: 16 }),
|
||||
};
|
||||
}
|
||||
|
||||
/** Inserts an `AdminUser` straight into the DB (no signup route exists) and returns its plaintext password. */
|
||||
async function seedAdmin(): Promise<{ email: string; password: string }> {
|
||||
const email = faker.internet.email().toLowerCase();
|
||||
const password = faker.internet.password({ length: 16 });
|
||||
await prisma.adminUser.create({
|
||||
data: { email, name: faker.person.fullName(), passwordHash: await hashAdminPassword(password) },
|
||||
});
|
||||
return { email, password };
|
||||
}
|
||||
|
||||
/** The admin login/verify path signs a JWT — self-skip those cases when no `ADMIN_JWT_SECRET` is configured (same posture as `tech-step-worker.routes.test.ts` with `INTERNAL_WORKER_SECRET`). */
|
||||
const adminSecretConfigured = env.ADMIN_JWT_SECRET !== undefined;
|
||||
|
||||
describe("Admin auth", () => {
|
||||
const app = createApp();
|
||||
|
||||
beforeEach(async () => {
|
||||
await resetDatabase();
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
await prisma.$disconnect();
|
||||
});
|
||||
|
||||
describe("POST /admin/auth/login", () => {
|
||||
it("rejects a missing body with 400 VALIDATION_ERROR", async () => {
|
||||
const res = await request(app).post("/admin/auth/login").send({});
|
||||
expect(res.status).to.equal(400);
|
||||
expect(res.body.code).to.equal(ErrorCode.VALIDATION_ERROR);
|
||||
});
|
||||
|
||||
it("rejects an unknown email with 401 INVALID_CREDENTIALS", async () => {
|
||||
const res = await request(app)
|
||||
.post("/admin/auth/login")
|
||||
.send({ email: "nobody@example.com", password: "whatever" });
|
||||
expect(res.status).to.equal(401);
|
||||
expect(res.body.code).to.equal(ErrorCode.INVALID_CREDENTIALS);
|
||||
});
|
||||
|
||||
it("rejects a wrong password with 401 INVALID_CREDENTIALS", async () => {
|
||||
const { email } = await seedAdmin();
|
||||
const res = await request(app)
|
||||
.post("/admin/auth/login")
|
||||
.send({ email, password: "not-the-password" });
|
||||
expect(res.status).to.equal(401);
|
||||
expect(res.body.code).to.equal(ErrorCode.INVALID_CREDENTIALS);
|
||||
});
|
||||
|
||||
it("logs in with correct credentials, sets the admin cookie, stamps lastLoginAt, never leaks the hash", async function () {
|
||||
if (!adminSecretConfigured) {
|
||||
// biome-ignore lint/suspicious/noExplicitAny: mocha's `this.skip()` isn't typed without @types/mocha (not a dependency here).
|
||||
(this as any).skip();
|
||||
return;
|
||||
}
|
||||
const { email, password } = await seedAdmin();
|
||||
|
||||
const res = await request(app).post("/admin/auth/login").send({ email, password });
|
||||
|
||||
expect(res.status).to.equal(200);
|
||||
expect(res.body.email).to.equal(email);
|
||||
expect(res.body).to.not.have.property("passwordHash");
|
||||
expect(res.body).to.not.have.property("tokenVersion");
|
||||
expect(res.body.lastLoginAt).to.be.a("string");
|
||||
|
||||
const setCookie = res.headers["set-cookie"];
|
||||
expect(Array.isArray(setCookie) ? setCookie.join(";") : String(setCookie)).to.include(
|
||||
env.ADMIN_COOKIE_NAME,
|
||||
);
|
||||
|
||||
const stored = await prisma.adminUser.findUniqueOrThrow({ where: { email } });
|
||||
expect(stored.lastLoginAt).to.not.equal(null);
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /admin/auth/me", () => {
|
||||
it("rejects a request with no admin cookie with 401 NOT_AUTHENTICATED", async () => {
|
||||
const res = await request(app).get("/admin/auth/me");
|
||||
expect(res.status).to.equal(401);
|
||||
expect(res.body.code).to.equal(ErrorCode.NOT_AUTHENTICATED);
|
||||
});
|
||||
|
||||
it("returns the admin behind a valid admin session", async function () {
|
||||
if (!adminSecretConfigured) {
|
||||
// biome-ignore lint/suspicious/noExplicitAny: see above.
|
||||
(this as any).skip();
|
||||
return;
|
||||
}
|
||||
const { email, password } = await seedAdmin();
|
||||
const agent = request.agent(app);
|
||||
await agent.post("/admin/auth/login").send({ email, password });
|
||||
|
||||
const res = await agent.get("/admin/auth/me");
|
||||
expect(res.status).to.equal(200);
|
||||
expect(res.body.email).to.equal(email);
|
||||
});
|
||||
|
||||
it("stops returning the admin after logout", async function () {
|
||||
if (!adminSecretConfigured) {
|
||||
// biome-ignore lint/suspicious/noExplicitAny: see above.
|
||||
(this as any).skip();
|
||||
return;
|
||||
}
|
||||
const { email, password } = await seedAdmin();
|
||||
const agent = request.agent(app);
|
||||
await agent.post("/admin/auth/login").send({ email, password });
|
||||
|
||||
const logoutRes = await agent.post("/admin/auth/logout");
|
||||
expect(logoutRes.status).to.equal(204);
|
||||
|
||||
const meRes = await agent.get("/admin/auth/me");
|
||||
expect(meRes.status).to.equal(401);
|
||||
});
|
||||
|
||||
it("does not accept an end-user session cookie as an admin session", async () => {
|
||||
// An ordinary user logs in (sets the `session` cookie), then tries the
|
||||
// admin surface with that same agent — `requireAdmin` reads a
|
||||
// different cookie entirely, so this must 401 regardless of whether
|
||||
// ADMIN_JWT_SECRET is configured.
|
||||
const agent = request.agent(app);
|
||||
await agent.post("/auth/signup").send(buildSignupPayload());
|
||||
|
||||
const res = await agent.get("/admin/auth/me");
|
||||
expect(res.status).to.equal(401);
|
||||
expect(res.body.code).to.equal(ErrorCode.NOT_AUTHENTICATED);
|
||||
});
|
||||
});
|
||||
});
|
||||
204
apps/api/test/cooking-session.test.ts
Normal file
204
apps/api/test/cooking-session.test.ts
Normal file
|
|
@ -0,0 +1,204 @@
|
|||
import type { DateTime } from "@batch-cooking/date-tools";
|
||||
import { ErrorCode, type SignupInput } from "@batch-cooking/shared";
|
||||
import { faker } from "@faker-js/faker";
|
||||
import { expect } from "chai";
|
||||
import request from "supertest";
|
||||
import { createApp } from "../src/app.js";
|
||||
import { prisma } from "../src/db/prisma.js";
|
||||
import { TEST_REFERENCE_DATE } from "../test-support/reference-date.js";
|
||||
import { resetDatabase } from "../test-support/reset-db.js";
|
||||
|
||||
/** See `auth.test.ts` — same rationale for generating rather than hardcoding. */
|
||||
function buildSignupPayload(): SignupInput {
|
||||
const firstName = faker.person.firstName();
|
||||
const lastName = faker.person.lastName();
|
||||
return {
|
||||
firstName,
|
||||
lastName,
|
||||
email: faker.internet.email({ firstName, lastName }).toLowerCase(),
|
||||
password: faker.internet.password({ length: 16 }),
|
||||
};
|
||||
}
|
||||
|
||||
/** `toISODate()` only returns `null` for an invalid `DateTime` — never the always-valid values here. */
|
||||
function isoDate(date: DateTime): string {
|
||||
const iso = date.toISODate();
|
||||
if (iso === null) throw new Error("Unexpectedly invalid DateTime in a test helper");
|
||||
return iso;
|
||||
}
|
||||
|
||||
/** The fixed test "today", as the `YYYY-MM-DD` string the `?date=` query expects. */
|
||||
function today(): string {
|
||||
return isoDate(TEST_REFERENCE_DATE);
|
||||
}
|
||||
|
||||
/** Resolves a reference row's id by its `reference-seed-data.ts` uid (also its DB `key`) — same helpers as `recipe.test.ts`. */
|
||||
async function ingredientId(key: string): Promise<number> {
|
||||
return (await prisma.ingredient.findFirstOrThrow({ where: { key } })).id;
|
||||
}
|
||||
async function unitId(key: string): Promise<number> {
|
||||
return (await prisma.unit.findFirstOrThrow({ where: { key } })).id;
|
||||
}
|
||||
async function techStepId(key: string): Promise<number> {
|
||||
return (await prisma.techStep.findFirstOrThrow({ where: { key } })).id;
|
||||
}
|
||||
|
||||
describe("Cooking session", () => {
|
||||
const app = createApp();
|
||||
|
||||
beforeEach(async () => {
|
||||
await resetDatabase();
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
await prisma.$disconnect();
|
||||
});
|
||||
|
||||
describe("GET /cooking-session", () => {
|
||||
it("rejects requests without a session cookie with 401 NOT_AUTHENTICATED", async () => {
|
||||
const res = await request(app).get("/cooking-session").query({ date: today() });
|
||||
|
||||
expect(res.status).to.equal(401);
|
||||
expect(res.body.code).to.equal(ErrorCode.NOT_AUTHENTICATED);
|
||||
});
|
||||
|
||||
it("rejects a malformed date with 400 VALIDATION_ERROR", async () => {
|
||||
const agent = request.agent(app);
|
||||
await agent.post("/auth/signup").send(buildSignupPayload());
|
||||
|
||||
const res = await agent.get("/cooking-session").query({ date: "not-a-date" });
|
||||
|
||||
expect(res.status).to.equal(400);
|
||||
expect(res.body.code).to.equal(ErrorCode.VALIDATION_ERROR);
|
||||
});
|
||||
|
||||
it("returns an empty plan when the profile has no household", async () => {
|
||||
const agent = request.agent(app);
|
||||
await agent.post("/auth/signup").send(buildSignupPayload());
|
||||
|
||||
const res = await agent.get("/cooking-session").query({ date: today() });
|
||||
|
||||
expect(res.status).to.equal(200);
|
||||
expect(res.body.recipes).to.deep.equal([]);
|
||||
expect(res.body.phases).to.deep.equal([]);
|
||||
});
|
||||
|
||||
it("returns an empty plan when no planning covers that week", async () => {
|
||||
const agent = request.agent(app);
|
||||
await agent.post("/auth/signup").send(buildSignupPayload());
|
||||
await agent.post("/house").send({ name: "Chez moi" });
|
||||
|
||||
const res = await agent.get("/cooking-session").query({ date: today() });
|
||||
|
||||
expect(res.status).to.equal(200);
|
||||
expect(res.body.phases).to.deep.equal([]);
|
||||
});
|
||||
|
||||
it("pools an identical prep step from two planned recipes into one merged-prep task", async () => {
|
||||
const agent = request.agent(app);
|
||||
await agent.post("/auth/signup").send(buildSignupPayload());
|
||||
const houseRes = await agent.post("/house").send({ name: "Chez moi" });
|
||||
const houseId: number = houseRes.body.id;
|
||||
const authorId: number = houseRes.body.adminId;
|
||||
|
||||
const onionId = await ingredientId("onion");
|
||||
const pieceId = await unitId("piece");
|
||||
const chopId = await techStepId("chop");
|
||||
const simmerId = await techStepId("simmer");
|
||||
|
||||
/** A recipe: one pure-prep "chop onion" step, then one simmer step. */
|
||||
async function makeRecipe(name: string, onionQty: number) {
|
||||
return prisma.recipe.create({
|
||||
data: {
|
||||
name,
|
||||
authorId,
|
||||
portions: 4,
|
||||
steps: {
|
||||
create: [
|
||||
{
|
||||
order: 0,
|
||||
description: "Émincer les oignons",
|
||||
techSteps: {
|
||||
create: [
|
||||
{
|
||||
techStepId: chopId,
|
||||
order: 0,
|
||||
ingredients: {
|
||||
create: [
|
||||
{
|
||||
ingredientId: onionId,
|
||||
quantity: onionQty,
|
||||
unitId: pieceId,
|
||||
start: 0,
|
||||
end: 1,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
order: 1,
|
||||
description: "Faire mijoter",
|
||||
techSteps: { create: [{ techStepId: simmerId, order: 0 }] },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const soupe = await makeRecipe("Soupe", 2);
|
||||
const tarte = await makeRecipe("Tarte", 3);
|
||||
|
||||
const planning = await prisma.planning.create({
|
||||
data: {
|
||||
houseId,
|
||||
startDate: TEST_REFERENCE_DATE.minus({ days: 2 }).toJSDate(),
|
||||
finishDate: TEST_REFERENCE_DATE.plus({ days: 2 }).toJSDate(),
|
||||
},
|
||||
});
|
||||
await prisma.planningItem.createMany({
|
||||
data: [
|
||||
{
|
||||
planningId: planning.id,
|
||||
weekDay: "lundi",
|
||||
meal: "dejeuner",
|
||||
recipeId: soupe.id,
|
||||
portions: 4,
|
||||
},
|
||||
{
|
||||
planningId: planning.id,
|
||||
weekDay: "mardi",
|
||||
meal: "diner",
|
||||
recipeId: tarte.id,
|
||||
portions: 4,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const res = await agent.get("/cooking-session").query({ date: today() });
|
||||
|
||||
expect(res.status).to.equal(200);
|
||||
expect(res.body.recipes.map((r: { name: string }) => r.name)).to.have.members([
|
||||
"Soupe",
|
||||
"Tarte",
|
||||
]);
|
||||
|
||||
const mise = res.body.phases[0];
|
||||
expect(mise.kind).to.equal("mise-en-place");
|
||||
const merged = mise.tasks.filter((t: { kind: string }) => t.kind === "merged-prep");
|
||||
expect(merged).to.have.length(1);
|
||||
expect(merged[0].technique.key).to.equal("chop");
|
||||
expect(merged[0].ingredients[0].ingredient.key).to.equal("onion");
|
||||
expect(merged[0].ingredients[0].quantity).to.equal(5);
|
||||
expect(merged[0].sourceRecipes).to.have.length(2);
|
||||
|
||||
// The simmer steps land in a later phase, and one shows as background.
|
||||
const later = res.body.phases.slice(1);
|
||||
const backgrounds = later.flatMap((p: { background: unknown[] }) => p.background);
|
||||
expect(backgrounds.length).to.be.greaterThan(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
1024
apps/api/test/recipe-matching/cooking-optimizer.test.ts
Normal file
1024
apps/api/test/recipe-matching/cooking-optimizer.test.ts
Normal file
File diff suppressed because it is too large
Load diff
174
apps/web/cypress/e2e/cooking-session-page.cy.ts
Normal file
174
apps/web/cypress/e2e/cooking-session-page.cy.ts
Normal file
|
|
@ -0,0 +1,174 @@
|
|||
// Mocks the API via cy.intercept — this job doesn't run a live backend (see
|
||||
// .github/workflows/ci.yml); apps/api's own Mocha suite covers real
|
||||
// `GET /cooking-session` behavior (including the optimizer) against a real
|
||||
// database.
|
||||
|
||||
const authenticatedProfile = {
|
||||
id: 1,
|
||||
firstName: "Alice",
|
||||
lastName: "Martin",
|
||||
email: "alice@example.com",
|
||||
tokenVersion: 0,
|
||||
houseId: 1,
|
||||
dietId: null,
|
||||
};
|
||||
|
||||
// 2026-08-17 is a Monday — frozen so "this week" is deterministic.
|
||||
const TODAY = new Date("2026-08-17T09:00:00Z");
|
||||
|
||||
/** Bare `IngredientView` — only `key` drives the page's label lookup. */
|
||||
function ingredient(key: string) {
|
||||
return {
|
||||
id: 1,
|
||||
key,
|
||||
icon: "VEGETABLE",
|
||||
category: "freshProduce",
|
||||
subcategory: "vegetables",
|
||||
reproducible: false,
|
||||
allergens: [],
|
||||
diets: [],
|
||||
};
|
||||
}
|
||||
|
||||
/** A plan with a pooled prep task in mise-en-place and a passive cook floated into a later phase. */
|
||||
function planFixture() {
|
||||
return {
|
||||
startDate: "2026-08-17T00:00:00.000Z",
|
||||
finishDate: "2026-08-23T00:00:00.000Z",
|
||||
recipes: [
|
||||
{ recipeId: 1, name: "Soupe à l'oignon", portions: 4 },
|
||||
{ recipeId: 2, name: "Tarte à l'oignon", portions: 4 },
|
||||
],
|
||||
phases: [
|
||||
{
|
||||
index: 0,
|
||||
kind: "mise-en-place",
|
||||
background: [],
|
||||
tasks: [
|
||||
{
|
||||
id: "prep:chop:onion",
|
||||
kind: "merged-prep",
|
||||
technique: { id: 1, key: "chop" },
|
||||
description: null,
|
||||
ingredients: [
|
||||
{
|
||||
ingredient: ingredient("onion"),
|
||||
quantity: 5,
|
||||
unit: { id: 2, key: "piece", type: "COUNT", toBaseFactor: 1 },
|
||||
},
|
||||
],
|
||||
utensils: [{ id: 1, key: "knife" }],
|
||||
sourceRecipes: [
|
||||
{ recipeId: 1, name: "Soupe à l'oignon", portions: 4 },
|
||||
{ recipeId: 2, name: "Tarte à l'oignon", portions: 4 },
|
||||
],
|
||||
originalSteps: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
index: 1,
|
||||
kind: "cooking",
|
||||
background: [],
|
||||
tasks: [
|
||||
{
|
||||
id: "step:0:1",
|
||||
kind: "step",
|
||||
technique: { id: 5, key: "simmer" },
|
||||
description: "Faire mijoter le bouillon",
|
||||
ingredients: [],
|
||||
utensils: [],
|
||||
sourceRecipes: [{ recipeId: 1, name: "Soupe à l'oignon", portions: 4 }],
|
||||
originalSteps: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
index: 2,
|
||||
kind: "cooking",
|
||||
background: [
|
||||
{
|
||||
id: "bg:step:0:1",
|
||||
technique: { id: 5, key: "simmer" },
|
||||
description: "Faire mijoter le bouillon",
|
||||
recipeId: 1,
|
||||
recipeName: "Soupe à l'oignon",
|
||||
},
|
||||
],
|
||||
tasks: [
|
||||
{
|
||||
id: "step:1:3",
|
||||
kind: "step",
|
||||
technique: { id: 4, key: "bake" },
|
||||
description: "Enfourner la tarte",
|
||||
ingredients: [],
|
||||
utensils: [],
|
||||
sourceRecipes: [{ recipeId: 2, name: "Tarte à l'oignon", portions: 4 }],
|
||||
originalSteps: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
describe("Cooking session page", () => {
|
||||
beforeEach(() => {
|
||||
cy.viewport(1400, 900);
|
||||
cy.clock(TODAY, ["Date"]);
|
||||
cy.intercept("GET", "**/auth/me", { statusCode: 200, body: authenticatedProfile });
|
||||
});
|
||||
|
||||
it("shows the empty message when nothing is planned that week", () => {
|
||||
cy.intercept("GET", /\/cooking-session\?/, {
|
||||
statusCode: 200,
|
||||
body: {
|
||||
startDate: "2026-08-17T00:00:00.000Z",
|
||||
finishDate: "2026-08-23T00:00:00.000Z",
|
||||
recipes: [],
|
||||
phases: [],
|
||||
},
|
||||
});
|
||||
|
||||
cy.visit("/cuisiner");
|
||||
|
||||
cy.contains("h1", "Cuisiner cette semaine").should("be.visible");
|
||||
cy.contains("Rien de planifié cette semaine à cuisiner").should("be.visible");
|
||||
});
|
||||
|
||||
it("renders each phase, the pooled prep task, and the 'meanwhile' band", () => {
|
||||
cy.intercept("GET", /\/cooking-session\?/, { statusCode: 200, body: planFixture() }).as(
|
||||
"getPlan",
|
||||
);
|
||||
|
||||
cy.visit("/cuisiner?date=2026-08-17");
|
||||
cy.wait("@getPlan").its("request.url").should("include", "date=2026-08-17");
|
||||
|
||||
// Mise en place: one pooled prep task, flagged shared, naming both recipes.
|
||||
cy.contains(".cooking-phase", "Mise en place").should("be.visible");
|
||||
cy.get(".cooking-task--merged-prep")
|
||||
.should("contain.text", "Hacher")
|
||||
.and("contain.text", "Oignon")
|
||||
.and("contain.text", "Mutualisé");
|
||||
cy.contains(".cooking-task--merged-prep", "Soupe à l'oignon").should("exist");
|
||||
|
||||
// A later phase shows the simmering soup as still running in the background.
|
||||
cy.contains(".cooking-phase__background", "Pendant ce temps")
|
||||
.should("contain.text", "Faire mijoter le bouillon")
|
||||
.and("contain.text", "Soupe à l'oignon");
|
||||
|
||||
// Recipe legend is present.
|
||||
cy.contains(".cooking-session__legend-item", "Tarte à l'oignon").should("be.visible");
|
||||
});
|
||||
|
||||
it("shows an error state when the request fails", () => {
|
||||
cy.intercept("GET", /\/cooking-session\?/, {
|
||||
statusCode: 500,
|
||||
body: { code: 5000, message: "boom" },
|
||||
});
|
||||
|
||||
cy.visit("/cuisiner");
|
||||
|
||||
cy.contains("Impossible de charger").should("be.visible");
|
||||
});
|
||||
});
|
||||
24
apps/web/cypress/e2e/cooking-session.feature
Normal file
24
apps/web/cypress/e2e/cooking-session.feature
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
Feature: Start cooking an optimized plan
|
||||
As a member of a household with a planned week
|
||||
I want to open an optimized cooking plan from my planning
|
||||
So that shared preparation is pooled and I cook the week efficiently
|
||||
|
||||
Background:
|
||||
Given I am signed in as "Alice" "Martin"
|
||||
And my household id is 1
|
||||
And today is frozen at "2026-08-17T09:00:00.000Z"
|
||||
|
||||
Scenario: The "Commencer à cuisiner" button is disabled while the week is empty
|
||||
Given the planning request returns nothing
|
||||
When I visit "/"
|
||||
Then the "Commencer à cuisiner" button should be disabled
|
||||
|
||||
Scenario: Opening the plan from the planning shows the pooled prep in mise en place
|
||||
Given the planning for this week has recipes "Soupe à l'oignon" and "Tarte à l'oignon"
|
||||
And the cooking plan for "2026-08-17" pools "Hacher" of "Oignon" across both recipes
|
||||
When I visit "/"
|
||||
And I click the button "Commencer à cuisiner"
|
||||
Then the URL should include "/cuisiner"
|
||||
And I should see "Mise en place"
|
||||
And the pooled prep task should mention "Hacher" and "Oignon"
|
||||
And the pooled prep task should be flagged as shared
|
||||
101
apps/web/cypress/e2e/cooking-session.ts
Normal file
101
apps/web/cypress/e2e/cooking-session.ts
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
import { Given, Then } from "@badeball/cypress-cucumber-preprocessor";
|
||||
|
||||
/** Bare `IngredientView` — only `key` drives the page's `catalog.ingredients.*` lookup. */
|
||||
function ingredient(key: string) {
|
||||
return {
|
||||
id: 1,
|
||||
key,
|
||||
icon: "VEGETABLE",
|
||||
category: "freshProduce",
|
||||
subcategory: "vegetables",
|
||||
reproducible: false,
|
||||
allergens: [],
|
||||
diets: [],
|
||||
};
|
||||
}
|
||||
|
||||
Given(
|
||||
"the planning for this week has recipes {string} and {string}",
|
||||
(first: string, second: string) => {
|
||||
cy.intercept("GET", /\/planning\?/, {
|
||||
statusCode: 200,
|
||||
body: {
|
||||
id: 1,
|
||||
startDate: "2026-08-17T00:00:00.000Z",
|
||||
finishDate: "2026-08-23T00:00:00.000Z",
|
||||
items: [
|
||||
{
|
||||
id: 1,
|
||||
weekDay: "lundi",
|
||||
meal: "dejeuner",
|
||||
portions: 4,
|
||||
recipe: { id: 1, name: first },
|
||||
},
|
||||
{ id: 2, weekDay: "mardi", meal: "diner", portions: 4, recipe: { id: 2, name: second } },
|
||||
],
|
||||
},
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
Given(
|
||||
"the cooking plan for {string} pools {string} of {string} across both recipes",
|
||||
(date: string, _techniqueLabel: string, _ingredientLabel: string) => {
|
||||
// The page composes the headline itself from the technique/ingredient
|
||||
// *keys* via i18n — `chop`→"Hacher", `onion`→"Oignon" — so the fixture
|
||||
// carries keys; the `Then` step checks the rendered French labels the
|
||||
// feature line names.
|
||||
cy.intercept("GET", `**/cooking-session?date=${date}`, {
|
||||
statusCode: 200,
|
||||
body: {
|
||||
startDate: `${date}T00:00:00.000Z`,
|
||||
finishDate: "2026-08-23T00:00:00.000Z",
|
||||
recipes: [
|
||||
{ recipeId: 1, name: "Soupe à l'oignon", portions: 4 },
|
||||
{ recipeId: 2, name: "Tarte à l'oignon", portions: 4 },
|
||||
],
|
||||
phases: [
|
||||
{
|
||||
index: 0,
|
||||
kind: "mise-en-place",
|
||||
background: [],
|
||||
tasks: [
|
||||
{
|
||||
id: "prep:chop:onion",
|
||||
kind: "merged-prep",
|
||||
technique: { id: 1, key: "chop" },
|
||||
description: null,
|
||||
ingredients: [
|
||||
{
|
||||
ingredient: ingredient("onion"),
|
||||
quantity: 5,
|
||||
unit: { id: 2, key: "piece", type: "COUNT", toBaseFactor: 1 },
|
||||
},
|
||||
],
|
||||
utensils: [],
|
||||
sourceRecipes: [
|
||||
{ recipeId: 1, name: "Soupe à l'oignon", portions: 4 },
|
||||
{ recipeId: 2, name: "Tarte à l'oignon", portions: 4 },
|
||||
],
|
||||
originalSteps: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
Then(
|
||||
"the pooled prep task should mention {string} and {string}",
|
||||
(techniqueLabel: string, ingredientLabel: string) => {
|
||||
cy.get(".cooking-task--merged-prep")
|
||||
.should("contain.text", techniqueLabel)
|
||||
.and("contain.text", ingredientLabel);
|
||||
},
|
||||
);
|
||||
|
||||
Then("the pooled prep task should be flagged as shared", () => {
|
||||
cy.get(".cooking-task--merged-prep").contains("Mutualisé").should("be.visible");
|
||||
});
|
||||
|
|
@ -111,6 +111,43 @@ describe("Planning grid", () => {
|
|||
cy.contains("th.today .day-date", "17").should("be.visible");
|
||||
});
|
||||
|
||||
it("disables 'Commencer à cuisiner' on an empty week and enables + navigates it once a recipe is planned", () => {
|
||||
cy.intercept("GET", /\/planning\?/, { statusCode: 200, body: null });
|
||||
cy.visit("/");
|
||||
cy.contains("button", "Commencer à cuisiner").should("be.disabled");
|
||||
|
||||
cy.intercept("GET", /\/planning\?/, {
|
||||
statusCode: 200,
|
||||
body: {
|
||||
id: 1,
|
||||
startDate: "2026-08-17T00:00:00.000Z",
|
||||
finishDate: "2026-08-23T00:00:00.000Z",
|
||||
items: [
|
||||
{
|
||||
id: 1,
|
||||
weekDay: "mardi",
|
||||
meal: "diner",
|
||||
portions: 4,
|
||||
recipe: { id: 1, name: "Ratatouille" },
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
cy.intercept("GET", /\/cooking-session\?/, {
|
||||
statusCode: 200,
|
||||
body: {
|
||||
startDate: "2026-08-17T00:00:00.000Z",
|
||||
finishDate: "2026-08-23T00:00:00.000Z",
|
||||
recipes: [],
|
||||
phases: [],
|
||||
},
|
||||
});
|
||||
cy.visit("/");
|
||||
cy.contains("button", "Commencer à cuisiner").should("not.be.disabled").click();
|
||||
cy.url().should("include", "/cuisiner");
|
||||
cy.url().should("include", "date=2026-08-17");
|
||||
});
|
||||
|
||||
it("shows a loading state, then an error state when the request fails", () => {
|
||||
cy.intercept("GET", /\/planning\?/, {
|
||||
statusCode: 500,
|
||||
|
|
|
|||
|
|
@ -273,6 +273,10 @@ Then("the {string} button should not be disabled", (text: string) => {
|
|||
cy.contains("button", text).should("not.be.disabled");
|
||||
});
|
||||
|
||||
Then("the {string} button should be disabled", (text: string) => {
|
||||
cy.contains("button", text).should("be.disabled");
|
||||
});
|
||||
|
||||
When("I open the account menu", () => {
|
||||
cy.get(".app-sidebar__account-toggle").click();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { RequireAuth } from "./features/auth/RequireAuth";
|
|||
import { AppLayout } from "./layouts/AppLayout";
|
||||
import { LoginPage } from "./pages/auth/LoginPage";
|
||||
import { SignupPage } from "./pages/auth/SignupPage";
|
||||
import { CookingSessionPage } from "./pages/cooking-session/CookingSessionPage";
|
||||
import { OnboardingAllergensPage } from "./pages/onboarding/OnboardingAllergensPage";
|
||||
import { OnboardingDietPage } from "./pages/onboarding/OnboardingDietPage";
|
||||
import { OnboardingHouseholdPage } from "./pages/onboarding/OnboardingHouseholdPage";
|
||||
|
|
@ -65,6 +66,7 @@ export function App() {
|
|||
<Route path="/recettes/:id" element={<RecipesPage />} />
|
||||
<Route path="/recettes/:id/modifier" element={<RecipeFormPage />} />
|
||||
<Route path="/liste-de-courses" element={<ShoppingListPage />} />
|
||||
<Route path="/cuisiner" element={<CookingSessionPage />} />
|
||||
<Route path="/parametres/compte" element={<AccountSettingsPage />} />
|
||||
<Route path="/parametres/preferences" element={<PreferencesPage />} />
|
||||
<Route path="/parametres/foyer" element={<HouseholdSettingsPage />} />
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import {
|
|||
type HouseView,
|
||||
type IngredientView,
|
||||
type LoginInput,
|
||||
type OptimizedCookingPlanView,
|
||||
type PlanningItemView,
|
||||
type PlanningView,
|
||||
type PreferencesView,
|
||||
|
|
@ -177,6 +178,19 @@ export class ApiClient {
|
|||
return this._request(`/shopping-list?date=${date}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches the current user's household's optimized cooking plan for the
|
||||
* week covering `date` (`YYYY-MM-DD`) — every recipe planned that week
|
||||
* reorganized into ordered phases (shared prep pooled, passive cooks
|
||||
* floated into the background). Like {@link getShoppingListForWeek} and
|
||||
* unlike {@link getPlanningForWeek}, never resolves to `null`: no
|
||||
* household or nothing planned both come back as a normal plan with
|
||||
* empty `recipes`/`phases`.
|
||||
*/
|
||||
public getCookingPlanForWeek(date: string): Promise<OptimizedCookingPlanView> {
|
||||
return this._request(`/cooking-session?date=${date}`);
|
||||
}
|
||||
|
||||
/** Reference list of dietary regimes to pick from (signup wizard, `/foyer`). Public — no session required. */
|
||||
public getDiets(): Promise<DietView[]> {
|
||||
return this._request("/reference/diets");
|
||||
|
|
|
|||
|
|
@ -121,6 +121,7 @@
|
|||
},
|
||||
"planning": {
|
||||
"title": "Planning de la semaine",
|
||||
"startCooking": "Commencer à cuisiner",
|
||||
"loading": "Chargement du planning…",
|
||||
"meals": {
|
||||
"petit-dejeuner": "Petit-déjeuner",
|
||||
|
|
@ -310,6 +311,28 @@
|
|||
"loading": "Chargement de la liste de courses…",
|
||||
"empty": "Aucun ingrédient à acheter pour cette semaine — ajoutez des recettes à votre planning."
|
||||
},
|
||||
"cookingSession": {
|
||||
"title": "Cuisiner cette semaine",
|
||||
"subtitle": "Toutes les étapes de la semaine, regroupées et réordonnées pour cuisiner efficacement.",
|
||||
"loading": "Optimisation du plan de cuisine…",
|
||||
"empty": "Rien de planifié cette semaine à cuisiner — ajoutez des recettes à votre planning.",
|
||||
"recipesLegend": "Recettes de la semaine",
|
||||
"phase": {
|
||||
"label": "Étape {{index}}",
|
||||
"mise-en-place": "Mise en place",
|
||||
"cooking": "Cuisson",
|
||||
"finishing": "Dressage"
|
||||
},
|
||||
"background": {
|
||||
"title": "Pendant ce temps"
|
||||
},
|
||||
"task": {
|
||||
"mergedPrepLabel": "{{technique}} : {{items}}",
|
||||
"forRecipes": "pour {{recipes}}",
|
||||
"utensils": "Ustensiles",
|
||||
"sharedBadge": "Mutualisé"
|
||||
}
|
||||
},
|
||||
"account": {
|
||||
"title": "Compte",
|
||||
"identity": {
|
||||
|
|
|
|||
188
apps/web/src/pages/cooking-session/CookingSessionPage.tsx
Normal file
188
apps/web/src/pages/cooking-session/CookingSessionPage.tsx
Normal file
|
|
@ -0,0 +1,188 @@
|
|||
import { DateTime, formatDateOnly, getWeekStart, parseDateOnly } from "@batch-cooking/date-tools";
|
||||
import type {
|
||||
CookingBackgroundTaskView,
|
||||
CookingPhaseView,
|
||||
CookingTaskView,
|
||||
OptimizedCookingPlanView,
|
||||
} from "@batch-cooking/shared";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
import { apiClient } from "../../api/client";
|
||||
import { WeekNavigator } from "../../features/planning/WeekNavigator";
|
||||
import { taskHeadline, taskRecipeNames } from "./cooking-session";
|
||||
import "./cooking-session-page.scss";
|
||||
|
||||
/** Load state for the `GET /cooking-session` call — same discriminated-union shape as `ShoppingListPage`'s own state. */
|
||||
type CookingSessionState =
|
||||
| { status: "loading" }
|
||||
| { status: "loaded"; plan: OptimizedCookingPlanView }
|
||||
| { status: "error" };
|
||||
|
||||
/**
|
||||
* "Cuisiner cette semaine" — routed at `/cuisiner`, reached from the
|
||||
* planning page's "Commencer à cuisiner" button. Shows the household's week
|
||||
* of planned recipes reorganized by the backend optimizer
|
||||
* (`GET /cooking-session`, see the API's `cooking-optimizer.ts`) into
|
||||
* ordered phases: a mise-en-place that pools shared prep, then cooking
|
||||
* phases that interleave the recipes with passive cooks shown as running
|
||||
* in the background.
|
||||
*
|
||||
* The week comes from a `?date=` query param (set by the planning button so
|
||||
* the two pages stay on the same week); absent/invalid falls back to the
|
||||
* current week. Read-only and recomputed on every visit — no progress
|
||||
* state to keep in sync, same design stance as `ShoppingListPage`.
|
||||
*/
|
||||
export function CookingSessionPage() {
|
||||
const { t } = useTranslation();
|
||||
const [searchParams] = useSearchParams();
|
||||
const [weekStart, setWeekStart] = useState<DateTime>(() => {
|
||||
const fromQuery = parseDateOnly(searchParams.get("date") ?? "");
|
||||
return getWeekStart(fromQuery ?? DateTime.utc());
|
||||
});
|
||||
const [state, setState] = useState<CookingSessionState>({ status: "loading" });
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setState({ status: "loading" });
|
||||
|
||||
apiClient
|
||||
.getCookingPlanForWeek(formatDateOnly(weekStart))
|
||||
.then((plan) => {
|
||||
if (!cancelled) setState({ status: "loaded", plan });
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setState({ status: "error" });
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [weekStart]);
|
||||
|
||||
return (
|
||||
<div className="cooking-session-page">
|
||||
<div className="cooking-session-page__header">
|
||||
<h1>{t("cookingSession.title")}</h1>
|
||||
<WeekNavigator weekStart={weekStart} onChangeWeek={setWeekStart} />
|
||||
</div>
|
||||
<p className="cooking-session-page__subtitle">{t("cookingSession.subtitle")}</p>
|
||||
|
||||
{state.status === "loading" && (
|
||||
<p className="cooking-session-page__status">{t("cookingSession.loading")}</p>
|
||||
)}
|
||||
|
||||
{state.status === "error" && (
|
||||
<p className="cooking-session-page__status cooking-session-page__status--error">
|
||||
{t("common.loadError")}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{state.status === "loaded" && <CookingPlan plan={state.plan} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** The plan body — the recipe legend then every phase, or the empty-week message. */
|
||||
function CookingPlan({ plan }: { plan: OptimizedCookingPlanView }) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (plan.phases.length === 0) {
|
||||
return <p className="cooking-session-page__status">{t("cookingSession.empty")}</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="cooking-session">
|
||||
<section className="cooking-session__legend" aria-label={t("cookingSession.recipesLegend")}>
|
||||
{plan.recipes.map((recipe) => (
|
||||
<span
|
||||
key={`${recipe.recipeId}-${recipe.portions}`}
|
||||
className="cooking-session__legend-item"
|
||||
>
|
||||
{recipe.name} · ×{recipe.portions}
|
||||
</span>
|
||||
))}
|
||||
</section>
|
||||
|
||||
{plan.phases.map((phase) => (
|
||||
<PhaseSection key={phase.index} phase={phase} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** One phase: its background band (if any) then its task cards. */
|
||||
function PhaseSection({ phase }: { phase: CookingPhaseView }) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<section className={`cooking-phase cooking-phase--${phase.kind}`}>
|
||||
<h2 className="cooking-phase__title">
|
||||
<span className="cooking-phase__index">
|
||||
{t("cookingSession.phase.label", { index: phase.index + 1 })}
|
||||
</span>
|
||||
<span className="cooking-phase__kind">{t(`cookingSession.phase.${phase.kind}`)}</span>
|
||||
</h2>
|
||||
|
||||
{phase.background.length > 0 && (
|
||||
<div className="cooking-phase__background">
|
||||
<span className="cooking-phase__background-title">
|
||||
{t("cookingSession.background.title")}
|
||||
</span>
|
||||
<ul>
|
||||
{phase.background.map((task) => (
|
||||
<li key={task.id}>
|
||||
<BackgroundLine task={task} />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ul className="cooking-phase__tasks">
|
||||
{phase.tasks.map((task) => (
|
||||
<li key={task.id}>
|
||||
<TaskCard task={task} />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
/** A single actionable task — a merged-prep pool or a plain recipe step. */
|
||||
function TaskCard({ task }: { task: CookingTaskView }) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<article className={`cooking-task cooking-task--${task.kind}`}>
|
||||
<p className="cooking-task__headline">
|
||||
{taskHeadline(task, t)}
|
||||
{task.kind === "merged-prep" && (
|
||||
<span className="cooking-task__badge">{t("cookingSession.task.sharedBadge")}</span>
|
||||
)}
|
||||
</p>
|
||||
<p className="cooking-task__recipes">
|
||||
{t("cookingSession.task.forRecipes", { recipes: taskRecipeNames(task) })}
|
||||
</p>
|
||||
{task.utensils.length > 0 && (
|
||||
<p className="cooking-task__utensils">
|
||||
{t("cookingSession.task.utensils")} :{" "}
|
||||
{task.utensils.map((utensil) => t(`catalog.utensils.${utensil.key}`)).join(", ")}
|
||||
</p>
|
||||
)}
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
/** One "meanwhile, X is cooking" line inside a phase's background band. */
|
||||
function BackgroundLine({ task }: { task: CookingBackgroundTaskView }) {
|
||||
const { t } = useTranslation();
|
||||
const technique = task.technique ? `${t(`catalog.techSteps.${task.technique.key}`)} — ` : "";
|
||||
return (
|
||||
<span>
|
||||
{technique}
|
||||
{task.description} <em>({task.recipeName})</em>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
164
apps/web/src/pages/cooking-session/cooking-session-page.scss
Normal file
164
apps/web/src/pages/cooking-session/cooking-session-page.scss
Normal file
|
|
@ -0,0 +1,164 @@
|
|||
// =============================================================================
|
||||
// Styles specific to CookingSessionPage — colocated next to
|
||||
// CookingSessionPage.tsx since nothing else uses these classes. Same page
|
||||
// shell/status conventions as shopping-list-page.scss
|
||||
// (`__header`/`__status`); below it, a vertical stack of phase sections
|
||||
// each holding a "meanwhile" band and a list of task cards.
|
||||
// =============================================================================
|
||||
|
||||
.cooking-session-page {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
&__header {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-md);
|
||||
margin-bottom: var(--space-sm);
|
||||
}
|
||||
|
||||
&__subtitle {
|
||||
flex-shrink: 0;
|
||||
margin: 0 0 var(--space-lg);
|
||||
color: var(--color-text-muted);
|
||||
font-size: var(--font-size-md);
|
||||
}
|
||||
|
||||
&__status {
|
||||
color: var(--color-text-muted);
|
||||
font-size: var(--font-size-md);
|
||||
}
|
||||
|
||||
&__status--error {
|
||||
color: var(--color-error);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Scrollable plan body --------------------------------------------------
|
||||
.cooking-session {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-lg);
|
||||
|
||||
// Recipe legend — one chip per planned recipe/portion pairing.
|
||||
&__legend {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-xs);
|
||||
}
|
||||
|
||||
&__legend-item {
|
||||
padding: 0.15rem var(--space-sm);
|
||||
border-radius: var(--radius-pill);
|
||||
background: var(--color-surface);
|
||||
box-shadow: var(--shadow-sm);
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--color-text);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
}
|
||||
|
||||
// --- One phase ----------------------------------------------------------------
|
||||
.cooking-phase {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-sm);
|
||||
|
||||
&__title {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: var(--space-sm);
|
||||
margin: 0;
|
||||
font-size: var(--font-size-md);
|
||||
}
|
||||
|
||||
&__index {
|
||||
font-weight: 700;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
&__kind {
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
// "Pendant ce temps" — passive cooks still running from earlier phases.
|
||||
&__background {
|
||||
border-left: 3px solid var(--color-border);
|
||||
padding: var(--space-xs) var(--space-md);
|
||||
color: var(--color-text-muted);
|
||||
font-size: var(--font-size-sm);
|
||||
|
||||
&-title {
|
||||
display: block;
|
||||
font-weight: 600;
|
||||
margin-bottom: 0.15rem;
|
||||
}
|
||||
|
||||
ul {
|
||||
margin: 0;
|
||||
padding-left: var(--space-md);
|
||||
}
|
||||
}
|
||||
|
||||
&__tasks {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
}
|
||||
|
||||
// --- One task card ----------------------------------------------------------
|
||||
.cooking-task {
|
||||
background: var(--color-surface);
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow: var(--shadow-sm);
|
||||
padding: var(--space-sm) var(--space-md);
|
||||
|
||||
// A pooled prep task is the headline feature of this page — give it a
|
||||
// subtle accent border so it stands out from plain recipe steps.
|
||||
&--merged-prep {
|
||||
border-left: 3px solid var(--color-accent);
|
||||
}
|
||||
|
||||
&__headline {
|
||||
margin: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-xs);
|
||||
font-weight: 600;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
&__badge {
|
||||
padding: 0.05rem var(--space-xs);
|
||||
border-radius: var(--radius-pill);
|
||||
background: var(--color-accent);
|
||||
color: var(--color-surface);
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
|
||||
&__recipes,
|
||||
&__utensils {
|
||||
margin: 0.2rem 0 0;
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
}
|
||||
56
apps/web/src/pages/cooking-session/cooking-session.ts
Normal file
56
apps/web/src/pages/cooking-session/cooking-session.ts
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
import type { CookingTaskIngredientView, CookingTaskView } from "@batch-cooking/shared";
|
||||
|
||||
/**
|
||||
* Minimal shape of `react-i18next`'s `t` — just what this module needs.
|
||||
* Passed in rather than importing `useTranslation` here so these helpers
|
||||
* stay pure functions the page (and a unit test) can call without mounting
|
||||
* i18next, the same "logic extracted from the .tsx" split as
|
||||
* `shopping-list.ts`'s `groupShoppingListItems`.
|
||||
*/
|
||||
export type TranslateFn = (key: string, options?: Record<string, unknown>) => string;
|
||||
|
||||
/**
|
||||
* Formats a task quantity for display — French conventions, at most 2
|
||||
* decimals so a scaled/pooled float never shows a trailing-digit artifact
|
||||
* (`"149.99999999999997"`). Same rule as `shopping-list.ts`'s
|
||||
* `formatShoppingListQuantity`.
|
||||
*/
|
||||
export function formatCookingQuantity(quantity: number): string {
|
||||
return quantity.toLocaleString("fr-FR", { maximumFractionDigits: 2 });
|
||||
}
|
||||
|
||||
/**
|
||||
* One ingredient line as a human string — `"3 oignon"`, `"200 g farine"`,
|
||||
* or just `"sel"` when the source clause carried no measurable amount
|
||||
* (`quantity`/`unit` both `null`, see {@link CookingTaskIngredientView}).
|
||||
* Labels are resolved through the same `catalog.*` i18n keys as everywhere
|
||||
* else.
|
||||
*/
|
||||
export function formatIngredientLine(line: CookingTaskIngredientView, t: TranslateFn): string {
|
||||
const name = t(`catalog.ingredients.${line.ingredient.key}`);
|
||||
if (line.quantity === null) return name;
|
||||
const amount = formatCookingQuantity(line.quantity);
|
||||
const unit = line.unit === null ? "" : `${t(`catalog.units.${line.unit.key}`)} `;
|
||||
return `${amount} ${unit}${name}`.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* The headline shown on a task card:
|
||||
* - `merged-prep` — `"Émincer : 3 oignon, 200 g carotte"` (technique label +
|
||||
* its pooled ingredient lines), built from the
|
||||
* `cookingSession.task.mergedPrepLabel` template.
|
||||
* - `step` — the original recipe step text, verbatim.
|
||||
*/
|
||||
export function taskHeadline(task: CookingTaskView, t: TranslateFn): string {
|
||||
if (task.kind === "step") return task.description ?? "";
|
||||
const technique = task.technique
|
||||
? t(`catalog.techSteps.${task.technique.key}`)
|
||||
: t("cookingSession.phase.mise-en-place");
|
||||
const items = task.ingredients.map((line) => formatIngredientLine(line, t)).join(", ");
|
||||
return t("cookingSession.task.mergedPrepLabel", { technique, items });
|
||||
}
|
||||
|
||||
/** Comma-joined names of the recipes a task belongs to — one for a `step`, several for a pooled `merged-prep`. */
|
||||
export function taskRecipeNames(task: CookingTaskView): string {
|
||||
return task.sourceRecipes.map((recipe) => recipe.name).join(", ");
|
||||
}
|
||||
|
|
@ -8,6 +8,7 @@ import {
|
|||
} from "@batch-cooking/shared";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { apiClient } from "../../api/client";
|
||||
import { type PlanningSlot, RecipePickerDialog } from "../../features/planning/RecipePickerDialog";
|
||||
import { WeekNavigator } from "../../features/planning/WeekNavigator";
|
||||
|
|
@ -37,6 +38,7 @@ const BAND_END_MEALS: ReadonlySet<Meal> = new Set(["collation", "dejeuner", "gou
|
|||
*/
|
||||
export function PlanningPage() {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const [weekStart, setWeekStart] = useState<DateTime>(() => getWeekStart(DateTime.utc()));
|
||||
const [state, setState] = useState<PlanningState>({ status: "loading" });
|
||||
// The slot a `RecipePickerDialog` is currently open for — `null` means
|
||||
|
|
@ -101,11 +103,23 @@ export function PlanningPage() {
|
|||
}
|
||||
}
|
||||
|
||||
// Enabled only once we know the week has at least one planned recipe —
|
||||
// "cuisiner" an empty week would just land on the page's own empty state.
|
||||
const hasPlannedRecipes = state.status === "loaded" && (state.planning?.items.length ?? 0) > 0;
|
||||
|
||||
return (
|
||||
<div className="planning-page">
|
||||
<div className="planning-page__header">
|
||||
<h1>{t("planning.title")}</h1>
|
||||
<WeekNavigator weekStart={weekStart} onChangeWeek={setWeekStart} />
|
||||
<button
|
||||
type="button"
|
||||
className="planning-page__cook-btn"
|
||||
disabled={!hasPlannedRecipes}
|
||||
onClick={() => navigate(`/cuisiner?date=${formatDateOnly(weekStart)}`)}
|
||||
>
|
||||
{t("planning.startCooking")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{state.status === "loading" && (
|
||||
|
|
|
|||
|
|
@ -36,6 +36,29 @@
|
|||
&__status--error {
|
||||
color: var(--color-error);
|
||||
}
|
||||
|
||||
// "Commencer à cuisiner" — same solid-primary treatment as the recipe
|
||||
// picker's confirm button (features/planning/recipe-picker-dialog.scss).
|
||||
&__cook-btn {
|
||||
background: var(--color-primary);
|
||||
color: var(--color-surface);
|
||||
border: none;
|
||||
border-radius: var(--radius-base);
|
||||
padding: var(--space-sm) var(--space-md);
|
||||
font-family: var(--font-body);
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background: var(--color-primary-hover);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- The grid itself --------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -48,15 +48,6 @@ services:
|
|||
# default: `/internal/tech-steps/*` fails closed rather than open
|
||||
# for a deployment that doesn't run the worker at all.
|
||||
INTERNAL_WORKER_SECRET: ${INTERNAL_WORKER_SECRET:-}
|
||||
# Admin application (apps/admin-web + /admin/*). Both unset by default:
|
||||
# `requireAdmin` fails closed without ADMIN_JWT_SECRET, so a stack
|
||||
# that doesn't run the admin app simply has every /admin/* route 401.
|
||||
# Must be a *different* secret than JWT_SECRET.
|
||||
ADMIN_JWT_SECRET: ${ADMIN_JWT_SECRET:-}
|
||||
# Public origin apps/admin-web is served from, added to the CORS
|
||||
# allow-list alongside the main app. Defaults to the compose
|
||||
# `admin-web` service's mapped host port.
|
||||
ADMIN_CORS_ORIGIN: ${ADMIN_CORS_ORIGIN:-http://localhost:3001}
|
||||
# Compose network service name, not localhost — same reasoning as
|
||||
# DATABASE_URL above. Unlike INTERNAL_WORKER_SECRET, no `:-` fallback:
|
||||
# tech-step-intent-service is a core dependency (see its own entry
|
||||
|
|
|
|||
|
|
@ -13,14 +13,8 @@ export type HttpMethod = "get" | "post" | "put" | "patch" | "delete";
|
|||
|
||||
/** Options for {@link ExpressServer.setupCore}. */
|
||||
export interface ExpressServerCoreOptions {
|
||||
/**
|
||||
* Origin(s) allowed by CORS — a single origin, or a list when more than
|
||||
* one frontend talks to this API from a different origin (e.g. the main
|
||||
* app plus a separate admin app). Passed straight through to the `cors`
|
||||
* package, which matches an incoming `Origin` against any entry of the
|
||||
* list.
|
||||
*/
|
||||
corsOrigin: string | string[];
|
||||
/** Origin allowed by CORS — must match wherever the frontend is served from. */
|
||||
corsOrigin: string;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -7,8 +7,8 @@ export * from "./data/catalog-labels-en.js";
|
|||
export * from "./data/catalog-labels-fr.js";
|
||||
export * from "./errors/error-codes.js";
|
||||
export * from "./schemas/account.js";
|
||||
export * from "./schemas/admin.js";
|
||||
export * from "./schemas/auth.js";
|
||||
export * from "./schemas/cooking-session.js";
|
||||
export * from "./schemas/household.js";
|
||||
export * from "./schemas/planning.js";
|
||||
export * from "./schemas/preferences.js";
|
||||
|
|
@ -18,7 +18,7 @@ export * from "./schemas/shopping-list.js";
|
|||
export * from "./schemas/sources.js";
|
||||
export * from "./schemas/tech-step-worker.js";
|
||||
export * from "./tools/assert-is-never.js";
|
||||
export * from "./types/admin.js";
|
||||
export * from "./types/cooking-session.js";
|
||||
export * from "./types/household.js";
|
||||
export * from "./types/planning.js";
|
||||
export * from "./types/preferences.js";
|
||||
|
|
|
|||
|
|
@ -1,18 +0,0 @@
|
|||
import { z } from "zod";
|
||||
|
||||
// Shared between apps/api (server-side validation, source of truth) and
|
||||
// apps/admin-web (client-side validation for instant feedback). Same
|
||||
// rationale as schemas/auth.ts — one set of rules, French messages surfaced
|
||||
// as-is in the admin login form.
|
||||
|
||||
/**
|
||||
* Payload accepted by `POST /admin/auth/login`. Deliberately its own schema
|
||||
* (not a re-export of `loginSchema`): the admin surface is a separate
|
||||
* contract from the end-user one even though the shape currently matches.
|
||||
*/
|
||||
export const adminLoginSchema = z.object({
|
||||
email: z.string().trim().toLowerCase().email("Email invalide"),
|
||||
password: z.string().min(1, "Le mot de passe est requis"),
|
||||
});
|
||||
/** Inferred TS type for {@link adminLoginSchema}'s validated output. */
|
||||
export type AdminLoginInput = z.infer<typeof adminLoginSchema>;
|
||||
18
packages/shared/src/schemas/cooking-session.ts
Normal file
18
packages/shared/src/schemas/cooking-session.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
import { z } from "zod";
|
||||
|
||||
// See schemas/auth.ts for the shared client/server validation rationale.
|
||||
|
||||
/**
|
||||
* Payload accepted by `GET /cooking-session`'s `?date=` query param — same
|
||||
* shape/rationale as `schemas/shopping-list.ts`'s `getShoppingListSchema`
|
||||
* (only the `YYYY-MM-DD` shape is checked here; real-calendar-date
|
||||
* validation is service-side via `@batch-cooking/date-tools`'s
|
||||
* `parseDateOnly`). Kept as its own schema rather than importing another
|
||||
* module's near-identical one — each router owns its own request contract
|
||||
* in this repo, even when two happen to share a shape.
|
||||
*/
|
||||
export const getCookingSessionSchema = z.object({
|
||||
date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, "Date invalide"),
|
||||
});
|
||||
/** Inferred TS type for {@link getCookingSessionSchema}'s validated output. */
|
||||
export type GetCookingSessionInput = z.infer<typeof getCookingSessionSchema>;
|
||||
|
|
@ -1,16 +0,0 @@
|
|||
/**
|
||||
* Public shape of an admin operator, as returned by `GET /admin/auth/me`
|
||||
* and `POST /admin/auth/login` — never includes the password hash. Mirrors
|
||||
* apps/api's `Omit<PrismaAdminUser, "passwordHash" | "tokenVersion">`,
|
||||
* declared by hand rather than derived from the Prisma type (same reason as
|
||||
* {@link SafeUserProfile}: apps/admin-web must not depend on
|
||||
* `@prisma/client`). `createdAt`/`lastLoginAt` are ISO 8601 strings (JSON
|
||||
* has no date type).
|
||||
*/
|
||||
export interface AdminUserView {
|
||||
id: number;
|
||||
email: string;
|
||||
name: string;
|
||||
createdAt: string;
|
||||
lastLoginAt: string | null;
|
||||
}
|
||||
120
packages/shared/src/types/cooking-session.ts
Normal file
120
packages/shared/src/types/cooking-session.ts
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
import type { IngredientView, TechStepView, UnitView, UtensilView } from "./reference.js";
|
||||
|
||||
/**
|
||||
* A recipe that contributes to an optimized cooking plan, resolved to just
|
||||
* enough for a display legend / provenance badge — same "resolve to
|
||||
* `{id, name}` and nothing more" treatment as `PlanningItemView.recipe`.
|
||||
* `portions` is this contribution's own portion count (the `PlanningItem`'s,
|
||||
* not `Recipe.portions`), since the plan scales quantities to it.
|
||||
*/
|
||||
export interface CookingSessionRecipeRef {
|
||||
recipeId: number;
|
||||
name: string;
|
||||
portions: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Which stage of the session a {@link CookingPhaseView} belongs to. Not a
|
||||
* free-form title — the label is resolved client-side via
|
||||
* `t(\`cookingSession.phase.${kind}\`)`, same "API sends a key, web owns the
|
||||
* wording" split as every reference catalog:
|
||||
*
|
||||
* - `"mise-en-place"` — the first phase: all shared prep pooled together
|
||||
* (`chop`/`peel`/… the same ingredient across recipes = one task) plus
|
||||
* `SETUP` tasks (preheat the oven, bring water to a boil).
|
||||
* - `"cooking"` — the interleaved middle phases, one "next ready step of
|
||||
* each recipe" per phase, with passive cooks floated into `background`.
|
||||
* - `"finishing"` — the last phase when it only holds plating/`plate` work.
|
||||
*/
|
||||
export type CookingPhaseKind = "mise-en-place" | "cooking" | "finishing";
|
||||
|
||||
/**
|
||||
* One ingredient line attached to a {@link CookingTaskView} — the same
|
||||
* `(ingredient, quantity, unit)` shape as `StepTechStepIngredientView`,
|
||||
* carried through so the cook sees "3 oignons" next to "Émincer". Both
|
||||
* `quantity` and `unit` are `null` when the source clause named the
|
||||
* ingredient with no measurable amount ("ajouter le sel"), or when a
|
||||
* merge pooled two incompatible units and no single total could be given
|
||||
* (see {@link CookingTaskView.kind}).
|
||||
*/
|
||||
export interface CookingTaskIngredientView {
|
||||
ingredient: IngredientView;
|
||||
quantity: number | null;
|
||||
unit: UnitView | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* One actionable unit of work in a phase's `tasks` list.
|
||||
*
|
||||
* - `kind: "step"` — a single recipe step, run as written. `technique` is
|
||||
* its dominant detected technique (or `null` if it mentions none),
|
||||
* `description` is the original step text, `sourceRecipes` has exactly one
|
||||
* entry.
|
||||
* - `kind: "merged-prep"` — shared preparation pooled across recipes: the
|
||||
* same prep technique applied to the same ingredient by two or more
|
||||
* recipes, collapsed into one task (the "mutualise the onions" case).
|
||||
* `description` is `null` (the web layer composes a label from
|
||||
* `technique` + `ingredients`), `sourceRecipes` lists every recipe it
|
||||
* covers, and `ingredients` holds the pooled quantity.
|
||||
*
|
||||
* `id` is stable within a single response (`"prep:<techniqueKey>:<ingredientKey>"`
|
||||
* or `"step:<stepId>"`) so the frontend can key a checklist off it.
|
||||
* `originalSteps` is the provenance trail — the exact step text(s) this
|
||||
* task stands in for, so the UI can link back to "voir la recette".
|
||||
*/
|
||||
export interface CookingTaskView {
|
||||
id: string;
|
||||
kind: "merged-prep" | "step";
|
||||
technique: TechStepView | null;
|
||||
description: string | null;
|
||||
ingredients: CookingTaskIngredientView[];
|
||||
utensils: UtensilView[];
|
||||
sourceRecipes: CookingSessionRecipeRef[];
|
||||
originalSteps: { recipeId: number; recipeName: string; description: string }[];
|
||||
}
|
||||
|
||||
/**
|
||||
* A passive cook (simmer, braise, bake, marinate…) started in an earlier
|
||||
* phase and still running — surfaced in every later phase's `background`
|
||||
* until the step that consumes it comes up, so the cook is reminded "the
|
||||
* beef is still braising" while doing active work from another recipe. Not
|
||||
* something to act on now, just a status line, hence a thinner shape than
|
||||
* {@link CookingTaskView}.
|
||||
*/
|
||||
export interface CookingBackgroundTaskView {
|
||||
id: string;
|
||||
technique: TechStepView | null;
|
||||
description: string;
|
||||
recipeId: number;
|
||||
recipeName: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* One phase of the optimized plan: a batch of work the cook does now
|
||||
* (`tasks`), plus any passive cooks carried over from before (`background`).
|
||||
* `index` is 0-based and matches the array position — carried explicitly so
|
||||
* a caller rendering a single phase still knows where it sits.
|
||||
*/
|
||||
export interface CookingPhaseView {
|
||||
index: number;
|
||||
kind: CookingPhaseKind;
|
||||
tasks: CookingTaskView[];
|
||||
background: CookingBackgroundTaskView[];
|
||||
}
|
||||
|
||||
/**
|
||||
* A household's week of planned recipes, reorganized into an ordered
|
||||
* sequence of cooking phases (see `apps/api`'s `cooking-optimizer.ts`).
|
||||
*
|
||||
* Like `ShoppingListView` and unlike `PlanningView`, this is **never**
|
||||
* `null` — no household, or a household with nothing planned that week,
|
||||
* both degrade to an empty `phases` array on an otherwise normal object
|
||||
* (the week's date range is always computable), not a separate "nothing to
|
||||
* show" state the frontend has to branch on.
|
||||
*/
|
||||
export interface OptimizedCookingPlanView {
|
||||
startDate: string;
|
||||
finishDate: string;
|
||||
recipes: CookingSessionRecipeRef[];
|
||||
phases: CookingPhaseView[];
|
||||
}
|
||||
|
|
@ -296,6 +296,57 @@ telles quelles par typage structurel.
|
|||
|
||||
---
|
||||
|
||||
## Cooking session — optimisation des étapes planifiées
|
||||
|
||||
Router `/cooking-session` (`cooking-session.routes.ts`/`.service.ts`),
|
||||
`requireAuth` — un seul endpoint : `GET /cooking-session?date=YYYY-MM-DD` →
|
||||
`getCookingPlanForDate` → `OptimizedCookingPlanView`. Même contrat `?date=`
|
||||
que `GET /shopping-list` (schéma shape-only + `parseDateOnly`), même requête
|
||||
"plage couvrante" que `getPlanningForDate`, et **jamais `null`** de la même
|
||||
façon que la liste de courses : pas de foyer / aucun `Planning` couvrant la
|
||||
semaine ⇒ `recipes: []`, `phases: []`.
|
||||
|
||||
C'est la première brique du module « Calcul batch-cooking »
|
||||
([batch-cooking-architecture.md](./batch-cooking-architecture.md)),
|
||||
jusqu'ici `TODO`. Le service ne fait que **charger + façonner** : sa requête
|
||||
Prisma (`cookingSessionPlanningInclude`) reprend le sous-arbre `steps` de
|
||||
`recipe.service.ts`'s `recipeInclude` (steps ordonnés → `StepTechStep`
|
||||
ordonnés → `techStep` + `ingredients` résolus + `utensils`), puis
|
||||
`toOptimizerRecipe` mappe chaque `PlanningItem` vers l'entrée pure de
|
||||
l'optimiseur (ingrédients/unités/techniques/ustensiles déjà en vues de
|
||||
référence via `toIngredientView`/`toUnitView` réutilisées — même raison que
|
||||
`shopping-list.service.ts`). Un `PlanningItem` = une entrée d'optimiseur,
|
||||
même si deux créneaux pointent la même recette à des portions différentes
|
||||
(deux vraies préparations ; la mutualisation de la découpe les regroupe
|
||||
quand même).
|
||||
|
||||
**`optimizeCookingPlan`** (`lib/recipe-matching/cooking-optimizer.ts`,
|
||||
pure/synchrone — testable sans base, même split que `ingredient-matcher.ts`
|
||||
/ `tech-step-matcher.ts`) réorganise les recettes en **phases ordonnées** :
|
||||
|
||||
- **Mutualisation de la mise en place** : une technique de découpe
|
||||
(`PREP_TECHNIQUES` : `chop`/`peel`/`mince`/`julienne`/…) appliquée au même
|
||||
ingrédient dans une étape *purement prep* (toutes ses techniques sont des
|
||||
`PREP_TECHNIQUES`) de **≥ 2 recettes** est regroupée en une tâche
|
||||
`merged-prep` ; les étapes d'origine sont *absorbées* (ne produisent plus
|
||||
de tâche). Une découpe présente dans une seule recette reste inline (juste
|
||||
classée en mise en place). Les découpes à l'intérieur d'une étape de
|
||||
cuisson ne sont pas mutualisées en v1.
|
||||
- **Parallélisme** : `TECHNIQUE_ATTENTION` classe chaque étape en `SETUP`
|
||||
(préchauffage, eau à ébullition — poussé en mise en place), `PASSIVE`
|
||||
(mijoter, braiser, cuire au four, mariner… — non surveillé une fois
|
||||
lancé) ou `ACTIVE` (défaut). Les phases de cuisson interclassent les
|
||||
recettes (une « prochaine étape de chaque recette » par phase) ; une
|
||||
étape `PASSIVE` fait patienter sa recette une phase et s'affiche en
|
||||
`background` des phases suivantes tant que sa consommatrice n'est pas
|
||||
remontée.
|
||||
|
||||
Quantités mises à l'échelle par `PlanningItem.portions / Recipe.portions`
|
||||
comme la liste de courses. Sommes d'ingrédients uniquement à unité
|
||||
identique (aucune conversion — même posture que `ShoppingListItemView`).
|
||||
|
||||
---
|
||||
|
||||
## `reference` — catalogues publics (pas de session requise)
|
||||
|
||||
Router `/reference` (`reference.routes.ts`/`.service.ts`) — **toutes les
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ L'application repose sur une architecture **client-serveur** classique :
|
|||
flowchart TB
|
||||
subgraph SERVER["Server"]
|
||||
API["API (REST)"]
|
||||
CALC["Calcul batch-cooking<br/><i>(TODO)</i>"]
|
||||
CALC["Calcul batch-cooking<br/><i>v1 implémentée (GET /cooking-session)</i>"]
|
||||
IMPORT["Import d'une recette<br/><i>implémenté</i>"]
|
||||
IMP1["Import depuis source<br/>(RecipeSourceAdapter)"]
|
||||
IMP2["Traduction en étapes<br/>(ingrédients + techniques)"]
|
||||
|
|
@ -38,8 +38,8 @@ flowchart TB
|
|||
```
|
||||
|
||||
*(Le canal websocket envisagé dans la conception d'origine pour le calcul
|
||||
batch-cooking temps réel n'existe pas encore — rien à documenter tant que ce
|
||||
module reste TODO ; voir la note plus bas.)*
|
||||
batch-cooking temps réel n'existe pas encore — le module v1 est un `GET`
|
||||
recalculé à chaque visite, pas de temps réel ; voir la note plus bas.)*
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -49,7 +49,7 @@ module reste TODO ; voir la note plus bas.)*
|
|||
Point d'entrée principal pour les échanges entre les clients et le serveur — REST classique, `requireAuth` (cookie JWT httpOnly) sur toute route qui n'est pas une donnée de référence publique. Détail complet des modules : [backend-architecture.md](./backend-architecture.md).
|
||||
|
||||
### Module « Calcul batch-cooking »
|
||||
Logique de calcul du batch-cooking (optimisation du planning/des recettes selon le planning). **Statut : TODO — reste à développer**, avec `packages/shared`'s `assertIsNever` déjà en place comme outil prêt à l'emploi pour ce futur module (voir [backend-architecture.md](./backend-architecture.md#packagesshared--assertisnever)).
|
||||
Logique de calcul du batch-cooking (optimisation des recettes entre elles selon le planning). **Statut : v1 implémentée** — `GET /cooking-session?date=` → `optimizeCookingPlan` (`apps/api/src/lib/recipe-matching/cooking-optimizer.ts`, pur) réorganise les recettes d'une semaine planifiée en **phases ordonnées** : une « mise en place » qui mutualise la découpe commune (même technique de découpe + même ingrédient dans une étape purement prep de ≥ 2 recettes = une seule tâche), puis des phases de cuisson qui interclassent les recettes en poussant les cuissons passives (mijotage, four…) en tâche de fond. v1 hors périmètre : fusion de cuissons, durées estimées, persistance/progression, canal websocket. Détail : [backend-architecture.md](./backend-architecture.md#cooking-session--optimisation-des-étapes-planifiées).
|
||||
|
||||
### Module « Import d'une recette »
|
||||
**Statut : implémenté.** Pipeline en trois étapes, comme prévu à la conception :
|
||||
|
|
@ -91,15 +91,15 @@ détectées, favoris, visibilité des recettes).
|
|||
[backend-architecture.md](./backend-architecture.md#liste-de-courses--agrégation-des-ingrédients-planifiés))
|
||||
— une simple **agrégation** des ingrédients déjà planifiés (somme par
|
||||
ingrédient/unité, mise à l'échelle par les portions de chaque créneau),
|
||||
pas une optimisation. Le module « Calcul batch-cooking » lui-même reste
|
||||
`TODO` : il désigne quelque chose de plus ambitieux qu'une somme
|
||||
d'ingrédients — optimiser le planning/les recettes entre elles (ex.
|
||||
mutualiser une préparation entre plusieurs recettes de la semaine), pas
|
||||
encore défini plus précisément. C'est le principal chantier restant côté
|
||||
serveur.
|
||||
pas une optimisation. Le module « Calcul batch-cooking », lui, optimise les
|
||||
recettes **entre elles** (mutualiser une préparation commune, paralléliser
|
||||
les cuissons passives) — **v1 implémentée** via `GET /cooking-session`
|
||||
(voir [backend-architecture.md](./backend-architecture.md#cooking-session--optimisation-des-étapes-planifiées)).
|
||||
- Le canal websocket envisagé pour la communication temps réel n'a pas encore
|
||||
été construit — rien ne le remplace aujourd'hui (pas de polling), à
|
||||
reconsidérer au moment d'attaquer le calcul batch-cooking.
|
||||
été construit — rien ne le remplace aujourd'hui (pas de polling). Le calcul
|
||||
batch-cooking v1 est un simple `GET` recalculé à chaque visite (comme la
|
||||
liste de courses), pas de temps réel ; à reconsidérer si une session de
|
||||
cuisine partagée/synchronisée est ajoutée.
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue