Compare commits

..

1 commit

Author SHA1 Message Date
cf8ef26f63 feat(admin): fondation auth de l'application d'administration
All checks were successful
CI / lint (push) Successful in 1m4s
CI / intent-service-test (push) Successful in 10m52s
CI / build (push) Successful in 1m6s
CI / e2e (push) Successful in 5m36s
CI / test (push) Successful in 19m57s
Premiere brique de l'app d'admin independante : une surface /admin/*
ajoutee a apps/api, avec une authentification totalement distincte de
celle des utilisateurs.

- Table AdminUser isolee (aucune relation vers UserProfile), migration
  20260828120000_admin_user.
- lib/admin-jwt.ts : sign/verify d'un JWT admin, secret ADMIN_JWT_SECRET
  propre (jamais interchangeable avec JWT_SECRET).
- middlewares/require-admin.ts : cookie admin_session dedie, re-check
  tokenVersion, echoue ferme si ADMIN_JWT_SECRET absent (posture
  requireInternalWorker). res.locals.adminUser type via AdminLocals.
- modules/admin/ : admin-auth.{routes,service}.ts (POST /login, POST
  /logout, GET /me), admin.routes.ts agregateur monte /admin. Pas de
  signup expose.
- lib/safe-admin.ts : mapping AdminUser -> AdminUserView (drop passwordHash
  + tokenVersion, dates ISO).
- scripts/create-admin.ts : creation du 1er admin hors-bande (flags ou
  ADMIN_INITIAL_*).
- CORS : setupCore accepte string[] ; app.ts autorise CORS_ORIGIN +
  ADMIN_CORS_ORIGIN.
- Shared : schemas/admin.ts (adminLoginSchema), types/admin.ts
  (AdminUserView).
- Env : ADMIN_JWT_SECRET (optionnel), ADMIN_COOKIE_NAME, ADMIN_CORS_ORIGIN,
  ADMIN_INITIAL_* ; .env.example, .env.test.example, docker-compose.yml,
  ci.yml mis a jour.
- reset-db.ts truncate admin_users.
- Tests Mocha admin-auth.test.ts : 400 sans body, 401 email inconnu /
  mauvais mdp, login OK (cookie pose, lastLoginAt, pas de hash/tokenVersion
  dans la reponse), /me derriere requireAdmin, logout, et un cookie
  `session` d'utilisateur normal ne donne pas acces a /admin/*.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-28 12:02:19 +02:00
115 changed files with 164 additions and 9788 deletions

View file

@ -15,17 +15,20 @@ 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 surface (apps/web's /admin/* routes + the /admin/* API) --------
# --- 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. The admin UI is
# served by the same app/container as the rest of the frontend — no
# separate origin, so no CORS entry of its own.
# 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.

View file

@ -58,16 +58,14 @@ 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

3
.gitignore vendored
View file

@ -163,6 +163,3 @@ tmp-mockups/
apps/web/cypress/screenshots/
apps/web/cypress/videos/
apps/web/cypress/downloads/
apps/admin-web/cypress/screenshots/
apps/admin-web/cypress/videos/
apps/admin-web/cypress/downloads/

View file

@ -25,13 +25,3 @@ INTENT_SERVICE_SECRET=changeme-generate-a-real-random-secret-at-least-32-chars
# Generate your own the same way as JWT_SECRET above; must match the
# worker's own INTERNAL_WORKER_SECRET.
# INTERNAL_WORKER_SECRET=changeme-generate-a-real-random-secret-at-least-32-chars
# Only needed to use the admin surface (apps/web's /admin/* routes) — every
# /admin/* request 401s while unset (`requireAdmin` fails closed). MUST be a
# different value than JWT_SECRET. Generate your own the same way.
# ADMIN_JWT_SECRET=changeme-a-different-random-secret-at-least-32-chars
# Read only by `src/scripts/create-admin.ts` when its --email/--password/
# --name flags are omitted — never by the running server.
# ADMIN_INITIAL_EMAIL=ops@example.com
# ADMIN_INITIAL_PASSWORD=changeme-at-least-8-chars
# ADMIN_INITIAL_NAME=Ops

View file

@ -1,32 +0,0 @@
-- AlterTable: usage-metrics timestamps. Existing rows adopt the migration's
-- own timestamp (acceptable one-off skew for trend charts — same posture as
-- the ingredient_unit_catalog migration).
ALTER TABLE "user_profiles" ADD COLUMN "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP;
ALTER TABLE "recipe" ADD COLUMN "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP;
ALTER TABLE "planning" ADD COLUMN "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP;
ALTER TABLE "planning_item" ADD COLUMN "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP;
-- CreateTable
CREATE TABLE "analytics_events" (
"id" SERIAL NOT NULL,
"type" TEXT NOT NULL,
"actor_type" TEXT NOT NULL,
"actor_id" INTEGER,
"context" JSONB,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "analytics_events_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "analytics_events_type_created_at_idx" ON "analytics_events"("type", "created_at");
-- CreateTable
CREATE TABLE "worker_heartbeats" (
"worker_key" TEXT NOT NULL,
"last_seen_at" TIMESTAMP(3) NOT NULL,
"last_run_at" TIMESTAMP(3),
"last_result" JSONB,
CONSTRAINT "worker_heartbeats_pkey" PRIMARY KEY ("worker_key")
);

View file

@ -1,14 +0,0 @@
-- AlterTable: off-catalog ingredient "placeholder" rows. Every existing row
-- is a real seeded catalog entry, so the flag defaults to false and the four
-- new nullable columns stay NULL for them — no backfill needed.
ALTER TABLE "ingredients" ADD COLUMN "is_placeholder" BOOLEAN NOT NULL DEFAULT false;
ALTER TABLE "ingredients" ADD COLUMN "display_name" TEXT;
ALTER TABLE "ingredients" ADD COLUMN "created_by_id" INTEGER;
ALTER TABLE "ingredients" ADD COLUMN "created_at" TIMESTAMP(3);
ALTER TABLE "ingredients" ADD COLUMN "reviewed_at" TIMESTAMP(3);
-- CreateIndex
CREATE INDEX "ingredients_is_placeholder_idx" ON "ingredients"("is_placeholder");
-- AddForeignKey
ALTER TABLE "ingredients" ADD CONSTRAINT "ingredients_created_by_id_fkey" FOREIGN KEY ("created_by_id") REFERENCES "user_profiles"("id") ON DELETE SET NULL ON UPDATE CASCADE;

View file

@ -100,13 +100,9 @@ model UserProfile {
passwordHash String @map("password_hash")
/// Bumped to invalidate previously-issued JWTs (e.g. on password change).
/// Not in the original spec doc — required for stateless JWT auth.
tokenVersion Int @default(0) @map("token_version")
houseId Int? @map("house_id")
dietId Int? @map("diet_id")
/// See `Planning.createdAt` — same admin-metrics-only timestamp, added by
/// the `admin_metrics` migration for the dashboard's signup curve. No
/// application code reads it (auth doesn't need it).
createdAt DateTime @default(now()) @map("created_at")
tokenVersion Int @default(0) @map("token_version")
houseId Int? @map("house_id")
dietId Int? @map("diet_id")
house House? @relation("HouseMember", fields: [houseId], references: [id], onDelete: SetNull)
diet Diet? @relation(fields: [dietId], references: [id], onDelete: SetNull)
@ -129,9 +125,6 @@ model UserProfile {
/// view a recipe may correct its tech-step matches, not just its author —
/// see `StepTechStepCorrection.correctorId`).
techStepCorrections StepTechStepCorrection[]
/// Placeholder `Ingredient` rows this profile created by typing a free-text
/// ingredient the catalog didn't cover — see `Ingredient.isPlaceholder`.
createdIngredientPlaceholders Ingredient[] @relation("PlaceholderCreator")
@@map("user_profiles")
}
@ -199,12 +192,6 @@ model Planning {
startDate DateTime @map("start_date") @db.Date
finishDate DateTime @map("finish_date") @db.Date
houseId Int @map("house_id")
/// When this planning row was first created. Added by the `admin_metrics`
/// migration purely for the admin dashboard's activity curves — no
/// application code reads it. Rows that predate the migration all get the
/// migration's own timestamp (same acceptable one-off skew as the
/// `ingredient_unit_catalog` migration), which is fine for a trend chart.
createdAt DateTime @default(now()) @map("created_at")
house House @relation(fields: [houseId], references: [id], onDelete: Cascade)
items PlanningItem[]
@ -213,14 +200,12 @@ model Planning {
}
model PlanningItem {
id Int @id @default(autoincrement())
planningId Int @map("planning_id")
weekDay String @map("week_day")
id Int @id @default(autoincrement())
planningId Int @map("planning_id")
weekDay String @map("week_day")
meal String
recipeId Int @map("recipe_id")
recipeId Int @map("recipe_id")
portions Int
/// See `Planning.createdAt` — same admin-metrics-only timestamp.
createdAt DateTime @default(now()) @map("created_at")
planning Planning @relation(fields: [planningId], references: [id], onDelete: Cascade)
recipe Recipe @relation(fields: [recipeId], references: [id])
@ -334,10 +319,6 @@ model Recipe {
/// the author had no household yet.
authorHouseId Int? @map("author_house_id")
visibility RecipeVisibility @default(PERSONAL)
/// See `Planning.createdAt` — same admin-metrics-only timestamp, added by
/// the `admin_metrics` migration for the dashboard's "recipes created"
/// curve. No application code reads it.
createdAt DateTime @default(now()) @map("created_at")
author UserProfile @relation(fields: [authorId], references: [id])
authorHouse House? @relation(fields: [authorHouseId], references: [id], onDelete: SetNull)
@ -533,38 +514,6 @@ model Ingredient {
/// ingredient↔recipe linking in the database, the UI only pre-fills the
/// catalog's own search with this ingredient's name).
reproducible Boolean @default(false)
/// `true` = a "placeholder" row: a free-text ingredient a user typed on a
/// recipe line because the seeded catalog had nothing matching (see
/// specs/batch-cooking-modele.md's "ingrédients hors-catalogue"). Such a
/// row has `displayName` non-null, a generated `key` (`placeholder:<uuid>`,
/// never an i18n label), and the default metadata (`JAR`/`dryGoods`/`other`,
/// no allergen/diet links). It is referenced by `RecipeIngredient` like any
/// other `Ingredient`, but `GET /reference/ingredients` and
/// `ingredient-matcher.ts`'s `loadIngredientCatalog` both exclude it — it is
/// never a browsable/matchable target, only a per-line stand-in a
/// maintainer later promotes into a real catalog entry by hand. The
/// `/admin/catalog/placeholders` view groups these by normalized name so
/// the maintainer sees which ingredients the catalog is missing.
isPlaceholder Boolean @default(false) @map("is_placeholder")
/// Display name of a placeholder ingredient — the exact text the user
/// typed. `null` for a real catalog row (whose label lives in i18n under
/// `catalog.ingredients.<key>`). Invariant "non-null iff `isPlaceholder`"
/// is enforced service-side, not by the schema (same posture as other
/// cross-field invariants here).
displayName String? @map("display_name")
/// Profile that first created this placeholder — context for the admin
/// catalog-gap review. `onDelete: SetNull` so deleting an account never
/// blocks on, or cascades into, the recipes that still use its placeholder.
/// `null` for a real catalog row.
createdById Int? @map("created_by_id")
/// When this placeholder was created. `null` for a real catalog row (the
/// seed carries no timestamp).
createdAt DateTime? @map("created_at")
/// Stamped when an admin has triaged this catalog gap
/// (`PATCH /admin/catalog/placeholders/mark-reviewed`) — the group then
/// drops out of the default "à traiter" list. `null` while pending / for a
/// real catalog row.
reviewedAt DateTime? @map("reviewed_at")
recipes RecipeIngredient[]
allergies IngredientAllergy[]
@ -575,10 +524,7 @@ model Ingredient {
/// Mentions of this ingredient detected in a step's free text alongside a
/// technique — see `StepTechStepIngredient`.
stepTechSteps StepTechStepIngredient[]
/// The profile that created this row when it is a placeholder — see `createdById`.
createdBy UserProfile? @relation("PlaceholderCreator", fields: [createdById], references: [id], onDelete: SetNull)
@@index([isPlaceholder])
@@map("ingredients")
}
@ -984,48 +930,3 @@ model AdminUser {
@@map("admin_users")
}
/// One recorded product event, for the admin dashboard's usage metrics.
/// Written fire-and-forget by `lib/analytics.service.ts`'s `recordEvent`
/// from a handful of key service methods (signup, recipe import/create,
/// planning add, cooking-session open, tech-step correction, shopping-list
/// view) — never on the request's critical path, so a failed insert is
/// logged and swallowed, never surfaced to the user.
///
/// `type` is a free `String` (`"user.signup"`, `"recipe.imported"`…), not
/// an enum: adding a new event to instrument is a one-line call site
/// change with **no migration**. `actorId` is a `UserProfile.id` when
/// `actorType == "user"` but carries **no FK** — an event is an immutable
/// historical fact that must outlive the account it describes (a deleted
/// user's signup still counts on the curve). `context` is a small free
/// JSON blob (`{ sourceKey, recipeId, … }`) for slicing later; nothing
/// queries into it today.
model AnalyticsEvent {
id Int @id @default(autoincrement())
type String
actorType String @map("actor_type")
actorId Int? @map("actor_id")
context Json?
createdAt DateTime @default(now()) @map("created_at")
@@index([type, createdAt])
@@map("analytics_events")
}
/// Liveness/last-run record for a background worker that has no inbound
/// HTTP surface of its own — one row per worker (`workerKey`, today only
/// `"tech-step-llm-worker"`). The worker POSTs `/internal/tech-steps/heartbeat`
/// (`requireInternalWorker`) on boot, on every scheduler tick, and after
/// each job; the admin monitoring board reads this to show the worker as
/// up / stale / down and to surface its last job result. Upserted, never
/// accumulated — only the latest state matters.
model WorkerHeartbeat {
workerKey String @id @map("worker_key")
lastSeenAt DateTime @map("last_seen_at")
/// Set only by a `"job"` heartbeat — the last time the worker actually ran a job (vs. just a tick proving it's alive).
lastRunAt DateTime? @map("last_run_at")
/// Small JSON summary of that last job (`{ job, ok, counts }`).
lastResult Json? @map("last_result")
@@map("worker_heartbeats")
}

View file

@ -7,7 +7,6 @@ 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";
@ -34,20 +33,18 @@ 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);
// One allowed origin: the app (`CORS_ORIGIN`). The admin surface
// (`/admin/*`) is served by this same API and consumed by `apps/web`'s
// own `/admin/*` routes — same origin as the rest of the app, so no
// extra CORS entry.
server.setupCore({ corsOrigin: env.CORS_ORIGIN });
// 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.addRoute("get", "/health", (_req: Request, res: Response) => {
res.status(200).json({ status: "ok" });
});
server.mountRouter("/auth", authRouter);
// Admin application surface (`apps/web`'s `/admin/*` routes) — its own
// auth (`requireAdmin`, distinct cookie/secret), never the end-user
// session.
// 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
@ -62,7 +59,6 @@ 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

View file

@ -105,6 +105,8 @@ const envSchema = z.object({
.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(),

View file

@ -1,65 +0,0 @@
import type { Prisma } from "@prisma/client";
import { prisma } from "../db/prisma.js";
import { logger } from "./logger.service.js";
/** Who caused an {@link AnalyticsEvent}. `"user"` pairs with an `actorId` (`UserProfile.id`); `"system"` is a background job; `"anon"` is an unauthenticated request. */
export type AnalyticsActorType = "user" | "system" | "anon";
/** Optional context for {@link AnalyticsService.recordEvent}. */
export interface RecordEventOptions {
/** `UserProfile.id` — set together with `actorType: "user"` (the default when this is present). */
actorId?: number;
/** Overrides the inferred actor type (`"user"` when `actorId` is set, else `"anon"`). */
actorType?: AnalyticsActorType;
/** Small free-form blob for later slicing (`{ sourceKey, recipeId, … }`) — nothing queries into it today. */
context?: Prisma.InputJsonValue;
}
/**
* Records product usage events for the admin dashboard's metrics (see the
* `AnalyticsEvent` model doc comment). A class rather than a bare function
* same convention as `LoggerService`/`ErrorHandlerService`: `public`
* `recordEvent` is the API, `_insert` is the internal it fans out to.
*
* **Fire-and-forget by contract**: `recordEvent` returns `void`, not a
* promise. The insert runs detached, and a failure is logged at `warn` and
* swallowed analytics must never add latency to, or fail, the request
* that triggered it. Call sites therefore never `await` it.
*/
export class AnalyticsService {
public recordEvent(type: string, options: RecordEventOptions = {}): void {
const actorType: AnalyticsActorType =
options.actorType ?? (options.actorId !== undefined ? "user" : "anon");
void this._insert(type, actorType, options).catch((err: unknown) => {
logger.warn("Analytics event insert failed", {
eventType: type,
error: err instanceof Error ? err.message : String(err),
});
});
}
private async _insert(
type: string,
actorType: AnalyticsActorType,
options: RecordEventOptions,
): Promise<void> {
try {
await prisma.analyticsEvent.create({
data: {
type,
actorType,
actorId: options.actorId ?? null,
context: options.context,
},
});
} catch (err) {
// Rethrown so `recordEvent`'s `.catch` above logs it — this layer
// just isn't allowed a bare `await` per the repo's convention.
throw err;
}
}
}
/** Single shared instance — stateless, same reasoning as `logger`. */
export const analytics = new AnalyticsService();

View file

@ -1,511 +0,0 @@
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 };
}

View file

@ -427,11 +427,6 @@ export async function loadIngredientCatalog(locale = "en"): Promise<IngredientMa
const labels = INGREDIENT_LABELS_BY_LOCALE[locale] ?? {};
const synonyms = INGREDIENT_LABEL_SYNONYMS_BY_LOCALE[locale] ?? {};
const ingredients = await prisma.ingredient.findMany({
// Placeholder rows (user free-text, `placeholder:<uuid>` key) have no
// authored label so they'd be skipped by the `label === undefined`
// check below anyway — filtered here too so an import never even
// considers resolving one raw line to another line's placeholder.
where: { isPlaceholder: false },
select: { id: true, key: true },
});
const catalog: IngredientMatchEntry[] = [];

View file

@ -19,8 +19,8 @@ export interface AdminLocals {
}
/**
* Express middleware guarding every `/admin/*` route the operations UI
* (`apps/web`'s `/admin/*` routes) authenticating as an `AdminUser`. Reads the admin
* 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`

View file

@ -1,44 +0,0 @@
import { wrapAsyncHandler } from "@batch-cooking/express-tools";
import { listPlaceholdersQuerySchema, markPlaceholdersReviewedSchema } from "@batch-cooking/shared";
import { Router } from "express";
import { requireAdmin } from "../../middlewares/require-admin.js";
import {
listPlaceholderGroups,
markPlaceholdersReviewed,
pruneOrphanPlaceholders,
} from "./admin-catalog.service.js";
/**
* Router mounted at `/admin/catalog` (via `admin.routes.ts`) every route
* behind {@link requireAdmin}. Surfaces the off-catalog ingredient
* "placeholders" users typed when the seeded catalog fell short, so a
* maintainer can see what's missing and mark gaps as handled.
*/
export const adminCatalogRouter = Router();
adminCatalogRouter.get(
"/placeholders",
requireAdmin,
wrapAsyncHandler(async (req, res) => {
res.status(200).json(await listPlaceholderGroups(listPlaceholdersQuerySchema.parse(req.query)));
}),
);
adminCatalogRouter.patch(
"/placeholders/mark-reviewed",
requireAdmin,
wrapAsyncHandler(async (req, res) => {
res
.status(200)
.json(await markPlaceholdersReviewed(markPlaceholdersReviewedSchema.parse(req.body)));
}),
);
/** Deletes placeholder rows no recipe references any more — see {@link pruneOrphanPlaceholders}. */
adminCatalogRouter.post(
"/placeholders/prune-orphans",
requireAdmin,
wrapAsyncHandler(async (_req, res) => {
res.status(200).json(await pruneOrphanPlaceholders());
}),
);

View file

@ -1,156 +0,0 @@
import type {
CatalogPlaceholderGroupView,
ListPlaceholdersQuery,
MarkPlaceholdersReviewedInput,
PruneOrphansResultView,
} from "@batch-cooking/shared";
import { prisma } from "../../db/prisma.js";
/**
* Grouping key for two placeholder spellings that mean the same missing
* ingredient lower-cased, accent-stripped, punctuation-neutralised,
* whitespace-collapsed. "Piment d'Espelette", "piment d espelette" and
* "PIMENT D'ESPELETTE" all normalise to `"piment d espelette"`, so the
* admin view shows one gap, not three. Pure (no DB) unit-tested on its
* own, same split convention as `matchXxx()` vs `loadXxx()` elsewhere.
*/
export function normalizePlaceholderName(raw: string): string {
return raw
.normalize("NFD")
.replace(/\p{Diacritic}/gu, "")
.toLowerCase()
.replace(/[^\p{Letter}\p{Number}]+/gu, " ")
.trim()
.replace(/\s+/g, " ");
}
/** Prisma `include` for the placeholder query — up to a few `RecipeIngredient` links per row, each with just enough of its recipe for the "seen in…" preview and the distinct-recipe count. */
const placeholderInclude = {
recipes: {
take: 5,
include: { recipe: { select: { id: true, name: true } } },
},
} as const;
/**
* Every placeholder `Ingredient` (see `Ingredient.isPlaceholder` in
* schema.prisma), grouped by {@link normalizePlaceholderName} so a
* maintainer reviews one row per *missing ingredient* rather than one per
* recipe line. Ordered by recipe impact (most-requested gap first), then
* name.
*
* `query.reviewed` selects which side to show: omitted / `"false"` drops
* groups whose every row has already been triaged (`reviewedAt` set) the
* default working list; `"true"` keeps only those fully-triaged groups.
*/
export async function listPlaceholderGroups(
query: ListPlaceholdersQuery,
): Promise<CatalogPlaceholderGroupView[]> {
try {
const rows = await prisma.ingredient.findMany({
where: { isPlaceholder: true },
include: placeholderInclude,
orderBy: { createdAt: "asc" },
});
/** Accumulator per normalized name — mutated in the loop, shaped into the view after. */
interface GroupAccumulator {
normalizedName: string;
displayNames: Set<string>;
ingredientIds: number[];
recipeIds: Set<number>;
sampleRecipes: Map<number, string>;
firstSeenAt: Date | null;
allReviewed: boolean;
}
const groups = new Map<string, GroupAccumulator>();
for (const row of rows) {
const name = row.displayName ?? "";
const normalizedName = normalizePlaceholderName(name);
let group = groups.get(normalizedName);
if (!group) {
group = {
normalizedName,
displayNames: new Set(),
ingredientIds: [],
recipeIds: new Set(),
sampleRecipes: new Map(),
firstSeenAt: null,
allReviewed: true,
};
groups.set(normalizedName, group);
}
if (name.length > 0) group.displayNames.add(name);
group.ingredientIds.push(row.id);
for (const link of row.recipes) {
group.recipeIds.add(link.recipe.id);
if (group.sampleRecipes.size < 5) group.sampleRecipes.set(link.recipe.id, link.recipe.name);
}
if (row.createdAt && (group.firstSeenAt === null || row.createdAt < group.firstSeenAt)) {
group.firstSeenAt = row.createdAt;
}
if (row.reviewedAt === null) group.allReviewed = false;
}
// `reviewed=true` → the archive of handled gaps; anything else → the
// working list of gaps still to look at.
const wantReviewed = query.reviewed === "true";
return [...groups.values()]
.filter((group) => group.allReviewed === wantReviewed)
.map((group) => ({
normalizedName: group.normalizedName,
displayNames: [...group.displayNames].sort((a, b) => a.localeCompare(b, "fr")),
ingredientIds: group.ingredientIds,
recipeCount: group.recipeIds.size,
sampleRecipes: [...group.sampleRecipes.entries()].map(([id, name]) => ({ id, name })),
firstSeenAt: group.firstSeenAt?.toISOString() ?? null,
allReviewed: group.allReviewed,
}))
.sort(
(a, b) => b.recipeCount - a.recipeCount || a.normalizedName.localeCompare(b.normalizedName),
);
} catch (err) {
throw err; // see recipe.service.ts's equivalent catch comment
}
}
/**
* Stamps `reviewedAt` on the given placeholder ids a maintainer has seen
* this gap (and, if it warranted it, added the real catalog entry by hand;
* this endpoint never touches the catalog itself). Scoped to
* `isPlaceholder: true` so a stray real id is a silent no-op, not a
* mislabel. Returns how many rows were actually stamped.
*/
export async function markPlaceholdersReviewed(
input: MarkPlaceholdersReviewedInput,
): Promise<{ reviewed: number }> {
try {
const { count } = await prisma.ingredient.updateMany({
where: { id: { in: input.ingredientIds }, isPlaceholder: true },
data: { reviewedAt: new Date() },
});
return { reviewed: count };
} catch (err) {
throw err; // see recipe.service.ts's equivalent catch comment
}
}
/**
* Deletes placeholder rows no recipe references any more the debris left
* when a recipe edit drops a placeholder line (the `RecipeIngredient` row
* goes, the `Ingredient` row doesn't). A manual GC (button in the admin
* catalog view, or `scripts/prune-orphan-placeholders.ts`) rather than a
* cascade: a placeholder is still evidence of a catalog gap even with no
* live recipe, so dropping it is a deliberate call, not automatic.
*/
export async function pruneOrphanPlaceholders(): Promise<PruneOrphansResultView> {
try {
const { count } = await prisma.ingredient.deleteMany({
where: { isPlaceholder: true, recipes: { none: {} } },
});
return { deleted: count };
} catch (err) {
throw err; // see recipe.service.ts's equivalent catch comment
}
}

View file

@ -1,22 +0,0 @@
import { wrapAsyncHandler } from "@batch-cooking/express-tools";
import { getMetricsSchema } from "@batch-cooking/shared";
import { Router } from "express";
import { requireAdmin } from "../../middlewares/require-admin.js";
import { getMetrics } from "./admin-metrics.service.js";
/** Router mounted at `/admin/metrics` (via `admin.routes.ts`) — every route behind {@link requireAdmin}. */
export const adminMetricsRouter = Router();
/**
* Returns the admin dashboard's usage metrics a `snapshot` of current
* totals plus `?days=` (7365, default 30) days of daily time series (see
* {@link getMetrics}). Read-only; no side effects.
*/
adminMetricsRouter.get(
"/",
requireAdmin,
wrapAsyncHandler(async (req, res) => {
const { days } = getMetricsSchema.parse(req.query);
res.status(200).json(await getMetrics(days));
}),
);

View file

@ -1,242 +0,0 @@
import type {
MetricsBreakdownRow,
MetricsEventSeries,
MetricsSnapshotView,
MetricsTimeBucket,
MetricsView,
} from "@batch-cooking/shared";
import { prisma } from "../../db/prisma.js";
/** UTC `YYYY-MM-DD` for a `Date` — the bucket key used by {@link bucketByDay}. */
function utcDayKey(date: Date): string {
return date.toISOString().slice(0, 10);
}
/** Start-of-day (UTC) `Date` that is `daysAgo` days before `from`. */
function startOfUtcDay(from: Date, daysAgo: number): Date {
return new Date(Date.UTC(from.getUTCFullYear(), from.getUTCMonth(), from.getUTCDate() - daysAgo));
}
/**
* Buckets `dates` into `days` consecutive daily counts starting at `since`
* (a start-of-UTC-day `Date`). Every day in the window is present, days
* with no matching date carry `count: 0`. Pure/synchronous factored out
* so the bucketing is unit-testable without a database, same split as
* `aggregateShoppingList`.
*/
export function bucketByDay(dates: Date[], since: Date, days: number): MetricsTimeBucket[] {
const counts = new Map<string, number>();
for (let i = 0; i < days; i++) {
const day = new Date(since.getTime() + i * 86_400_000);
counts.set(utcDayKey(day), 0);
}
for (const date of dates) {
const key = utcDayKey(date);
const current = counts.get(key);
if (current !== undefined) counts.set(key, current + 1);
}
return [...counts.entries()]
.sort(([a], [b]) => a.localeCompare(b))
.map(([date, count]) => ({ date, count }));
}
/** Shapes a Prisma `groupBy ... _count` result into the frontend's `{ key, label, count }` rows, sorted by count desc. */
function toBreakdown(
rows: { key: string | null; count: number }[],
labelFor: (key: string) => string = (key) => key,
): MetricsBreakdownRow[] {
return rows
.map(({ key, count }) => {
const resolved = key ?? "unknown";
return { key: resolved, label: labelFor(resolved), count };
})
.sort((a, b) => b.count - a.count);
}
/** Every point-in-time `COUNT` for the KPI tiles — see {@link MetricsSnapshotView}. */
async function getSnapshot(): Promise<MetricsSnapshotView> {
try {
const [
admins,
users,
households,
activeHouseholdGroups,
recipes,
recipesManual,
recipesImported,
recipeBySourceGroups,
sources,
plannings,
planningItems,
steps,
detectedTechniques,
favorites,
corrections,
correctionsUnconsumed,
correctionsRemoval,
trainingSuggestions,
suggestionStatusGroups,
suggestionSourceTypeGroups,
] = await Promise.all([
prisma.adminUser.count(),
prisma.userProfile.count(),
prisma.house.count(),
prisma.planning.groupBy({ by: ["houseId"] }),
prisma.recipe.count(),
prisma.recipe.count({ where: { sourceId: null } }),
prisma.recipe.count({ where: { sourceId: { not: null } } }),
prisma.recipe.groupBy({
by: ["sourceId"],
where: { sourceId: { not: null } },
_count: { _all: true },
}),
prisma.source.findMany({ select: { id: true, key: true, name: true } }),
prisma.planning.count(),
prisma.planningItem.count(),
prisma.step.count(),
prisma.stepTechStep.count(),
prisma.recipeFavorite.count(),
prisma.stepTechStepCorrection.count(),
prisma.stepTechStepCorrection.count({ where: { consumedAt: null } }),
prisma.stepTechStepCorrection.count({ where: { correctedTechStepId: null } }),
prisma.techStepTrainingSuggestion.count(),
prisma.techStepTrainingSuggestion.groupBy({ by: ["status"], _count: { _all: true } }),
prisma.techStepTrainingSuggestion.groupBy({ by: ["sourceType"], _count: { _all: true } }),
]);
const sourceById = new Map(sources.map((source) => [source.id, source]));
return {
admins,
users,
households,
activeHouseholds: activeHouseholdGroups.length,
recipes,
recipesManual,
recipesImported,
recipesBySource: toBreakdown(
recipeBySourceGroups.map((group) => ({
key: group.sourceId === null ? null : (sourceById.get(group.sourceId)?.key ?? null),
count: group._count._all,
})),
(key) => {
const source = sources.find((s) => s.key === key);
return source ? source.name : key;
},
),
plannings,
planningItems,
steps,
detectedTechniques,
favorites,
corrections,
correctionsUnconsumed,
correctionsRemoval,
trainingSuggestions,
trainingSuggestionsByStatus: toBreakdown(
suggestionStatusGroups.map((group) => ({ key: group.status, count: group._count._all })),
),
trainingSuggestionsBySourceType: toBreakdown(
suggestionSourceTypeGroups.map((group) => ({
key: group.sourceType,
count: group._count._all,
})),
),
};
} catch (err) {
throw err; // see recipe.service.ts's equivalent catch comment
}
}
/**
* Builds the admin dashboard's full metrics payload a `snapshot` of
* current totals plus `rangeDays` days of daily time series, derived from
* the `createdAt` columns the `admin_metrics` migration added and from the
* `AnalyticsEvent` table. `days` is the caller-validated `?days=` value
* (see `getMetricsSchema`, 7365).
*/
export async function getMetrics(days: number): Promise<MetricsView> {
try {
const now = new Date();
const since = startOfUtcDay(now, days - 1);
const [
snapshot,
signups,
recipesCreated,
planningItemsAdded,
correctionsSubmitted,
trainingSuggestions,
eventRows,
] = await Promise.all([
getSnapshot(),
prisma.userProfile.findMany({
where: { createdAt: { gte: since } },
select: { createdAt: true },
}),
prisma.recipe.findMany({ where: { createdAt: { gte: since } }, select: { createdAt: true } }),
prisma.planningItem.findMany({
where: { createdAt: { gte: since } },
select: { createdAt: true },
}),
prisma.stepTechStepCorrection.findMany({
where: { createdAt: { gte: since } },
select: { createdAt: true },
}),
prisma.techStepTrainingSuggestion.findMany({
where: { createdAt: { gte: since } },
select: { createdAt: true },
}),
prisma.analyticsEvent.findMany({
where: { createdAt: { gte: since } },
select: { type: true, createdAt: true },
}),
]);
const eventsByType = new Map<string, Date[]>();
for (const row of eventRows) {
const list = eventsByType.get(row.type);
if (list) list.push(row.createdAt);
else eventsByType.set(row.type, [row.createdAt]);
}
const events: MetricsEventSeries[] = [...eventsByType.entries()]
.sort(([a], [b]) => a.localeCompare(b))
.map(([type, dates]) => ({ type, buckets: bucketByDay(dates, since, days) }));
return {
generatedAt: now.toISOString(),
rangeDays: days,
snapshot,
series: {
signups: bucketByDay(
signups.map((r) => r.createdAt),
since,
days,
),
recipesCreated: bucketByDay(
recipesCreated.map((r) => r.createdAt),
since,
days,
),
planningItemsAdded: bucketByDay(
planningItemsAdded.map((r) => r.createdAt),
since,
days,
),
correctionsSubmitted: bucketByDay(
correctionsSubmitted.map((r) => r.createdAt),
since,
days,
),
trainingSuggestions: bucketByDay(
trainingSuggestions.map((r) => r.createdAt),
since,
days,
),
},
events,
};
} catch (err) {
throw err; // see recipe.service.ts's equivalent catch comment
}
}

View file

@ -1,20 +0,0 @@
import { wrapAsyncHandler } from "@batch-cooking/express-tools";
import { Router } from "express";
import { requireAdmin } from "../../middlewares/require-admin.js";
import { getMonitoring } from "./admin-monitoring.service.js";
/** Router mounted at `/admin/monitoring` (via `admin.routes.ts`) — behind {@link requireAdmin}. */
export const adminMonitoringRouter = Router();
/**
* Actively probes Postgres, the API, `tech-step-intent-service` and the LLM
* worker's heartbeat, returning a {@link MonitoringView} status board (see
* {@link getMonitoring}). No params; the admin UI polls it on an interval.
*/
adminMonitoringRouter.get(
"/",
requireAdmin,
wrapAsyncHandler(async (_req, res) => {
res.status(200).json(await getMonitoring());
}),
);

View file

@ -1,174 +0,0 @@
import type { MonitoringView, ServiceHealthView, ServiceStatus } from "@batch-cooking/shared";
import { env } from "../../config/env.js";
import { prisma } from "../../db/prisma.js";
/** How long each outbound probe (Postgres query, intent-service HTTP) is allowed to take before it counts as `down`. */
const PROBE_TIMEOUT_MS = 2000;
/**
* Heartbeat-age thresholds for the LLM worker. Its default cron is weekly
* (`TECH_STEP_WORKER_CRON`, `0 3 * * 0`), and it also pings on boot/tick
* so no ping for **8 days** means it likely missed its last scheduled fire
* (`degraded`), and none for **3 weeks** means it's almost certainly not
* running at all (`down`).
*/
const WORKER_STALE_AFTER_MS = 8 * 24 * 60 * 60 * 1000;
const WORKER_DOWN_AFTER_MS = 21 * 24 * 60 * 60 * 1000;
const WORKER_KEY = "tech-step-llm-worker";
function nowIso(): string {
return new Date().toISOString();
}
function roundMs(value: number): number {
return Math.round(value * 10) / 10;
}
function errMessage(err: unknown): string {
return err instanceof Error ? err.message : String(err);
}
/** `12500` → `"il y a 12 s"`, `90000` → `"il y a 1 min"`, `172800000` → `"il y a 2 j"`. */
function formatAgo(ms: number): string {
const s = Math.round(ms / 1000);
if (s < 60) return `il y a ${s} s`;
const m = Math.round(s / 60);
if (m < 60) return `il y a ${m} min`;
const h = Math.round(m / 60);
if (h < 48) return `il y a ${h} h`;
return `il y a ${Math.round(h / 24)} j`;
}
/** `process.uptime()` seconds → `"3 h 12 min"` / `"5 min"` / `"42 s"`. */
function formatUptime(seconds: number): string {
const s = Math.floor(seconds);
if (s < 60) return `${s} s`;
const m = Math.floor(s / 60);
if (m < 60) return `${m} min`;
const h = Math.floor(m / 60);
return `${h} h ${m % 60} min`;
}
/** `prisma.$queryRaw\`SELECT 1\`` with a bounded timeout — the DB connectivity probe. */
async function probePostgres(): Promise<ServiceHealthView> {
const start = performance.now();
try {
// `$queryRaw` doesn't take an AbortSignal — bound it with a race instead.
await Promise.race([
prisma.$queryRaw`SELECT 1`,
new Promise((_resolve, reject) =>
setTimeout(() => reject(new Error("timeout")), PROBE_TIMEOUT_MS),
),
]);
return {
key: "postgres",
status: "up",
latencyMs: roundMs(performance.now() - start),
detail: null,
checkedAt: nowIso(),
};
} catch (err) {
return {
key: "postgres",
status: "down",
latencyMs: null,
detail: errMessage(err),
checkedAt: nowIso(),
};
}
}
/** The API itself — trivially "up" (it's answering), reported with its process uptime/memory. */
function probeApi(): ServiceHealthView {
const mem = process.memoryUsage();
return {
key: "api",
status: "up",
latencyMs: 0,
detail: `uptime ${formatUptime(process.uptime())} · RSS ${Math.round(mem.rss / 1_000_000)} Mo`,
checkedAt: nowIso(),
};
}
/** `GET {INTENT_SERVICE_BASE_URL}/health` — no secret needed on that route (see the service's `routes/health.py`). */
async function probeIntentService(): Promise<ServiceHealthView> {
const start = performance.now();
try {
const res = await fetch(`${env.INTENT_SERVICE_BASE_URL}/health`, {
signal: AbortSignal.timeout(PROBE_TIMEOUT_MS),
});
const latencyMs = roundMs(performance.now() - start);
return {
key: "intent-service",
status: res.ok ? "up" : "degraded",
latencyMs,
detail: `HTTP ${res.status}`,
checkedAt: nowIso(),
};
} catch (err) {
return {
key: "intent-service",
status: "down",
latencyMs: null,
detail: errMessage(err),
checkedAt: nowIso(),
};
}
}
/** Reads the LLM worker's stored `WorkerHeartbeat` (it has no HTTP surface to probe directly) and grades it by age + last job outcome. */
async function probeWorker(): Promise<ServiceHealthView> {
const heartbeat = await prisma.workerHeartbeat.findUnique({ where: { workerKey: WORKER_KEY } });
if (!heartbeat) {
return {
key: WORKER_KEY,
status: "unknown",
latencyMs: null,
detail: "aucun battement reçu",
checkedAt: nowIso(),
lastRunAt: null,
lastResult: null,
};
}
const ageMs = Date.now() - heartbeat.lastSeenAt.getTime();
const lastResult = (heartbeat.lastResult ?? null) as ServiceHealthView["lastResult"];
let status: ServiceStatus = "up";
if (ageMs > WORKER_DOWN_AFTER_MS) status = "down";
else if (ageMs > WORKER_STALE_AFTER_MS || lastResult?.ok === false) status = "degraded";
return {
key: WORKER_KEY,
status,
latencyMs: null,
detail: `dernier battement ${formatAgo(ageMs)}`,
checkedAt: nowIso(),
lastRunAt: heartbeat.lastRunAt?.toISOString() ?? null,
lastResult,
};
}
/**
* Actively probes every dependency the admin monitoring board watches
* Postgres, the API itself, `tech-step-intent-service` (`/health`), and the
* LLM worker (via its stored heartbeat). Each probe is independent and
* bounded ({@link PROBE_TIMEOUT_MS}); one being `down` never fails the
* others or the endpoint.
*/
export async function getMonitoring(): Promise<MonitoringView> {
try {
const [postgres, intentService, worker] = await Promise.all([
probePostgres(),
probeIntentService(),
probeWorker(),
]);
return {
generatedAt: nowIso(),
services: [postgres, probeApi(), intentService, worker],
};
} catch (err) {
throw err; // see recipe.service.ts's equivalent catch comment
}
}

View file

@ -1,80 +0,0 @@
import { HttpError } from "@batch-cooking/error-tools";
import { wrapAsyncHandler } from "@batch-cooking/express-tools";
import {
ErrorCode,
listCorrectionsQuerySchema,
listSuggestionsQuerySchema,
retrainRequestSchema,
trainingDataSnippetQuerySchema,
updateTrainingSuggestionSchema,
} from "@batch-cooking/shared";
import { Router } from "express";
import { requireAdmin } from "../../middlewares/require-admin.js";
import {
getTrainingDataSnippet,
listCorrections,
listSuggestions,
runRetrain,
updateSuggestion,
} from "./admin-tech-steps.service.js";
/** Router mounted at `/admin/tech-steps` (via `admin.routes.ts`) — every route behind {@link requireAdmin}. Correction/suggestion triage + the retrain trigger. */
export const adminTechStepsRouter = Router();
adminTechStepsRouter.get(
"/suggestions",
requireAdmin,
wrapAsyncHandler(async (req, res) => {
res.status(200).json(await listSuggestions(listSuggestionsQuerySchema.parse(req.query)));
}),
);
adminTechStepsRouter.get(
"/corrections",
requireAdmin,
wrapAsyncHandler(async (req, res) => {
res.status(200).json(await listCorrections(listCorrectionsQuerySchema.parse(req.query)));
}),
);
adminTechStepsRouter.get(
"/training-data-snippet",
requireAdmin,
wrapAsyncHandler(async (req, res) => {
res
.status(200)
.json(await getTrainingDataSnippet(trainingDataSnippetQuerySchema.parse(req.query)));
}),
);
adminTechStepsRouter.patch(
"/suggestions/:id",
requireAdmin,
wrapAsyncHandler(async (req, res) => {
const id = Number(req.params.id);
if (!Number.isInteger(id) || id <= 0) {
throw new HttpError(
400,
ErrorCode.VALIDATION_ERROR,
`Not a valid suggestion id: ${req.params.id}`,
);
}
const input = updateTrainingSuggestionSchema.parse(req.body);
res.status(200).json(await updateSuggestion(id, input));
}),
);
/**
* Runs the F1 regression gate then (if it passes) the full step backfill,
* and marks the given suggestion ids see {@link runRetrain}. Long-running
* and process-locked: `409 RETRAIN_ALREADY_RUNNING` if one is already
* underway.
*/
adminTechStepsRouter.post(
"/retrain",
requireAdmin,
wrapAsyncHandler(async (req, res) => {
const input = retrainRequestSchema.parse(req.body);
res.status(200).json(await runRetrain(input));
}),
);

View file

@ -1,325 +0,0 @@
import { HttpError } from "@batch-cooking/error-tools";
import {
type CorrectionAdminView,
ErrorCode,
type ListCorrectionsQuery,
type ListSuggestionsQuery,
type RetrainRequestInput,
type RetrainResultView,
type TrainingDataSnippetQuery,
type TrainingDataSnippetView,
type TrainingSuggestionAdminView,
type TrainingSuggestionGroupView,
type UpdateTrainingSuggestionInput,
} from "@batch-cooking/shared";
import { prisma } from "../../db/prisma.js";
import {
MIN_OVERALL_F1,
runTechStepEvalSuite,
} from "../../lib/recipe-matching/tech-step-eval-runner.js";
import { backfillTechSteps } from "../../scripts/backfill-tech-steps.js";
/** How many raw corrections `listCorrections` returns per call — the browser is a triage view, not an export. */
const CORRECTIONS_PAGE_SIZE = 200;
const suggestionInclude = {
techStep: { select: { key: true } },
sourceCorrection: {
include: {
step: { select: { id: true, recipeId: true, description: true } },
previousTechStep: { select: { key: true } },
correctedTechStep: { select: { key: true } },
},
},
} as const;
type SuggestionRow = Awaited<
ReturnType<
typeof prisma.techStepTrainingSuggestion.findFirstOrThrow<{ include: typeof suggestionInclude }>
>
>;
/** Shapes one Prisma suggestion row (with {@link suggestionInclude}) into its admin view. */
function toSuggestionView(row: SuggestionRow): TrainingSuggestionAdminView {
const correction = row.sourceCorrection;
return {
id: row.id,
techStepKey: row.techStep.key,
locale: row.locale,
suggestedSynonyms: row.suggestedSynonyms,
suggestedUtterances: row.suggestedUtterances,
sourceType: row.sourceType,
status: row.status,
createdAt: row.createdAt.toISOString(),
sourceCorrection: correction
? {
id: correction.id,
recipeId: correction.step.recipeId,
stepId: correction.step.id,
clauseText: correction.step.description.slice(correction.start, correction.end),
previousTechStepKey: correction.previousTechStep?.key ?? null,
correctedTechStepKey: correction.correctedTechStep?.key ?? null,
}
: null,
};
}
/**
* Every `TechStepTrainingSuggestion` matching the (all-optional) filters,
* grouped by technique key same "one block per technique" organisation
* as `list-pending-training-suggestions.ts`'s CLI report, which this UI
* replaces.
*/
export async function listSuggestions(
query: ListSuggestionsQuery,
): Promise<TrainingSuggestionGroupView[]> {
try {
const rows = await prisma.techStepTrainingSuggestion.findMany({
where: {
...(query.status ? { status: query.status } : {}),
...(query.sourceType ? { sourceType: query.sourceType } : {}),
...(query.locale ? { locale: query.locale } : {}),
...(query.techStepKey ? { techStep: { key: query.techStepKey } } : {}),
},
orderBy: [{ techStepId: "asc" }, { createdAt: "asc" }],
include: suggestionInclude,
});
const byKey = new Map<string, TrainingSuggestionAdminView[]>();
for (const row of rows) {
const view = toSuggestionView(row);
const group = byKey.get(view.techStepKey);
if (group) group.push(view);
else byKey.set(view.techStepKey, [view]);
}
return [...byKey.entries()]
.sort(([a], [b]) => a.localeCompare(b))
.map(([techStepKey, suggestions]) => ({ techStepKey, suggestions }));
} catch (err) {
throw err; // see recipe.service.ts's equivalent catch comment
}
}
/**
* Raw `StepTechStepCorrection`s for the admin browser newest first,
* capped at {@link CORRECTIONS_PAGE_SIZE}. Unlike the worker's own
* `getPendingCorrections`, this **includes** the `correctedTechStepId IS NULL`
* removals ("no technique here") that never become suggestions and are
* otherwise invisible.
*/
export async function listCorrections(query: ListCorrectionsQuery): Promise<CorrectionAdminView[]> {
try {
const consumedFilter =
query.consumed === "true"
? { consumedAt: { not: null } }
: query.consumed === "false"
? { consumedAt: null }
: {};
const correctedFilter =
query.hasCorrectedTechStep === "true"
? { correctedTechStepId: { not: null } }
: query.hasCorrectedTechStep === "false"
? { correctedTechStepId: null }
: {};
const rows = await prisma.stepTechStepCorrection.findMany({
where: { ...consumedFilter, ...correctedFilter },
orderBy: { createdAt: "desc" },
take: CORRECTIONS_PAGE_SIZE,
include: {
step: { select: { id: true, recipeId: true, description: true } },
previousTechStep: { select: { key: true } },
correctedTechStep: { select: { key: true } },
},
});
return rows.map((row) => ({
id: row.id,
recipeId: row.step.recipeId,
stepId: row.step.id,
stepDescription: row.step.description,
clauseText: row.step.description.slice(row.start, row.end),
start: row.start,
end: row.end,
previousTechStepKey: row.previousTechStep?.key ?? null,
correctedTechStepKey: row.correctedTechStep?.key ?? null,
createdAt: row.createdAt.toISOString(),
consumedAt: row.consumedAt?.toISOString() ?? null,
}));
} catch (err) {
throw err;
}
}
/**
* Curates one suggestion edit its proposed synonyms/utterances and/or
* flip its `status`. At least one field must be present.
*
* @throws {HttpError} `400 VALIDATION_ERROR` if the body is empty.
* @throws {HttpError} `404 NOT_FOUND` if `id` matches no suggestion.
*/
export async function updateSuggestion(
id: number,
input: UpdateTrainingSuggestionInput,
): Promise<TrainingSuggestionAdminView> {
try {
if (
input.status === undefined &&
input.suggestedSynonyms === undefined &&
input.suggestedUtterances === undefined
) {
throw new HttpError(400, ErrorCode.VALIDATION_ERROR, "Nothing to update");
}
const existing = await prisma.techStepTrainingSuggestion.findUnique({ where: { id } });
if (!existing) {
throw new HttpError(404, ErrorCode.NOT_FOUND, `Training suggestion ${id} not found`);
}
const updated = await prisma.techStepTrainingSuggestion.update({
where: { id },
data: {
...(input.status !== undefined ? { status: input.status } : {}),
...(input.suggestedSynonyms !== undefined
? { suggestedSynonyms: input.suggestedSynonyms }
: {}),
...(input.suggestedUtterances !== undefined
? { suggestedUtterances: input.suggestedUtterances }
: {}),
},
include: suggestionInclude,
});
return toSuggestionView(updated);
} catch (err) {
throw err;
}
}
/** Deduplicates while preserving first-seen order — for pooling synonyms/utterances across suggestions. */
function dedupe(values: string[]): string[] {
return [...new Set(values.map((value) => value.trim()).filter((value) => value.length > 0))];
}
/** Indents each entry as a Python list literal line (4-space, trailing comma) — the shape `training_data.py`'s blocks use. */
function pythonListBody(entries: string[]): string {
return entries.map((entry) => ` ${JSON.stringify(entry)},`).join("\n");
}
/**
* Aggregates the synonyms/utterances of every suggestion matching
* `techStepKey` + `locale` + `status` into a ready-to-paste
* `training_data.py` block. **Read-only** editing that Python file and
* restarting the intent-service stay a manual maintainer step.
*/
export async function getTrainingDataSnippet(
query: TrainingDataSnippetQuery,
): Promise<TrainingDataSnippetView> {
try {
const rows = await prisma.techStepTrainingSuggestion.findMany({
where: {
locale: query.locale,
status: query.status,
techStep: { key: query.techStepKey },
},
select: { suggestedSynonyms: true, suggestedUtterances: true },
});
const synonyms = dedupe(rows.flatMap((row) => row.suggestedSynonyms));
const utterances = dedupe(rows.flatMap((row) => row.suggestedUtterances));
const snippet = [
`# ${query.techStepKey} (${query.locale}) — ${rows.length} suggestion(s) "${query.status}"`,
`"synonyms": [`,
pythonListBody(synonyms),
`],`,
`"utterances": [`,
pythonListBody(utterances),
`],`,
]
.filter((line) => line.length > 0)
.join("\n");
return {
techStepKey: query.techStepKey,
locale: query.locale,
status: query.status,
suggestionCount: rows.length,
synonyms,
utterances,
snippet,
};
} catch (err) {
throw err;
}
}
/** Process-wide lock — the F1 gate + full backfill is heavy and must never run twice concurrently. */
let retrainInProgress = false;
/**
* Runs the training-corpus regression gate then, if it passes, backfills
* every step and marks the given suggestion ids the same three steps as
* `scripts/retrain-tech-steps.ts`, callable from the admin UI.
*
* **Only meaningful after** a maintainer has hand-edited
* `services/tech-step-intent-service/intent_service/training_data.py` **and
* restarted that service** (it trains once at boot) this endpoint can do
* neither, and the admin UI states that prominently.
*
* A failed gate returns `gatePassed: false` with no backfill / no marking
* (HTTP 200 it's an expected outcome to show the operator, not an error).
*
* @throws {HttpError} `409 RETRAIN_ALREADY_RUNNING` if a retrain is already in flight.
*/
export async function runRetrain(input: RetrainRequestInput): Promise<RetrainResultView> {
if (retrainInProgress) {
throw new HttpError(
409,
ErrorCode.RETRAIN_ALREADY_RUNNING,
"A retrain (F1 gate + backfill) is already running",
);
}
retrainInProgress = true;
try {
const { overall } = await runTechStepEvalSuite();
const gatePassed = overall.f1 >= MIN_OVERALL_F1;
let backfilled: RetrainResultView["backfilled"] = null;
const marked = { applied: 0, rejected: 0 };
if (gatePassed) {
backfilled = await backfillTechSteps();
const appliedIds = input.appliedIds ?? [];
const rejectedIds = input.rejectedIds ?? [];
if (appliedIds.length > 0) {
const { count } = await prisma.techStepTrainingSuggestion.updateMany({
where: { id: { in: appliedIds } },
data: { status: "applied" },
});
marked.applied = count;
}
if (rejectedIds.length > 0) {
const { count } = await prisma.techStepTrainingSuggestion.updateMany({
where: { id: { in: rejectedIds } },
data: { status: "rejected" },
});
marked.rejected = count;
}
}
return {
f1: overall.f1,
precision: overall.precision,
recall: overall.recall,
minF1: MIN_OVERALL_F1,
gatePassed,
backfilled,
marked,
};
} catch (err) {
throw err; // see recipe.service.ts's equivalent catch comment
} finally {
retrainInProgress = false;
}
}

View file

@ -1,22 +1,13 @@
import { Router } from "express";
import { adminAuthRouter } from "./admin-auth.routes.js";
import { adminCatalogRouter } from "./admin-catalog.routes.js";
import { adminMetricsRouter } from "./admin-metrics.routes.js";
import { adminMonitoringRouter } from "./admin-monitoring.routes.js";
import { adminTechStepsRouter } from "./admin-tech-steps.routes.js";
/**
* Aggregator for the admin application's API surface, mounted at `/admin`
* in `app.ts`. Every sub-router here backs `apps/web`'s `/admin/*` routes
* only `/admin/auth` is public (login), everything added later
* (`/admin/metrics`, `/admin/monitoring`, `/admin/tech-steps/*`,
* `/admin/catalog/*`) sits behind `requireAdmin`
* (`middlewares/require-admin.ts`).
* 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);
adminRouter.use("/metrics", adminMetricsRouter);
adminRouter.use("/monitoring", adminMonitoringRouter);
adminRouter.use("/tech-steps", adminTechStepsRouter);
adminRouter.use("/catalog", adminCatalogRouter);

View file

@ -8,7 +8,6 @@ import {
import argon2 from "argon2";
import { env } from "../../config/env.js";
import { prisma } from "../../db/prisma.js";
import { analytics } from "../../lib/analytics.service.js";
import { signAuthToken } from "../../lib/jwt.js";
import { toSafeProfile } from "../../lib/safe-profile.js";
import { leaveCurrentHouse } from "../house/house.service.js";
@ -59,8 +58,6 @@ export async function signup(input: SignupInput): Promise<AuthResult> {
},
});
analytics.recordEvent("user.signup", { actorId: profile.id });
const token = signAuthToken({
userProfileId: profile.id,
tokenVersion: profile.tokenVersion,

View file

@ -1,37 +0,0 @@
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);
}),
);

View file

@ -1,168 +0,0 @@
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;
}
}

View file

@ -3,14 +3,12 @@ import {
auditBatchQuerySchema,
submitTrainingSuggestionsSchema,
workerBatchQuerySchema,
workerHeartbeatSchema,
} from "@batch-cooking/shared";
import { Router } from "express";
import { requireInternalWorker } from "../../middlewares/require-internal-worker.js";
import {
getAuditBatch,
getPendingCorrections,
recordWorkerHeartbeat,
submitTrainingSuggestions,
} from "./tech-step-worker.service.js";
@ -49,19 +47,3 @@ techStepWorkerRouter.post(
res.status(201).json(await submitTrainingSuggestions(input));
}),
);
/**
* Liveness ping from the worker (which has no inbound HTTP surface of its
* own) upserts its `WorkerHeartbeat` row so the admin monitoring board
* can show it as up / stale / down and surface its last job result. Sent
* on boot, on every scheduler tick, and after each job.
*/
techStepWorkerRouter.post(
"/heartbeat",
requireInternalWorker,
wrapAsyncHandler(async (req, res) => {
const input = workerHeartbeatSchema.parse(req.body);
await recordWorkerHeartbeat(input);
res.status(200).json({ ok: true });
}),
);

View file

@ -4,9 +4,7 @@ import {
type PendingTechStepCorrectionView,
type SubmitTrainingSuggestionsInput,
type TechStepAuditClauseView,
type WorkerHeartbeatInput,
} from "@batch-cooking/shared";
import type { Prisma } from "@prisma/client";
import { prisma } from "../../db/prisma.js";
import {
CONFIDENCE_THRESHOLD,
@ -201,44 +199,3 @@ export async function submitTrainingSuggestions(
throw err; // see recipe.service.ts's equivalent catch comment
}
}
/**
* The one worker with a `WorkerHeartbeat` row today a fixed key, not
* something the caller supplies (only one worker exists, and letting it
* name itself would just be a spoofing surface behind the same shared
* secret).
*/
const WORKER_KEY = "tech-step-llm-worker";
/**
* Upserts `services/tech-step-llm-worker`'s heartbeat row (see
* `POST /internal/tech-steps/heartbeat`). Every ping bumps `lastSeenAt`; a
* `"job"` ping also records `lastRunAt` + a small `lastResult` summary so
* the admin monitoring board can show what the worker last did and whether
* it worked.
*/
export async function recordWorkerHeartbeat(input: WorkerHeartbeatInput): Promise<void> {
try {
const now = new Date();
const jobResult: Prisma.InputJsonValue | undefined =
input.event === "job"
? { job: input.job ?? null, ok: input.ok ?? null, counts: input.counts ?? {} }
: undefined;
await prisma.workerHeartbeat.upsert({
where: { workerKey: WORKER_KEY },
create: {
workerKey: WORKER_KEY,
lastSeenAt: now,
lastRunAt: input.event === "job" ? now : null,
lastResult: jobResult,
},
update: {
lastSeenAt: now,
...(input.event === "job" ? { lastRunAt: now, lastResult: jobResult } : {}),
},
});
} catch (err) {
throw err; // see recipe.service.ts's equivalent catch comment
}
}

View file

@ -7,7 +7,6 @@ import {
type PlanningView,
} from "@batch-cooking/shared";
import { prisma } from "../../db/prisma.js";
import { analytics } from "../../lib/analytics.service.js";
import { assertRecipeVisible } from "../recipe/recipe.service.js";
/**
@ -158,11 +157,6 @@ export async function addPlanningItem(
include: { recipe: { select: { id: true, name: true } } },
});
analytics.recordEvent("planning.item_added", {
actorId: viewerId,
context: { recipeId: input.recipeId, portions: input.portions },
});
return {
id: item.id,
weekDay: item.weekDay,

View file

@ -7,7 +7,6 @@ import {
} from "@batch-cooking/shared";
import type { Prisma } from "@prisma/client";
import { prisma } from "../../db/prisma.js";
import { analytics } from "../../lib/analytics.service.js";
import { assertRecipeVisible, toStepTechStepViews } from "./recipe.service.js";
/**
@ -473,16 +472,6 @@ export async function submitTechStepCorrection(
return { correction: createdCorrection, techSteps: freshTechSteps };
});
analytics.recordEvent("tech_step.correction_submitted", {
actorId: correctorId,
context: {
recipeId,
stepId,
previousTechStepId: input.previousTechStepId ?? null,
correctedTechStepId: input.correctedTechStepId ?? null,
},
});
return { correction: toCorrectionView(correction), techSteps: toStepTechStepViews(techSteps) };
} catch (err) {
throw err; // see loadVisibleStepOrThrow's catch comment

View file

@ -1,4 +1,3 @@
import { randomUUID } from "node:crypto";
import { HttpError } from "@batch-cooking/error-tools";
import {
type AllergyView,
@ -15,7 +14,6 @@ import {
} from "@batch-cooking/shared";
import type { Prisma } from "@prisma/client";
import { prisma } from "../../db/prisma.js";
import { analytics } from "../../lib/analytics.service.js";
import {
type TechStepMatch,
techStepClassifier,
@ -110,12 +108,6 @@ export function toIngredientView(ingredient: IngredientWithDetails): IngredientV
kind: allergy.category.kind,
})),
diets: ingredient.diets.map(({ diet }) => ({ id: diet.id, key: diet.key })),
// A placeholder line round-trips as a normal `Ingredient` with a real
// id — the frontend tells it apart by this flag (badge, no
// allergen/diet info) and shows `displayName` verbatim instead of
// looking up an i18n label that doesn't exist for a `placeholder:` key.
isPlaceholder: ingredient.isPlaceholder,
displayName: ingredient.displayName,
};
}
@ -549,69 +541,6 @@ async function matchStepsTechSteps<T extends { description: string }>(
);
}
/** One resolved ingredient line — every `placeholderName` has been turned into a real (placeholder) `ingredientId`, ready for a `RecipeIngredient` create. */
interface ResolvedIngredientLine {
ingredientId: number;
quantity: number;
unitId: number;
}
/**
* Turns each `input.ingredients` line into a {@link ResolvedIngredientLine}:
* a line that already carries an `ingredientId` (a catalog pick, or an
* existing placeholder round-tripping through an edit) passes through
* unchanged; a `placeholderName` line gets a fresh placeholder `Ingredient`
* row (`isPlaceholder: true`, a generated `placeholder:<uuid>` key, the
* typed text as `displayName`, stamped with `authorId`/now) created via
* `tx` so it rolls back together with the recipe if anything later in the
* same transaction fails, never leaving an orphan behind.
*
* Returns the created placeholders separately so the caller can emit one
* `ingredient.placeholder_created` analytics event per row *after* the
* transaction commits (the recipe id it wants in the event context doesn't
* exist yet in here).
*/
async function resolveIngredientLines(
lines: CreateRecipeInput["ingredients"],
authorId: number,
tx: Prisma.TransactionClient,
): Promise<{
resolved: ResolvedIngredientLine[];
createdPlaceholders: { id: number; name: string }[];
}> {
try {
const resolved: ResolvedIngredientLine[] = [];
const createdPlaceholders: { id: number; name: string }[] = [];
for (const line of lines) {
if (line.ingredientId !== undefined) {
resolved.push({
ingredientId: line.ingredientId,
quantity: line.quantity,
unitId: line.unitId,
});
continue;
}
// `recipeIngredientInputSchema`'s refine guarantees the other branch.
const name = (line.placeholderName ?? "").trim();
const placeholder = await tx.ingredient.create({
data: {
key: `placeholder:${randomUUID()}`,
isPlaceholder: true,
displayName: name,
createdById: authorId,
createdAt: new Date(),
},
select: { id: true },
});
resolved.push({ ingredientId: placeholder.id, quantity: line.quantity, unitId: line.unitId });
createdPlaceholders.push({ id: placeholder.id, name });
}
return { resolved, createdPlaceholders };
} catch (err) {
throw err; // see suitableForHouseholdWhere()'s catch comment above
}
}
async function createRecipeInternal(
input: CreateRecipeInput,
authorId: number,
@ -619,9 +548,7 @@ async function createRecipeInternal(
source: { sourceId: number; externalId: string; locale: string } | null,
): Promise<RecipeView> {
try {
await assertIngredientsExist(
input.ingredients.map((i) => i.ingredientId).filter((id): id is number => id !== undefined),
);
await assertIngredientsExist(input.ingredients.map((i) => i.ingredientId));
await assertUnitsExist(input.ingredients.map((i) => i.unitId));
await assertDietsExist(input.dietIds);
// Matched up front (one call per step, in parallel) rather than inline
@ -634,84 +561,61 @@ async function createRecipeInternal(
source?.locale ?? DEFAULT_TECH_STEP_LOCALE,
);
// One transaction so the free-text placeholder `Ingredient` rows and the
// recipe that references them commit together — a failed recipe create
// must never leave orphan placeholders behind.
const { created, createdPlaceholders } = await prisma.$transaction(async (tx) => {
const { resolved, createdPlaceholders } = await resolveIngredientLines(
input.ingredients,
const created = await prisma.recipe.create({
data: {
name: input.name,
description: input.description ?? null,
picture: input.picture ?? null,
portions: input.portions,
authorId,
tx,
);
const created = await tx.recipe.create({
data: {
name: input.name,
description: input.description ?? null,
picture: input.picture ?? null,
portions: input.portions,
authorId,
authorHouseId,
visibility: input.visibility,
sourceId: source?.sourceId ?? null,
externalId: source?.externalId ?? null,
ingredients: {
create: resolved.map((line) => ({
ingredientId: line.ingredientId,
quantity: line.quantity,
unitId: line.unitId,
})),
},
steps: {
create: stepsWithTechSteps.map(({ step, matches }, index) => ({
description: step.description,
picture: step.picture ?? null,
order: index,
techSteps: {
create: matches.map((match, order) => ({
techStepId: match.techStepId,
start: match.start,
end: match.end,
contextStart: match.contextStart,
contextEnd: match.contextEnd,
order,
ingredients: {
create: match.ingredients.map((ingredient) => ({
ingredientId: ingredient.ingredientId,
quantity: ingredient.quantity,
unitId: ingredient.unitId,
start: ingredient.start,
end: ingredient.end,
})),
},
utensils: {
create: match.utensils.map((utensil) => ({
utensilId: utensil.utensilId,
start: utensil.start,
end: utensil.end,
})),
},
})),
},
})),
},
diets: { create: input.dietIds.map((dietId) => ({ dietId })) },
authorHouseId,
visibility: input.visibility,
sourceId: source?.sourceId ?? null,
externalId: source?.externalId ?? null,
ingredients: {
create: input.ingredients.map((ingredient) => ({
ingredientId: ingredient.ingredientId,
quantity: ingredient.quantity,
unitId: ingredient.unitId,
})),
},
include: recipeInclude(authorId),
});
return { created, createdPlaceholders };
steps: {
create: stepsWithTechSteps.map(({ step, matches }, index) => ({
description: step.description,
picture: step.picture ?? null,
order: index,
techSteps: {
create: matches.map((match, order) => ({
techStepId: match.techStepId,
start: match.start,
end: match.end,
contextStart: match.contextStart,
contextEnd: match.contextEnd,
order,
ingredients: {
create: match.ingredients.map((ingredient) => ({
ingredientId: ingredient.ingredientId,
quantity: ingredient.quantity,
unitId: ingredient.unitId,
start: ingredient.start,
end: ingredient.end,
})),
},
utensils: {
create: match.utensils.map((utensil) => ({
utensilId: utensil.utensilId,
start: utensil.start,
end: utensil.end,
})),
},
})),
},
})),
},
diets: { create: input.dietIds.map((dietId) => ({ dietId })) },
},
include: recipeInclude(authorId),
});
analytics.recordEvent(source === null ? "recipe.created" : "recipe.imported", {
actorId: authorId,
context: { recipeId: created.id, sourceId: source?.sourceId ?? null },
});
for (const placeholder of createdPlaceholders) {
analytics.recordEvent("ingredient.placeholder_created", {
actorId: authorId,
context: { name: placeholder.name, ingredientId: placeholder.id, recipeId: created.id },
});
}
return toRecipeView(created);
} catch (err) {
throw err; // see suitableForHouseholdWhere()'s catch comment above
@ -739,30 +643,16 @@ export async function updateRecipe(
): Promise<RecipeView> {
try {
await assertIsAuthor(id, viewerId, viewerHouseId);
await assertIngredientsExist(
input.ingredients
.map((i) => i.ingredientId)
.filter((ingredientId): ingredientId is number => ingredientId !== undefined),
);
await assertIngredientsExist(input.ingredients.map((i) => i.ingredientId));
await assertUnitsExist(input.ingredients.map((i) => i.unitId));
await assertDietsExist(input.dietIds);
const stepsWithTechSteps = await matchStepsTechSteps(input.steps, DEFAULT_TECH_STEP_LOCALE);
// Interactive transaction (not the array form) so any new free-text
// placeholder rows are created in the same atomic unit as the
// delete+recreate of the recipe's content. An *existing* placeholder
// line round-trips by its real `ingredientId` and is left untouched;
// only a brand-new `placeholderName` line creates a row here.
const createdPlaceholders = await prisma.$transaction(async (tx) => {
await tx.recipeIngredient.deleteMany({ where: { recipeId: id } });
await tx.step.deleteMany({ where: { recipeId: id } });
await tx.recipeDiet.deleteMany({ where: { recipeId: id } });
const { resolved, createdPlaceholders } = await resolveIngredientLines(
input.ingredients,
viewerId,
tx,
);
await tx.recipe.update({
await prisma.$transaction([
prisma.recipeIngredient.deleteMany({ where: { recipeId: id } }),
prisma.step.deleteMany({ where: { recipeId: id } }),
prisma.recipeDiet.deleteMany({ where: { recipeId: id } }),
prisma.recipe.update({
where: { id },
data: {
name: input.name,
@ -771,10 +661,10 @@ export async function updateRecipe(
portions: input.portions,
visibility: input.visibility,
ingredients: {
create: resolved.map((line) => ({
ingredientId: line.ingredientId,
quantity: line.quantity,
unitId: line.unitId,
create: input.ingredients.map((ingredient) => ({
ingredientId: ingredient.ingredientId,
quantity: ingredient.quantity,
unitId: ingredient.unitId,
})),
},
steps: {
@ -796,16 +686,8 @@ export async function updateRecipe(
},
diets: { create: input.dietIds.map((dietId) => ({ dietId })) },
},
});
return createdPlaceholders;
});
for (const placeholder of createdPlaceholders) {
analytics.recordEvent("ingredient.placeholder_created", {
actorId: viewerId,
context: { name: placeholder.name, ingredientId: placeholder.id, recipeId: id },
});
}
}),
]);
return toRecipeView(await findRecipeOrThrow(id, viewerId));
} catch (err) {

View file

@ -133,16 +133,10 @@ export async function getSources(): Promise<SourceView[]> {
* and compatible diet regimes (see `IngredientDiet`) same flattening
* approach as {@link getAllergies}. Ingredients with no linked
* allergen/diet come back with `allergens: []`/`diets: []`.
*
* Excludes placeholder rows (`Ingredient.isPlaceholder` the free-text
* ingredients users type when the catalog falls short): this is the
* *browsable* catalog, and a placeholder is a per-recipe-line stand-in, not
* a real entry anyone should be able to pick again.
*/
export async function getIngredients(): Promise<IngredientView[]> {
try {
const ingredients = await prisma.ingredient.findMany({
where: { isPlaceholder: false },
include: {
allergies: { include: { allergy: { include: { category: true } } } },
diets: { include: { diet: true } },
@ -165,9 +159,6 @@ export async function getIngredients(): Promise<IngredientView[]> {
id: diet.id,
key: diet.key,
})),
// Always a real catalog row here (placeholders are filtered out above).
isPlaceholder: false,
displayName: null,
}));
} catch (err) {
throw err; // see getDiets()'s catch comment above

View file

@ -3,7 +3,6 @@ import { HttpError } from "@batch-cooking/error-tools";
import { wrapAsyncHandler } from "@batch-cooking/express-tools";
import { ErrorCode, getShoppingListSchema } from "@batch-cooking/shared";
import { Router } from "express";
import { analytics } from "../../lib/analytics.service.js";
import { type AuthLocals, requireAuth } from "../../middlewares/require-auth.js";
import { getShoppingListForDate } from "./shopping-list.service.js";
@ -32,7 +31,6 @@ shoppingListRouter.get(
}
const shoppingList = await getShoppingListForDate(res.locals.userProfile.houseId, date);
analytics.recordEvent("shopping_list.viewed", { actorId: res.locals.userProfile.id });
res.status(200).json(shoppingList);
}),
);

View file

@ -1,27 +0,0 @@
import { prisma } from "../db/prisma.js";
import { pruneOrphanPlaceholders } from "../modules/admin/admin-catalog.service.js";
/**
* Deletes every placeholder `Ingredient` row (`Ingredient.isPlaceholder`)
* that no recipe references any more the debris left behind when a recipe
* edit drops a placeholder line (the `RecipeIngredient` row goes, the
* `Ingredient` row stays). The admin catalog view has a button for this
* too; this script is the same operation for a cron / one-off cleanup:
*
* pnpm --filter api exec tsx src/scripts/prune-orphan-placeholders.ts
*
* Delegates to `admin-catalog.service.ts` so the "what counts as an orphan"
* rule lives in exactly one place.
*/
async function main(): Promise<void> {
const { deleted } = await pruneOrphanPlaceholders();
console.info(`Pruned ${deleted} orphan placeholder ingredient(s).`);
}
main()
.then(() => prisma.$disconnect())
.catch(async (err) => {
console.error(err);
await prisma.$disconnect();
process.exit(1);
});

View file

@ -48,7 +48,7 @@ export async function resetDatabase() {
"recipe_ingredient", "step_tech_step", "step", "tech_step",
"recipe", "ingredients", "sources", "unit",
"user_profiles", "diet", "house",
"admin_users", "analytics_events", "worker_heartbeats"
"admin_users"
RESTART IDENTITY CASCADE;
`);
await seedReferenceData(prisma);

View file

@ -1,32 +0,0 @@
import { expect } from "chai";
import { normalizePlaceholderName } from "../src/modules/admin/admin-catalog.service.js";
/**
* Pure unit tests for {@link normalizePlaceholderName} the grouping key
* that collapses near-duplicate placeholder spellings into one catalog gap.
* No database, so this file can run standalone (`mocha --no-config`) as
* well as inside the full suite.
*/
describe("normalizePlaceholderName", () => {
it("lower-cases, strips accents and collapses whitespace", () => {
expect(normalizePlaceholderName(" Piment d'Espelette ")).to.equal("piment d espelette");
expect(normalizePlaceholderName("PIMENT DESPELETTE")).to.equal("piment d espelette");
expect(normalizePlaceholderName("piment d espelette")).to.equal("piment d espelette");
});
it("neutralises punctuation to a single space", () => {
expect(normalizePlaceholderName("sel & poivre")).to.equal("sel poivre");
expect(normalizePlaceholderName("sel, poivre")).to.equal("sel poivre");
expect(normalizePlaceholderName("fleur-de-sel")).to.equal("fleur de sel");
});
it("keeps digits (a quantity baked into the name still distinguishes it)", () => {
expect(normalizePlaceholderName("Chocolat 70%")).to.equal("chocolat 70");
});
it("maps an all-punctuation / empty string to an empty key", () => {
expect(normalizePlaceholderName("")).to.equal("");
expect(normalizePlaceholderName(" ")).to.equal("");
expect(normalizePlaceholderName("--- ///")).to.equal("");
});
});

View file

@ -1,141 +0,0 @@
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 { hashAdminPassword } from "../src/modules/admin/admin-auth.service.js";
import { resetDatabase } from "../test-support/reset-db.js";
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 };
}
/** A recipe with one placeholder ingredient line whose `displayName` is `name`. Returns the placeholder `Ingredient` id. */
async function seedPlaceholderRecipe(name: string): Promise<number> {
const author = await prisma.userProfile.create({
data: {
firstName: "T",
lastName: "A",
email: `${faker.string.uuid()}@example.test`,
passwordHash: "x",
},
});
const unit = await prisma.unit.findFirstOrThrow({ where: { key: "piece" } });
const placeholder = await prisma.ingredient.create({
data: {
key: `placeholder:${faker.string.uuid()}`,
isPlaceholder: true,
displayName: name,
createdById: author.id,
createdAt: new Date(),
},
});
await prisma.recipe.create({
data: {
name: faker.lorem.words(3),
authorId: author.id,
portions: 2,
ingredients: { create: [{ ingredientId: placeholder.id, quantity: 1, unitId: unit.id }] },
},
});
return placeholder.id;
}
/**
* `/admin/catalog/*` the off-catalog ingredient review. Every route is
* behind `requireAdmin`; the list groups placeholder rows by normalized
* name, `mark-reviewed` stamps `reviewedAt`, `prune-orphans` deletes rows
* no recipe references any more.
*/
describe("Admin catalog — off-catalog ingredients", () => {
const app = createApp();
beforeEach(async () => {
await resetDatabase();
});
after(async () => {
await prisma.$disconnect();
});
async function adminAgent() {
const { email, password } = await seedAdmin();
const agent = request.agent(app);
await agent.post("/admin/auth/login").send({ email, password });
return agent;
}
it("rejects every route without an admin session", async () => {
const get = await request(app).get("/admin/catalog/placeholders");
expect(get.status).to.equal(401);
const patch = await request(app)
.patch("/admin/catalog/placeholders/mark-reviewed")
.send({ ingredientIds: [1] });
expect(patch.status).to.equal(401);
const post = await request(app).post("/admin/catalog/placeholders/prune-orphans");
expect(post.status).to.equal(401);
});
it("groups two spellings of the same missing ingredient into one row", async () => {
await seedPlaceholderRecipe("Piment d'Espelette");
await seedPlaceholderRecipe("piment d espelette");
await seedPlaceholderRecipe("Sumac");
const agent = await adminAgent();
const res = await agent.get("/admin/catalog/placeholders");
expect(res.status).to.equal(200);
expect(res.body).to.have.length(2);
const espelette = res.body.find(
(g: { normalizedName: string }) => g.normalizedName === "piment d espelette",
);
expect(espelette.recipeCount).to.equal(2);
expect(espelette.ingredientIds).to.have.length(2);
expect(espelette.displayNames).to.have.members(["Piment d'Espelette", "piment d espelette"]);
// Impact-ordered: the 2-recipe gap before the 1-recipe one.
expect(res.body[0].normalizedName).to.equal("piment d espelette");
});
it("mark-reviewed stamps reviewedAt and moves the group out of the default list", async () => {
const id = await seedPlaceholderRecipe("Galanga");
const agent = await adminAgent();
const patched = await agent
.patch("/admin/catalog/placeholders/mark-reviewed")
.send({ ingredientIds: [id] });
expect(patched.status).to.equal(200);
expect(patched.body.reviewed).to.equal(1);
expect(
(await prisma.ingredient.findUniqueOrThrow({ where: { id } })).reviewedAt,
).to.be.an.instanceOf(Date);
const pending = await agent.get("/admin/catalog/placeholders");
expect(pending.body).to.have.length(0);
const reviewed = await agent.get("/admin/catalog/placeholders").query({ reviewed: "true" });
expect(reviewed.body).to.have.length(1);
expect(reviewed.body[0].allReviewed).to.equal(true);
});
it("prune-orphans deletes only placeholder rows with no recipe left", async () => {
await seedPlaceholderRecipe("Encore utilisé");
await prisma.ingredient.create({
data: {
key: `placeholder:${faker.string.uuid()}`,
isPlaceholder: true,
displayName: "Orphelin",
createdAt: new Date(),
},
});
const agent = await adminAgent();
const res = await agent.post("/admin/catalog/placeholders/prune-orphans");
expect(res.status).to.equal(200);
expect(res.body.deleted).to.equal(1);
expect(await prisma.ingredient.count({ where: { isPlaceholder: true } })).to.equal(1);
});
});

View file

@ -1,149 +0,0 @@
import 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 { bucketByDay } from "../src/modules/admin/admin-metrics.service.js";
import { resetDatabase } from "../test-support/reset-db.js";
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 }),
};
}
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 };
}
/** Retries `check` until it stops throwing or `timeoutMs` elapses — `analytics.recordEvent` writes its row fire-and-forget, so a test observing it has to poll briefly. */
async function eventually(check: () => Promise<void>, timeoutMs = 2000): Promise<void> {
const start = Date.now();
for (;;) {
try {
await check();
return;
} catch (err) {
if (Date.now() - start > timeoutMs) throw err;
await new Promise((resolve) => setTimeout(resolve, 50));
}
}
}
const adminSecretConfigured = env.ADMIN_JWT_SECRET !== undefined;
describe("Admin metrics", () => {
describe("bucketByDay (pure)", () => {
const since = new Date("2026-08-01T00:00:00.000Z");
it("returns one zero-filled bucket per day, in date order", () => {
const result = bucketByDay([], since, 3);
expect(result).to.deep.equal([
{ date: "2026-08-01", count: 0 },
{ date: "2026-08-02", count: 0 },
{ date: "2026-08-03", count: 0 },
]);
});
it("counts dates into their UTC day and ignores dates outside the window", () => {
const result = bucketByDay(
[
new Date("2026-08-01T09:00:00Z"),
new Date("2026-08-01T23:30:00Z"),
new Date("2026-08-03T00:00:00Z"),
new Date("2026-07-31T23:59:59Z"), // before the window
new Date("2026-08-10T00:00:00Z"), // after the window
],
since,
3,
);
expect(result).to.deep.equal([
{ date: "2026-08-01", count: 2 },
{ date: "2026-08-02", count: 0 },
{ date: "2026-08-03", count: 1 },
]);
});
});
describe("GET /admin/metrics", () => {
const app = createApp();
beforeEach(async () => {
await resetDatabase();
});
after(async () => {
await prisma.$disconnect();
});
it("rejects a request with no admin session with 401", async () => {
const res = await request(app).get("/admin/metrics");
expect(res.status).to.equal(401);
});
it("returns a snapshot reflecting seeded data, plus zero-filled series", async function () {
if (!adminSecretConfigured) {
// biome-ignore lint/suspicious/noExplicitAny: mocha's `this.skip()` isn't typed here.
(this as any).skip();
return;
}
const { email, password } = await seedAdmin();
// Two end users sign up (also emits `user.signup` analytics events).
const userA = request.agent(app);
const userB = request.agent(app);
await userA.post("/auth/signup").send(buildSignupPayload());
await userB.post("/auth/signup").send(buildSignupPayload());
const adminAgent = request.agent(app);
await adminAgent.post("/admin/auth/login").send({ email, password });
const res = await adminAgent.get("/admin/metrics").query({ days: 14 });
expect(res.status).to.equal(200);
expect(res.body.rangeDays).to.equal(14);
expect(res.body.snapshot.users).to.equal(2);
expect(res.body.snapshot.admins).to.equal(1);
expect(res.body.snapshot.recipes).to.equal(0);
// 14 daily buckets, each series zero-filled to that length.
expect(res.body.series.signups).to.have.length(14);
expect(
res.body.series.signups.every((b: { count: number }) => typeof b.count === "number"),
).to.equal(true);
// Two signups today → the last bucket counts them.
const signupTotal = res.body.series.signups.reduce(
(sum: number, b: { count: number }) => sum + b.count,
0,
);
expect(signupTotal).to.equal(2);
});
it("records a user.signup analytics event (fire-and-forget, never blocks signup)", async () => {
const agent = request.agent(app);
const signupRes = await agent.post("/auth/signup").send(buildSignupPayload());
expect(signupRes.status).to.equal(201);
// `>= 1`, not `=== 1`: `recordEvent` is fire-and-forget, so an insert
// from an earlier test's signup could in principle land in this
// window too — the point here is that the instrumentation fires and
// the signup itself was never blocked by it.
await eventually(async () => {
const count = await prisma.analyticsEvent.count({ where: { type: "user.signup" } });
expect(count).to.be.greaterThan(0);
});
});
});
});

View file

@ -1,160 +0,0 @@
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";
const SECRET_HEADER = "X-Internal-Worker-Secret";
const VALID_STATUSES = ["up", "degraded", "down", "unknown"];
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 };
}
const adminSecretConfigured = env.ADMIN_JWT_SECRET !== undefined;
const workerSecretConfigured = env.INTERNAL_WORKER_SECRET !== undefined;
describe("Admin monitoring", () => {
const app = createApp();
beforeEach(async () => {
await resetDatabase();
});
after(async () => {
await prisma.$disconnect();
});
describe("POST /internal/tech-steps/heartbeat", () => {
it("rejects a request with no worker secret with 401", async () => {
const res = await request(app).post("/internal/tech-steps/heartbeat").send({ event: "boot" });
expect(res.status).to.equal(401);
});
it("rejects a malformed body with 400", async function () {
if (!workerSecretConfigured) {
// biome-ignore lint/suspicious/noExplicitAny: mocha's `this.skip()` isn't typed here.
(this as any).skip();
return;
}
const res = await request(app)
.post("/internal/tech-steps/heartbeat")
.set(SECRET_HEADER, env.INTERNAL_WORKER_SECRET as string)
.send({ event: "not-a-real-event" });
expect(res.status).to.equal(400);
});
it("upserts the worker heartbeat, recording lastRunAt/lastResult for a job ping", async function () {
if (!workerSecretConfigured) {
// biome-ignore lint/suspicious/noExplicitAny: see above.
(this as any).skip();
return;
}
const res = await request(app)
.post("/internal/tech-steps/heartbeat")
.set(SECRET_HEADER, env.INTERNAL_WORKER_SECRET as string)
.send({
event: "job",
job: "audit-low-confidence",
ok: true,
counts: { suggestions: 3 },
});
expect(res.status).to.equal(200);
expect(res.body).to.deep.equal({ ok: true });
const stored = await prisma.workerHeartbeat.findUniqueOrThrow({
where: { workerKey: "tech-step-llm-worker" },
});
expect(stored.lastRunAt).to.not.equal(null);
expect(stored.lastResult).to.deep.equal({
job: "audit-low-confidence",
ok: true,
counts: { suggestions: 3 },
});
});
it("leaves lastRunAt null for a boot/tick ping", async function () {
if (!workerSecretConfigured) {
// biome-ignore lint/suspicious/noExplicitAny: see above.
(this as any).skip();
return;
}
await request(app)
.post("/internal/tech-steps/heartbeat")
.set(SECRET_HEADER, env.INTERNAL_WORKER_SECRET as string)
.send({ event: "boot" });
const stored = await prisma.workerHeartbeat.findUniqueOrThrow({
where: { workerKey: "tech-step-llm-worker" },
});
expect(stored.lastSeenAt).to.be.instanceOf(Date);
expect(stored.lastRunAt).to.equal(null);
});
});
describe("GET /admin/monitoring", () => {
it("rejects a request with no admin session with 401", async () => {
const res = await request(app).get("/admin/monitoring");
expect(res.status).to.equal(401);
});
it("returns a status board covering all four targets, never crashing on an unreachable probe", 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/monitoring");
expect(res.status).to.equal(200);
const keys = res.body.services.map((s: { key: string }) => s.key);
expect(keys).to.have.members(["postgres", "api", "intent-service", "tech-step-llm-worker"]);
for (const service of res.body.services) {
expect(VALID_STATUSES).to.include(service.status);
}
const byKey = Object.fromEntries(res.body.services.map((s: { key: string }) => [s.key, s]));
// The DB is up during the test run, and the API is answering us.
expect(byKey.postgres.status).to.equal("up");
expect(byKey.api.status).to.equal("up");
// No heartbeat has ever been recorded (resetDatabase truncated it).
expect(byKey["tech-step-llm-worker"].status).to.equal("unknown");
});
it("reports the worker as up once it has sent a recent heartbeat", async function () {
if (!adminSecretConfigured || !workerSecretConfigured) {
// biome-ignore lint/suspicious/noExplicitAny: see above.
(this as any).skip();
return;
}
await request(app)
.post("/internal/tech-steps/heartbeat")
.set(SECRET_HEADER, env.INTERNAL_WORKER_SECRET as string)
.send({ event: "job", job: "transform-corrections", ok: true, counts: { suggestions: 0 } });
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/monitoring");
const worker = res.body.services.find(
(s: { key: string }) => s.key === "tech-step-llm-worker",
);
expect(worker.status).to.equal("up");
expect(worker.lastResult.job).to.equal("transform-corrections");
expect(worker.lastRunAt).to.be.a("string");
});
});
});

View file

@ -1,308 +0,0 @@
import { ErrorCode } 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";
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 };
}
async function techStepId(key: string): Promise<number> {
return (await prisma.techStep.findFirstOrThrow({ where: { key } })).id;
}
/** A recipe + one step + one correction on it, optionally already turned into a suggestion. */
async function seedCorrectionAndSuggestion(options: {
clause: string;
correctedKey: string | null;
withSuggestion?: { status: string; synonyms: string[] };
}) {
const author = await prisma.userProfile.create({
data: {
firstName: "T",
lastName: "A",
email: `${faker.string.uuid()}@example.test`,
passwordHash: "x",
},
});
const recipe = await prisma.recipe.create({
data: {
name: "R",
authorId: author.id,
portions: 4,
steps: { create: [{ description: options.clause, order: 0 }] },
},
include: { steps: true },
});
const step = recipe.steps[0];
if (!step) throw new Error("expected a step");
const correctedKey = options.correctedKey;
const correction = await prisma.stepTechStepCorrection.create({
data: {
stepId: step.id,
correctorId: author.id,
start: 0,
end: options.clause.length,
previousTechStepId: null,
correctedTechStepId: correctedKey === null ? null : await techStepId(correctedKey),
},
});
let suggestion: { id: number } | null = null;
if (options.withSuggestion && correctedKey !== null) {
suggestion = await prisma.techStepTrainingSuggestion.create({
data: {
techStepId: await techStepId(correctedKey),
locale: "fr",
suggestedSynonyms: options.withSuggestion.synonyms,
suggestedUtterances: [],
sourceType: "correction",
sourceCorrectionId: correction.id,
status: options.withSuggestion.status,
},
});
}
return { recipeId: recipe.id, stepId: step.id, correctionId: correction.id, suggestion };
}
const adminSecretConfigured = env.ADMIN_JWT_SECRET !== undefined;
describe("Admin tech-steps triage", () => {
const app = createApp();
beforeEach(async () => {
await resetDatabase();
});
after(async () => {
await prisma.$disconnect();
});
async function adminAgent() {
const { email, password } = await seedAdmin();
const agent = request.agent(app);
await agent.post("/admin/auth/login").send({ email, password });
return agent;
}
it("rejects every route without an admin session", async () => {
for (const path of [
"/admin/tech-steps/suggestions",
"/admin/tech-steps/corrections",
"/admin/tech-steps/training-data-snippet?techStepKey=simmer",
]) {
const res = await request(app).get(path);
expect(res.status, path).to.equal(401);
}
const post = await request(app).post("/admin/tech-steps/retrain").send({});
expect(post.status).to.equal(401);
});
describe("GET /suggestions", () => {
it("groups suggestions by technique and filters by status", async function () {
if (!adminSecretConfigured) {
// biome-ignore lint/suspicious/noExplicitAny: mocha's `this.skip()` isn't typed here.
(this as any).skip();
return;
}
await seedCorrectionAndSuggestion({
clause: "Faire mijoter",
correctedKey: "simmer",
withSuggestion: { status: "pending", synonyms: ["laisser frémir"] },
});
await seedCorrectionAndSuggestion({
clause: "Émincer les oignons",
correctedKey: "chop",
withSuggestion: { status: "applied", synonyms: ["ciseler"] },
});
const agent = await adminAgent();
const all = await agent.get("/admin/tech-steps/suggestions");
expect(all.status).to.equal(200);
expect(all.body.map((g: { techStepKey: string }) => g.techStepKey)).to.have.members([
"chop",
"simmer",
]);
const simmerGroup = all.body.find((g: { techStepKey: string }) => g.techStepKey === "simmer");
expect(simmerGroup.suggestions[0].sourceCorrection.clauseText).to.equal("Faire mijoter");
const pendingOnly = await agent
.get("/admin/tech-steps/suggestions")
.query({ status: "pending" });
expect(pendingOnly.body).to.have.length(1);
expect(pendingOnly.body[0].techStepKey).to.equal("simmer");
});
});
describe("PATCH /suggestions/:id", () => {
it("rejects an empty body with 400", async function () {
if (!adminSecretConfigured) {
// biome-ignore lint/suspicious/noExplicitAny: see above.
(this as any).skip();
return;
}
const { suggestion } = await seedCorrectionAndSuggestion({
clause: "Faire mijoter",
correctedKey: "simmer",
withSuggestion: { status: "pending", synonyms: ["x"] },
});
const agent = await adminAgent();
const res = await agent.patch(`/admin/tech-steps/suggestions/${suggestion?.id}`).send({});
expect(res.status).to.equal(400);
expect(res.body.code).to.equal(ErrorCode.VALIDATION_ERROR);
});
it("404s an unknown id", async function () {
if (!adminSecretConfigured) {
// biome-ignore lint/suspicious/noExplicitAny: see above.
(this as any).skip();
return;
}
const agent = await adminAgent();
const res = await agent
.patch("/admin/tech-steps/suggestions/999999")
.send({ status: "applied" });
expect(res.status).to.equal(404);
});
it("flips the status and edits the synonyms, reflected in a later GET", async function () {
if (!adminSecretConfigured) {
// biome-ignore lint/suspicious/noExplicitAny: see above.
(this as any).skip();
return;
}
const { suggestion } = await seedCorrectionAndSuggestion({
clause: "Faire mijoter",
correctedKey: "simmer",
withSuggestion: { status: "pending", synonyms: ["frémir"] },
});
const agent = await adminAgent();
const patched = await agent
.patch(`/admin/tech-steps/suggestions/${suggestion?.id}`)
.send({ status: "applied", suggestedSynonyms: ["frémir", "mijoter doucement"] });
expect(patched.status).to.equal(200);
expect(patched.body.status).to.equal("applied");
expect(patched.body.suggestedSynonyms).to.deep.equal(["frémir", "mijoter doucement"]);
const stored = await prisma.techStepTrainingSuggestion.findUniqueOrThrow({
where: { id: suggestion?.id },
});
expect(stored.status).to.equal("applied");
});
});
describe("GET /corrections", () => {
it("includes the 'no technique here' removals", async function () {
if (!adminSecretConfigured) {
// biome-ignore lint/suspicious/noExplicitAny: see above.
(this as any).skip();
return;
}
await seedCorrectionAndSuggestion({ clause: "Rien ici", correctedKey: null });
await seedCorrectionAndSuggestion({ clause: "Faire mijoter", correctedKey: "simmer" });
const agent = await adminAgent();
const res = await agent.get("/admin/tech-steps/corrections");
expect(res.status).to.equal(200);
expect(res.body).to.have.length(2);
const removals = await agent
.get("/admin/tech-steps/corrections")
.query({ hasCorrectedTechStep: "false" });
expect(removals.body).to.have.length(1);
expect(removals.body[0].clauseText).to.equal("Rien ici");
expect(removals.body[0].correctedTechStepKey).to.equal(null);
});
});
describe("GET /training-data-snippet", () => {
it("aggregates the applied suggestions' synonyms into a paste-ready block", async function () {
if (!adminSecretConfigured) {
// biome-ignore lint/suspicious/noExplicitAny: see above.
(this as any).skip();
return;
}
await seedCorrectionAndSuggestion({
clause: "Faire mijoter",
correctedKey: "simmer",
withSuggestion: { status: "applied", synonyms: ["frémir", "réduire à feu doux"] },
});
const agent = await adminAgent();
const res = await agent
.get("/admin/tech-steps/training-data-snippet")
.query({ techStepKey: "simmer", locale: "fr", status: "applied" });
expect(res.status).to.equal(200);
expect(res.body.suggestionCount).to.equal(1);
expect(res.body.synonyms).to.deep.equal(["frémir", "réduire à feu doux"]);
expect(res.body.snippet).to.include('"frémir"');
expect(res.body.snippet).to.include('"synonyms": [');
});
});
describe("POST /retrain", () => {
it("runs the F1 gate and returns its result shape (needs tech-step-intent-service)", async function () {
if (!adminSecretConfigured) {
// biome-ignore lint/suspicious/noExplicitAny: see above.
(this as any).skip();
return;
}
this.timeout(60000);
const agent = await adminAgent();
const res = await agent.post("/admin/tech-steps/retrain").send({});
expect(res.status).to.equal(200);
expect(res.body).to.have.keys([
"f1",
"precision",
"recall",
"minF1",
"gatePassed",
"backfilled",
"marked",
]);
expect(res.body.minF1).to.equal(0.8);
if (res.body.gatePassed) {
expect(res.body.backfilled).to.have.keys(["total", "changed"]);
}
});
it("returns 409 RETRAIN_ALREADY_RUNNING while one is in flight", async function () {
if (!adminSecretConfigured) {
// biome-ignore lint/suspicious/noExplicitAny: see above.
(this as any).skip();
return;
}
this.timeout(60000);
const agent = await adminAgent();
// supertest requests are lazy — they only dispatch when awaited/then'd.
// Attaching the `.then` here is what actually fires the first request,
// so its handler acquires the process-wide lock before the second one
// (100ms later) checks it. Swallow its result/rejection — this test
// only asserts on the second request.
const first = agent
.post("/admin/tech-steps/retrain")
.send({})
.then(
(res) => res,
(err) => err,
);
await new Promise((resolve) => setTimeout(resolve, 100));
const second = await agent.post("/admin/tech-steps/retrain").send({});
expect(second.status).to.equal(409);
expect(second.body.code).to.equal(ErrorCode.RETRAIN_ALREADY_RUNNING);
await first;
});
});
});

View file

@ -1,214 +0,0 @@
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");
const mixId = await techStepId("mix");
/**
* A recipe: one pure-prep "chop onion" step, then (optionally) an
* active "mix" step, then one simmer step. The `withActiveStep` recipe
* is still doing hands-on work in the phase after its simmer starts, so
* the other recipe's simmer floats into that phase as `background`.
*/
async function makeRecipe(name: string, onionQty: number, withActiveStep = false) {
const chopStep = {
order: 0,
description: "Émincer les oignons",
techSteps: {
create: [
{
techStepId: chopId,
order: 0,
ingredients: {
create: [
{
ingredientId: onionId,
quantity: onionQty,
unitId: pieceId,
start: 0,
end: 1,
},
],
},
},
],
},
};
const activeStep = {
order: 1,
description: "Mélanger l'appareil",
techSteps: { create: [{ techStepId: mixId, order: 0 }] },
};
const simmerStep = {
order: withActiveStep ? 2 : 1,
description: "Faire mijoter",
techSteps: { create: [{ techStepId: simmerId, order: 0 }] },
};
return prisma.recipe.create({
data: {
name,
authorId,
portions: 4,
steps: {
create: withActiveStep ? [chopStep, activeStep, simmerStep] : [chopStep, simmerStep],
},
},
});
}
const soupe = await makeRecipe("Soupe", 2);
const tarte = await makeRecipe("Tarte", 3, true);
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);
});
});
});

File diff suppressed because it is too large Load diff

View file

@ -1,196 +0,0 @@
import type { SignupInput } from "@batch-cooking/shared";
import { ErrorCode } 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 { resetDatabase } from "../test-support/reset-db.js";
/** See `auth.test.ts` — generated, never a real-looking person. */
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 }),
};
}
/** Resolves a reference unit's id by its seed uid (also its DB `key`). */
async function unitId(key: string): Promise<number> {
return (await prisma.unit.findFirstOrThrow({ where: { key } })).id;
}
/** Resolves a reference ingredient's id by its seed uid. */
async function ingredientId(key: string): Promise<number> {
return (await prisma.ingredient.findFirstOrThrow({ where: { key } })).id;
}
/**
* The off-catalog ("placeholder") ingredient escape hatch on `POST /recipes`
* / `PATCH /recipes/:id` a line with `placeholderName` instead of
* `ingredientId` creates a dedicated `Ingredient` row
* (`isPlaceholder: true`) so the recipe still saves, and that row is
* surfaced (with its `displayName`) inside the recipe but never in the
* browsable catalog.
*/
describe("Recipes — off-catalog placeholder ingredients", () => {
const app = createApp();
async function signup(): Promise<{ agent: ReturnType<typeof request.agent>; profileId: number }> {
const agent = request.agent(app);
const res = await agent.post("/auth/signup").send(buildSignupPayload());
return { agent, profileId: res.body.id };
}
beforeEach(async () => {
await resetDatabase();
});
after(async () => {
await prisma.$disconnect();
});
it("creates a placeholder Ingredient row for a `placeholderName` line and returns it inside the recipe", async () => {
const { agent, profileId } = await signup();
const piece = await unitId("piece");
const tomato = await ingredientId("tomato");
const res = await agent.post("/recipes").send({
name: "Poulet basquaise",
portions: 4,
dietIds: [],
ingredients: [
{ ingredientId: tomato, quantity: 3, unitId: piece },
{ placeholderName: "Piment d'Espelette", quantity: 1, unitId: piece },
],
steps: [{ description: "Tout mélanger" }],
});
expect(res.status).to.equal(201);
expect(res.body.ingredients).to.have.length(2);
const placeholderLine = res.body.ingredients.find(
(line: { ingredient: { isPlaceholder: boolean } }) => line.ingredient.isPlaceholder,
);
expect(placeholderLine, "a placeholder line is present").to.not.equal(undefined);
expect(placeholderLine.ingredient.displayName).to.equal("Piment d'Espelette");
expect(placeholderLine.ingredient.key).to.match(/^placeholder:/);
expect(placeholderLine.ingredient.allergens).to.deep.equal([]);
expect(placeholderLine.quantity).to.equal(1);
const row = await prisma.ingredient.findUniqueOrThrow({
where: { id: placeholderLine.ingredient.id },
});
expect(row.isPlaceholder).to.equal(true);
expect(row.displayName).to.equal("Piment d'Espelette");
expect(row.createdById).to.equal(profileId);
expect(row.createdAt).to.be.an.instanceOf(Date);
expect(row.reviewedAt).to.equal(null);
});
it("never lists placeholder rows in GET /reference/ingredients", async () => {
const { agent } = await signup();
const piece = await unitId("piece");
await agent.post("/recipes").send({
name: "Test",
portions: 2,
dietIds: [],
ingredients: [{ placeholderName: "Feuille de combava", quantity: 1, unitId: piece }],
steps: [{ description: "x" }],
});
const reference = await agent.get("/reference/ingredients");
expect(reference.status).to.equal(200);
expect(
reference.body.some(
(i: { isPlaceholder?: boolean; displayName?: string }) =>
i.isPlaceholder === true || i.displayName === "Feuille de combava",
),
).to.equal(false);
});
it("reuses the existing placeholder row on edit (no duplicate) and drops it when the line is removed", async () => {
const { agent } = await signup();
const piece = await unitId("piece");
const tomato = await ingredientId("tomato");
const created = await agent.post("/recipes").send({
name: "Édition",
portions: 2,
dietIds: [],
ingredients: [{ placeholderName: "Sumac", quantity: 1, unitId: piece }],
steps: [{ description: "x" }],
});
const placeholderId = created.body.ingredients[0].ingredient.id;
// Re-submit the same recipe, keeping the placeholder line by its real id.
const edited = await agent.patch(`/recipes/${created.body.id}`).send({
name: "Édition",
portions: 2,
dietIds: [],
ingredients: [
{ ingredientId: placeholderId, quantity: 2, unitId: piece },
{ ingredientId: tomato, quantity: 1, unitId: piece },
],
steps: [{ description: "x" }],
});
expect(edited.status).to.equal(200);
expect(await prisma.ingredient.count({ where: { isPlaceholder: true } })).to.equal(1);
// Now edit again, dropping the placeholder line entirely.
await agent.patch(`/recipes/${created.body.id}`).send({
name: "Édition",
portions: 2,
dietIds: [],
ingredients: [{ ingredientId: tomato, quantity: 1, unitId: piece }],
steps: [{ description: "x" }],
});
// The row is now an orphan (kept on purpose — the admin catalog view
// prunes it), but no *new* placeholder was created.
expect(await prisma.ingredient.count({ where: { isPlaceholder: true } })).to.equal(1);
});
it("rejects a line carrying both ingredientId and placeholderName with 400", async () => {
const { agent } = await signup();
const piece = await unitId("piece");
const tomato = await ingredientId("tomato");
const res = await agent.post("/recipes").send({
name: "Invalide",
portions: 2,
dietIds: [],
ingredients: [
{ ingredientId: tomato, placeholderName: "Tomate", quantity: 1, unitId: piece },
],
steps: [{ description: "x" }],
});
expect(res.status).to.equal(400);
expect(res.body.code).to.equal(ErrorCode.VALIDATION_ERROR);
});
it("allows two placeholder lines with the same text (each becomes its own row)", async () => {
const { agent } = await signup();
const piece = await unitId("piece");
const res = await agent.post("/recipes").send({
name: "Doublons libres",
portions: 2,
dietIds: [],
ingredients: [
{ placeholderName: "Herbes de garrigue", quantity: 1, unitId: piece },
{ placeholderName: "Herbes de garrigue", quantity: 2, unitId: piece },
],
steps: [{ description: "x" }],
});
expect(res.status).to.equal(201);
expect(res.body.ingredients).to.have.length(2);
expect(await prisma.ingredient.count({ where: { isPlaceholder: true } })).to.equal(2);
});
});

View file

@ -95,8 +95,6 @@ describe("Reference data", () => {
"reproducible",
"allergens",
"diets",
"isPlaceholder",
"displayName",
]);
});

View file

@ -1,99 +0,0 @@
// Mocks the admin API via cy.intercept — no live backend.
const adminBody = {
id: 1,
email: "ops@example.com",
name: "Ops",
createdAt: "2026-08-01T00:00:00.000Z",
lastLoginAt: "2026-08-28T09:00:00.000Z",
};
function pendingGroups() {
return [
{
normalizedName: "piment d espelette",
displayNames: ["Piment d'Espelette", "piment d espelette"],
ingredientIds: [11, 12],
recipeCount: 2,
sampleRecipes: [
{ id: 1, name: "Poulet basquaise" },
{ id: 2, name: "Piperade" },
],
firstSeenAt: "2026-08-20T10:00:00.000Z",
allReviewed: false,
},
{
normalizedName: "sumac",
displayNames: ["Sumac"],
ingredientIds: [13],
recipeCount: 1,
sampleRecipes: [{ id: 3, name: "Fattoush" }],
firstSeenAt: "2026-08-22T10:00:00.000Z",
allReviewed: false,
},
];
}
describe("Admin catalog — off-catalog ingredients", () => {
beforeEach(() => {
cy.viewport(1400, 900);
cy.intercept("GET", "**/admin/auth/me", { statusCode: 200, body: adminBody });
});
it("lists placeholder groups newest-impact first with their recipe count and spelling variants", () => {
cy.intercept("GET", "**/admin/catalog/placeholders*", {
statusCode: 200,
body: pendingGroups(),
}).as("getPlaceholders");
cy.visit("/admin/catalogue");
cy.wait("@getPlaceholders");
cy.get(".catalog-card").should("have.length", 2);
cy.get(".catalog-card").first().should("contain.text", "Piment d'Espelette");
cy.contains(".catalog-card", "Piment d'Espelette")
.should("contain.text", "2 recette")
.and("contain.text", "piment d espelette")
.and("contain.text", "Poulet basquaise");
});
it("marks a group reviewed and reloads the list", () => {
cy.intercept("GET", "**/admin/catalog/placeholders*", {
statusCode: 200,
body: pendingGroups(),
}).as("getPlaceholders");
cy.intercept("PATCH", "**/admin/catalog/placeholders/mark-reviewed", {
statusCode: 200,
body: { reviewed: 1 },
}).as("markReviewed");
cy.visit("/admin/catalogue");
cy.wait("@getPlaceholders");
cy.contains(".catalog-card", "Sumac").contains("button", "Marquer comme traité").click();
cy.wait("@markReviewed")
.its("request.body")
.should("deep.equal", { ingredientIds: [13] });
// The page re-fetches the list after the PATCH.
cy.get("@getPlaceholders.all").should("have.length.greaterThan", 1);
});
it("switches to the reviewed archive tab", () => {
// Regex, not a glob: the two calls differ only by the `?reviewed=true`
// query, and minimatch's `?` is itself a wildcard — a glob can't tell
// them apart reliably.
cy.intercept("GET", /\/admin\/catalog\/placeholders$/, {
statusCode: 200,
body: pendingGroups(),
});
cy.intercept("GET", /\/admin\/catalog\/placeholders\?reviewed=true$/, {
statusCode: 200,
body: [],
}).as("getReviewed");
cy.visit("/admin/catalogue");
cy.contains(".catalog-tabs button", "Traités").click();
cy.wait("@getReviewed");
cy.contains("Aucun ingrédient hors-catalogue").should("be.visible");
});
});

View file

@ -1,159 +0,0 @@
// Mocks the admin API via cy.intercept — no live backend.
const adminBody = {
id: 1,
email: "ops@example.com",
name: "Ops",
createdAt: "2026-08-01T00:00:00.000Z",
lastLoginAt: "2026-08-28T09:00:00.000Z",
};
function suggestionGroups() {
return [
{
techStepKey: "simmer",
suggestions: [
{
id: 11,
techStepKey: "simmer",
locale: "fr",
suggestedSynonyms: ["frémir"],
suggestedUtterances: ["laisser cuire tout doucement"],
sourceType: "correction",
status: "pending",
createdAt: "2026-08-20T00:00:00.000Z",
sourceCorrection: {
id: 5,
recipeId: 2,
stepId: 7,
clauseText: "faire mijoter la sauce",
previousTechStepKey: "cook",
correctedTechStepKey: "simmer",
},
},
],
},
];
}
function corrections() {
return [
{
id: 5,
recipeId: 2,
stepId: 7,
stepDescription: "Faire mijoter la sauce 20 min.",
clauseText: "faire mijoter la sauce",
start: 0,
end: 21,
previousTechStepKey: "cook",
correctedTechStepKey: "simmer",
createdAt: "2026-08-20T00:00:00.000Z",
consumedAt: null,
},
{
id: 6,
recipeId: 3,
stepId: 9,
stepDescription: "Réserver au frais.",
clauseText: "Réserver au frais",
start: 0,
end: 17,
previousTechStepKey: "setAside",
correctedTechStepKey: null,
createdAt: "2026-08-19T00:00:00.000Z",
consumedAt: null,
},
];
}
describe("Admin corrections triage", () => {
beforeEach(() => {
cy.viewport(1400, 1000);
cy.intercept("GET", "**/admin/auth/me", { statusCode: 200, body: adminBody });
cy.intercept("GET", "**/admin/tech-steps/suggestions*", {
statusCode: 200,
body: suggestionGroups(),
}).as("getSuggestions");
cy.intercept("GET", "**/admin/tech-steps/corrections*", {
statusCode: 200,
body: corrections(),
}).as("getCorrections");
});
it("shows the caveat, groups suggestions by technique, and applies one", () => {
cy.intercept("PATCH", "**/admin/tech-steps/suggestions/11", {
statusCode: 200,
body: { ...suggestionGroups()[0].suggestions[0], status: "applied" },
}).as("patch");
cy.visit("/admin/corrections");
cy.wait("@getSuggestions");
cy.contains(".corrections-caveat", "training_data.py").should("be.visible");
cy.contains(".suggestion-group h2", "simmer").should("be.visible");
cy.contains(".suggestion-card", "faire mijoter la sauce").should(
"contain.text",
"cook → simmer",
);
cy.contains(".suggestion-card button", "Appliquer").click();
cy.wait("@patch").its("request.body").should("deep.equal", { status: "applied" });
});
it("generates a training_data.py snippet", () => {
cy.intercept("GET", "**/admin/tech-steps/training-data-snippet*", {
statusCode: 200,
body: {
techStepKey: "simmer",
locale: "fr",
status: "applied",
suggestionCount: 2,
synonyms: ["frémir", "réduire"],
utterances: [],
snippet:
'# simmer (fr) — 2 suggestion(s) "applied"\n"synonyms": [\n "frémir",\n "réduire",\n],',
},
}).as("getSnippet");
cy.visit("/admin/corrections");
cy.get(".corrections-panel input").type("simmer");
cy.contains(".corrections-panel button", "Générer").click();
cy.wait("@getSnippet");
cy.get(".corrections-snippet").should("contain.value", '"synonyms": [');
});
it("runs the F1 gate and shows the result", () => {
cy.intercept("POST", "**/admin/tech-steps/retrain", {
statusCode: 200,
body: {
f1: 0.83,
precision: 0.8,
recall: 0.86,
minF1: 0.8,
gatePassed: true,
backfilled: { total: 120, changed: 4 },
marked: { applied: 0, rejected: 0 },
},
}).as("retrain");
cy.visit("/admin/corrections");
cy.contains(".corrections-panel--retrain button", "Lancer").click();
cy.wait("@retrain");
cy.contains(".retrain-result", "F1 0.830")
.should("have.class", "retrain-result--ok")
.and("contain.text", "4/120");
});
it("lists raw corrections including the removals, on the second tab", () => {
cy.visit("/admin/corrections");
cy.contains(".corrections-tabs button", "Corrections brutes").click();
cy.wait("@getCorrections");
cy.get(".corrections-table tbody tr").should("have.length", 2);
cy.contains(".corrections-table tr", "Réserver au frais").should(
"contain.text",
"setAside → ∅",
);
});
});

View file

@ -1,95 +0,0 @@
// Mocks the admin API via cy.intercept — no live backend.
const adminBody = {
id: 1,
email: "ops@example.com",
name: "Ops",
createdAt: "2026-08-01T00:00:00.000Z",
lastLoginAt: "2026-08-28T09:00:00.000Z",
};
/** A 3-day series helper for the fixture. */
function series(counts: number[]) {
return counts.map((count, i) => ({
date: `2026-08-${String(10 + i).padStart(2, "0")}`,
count,
}));
}
function metricsFixture() {
return {
generatedAt: "2026-08-28T09:00:00.000Z",
rangeDays: 30,
snapshot: {
admins: 2,
users: 42,
households: 15,
activeHouseholds: 9,
recipes: 120,
recipesManual: 30,
recipesImported: 90,
recipesBySource: [
{ key: "themealdb", label: "TheMealDB", count: 60 },
{ key: "marmiton", label: "Marmiton", count: 30 },
],
plannings: 18,
planningItems: 210,
steps: 640,
detectedTechniques: 900,
favorites: 55,
corrections: 12,
correctionsUnconsumed: 4,
correctionsRemoval: 2,
trainingSuggestions: 8,
trainingSuggestionsByStatus: [{ key: "pending", label: "pending", count: 8 }],
trainingSuggestionsBySourceType: [{ key: "correction", label: "correction", count: 8 }],
},
series: {
signups: series([1, 3, 2]),
recipesCreated: series([0, 2, 1]),
planningItemsAdded: series([4, 1, 5]),
correctionsSubmitted: series([0, 0, 1]),
trainingSuggestions: series([0, 1, 0]),
},
events: [{ type: "user.signup", buckets: series([1, 3, 2]) }],
};
}
describe("Admin dashboard", () => {
beforeEach(() => {
cy.viewport(1400, 900);
cy.intercept("GET", "**/admin/auth/me", { statusCode: 200, body: adminBody });
});
it("renders KPI tiles, a chart per series, and the breakdown lists", () => {
cy.intercept("GET", "**/admin/metrics*", { statusCode: 200, body: metricsFixture() }).as(
"getMetrics",
);
cy.visit("/admin");
cy.wait("@getMetrics").its("request.url").should("include", "days=30");
// KPI tiles — value + label.
cy.contains(".kpi-tile", "Utilisateurs").should("contain.text", "42");
cy.contains(".kpi-tile", "Recettes importées").should("contain.text", "90");
cy.contains(".kpi-tile", "Corrections à traiter").should("contain.text", "4");
// One chart card per instrumented series.
cy.get(".chart-card").should("have.length", 5);
cy.contains(".chart-card", "Inscriptions").should("contain.text", "6 sur 30 j");
// Breakdown lists.
cy.contains(".breakdown", "Recettes importées par source")
.should("contain.text", "TheMealDB")
.and("contain.text", "Marmiton");
cy.contains(".breakdown", "Évènements enregistrés").should("contain.text", "user.signup");
});
it("shows an error state when the metrics request fails", () => {
cy.intercept("GET", "**/admin/metrics*", {
statusCode: 500,
body: { code: 5000, message: "x" },
});
cy.visit("/admin");
cy.contains("Impossible de charger").should("be.visible");
});
});

View file

@ -1,62 +0,0 @@
// Mocks the admin API via cy.intercept — no live backend (apps/api's Mocha
// suite covers real /admin/* behaviour).
const adminBody = {
id: 1,
email: "ops@example.com",
name: "Ops",
createdAt: "2026-08-01T00:00:00.000Z",
lastLoginAt: "2026-08-28T09:00:00.000Z",
};
describe("Admin layout", () => {
it("redirects to /login when there is no admin session", () => {
cy.intercept("GET", "**/admin/auth/me", {
statusCode: 401,
body: { code: 4011, message: "no" },
});
cy.visit("/admin/monitoring");
cy.url().should("include", "/admin/login");
cy.contains("h1", "Administration").should("be.visible");
});
it("shows the sidebar and navigates between the sections", () => {
cy.intercept("GET", "**/admin/auth/me", { statusCode: 200, body: adminBody });
cy.visit("/admin");
cy.contains("h1", "Tableau de bord").should("be.visible");
cy.contains(".admin-sidebar__who", "Ops").should("be.visible");
cy.contains("nav a", "Monitoring").click();
cy.url().should("include", "/admin/monitoring");
cy.contains("h1", "Monitoring").should("be.visible");
cy.contains("nav a", "Monitoring").should("have.class", "active");
cy.contains("nav a", "Corrections").click();
cy.url().should("include", "/admin/corrections");
cy.contains("h1", "Corrections").should("be.visible");
cy.intercept("GET", "**/admin/catalog/placeholders*", { statusCode: 200, body: [] });
cy.contains("nav a", "Catalogue").click();
cy.url().should("include", "/admin/catalogue");
cy.contains("h1", "Ingrédients hors-catalogue").should("be.visible");
cy.contains("nav a", "Tableau de bord").click();
cy.url().should("eq", `${Cypress.config().baseUrl}/admin`);
});
it("logs out back to /login", () => {
cy.intercept("GET", "**/admin/auth/me", { statusCode: 200, body: adminBody });
cy.intercept("POST", "**/admin/auth/logout", { statusCode: 204 });
cy.visit("/admin");
// Wait until the guarded layout has actually mounted before acting.
cy.contains("h1", "Tableau de bord").should("be.visible");
// Logout clears the in-memory admin state, which is what bounces the
// guard to /login — no fresh `me` round-trip involved, so nothing to
// re-stub here.
cy.contains("button", "Se déconnecter").click();
cy.url().should("include", "/admin/login");
});
});

View file

@ -1,24 +0,0 @@
Feature: Admin login
As an operator
I want to sign in to the admin application
So that I can reach the metrics, monitoring and correction-triage sections
Scenario: A wrong password shows a translated error, no redirect
Given the admin session check returns unauthenticated
And admin login fails with invalid credentials
When I visit "/admin/login"
And I fill in the "email" field with "ops@example.com"
And I fill in the "password" field with "wrong"
And I click the button "Se connecter"
Then I should see "Email ou mot de passe incorrect"
And the URL should include "/admin/login"
Scenario: A correct login lands on the dashboard
Given the admin session check returns unauthenticated
And admin login succeeds as "Ops"
When I visit "/admin/login"
And I fill in the "email" field with "ops@example.com"
And I fill in the "password" field with "correct-horse"
And I click the button "Se connecter"
Then the URL should not include "/admin/login"
And I should see the heading "Tableau de bord"

View file

@ -1,35 +0,0 @@
import { Given } from "@badeball/cypress-cucumber-preprocessor";
// Admin-specific steps for `admin-login.feature`. Generic navigation / form
// steps ("I visit", "I fill in the … field", "I click the button", "I
// should see …") are reused from `support/step_definitions/common.steps.ts`;
// only the `/admin/*` API mocks live here. Every admin call is stubbed via
// `cy.intercept` — this suite never runs a live backend (apps/api's Mocha
// suite covers real `/admin/*` behaviour).
const adminBody = {
id: 1,
email: "ops@example.com",
name: "Ops",
createdAt: "2026-08-01T00:00:00.000Z",
lastLoginAt: "2026-08-28T09:00:00.000Z",
};
Given("the admin session check returns unauthenticated", () => {
cy.intercept("GET", "**/admin/auth/me", { statusCode: 401, body: { code: 4011, message: "no" } });
});
Given("admin login fails with invalid credentials", () => {
cy.intercept("POST", "**/admin/auth/login", {
statusCode: 401,
body: { code: 4010, message: "Invalid email or password" },
});
});
Given("admin login succeeds as {string}", (name: string) => {
const body = { ...adminBody, name, email: `${name.toLowerCase()}@example.com` };
cy.intercept("POST", "**/admin/auth/login", { statusCode: 200, body });
// After navigate("/admin"), RequireAdmin re-checks the session — from now
// on it must report authenticated (last matching intercept wins).
cy.intercept("GET", "**/admin/auth/me", { statusCode: 200, body });
});

View file

@ -1,95 +0,0 @@
// Mocks the admin API via cy.intercept — no live backend.
const adminBody = {
id: 1,
email: "ops@example.com",
name: "Ops",
createdAt: "2026-08-01T00:00:00.000Z",
lastLoginAt: "2026-08-28T09:00:00.000Z",
};
function monitoringFixture() {
return {
generatedAt: "2026-08-28T09:15:00.000Z",
services: [
{
key: "postgres",
status: "up",
latencyMs: 3.2,
detail: null,
checkedAt: "2026-08-28T09:15:00.000Z",
},
{
key: "api",
status: "up",
latencyMs: 0,
detail: "uptime 3 h 12 min · RSS 120 Mo",
checkedAt: "2026-08-28T09:15:00.000Z",
},
{
key: "intent-service",
status: "down",
latencyMs: null,
detail: "fetch failed",
checkedAt: "2026-08-28T09:15:00.000Z",
},
{
key: "tech-step-llm-worker",
status: "degraded",
latencyMs: null,
detail: "dernier battement il y a 9 j",
checkedAt: "2026-08-28T09:15:00.000Z",
lastRunAt: "2026-08-19T03:00:00.000Z",
lastResult: { job: "audit-low-confidence", ok: true, counts: { suggestions: 2 } },
},
],
};
}
describe("Admin monitoring", () => {
beforeEach(() => {
cy.viewport(1400, 900);
cy.intercept("GET", "**/admin/auth/me", { statusCode: 200, body: adminBody });
});
// The page route `/admin/monitoring` and the API path `GET /admin/monitoring`
// are identical — a plain `**/admin/monitoring` glob would also match the
// `cy.visit()` document request (and whether that's same-origin or not
// depends on `VITE_API_URL`, which isn't set in CI). `resourceType: "fetch"`
// pins the intercept to the `AdminApiClient` XHR only.
const monitoringApi = {
method: "GET",
url: "**/admin/monitoring",
resourceType: "fetch",
} as const;
it("renders one card per service with its status and details", () => {
cy.intercept(monitoringApi, {
statusCode: 200,
body: monitoringFixture(),
}).as("getMonitoring");
cy.visit("/admin/monitoring");
cy.wait("@getMonitoring");
cy.get(".monitoring-card").should("have.length", 4);
cy.contains(".monitoring-card", "Base de données")
.should("have.class", "monitoring-card--up")
.and("contain.text", "3.2 ms");
cy.contains(".monitoring-card", "Service NLP (spaCy)")
.should("have.class", "monitoring-card--down")
.and("contain.text", "Hors service");
cy.contains(".monitoring-card", "Worker LLM")
.should("have.class", "monitoring-card--degraded")
.and("contain.text", "audit-low-confidence");
});
it("shows an error state when the request fails", () => {
cy.intercept(monitoringApi, {
statusCode: 500,
body: { code: 5000, message: "x" },
});
cy.visit("/admin/monitoring");
cy.contains("Impossible de charger").should("be.visible");
});
});

View file

@ -1,174 +0,0 @@
// 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");
});
});

View file

@ -1,24 +0,0 @@
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

View file

@ -1,101 +0,0 @@
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");
});

View file

@ -111,43 +111,6 @@ 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,

View file

@ -45,21 +45,6 @@ Feature: Recipe form — associating ingredients
And I add a step
Then there should be 2 step editor items
Scenario: Adds an off-catalog ingredient as a free-text placeholder when nothing in the picker matches
Given creating the recipe will succeed and return id 43
When I visit "/recettes/nouvelle"
And I fill in the "recipe-name" field with "Poulet basquaise"
And I search the ingredient picker for "piment d'espelette"
Then the picker should offer to add "piment d'espelette" as a placeholder
When I add "piment d'espelette" as a placeholder ingredient
Then the recipe should include the placeholder ingredient "piment d'espelette"
When I fill in the ingredient's quantity with "1" and unit "unité"
And I add a step
And I fill in the step description with "Tout mélanger."
And I click the button "Enregistrer"
Then the recipe creation request should have included a placeholder ingredient "piment d'espelette" with quantity 1 and unitId 1
And the URL should include "/recettes/43"
Scenario: Excludes an already-selected ingredient from the picker, and removing it brings it back
When I visit "/recettes/nouvelle"
And I select the ingredient "Carotte" from the picker

View file

@ -70,31 +70,6 @@ Then(
},
);
Then("the picker should offer to add {string} as a placeholder", (name: string) => {
cy.get(".ingredient-picker__add-placeholder").should("contain.text", name);
});
When("I add {string} as a placeholder ingredient", () => {
// The search field already holds the query from the previous step, so the
// "add « … »" button carries the right name.
cy.get(".ingredient-picker__add-placeholder").click();
});
Then("the recipe should include the placeholder ingredient {string}", (name: string) => {
cy.contains(".ingredient-row__name", name)
.find(".ingredient-row__placeholder-badge")
.should("be.visible");
});
Then(
"the recipe creation request should have included a placeholder ingredient {string} with quantity {int} and unitId {int}",
(placeholderName: string, quantity: number, unitId: number) => {
cy.wait("@createRecipe")
.its("request.body.ingredients")
.should("deep.equal", [{ placeholderName, quantity, unitId }]);
},
);
Given("recipe 7 exists with an egg omelette", () => {
const existingRecipe = {
id: 7,

View file

@ -273,10 +273,6 @@ 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();
});

View file

@ -22,7 +22,6 @@
"react-dom": "^18.3.1",
"react-i18next": "^17.0.11",
"react-router-dom": "^7.18.2",
"recharts": "^2.15.0",
"zod": "^3.25.76"
},
"devDependencies": {

View file

@ -1,18 +1,9 @@
import { Navigate, Outlet, Route, Routes } from "react-router-dom";
import { AdminAuthProvider } from "./features/admin/AdminAuthContext";
import { RequireAdmin } from "./features/admin/RequireAdmin";
import { Navigate, Route, Routes } from "react-router-dom";
import { RedirectIfAuthenticated } from "./features/auth/RedirectIfAuthenticated";
import { RequireAuth } from "./features/auth/RequireAuth";
import { AdminLayout } from "./layouts/AdminLayout";
import { AppLayout } from "./layouts/AppLayout";
import { CatalogPage as AdminCatalogPage } from "./pages/admin/catalog/CatalogPage";
import { CorrectionsPage as AdminCorrectionsPage } from "./pages/admin/corrections/CorrectionsPage";
import { DashboardPage as AdminDashboardPage } from "./pages/admin/dashboard/DashboardPage";
import { AdminLoginPage } from "./pages/admin/login/AdminLoginPage";
import { MonitoringPage as AdminMonitoringPage } from "./pages/admin/monitoring/MonitoringPage";
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";
@ -51,15 +42,6 @@ import { ShoppingListPage } from "./pages/shopping-list/ShoppingListPage";
* `/onboarding/sources` is conditional only reached when the `foyer` step
* created/joined a household (see `OnboardingHouseholdPage`'s `goToNextStep`);
* skipped otherwise, straight to `/onboarding/allergenes`.
*
* `/admin/*` is the internal admin surface (metrics, monitoring, correction
* triage, off-catalog ingredient review). It's its own top-level group,
* wrapped in {@link AdminAuthProvider} so the `GET /admin/auth/me` session
* probe only runs under `/admin` the admin session (cookie `admin_session`,
* `ADMIN_JWT_SECRET`) is entirely separate from the end-user one, so this is
* never nested under `RequireAuth`. `/admin/login` is the only
* unauthenticated admin route; everything else sits under `RequireAdmin` +
* `AdminLayout`.
*/
export function App() {
return (
@ -83,7 +65,6 @@ 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 />} />
@ -139,28 +120,6 @@ export function App() {
</RedirectIfAuthenticated>
}
/>
<Route
element={
<AdminAuthProvider>
<Outlet />
</AdminAuthProvider>
}
>
<Route path="/admin/login" element={<AdminLoginPage />} />
<Route
path="/admin"
element={
<RequireAdmin>
<AdminLayout />
</RequireAdmin>
}
>
<Route index element={<AdminDashboardPage />} />
<Route path="monitoring" element={<AdminMonitoringPage />} />
<Route path="corrections" element={<AdminCorrectionsPage />} />
<Route path="catalogue" element={<AdminCatalogPage />} />
</Route>
</Route>
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
);

View file

@ -1,192 +0,0 @@
import {
type AdminLoginInput,
type AdminUserView,
type ApiErrorResponse,
type CatalogPlaceholderGroupView,
type CorrectionAdminView,
ErrorCode,
type MarkPlaceholdersReviewedInput,
type MetricsView,
type MonitoringView,
type PruneOrphansResultView,
type RetrainRequestInput,
type RetrainResultView,
type TrainingDataSnippetView,
type TrainingSuggestionAdminView,
type TrainingSuggestionGroupView,
type UpdateTrainingSuggestionInput,
} from "@batch-cooking/shared";
/** Builds a `?a=b&c=d` string from defined values only. */
function query(params: Record<string, string | undefined>): string {
const entries = Object.entries(params).filter(
(entry): entry is [string, string] => entry[1] !== undefined && entry[1] !== "",
);
return entries.length === 0 ? "" : `?${new URLSearchParams(entries).toString()}`;
}
/**
* Base URL of the API the same `VITE_API_URL` the user-facing
* {@link apiClient} reads (see `api/client.ts`). Defaults to `""` (same
* origin) correct behind a shared reverse proxy; native dev overrides it
* to `http://localhost:3000` in `apps/web/.env`. The `/admin/*` surface is
* served by the same API process as the rest of the app.
*/
const ADMIN_API_BASE_URL: string = import.meta.env.VITE_API_URL ?? "";
/**
* Thrown by {@link AdminApiClient} on any non-2xx response carries the
* {@link ErrorCode} the API returned. Distinct from `api/client.ts`'s
* `ApiError` (same shape) so a file importing both never has a name clash;
* the admin transport layer stays independent of the user-facing one.
*/
export class AdminApiError extends Error {
public readonly status: number;
public readonly code: ErrorCode;
public readonly fieldErrors?: Record<string, string[] | undefined>;
public constructor(status: number, body: ApiErrorResponse) {
super(body.message);
this.name = "AdminApiError";
this.status = status;
this.code = body.code;
this.fieldErrors = body.details;
}
}
/**
* Thin fetch wrapper around the `/admin/*` endpoints same design as
* apps/web's `ApiClient` (a class for cohesion/extensibility, one shared
* stateless instance). Every request sends credentials so the
* `admin_session` httpOnly cookie round-trips.
*/
export class AdminApiClient {
/**
* Performs a JSON request against the admin API and returns the parsed body.
*
* @throws {AdminApiError} if the response status is not in the 2xx range.
*/
private async _request<TResponseBody>(
path: string,
options: RequestInit = {},
): Promise<TResponseBody> {
try {
const response = await fetch(`${ADMIN_API_BASE_URL}${path}`, {
...options,
credentials: "include",
headers: { "Content-Type": "application/json", ...options.headers },
});
if (!response.ok) {
const body = (await response.json().catch(() => null)) as ApiErrorResponse | null;
throw new AdminApiError(
response.status,
body ?? { code: ErrorCode.INTERNAL_ERROR, message: "Something went wrong" },
);
}
if (response.status === 204) {
return undefined as TResponseBody;
}
return (await response.json()) as TResponseBody;
} catch (err) {
// Rethrown as-is — callers surface it their own way; this is just the
// one place the fetch/`await` sits in a try/catch per the repo's rule.
throw err;
}
}
/** Verifies admin credentials and starts an admin session. */
public login(input: AdminLoginInput): Promise<AdminUserView> {
return this._request("/admin/auth/login", { method: "POST", body: JSON.stringify(input) });
}
/** Ends the current admin session. */
public logout(): Promise<void> {
return this._request("/admin/auth/logout", { method: "POST" });
}
/** Fetches the currently authenticated admin — rejects with `NOT_AUTHENTICATED` if there's no session. */
public me(): Promise<AdminUserView> {
return this._request("/admin/auth/me");
}
/** Usage metrics for the dashboard — snapshot totals + `days` (7365) of daily time series. */
public getMetrics(days: number): Promise<MetricsView> {
return this._request(`/admin/metrics?days=${days}`);
}
/** Live health of Postgres, the API, the intent-service and the LLM worker — polled by the monitoring board. */
public getMonitoring(): Promise<MonitoringView> {
return this._request("/admin/monitoring");
}
/** Training suggestions, grouped by technique, filtered by the given (all-optional) criteria. */
public getSuggestions(filters: {
status?: string;
sourceType?: string;
techStepKey?: string;
locale?: string;
}): Promise<TrainingSuggestionGroupView[]> {
return this._request(`/admin/tech-steps/suggestions${query(filters)}`);
}
/** Raw user corrections, including the "no technique here" removals. */
public getCorrections(filters: {
consumed?: string;
hasCorrectedTechStep?: string;
}): Promise<CorrectionAdminView[]> {
return this._request(`/admin/tech-steps/corrections${query(filters)}`);
}
/** Edits a suggestion's proposed synonyms/utterances and/or its status. */
public updateSuggestion(
id: number,
body: UpdateTrainingSuggestionInput,
): Promise<TrainingSuggestionAdminView> {
return this._request(`/admin/tech-steps/suggestions/${id}`, {
method: "PATCH",
body: JSON.stringify(body),
});
}
/** The ready-to-paste `training_data.py` block aggregating suggestions for one technique/locale/status. */
public getTrainingDataSnippet(params: {
techStepKey: string;
locale?: string;
status?: string;
}): Promise<TrainingDataSnippetView> {
return this._request(`/admin/tech-steps/training-data-snippet${query(params)}`);
}
/** Runs the F1 gate + backfill (+ marks suggestion ids). Rejects with `RETRAIN_ALREADY_RUNNING` if one is in flight. */
public retrain(body: RetrainRequestInput): Promise<RetrainResultView> {
return this._request("/admin/tech-steps/retrain", {
method: "POST",
body: JSON.stringify(body),
});
}
/** Off-catalog ingredient "placeholders" users typed, grouped by normalized name. `reviewed` omitted/`"false"` = the still-to-triage list, `"true"` = the archive. */
public getPlaceholders(reviewed?: "true" | "false"): Promise<CatalogPlaceholderGroupView[]> {
return this._request(`/admin/catalog/placeholders${query({ reviewed })}`);
}
/** Marks the given placeholder ingredient ids as triaged (`reviewedAt`). */
public markPlaceholdersReviewed(
body: MarkPlaceholdersReviewedInput,
): Promise<{ reviewed: number }> {
return this._request("/admin/catalog/placeholders/mark-reviewed", {
method: "PATCH",
body: JSON.stringify(body),
});
}
/** Deletes placeholder rows no recipe references any more. */
public pruneOrphanPlaceholders(): Promise<PruneOrphansResultView> {
return this._request("/admin/catalog/placeholders/prune-orphans", { method: "POST" });
}
}
/** Single shared instance — stateless, same reasoning as apps/web's `apiClient`. */
export const adminApiClient = new AdminApiClient();

View file

@ -9,7 +9,6 @@ import {
type HouseView,
type IngredientView,
type LoginInput,
type OptimizedCookingPlanView,
type PlanningItemView,
type PlanningView,
type PreferencesView,
@ -178,19 +177,6 @@ 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");

View file

@ -1,71 +0,0 @@
import type { AdminLoginInput, AdminUserView } from "@batch-cooking/shared";
import { createContext, type ReactNode, useCallback, useContext, useEffect, useState } from "react";
import { adminApiClient } from "../../api/admin-client";
/** Auth state/actions exposed via {@link useAdminAuth} — the admin-app counterpart of apps/web's `AuthContext`. */
interface AdminAuthContextValue {
/** Currently authenticated admin, or `null` if no active session. */
admin: AdminUserView | null;
/** True only while the initial `GET /admin/auth/me` check is pending — lets `RequireAdmin` avoid a premature redirect. */
isLoading: boolean;
/** Verifies credentials and updates `admin` on success. Throws `ApiError` on failure. */
login: (input: AdminLoginInput) => Promise<void>;
/** Ends the session and clears `admin`. */
logout: () => Promise<void>;
}
const AdminAuthContext = createContext<AdminAuthContextValue | null>(null);
/**
* Provides admin authentication state to the whole app. On mount, calls
* `GET /admin/auth/me` once to restore the session from the `admin_session`
* httpOnly cookie (if any) same "reload keeps you logged in" behaviour as
* apps/web's `AuthProvider`.
*/
export function AdminAuthProvider({ children }: { children: ReactNode }) {
const [admin, setAdmin] = useState<AdminUserView | null>(null);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
adminApiClient
.me()
.then(setAdmin)
// No/invalid session — the normal state for a first visit, not an error.
.catch(() => setAdmin(null))
.finally(() => setIsLoading(false));
}, []);
const login = useCallback(async (input: AdminLoginInput) => {
try {
setAdmin(await adminApiClient.login(input));
} catch (err) {
// Rethrown as-is — `LoginPage`'s submit handler catches and displays
// it; this callback just isn't allowed a bare `await`.
throw err;
}
}, []);
const logout = useCallback(async () => {
try {
await adminApiClient.logout();
setAdmin(null);
} catch (err) {
throw err; // see login()'s catch comment
}
}, []);
return (
<AdminAuthContext.Provider value={{ admin, isLoading, login, logout }}>
{children}
</AdminAuthContext.Provider>
);
}
/** Reads the current admin auth state/actions. Must be called within an {@link AdminAuthProvider}. */
export function useAdminAuth(): AdminAuthContextValue {
const ctx = useContext(AdminAuthContext);
if (!ctx) {
throw new Error("useAdminAuth must be used within an AdminAuthProvider");
}
return ctx;
}

View file

@ -1,22 +0,0 @@
import type { ReactNode } from "react";
import { Navigate } from "react-router-dom";
import { useAdminAuth } from "./AdminAuthContext";
/**
* Route guard for every admin page. Renders nothing while the initial
* `GET /admin/auth/me` check is pending (avoids a flash-then-redirect);
* once resolved, renders `children` or redirects to `/admin/login`. Mirror
* of `RequireAuth` (`features/auth/`), but keyed to the separate admin
* session (`admin_session` cookie), not the user one.
*/
export function RequireAdmin({ children }: { children: ReactNode }) {
const { admin, isLoading } = useAdminAuth();
if (isLoading) {
return null;
}
if (!admin) {
return <Navigate to="/admin/login" replace />;
}
return <>{children}</>;
}

View file

@ -1,84 +0,0 @@
// =============================================================================
// Admin login card the only unauthenticated screen. A centered card on a
// plain background, same language as apps/web's auth-form.scss (kept its own
// copy rather than shared, the two apps' chrome is independent).
// =============================================================================
.admin-auth-page {
min-height: 100vh;
display: grid;
place-items: center;
padding: var(--space-lg);
background: var(--color-background);
}
.admin-auth-card {
width: 100%;
max-width: var(--max-width-form);
display: flex;
flex-direction: column;
gap: var(--space-sm);
padding: var(--space-xl);
background: var(--color-surface);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-md);
h1 {
font-size: var(--font-size-xl);
margin-bottom: var(--space-sm);
}
label {
font-size: var(--font-size-sm);
font-weight: 600;
color: var(--color-text-muted);
}
input {
padding: var(--space-sm);
font-size: var(--font-size-base);
font-family: var(--font-body);
border: 1.5px solid var(--color-border);
border-radius: var(--radius-base);
background: var(--color-surface);
color: var(--color-text);
&:focus-visible {
border-color: var(--color-primary);
}
}
button[type="submit"] {
margin-top: var(--space-sm);
padding: var(--space-sm) var(--space-md);
font-size: var(--font-size-base);
font-weight: 600;
font-family: var(--font-body);
color: var(--color-surface);
background: var(--color-primary);
border: none;
border-radius: var(--radius-base);
cursor: pointer;
&:hover {
background: var(--color-primary-hover);
}
&:disabled {
opacity: 0.6;
cursor: not-allowed;
}
}
.field-error {
margin: 0;
font-size: var(--font-size-xs);
color: var(--color-error);
}
.form-error {
margin: var(--space-xs) 0 0;
font-size: var(--font-size-sm);
color: var(--color-error);
}
}

View file

@ -17,7 +17,6 @@ import { Dialog } from "../../components/ui/Dialog";
import { errorMessageService } from "../../services/error-message.service";
import { DietTagSelect } from "../recipes/badges/DietTagSelect";
import { IngredientPicker } from "../recipes/ingredients/IngredientPicker";
import { ingredientLabel } from "../recipes/ingredients/ingredient-label";
import { RecipeDetailPanel, type RecipeDetailState } from "../recipes/RecipeDetailPanel";
import { RecipeTable } from "../recipes/RecipeTable";
import {
@ -448,7 +447,7 @@ export function RecipePickerDialog({
<div className="recipe-picker__chips">
{selectedIngredients.map((ingredient) => (
<span key={ingredient.id} className="filter-chip">
{ingredientLabel(ingredient, t)}
{t(`catalog.ingredients.${ingredient.key}`)}
<button
type="button"
onClick={() =>

View file

@ -11,7 +11,6 @@ import { IngredientPicker } from "../recipes/ingredients/IngredientPicker";
// picker" visual family, even though this field lives in the profile
// feature).
import { IngredientTypeIcon } from "../recipes/ingredients/ingredient-icons";
import { ingredientLabel } from "../recipes/ingredients/ingredient-label";
import "../recipes/recipes.scss";
import "./profile-forms.scss";
@ -55,7 +54,7 @@ export function DislikedIngredientsField({
<span aria-hidden="true">
<IngredientTypeIcon icon={ingredient.icon} />
</span>
{ingredientLabel(ingredient, t)}
{t(`catalog.ingredients.${ingredient.key}`)}
<button
type="button"
onClick={() => remove(ingredient.id)}

View file

@ -7,7 +7,6 @@ import { SourceLinkIcon } from "../../layouts/nav-icons";
import { errorMessageService } from "../../services/error-message.service";
import { AllergenBadges } from "./badges/AllergenBadges";
import { FavoriteStarButton } from "./badges/FavoriteStarButton";
import { ingredientLabel } from "./ingredients/ingredient-label";
import { StepDescription } from "./steps/StepDescription";
import "./recipes.scss";
@ -181,7 +180,7 @@ export function RecipeDetailPanel({
<ul className="disliked-badges">
{dislikedIngredients.map((ingredient) => (
<li key={ingredient.id} className="disliked-badge">
🚫 {ingredientLabel(ingredient, t)}
🚫 {t(`catalog.ingredients.${ingredient.key}`)}
</li>
))}
</ul>

View file

@ -13,7 +13,6 @@ import { AllergenBadges } from "../badges/AllergenBadges";
import { DietBadges } from "../badges/DietBadges";
import { ReproducibleBadge } from "../badges/ReproducibleBadge";
import { CategoryIcon, IngredientTypeIcon, SubcategoryIcon } from "./ingredient-icons";
import { ingredientLabel } from "./ingredient-label";
import "../recipes.scss";
/** "No filter at this level" — a UI-only pseudo-value, never sent to/received from the API (see {@link INGREDIENT_CATEGORIES}/{@link INGREDIENT_CATEGORY_SUBCATEGORIES} for the real, closed sets). */
@ -41,25 +40,15 @@ const ALL = "ALL" as const;
* just browsing/searching by name. A labeled checkbox menu rather than two
* bare icon-only toggle buttons those turned out too ambiguous on their
* own (unclear what each icon meant without a label attached).
*
* `onAddPlaceholder`, when passed, turns the "aucun ingrédient trouvé"
* empty state into an escape hatch: with a non-empty search query, an
* "ajouter « … » comme ingrédient à compléter" button lets the user add a
* free-text line so the catalog gap doesn't block them (see
* `Ingredient.isPlaceholder` in schema.prisma). Omitted on pickers where
* that makes no sense a profile's disliked-ingredients field, a search
* filter.
*/
export function IngredientPicker({
ingredients,
excludeIds,
onSelect,
onAddPlaceholder,
}: {
ingredients: IngredientView[];
excludeIds: number[];
onSelect: (ingredient: IngredientView) => void;
onAddPlaceholder?: (name: string) => void;
}) {
const { t } = useTranslation();
const [category, setCategory] = useState<IngredientCategory | typeof ALL>(ALL);
@ -91,26 +80,17 @@ export function IngredientPicker({
// key — searching "œuf" should find "Œuf" the way it always has, not
// require typing its key.
if (normalizedQuery.length > 0) {
const label = ingredientLabel(ingredient, t).toLowerCase();
const label = t(`catalog.ingredients.${ingredient.key}`).toLowerCase();
if (!label.includes(normalizedQuery)) return false;
}
return true;
});
const trimmedQuery = query.trim();
function handleSelect(ingredient: IngredientView) {
onSelect(ingredient);
setQuery("");
}
function handleAddPlaceholder() {
if (onAddPlaceholder && trimmedQuery.length > 0) {
onAddPlaceholder(trimmedQuery);
setQuery("");
}
}
return (
<div className="ingredient-picker">
<div className="ingredient-picker__search">
@ -191,18 +171,7 @@ export function IngredientPicker({
)}
{visible.length === 0 ? (
<div className="ingredient-picker__empty">
<p>{t("recipes.form.noIngredientFound")}</p>
{onAddPlaceholder && trimmedQuery.length > 0 && (
<button
type="button"
className="ingredient-picker__add-placeholder"
onClick={handleAddPlaceholder}
>
{t("recipes.form.addPlaceholderButton", { name: trimmedQuery })}
</button>
)}
</div>
<p className="ingredient-picker__empty">{t("recipes.form.noIngredientFound")}</p>
) : (
<div className="ingredient-picker__grid">
{visible.map((ingredient) => (
@ -215,7 +184,9 @@ export function IngredientPicker({
<span className="ingredient-picker__card-icon" aria-hidden="true">
<IngredientTypeIcon icon={ingredient.icon} />
</span>
<span className="ingredient-picker__card-name">{ingredientLabel(ingredient, t)}</span>
<span className="ingredient-picker__card-name">
{t(`catalog.ingredients.${ingredient.key}`)}
</span>
{showAllergens && <AllergenBadges allergens={ingredient.allergens} />}
{showDiets && <DietBadges diets={ingredient.diets} />}
{showReproducible && <ReproducibleBadge reproducible={ingredient.reproducible} />}

View file

@ -4,7 +4,6 @@ import { AllergenBadges } from "../badges/AllergenBadges";
import { DietBadges } from "../badges/DietBadges";
import { ReproducibleBadge } from "../badges/ReproducibleBadge";
import { IngredientTypeIcon } from "./ingredient-icons";
import { ingredientLabel } from "./ingredient-label";
import "../recipes.scss";
/** One selected ingredient line in the recipe form — the ingredient itself (picked via `IngredientPicker`) plus its quantity/unit for this recipe. Quantity is kept as a raw string while editing (not parsed to a number until submit) so an in-progress/invalid value doesn't fight the input; `unit` picks from `unitsCatalog` (a closed reference list, see `Unit` in schema.prisma) rather than free text. */
@ -37,25 +36,13 @@ export function IngredientRow({
// them, or why).
const unitMissing = unitId === null;
const needsAttention = unitMissing || duplicate;
// A free-text placeholder line (the catalog had nothing matching) — shown
// with an "à compléter" badge and none of the allergen/diet/reproducible
// badges, which carry no meaning until a maintainer promotes it to a real
// catalog entry. Its quantity/unit still work exactly like any other line.
const isPlaceholder = ingredient.isPlaceholder;
return (
<li className={needsAttention ? "ingredient-row ingredient-row--incomplete" : "ingredient-row"}>
<span className="ingredient-row__icon" aria-hidden="true">
<IngredientTypeIcon icon={ingredient.icon} />
</span>
<span className="ingredient-row__name">
{ingredientLabel(ingredient, t)}
{isPlaceholder && (
<span className="ingredient-row__placeholder-badge">
{t("recipes.form.placeholderBadge")}
</span>
)}
</span>
<span className="ingredient-row__name">{t(`catalog.ingredients.${ingredient.key}`)}</span>
<input
type="number"
min="0"
@ -81,16 +68,12 @@ export function IngredientRow({
</option>
))}
</select>
{!isPlaceholder && (
<>
<AllergenBadges allergens={ingredient.allergens} />
<DietBadges diets={ingredient.diets} />
<ReproducibleBadge
reproducible={ingredient.reproducible}
searchLabel={ingredientLabel(ingredient, t)}
/>
</>
)}
<AllergenBadges allergens={ingredient.allergens} />
<DietBadges diets={ingredient.diets} />
<ReproducibleBadge
reproducible={ingredient.reproducible}
searchLabel={t(`catalog.ingredients.${ingredient.key}`)}
/>
<button
type="button"
className="ingredient-row__remove"

View file

@ -1,26 +0,0 @@
import type { IngredientView } from "@batch-cooking/shared";
/**
* The display name for an ingredient line.
*
* A real catalog ingredient has no name in the database its French label
* lives in i18n under `catalog.ingredients.<key>` so it's resolved
* through `t`. A **placeholder** (`IngredientView.isPlaceholder`, the
* free text a user typed when the catalog fell short see
* `Ingredient.isPlaceholder` in schema.prisma) has its name stored on the
* row as `displayName` and no i18n key at all, so it's shown verbatim.
*
* Every place that used to inline `t(\`catalog.ingredients.${ingredient.key}\`)`
* goes through this instead, so a placeholder never renders as the raw,
* missing translation key `catalog.ingredients.placeholder:<uuid>`.
*
* `t` is passed in (rather than calling `useTranslation` here) so this
* stays a plain function usable from non-component code and unit-testable
* without mounting i18next same split as `shopping-list.ts`.
*/
export function ingredientLabel(
ingredient: Pick<IngredientView, "key" | "displayName">,
t: (key: string) => string,
): string {
return ingredient.displayName ?? t(`catalog.ingredients.${ingredient.key}`);
}

View file

@ -1,50 +0,0 @@
import type { IngredientView } from "@batch-cooking/shared";
/**
* Sentinel `id` for a placeholder ingredient line the user just added in
* the recipe form but that has **no database row yet** the API creates
* the real `Ingredient` row (with a real id) when the recipe is saved (see
* `recipe.service.ts`'s `resolveIngredientLines`). Never sent to the API:
* the submit payload uses `placeholderName`, not `ingredientId`, for these
* lines. Kept out of `excludeIds`/duplicate checks by testing
* `isPlaceholder` rather than the id.
*/
export const UNSAVED_PLACEHOLDER_ID = 0;
/**
* Builds the in-memory {@link IngredientView} for a brand-new placeholder
* line (the "add « … » as an ingredient to complete" action in
* `IngredientPicker`). Shaped exactly like what the API returns for a
* placeholder once persisted `isPlaceholder: true`, the typed text as
* `displayName`, a generic `JAR` icon, default aisle, no allergen/diet
* info so `IngredientRow` renders it through the same path as an
* edit-loaded placeholder with no special-casing.
*/
export function makePlaceholderIngredientView(name: string): IngredientView {
return {
id: UNSAVED_PLACEHOLDER_ID,
key: "",
icon: "JAR",
category: "dryGoods",
subcategory: "other",
reproducible: false,
allergens: [],
diets: [],
isPlaceholder: true,
displayName: name,
};
}
/**
* Whether this line is a placeholder the user just added that has **no
* database row yet** the submit payload must describe it with
* `placeholderName` (asking the API to create the row) rather than
* `ingredientId`. An *edit-loaded* placeholder fails this test: it already
* has a real id and round-trips as a normal `{ ingredientId }` line, so
* editing a recipe never spawns a duplicate placeholder row.
*/
export function isUnsavedPlaceholder(
ingredient: Pick<IngredientView, "id" | "isPlaceholder">,
): boolean {
return ingredient.isPlaceholder && ingredient.id === UNSAVED_PLACEHOLDER_ID;
}

View file

@ -1218,19 +1218,6 @@
margin-right: auto;
}
// Marks a free-text placeholder line (nothing in the catalog matched)
// the ingredient still needs a real catalog entry created by a
// maintainer, but the line is usable (quantity/unit) in the meantime.
&__placeholder-badge {
margin-left: var(--space-xs);
padding: 0 var(--space-xs);
font-size: var(--font-size-xs);
font-weight: 500;
border-radius: var(--radius-base);
background: var(--color-surface-alt);
color: var(--color-text-muted);
}
&__quantity {
width: 5rem;
}
@ -1525,37 +1512,11 @@
font-size: var(--font-size-sm);
}
// Now a container (not a bare `<p>`) the "aucun ingrédient trouvé"
// message plus, when the picker allows it, an "ajouter « … » comme
// ingrédient à compléter" escape-hatch button stacked under it.
&__empty {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--space-sm);
padding: var(--space-md);
font-size: var(--font-size-sm);
color: var(--color-text-muted);
text-align: center;
p {
margin: 0;
}
}
&__add-placeholder {
padding: var(--space-xs) var(--space-md);
font-size: var(--font-size-sm);
cursor: pointer;
border: 1px dashed var(--color-border);
border-radius: var(--radius-base);
background: var(--color-surface);
color: var(--color-text);
&:hover {
border-color: var(--color-accent);
color: var(--color-accent);
}
}
}

View file

@ -19,17 +19,13 @@ import { errorMessageService } from "../../../services/error-message.service";
import { DietTagSelect } from "../badges/DietTagSelect";
import { IngredientPicker } from "../ingredients/IngredientPicker";
import { IngredientRow } from "../ingredients/IngredientRow";
import {
isUnsavedPlaceholder,
makePlaceholderIngredientView,
} from "../ingredients/placeholder-ingredient";
import { type StepDraft, StepListEditor } from "../steps/StepListEditor";
import "../recipes.scss";
/** In display order — mirrors `RecipeVisibility` (schema.prisma/shared types). Same list as `RecipeFormPage`. */
const VISIBILITY_OPTIONS: RecipeVisibility[] = ["PERSONAL", "HOUSE", "PUBLIC"];
/** A resolved ingredient line — identical shape to `RecipeFormPage`'s own `IngredientLine` (`ingredient` may be a synthetic placeholder view, see there). */
/** A resolved ingredient line — identical shape to `RecipeFormPage`'s own `IngredientLine`. */
interface IngredientLine {
key: string;
ingredient: IngredientView;
@ -37,7 +33,7 @@ interface IngredientLine {
unitId: number | null;
}
/** One draft line that didn't resolve to a real `Ingredient` on preview (`DraftRecipeIngredientView.ingredient === null`) — still needs a person to pick the right one, keep it as a free-text placeholder, or discard it, before this recipe can be saved. */
/** One draft line that didn't resolve to a real `Ingredient` on preview (`DraftRecipeIngredientView.ingredient === null`) — still needs a person to pick the right one, or discard it, before this recipe can be saved. */
interface UnresolvedIngredientLine {
key: string;
rawText: string;
@ -211,26 +207,6 @@ export function RecipeImportForm({
setResolvingKey(null);
}
/** Keeps an unresolved line as a free-text placeholder — the catalog had nothing matching, but the ingredient shouldn't be silently dropped (see `Ingredient.isPlaceholder` in schema.prisma). Its unit still needs picking, same as a resolved line. */
function keepUnresolvedAsPlaceholder(unresolvedKey: string) {
setUnresolvedIngredients((lines) => {
const line = lines.find((l) => l.key === unresolvedKey);
if (line) {
setIngredientLines((resolved) => [
...resolved,
{
key: makeClientKey(),
ingredient: makePlaceholderIngredientView(line.rawText.trim()),
quantity: line.quantity,
unitId: null,
},
]);
}
return lines.filter((l) => l.key !== unresolvedKey);
});
setResolvingKey((current) => (current === unresolvedKey ? null : current));
}
function discardUnresolvedIngredient(key: string) {
setUnresolvedIngredients((lines) => lines.filter((line) => line.key !== key));
setResolvingKey((current) => (current === key ? null : current));
@ -254,10 +230,6 @@ export function RecipeImportForm({
const seen = new Set<number>();
const duplicates = new Set<number>();
for (const line of ingredientLines) {
// Placeholder lines have no catalog id (all share the sentinel 0) and
// each becomes its own fresh row server-side — two of them never
// collide on `RecipeIngredient`'s `(recipeId, ingredientId)` key.
if (line.ingredient.isPlaceholder) continue;
if (seen.has(line.ingredient.id)) duplicates.add(line.ingredient.id);
seen.add(line.ingredient.id);
}
@ -288,12 +260,7 @@ export function RecipeImportForm({
visibility,
dietIds,
ingredients: ingredientLines.map((line) => ({
// Free-text lines (nothing in the catalog matched) submit as
// `placeholderName` so the API creates the placeholder row — same
// branch as RecipeFormPage's identical submit.
...(isUnsavedPlaceholder(line.ingredient)
? { placeholderName: line.ingredient.displayName ?? "" }
: { ingredientId: line.ingredient.id }),
ingredientId: line.ingredient.id,
quantity: Number(line.quantity),
// `canSubmit` already requires every line to have a unit picked —
// same "?? 0, the schema rejects it if ever reached" reasoning as
@ -364,9 +331,7 @@ export function RecipeImportForm({
);
}
const selectedIds = ingredientLines
.filter((line) => !line.ingredient.isPlaceholder)
.map((line) => line.ingredient.id);
const selectedIds = ingredientLines.map((line) => line.ingredient.id);
return (
<form className="recipe-form" onSubmit={handleSubmit} noValidate>
@ -456,9 +421,6 @@ export function RecipeImportForm({
>
{t("recipes.sources.import.resolveButton")}
</button>
<button type="button" onClick={() => keepUnresolvedAsPlaceholder(line.key)}>
{t("recipes.sources.import.keepAsPlaceholderButton")}
</button>
<button type="button" onClick={() => discardUnresolvedIngredient(line.key)}>
{t("recipes.sources.import.discardButton")}
</button>

View file

@ -12,7 +12,6 @@ import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { ApiError, apiClient } from "../../../api/client";
import { errorMessageService } from "../../../services/error-message.service";
import { ingredientLabel } from "../ingredients/ingredient-label";
import { CatalogSearchPicker } from "./CatalogSearchPicker";
import type { TextSelectionRange } from "./use-text-selection";
@ -350,7 +349,7 @@ export function TechStepCorrectionPopover({
<CatalogSearchPicker
items={(catalogs?.ingredients ?? []).map((ingredient) => ({
id: ingredient.id,
label: ingredientLabel(ingredient, t),
label: t(`catalog.ingredients.${ingredient.key}`),
}))}
onSelect={setPickedIngredientId}
placeholder={t("recipes.form.searchIngredientPlaceholder")}
@ -448,7 +447,7 @@ export function TechStepCorrectionPopover({
const view = ingredientById.get(ingredient.ingredientId);
const unit =
ingredient.unitId !== null ? unitById.get(ingredient.unitId) : undefined;
const label = view ? ingredientLabel(view, t) : "…";
const label = view ? t(`catalog.ingredients.${view.key}`) : "…";
return (
// biome-ignore lint/suspicious/noArrayIndexKey: `pendingIngredients` has no other stable identity (an ingredient can appear more than once, each with its own span) — always fully rebuilt on add/remove, never reordered in place.
<li key={index} className="tech-step-correction-popover__chip">

View file

@ -1,108 +0,0 @@
// =============================================================================
// Admin app shell a fixed left sidebar + scrollable main content area.
// Simpler than apps/web's AppLayout (no collapsible rail, no nested submenu)
// an internal ops tool, three sections.
// =============================================================================
.admin-layout {
display: flex;
min-height: 100vh;
}
.admin-sidebar {
flex-shrink: 0;
width: 15rem;
display: flex;
flex-direction: column;
padding: var(--space-lg) var(--space-md);
background: var(--color-surface);
border-right: 1px solid var(--color-border);
&__brand {
font-family: var(--font-display);
font-weight: 700;
font-size: var(--font-size-lg);
color: var(--color-text);
margin-bottom: var(--space-lg);
span {
color: var(--color-accent);
}
}
&__nav {
display: flex;
flex-direction: column;
gap: 0.15rem;
a {
display: flex;
align-items: center;
gap: var(--space-sm);
padding: var(--space-sm);
border-radius: var(--radius-base);
color: var(--color-text-muted);
text-decoration: none;
font-size: var(--font-size-sm);
font-weight: 600;
&:hover {
background: var(--color-surface-alt);
color: var(--color-text);
}
&.active {
background: color-mix(in srgb, var(--color-primary) 12%, var(--color-surface));
color: var(--color-primary);
}
}
}
&__footer {
margin-top: auto;
display: flex;
flex-direction: column;
gap: var(--space-xs);
padding-top: var(--space-md);
border-top: 1px solid var(--color-border);
}
&__who {
font-size: var(--font-size-sm);
font-weight: 600;
color: var(--color-text);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
&__footer button {
padding: var(--space-xs) var(--space-sm);
font-size: var(--font-size-sm);
font-family: var(--font-body);
color: var(--color-text-muted);
background: none;
border: 1px solid var(--color-border);
border-radius: var(--radius-base);
cursor: pointer;
text-align: left;
&:hover {
color: var(--color-text);
border-color: var(--color-primary);
}
}
&__version {
margin: 0;
font-size: var(--font-size-xs);
color: var(--color-text-muted);
}
}
.admin-content {
flex: 1;
min-width: 0;
padding: var(--space-xl);
overflow: auto;
}

View file

@ -1,86 +0,0 @@
import { Activity, LayoutDashboard, ListChecks, PackageSearch } from "lucide-react";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { NavLink, Outlet, useNavigate } from "react-router-dom";
import { useAdminAuth } from "../features/admin/AdminAuthContext";
import "./AdminLayout.scss";
/**
* One entry in the admin sidebar's nav. `key` maps to `admin.nav.<key>` in
* the locale file adding a section is one array entry plus one locale key.
* Paths are absolute under `/admin` (the admin route group lives inside
* `apps/web`'s `App.tsx`, mounted at `/admin`).
*/
const NAV_ITEMS = [
{ to: "/admin", key: "dashboard", Icon: LayoutDashboard, end: true },
{ to: "/admin/monitoring", key: "monitoring", Icon: Activity, end: false },
{ to: "/admin/corrections", key: "corrections", Icon: ListChecks, end: false },
{ to: "/admin/catalogue", key: "catalog", Icon: PackageSearch, end: false },
] as const;
/**
* Shell for every authenticated admin page: a fixed sidebar (brand, section
* nav, the signed-in admin's name + logout) plus a main area rendering the
* matched child route via `<Outlet />`. Mounted once as the parent of the
* whole `RequireAdmin`-guarded route group (see `App.tsx`), so `admin` is
* guaranteed non-null here.
*/
export function AdminLayout() {
const { t } = useTranslation();
const { admin, logout } = useAdminAuth();
const navigate = useNavigate();
const [isLoggingOut, setIsLoggingOut] = useState(false);
async function handleLogout() {
setIsLoggingOut(true);
try {
await logout();
void navigate("/admin/login");
} catch {
// Even if the network call failed, the local session state was
// cleared optimistically enough for the guard to bounce to /login;
// nothing useful to show the operator here.
void navigate("/admin/login");
}
}
return (
<div className="admin-layout">
<aside className="admin-sidebar">
<div className="admin-sidebar__brand">
batchCooking <span>Admin</span>
</div>
<nav className="admin-sidebar__nav">
{NAV_ITEMS.map(({ to, key, Icon, end }) => (
<NavLink
key={to}
to={to}
end={end}
className={({ isActive }) => (isActive ? "active" : undefined)}
>
<Icon size={18} aria-hidden="true" />
<span>{t(`admin.nav.${key}`)}</span>
</NavLink>
))}
</nav>
<div className="admin-sidebar__footer">
<span className="admin-sidebar__who" title={admin?.email}>
{admin?.name}
</span>
<button type="button" onClick={handleLogout} disabled={isLoggingOut}>
{t("admin.layout.logout")}
</button>
<p className="admin-sidebar__version" aria-hidden="true">
v{__APP_VERSION__}
</p>
</div>
</aside>
<main className="admin-content">
<Outlet />
</main>
</div>
);
}

View file

@ -46,8 +46,7 @@
"TECH_STEP_NOT_FOUND": "Cette technique n'existe pas",
"UTENSIL_NOT_FOUND": "Un des ustensiles sélectionnés n'existe pas",
"INVALID_CORRECTION_SPAN": "La sélection ne correspond plus au texte de l'étape",
"INTERNAL_ERROR": "Une erreur est survenue, réessayez plus tard",
"RETRAIN_ALREADY_RUNNING": "Un ré-entraînement est déjà en cours"
"INTERNAL_ERROR": "Une erreur est survenue, réessayez plus tard"
},
"auth": {
"login": {
@ -122,7 +121,6 @@
},
"planning": {
"title": "Planning de la semaine",
"startCooking": "Commencer à cuisiner",
"loading": "Chargement du planning…",
"meals": {
"petit-dejeuner": "Petit-déjeuner",
@ -217,9 +215,8 @@
"planningHint": "Cette recette sera automatiquement ajoutée à votre planning une fois importée.",
"loadError": "Impossible de charger cette recette pour le moment.",
"unresolvedTitle": "Ingrédients à compléter",
"unresolvedHint": "Ces lignes n'ont pas été reconnues automatiquement — choisissez le bon ingrédient, gardez le texte tel quel à compléter plus tard, ou retirez-les.",
"unresolvedHint": "Ces lignes n'ont pas été reconnues automatiquement — choisissez le bon ingrédient, ou retirez-les.",
"resolveButton": "Choisir un ingrédient",
"keepAsPlaceholderButton": "Garder tel quel",
"discardButton": "Retirer cette ligne",
"submit": "Importer",
"submitting": "Import en cours…",
@ -256,8 +253,6 @@
"allCategories": "Tout",
"allSubcategories": "Tout",
"noIngredientFound": "Aucun ingrédient trouvé.",
"addPlaceholderButton": "Ajouter « {{name}} » comme ingrédient à compléter",
"placeholderBadge": "à compléter",
"category": {
"freshProduce": "Produits frais",
"meatAndSeafood": "Boucherie & poissonnerie",
@ -315,28 +310,6 @@
"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": {
@ -1124,142 +1097,5 @@
"palmSugar": "Sucre de palme",
"caneSyrup": "Sirop de sucre de canne"
}
},
"admin": {
"common": {
"comingSoon": "Section à venir.",
"loading": "Chargement…",
"loadError": "Impossible de charger les données, réessayez plus tard."
},
"login": {
"title": "Administration",
"emailLabel": "Email",
"passwordLabel": "Mot de passe",
"submit": "Se connecter",
"submitting": "Connexion…"
},
"nav": {
"dashboard": "Tableau de bord",
"monitoring": "Monitoring",
"corrections": "Corrections",
"catalog": "Catalogue"
},
"layout": {
"logout": "Se déconnecter"
},
"dashboard": {
"title": "Tableau de bord",
"lead": "Métriques d'utilisation de l'application.",
"windowTotal": "{{n}} sur 30 j",
"recipesBySource": "Recettes importées par source",
"noImports": "Aucune recette importée.",
"events": "Évènements enregistrés (30 j)",
"kpi": {
"users": "Utilisateurs",
"households": "Foyers",
"activeHouseholds": "Foyers actifs",
"recipes": "Recettes",
"recipesImported": "Recettes importées",
"plannings": "Plannings",
"planningItems": "Créneaux planifiés",
"favorites": "Favoris",
"corrections": "Corrections",
"correctionsUnconsumed": "Corrections à traiter",
"trainingSuggestions": "Suggestions d'entraînement",
"admins": "Administrateurs"
},
"series": {
"signups": "Inscriptions",
"recipesCreated": "Recettes créées",
"planningItemsAdded": "Ajouts au planning",
"correctionsSubmitted": "Corrections soumises",
"trainingSuggestions": "Suggestions générées"
}
},
"monitoring": {
"title": "Monitoring",
"lead": "Santé des microservices et de la base de données.",
"lastChecked": "Dernière vérification à {{time}}",
"latency": "Latence",
"detail": "Détail",
"lastRun": "Dernier job",
"never": "jamais",
"jobFailed": "échec",
"status": {
"up": "OK",
"degraded": "Dégradé",
"down": "Hors service",
"unknown": "Inconnu"
},
"service": {
"postgres": "Base de données",
"api": "API",
"intent-service": "Service NLP (spaCy)",
"tech-step-llm-worker": "Worker LLM"
}
},
"corrections": {
"title": "Corrections",
"lead": "Tri des corrections utilisateur pour le ré-entraînement NLP.",
"caveat": "Le gate F1 + backfill n'a de sens qu'APRÈS avoir édité training_data.py à la main et redémarré le service NLP (il ne s'entraîne qu'au démarrage). Cet écran ne peut faire ni l'un ni l'autre.",
"noSuggestions": "Aucune suggestion pour ces filtres.",
"synonyms": "Synonymes proposés (un par ligne)",
"utterances": "Phrases proposées (une par ligne)",
"save": "Enregistrer",
"apply": "Appliquer",
"reject": "Rejeter",
"tab": {
"suggestions": "Suggestions",
"corrections": "Corrections brutes"
},
"filter": {
"status": "Statut",
"source": "Source",
"consumed": "Consommée",
"hasCorrected": "Technique corrigée",
"any": "Toutes",
"yes": "Oui",
"no": "Non"
},
"snippet": {
"title": "Snippet training_data.py",
"help": "Agrège les synonymes/phrases des suggestions « applied » d'une technique, au format à coller dans training_data.py.",
"keyPlaceholder": "clé de technique (ex. simmer)",
"generate": "Générer"
},
"retrain": {
"title": "Gate F1 + backfill",
"help": "Lance l'évaluation de régression F1 puis, si elle passe, recalcule les techniques de toutes les étapes.",
"run": "Lancer",
"running": "En cours…",
"passed": "OK — {{changed}}/{{total}} étape(s) recalculée(s)",
"failed": "Échec du gate — aucun backfill"
},
"col": {
"clause": "Clause",
"change": "Changement",
"created": "Créée",
"consumed": "Consommée"
}
},
"catalog": {
"title": "Ingrédients hors-catalogue",
"lead": "Ingrédients saisis en texte libre par les utilisateurs parce que le catalogue ne les couvrait pas. Regroupés par nom normalisé — à promouvoir dans reference-seed-data.ts + les locales, à la main.",
"empty": "Aucun ingrédient hors-catalogue.",
"tab": {
"pending": "À traiter",
"reviewed": "Traités"
},
"recipeCount": "{{count}} recette(s)",
"alsoWritten": "Aussi écrit : {{variants}}",
"seenIn": "Vu dans :",
"firstSeen": "Première fois le {{date}}",
"markReviewed": "Marquer comme traité",
"marking": "…",
"pruneOrphans": "Purger les orphelins",
"pruning": "Purge…",
"prunedNone": "Aucun placeholder orphelin à purger.",
"pruned": "{{count}} placeholder(s) orphelin(s) supprimé(s)."
}
}
}

View file

@ -1,26 +0,0 @@
// =============================================================================
// Shared chrome for every routed admin page a page title and an optional
// lead paragraph. Individual pages add their own colocated .scss for their
// specific content (charts, tables, status board) on top.
// =============================================================================
.admin-page {
&__title {
font-size: var(--font-size-2xl);
margin-bottom: var(--space-xs);
}
&__lead {
margin: 0 0 var(--space-lg);
color: var(--color-text-muted);
font-size: var(--font-size-md);
}
&__placeholder {
padding: var(--space-xl);
border: 1px dashed var(--color-border);
border-radius: var(--radius-md);
color: var(--color-text-muted);
text-align: center;
}
}

View file

@ -1,179 +0,0 @@
import type { CatalogPlaceholderGroupView } from "@batch-cooking/shared";
import { useCallback, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { adminApiClient } from "../../../api/admin-client";
import "../admin-page.scss";
import "./catalog-page.scss";
import { type CatalogTab, formatDate, reviewedParam, splitSpellings } from "./catalog";
type CatalogState =
| { status: "loading" }
| { status: "loaded"; groups: CatalogPlaceholderGroupView[] }
| { status: "error" };
/**
* Off-catalog ingredient review. Lists every placeholder `Ingredient` (the
* free text users typed when the seeded catalog fell short see
* `Ingredient.isPlaceholder` in the API schema), grouped by normalized
* name, so a maintainer sees what the catalog is missing and how many
* recipes are waiting on it. Actions are deliberately minimal: mark a gap
* as handled, or purge rows no recipe references any more. Actually adding
* the catalog entry stays a manual edit of `reference-seed-data.ts` + the
* locale files.
*/
export function CatalogPage() {
const { t } = useTranslation();
const [tab, setTab] = useState<CatalogTab>("pending");
const [state, setState] = useState<CatalogState>({ status: "loading" });
const [pendingIds, setPendingIds] = useState<number[] | null>(null);
const [pruneMessage, setPruneMessage] = useState<string | null>(null);
const [isPruning, setIsPruning] = useState(false);
// Monotonic id of the most recent `load()` call — a response from an
// earlier one (switching tabs fast, or React 18 StrictMode's double-mount
// firing the effect twice) must not clobber the current tab's data.
const requestSeq = useRef(0);
const load = useCallback((forTab: CatalogTab) => {
const seq = ++requestSeq.current;
setState({ status: "loading" });
adminApiClient
.getPlaceholders(reviewedParam(forTab))
.then((groups) => {
if (seq === requestSeq.current) setState({ status: "loaded", groups });
})
.catch(() => {
if (seq === requestSeq.current) setState({ status: "error" });
});
}, []);
useEffect(() => {
load(tab);
}, [tab, load]);
function markReviewed(group: CatalogPlaceholderGroupView) {
setPendingIds(group.ingredientIds);
adminApiClient
.markPlaceholdersReviewed({ ingredientIds: group.ingredientIds })
.then(() => load(tab))
.catch(() => load(tab))
.finally(() => setPendingIds(null));
}
function pruneOrphans() {
setIsPruning(true);
setPruneMessage(null);
adminApiClient
.pruneOrphanPlaceholders()
.then(({ deleted }) => {
setPruneMessage(
deleted === 0
? t("admin.catalog.prunedNone")
: t("admin.catalog.pruned", { count: deleted }),
);
load(tab);
})
.catch(() => setPruneMessage(t("admin.common.loadError")))
.finally(() => setIsPruning(false));
}
return (
<div className="admin-page">
<h1 className="admin-page__title">{t("admin.catalog.title")}</h1>
<p className="admin-page__lead">{t("admin.catalog.lead")}</p>
<div className="catalog-toolbar">
<div className="catalog-tabs" role="tablist">
{(["pending", "reviewed"] as const).map((value) => (
<button
key={value}
type="button"
role="tab"
aria-selected={tab === value}
className={tab === value ? "active" : undefined}
onClick={() => setTab(value)}
>
{t(`admin.catalog.tab.${value}`)}
</button>
))}
</div>
<button type="button" onClick={pruneOrphans} disabled={isPruning}>
{isPruning ? t("admin.catalog.pruning") : t("admin.catalog.pruneOrphans")}
</button>
</div>
{pruneMessage && <p className="catalog-prune-message">{pruneMessage}</p>}
{state.status === "loading" && (
<p className="admin-page__placeholder">{t("admin.common.loading")}</p>
)}
{state.status === "error" && (
<p className="admin-page__placeholder">{t("admin.common.loadError")}</p>
)}
{state.status === "loaded" &&
(state.groups.length === 0 ? (
<p className="admin-page__placeholder">{t("admin.catalog.empty")}</p>
) : (
<ul className="catalog-list">
{state.groups.map((group) => (
<PlaceholderGroupCard
key={group.normalizedName}
group={group}
busy={pendingIds === group.ingredientIds}
onMarkReviewed={() => markReviewed(group)}
/>
))}
</ul>
))}
</div>
);
}
function PlaceholderGroupCard({
group,
busy,
onMarkReviewed,
}: {
group: CatalogPlaceholderGroupView;
busy: boolean;
onMarkReviewed: () => void;
}) {
const { t } = useTranslation();
const { headline, variants } = splitSpellings(group);
const firstSeen = formatDate(group.firstSeenAt);
return (
<li className="catalog-card">
<div className="catalog-card__head">
<h2 className="catalog-card__name">{headline}</h2>
<span className="catalog-card__count">
{t("admin.catalog.recipeCount", { count: group.recipeCount })}
</span>
</div>
{variants.length > 0 && (
<p className="catalog-card__variants">
{t("admin.catalog.alsoWritten", { variants: variants.join(" · ") })}
</p>
)}
{group.sampleRecipes.length > 0 && (
<p className="catalog-card__recipes">
{t("admin.catalog.seenIn")} {group.sampleRecipes.map((recipe) => recipe.name).join(", ")}
</p>
)}
<div className="catalog-card__foot">
{firstSeen && (
<span className="catalog-card__seen">
{t("admin.catalog.firstSeen", { date: firstSeen })}
</span>
)}
{!group.allReviewed && (
<button type="button" onClick={onMarkReviewed} disabled={busy}>
{busy ? t("admin.catalog.marking") : t("admin.catalog.markReviewed")}
</button>
)}
</div>
</li>
);
}

View file

@ -1,144 +0,0 @@
// =============================================================================
// CatalogPage the off-catalog ingredient review: a pending/reviewed tab
// switch + "purge orphans" action, then one card per grouped placeholder.
// Mirrors CorrectionsPage's tab/card vocabulary so the admin app stays
// visually consistent.
// =============================================================================
.catalog-toolbar {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: var(--space-md);
margin-bottom: var(--space-md);
// Right-hand "purge orphans" button a secondary/destructive action, so
// outlined rather than filled like the primary actions elsewhere.
> button {
padding: var(--space-xs) var(--space-md);
font-family: var(--font-body);
font-size: var(--font-size-sm);
font-weight: 600;
color: var(--color-text-muted);
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-base);
cursor: pointer;
&:hover {
border-color: var(--color-error);
color: var(--color-error);
}
&:disabled {
opacity: 0.6;
cursor: not-allowed;
}
}
}
.catalog-tabs {
display: flex;
gap: var(--space-xs);
button {
padding: var(--space-sm) var(--space-md);
font-family: var(--font-body);
font-size: var(--font-size-sm);
font-weight: 600;
color: var(--color-text-muted);
background: none;
border: 1px solid var(--color-border);
border-radius: var(--radius-base);
cursor: pointer;
&.active {
color: var(--color-primary);
border-color: var(--color-primary);
background: color-mix(in srgb, var(--color-primary) 10%, var(--color-surface));
}
}
}
.catalog-prune-message {
margin: 0 0 var(--space-md);
font-size: var(--font-size-sm);
color: var(--color-text-muted);
}
.catalog-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: var(--space-sm);
}
.catalog-card {
padding: var(--space-md);
background: var(--color-surface);
border: 1px solid var(--color-border);
border-left: 4px solid var(--color-accent);
border-radius: var(--radius-md);
&__head {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: var(--space-sm);
}
&__name {
font-size: var(--font-size-md);
margin: 0;
}
&__count {
flex-shrink: 0;
font-size: var(--font-size-xs);
font-weight: 600;
color: var(--color-text-muted);
}
&__variants,
&__recipes {
margin: var(--space-xs) 0 0;
font-size: var(--font-size-sm);
color: var(--color-text-muted);
}
&__foot {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: var(--space-sm);
margin-top: var(--space-sm);
}
&__seen {
font-size: var(--font-size-xs);
color: var(--color-text-muted);
}
&__foot button {
padding: var(--space-xs) var(--space-md);
font-family: var(--font-body);
font-size: var(--font-size-sm);
font-weight: 600;
color: var(--color-surface);
background: var(--color-primary);
border: none;
border-radius: var(--radius-base);
cursor: pointer;
&:hover {
background: var(--color-primary-hover);
}
&:disabled {
opacity: 0.6;
cursor: not-allowed;
}
}
}

View file

@ -1,34 +0,0 @@
import type { CatalogPlaceholderGroupView } from "@batch-cooking/shared";
/**
* Pure helpers for `CatalogPage` kept out of the `.tsx` per repo
* convention, unit-tested on their own.
*/
/** Which server-side list a UI tab maps to — `pending` sends no `reviewed` param (the working list), `reviewed` sends `reviewed=true` (the archive). */
export type CatalogTab = "pending" | "reviewed";
/** `CatalogTab` → the `reviewed` query value `adminApiClient.getPlaceholders` expects. */
export function reviewedParam(tab: CatalogTab): "true" | undefined {
return tab === "reviewed" ? "true" : undefined;
}
/**
* Splits a group's spellings into the one to show as the card title and the
* rest to list as "aussi écrit : …". The API already sorts `displayNames`
* alphabetically and none is more canonical than another, so the first is
* as good a headline as any the point of the group is that they're the
* same missing ingredient.
*/
export function splitSpellings(group: CatalogPlaceholderGroupView): {
headline: string;
variants: string[];
} {
const [headline = group.normalizedName, ...variants] = group.displayNames;
return { headline, variants };
}
/** `"2026-08-28T09:00:00.000Z"` → `"28/08/2026"` for the "première fois le …" line. `null` → `null`. */
export function formatDate(iso: string | null): string | null {
return iso === null ? null : new Date(iso).toLocaleDateString("fr-FR");
}

View file

@ -1,401 +0,0 @@
import {
type CorrectionAdminView,
ErrorCode,
type RetrainResultView,
type TrainingSuggestionAdminView,
type TrainingSuggestionGroupView,
} from "@batch-cooking/shared";
import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { AdminApiError, adminApiClient } from "../../../api/admin-client";
import { errorMessageService } from "../../../services/error-message.service";
import "../admin-page.scss";
import "./corrections-page.scss";
import { linesToList, listsDiffer, listToLines } from "./corrections";
type Tab = "suggestions" | "corrections";
/**
* Tech-step correction triage. Two tabs curated `TrainingSuggestion`s and
* raw `StepTechStepCorrection`s plus the snippet generator and the F1
* gate + backfill trigger. Replaces the `list-pending-training-suggestions.ts`
* / `retrain-tech-steps.ts` CLI pair.
*/
export function CorrectionsPage() {
const { t } = useTranslation();
const [tab, setTab] = useState<Tab>("suggestions");
return (
<div className="admin-page">
<h1 className="admin-page__title">{t("admin.corrections.title")}</h1>
<p className="admin-page__lead">{t("admin.corrections.lead")}</p>
<p className="corrections-caveat">{t("admin.corrections.caveat")}</p>
<div className="corrections-tabs">
<button
type="button"
className={tab === "suggestions" ? "active" : undefined}
onClick={() => setTab("suggestions")}
>
{t("admin.corrections.tab.suggestions")}
</button>
<button
type="button"
className={tab === "corrections" ? "active" : undefined}
onClick={() => setTab("corrections")}
>
{t("admin.corrections.tab.corrections")}
</button>
</div>
{tab === "suggestions" ? <SuggestionsTab /> : <CorrectionsTab />}
</div>
);
}
// --- Suggestions tab -------------------------------------------------------
type SuggestionsState =
| { status: "loading" }
| { status: "loaded"; groups: TrainingSuggestionGroupView[] }
| { status: "error" };
function SuggestionsTab() {
const { t } = useTranslation();
const [statusFilter, setStatusFilter] = useState("");
const [sourceFilter, setSourceFilter] = useState("");
const [state, setState] = useState<SuggestionsState>({ status: "loading" });
const load = useCallback(() => {
setState({ status: "loading" });
adminApiClient
.getSuggestions({
status: statusFilter || undefined,
sourceType: sourceFilter || undefined,
})
.then((groups) => setState({ status: "loaded", groups }))
.catch(() => setState({ status: "error" }));
}, [statusFilter, sourceFilter]);
useEffect(load, [load]);
return (
<div className="suggestions-tab">
<RetrainPanel />
<SnippetPanel />
<div className="corrections-filters">
<label>
{t("admin.corrections.filter.status")}
<select value={statusFilter} onChange={(e) => setStatusFilter(e.target.value)}>
<option value="">{t("admin.corrections.filter.any")}</option>
<option value="pending">pending</option>
<option value="applied">applied</option>
<option value="rejected">rejected</option>
</select>
</label>
<label>
{t("admin.corrections.filter.source")}
<select value={sourceFilter} onChange={(e) => setSourceFilter(e.target.value)}>
<option value="">{t("admin.corrections.filter.any")}</option>
<option value="correction">correction</option>
<option value="llm_audit">llm_audit</option>
</select>
</label>
</div>
{state.status === "loading" && (
<p className="admin-page__placeholder">{t("admin.common.loading")}</p>
)}
{state.status === "error" && (
<p className="admin-page__placeholder">{t("admin.common.loadError")}</p>
)}
{state.status === "loaded" && state.groups.length === 0 && (
<p className="admin-page__placeholder">{t("admin.corrections.noSuggestions")}</p>
)}
{state.status === "loaded" &&
state.groups.map((group) => (
<section key={group.techStepKey} className="suggestion-group">
<h2>{group.techStepKey}</h2>
{group.suggestions.map((suggestion) => (
<SuggestionCard key={suggestion.id} suggestion={suggestion} onMutated={load} />
))}
</section>
))}
</div>
);
}
function SuggestionCard({
suggestion,
onMutated,
}: {
suggestion: TrainingSuggestionAdminView;
onMutated: () => void;
}) {
const { t } = useTranslation();
const [synonyms, setSynonyms] = useState(listToLines(suggestion.suggestedSynonyms));
const [utterances, setUtterances] = useState(listToLines(suggestion.suggestedUtterances));
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const dirty =
listsDiffer(linesToList(synonyms), suggestion.suggestedSynonyms) ||
listsDiffer(linesToList(utterances), suggestion.suggestedUtterances);
async function patch(body: Parameters<typeof adminApiClient.updateSuggestion>[1]) {
setBusy(true);
setError(null);
try {
await adminApiClient.updateSuggestion(suggestion.id, body);
onMutated();
} catch (err) {
setError(
errorMessageService.getLabel(
err instanceof AdminApiError ? err.code : ErrorCode.INTERNAL_ERROR,
),
);
} finally {
setBusy(false);
}
}
return (
<article className={`suggestion-card suggestion-card--${suggestion.status}`}>
<header className="suggestion-card__head">
<span className="suggestion-card__meta">
#{suggestion.id} · {suggestion.locale} · {suggestion.sourceType} ·{" "}
<strong>{suggestion.status}</strong>
</span>
</header>
{suggestion.sourceCorrection && (
<p className="suggestion-card__source">
<span className="suggestion-card__clause">
« {suggestion.sourceCorrection.clauseText} »
</span>{" "}
{suggestion.sourceCorrection.previousTechStepKey ?? "∅"} {" "}
{suggestion.sourceCorrection.correctedTechStepKey ?? "∅"}
</p>
)}
<label>
{t("admin.corrections.synonyms")}
<textarea value={synonyms} onChange={(e) => setSynonyms(e.target.value)} rows={3} />
</label>
<label>
{t("admin.corrections.utterances")}
<textarea value={utterances} onChange={(e) => setUtterances(e.target.value)} rows={3} />
</label>
{error && <p className="suggestion-card__error">{error}</p>}
<div className="suggestion-card__actions">
<button
type="button"
disabled={busy || !dirty}
onClick={() =>
patch({
suggestedSynonyms: linesToList(synonyms),
suggestedUtterances: linesToList(utterances),
})
}
>
{t("admin.corrections.save")}
</button>
<button type="button" disabled={busy} onClick={() => patch({ status: "applied" })}>
{t("admin.corrections.apply")}
</button>
<button type="button" disabled={busy} onClick={() => patch({ status: "rejected" })}>
{t("admin.corrections.reject")}
</button>
</div>
</article>
);
}
// --- Snippet + retrain panels ------------------------------------------------
function SnippetPanel() {
const { t } = useTranslation();
const [techStepKey, setTechStepKey] = useState("");
const [snippet, setSnippet] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
async function generate() {
setError(null);
setSnippet(null);
try {
const result = await adminApiClient.getTrainingDataSnippet({
techStepKey: techStepKey.trim(),
status: "applied",
});
setSnippet(result.snippet);
} catch (err) {
setError(
errorMessageService.getLabel(
err instanceof AdminApiError ? err.code : ErrorCode.INTERNAL_ERROR,
),
);
}
}
return (
<section className="corrections-panel">
<h2>{t("admin.corrections.snippet.title")}</h2>
<p>{t("admin.corrections.snippet.help")}</p>
<div className="corrections-panel__row">
<input
value={techStepKey}
onChange={(e) => setTechStepKey(e.target.value)}
placeholder={t("admin.corrections.snippet.keyPlaceholder")}
/>
<button type="button" disabled={techStepKey.trim().length === 0} onClick={generate}>
{t("admin.corrections.snippet.generate")}
</button>
</div>
{error && <p className="suggestion-card__error">{error}</p>}
{snippet !== null && (
<textarea className="corrections-snippet" readOnly rows={10} value={snippet} />
)}
</section>
);
}
function RetrainPanel() {
const { t } = useTranslation();
const [busy, setBusy] = useState(false);
const [result, setResult] = useState<RetrainResultView | null>(null);
const [error, setError] = useState<string | null>(null);
async function run() {
setBusy(true);
setError(null);
setResult(null);
try {
setResult(await adminApiClient.retrain({}));
} catch (err) {
setError(
errorMessageService.getLabel(
err instanceof AdminApiError ? err.code : ErrorCode.INTERNAL_ERROR,
),
);
} finally {
setBusy(false);
}
}
return (
<section className="corrections-panel corrections-panel--retrain">
<h2>{t("admin.corrections.retrain.title")}</h2>
<p>{t("admin.corrections.retrain.help")}</p>
<button type="button" disabled={busy} onClick={run}>
{busy ? t("admin.corrections.retrain.running") : t("admin.corrections.retrain.run")}
</button>
{error && <p className="suggestion-card__error">{error}</p>}
{result && (
<p className={`retrain-result retrain-result--${result.gatePassed ? "ok" : "fail"}`}>
F1 {result.f1.toFixed(3)} / {result.minF1} {" "}
{result.gatePassed
? t("admin.corrections.retrain.passed", {
total: result.backfilled?.total ?? 0,
changed: result.backfilled?.changed ?? 0,
})
: t("admin.corrections.retrain.failed")}
</p>
)}
</section>
);
}
// --- Raw corrections tab --------------------------------------------------
type CorrectionsState =
| { status: "loading" }
| { status: "loaded"; rows: CorrectionAdminView[] }
| { status: "error" };
function CorrectionsTab() {
const { t } = useTranslation();
const [consumed, setConsumed] = useState("");
const [hasCorrected, setHasCorrected] = useState("");
const [state, setState] = useState<CorrectionsState>({ status: "loading" });
useEffect(() => {
let cancelled = false;
setState({ status: "loading" });
adminApiClient
.getCorrections({
consumed: consumed || undefined,
hasCorrectedTechStep: hasCorrected || undefined,
})
.then((rows) => {
if (!cancelled) setState({ status: "loaded", rows });
})
.catch(() => {
if (!cancelled) setState({ status: "error" });
});
return () => {
cancelled = true;
};
}, [consumed, hasCorrected]);
return (
<div className="corrections-tab">
<div className="corrections-filters">
<label>
{t("admin.corrections.filter.consumed")}
<select value={consumed} onChange={(e) => setConsumed(e.target.value)}>
<option value="">{t("admin.corrections.filter.any")}</option>
<option value="true">{t("admin.corrections.filter.yes")}</option>
<option value="false">{t("admin.corrections.filter.no")}</option>
</select>
</label>
<label>
{t("admin.corrections.filter.hasCorrected")}
<select value={hasCorrected} onChange={(e) => setHasCorrected(e.target.value)}>
<option value="">{t("admin.corrections.filter.any")}</option>
<option value="true">{t("admin.corrections.filter.yes")}</option>
<option value="false">{t("admin.corrections.filter.no")}</option>
</select>
</label>
</div>
{state.status === "loading" && (
<p className="admin-page__placeholder">{t("admin.common.loading")}</p>
)}
{state.status === "error" && (
<p className="admin-page__placeholder">{t("admin.common.loadError")}</p>
)}
{state.status === "loaded" && (
<div className="corrections-table-wrap">
<table className="corrections-table">
<thead>
<tr>
<th>#</th>
<th>{t("admin.corrections.col.clause")}</th>
<th>{t("admin.corrections.col.change")}</th>
<th>{t("admin.corrections.col.created")}</th>
<th>{t("admin.corrections.col.consumed")}</th>
</tr>
</thead>
<tbody>
{state.rows.map((row) => (
<tr key={row.id}>
<td>{row.id}</td>
<td className="corrections-table__clause">« {row.clauseText} »</td>
<td>
{row.previousTechStepKey ?? "∅"} {row.correctedTechStepKey ?? "∅"}
</td>
<td>{new Date(row.createdAt).toLocaleDateString("fr-FR")}</td>
<td>{row.consumedAt ? "✓" : "—"}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
);
}

View file

@ -1,266 +0,0 @@
// =============================================================================
// CorrectionsPage a caveat banner, two tabs, filter rows, suggestion cards
// with editable textareas, the snippet + retrain panels, and the raw
// corrections table.
// =============================================================================
.corrections-caveat {
margin: 0 0 var(--space-lg);
padding: var(--space-sm) var(--space-md);
border-left: 4px solid var(--color-warning);
background: color-mix(in srgb, var(--color-warning) 12%, var(--color-surface));
border-radius: var(--radius-base);
font-size: var(--font-size-sm);
color: var(--color-text);
}
.corrections-tabs {
display: flex;
gap: var(--space-xs);
margin-bottom: var(--space-lg);
button {
padding: var(--space-sm) var(--space-md);
font-family: var(--font-body);
font-size: var(--font-size-sm);
font-weight: 600;
color: var(--color-text-muted);
background: none;
border: 1px solid var(--color-border);
border-radius: var(--radius-base);
cursor: pointer;
&.active {
color: var(--color-primary);
border-color: var(--color-primary);
background: color-mix(in srgb, var(--color-primary) 10%, var(--color-surface));
}
}
}
.corrections-filters {
display: flex;
flex-wrap: wrap;
gap: var(--space-md);
margin-bottom: var(--space-md);
label {
display: flex;
flex-direction: column;
gap: 0.15rem;
font-size: var(--font-size-xs);
color: var(--color-text-muted);
}
select {
padding: var(--space-xs) var(--space-sm);
font-family: var(--font-body);
border: 1px solid var(--color-border);
border-radius: var(--radius-base);
background: var(--color-surface);
color: var(--color-text);
}
}
.corrections-panel {
margin-bottom: var(--space-lg);
padding: var(--space-md);
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
h2 {
font-size: var(--font-size-md);
margin-bottom: var(--space-xs);
}
p {
margin: 0 0 var(--space-sm);
font-size: var(--font-size-sm);
color: var(--color-text-muted);
}
&--retrain {
border-left: 4px solid var(--color-accent);
}
&__row {
display: flex;
gap: var(--space-sm);
input {
flex: 1;
padding: var(--space-xs) var(--space-sm);
font-family: var(--font-body);
border: 1px solid var(--color-border);
border-radius: var(--radius-base);
background: var(--color-surface);
color: var(--color-text);
}
}
button {
padding: var(--space-xs) var(--space-md);
font-family: var(--font-body);
font-weight: 600;
color: var(--color-surface);
background: var(--color-primary);
border: none;
border-radius: var(--radius-base);
cursor: pointer;
&:hover {
background: var(--color-primary-hover);
}
&:disabled {
opacity: 0.6;
cursor: not-allowed;
}
}
}
.corrections-snippet {
width: 100%;
margin-top: var(--space-sm);
padding: var(--space-sm);
font-family: var(--font-mono);
font-size: var(--font-size-xs);
border: 1px solid var(--color-border);
border-radius: var(--radius-base);
background: var(--color-surface-alt);
color: var(--color-text);
resize: vertical;
}
.retrain-result {
margin-top: var(--space-sm);
font-weight: 600;
&--ok {
color: var(--color-success);
}
&--fail {
color: var(--color-error);
}
}
.suggestion-group {
margin-bottom: var(--space-lg);
h2 {
font-size: var(--font-size-md);
margin-bottom: var(--space-sm);
}
}
.suggestion-card {
margin-bottom: var(--space-sm);
padding: var(--space-md);
background: var(--color-surface);
border: 1px solid var(--color-border);
border-left: 4px solid var(--color-border);
border-radius: var(--radius-md);
&--applied {
border-left-color: var(--color-success);
}
&--rejected {
border-left-color: var(--color-error);
}
&--pending {
border-left-color: var(--color-warning);
}
&__meta {
font-size: var(--font-size-xs);
color: var(--color-text-muted);
}
&__source {
margin: var(--space-xs) 0 var(--space-sm);
font-size: var(--font-size-sm);
}
&__clause {
font-style: italic;
color: var(--color-text);
}
label {
display: block;
margin-bottom: var(--space-sm);
font-size: var(--font-size-xs);
color: var(--color-text-muted);
}
textarea {
width: 100%;
margin-top: 0.15rem;
padding: var(--space-xs) var(--space-sm);
font-family: var(--font-mono);
font-size: var(--font-size-xs);
border: 1px solid var(--color-border);
border-radius: var(--radius-base);
background: var(--color-surface);
color: var(--color-text);
resize: vertical;
}
&__error {
margin: 0 0 var(--space-sm);
font-size: var(--font-size-sm);
color: var(--color-error);
}
&__actions {
display: flex;
gap: var(--space-sm);
button {
padding: var(--space-xs) var(--space-md);
font-family: var(--font-body);
font-size: var(--font-size-sm);
font-weight: 600;
border: 1px solid var(--color-border);
border-radius: var(--radius-base);
background: var(--color-surface);
color: var(--color-text);
cursor: pointer;
&:hover {
border-color: var(--color-primary);
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
}
}
}
.corrections-table-wrap {
overflow-x: auto;
}
.corrections-table {
width: 100%;
border-collapse: collapse;
font-size: var(--font-size-sm);
th,
td {
padding: var(--space-xs) var(--space-sm);
border-bottom: 1px solid var(--color-border);
text-align: left;
vertical-align: top;
}
th {
color: var(--color-text-muted);
font-weight: 600;
}
&__clause {
font-style: italic;
max-width: 28rem;
}
}

View file

@ -1,23 +0,0 @@
/**
* Pure helpers for `CorrectionsPage` the editable synonym/utterance
* fields are one-per-line textareas, so these convert between that and the
* `string[]` the API wants. Kept out of the `.tsx` per repo convention.
*/
/** Textarea value (one entry per line) → trimmed, non-empty `string[]`. */
export function linesToList(text: string): string[] {
return text
.split("\n")
.map((line) => line.trim())
.filter((line) => line.length > 0);
}
/** `string[]` → textarea value (one entry per line). */
export function listToLines(values: string[]): string {
return values.join("\n");
}
/** True when two string lists differ (order-sensitive) — gates the "save" button. */
export function listsDiffer(a: string[], b: string[]): boolean {
return a.length !== b.length || a.some((value, i) => value !== b[i]);
}

View file

@ -1,175 +0,0 @@
import type { MetricsTimeBucket, MetricsView } from "@batch-cooking/shared";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import {
CartesianGrid,
Line,
LineChart,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from "recharts";
import { adminApiClient } from "../../../api/admin-client";
import "../admin-page.scss";
import "./dashboard-page.scss";
import { formatCount, kpiTiles, seriesTotal, shortDay } from "./dashboard";
/** Load state for `GET /admin/metrics` — same discriminated-union shape as apps/web's page states. */
type DashboardState =
| { status: "loading" }
| { status: "loaded"; metrics: MetricsView }
| { status: "error" };
const RANGE_DAYS = 30;
/**
* Usage-metrics dashboard. KPI tiles from the snapshot, then a small line
* chart per instrumented time series over the last {@link RANGE_DAYS} days.
* Fed by `GET /admin/metrics`; read-only, refetched only on mount.
*/
export function DashboardPage() {
const { t } = useTranslation();
const [state, setState] = useState<DashboardState>({ status: "loading" });
useEffect(() => {
let cancelled = false;
setState({ status: "loading" });
adminApiClient
.getMetrics(RANGE_DAYS)
.then((metrics) => {
if (!cancelled) setState({ status: "loaded", metrics });
})
.catch(() => {
if (!cancelled) setState({ status: "error" });
});
return () => {
cancelled = true;
};
}, []);
return (
<div className="admin-page">
<h1 className="admin-page__title">{t("admin.dashboard.title")}</h1>
<p className="admin-page__lead">{t("admin.dashboard.lead")}</p>
{state.status === "loading" && (
<p className="admin-page__placeholder">{t("admin.common.loading")}</p>
)}
{state.status === "error" && (
<p className="admin-page__placeholder">{t("admin.common.loadError")}</p>
)}
{state.status === "loaded" && <DashboardBody metrics={state.metrics} />}
</div>
);
}
function DashboardBody({ metrics }: { metrics: MetricsView }) {
const { t } = useTranslation();
const { snapshot, series } = metrics;
const charts: { key: string; buckets: MetricsTimeBucket[] }[] = [
{ key: "signups", buckets: series.signups },
{ key: "recipesCreated", buckets: series.recipesCreated },
{ key: "planningItemsAdded", buckets: series.planningItemsAdded },
{ key: "correctionsSubmitted", buckets: series.correctionsSubmitted },
{ key: "trainingSuggestions", buckets: series.trainingSuggestions },
];
return (
<>
<section className="kpi-grid">
{kpiTiles(snapshot).map((tile) => (
<div className="kpi-tile" key={tile.labelKey}>
<span className="kpi-tile__value">{formatCount(tile.value)}</span>
<span className="kpi-tile__label">{t(`admin.dashboard.kpi.${tile.labelKey}`)}</span>
</div>
))}
</section>
<section className="chart-grid">
{charts.map(({ key, buckets }) => (
<article className="chart-card" key={key}>
<header className="chart-card__head">
<h2>{t(`admin.dashboard.series.${key}`)}</h2>
<span className="chart-card__total">
{t("admin.dashboard.windowTotal", { n: seriesTotal(buckets) })}
</span>
</header>
<TrendChart buckets={buckets} />
</article>
))}
</section>
<section className="breakdown">
<h2>{t("admin.dashboard.recipesBySource")}</h2>
{snapshot.recipesBySource.length === 0 ? (
<p className="admin-page__placeholder">{t("admin.dashboard.noImports")}</p>
) : (
<ul className="breakdown__list">
{snapshot.recipesBySource.map((row) => (
<li key={row.key}>
<span>{row.label}</span>
<span>{formatCount(row.count)}</span>
</li>
))}
</ul>
)}
</section>
{metrics.events.length > 0 && (
<section className="breakdown">
<h2>{t("admin.dashboard.events")}</h2>
<ul className="breakdown__list">
{metrics.events.map((event) => (
<li key={event.type}>
<span>{event.type}</span>
<span>{formatCount(seriesTotal(event.buckets))}</span>
</li>
))}
</ul>
</section>
)}
</>
);
}
/** A compact 30-day line chart for one metrics series. */
function TrendChart({ buckets }: { buckets: MetricsTimeBucket[] }) {
const data = buckets.map((bucket) => ({ day: shortDay(bucket.date), count: bucket.count }));
return (
<div className="chart-card__body">
<ResponsiveContainer width="100%" height={160}>
<LineChart data={data} margin={{ top: 4, right: 8, bottom: 0, left: -20 }}>
<CartesianGrid strokeDasharray="3 3" stroke="var(--color-border)" />
<XAxis
dataKey="day"
tick={{ fontSize: 10, fill: "var(--color-text-muted)" }}
interval="preserveStartEnd"
minTickGap={24}
/>
<YAxis
allowDecimals={false}
width={40}
tick={{ fontSize: 10, fill: "var(--color-text-muted)" }}
/>
<Tooltip
contentStyle={{
background: "var(--color-surface)",
border: "1px solid var(--color-border)",
borderRadius: 8,
fontSize: 12,
}}
/>
<Line
type="monotone"
dataKey="count"
stroke="var(--color-primary)"
strokeWidth={2}
dot={false}
/>
</LineChart>
</ResponsiveContainer>
</div>
);
}

View file

@ -1,103 +0,0 @@
// =============================================================================
// DashboardPage KPI tile grid + a grid of small trend charts + a couple of
// breakdown lists. Colocated with DashboardPage.tsx.
// =============================================================================
.kpi-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(9rem, 1fr));
gap: var(--space-sm);
margin-bottom: var(--space-xl);
}
.kpi-tile {
display: flex;
flex-direction: column;
gap: 0.15rem;
padding: var(--space-md);
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
&__value {
font-family: var(--font-display);
font-size: var(--font-size-xl);
font-weight: 700;
color: var(--color-text);
font-variant-numeric: tabular-nums;
}
&__label {
font-size: var(--font-size-xs);
color: var(--color-text-muted);
}
}
.chart-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(20rem, 1fr));
gap: var(--space-md);
margin-bottom: var(--space-xl);
}
.chart-card {
padding: var(--space-md);
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
&__head {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: var(--space-sm);
margin-bottom: var(--space-sm);
h2 {
font-size: var(--font-size-md);
}
}
&__total {
font-size: var(--font-size-sm);
color: var(--color-text-muted);
font-variant-numeric: tabular-nums;
}
}
.breakdown {
margin-bottom: var(--space-lg);
h2 {
font-size: var(--font-size-md);
margin-bottom: var(--space-sm);
}
&__list {
list-style: none;
margin: 0;
padding: 0;
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
max-width: 30rem;
li {
display: flex;
justify-content: space-between;
gap: var(--space-md);
padding: var(--space-sm) var(--space-md);
border-bottom: 1px solid var(--color-border);
font-size: var(--font-size-sm);
&:last-child {
border-bottom: none;
}
span:last-child {
font-variant-numeric: tabular-nums;
color: var(--color-text-muted);
}
}
}
}

View file

@ -1,51 +0,0 @@
import type { MetricsSnapshotView, MetricsTimeBucket } from "@batch-cooking/shared";
/**
* Pure helpers for `DashboardPage` number/date formatting and the tile
* list, kept out of the `.tsx` (repo convention: no derivation logic in a
* component file) so they're trivially testable.
*/
/** French-grouped integer, e.g. `1234` → `"1 234"`. */
export function formatCount(value: number): string {
return value.toLocaleString("fr-FR");
}
/** `"2026-08-28"` → `"28/08"` for a compact chart axis tick. */
export function shortDay(isoDate: string): string {
const [, month, day] = isoDate.split("-");
return `${day}/${month}`;
}
/** Sum of a time series — the "total over the window" figure shown next to each chart. */
export function seriesTotal(buckets: MetricsTimeBucket[]): number {
return buckets.reduce((sum, bucket) => sum + bucket.count, 0);
}
/** One KPI tile: an i18n label key and the snapshot value it reads. */
export interface KpiTile {
labelKey: string;
value: number;
}
/**
* The dashboard's KPI tiles, in display order. `labelKey` resolves under
* `admin.dashboard.kpi.*`. Kept here (not inline in JSX) so the set is one
* list to reorder/extend.
*/
export function kpiTiles(snapshot: MetricsSnapshotView): KpiTile[] {
return [
{ labelKey: "users", value: snapshot.users },
{ labelKey: "households", value: snapshot.households },
{ labelKey: "activeHouseholds", value: snapshot.activeHouseholds },
{ labelKey: "recipes", value: snapshot.recipes },
{ labelKey: "recipesImported", value: snapshot.recipesImported },
{ labelKey: "plannings", value: snapshot.plannings },
{ labelKey: "planningItems", value: snapshot.planningItems },
{ labelKey: "favorites", value: snapshot.favorites },
{ labelKey: "corrections", value: snapshot.corrections },
{ labelKey: "correctionsUnconsumed", value: snapshot.correctionsUnconsumed },
{ labelKey: "trainingSuggestions", value: snapshot.trainingSuggestions },
{ labelKey: "admins", value: snapshot.admins },
];
}

View file

@ -1,85 +0,0 @@
import { adminLoginSchema, ErrorCode } from "@batch-cooking/shared";
import { type FormEvent, useState } from "react";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
import { AdminApiError } from "../../../api/admin-client";
import { useAdminAuth } from "../../../features/admin/AdminAuthContext";
import "../../../features/admin/admin-auth.scss";
import { fieldErrorsFrom } from "../../../lib/zod-errors";
import { errorMessageService } from "../../../services/error-message.service";
/**
* The admin login screen the only unauthenticated route under `/admin`.
* Client-side validation via the shared `adminLoginSchema` (same rules the
* API enforces), then `POST /admin/auth/login`; any API failure is
* translated to a localized label via {@link ErrorMessageService}. Same
* structure as the user-facing `LoginPage` (`pages/auth/`).
*/
export function AdminLoginPage() {
const { login } = useAdminAuth();
const navigate = useNavigate();
const { t } = useTranslation();
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [fieldErrors, setFieldErrors] = useState<Record<string, string>>({});
const [formError, setFormError] = useState<string | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
async function handleSubmit(e: FormEvent) {
e.preventDefault();
setFormError(null);
const result = adminLoginSchema.safeParse({ email, password });
if (!result.success) {
setFieldErrors(fieldErrorsFrom(result.error));
return;
}
setFieldErrors({});
setIsSubmitting(true);
try {
await login(result.data);
void navigate("/admin");
} catch (err) {
const code = err instanceof AdminApiError ? err.code : ErrorCode.INTERNAL_ERROR;
setFormError(errorMessageService.getLabel(code));
} finally {
setIsSubmitting(false);
}
}
return (
<main className="admin-auth-page">
<form className="admin-auth-card" onSubmit={handleSubmit} noValidate>
<h1>{t("admin.login.title")}</h1>
<label htmlFor="email">{t("admin.login.emailLabel")}</label>
<input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
autoComplete="email"
/>
{fieldErrors.email && <p className="field-error">{fieldErrors.email}</p>}
<label htmlFor="password">{t("admin.login.passwordLabel")}</label>
<input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
autoComplete="current-password"
/>
{fieldErrors.password && <p className="field-error">{fieldErrors.password}</p>}
{formError && <p className="form-error">{formError}</p>}
<button type="submit" disabled={isSubmitting}>
{isSubmitting ? t("admin.login.submitting") : t("admin.login.submit")}
</button>
</form>
</main>
);
}

View file

@ -1,110 +0,0 @@
import type { MonitoringView, ServiceHealthView } from "@batch-cooking/shared";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { adminApiClient } from "../../../api/admin-client";
import "../admin-page.scss";
import "./monitoring-page.scss";
import { clockTime, POLL_INTERVAL_MS, statusModifier } from "./monitoring";
type MonitoringState =
| { status: "loading" }
| { status: "loaded"; data: MonitoringView }
| { status: "error" };
/**
* Microservice health board. Fetches `GET /admin/monitoring` on mount and
* re-polls every {@link POLL_INTERVAL_MS} ms. One card per probed target
* (Postgres, API, intent-service, LLM worker), coloured by status.
*/
export function MonitoringPage() {
const { t } = useTranslation();
const [state, setState] = useState<MonitoringState>({ status: "loading" });
useEffect(() => {
let cancelled = false;
function load() {
adminApiClient
.getMonitoring()
.then((data) => {
if (!cancelled) setState({ status: "loaded", data });
})
.catch(() => {
if (!cancelled)
setState((prev) => (prev.status === "loaded" ? prev : { status: "error" }));
});
}
load();
const timer = setInterval(load, POLL_INTERVAL_MS);
return () => {
cancelled = true;
clearInterval(timer);
};
}, []);
return (
<div className="admin-page">
<h1 className="admin-page__title">{t("admin.monitoring.title")}</h1>
<p className="admin-page__lead">{t("admin.monitoring.lead")}</p>
{state.status === "loading" && (
<p className="admin-page__placeholder">{t("admin.common.loading")}</p>
)}
{state.status === "error" && (
<p className="admin-page__placeholder">{t("admin.common.loadError")}</p>
)}
{state.status === "loaded" && (
<>
<p className="monitoring-checked">
{t("admin.monitoring.lastChecked", { time: clockTime(state.data.generatedAt) })}
</p>
<div className="monitoring-grid">
{state.data.services.map((service) => (
<ServiceCard key={service.key} service={service} />
))}
</div>
</>
)}
</div>
);
}
function ServiceCard({ service }: { service: ServiceHealthView }) {
const { t } = useTranslation();
return (
<article className={`monitoring-card monitoring-card--${statusModifier(service.status)}`}>
<header className="monitoring-card__head">
<span className="monitoring-card__dot" aria-hidden="true" />
<h2>{t(`admin.monitoring.service.${service.key}`, { defaultValue: service.key })}</h2>
<span className="monitoring-card__status">
{t(`admin.monitoring.status.${service.status}`)}
</span>
</header>
<dl className="monitoring-card__meta">
{service.latencyMs !== null && (
<div>
<dt>{t("admin.monitoring.latency")}</dt>
<dd>{service.latencyMs} ms</dd>
</div>
)}
{service.detail && (
<div>
<dt>{t("admin.monitoring.detail")}</dt>
<dd>{service.detail}</dd>
</div>
)}
{service.lastRunAt !== undefined && (
<div>
<dt>{t("admin.monitoring.lastRun")}</dt>
<dd>
{service.lastRunAt ? clockTime(service.lastRunAt) : t("admin.monitoring.never")}
{service.lastResult?.job ? ` · ${service.lastResult.job}` : ""}
{service.lastResult?.ok === false ? ` · ${t("admin.monitoring.jobFailed")}` : ""}
</dd>
</div>
)}
</dl>
</article>
);
}

View file

@ -1,98 +0,0 @@
// =============================================================================
// MonitoringPage a grid of service health cards, one per probed target.
// Status drives a coloured left border + dot.
// =============================================================================
.monitoring-checked {
margin: 0 0 var(--space-md);
font-size: var(--font-size-sm);
color: var(--color-text-muted);
}
.monitoring-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(18rem, 1fr));
gap: var(--space-md);
}
.monitoring-card {
padding: var(--space-md);
background: var(--color-surface);
border: 1px solid var(--color-border);
border-left: 4px solid var(--color-border);
border-radius: var(--radius-md);
--status-color: var(--color-text-muted);
&--up {
--status-color: var(--color-success);
}
&--degraded {
--status-color: var(--color-warning);
}
&--down {
--status-color: var(--color-error);
}
&--unknown {
--status-color: var(--color-text-muted);
}
border-left-color: var(--status-color);
&__head {
display: flex;
align-items: center;
gap: var(--space-sm);
margin-bottom: var(--space-sm);
h2 {
flex: 1;
min-width: 0;
font-size: var(--font-size-md);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
&__dot {
flex-shrink: 0;
width: 0.6rem;
height: 0.6rem;
border-radius: 50%;
background: var(--status-color);
}
&__status {
flex-shrink: 0;
font-size: var(--font-size-xs);
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--status-color);
}
&__meta {
margin: 0;
display: flex;
flex-direction: column;
gap: var(--space-xs);
div {
display: flex;
gap: var(--space-sm);
font-size: var(--font-size-sm);
}
dt {
flex-shrink: 0;
color: var(--color-text-muted);
min-width: 5rem;
}
dd {
margin: 0;
color: var(--color-text);
word-break: break-word;
}
}
}

View file

@ -1,19 +0,0 @@
import type { ServiceStatus } from "@batch-cooking/shared";
/**
* Pure helpers for `MonitoringPage` kept out of the `.tsx` per repo
* convention.
*/
/** CSS modifier suffix for a status pill (`monitoring-card--up`, etc.). */
export function statusModifier(status: ServiceStatus): string {
return status;
}
/** How often the board re-polls `GET /admin/monitoring`, in ms. */
export const POLL_INTERVAL_MS = 15_000;
/** `"2026-08-28T09:00:00.000Z"` → `"09:00:00"` (local time) for the "last checked" line. */
export function clockTime(iso: string): string {
return new Date(iso).toLocaleTimeString("fr-FR");
}

View file

@ -1,188 +0,0 @@
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>
);
}

View file

@ -1,164 +0,0 @@
// =============================================================================
// 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);
}
}

View file

@ -1,56 +0,0 @@
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(", ");
}

View file

@ -8,7 +8,6 @@ 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";
@ -38,7 +37,6 @@ 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
@ -103,23 +101,11 @@ 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" && (

View file

@ -36,29 +36,6 @@
&__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 --------------------------------------------------------

View file

@ -14,10 +14,6 @@ import { ApiError, apiClient } from "../../api/client";
import { DietTagSelect } from "../../features/recipes/badges/DietTagSelect";
import { IngredientPicker } from "../../features/recipes/ingredients/IngredientPicker";
import { IngredientRow } from "../../features/recipes/ingredients/IngredientRow";
import {
isUnsavedPlaceholder,
makePlaceholderIngredientView,
} from "../../features/recipes/ingredients/placeholder-ingredient";
import { type StepDraft, StepListEditor } from "../../features/recipes/steps/StepListEditor";
import "../../features/recipes/recipes.scss";
import { makeClientKey } from "../../lib/client-key";
@ -26,19 +22,7 @@ import { errorMessageService } from "../../services/error-message.service";
/** In display order — mirrors `RecipeVisibility` (schema.prisma/shared types). */
const VISIBILITY_OPTIONS: RecipeVisibility[] = ["PERSONAL", "HOUSE", "PUBLIC"];
/**
* One selected ingredient line `key` is a client-only stable identity,
* same reasoning as `StepDraft`. `unitId` is `null` until the user picks
* one (no default unlike `portions`, there's no single "usually right"
* unit across every ingredient); `canSubmit` gates on every line having one
* set before allowing save.
*
* `ingredient` is a real `IngredientView` for a catalog pick or an
* edit-loaded placeholder, or a synthetic one (see
* `makePlaceholderIngredientView`) for a brand-new free-text placeholder
* the user added because the catalog fell short the latter submits as
* `placeholderName`, not `ingredientId` (see {@link isUnsavedPlaceholder}).
*/
/** One selected ingredient line — `key` is a client-only stable identity, same reasoning as `StepDraft`. `unitId` is `null` until the user picks one (no default — unlike `portions`, there's no single "usually right" unit across every ingredient); `canSubmit` gates on every line having one set before allowing save. */
interface IngredientLine {
key: string;
ingredient: IngredientView;
@ -141,19 +125,6 @@ export function RecipeFormPage() {
]);
}
/** Adds a free-text placeholder line — the escape hatch when nothing in the catalog matches (see `IngredientPicker`'s `onAddPlaceholder`). */
function addPlaceholderIngredient(name: string) {
setIngredientLines((lines) => [
...lines,
{
key: makeClientKey(),
ingredient: makePlaceholderIngredientView(name),
quantity: "",
unitId: null,
},
]);
}
function updateIngredientLine(
key: string,
patch: Partial<Pick<IngredientLine, "quantity" | "unitId">>,
@ -198,13 +169,7 @@ export function RecipeFormPage() {
visibility,
dietIds,
ingredients: ingredientLines.map((line) => ({
// A just-added free-text line has no catalog id yet — ask the API to
// create the placeholder row via `placeholderName`. An edit-loaded
// placeholder already has a real id and goes through `ingredientId`
// like any other line (so re-saving never duplicates it).
...(isUnsavedPlaceholder(line.ingredient)
? { placeholderName: line.ingredient.displayName ?? "" }
: { ingredientId: line.ingredient.id }),
ingredientId: line.ingredient.id,
quantity: Number(line.quantity),
// `canSubmit` already requires every line to have a unit picked
// before the button is enabled — `?? 0` is just to satisfy the
@ -256,12 +221,7 @@ export function RecipeFormPage() {
);
}
// Placeholder lines carry no real catalog id (a brand-new one is id 0, an
// edit-loaded one isn't in the browsable catalog anyway), so they never
// belong in the picker's "already picked, hide it" set.
const selectedIds = ingredientLines
.filter((line) => !line.ingredient.isPlaceholder)
.map((line) => line.ingredient.id);
const selectedIds = ingredientLines.map((line) => line.ingredient.id);
return (
<form className="recipe-form" onSubmit={handleSubmit} noValidate>
@ -332,7 +292,6 @@ export function RecipeFormPage() {
ingredients={ingredientsCatalog}
excludeIds={selectedIds}
onSelect={addIngredient}
onAddPlaceholder={addPlaceholderIngredient}
/>
</section>

View file

@ -8,7 +8,6 @@ import {
CategoryIcon,
IngredientTypeIcon,
} from "../../features/recipes/ingredients/ingredient-icons";
import { ingredientLabel } from "../../features/recipes/ingredients/ingredient-label";
import { formatShoppingListQuantity, groupShoppingListItems } from "./shopping-list";
import "./shopping-list-page.scss";
@ -81,7 +80,9 @@ function ShoppingListItems({ items }: { items: ShoppingListItemView[] }) {
return <p className="shopping-list-page__status">{t("shoppingList.empty")}</p>;
}
const groups = groupShoppingListItems(items, (item) => ingredientLabel(item.ingredient, t));
const groups = groupShoppingListItems(items, (item) =>
t(`catalog.ingredients.${item.ingredient.key}`),
);
return (
<div className="shopping-list">
@ -98,7 +99,7 @@ function ShoppingListItems({ items }: { items: ShoppingListItemView[] }) {
<IngredientTypeIcon icon={item.ingredient.icon} />
</span>
<span className="shopping-list__item-name">
{ingredientLabel(item.ingredient, t)}
{t(`catalog.ingredients.${item.ingredient.key}`)}
</span>
<span className="shopping-list__item-quantity">
{formatShoppingListQuantity(item.quantity)} {t(`catalog.units.${item.unit.key}`)}

View file

@ -48,12 +48,15 @@ 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 surface (apps/web's /admin/* routes + the API's /admin/*).
# Unset by default: `requireAdmin` fails closed without
# ADMIN_JWT_SECRET, so a stack that doesn't need the admin app simply
# has every /admin/* route 401. Must be a *different* secret than
# JWT_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

View file

@ -40,8 +40,6 @@ export enum ErrorCode {
RECIPE_IN_USE = 4021,
/** `POST /sources/:sourceKey/import/:externalId` attempted on an item already imported (a `Recipe` already exists for that `sourceId`/`externalId` pair). */
RECIPE_ALREADY_IMPORTED = 4022,
/** `POST /admin/tech-steps/retrain` attempted while a previous retrain (F1 gate + backfill) is still running — it holds a process-wide lock so two can't overlap. */
RETRAIN_ALREADY_RUNNING = 4023,
/** A household action reserved to its admin (delete the household, remove a member) attempted by a non-admin member. */
NOT_HOUSE_ADMIN = 4030,
/** `PATCH /recipes/:id` or `DELETE /recipes/:id` attempted by someone other than the recipe's author — visibility controls reading, not writing. */

Some files were not shown because too many files have changed in this diff Show more