Compare commits

...

10 commits

Author SHA1 Message Date
c246a42a77 feat(recipes): permet d'ajouter des ingredients hors-catalogue
Some checks failed
CI / e2e (push) Waiting to run
CI / lint (push) Successful in 3m39s
CI / intent-service-test (push) Has been cancelled
CI / build (push) Has been cancelled
CI / test (push) Has been cancelled
Quand le catalogue seede ne couvre pas un ingredient, l'utilisateur pouvait
etre bloque (creation manuelle) ou perdre silencieusement la ligne (import).
Une ligne de recette accepte desormais `placeholderName` (texte libre) au
lieu de `ingredientId` : l'API cree une ligne `Ingredient` `isPlaceholder`
(cle `placeholder:<uuid>`, `displayName`, `createdById`) dans la transaction
de la recette, et emet `ingredient.placeholder_created`. Ces lignes sont
exclues de `GET /reference/ingredients` et de `ingredient-matcher`.

Front : bouton "Ajouter << ... >>" dans l'etat vide de `IngredientPicker`
(formulaire + import), badge "a completer" sur la ligne, helper
`ingredientLabel` applique partout ou un libelle d'ingredient est rendu.

Admin : `/admin/catalog/*` (+ page `apps/admin-web`) liste les placeholders
regroupes par nom normalise, "marquer traite" (`reviewedAt`) et purge des
orphelins. La promotion en vraie entree catalogue reste manuelle.

Migration `ingredient_placeholder` ecrite a la main (Postgres indisponible).
Suites Mocha DB-backed ecrites, non executees en session ; test pur
`normalizePlaceholderName` + Cypress admin-web/web verts.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-28 22:58:05 +02:00
74b1657590 feat(admin): tri des corrections + declenchement du gate F1/backfill
Some checks failed
CI / test (push) Failing after 5s
CI / lint (push) Successful in 3m7s
CI / build (push) Successful in 4m47s
CI / e2e (push) Successful in 15m51s
CI / intent-service-test (push) Successful in 20m13s
PR 5 (derniere) du chantier admin. Remplace le duo CLI
list-pending-training-suggestions.ts / retrain-tech-steps.ts par une UI.

API (admin-tech-steps.service.ts, routes /admin/tech-steps/*, requireAdmin) :
- GET /suggestions : TechStepTrainingSuggestion filtrees, groupees par
  technique, enrichies du contexte de la correction source.
- GET /corrections : corrections brutes filtrables, incluant les
  suppressions correctedTechStepId:null invisibles ailleurs.
- PATCH /suggestions/:id : edite synonymes/phrases et/ou status.
- GET /training-data-snippet : bloc training_data.py a coller (lecture
  seule).
- POST /retrain : runTechStepEvalSuite() (gate F1 vs MIN_OVERALL_F1) puis
  si passe backfillTechSteps() + marquage applied/rejected. Verrou memoire
  -> 409 RETRAIN_ALREADY_RUNNING. Gate echoue -> 200 gatePassed:false.
  N'edite pas le .py ni ne redemarre l'intent-service (manuel).

Shared : nouveau ErrorCode RETRAIN_ALREADY_RUNNING (4023, + cle i18n
apps/web), schemas (list*/update*/retrain*/snippet), types
(TrainingSuggestion*/Correction*/RetrainResultView...).

Front : CorrectionsPage (onglets Suggestions / Corrections brutes,
bandeau caveat permanent, cartes editables + Appliquer/Rejeter, panneau
snippet, panneau gate F1). Logique pure corrections.ts. i18n
admin.corrections.*. AdminApiClient : 5 methodes.

Tests : Mocha admin-tech-steps.test.ts (401 partout, groupement+filtre,
PATCH 400/404/ok, corrections incluant removals, snippet, retrain shape +
409 concurrent) ; Cypress corrections.cy.ts (4 verts). Admin-web Cypress
13/13. specs/backend-architecture.md : section tri + retrain.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-28 22:57:43 +02:00
ba7218347f feat(admin): monitoring des microservices + heartbeat du worker LLM
PR 4 du chantier admin. Board de sante temps reel des dependances.

- POST /internal/tech-steps/heartbeat (requireInternalWorker) ->
  recordWorkerHeartbeat : upsert WorkerHeartbeat (cle fixe
  "tech-step-llm-worker"), lastRunAt/lastResult pour un ping "job".
  Schema workerHeartbeatSchema dans packages/shared.
- services/tech-step-llm-worker : api-client.postHeartbeat (best-effort,
  ne throw jamais) appele au boot (index.ts), a chaque tick et apres
  chaque job (scheduler.ts, avec job/ok/counts).
- admin-monitoring.service.ts + GET /admin/monitoring (requireAdmin) :
  sonde active bornee (~2 s) de Postgres (SELECT 1), l'API (uptime/RSS),
  tech-step-intent-service (/health), et le worker via son heartbeat.
  Statut up/degraded/down/unknown ; une sonde down ne casse ni les autres
  ni l'endpoint. Seuils worker : > 8 j degraded, > 21 j down.
- MonitoringView / ServiceHealthView dans packages/shared.
- Front : MonitoringPage (grille de cartes coloree par statut, re-poll
  15 s), logique pure monitoring.ts, i18n admin.monitoring.*,
  AdminApiClient.getMonitoring.
- Tests : Mocha admin-monitoring.test.ts (heartbeat 401/400/upsert
  job+boot ; GET /admin/monitoring 401, board 4 cibles, worker unknown
  sans heartbeat puis up apres) ; Cypress monitoring.cy.ts (2 verts).
  Worker mocha : 6/6 toujours verts.
- specs/backend-architecture.md : section monitoring. .gitignore :
  apps/admin-web/cypress/{screenshots,videos,downloads}.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-28 22:57:41 +02:00
afe47e161c feat(admin): metriques d'utilisation (derive DB + AnalyticsEvent)
PR 3 du chantier admin. Tableau de bord metriques : snapshot de compteurs
+ series temporelles journalieres.

Schema (migration admin_metrics) :
- AnalyticsEvent (type String libre, actorType/actorId sans FK, context Json,
  index [type, created_at]) + WorkerHeartbeat (cable en PR 4).
- colonnes createdAt @default(now()) sur UserProfile / Recipe / Planning /
  PlanningItem (lecture admin uniquement ; lignes existantes = timestamp de
  la migration).

Instrumentation (lib/analytics.service.ts, fire-and-forget) :
- analytics.recordEvent(type, {actorId?, context?}) : retourne void, insert
  detache, echec loggue+avale, jamais de latence sur la requete.
- points d'appel : user.signup, recipe.created, recipe.imported,
  planning.item_added, tech_step.correction_submitted, shopping_list.viewed.

API : GET /admin/metrics?days= (7-365, defaut 30, requireAdmin) ->
admin-metrics.service.ts. bucketByDay pur (zero-remplissage, teste sans
base). MetricsView dans packages/shared.

Front : DashboardPage (tuiles KPI + un graphe recharts par serie + listes
recettes-par-source / evenements), logique pure dans dashboard.ts, i18n
admin.dashboard.*. AdminApiClient.getMetrics.

reset-db.ts truncate analytics_events + worker_heartbeats.
Tests : Mocha admin-metrics.test.ts (bucketByDay pur x2 verts ; snapshot,
series zero-remplies, event user.signup fire-and-forget) ; Cypress
dashboard.cy.ts (2 verts). specs/backend-architecture.md : section admin.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-28 22:57:40 +02:00
e90a6d16e7 feat(admin): scaffold de l'application d'administration apps/admin-web
Nouvelle app Vite/React independante (workspace apps/*), calquee sur
apps/web : port 5174, sa propre image Docker (nginx statique), son propre
domaine/deploiement.

- AdminApiClient (VITE_ADMIN_API_URL, credentials: include) + ApiError.
- AdminAuthContext / RequireAdmin : restaure la session admin via
  GET /admin/auth/me, garde de route (miroir de AuthContext/RequireAuth).
- LoginPage (/login) : validation cliente via adminLoginSchema partage,
  erreurs traduites via ErrorMessageService.
- AdminLayout : sidebar (Tableau de bord / Monitoring / Corrections) +
  deconnexion, rendu une fois autour du groupe RequireAdmin.
- Pages Dashboard / Monitoring / Corrections en placeholder (remplies aux
  PR 3-5).
- i18n fr (bloc admin.* + sous-ensemble errors.*), tokens _theme.scss
  copies de apps/web (extraction en package partage : suivi separe).
- Dockerfile multi-stage (node build -> nginx:alpine) + nginx.conf (SPA
  fallback). Service admin-web dans docker-compose.yml (port ADMIN_WEB_PORT,
  VITE_ADMIN_API_URL en build arg).
- Cypress : login.feature (KO -> message, OK -> dashboard) + admin-layout
  .cy.ts (redirection /login sans session, nav entre sections, logout).
  Job "Run admin-web E2E tests" ajoute a ci.yml.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-28 22:57:02 +02:00
ebb3537198 feat(admin): fondation auth de l'application d'administration
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 22:57:01 +02:00
46635cb76f Merge pull request 'feat(cooking): « Commencer à cuisiner » — moteur d'optimisation + endpoint + page /cuisiner' (#11) from feat/cooking-session-ui into main
Some checks failed
CI / lint (push) Failing after 1m38s
CI / build (push) Successful in 3m25s
CI / e2e (push) Successful in 9m51s
CI / intent-service-test (push) Successful in 15m38s
CI / test (push) Failing after 30m23s
Reviewed-on: #11
2026-08-28 22:53:40 +02:00
e8ac2d1629 feat(cooking): page /cuisiner + bouton "Commencer a cuisiner"
Some checks failed
CI / lint (push) Successful in 3m1s
CI / test (push) Failing after 3s
CI / build (push) Successful in 4m24s
CI / intent-service-test (push) Successful in 18m40s
CI / e2e (push) Successful in 13m15s
- Bouton "Commencer a cuisiner" dans l'en-tete du planning, actif seulement
  quand la semaine affichee contient >= 1 recette ; navigue vers
  /cuisiner?date=<semaine>.
- Page CookingSessionPage (/cuisiner) : consomme GET /cooking-session, rend
  les phases (mise en place / cuisson / dressage), les taches mutualisees
  avec badge "Mutualise" + ingredients/ustensiles resolus, et la bande
  "Pendant ce temps" pour les cuissons de fond. Semaine lue depuis ?date=,
  WeekNavigator en fallback ; recalcul a chaque visite (comme la liste de
  courses).
- Logique pure extraite dans cooking-session.ts (composition des libelles,
  formatage des quantites).
- i18n : bloc cookingSession.* + planning.startCooking.
- apiClient.getCookingPlanForWeek.
- Tests Cypress : cooking-session-page.cy.ts (layout, 3 cas), cooking-session
  .feature (parcours planning -> /cuisiner), assertions bouton dans
  planning-page.cy.ts ; step generique "button should be disabled" mutualise.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-28 19:06:24 +02:00
6b60c11408 feat(cooking): endpoint GET /cooking-session (plan de cuisine optimise)
Module cooking-session : charge le Planning couvrant ?date= (meme requete
"plage couvrante" + degradation "jamais null" que /shopping-list), mappe
chaque PlanningItem vers l'entree pure de optimizeCookingPlan (ingredients/
unites/techniques/ustensiles resolus via toIngredientView/toUnitView
reutilisees de recipe.service), renvoie OptimizedCookingPlanView.

- cookingSessionPlanningInclude reprend le sous-arbre steps de recipeInclude.
- Route requireAuth, contrat ?date= identique a /shopping-list.
- Monte /cooking-session dans app.ts.
- Tests d'integration Mocha (401, date invalide, plan vide sans foyer /
  sans planning, mutualisation d'une decoupe entre 2 recettes planifiees).
- specs/batch-cooking-architecture.md : module "Calcul batch-cooking" TODO
  -> v1 implementee ; nouvelle section dans backend-architecture.md.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-28 19:06:24 +02:00
83a12d474d feat(cooking): moteur d'optimisation des etapes de batch-cooking
Coeur pur du module "Calcul batch-cooking" (specs/batch-cooking-architecture.md,
jusqu'ici TODO) : optimizeCookingPlan() prend les recettes planifiees d'une
semaine et les reorganise en phases ordonnees.

- Mutualisation de la mise en place : une meme technique de decoupe (chop, peel,
  mince...) appliquee au meme ingredient par >= 2 recettes est regroupee en une
  seule tache "merged-prep" (les oignons de plusieurs recettes = une decoupe).
- Parallelisme : les cuissons passives (simmer, braise, bake, marinate...) sont
  poussees en tache de fond des phases suivantes pendant qu'une autre recette
  avance en actif.
- Fonction pure sans base (meme split matchXxx pur / loadXxx DB-backed que
  ingredient-matcher / tech-step-matcher), testable en isolation.

Types partages : OptimizedCookingPlanView + schema getCookingSessionSchema.
Tests Mocha purs (6 cas) : merge, non-merge d'une decoupe solo, tache de fond,
mise a l'echelle par portions, plan vide, legende.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-28 19:06:24 +02:00
142 changed files with 11076 additions and 135 deletions

View file

@ -15,6 +15,27 @@ JWT_SECRET=changeme-generate-a-real-random-secret-at-least-32-chars
# (docker-compose.yml), serving both the API and the built frontend.
# APP_PORT=3000
# --- Admin application (apps/admin-web + the /admin/* API surface) ---------
# All optional: an instance that doesn't run the admin app needs none of
# these. `requireAdmin` fails closed when ADMIN_JWT_SECRET is unset, so
# leaving it out simply disables every /admin/* route.
#
# Secret for the admin session JWT — MUST be different from JWT_SECRET so an
# end-user token can never be replayed against /admin/*. Generate your own
# the same way as JWT_SECRET above.
# ADMIN_JWT_SECRET=changeme-generate-a-real-random-secret-at-least-32-chars
# Origin apps/admin-web is served from, added to the CORS allow-list.
# ADMIN_CORS_ORIGIN=http://localhost:5174
# Host port for the Docker `admin-web` service (static nginx serving the
# built admin frontend).
# ADMIN_WEB_PORT=3001
# Optional — read only by `src/scripts/create-admin.ts` when its --email /
# --password / --name flags are omitted (e.g. to bootstrap the first admin
# from inside the container). Never read by the running server.
# ADMIN_INITIAL_EMAIL=ops@example.com
# ADMIN_INITIAL_PASSWORD=changeme-at-least-8-chars
# ADMIN_INITIAL_NAME=Ops
# Optional — only set this to false if THIS deployment is served over
# plain HTTP (no TLS in front of it). Left unset, the session cookie
# requires HTTPS (Secure attribute) as it should for a real deployment;

View file

@ -19,6 +19,10 @@ env:
# exercise the success path (matching secret), not just the "unset"
# rejection every environment that doesn't set this gets by default.
INTERNAL_WORKER_SECRET: "ci-only-worker-secret-not-used-anywhere-else-32chars+"
# Same reasoning — lets admin-auth.test.ts exercise the admin login
# success path + the requireAdmin-guarded routes, not just the "no admin
# secret configured -> 401" path.
ADMIN_JWT_SECRET: "ci-only-admin-secret-not-used-anywhere-else-32chars+"
# Shared between the `test` job's own uvicorn step (below) and apps/api's
# IntentServiceClient — see the `test` job for why this can't be a
# `services:` container like postgres above (GitHub Actions can only pull
@ -177,3 +181,10 @@ jobs:
# `component.devServer`), unlike `e2e` above which needs the real app
# running first.
- run: pnpm --filter web cy:run:component
# The admin app's own Cypress suite (`apps/admin-web`) — its own dev
# server on :5174, all `/admin/*` calls mocked via `cy.intercept`
# (no live backend needed), same as the `web` e2e run above.
- name: Run admin-web E2E tests
env:
HOST: "0.0.0.0"
run: pnpm --filter admin-web e2e

3
.gitignore vendored
View file

@ -163,3 +163,6 @@ 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

@ -0,0 +1,6 @@
# Vite only exposes vars prefixed with VITE_ to client code.
# Base URL of the API's /admin/* surface. Empty string = same origin as the
# page (correct behind a shared reverse proxy). Native dev overrides it in
# apps/admin-web/.env since the Vite dev server (5174) and the API (3000)
# are different origins.
VITE_ADMIN_API_URL=http://localhost:3000

25
apps/admin-web/Dockerfile Normal file
View file

@ -0,0 +1,25 @@
# Its own image (not built into apps/api's) — the admin app is deployed
# independently of the main app. Build stage compiles the Vite bundle from
# the monorepo; runtime is a plain static nginx serving that bundle.
#
# Build context is the repo root (like apps/api/Dockerfile) — the workspace
# packages (@batch-cooking/shared, @batch-cooking/date-tools) must resolve.
FROM node:22-slim AS build
RUN corepack enable
WORKDIR /repo
# Skip Cypress's Electron binary download — this image never runs it.
ENV CYPRESS_INSTALL_BINARY=0
COPY . .
RUN pnpm install --frozen-lockfile
# The admin bundle bakes in VITE_ADMIN_API_URL at build time. Default ""
# (same-origin — correct behind a shared reverse proxy); override with
# `--build-arg VITE_ADMIN_API_URL=https://api.example.com` when the admin
# app is served from a different origin than the API.
ARG VITE_ADMIN_API_URL=""
ENV VITE_ADMIN_API_URL=$VITE_ADMIN_API_URL
RUN pnpm --filter admin-web build
FROM nginx:alpine AS runtime
COPY apps/admin-web/nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=build /repo/apps/admin-web/dist /usr/share/nginx/html
EXPOSE 80

View file

@ -0,0 +1,28 @@
import { addCucumberPreprocessorPlugin } from "@badeball/cypress-cucumber-preprocessor";
import { createEsbuildPlugin } from "@badeball/cypress-cucumber-preprocessor/esbuild";
import createBundler from "@bahmutov/cypress-esbuild-preprocessor";
import { defineConfig } from "cypress";
// Disable GPU for headless/sandboxed environments where no GPU device is
// available — same helper as apps/web's cypress.config.ts.
function disableGpu(on: Cypress.PluginEvents) {
on("before:browser:launch", (browser, launchOptions) => {
if (browser.family === "chromium") {
launchOptions.args.push("--disable-gpu", "--no-sandbox");
}
return launchOptions;
});
}
export default defineConfig({
e2e: {
baseUrl: "http://localhost:5174",
specPattern: ["cypress/e2e/**/*.cy.ts", "cypress/e2e/**/*.feature"],
async setupNodeEvents(on, config) {
disableGpu(on);
await addCucumberPreprocessorPlugin(on, config);
on("file:preprocessor", createBundler({ plugins: [createEsbuildPlugin(config)] }));
return config;
},
},
});

View file

@ -0,0 +1,62 @@
// 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("/monitoring");
cy.url().should("include", "/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("/");
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", "/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", "/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", "/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}/`);
});
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("/");
// 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", "/login");
});
});

View file

@ -0,0 +1,96 @@
// 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("/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("/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", () => {
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("/catalogue");
cy.contains(".catalog-tabs button", "Traités").click();
cy.wait("@getReviewed");
cy.contains("Aucun ingrédient hors-catalogue").should("be.visible");
});
});

View file

@ -0,0 +1,159 @@
// 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("/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("/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("/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("/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

@ -0,0 +1,95 @@
// 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("/");
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("/");
cy.contains("Impossible de charger").should("be.visible");
});
});

View file

@ -0,0 +1,24 @@
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 "/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 "/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 "/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 "/login"
And I should see the heading "Tableau de bord"

View file

@ -0,0 +1,24 @@
import { Given } from "@badeball/cypress-cucumber-preprocessor";
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("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("/"), 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

@ -0,0 +1,83 @@
// 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 });
});
it("renders one card per service with its status and details", () => {
cy.intercept("GET", "**/admin/monitoring", { statusCode: 200, body: monitoringFixture() }).as(
"getMonitoring",
);
cy.visit("/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("GET", "**/admin/monitoring", {
statusCode: 500,
body: { code: 5000, message: "x" },
});
cy.visit("/monitoring");
cy.contains("Impossible de charger").should("be.visible");
});
});

View file

@ -0,0 +1,3 @@
// Cypress support file — global config and custom commands go here as the
// admin app grows. Same minimal starting point as apps/web's e2e.ts.
export {};

View file

@ -0,0 +1,54 @@
import { Given, Then, When } from "@badeball/cypress-cucumber-preprocessor";
// Steps shared across admin feature specs — navigation and generic UI
// assertions. Anything specific to one feature (its own API mocks, its own
// DOM structure) lives in that feature's own `<name>.ts` step file.
//
// Every admin API call is mocked via `cy.intercept` — the Cypress suite
// never runs a live backend; apps/api's own Mocha suite covers real
// `/admin/*` behaviour.
Given("the admin session check returns unauthenticated", () => {
cy.intercept("GET", "**/admin/auth/me", { statusCode: 401, body: { code: 4011, message: "no" } });
});
Given("I am signed in as admin {string}", (name: string) => {
cy.intercept("GET", "**/admin/auth/me", {
statusCode: 200,
body: {
id: 1,
email: `${name.toLowerCase()}@example.com`,
name,
createdAt: "2026-08-01T00:00:00.000Z",
lastLoginAt: "2026-08-28T09:00:00.000Z",
},
});
});
When("I visit {string}", (path: string) => {
cy.visit(path);
});
When("I fill in the {string} field with {string}", (fieldId: string, value: string) => {
cy.get(`#${fieldId}`).clear().type(value);
});
When("I click the button {string}", (text: string) => {
cy.contains("button", text).click();
});
Then("the URL should include {string}", (fragment: string) => {
cy.url().should("include", fragment);
});
Then("the URL should not include {string}", (fragment: string) => {
cy.url().should("not.include", fragment);
});
Then("I should see {string}", (text: string) => {
cy.contains(text).should("be.visible");
});
Then("I should see the heading {string}", (text: string) => {
cy.contains("h1", text).should("be.visible");
});

12
apps/admin-web/index.html Normal file
View file

@ -0,0 +1,12 @@
<!doctype html>
<html lang="fr">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>batchCooking — Admin</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

20
apps/admin-web/nginx.conf Normal file
View file

@ -0,0 +1,20 @@
# Static host for the built admin SPA. Client-side routing (react-router)
# means any unknown path must fall back to index.html rather than 404
# same reason apps/api serves its own SPA that way.
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
location / {
try_files $uri $uri/ /index.html;
}
# Long-cache the fingerprinted assets Vite emits; never cache the HTML
# entry point so a new deploy is picked up immediately.
location /assets/ {
expires 1y;
add_header Cache-Control "public, immutable";
}
}

View file

@ -0,0 +1,42 @@
{
"name": "admin-web",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite --host 0.0.0.0 --port 5174",
"build": "tsc -b && vite build",
"preview": "vite preview --port 5174",
"test": "echo \"no unit tests yet\" && exit 0",
"cy:open": "cypress open",
"cy:run": "cypress run",
"e2e": "start-server-and-test dev http://localhost:5174 cy:run"
},
"dependencies": {
"@batch-cooking/date-tools": "workspace:*",
"@batch-cooking/shared": "workspace:*",
"i18next": "^26.3.6",
"lucide-react": "^1.32.0",
"react": "^18.3.1",
"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": {
"@badeball/cypress-cucumber-preprocessor": "22.2.0",
"@bahmutov/cypress-esbuild-preprocessor": "2.2.8",
"@cypress/vite-dev-server": "5.2.1",
"@types/node": "^22.9.0",
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"@vitejs/plugin-react": "^4.3.3",
"cypress": "13.17.0",
"esbuild": "0.21.5",
"sass": "^1.102.0",
"start-server-and-test": "^2.0.8",
"typescript": "^5.7.2",
"vite": "^5.4.11"
}
}

View file

@ -0,0 +1,36 @@
import { Navigate, Route, Routes } from "react-router-dom";
import { RequireAdmin } from "./features/auth/RequireAdmin";
import { AdminLayout } from "./layouts/AdminLayout";
import { CatalogPage } from "./pages/catalog/CatalogPage";
import { CorrectionsPage } from "./pages/corrections/CorrectionsPage";
import { DashboardPage } from "./pages/dashboard/DashboardPage";
import { LoginPage } from "./pages/login/LoginPage";
import { MonitoringPage } from "./pages/monitoring/MonitoringPage";
/**
* Admin app route table. `/login` is the only unauthenticated route;
* everything else is nested under one `RequireAdmin` + `AdminLayout` parent
* (the guard + sidebar chrome applied once), same shape as apps/web's
* `App.tsx`. Unknown paths fall back to `/`, which redirects to `/login`
* when there's no admin session.
*/
export function App() {
return (
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route
element={
<RequireAdmin>
<AdminLayout />
</RequireAdmin>
}
>
<Route path="/" element={<DashboardPage />} />
<Route path="/monitoring" element={<MonitoringPage />} />
<Route path="/corrections" element={<CorrectionsPage />} />
<Route path="/catalogue" element={<CatalogPage />} />
</Route>
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
);
}

View file

@ -0,0 +1,192 @@
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 admin API surface, configurable via `VITE_ADMIN_API_URL`
* (see `.env.example`). Defaults to `""` (same origin) correct behind a
* shared reverse proxy; native dev overrides it to `http://localhost:3000`
* in `apps/admin-web/.env` since the Vite dev server (5174) and the API
* (3000) are different origins.
*/
const ADMIN_API_BASE_URL: string = import.meta.env.VITE_ADMIN_API_URL ?? "";
/**
* Thrown by {@link AdminApiClient} on any non-2xx response carries the
* same {@link ErrorCode} the API returned. Same shape as apps/web's
* `ApiError`; kept separate rather than shared so the two apps' transport
* layers stay independent.
*/
export class ApiError 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 = "ApiError";
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 {ApiError} 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 ApiError(
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

@ -0,0 +1,71 @@
import type { AdminLoginInput, AdminUserView } from "@batch-cooking/shared";
import { createContext, type ReactNode, useCallback, useContext, useEffect, useState } from "react";
import { adminApiClient } from "../../api/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

@ -0,0 +1,21 @@
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 `/login`. Mirror of
* apps/web's `RequireAuth`.
*/
export function RequireAdmin({ children }: { children: ReactNode }) {
const { admin, isLoading } = useAdminAuth();
if (isLoading) {
return null;
}
if (!admin) {
return <Navigate to="/login" replace />;
}
return <>{children}</>;
}

View file

@ -0,0 +1,84 @@
// =============================================================================
// 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

@ -0,0 +1,22 @@
import i18next from "i18next";
import { initReactI18next } from "react-i18next";
import fr from "../locales/fr/translation.json";
/**
* i18next instance for the admin app, imported once for its side effect
* (`main.tsx`) before anything renders. Only French exists today same
* setup as apps/web's `i18n/i18n.ts`, its own separate locale file so the
* two apps' copy never has to be kept identical. `packages/shared`'s
* `ErrorCode` member names double as keys under the `errors` namespace
* (see `services/error-message.service.ts`).
*/
void i18next.use(initReactI18next).init({
resources: {
fr: { translation: fr },
},
lng: "fr",
fallbackLng: "fr",
interpolation: { escapeValue: false },
});
export default i18next;

View file

@ -0,0 +1,108 @@
// =============================================================================
// 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

@ -0,0 +1,84 @@
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/auth/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.
*/
const NAV_ITEMS = [
{ to: "/", key: "dashboard", Icon: LayoutDashboard, end: true },
{ to: "/monitoring", key: "monitoring", Icon: Activity, end: false },
{ to: "/corrections", key: "corrections", Icon: ListChecks, end: false },
{ to: "/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("/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("/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

@ -0,0 +1,18 @@
import type { ZodError } from "zod";
/**
* Flattens a zod validation error into `{ fieldName: firstMessage }` for
* inline display under each form field verbatim copy of apps/web's
* `lib/zod-errors.ts` (only the first message per field, enough for the
* single-rule-per-field schemas used here).
*/
export function fieldErrorsFrom(error: ZodError): Record<string, string> {
const fieldErrors = error.flatten().fieldErrors;
const firstMessagePerField: Record<string, string> = {};
for (const [field, messages] of Object.entries(fieldErrors)) {
if (messages?.[0]) {
firstMessagePerField[field] = messages[0];
}
}
return firstMessagePerField;
}

View file

@ -0,0 +1,148 @@
{
"errors": {
"VALIDATION_ERROR": "Erreur de validation",
"INVALID_CREDENTIALS": "Email ou mot de passe incorrect",
"NOT_AUTHENTICATED": "Vous devez être connecté",
"NOT_FOUND": "Ressource introuvable",
"TECH_STEP_NOT_FOUND": "Cette technique n'existe pas",
"RETRAIN_ALREADY_RUNNING": "Un ré-entraînement est déjà en cours",
"INTERNAL_ERROR": "Une erreur est survenue, réessayez plus tard"
},
"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

@ -0,0 +1,24 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { BrowserRouter } from "react-router-dom";
import { App } from "./App";
import { AdminAuthProvider } from "./features/auth/AdminAuthContext";
// Side-effect import: initializes i18next before anything renders.
import "./i18n/i18n";
// Global stylesheet (theme tokens + minimal reset) — the only non-colocated .scss import.
import "./styles/global.scss";
const rootElement = document.getElementById("root");
if (!rootElement) {
throw new Error("Root element not found");
}
createRoot(rootElement).render(
<StrictMode>
<BrowserRouter>
<AdminAuthProvider>
<App />
</AdminAuthProvider>
</BrowserRouter>
</StrictMode>,
);

View file

@ -0,0 +1,26 @@
// =============================================================================
// 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

@ -0,0 +1,169 @@
import type { CatalogPlaceholderGroupView } from "@batch-cooking/shared";
import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { adminApiClient } from "../../api/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);
const load = useCallback((forTab: CatalogTab) => {
setState({ status: "loading" });
adminApiClient
.getPlaceholders(reviewedParam(forTab))
.then((groups) => setState({ status: "loaded", groups }))
.catch(() => 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

@ -0,0 +1,144 @@
// =============================================================================
// 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

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

@ -0,0 +1,395 @@
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 { ApiError, adminApiClient } from "../../api/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 ApiError ? 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 ApiError ? 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 ApiError ? 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

@ -0,0 +1,266 @@
// =============================================================================
// 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

@ -0,0 +1,23 @@
/**
* 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

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

@ -0,0 +1,103 @@
// =============================================================================
// 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

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

@ -0,0 +1,85 @@
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 { ApiError } from "../../api/client";
import { useAdminAuth } from "../../features/auth/AdminAuthContext";
import "../../features/auth/admin-auth.scss";
import { fieldErrorsFrom } from "../../lib/zod-errors";
import { errorMessageService } from "../../services/error-message.service";
/**
* The admin login screen the only unauthenticated route. 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
* apps/web's `LoginPage`.
*/
export function LoginPage() {
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("/");
} catch (err) {
const code = err instanceof ApiError ? 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

@ -0,0 +1,110 @@
import type { MonitoringView, ServiceHealthView } from "@batch-cooking/shared";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { adminApiClient } from "../../api/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

@ -0,0 +1,98 @@
// =============================================================================
// 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

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

@ -0,0 +1,19 @@
import { ErrorCode } from "@batch-cooking/shared";
import i18n from "../i18n/i18n";
/**
* Localized label for an {@link ErrorCode} returned by the admin API
* verbatim behaviour of apps/web's `ErrorMessageService`: reverse-maps the
* numeric enum value to its member name (`4010` `"INVALID_CREDENTIALS"`)
* and looks it up under the `errors` namespace, falling back to
* `INTERNAL_ERROR` for a code this client doesn't recognise.
*/
export class ErrorMessageService {
public getLabel(code: ErrorCode): string {
const memberName = ErrorCode[code] ?? ErrorCode[ErrorCode.INTERNAL_ERROR];
return i18n.t(`errors.${memberName}`);
}
}
/** Single shared instance — stateless. */
export const errorMessageService = new ErrorMessageService();

View file

@ -0,0 +1,171 @@
// NOTE: verbatim copy of apps/web/src/styles/_theme.scss. Extracting these
// tokens into a shared package (consumed by both apps) is tracked separately
// keep the two files in sync by hand until then.
// =============================================================================
// Design tokens the single source of truth for colors, spacing, typography
// and other reusable values across the whole app.
//
// Exposed as CSS custom properties on :root (not plain SCSS variables) so
// they're available at *runtime*, not just compile time — this is what lets
// dark mode work below by simply redefining these variables instead of
// rebuilding the stylesheet. Every other .scss file should reference
// `var(--token-name)`, never a hardcoded color/size.
//
// Palette name: "Mise en Place" a kitchen-operations identity (batch
// cooking as logistics: everything labeled and in its place before you
// start) rather than a food-blog one. See the design proposal for the full
// rationale: https://claude.ai/code/artifact/1db63af0-cfd1-4f77-9369-71ca6accd06f
//
// Import this partial once, globally (see global.scss) never re-import it
// from a component-level .scss file, `:root` only needs to be declared once.
// =============================================================================
:root {
// --- Surfaces & ink ---------------------------------------------------
// Neutral surface: page background ("porcelaine") vs. the card surface
// content sits on, plus a recessed variant for panels/table headers.
--color-background: #eef2ed;
--color-surface: #ffffff;
--color-surface-alt: #e2e8e0;
// Text.
--color-text: #1f2a22;
--color-text-muted: #57685a;
--color-border: #c7d0c4;
// --- Brand accents, each with one job --------------------------------
// Basil primary actions, links, brand presence.
--color-primary: #2e6b4a;
--color-primary-hover: #244f38;
// Vermillion secondary accent for urgency/strong calls to action
// (e.g. a timer, "start session"). Never reused for allergen alerts
// below those need their own, unambiguous color.
--color-accent: #cc4b26;
--color-accent-hover: #a83c1c;
// Turmeric category/classification tags.
--color-tag: #c98a1b;
--color-tag-ink: #3a2c05; // pairs with a solid --color-tag fill only.
// --- Feedback -----------------------------------------------------------
--color-success: #2e6b4a;
--color-warning: #c98a1b;
--color-error: #b3271e;
// --- Allergens / intolerances --------------------------------------------
// A 3-tier food-safety scale, kept distinct from --color-error so an
// allergen warning is never confused with a form validation error:
// - critical (declared allergen): its own color, solid/inverted fill
// - moderate (intolerance): reuses --color-warning, tinted fill
// - trace ("may contain traces of…"): neutral, dashed outline
// The severity is carried by the FILL TREATMENT, not the hue alone, so
// it stays legible for color-blind users. See the design proposal's
// "Alertes & allergènes" section for the full component set.
--color-allergen: #a8123f;
--color-allergen-ink: #ffe9ef; // pairs with a solid --color-allergen fill only.
// --- Spacing scale ---------------------------------------------------------
// Multiples of a 4px base unit use these instead of ad hoc px values so
// spacing stays visually consistent as the app grows.
--space-xs: 0.25rem; // 4px
--space-sm: 0.5rem; // 8px
--space-md: 1rem; // 16px
--space-lg: 1.5rem; // 24px
--space-xl: 2rem; // 32px
--space-2xl: 3rem; // 48px inter-section spacing
// --- Typography --------------------------------------------------------
// System font stacks only no remote webfont, so there's zero loading
// latency and no flash of unstyled text, which fits an app meant to be
// used quickly under time pressure. Three roles: a condensed "label"
// face for headings/eyebrows, a humanist face for body copy, and a
// monospace for anything that lines up in columns (times, quantities).
--font-display: "Bahnschrift", "Arial Narrow", "Segoe UI", sans-serif;
--font-body: "Segoe UI", "Helvetica Neue", Arial, sans-serif;
--font-mono: "Cascadia Mono", Consolas, "SF Mono", "Liberation Mono", monospace;
--font-size-xs: 0.75rem; // 12px captions, meta
--font-size-sm: 0.875rem; // 14px labels, secondary text
--font-size-base: 1rem; // 16px body
--font-size-md: 1.125rem; // 18px lead paragraph
--font-size-lg: 1.375rem; // 22px H3 / card titles
--font-size-xl: 1.75rem; // 28px H2 / section titles
--font-size-2xl: 2.25rem; // 36px H1 / page titles
--font-size-3xl: 3rem; // 48px display, exceptional use only
// --- Shape / elevation --------------------------------------------------
--radius-base: 4px; // controls (inputs, buttons) deliberately flat
--radius-md: 10px; // cards
--radius-lg: 18px; // panels, modals
--radius-pill: 999px; // tags, badges
--max-width-form: 22rem;
--shadow-sm: 0 1px 2px rgba(31, 42, 34, 0.08), 0 1px 1px rgba(31, 42, 34, 0.06);
--shadow-md: 0 6px 16px rgba(31, 42, 34, 0.12), 0 2px 6px rgba(31, 42, 34, 0.08);
}
// Lets the browser pick sensible default colors (form controls, scrollbars)
// for whichever mode the user ends up in.
:root {
color-scheme: light dark;
}
// --- Dark mode ---------------------------------------------------------
// Follows the OS/browser preference by default. Guarded with
// `:root:not([data-theme="light"])` so an explicit "light" choice (see
// apps/web's `ThemeContext`, `SYSTEM` = no `data-theme` attribute at all —
// this block then decides) can override a dark OS setting.
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
--color-background: #14181a;
--color-surface: #1c221e;
--color-surface-alt: #262e27;
--color-text: #edf1ea;
--color-text-muted: #a9b5a6;
--color-border: #384038;
--color-primary: #5fae7e;
--color-primary-hover: #7cc496;
--color-accent: #ea7a48;
--color-accent-hover: #f0946c;
--color-tag: #e8b84b;
--color-tag-ink: #2a2005;
--color-success: #5fae7e;
--color-warning: #e8b84b;
--color-error: #e5675a;
--color-allergen: #e2547b;
--color-allergen-ink: #3a0416;
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.35);
--shadow-md: 0 8px 20px rgba(0, 0, 0, 0.45);
}
}
// Mirrors the block above for an explicit "dark" choice (`ThemeContext`
// sets `data-theme="dark"` on `<html>`), so it wins over the OS setting in
// both directions.
:root[data-theme="dark"] {
--color-background: #14181a;
--color-surface: #1c221e;
--color-surface-alt: #262e27;
--color-text: #edf1ea;
--color-text-muted: #a9b5a6;
--color-border: #384038;
--color-primary: #5fae7e;
--color-primary-hover: #7cc496;
--color-accent: #ea7a48;
--color-accent-hover: #f0946c;
--color-tag: #e8b84b;
--color-tag-ink: #2a2005;
--color-success: #5fae7e;
--color-warning: #e8b84b;
--color-error: #e5675a;
--color-allergen: #e2547b;
--color-allergen-ink: #3a0416;
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.35);
--shadow-md: 0 8px 20px rgba(0, 0, 0, 0.45);
}

View file

@ -0,0 +1,149 @@
// =============================================================================
// Global stylesheet imported exactly once, in main.tsx. Contains only
// truly app-wide rules: the theme tokens and a minimal reset/base styling
// that every page inherits. Anything specific to one component or page
// belongs in a .scss file colocated next to that component/page instead.
// =============================================================================
@use "./theme";
// Include borders/padding in an element's declared width/height everywhere,
// rather than the browser default of adding them on top.
*,
*::before,
*::after {
box-sizing: border-box;
}
// Minimal reset: remove the default body margin so pages can control their
// own layout without fighting the browser's default 8px margin.
body {
margin: 0;
font-family: var(--font-body);
font-size: var(--font-size-base);
line-height: 1.55;
color: var(--color-text);
background: var(--color-background);
}
// Headings use the condensed "label" face app-wide see _theme.scss for
// the rationale. `text-wrap: balance` avoids a lone short word wrapping
// onto its own line in multi-line titles.
h1,
h2,
h3,
h4,
h5,
h6 {
margin: 0;
font-family: var(--font-display);
font-weight: 700;
text-wrap: balance;
}
// Default to the page-title size; a heading used as a smaller component
// title (e.g. the auth card's <h1>) overrides this in its own stylesheet.
h1 {
font-size: var(--font-size-2xl);
}
// A visible, consistent focus ring for keyboard navigation the browser
// default varies a lot between elements and browsers.
:focus-visible {
outline: 2px solid var(--color-accent);
outline-offset: 2px;
}
// Checkbox/radio appearance, app-wide "selectable card" style: the native
// control itself is visually hidden (still real, focusable and
// screen-reader-visible see the `input[type=...]` rule below, not
// `display: none`) and the whole label row it lives in becomes the
// interactive surface instead: a flat bordered box that fills in with a
// tinted background + primary border once selected, with a checkmark
// fading in on the leading edge.
//
// The base (unselected) look below is detected structurally with `:has()`
// safe, since "does this label contain a checkbox/radio" never changes
// after mount. The *selected* look is instead driven by the `is-selected`
// class {@link CheckboxOption}/{@link RadioOption} (components/ui/) toggle
// in JS from the same boolean their caller already passes to `checked`
// chaining a second `:has(:checked)` to react to that live state turned
// out to be unreliable across browsers, so this only needs one
// always-true `:has()`.
//
// Every checkbox/radio in the app goes through this one place (the allergy
// grid, the theme picker, anywhere future) rather than each feature styling
// its own see profile-forms.scss / settings-pages.scss, which only
// arrange these within their own layout (grid vs. stacked list) and
// intentionally don't re-style the control/label look itself.
label:has(> input[type="checkbox"]),
label:has(> input[type="radio"]) {
position: relative;
display: flex;
align-items: center;
gap: var(--space-xs);
padding: var(--space-xs) var(--space-sm);
// Overrides the generic `label { font-weight: 600 }` base rule
// (profile-forms.scss) without this, an *unselected* row reads just as
// bold as a selected one (only `.allergy-select__option` happened to set
// its own 400 already; `.theme-select__option` didn't, so its rows were
// all permanently bold until this was centralized here).
font-weight: 400;
border: 1.5px solid var(--color-border);
border-radius: var(--radius-base);
background: var(--color-surface);
cursor: pointer;
transition:
background-color 0.15s ease,
border-color 0.15s ease;
&:hover {
border-color: var(--color-primary);
}
&.is-selected {
border-color: var(--color-primary);
background: color-mix(in srgb, var(--color-primary) 12%, var(--color-surface));
color: var(--color-primary);
font-weight: 600;
}
&:has(:focus-visible) {
outline: 2px solid var(--color-accent);
outline-offset: 2px;
}
}
// The control itself is removed from the visual flow hidden the
// "sr-only" way (not `display: none`) so it stays focusable/tabbable and
// announced correctly by screen readers; the label above carries the
// entire visible selected/unchecked look.
input[type="checkbox"],
input[type="radio"] {
position: absolute;
width: 1px;
height: 1px;
margin: 0;
opacity: 0;
}
// The checkmark a real element (see components/ui/Checkbox.tsx /
// Radio.tsx) shown via the same `is-selected` class as the label's own
// look above, not a separate CSS-only trigger. Scaled in from nothing so
// toggling has a bit of motion. Same mark for both checkbox and radio: one
// consistent "selected" language app-wide rather than a checkmark here and
// a dot there. Sits first in the row (before the label text, per DOM
// order) a classic "control on the left" layout rather than trailing.
.check-mark {
flex: none;
width: 0.9rem;
height: 0.9rem;
background: var(--color-primary);
clip-path: polygon(14% 44%, 0 65%, 50% 100%, 100% 16%, 80% 0%, 43% 62%);
transform: scale(0);
transition: transform 0.1s ease;
}
.is-selected .check-mark {
transform: scale(1);
}

5
apps/admin-web/src/vite-env.d.ts vendored Normal file
View file

@ -0,0 +1,5 @@
/// <reference types="vite/client" />
// Injected by `define` in vite.config.ts, sourced from package.json's
// version field — rendered in AdminLayout.tsx.
declare const __APP_VERSION__: string;

View file

@ -0,0 +1,14 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"module": "ESNext",
"moduleResolution": "Bundler",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"jsx": "react-jsx",
"types": ["vite/client"],
"noEmit": true,
"composite": true,
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo"
},
"include": ["src"]
}

View file

@ -0,0 +1,8 @@
{
"compilerOptions": {
"module": "ESNext",
"moduleResolution": "Bundler"
},
"files": [],
"references": [{ "path": "./tsconfig.app.json" }, { "path": "./tsconfig.node.json" }]
}

View file

@ -0,0 +1,12 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"composite": true,
"noEmit": true,
"skipLibCheck": true,
"types": ["node"]
},
"include": ["vite.config.ts"]
}

View file

@ -0,0 +1,19 @@
import { readFileSync } from "node:fs";
import react from "@vitejs/plugin-react";
import { defineConfig } from "vite";
// Read once at config-eval time — same trick as apps/web's vite.config.ts.
const pkg = JSON.parse(readFileSync(new URL("./package.json", import.meta.url), "utf-8"));
export default defineConfig({
plugins: [react()],
server: { port: 5174 },
css: {
preprocessorOptions: {
scss: { api: "modern-compiler" },
},
},
define: {
__APP_VERSION__: JSON.stringify(pkg.version),
},
});

View file

@ -27,3 +27,10 @@ INTENT_SERVICE_SECRET=changeme-generate-a-real-random-secret-at-least-32-chars
# success path (a request with a matching secret); every other test runs
# fine without it. Any value at least 32 chars works locally.
# INTERNAL_WORKER_SECRET=changeme-generate-a-real-random-secret-at-least-32-chars
# Optional — set to run admin-auth.test.ts's login success path and the
# requireAdmin-guarded routes (any value at least 32 chars). Left unset,
# those cases self-skip and only the "no secret configured -> 401" path
# runs. Same "optional in test, fail-closed at runtime" posture as
# INTERNAL_WORKER_SECRET above.
# ADMIN_JWT_SECRET=changeme-generate-a-real-random-secret-at-least-32-chars

View file

@ -0,0 +1,15 @@
-- CreateTable
CREATE TABLE "admin_users" (
"id" SERIAL NOT NULL,
"email" TEXT NOT NULL,
"password_hash" TEXT NOT NULL,
"name" TEXT NOT NULL,
"token_version" INTEGER NOT NULL DEFAULT 0,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"last_login_at" TIMESTAMP(3),
CONSTRAINT "admin_users_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "admin_users_email_key" ON "admin_users"("email");

View file

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

@ -0,0 +1,14 @@
-- 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,9 +100,13 @@ 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")
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")
house House? @relation("HouseMember", fields: [houseId], references: [id], onDelete: SetNull)
diet Diet? @relation(fields: [dietId], references: [id], onDelete: SetNull)
@ -125,6 +129,9 @@ 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")
}
@ -192,6 +199,12 @@ 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[]
@ -200,12 +213,14 @@ 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])
@ -319,6 +334,10 @@ 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)
@ -514,6 +533,38 @@ 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[]
@ -524,7 +575,10 @@ 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")
}
@ -897,3 +951,81 @@ model TechStepTrainingSuggestion {
@@map("tech_step_training_suggestion")
}
// -----------------------------------------------------------------------------
// Admin application
// A separate operations app (usage metrics, microservice monitoring, tech-step
// correction triage) — see specs/backend-architecture.md's "admin" section.
// -----------------------------------------------------------------------------
/// An operator of the admin application (`apps/admin-web` / the `/admin/*`
/// API surface). Deliberately its **own** table with **no relation** to
/// `UserProfile`: admin access is a completely separate concern from being
/// an end user of the recipe app — a person can be one, both or neither,
/// and the two auth mechanisms (`requireAdmin` vs `requireAuth`, distinct
/// cookies, distinct JWT secrets) never overlap. No self-service signup —
/// the first row is created out-of-band by `src/scripts/create-admin.ts`,
/// and (for now) there's no in-app admin-management UI.
model AdminUser {
id Int @id @default(autoincrement())
email String @unique
/// argon2 hash of the password — same hashing as `UserProfile.passwordHash`
/// (`auth.service.ts`'s `hashOptions`, cheaper cost under NODE_ENV=test).
passwordHash String @map("password_hash")
/// Display name shown in the admin UI's account menu.
name String
/// Bumped to invalidate previously-issued admin JWTs — same mechanism as
/// `UserProfile.tokenVersion`, checked on every request by `requireAdmin`.
tokenVersion Int @default(0) @map("token_version")
createdAt DateTime @default(now()) @map("created_at")
/// Stamped on every successful login — a cheap "is this account still in
/// use" signal for the operator managing admins by hand.
lastLoginAt DateTime? @map("last_login_at")
@@map("admin_users")
}
/// 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

@ -5,7 +5,9 @@ import type { Express, Request, Response } from "express";
import { env } from "./config/env.js";
import { errorLogger } from "./middlewares/error-logger.js";
import { requestLogger } from "./middlewares/request-logger.js";
import { adminRouter } from "./modules/admin/admin.routes.js";
import { authRouter } from "./modules/auth/auth.routes.js";
import { cookingSessionRouter } from "./modules/cooking-session/cooking-session.routes.js";
import { houseRouter } from "./modules/house/house.routes.js";
import { techStepWorkerRouter } from "./modules/internal/tech-step-worker.routes.js";
import { planningRouter } from "./modules/planning/planning.routes.js";
@ -32,13 +34,19 @@ 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);
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/admin-web`) — its own auth
// (`requireAdmin`, distinct cookie/secret), never the end-user session.
server.mountRouter("/admin", adminRouter);
server.mountRouter("/house", houseRouter);
// Not user-facing — `services/tech-step-llm-worker` only, guarded by
// `requireInternalWorker` on every route within (see that router's own
@ -52,6 +60,7 @@ export function createServer(): ExpressServer {
server.mountRouter("/recipes", recipeRouter);
server.mountRouter("/reference", referenceRouter);
server.mountRouter("/shopping-list", shoppingListRouter);
server.mountRouter("/cooking-session", cookingSessionRouter);
server.mountRouter("/sources", sourcesRouter);
// Serves the built frontend (production Docker image only — see

View file

@ -90,6 +90,27 @@ const envSchema = z.object({
* every technique detection request failing one at a time.
*/
INTENT_SERVICE_SECRET: z.string().min(32, "INTENT_SERVICE_SECRET must be at least 32 characters"),
/**
* Secret used to sign/verify the **admin** session JWT (`lib/admin-jwt.ts`)
* entirely separate from `JWT_SECRET`, so an end-user session token can
* never be replayed against `/admin/*` and vice versa. `.optional()`
* (unlike `JWT_SECRET`): an instance that doesn't run the admin app at
* all never needs it but `requireAdmin` (`middlewares/require-admin.ts`)
* rejects every request outright when it's unset, so the surface fails
* closed, same posture as `INTERNAL_WORKER_SECRET`.
*/
ADMIN_JWT_SECRET: z
.string()
.min(32, "ADMIN_JWT_SECRET must be at least 32 characters")
.optional(),
/** Name of the httpOnly cookie carrying the admin session JWT — must differ from `AUTH_COOKIE_NAME` so the two sessions coexist in one browser. */
ADMIN_COOKIE_NAME: z.string().default("admin_session"),
/** Origin `apps/admin-web` is served from — added to the CORS allow-list alongside `CORS_ORIGIN`. */
ADMIN_CORS_ORIGIN: z.string().default("http://localhost:5174"),
/** Optional seed values read by `src/scripts/create-admin.ts` when its `--email`/`--password`/`--name` flags are omitted — never used by the running server. */
ADMIN_INITIAL_EMAIL: z.string().optional(),
ADMIN_INITIAL_PASSWORD: z.string().optional(),
ADMIN_INITIAL_NAME: z.string().optional(),
});
/** Parsed, validated environment — import this instead of reading `process.env` directly anywhere else. */

View file

@ -0,0 +1,59 @@
import jwt from "jsonwebtoken";
import { env } from "../config/env.js";
/**
* Decoded contents of an **admin** session JWT, once verified. Deliberately
* a separate token type from `lib/jwt.ts`'s `AuthTokenPayload`: the admin
* app authenticates against its own `AdminUser` table with its own secret
* (`ADMIN_JWT_SECRET`), so an end-user session token and an admin session
* token are never interchangeable.
*/
export interface AdminTokenPayload {
/** `AdminUser.id` this token authenticates. */
adminUserId: number;
/** Snapshot of `AdminUser.tokenVersion` at sign time — re-checked against the DB on every request (see `requireAdmin`) to allow server-side invalidation. */
tokenVersion: number;
}
/**
* The admin JWT secret, or a thrown error if the instance never configured
* one. A misconfigured admin deployment surfaces as a loud 500 on login
* rather than a silently-unsigned token; a deployment that doesn't run the
* admin app at all never reaches here (nothing calls sign/verify), and
* `requireAdmin` independently fails closed on the same unset value.
*/
function adminSecret(): string {
if (env.ADMIN_JWT_SECRET === undefined) {
throw new Error("ADMIN_JWT_SECRET is not configured — cannot issue or verify admin sessions");
}
return env.ADMIN_JWT_SECRET;
}
/** Signs a new admin session JWT, expiring per `JWT_EXPIRES_IN` (shared with the end-user token — same "how long a session lasts" policy). */
export function signAdminToken(payload: AdminTokenPayload): string {
return jwt.sign(
{ sub: String(payload.adminUserId), tokenVersion: payload.tokenVersion },
adminSecret(),
{ expiresIn: env.JWT_EXPIRES_IN as jwt.SignOptions["expiresIn"] },
);
}
/**
* Verifies an admin session JWT's signature/expiry and decodes it back
* into an {@link AdminTokenPayload}.
*
* @throws {Error} if the token is invalid/expired (from `jwt.verify`) or
* structurally malformed (missing/wrong-typed claims).
*/
export function verifyAdminToken(token: string): AdminTokenPayload {
const decoded = jwt.verify(token, adminSecret());
const adminUserId = typeof decoded === "object" ? Number(decoded.sub) : Number.NaN;
if (
typeof decoded !== "object" ||
Number.isNaN(adminUserId) ||
typeof decoded.tokenVersion !== "number"
) {
throw new Error("Malformed admin token payload");
}
return { adminUserId, tokenVersion: decoded.tokenVersion };
}

View file

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

@ -0,0 +1,511 @@
import type {
CookingBackgroundTaskView,
CookingPhaseKind,
CookingPhaseView,
CookingSessionRecipeRef,
CookingTaskIngredientView,
CookingTaskView,
TechStepView,
UtensilView,
} from "@batch-cooking/shared";
/**
* The pure core of the "Calcul batch-cooking" module (`specs/batch-cooking-architecture.md`):
* takes the week's planned recipes already resolved to reference views by
* `cooking-session.service.ts` and reorganizes their steps into an ordered
* sequence of {@link CookingPhaseView}s that pools shared preparation and
* interleaves the recipes so passive cooks (simmer, braise, bake) run in
* the background while the cook does active work from another recipe.
*
* Pure and synchronous, no database access same `matchXxx()` pure /
* `loadXxx()` DB-backed split as `ingredient-matcher.ts` /
* `tech-step-matcher.ts` / `shopping-list.service.ts`'s
* `aggregateShoppingList`, so the whole optimization is unit-testable
* without a Postgres round-trip.
*
* v1 scope (see the plan / spec): preparation is the only thing *merged*
* across recipes a `chop`/`peel`/ technique applied to the same
* ingredient by two or more recipes, in a step that does nothing but prep,
* collapses into a single {@link CookingTaskView} of `kind: "merged-prep"`.
* Cooking steps themselves are never merged (no "same oven, same
* temperature" reasoning yet); they're only *reordered* for parallelism.
*/
/**
* Technique keys (`TechStep.key`, see `reference-seed-data.ts`'s
* `TECH_STEPS`) that are pure knife/prep work on an ingredient the only
* techniques v1 pools across recipes. A *step* counts as prep only when
* **every** technique it mentions is in here (see {@link isPurePrepStep}):
* "émincer les oignons" merges, "faire revenir les oignons émincés" does
* not (its `panFry` keeps it a cooking step).
*/
const PREP_TECHNIQUES: ReadonlySet<string> = new Set([
"chop",
"peel",
"mince",
"julienne",
"brunoise",
"concasse",
"paysanne",
"mirepoix",
"zest",
"score",
"pod",
"shellEgg",
"hollowOut",
"filet",
"disgorge",
"sift",
"dustWithFlour",
"peelBlanch",
]);
/**
* How much of the cook's attention a technique needs once it's under way
* the axis that makes parallelism possible.
*
* - `"SETUP"` a short active trigger, then it looks after itself: preheat
* the oven, bring a pot of water to the boil. Pooled into the first
* ("mise en place") phase so it's running before it's needed.
* - `"PASSIVE"` unattended once started (simmer, braise, bake, marinate,
* rest). Scheduled, then floated into every following phase's
* `background` until the step that consumes it comes up.
* - anything not listed here, or a step with no detected technique at all,
* is treated as `"ACTIVE"` hands-on, occupies the cook.
*/
const SETUP_TECHNIQUES: ReadonlySet<string> = new Set(["preheat", "boil", "bainMarie"]);
/** See {@link SETUP_TECHNIQUES}. */
const PASSIVE_TECHNIQUES: ReadonlySet<string> = new Set([
"simmer",
"bake",
"roast",
"braise",
"marinate",
"rest",
"proof",
"confit",
"reduce",
"blindBake",
"compote",
"smother",
"setGel",
"pasteurize",
"appertize",
"poach",
"sweat",
"glaze",
]);
/** Attention class of a single step — see {@link SETUP_TECHNIQUES}. */
type Attention = "SETUP" | "PASSIVE" | "ACTIVE";
/** One technique occurrence within a step, already scaled to the planned portions. */
interface OptimizerTechStepInput {
techStep: TechStepView;
order: number;
ingredients: CookingTaskIngredientView[];
utensils: UtensilView[];
}
/** One recipe step, as handed to {@link optimizeCookingPlan}. */
interface OptimizerStepInput {
stepId: number;
order: number;
description: string;
techSteps: OptimizerTechStepInput[];
}
/**
* One planned recipe, as handed to {@link optimizeCookingPlan}. `portions`
* is the planning slot's own count and `recipePortions` the recipe's
* as-written yield quantities are scaled by `portions / recipePortions`
* (see {@link scaleOf}). The same recipe planned twice at different portion
* counts arrives as two entries with the same `recipeId`; that's
* intentional (two real cooking jobs), and merged-prep still pools their
* knife work back together.
*/
interface OptimizerRecipeInput {
recipeId: number;
name: string;
portions: number;
recipePortions: number;
steps: OptimizerStepInput[];
}
/** {@link optimizeCookingPlan}'s result — the date-range/legend wrapper is added by the service. */
interface OptimizeCookingPlanResult {
recipes: CookingSessionRecipeRef[];
phases: CookingPhaseView[];
}
export type {
OptimizeCookingPlanResult,
OptimizerRecipeInput,
OptimizerStepInput,
OptimizerTechStepInput,
};
/** Portion scale factor for a recipe — guards a missing/zero as-written yield (bad data) by falling back to 1× rather than dividing by zero. */
function scaleOf(recipe: OptimizerRecipeInput): number {
if (!recipe.recipePortions || recipe.recipePortions <= 0) return 1;
return recipe.portions / recipe.recipePortions;
}
/** A step normalized for scheduling — techniques scaled, attention resolved, ingredients/utensils unioned across its technique clauses. */
interface NormalizedStep {
/** Stable within one response: `step:<recipeIndex>:<stepId>` (the index disambiguates the same recipe planned twice). */
taskId: string;
recipeIndex: number;
recipe: CookingSessionRecipeRef;
stepId: number;
order: number;
description: string;
techSteps: OptimizerTechStepInput[];
attention: Attention;
isPurePrep: boolean;
dominantTechnique: TechStepView | null;
ingredients: CookingTaskIngredientView[];
utensils: UtensilView[];
/** Set once merged-prep extraction absorbs this step wholesale (all its prep pooled elsewhere) — it then produces no standalone task. */
absorbed: boolean;
}
/** Sums two ingredient lines only when it's unambiguous — same unit id and both quantities known; otherwise the pooled line carries no number (see `ShoppingListItemView`'s "don't guess a conversion" rule). */
function poolIngredient(lines: CookingTaskIngredientView[]): {
quantity: number | null;
unit: CookingTaskIngredientView["unit"];
} {
const first = lines[0];
if (!first) return { quantity: null, unit: null };
const unitId = first.unit?.id ?? null;
let total = 0;
for (const line of lines) {
if (line.quantity === null || (line.unit?.id ?? null) !== unitId) {
return { quantity: null, unit: null };
}
total += line.quantity;
}
return { quantity: total, unit: first.unit };
}
/** Unions ingredient lines by `(ingredientId, unitId)`, summing quantities within a group the same careful way as {@link poolIngredient}. */
function unionIngredients(lines: CookingTaskIngredientView[]): CookingTaskIngredientView[] {
const groups = new Map<string, CookingTaskIngredientView[]>();
for (const line of lines) {
const key = `${line.ingredient.id}:${line.unit?.id ?? "x"}`;
const group = groups.get(key);
if (group) group.push(line);
else groups.set(key, [line]);
}
const out: CookingTaskIngredientView[] = [];
for (const group of groups.values()) {
const head = group[0];
if (!head) continue;
const pooled = poolIngredient(group);
out.push({ ingredient: head.ingredient, quantity: pooled.quantity, unit: pooled.unit });
}
return out.sort((a, b) => a.ingredient.key.localeCompare(b.ingredient.key));
}
/** Unions utensils by id, keeping a stable order by key. */
function unionUtensils(utensils: UtensilView[]): UtensilView[] {
const byId = new Map<number, UtensilView>();
for (const utensil of utensils) byId.set(utensil.id, utensil);
return [...byId.values()].sort((a, b) => a.key.localeCompare(b.key));
}
/** A step is pure prep only if it has techniques and every one of them is in {@link PREP_TECHNIQUES}. */
function isPurePrepStep(techSteps: OptimizerTechStepInput[]): boolean {
return techSteps.length > 0 && techSteps.every((ts) => PREP_TECHNIQUES.has(ts.techStep.key));
}
/** Resolves a step's {@link Attention} — SETUP wins, then a *trailing* passive technique, else ACTIVE (see {@link SETUP_TECHNIQUES}). */
function attentionOf(techSteps: OptimizerTechStepInput[]): Attention {
if (techSteps.some((ts) => SETUP_TECHNIQUES.has(ts.techStep.key))) return "SETUP";
const last = techSteps[techSteps.length - 1];
if (last && PASSIVE_TECHNIQUES.has(last.techStep.key)) return "PASSIVE";
return "ACTIVE";
}
/** Turns one recipe's raw steps into {@link NormalizedStep}s — scales quantities, resolves attention, unions per-clause ingredients/utensils up to the step. */
function normalizeRecipe(recipe: OptimizerRecipeInput, recipeIndex: number): NormalizedStep[] {
const scale = scaleOf(recipe);
const recipeRef: CookingSessionRecipeRef = {
recipeId: recipe.recipeId,
name: recipe.name,
portions: recipe.portions,
};
return [...recipe.steps]
.sort((a, b) => a.order - b.order)
.map((step) => {
const techSteps: OptimizerTechStepInput[] = [...step.techSteps]
.sort((a, b) => a.order - b.order)
.map((ts) => ({
techStep: ts.techStep,
order: ts.order,
ingredients: ts.ingredients.map((line) => ({
ingredient: line.ingredient,
quantity: line.quantity === null ? null : line.quantity * scale,
unit: line.unit,
})),
utensils: ts.utensils,
}));
const lastTech = techSteps[techSteps.length - 1];
return {
taskId: `step:${recipeIndex}:${step.stepId}`,
recipeIndex,
recipe: recipeRef,
stepId: step.stepId,
order: step.order,
description: step.description,
techSteps,
attention: attentionOf(techSteps),
isPurePrep: isPurePrepStep(techSteps),
dominantTechnique: lastTech ? lastTech.techStep : null,
ingredients: unionIngredients(techSteps.flatMap((ts) => ts.ingredients)),
utensils: unionUtensils(techSteps.flatMap((ts) => ts.utensils)),
absorbed: false,
} satisfies NormalizedStep;
});
}
/** The prep signature of a pure-prep step — sorted `<techniqueKey>:<ingredientId>` pairs; two steps with the same signature do identical knife work and can be pooled. */
function prepSignature(step: NormalizedStep): string {
const pairs: string[] = [];
for (const ts of step.techSteps) {
for (const line of ts.ingredients) {
pairs.push(`${ts.techStep.key}:${line.ingredient.id}`);
}
}
return [...new Set(pairs)].sort().join("+");
}
/** Builds one {@link CookingTaskView} from a normalized step run as written. */
function stepToTask(step: NormalizedStep): CookingTaskView {
return {
id: step.taskId,
kind: "step",
technique: step.dominantTechnique,
description: step.description,
ingredients: step.ingredients,
utensils: step.utensils,
sourceRecipes: [step.recipe],
originalSteps: [
{
recipeId: step.recipe.recipeId,
recipeName: step.recipe.name,
description: step.description,
},
],
};
}
/** Builds the running-in-the-background status line for a passive step already scheduled in an earlier phase. */
function stepToBackground(step: NormalizedStep): CookingBackgroundTaskView {
return {
id: `bg:${step.taskId}`,
technique: step.dominantTechnique,
description: step.description,
recipeId: step.recipe.recipeId,
recipeName: step.recipe.name,
};
}
/**
* Pools pure-prep steps that do the *exact same* knife work (same
* {@link prepSignature}) in two or more distinct recipes into one
* `merged-prep` {@link CookingTaskView}, and marks every contributing step
* `absorbed` so it produces no standalone task. A pure-prep step whose
* signature is unique (only one recipe needs it) is left untouched it
* still lands in the mise-en-place phase, just as its own step task.
*
* Returns the merged tasks in a stable order (by id).
*/
function extractMergedPrep(steps: NormalizedStep[]): CookingTaskView[] {
const bySignature = new Map<string, NormalizedStep[]>();
for (const step of steps) {
if (!step.isPurePrep) continue;
const signature = prepSignature(step);
if (signature === "") continue;
const group = bySignature.get(signature);
if (group) group.push(step);
else bySignature.set(signature, [step]);
}
const merged: CookingTaskView[] = [];
for (const [signature, group] of bySignature) {
const recipeIndexes = new Set(group.map((s) => s.recipeIndex));
if (recipeIndexes.size < 2) continue;
for (const step of group) step.absorbed = true;
// Every contributing clause's ingredient lines, pooled per ingredient.
const allLines = group.flatMap((s) => s.techSteps.flatMap((ts) => ts.ingredients));
const ingredients = unionIngredients(allLines);
const utensils = unionUtensils(group.flatMap((s) => s.utensils));
// Dominant technique of the pool = the first pair's technique (v1
// signatures are almost always a single `<technique>:<ingredient>`
// pair; a multi-pair signature just takes the earliest).
const firstTech = group[0]?.techSteps[0]?.techStep ?? null;
const firstIngredientKey = ingredients[0]?.ingredient.key ?? signature;
// Distinct source recipes / original step texts, in input order.
const sourceRecipes: CookingSessionRecipeRef[] = [];
const seenRecipe = new Set<number>();
const originalSteps: CookingTaskView["originalSteps"] = [];
for (const step of [...group].sort((a, b) => a.recipeIndex - b.recipeIndex)) {
if (!seenRecipe.has(step.recipeIndex)) {
seenRecipe.add(step.recipeIndex);
sourceRecipes.push(step.recipe);
}
originalSteps.push({
recipeId: step.recipe.recipeId,
recipeName: step.recipe.name,
description: step.description,
});
}
merged.push({
id: `prep:${firstTech ? firstTech.key : "prep"}:${firstIngredientKey}`,
kind: "merged-prep",
technique: firstTech,
description: null,
ingredients,
utensils,
sourceRecipes,
originalSteps,
});
}
return merged.sort((a, b) => a.id.localeCompare(b.id));
}
/** Maps the input recipe list to its display legend, de-duplicating an exact `(recipeId, portions)` repeat. */
function toRecipeLegend(recipes: OptimizerRecipeInput[]): CookingSessionRecipeRef[] {
const seen = new Set<string>();
const out: CookingSessionRecipeRef[] = [];
for (const recipe of recipes) {
const key = `${recipe.recipeId}:${recipe.portions}`;
if (seen.has(key)) continue;
seen.add(key);
out.push({ recipeId: recipe.recipeId, name: recipe.name, portions: recipe.portions });
}
return out;
}
/** A phase is `"finishing"` when everything left in it is plating; otherwise it's a normal `"cooking"` phase. */
function cookingPhaseKind(tasks: CookingTaskView[]): CookingPhaseKind {
return tasks.every((task) => task.technique?.key === "plate") ? "finishing" : "cooking";
}
/**
* See the file header. Given the week's planned recipes (already resolved
* to reference views), returns the display legend plus the ordered phases:
*
* 1. **Mise en place** (`"mise-en-place"`) every `merged-prep` task, then
* every leftover pure-prep step, then every `SETUP` step. Omitted
* entirely if it would be empty.
* 2. **Cooking** (`"cooking"` / `"finishing"`) the recipes interleaved:
* each phase pops the next remaining step of every recipe that still has
* one (passive-cook steps first, so long cooks start early). A passive
* step scheduled in one phase is echoed in every later phase's
* `background` until that recipe's next step is popped.
*/
export function optimizeCookingPlan(recipes: OptimizerRecipeInput[]): OptimizeCookingPlanResult {
const legend = toRecipeLegend(recipes);
const normalized = recipes.map((recipe, index) => normalizeRecipe(recipe, index));
const allSteps = normalized.flat();
const mergedPrep = extractMergedPrep(allSteps);
const phases: CookingPhaseView[] = [];
// Phase 0 — mise en place.
const miseTasks: CookingTaskView[] = [...mergedPrep];
for (const step of allSteps) {
if (step.absorbed) continue;
if (step.isPurePrep || step.attention === "SETUP") {
miseTasks.push(stepToTask(step));
step.absorbed = true; // consumed here, not again in the cooking loop
}
}
if (miseTasks.length > 0) {
phases.push({ index: 0, kind: "mise-en-place", tasks: miseTasks, background: [] });
}
// Cooking phases — one "next step of each recipe" per phase. `hold` keeps
// a recipe out of the *next* phase right after it starts a passive cook,
// so another recipe's active work fills that phase and the passive cook
// shows up as `background` there instead of being immediately followed by
// its own next step.
const queues = normalized.map((steps) => ({
remaining: steps.filter((s) => !s.absorbed),
cursor: 0,
hold: 0,
}));
/** Passive steps started in an earlier phase, keyed by recipe index, still "cooking". */
const runningPassive = new Map<number, NormalizedStep>();
while (queues.some((queue) => queue.cursor < queue.remaining.length)) {
const phaseSteps: NormalizedStep[] = [];
queues.forEach((queue, recipeIndex) => {
const next = queue.remaining[queue.cursor];
if (!next) return;
if (queue.hold > 0) {
// Still tending its passive cook this phase — leave it in
// `runningPassive` so it renders as background, don't advance.
queue.hold--;
return;
}
// This recipe is advancing — whatever passive cook it had going is
// now being tended to, so it stops showing as background.
runningPassive.delete(recipeIndex);
phaseSteps.push(next);
queue.cursor++;
});
// Every recipe with steps left is holding on a passive cook — break the
// stall by releasing all holds and letting the next iteration advance.
if (phaseSteps.length === 0) {
for (const queue of queues) queue.hold = 0;
continue;
}
// Background = passive cooks from earlier phases not yet resolved above.
const background = [...runningPassive.values()].map(stepToBackground);
// Start the long cooks first within the phase.
phaseSteps.sort((a, b) => {
const rank = (s: NormalizedStep) => (s.attention === "PASSIVE" ? 0 : 1);
return rank(a) - rank(b) || a.recipeIndex - b.recipeIndex;
});
const tasks = phaseSteps.map(stepToTask);
phases.push({
index: phases.length,
kind: cookingPhaseKind(tasks),
tasks,
background,
});
for (const step of phaseSteps) {
if (step.attention === "PASSIVE") {
runningPassive.set(step.recipeIndex, step);
const queue = queues[step.recipeIndex];
if (queue) queue.hold = 1;
}
}
}
// `index` was set from `phases.length` as we went; re-stamp so it always
// matches the final array position even if phase 0 was skipped.
phases.forEach((phase, index) => {
phase.index = index;
});
return { recipes: legend, phases };
}

View file

@ -427,6 +427,11 @@ 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

@ -0,0 +1,20 @@
import type { AdminUserView } from "@batch-cooking/shared";
import type { AdminUser } from "@prisma/client";
/**
* Shapes a Prisma `AdminUser` into the {@link AdminUserView} sent to the
* admin client drops `passwordHash` **and** `tokenVersion` (an internal
* invalidation counter the client never needs, unlike `SafeUserProfile`
* which does expose it), and serializes the two dates to ISO strings. The
* one place this security-relevant stripping happens, same role as
* `toSafeProfile` (`lib/safe-profile.ts`).
*/
export function toSafeAdmin(admin: AdminUser): AdminUserView {
return {
id: admin.id,
email: admin.email,
name: admin.name,
createdAt: admin.createdAt.toISOString(),
lastLoginAt: admin.lastLoginAt === null ? null : admin.lastLoginAt.toISOString(),
};
}

View file

@ -0,0 +1,71 @@
import { HttpError } from "@batch-cooking/error-tools";
import { type AdminUserView, ErrorCode } from "@batch-cooking/shared";
import type { NextFunction, Request, Response } from "express";
import { env } from "../config/env.js";
import { prisma } from "../db/prisma.js";
import { verifyAdminToken } from "../lib/admin-jwt.js";
import { toSafeAdmin } from "../lib/safe-admin.js";
/**
* Shape of `res.locals` once {@link requireAdmin} has run successfully.
* Type a handler's response as `Response<unknown, AdminLocals>` to read
* `res.locals.adminUser` fully typed, no cast same `res.locals` (not
* global `Request` augmentation) approach as {@link AuthLocals}
* (`require-auth.ts`).
*/
export interface AdminLocals {
/** The authenticated admin operator, resolved from the admin session cookie's JWT. */
adminUser: AdminUserView;
}
/**
* Express middleware guarding every `/admin/*` route the operations app
* (`apps/admin-web`) authenticating as an `AdminUser`. Reads the admin
* session cookie (`ADMIN_COOKIE_NAME`, deliberately **not** the same cookie
* as end-user sessions), verifies the JWT against `ADMIN_JWT_SECRET`
* (a different secret than `JWT_SECRET`), and re-checks `tokenVersion`
* against the database so a stateless JWT can still be invalidated
* server-side.
*
* A completely separate mechanism from {@link requireAuth}, not layered on
* it: an end-user session token and an admin session token are never
* interchangeable in either direction.
*
* Fails closed: an unset `ADMIN_JWT_SECRET` (the default for any instance
* that doesn't run the admin app) makes {@link verifyAdminToken} throw, so
* every request is rejected rather than the surface left open same
* posture as `requireInternalWorker`.
*
* @throws {HttpError} `401 NOT_AUTHENTICATED` for any failure missing
* cookie, malformed/expired JWT, unknown admin, stale tokenVersion, or
* no secret configured. Never distinguishes the reason.
*/
export async function requireAdmin(
req: Request,
res: Response<unknown, AdminLocals>,
next: NextFunction,
) {
try {
const token = req.cookies?.[env.ADMIN_COOKIE_NAME];
if (typeof token !== "string") {
throw new HttpError(401, ErrorCode.NOT_AUTHENTICATED, "Not authenticated");
}
const payload = verifyAdminToken(token);
const admin = await prisma.adminUser.findUnique({ where: { id: payload.adminUserId } });
if (!admin || admin.tokenVersion !== payload.tokenVersion) {
throw new HttpError(401, ErrorCode.NOT_AUTHENTICATED, "Not authenticated");
}
res.locals.adminUser = toSafeAdmin(admin);
next();
} catch (err) {
if (err instanceof HttpError) {
next(err);
} else {
// Covers jwt.verify failures and the unset-secret throw from verifyAdminToken.
next(new HttpError(401, ErrorCode.NOT_AUTHENTICATED, "Not authenticated"));
}
}
}

View file

@ -0,0 +1,51 @@
import { wrapAsyncHandler } from "@batch-cooking/express-tools";
import { adminLoginSchema } from "@batch-cooking/shared";
import { type CookieOptions, type Response, Router } from "express";
import { env } from "../../config/env.js";
import { type AdminLocals, requireAdmin } from "../../middlewares/require-admin.js";
import { adminLogin } from "./admin-auth.service.js";
/** Router mounted at `/admin/auth` (via `admin.routes.ts`) — admin login, logout, current-admin. No signup: admins are created out-of-band (`src/scripts/create-admin.ts`). */
export const adminAuthRouter = Router();
const SEVEN_DAYS_MS = 7 * 24 * 60 * 60 * 1000;
/**
* Cookie options for the admin session same shape as `auth.routes.ts`'s
* end-user cookie (httpOnly, `Secure` in production unless `COOKIE_SECURE`
* overrides, `SameSite=Lax`), just written under {@link env.ADMIN_COOKIE_NAME}
* so the two sessions never collide in one browser.
*/
const adminCookieOptions: CookieOptions = {
httpOnly: true,
secure: env.COOKIE_SECURE ?? env.NODE_ENV === "production",
sameSite: "lax",
maxAge: SEVEN_DAYS_MS,
};
// `res.clearCookie` sets its own expiry — passing `maxAge` alongside is
// deprecated as of Express 4.20, so the logout route reuses the options
// minus that one field (same trick as `auth.routes.ts`).
const { maxAge: _maxAge, ...clearAdminCookieOptions } = adminCookieOptions;
/** Verifies admin credentials and starts an admin session. */
adminAuthRouter.post(
"/login",
wrapAsyncHandler(async (req, res) => {
const input = adminLoginSchema.parse(req.body);
const { admin, token } = await adminLogin(input);
res.cookie(env.ADMIN_COOKIE_NAME, token, adminCookieOptions);
res.status(200).json(admin);
}),
);
/** Ends the admin session by clearing the cookie. Stateless JWT — nothing to revoke server-side beyond bumping `tokenVersion` (no UI for that yet). */
adminAuthRouter.post("/logout", (_req, res) => {
res.clearCookie(env.ADMIN_COOKIE_NAME, clearAdminCookieOptions);
res.status(204).end();
});
/** Returns the currently authenticated admin. Behind `requireAdmin` — 401s if there's no valid admin session. */
adminAuthRouter.get("/me", requireAdmin, (_req, res: Response<unknown, AdminLocals>) => {
res.status(200).json(res.locals.adminUser);
});

View file

@ -0,0 +1,59 @@
import { HttpError } from "@batch-cooking/error-tools";
import { type AdminLoginInput, type AdminUserView, ErrorCode } from "@batch-cooking/shared";
import argon2 from "argon2";
import { env } from "../../config/env.js";
import { prisma } from "../../db/prisma.js";
import { signAdminToken } from "../../lib/admin-jwt.js";
import { toSafeAdmin } from "../../lib/safe-admin.js";
/** Result of a successful admin login: the safe admin view plus the signed admin session JWT to set as a cookie. */
interface AdminAuthResult {
admin: AdminUserView;
token: string;
}
// Same reasoning as `auth.service.ts`'s `hashOptions`: argon2's real
// defaults are deliberately expensive; the test suite hashes/verifies
// against throwaway data many times per run, so a cheaper cost keeps it
// fast without weakening anything real. Never applies outside NODE_ENV=test.
const testHashOptions = { memoryCost: 8192, timeCost: 2, parallelism: 1 };
const hashOptions = env.NODE_ENV === "test" ? testHashOptions : undefined;
/** Exposed so `src/scripts/create-admin.ts` hashes exactly the same way `login` verifies. */
export function hashAdminPassword(password: string): Promise<string> {
return argon2.hash(password, hashOptions);
}
/**
* Verifies an admin operator's credentials, stamps `lastLoginAt`, and
* issues a fresh admin session token.
*
* @throws {HttpError} `401 INVALID_CREDENTIALS` for either an unknown email
* or a wrong password deliberately indistinguishable, same reasoning as
* `auth.service.ts`'s `login`.
*/
export async function adminLogin(input: AdminLoginInput): Promise<AdminAuthResult> {
try {
const admin = await prisma.adminUser.findUnique({ where: { email: input.email } });
if (!admin || !(await argon2.verify(admin.passwordHash, input.password))) {
throw new HttpError(401, ErrorCode.INVALID_CREDENTIALS, "Invalid email or password");
}
const updated = await prisma.adminUser.update({
where: { id: admin.id },
data: { lastLoginAt: new Date() },
});
const token = signAdminToken({
adminUserId: updated.id,
tokenVersion: updated.tokenVersion,
});
return { admin: toSafeAdmin(updated), token };
} catch (err) {
// Rethrown as-is — `wrapAsyncHandler`/the error middleware handles it,
// this service layer just isn't allowed a bare `await` per the repo's
// async/try-catch convention.
throw err;
}
}

View file

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

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

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

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

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

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

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

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

@ -0,0 +1,22 @@
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 is for `apps/admin-web` 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`).
*/
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,6 +8,7 @@ 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";
@ -58,6 +59,8 @@ 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

@ -0,0 +1,37 @@
import { parseDateOnly } from "@batch-cooking/date-tools";
import { HttpError } from "@batch-cooking/error-tools";
import { wrapAsyncHandler } from "@batch-cooking/express-tools";
import { ErrorCode, getCookingSessionSchema } from "@batch-cooking/shared";
import { Router } from "express";
import { type AuthLocals, requireAuth } from "../../middlewares/require-auth.js";
import { getCookingPlanForDate } from "./cooking-session.service.js";
/** Router mounted at `/cooking-session` in app.ts. */
export const cookingSessionRouter = Router();
/**
* Returns the authenticated user's household's optimized cooking plan for
* the week covering `?date=` (`YYYY-MM-DD`) every recipe planned that
* week reorganized into ordered phases (see {@link getCookingPlanForDate}).
* Always `200`, never `null` no household or nothing planned that week
* both come back as a normal `OptimizedCookingPlanView` with empty
* `recipes`/`phases`. Same request contract as `GET /shopping-list`.
*/
cookingSessionRouter.get(
"/",
requireAuth,
wrapAsyncHandler<unknown, AuthLocals>(async (req, res) => {
const input = getCookingSessionSchema.parse(req.query);
const date = parseDateOnly(input.date);
if (date === null) {
throw new HttpError(
400,
ErrorCode.VALIDATION_ERROR,
`Not a real calendar date: ${input.date}`,
);
}
const plan = await getCookingPlanForDate(res.locals.userProfile.houseId, date);
res.status(200).json(plan);
}),
);

View file

@ -0,0 +1,168 @@
import { type DateTime, getWeekStart, toDateOnly } from "@batch-cooking/date-tools";
import type { CookingTaskIngredientView, OptimizedCookingPlanView } from "@batch-cooking/shared";
import type { Prisma } from "@prisma/client";
import { prisma } from "../../db/prisma.js";
import {
type OptimizerRecipeInput,
type OptimizerStepInput,
optimizeCookingPlan,
} from "../../lib/recipe-matching/cooking-optimizer.js";
import { toIngredientView, toUnitView } from "../recipe/recipe.service.js";
/**
* Prisma `include` for a `Planning` query that needs, for every item, its
* recipe's ordered steps with the full detected-technique tree the raw
* material the optimizer works on (see `cooking-optimizer.ts`). It's the
* `steps` sub-tree of `recipe.service.ts`'s own `recipeInclude`, resolved
* the same way so {@link toIngredientView}/{@link toUnitView} can be reused
* as-is; deliberately narrower than a full `RecipeView` fetch (no
* diets/favorites/recipe-level ingredient list the optimizer reads
* quantities off the technique clauses, not the recipe header).
*/
function cookingSessionPlanningInclude() {
return {
items: {
include: {
recipe: {
select: {
id: true,
name: true,
portions: true,
steps: {
orderBy: { order: "asc" },
include: {
techSteps: {
orderBy: { order: "asc" },
include: {
techStep: true,
ingredients: {
include: {
ingredient: {
include: {
allergies: { include: { allergy: { include: { category: true } } } },
diets: { include: { diet: true } },
},
},
unit: true,
},
},
utensils: { include: { utensil: true } },
},
},
},
},
},
},
},
},
} satisfies Prisma.PlanningInclude;
}
type PlanningWithSteps = Prisma.PlanningGetPayload<{
include: ReturnType<typeof cookingSessionPlanningInclude>;
}>;
type PlanningItemWithSteps = PlanningWithSteps["items"][number];
/**
* Maps one planning item's recipe (with {@link cookingSessionPlanningInclude})
* to the optimizer's pure input shape ingredient/unit/technique/utensil
* rows resolved to their reference views here so the optimizer itself never
* touches Prisma. `Decimal` quantities become plain numbers (same
* `Number(...)` conversion as `recipe.service.ts`'s own view mappers); an
* unresolved-unit line keeps `unit: null`.
*/
function toOptimizerRecipe(item: PlanningItemWithSteps): OptimizerRecipeInput {
const steps: OptimizerStepInput[] = item.recipe.steps.map((step) => ({
stepId: step.id,
order: step.order,
description: step.description,
techSteps: step.techSteps.map((techStep) => {
const ingredients: CookingTaskIngredientView[] = techStep.ingredients.map((line) => ({
ingredient: toIngredientView(line.ingredient),
quantity: line.quantity === null ? null : Number(line.quantity),
unit: line.unit === null ? null : toUnitView(line.unit),
}));
return {
techStep: { id: techStep.techStep.id, key: techStep.techStep.key },
order: techStep.order,
ingredients,
utensils: techStep.utensils.map(({ utensil }) => ({ id: utensil.id, key: utensil.key })),
};
}),
}));
return {
recipeId: item.recipe.id,
name: item.recipe.name,
// The slot's own portion count vs. the recipe's as-written yield — the
// optimizer scales technique-clause quantities by the ratio, same
// reasoning as `shopping-list.service.ts`'s `aggregateShoppingList`.
portions: item.portions,
recipePortions: item.recipe.portions,
steps,
};
}
/**
* Builds the household's optimized cooking plan for the week covering
* `date` every recipe planned that week, reorganized into ordered phases
* that pool shared prep and float passive cooks into the background (see
* `cooking-optimizer.ts`). `date` follows the same convention as
* `planning.service.ts`'s `getPlanningForDate` (a caller-parsed `?date=`,
* not necessarily a Monday).
*
* Like `getShoppingListForDate` and unlike `getPlanningForDate`, this
* **never** returns `null` no household and "no planning covers this week
* yet" both degrade to an empty `phases`/`recipes` on an otherwise normal
* {@link OptimizedCookingPlanView} (the week's date range is always
* computable from `date` alone).
*/
export async function getCookingPlanForDate(
houseId: number | null,
date: DateTime,
): Promise<OptimizedCookingPlanView> {
try {
const weekStart = getWeekStart(toDateOnly(date));
const weekFinish = weekStart.plus({ days: 6 });
const emptyPlan: OptimizedCookingPlanView = {
startDate: weekStart.toJSDate().toISOString(),
finishDate: weekFinish.toJSDate().toISOString(),
recipes: [],
phases: [],
};
if (houseId === null) {
return emptyPlan;
}
// Same "covering range" lookup as getShoppingListForDate — see
// getPlanningForDate's doc comment for the UTC-midnight `Date` rationale.
const dateOnly = toDateOnly(date).toJSDate();
const planning = await prisma.planning.findFirst({
where: {
houseId,
startDate: { lte: dateOnly },
finishDate: { gte: dateOnly },
},
orderBy: { startDate: "desc" },
include: cookingSessionPlanningInclude(),
});
if (!planning) {
return emptyPlan;
}
const { recipes, phases } = optimizeCookingPlan(planning.items.map(toOptimizerRecipe));
return {
startDate: planning.startDate.toISOString(),
finishDate: planning.finishDate.toISOString(),
recipes,
phases,
};
} catch (err) {
// Rethrown as-is — `wrapAsyncHandler`/the error middleware handles it,
// this service layer just isn't allowed a bare `await` per the repo's
// async/try-catch convention.
throw err;
}
}

View file

@ -3,12 +3,14 @@ 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";
@ -47,3 +49,19 @@ 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,7 +4,9 @@ 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,
@ -199,3 +201,44 @@ 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,6 +7,7 @@ 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";
/**
@ -157,6 +158,11 @@ 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,6 +7,7 @@ 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";
/**
@ -472,6 +473,16 @@ 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,3 +1,4 @@
import { randomUUID } from "node:crypto";
import { HttpError } from "@batch-cooking/error-tools";
import {
type AllergyView,
@ -14,6 +15,7 @@ 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,
@ -108,6 +110,12 @@ 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,
};
}
@ -541,6 +549,69 @@ 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,
@ -548,7 +619,9 @@ async function createRecipeInternal(
source: { sourceId: number; externalId: string; locale: string } | null,
): Promise<RecipeView> {
try {
await assertIngredientsExist(input.ingredients.map((i) => i.ingredientId));
await assertIngredientsExist(
input.ingredients.map((i) => i.ingredientId).filter((id): id is number => id !== undefined),
);
await assertUnitsExist(input.ingredients.map((i) => i.unitId));
await assertDietsExist(input.dietIds);
// Matched up front (one call per step, in parallel) rather than inline
@ -561,61 +634,84 @@ async function createRecipeInternal(
source?.locale ?? DEFAULT_TECH_STEP_LOCALE,
);
const created = await prisma.recipe.create({
data: {
name: input.name,
description: input.description ?? null,
picture: input.picture ?? null,
portions: input.portions,
// 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,
authorId,
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,
})),
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 })) },
},
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),
include: recipeInclude(authorId),
});
return { created, createdPlaceholders };
});
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
@ -643,16 +739,30 @@ export async function updateRecipe(
): Promise<RecipeView> {
try {
await assertIsAuthor(id, viewerId, viewerHouseId);
await assertIngredientsExist(input.ingredients.map((i) => i.ingredientId));
await assertIngredientsExist(
input.ingredients
.map((i) => i.ingredientId)
.filter((ingredientId): ingredientId is number => ingredientId !== undefined),
);
await assertUnitsExist(input.ingredients.map((i) => i.unitId));
await assertDietsExist(input.dietIds);
const stepsWithTechSteps = await matchStepsTechSteps(input.steps, DEFAULT_TECH_STEP_LOCALE);
await prisma.$transaction([
prisma.recipeIngredient.deleteMany({ where: { recipeId: id } }),
prisma.step.deleteMany({ where: { recipeId: id } }),
prisma.recipeDiet.deleteMany({ where: { recipeId: id } }),
prisma.recipe.update({
// 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({
where: { id },
data: {
name: input.name,
@ -661,10 +771,10 @@ export async function updateRecipe(
portions: input.portions,
visibility: input.visibility,
ingredients: {
create: input.ingredients.map((ingredient) => ({
ingredientId: ingredient.ingredientId,
quantity: ingredient.quantity,
unitId: ingredient.unitId,
create: resolved.map((line) => ({
ingredientId: line.ingredientId,
quantity: line.quantity,
unitId: line.unitId,
})),
},
steps: {
@ -686,8 +796,16 @@ 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,10 +133,16 @@ 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 } },
@ -159,6 +165,9 @@ 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,6 +3,7 @@ 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";
@ -31,6 +32,7 @@ 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

@ -0,0 +1,56 @@
import { env } from "../config/env.js";
import { prisma } from "../db/prisma.js";
import { hashAdminPassword } from "../modules/admin/admin-auth.service.js";
/**
* Creates the first (or an additional) `AdminUser` for the admin
* application there is no self-service admin signup, on purpose (see the
* `AdminUser` model doc comment in schema.prisma).
*
* pnpm --filter api exec tsx src/scripts/create-admin.ts \
* --email=ops@example.com --password='...' --name='Ops'
*
* Each flag falls back to the matching `ADMIN_INITIAL_*` env var when
* omitted, so a deployment can bake the first admin's credentials into its
* environment and run this once from the container without passing args.
* Refuses (exit 1) if an `AdminUser` with that email already exists
* changing an existing admin's password is a manual DB operation for now,
* not something this script does.
*/
function flag(name: string): string | undefined {
const prefix = `--${name}=`;
const arg = process.argv.find((value) => value.startsWith(prefix));
return arg === undefined ? undefined : arg.slice(prefix.length);
}
async function createAdmin(): Promise<void> {
const email = (flag("email") ?? env.ADMIN_INITIAL_EMAIL)?.trim().toLowerCase();
const password = flag("password") ?? env.ADMIN_INITIAL_PASSWORD;
const name = (flag("name") ?? env.ADMIN_INITIAL_NAME)?.trim();
if (!email || !password || !name) {
throw new Error(
"Missing required input. Provide --email, --password and --name (or set ADMIN_INITIAL_EMAIL / ADMIN_INITIAL_PASSWORD / ADMIN_INITIAL_NAME).",
);
}
if (password.length < 8) {
throw new Error("Password must be at least 8 characters.");
}
const existing = await prisma.adminUser.findUnique({ where: { email } });
if (existing) {
throw new Error(`An admin with email "${email}" already exists (id ${existing.id}).`);
}
const passwordHash = await hashAdminPassword(password);
const admin = await prisma.adminUser.create({ data: { email, name, passwordHash } });
console.info(`Created admin #${admin.id} <${admin.email}> ("${admin.name}").`);
}
createAdmin()
.then(() => prisma.$disconnect())
.catch(async (err) => {
console.error(err instanceof Error ? err.message : err);
await prisma.$disconnect();
process.exit(1);
});

View file

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

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

View file

@ -0,0 +1,149 @@
import { ErrorCode, type SignupInput } from "@batch-cooking/shared";
import { faker } from "@faker-js/faker";
import { expect } from "chai";
import request from "supertest";
import { createApp } from "../src/app.js";
import { env } from "../src/config/env.js";
import { prisma } from "../src/db/prisma.js";
import { hashAdminPassword } from "../src/modules/admin/admin-auth.service.js";
import { resetDatabase } from "../test-support/reset-db.js";
/** See `auth.test.ts` — same rationale for generating rather than hardcoding. */
function buildSignupPayload(): SignupInput {
const firstName = faker.person.firstName();
const lastName = faker.person.lastName();
return {
firstName,
lastName,
email: faker.internet.email({ firstName, lastName }).toLowerCase(),
password: faker.internet.password({ length: 16 }),
};
}
/** Inserts an `AdminUser` straight into the DB (no signup route exists) and returns its plaintext password. */
async function seedAdmin(): Promise<{ email: string; password: string }> {
const email = faker.internet.email().toLowerCase();
const password = faker.internet.password({ length: 16 });
await prisma.adminUser.create({
data: { email, name: faker.person.fullName(), passwordHash: await hashAdminPassword(password) },
});
return { email, password };
}
/** The admin login/verify path signs a JWT — self-skip those cases when no `ADMIN_JWT_SECRET` is configured (same posture as `tech-step-worker.routes.test.ts` with `INTERNAL_WORKER_SECRET`). */
const adminSecretConfigured = env.ADMIN_JWT_SECRET !== undefined;
describe("Admin auth", () => {
const app = createApp();
beforeEach(async () => {
await resetDatabase();
});
after(async () => {
await prisma.$disconnect();
});
describe("POST /admin/auth/login", () => {
it("rejects a missing body with 400 VALIDATION_ERROR", async () => {
const res = await request(app).post("/admin/auth/login").send({});
expect(res.status).to.equal(400);
expect(res.body.code).to.equal(ErrorCode.VALIDATION_ERROR);
});
it("rejects an unknown email with 401 INVALID_CREDENTIALS", async () => {
const res = await request(app)
.post("/admin/auth/login")
.send({ email: "nobody@example.com", password: "whatever" });
expect(res.status).to.equal(401);
expect(res.body.code).to.equal(ErrorCode.INVALID_CREDENTIALS);
});
it("rejects a wrong password with 401 INVALID_CREDENTIALS", async () => {
const { email } = await seedAdmin();
const res = await request(app)
.post("/admin/auth/login")
.send({ email, password: "not-the-password" });
expect(res.status).to.equal(401);
expect(res.body.code).to.equal(ErrorCode.INVALID_CREDENTIALS);
});
it("logs in with correct credentials, sets the admin cookie, stamps lastLoginAt, never leaks the hash", async function () {
if (!adminSecretConfigured) {
// biome-ignore lint/suspicious/noExplicitAny: mocha's `this.skip()` isn't typed without @types/mocha (not a dependency here).
(this as any).skip();
return;
}
const { email, password } = await seedAdmin();
const res = await request(app).post("/admin/auth/login").send({ email, password });
expect(res.status).to.equal(200);
expect(res.body.email).to.equal(email);
expect(res.body).to.not.have.property("passwordHash");
expect(res.body).to.not.have.property("tokenVersion");
expect(res.body.lastLoginAt).to.be.a("string");
const setCookie = res.headers["set-cookie"];
expect(Array.isArray(setCookie) ? setCookie.join(";") : String(setCookie)).to.include(
env.ADMIN_COOKIE_NAME,
);
const stored = await prisma.adminUser.findUniqueOrThrow({ where: { email } });
expect(stored.lastLoginAt).to.not.equal(null);
});
});
describe("GET /admin/auth/me", () => {
it("rejects a request with no admin cookie with 401 NOT_AUTHENTICATED", async () => {
const res = await request(app).get("/admin/auth/me");
expect(res.status).to.equal(401);
expect(res.body.code).to.equal(ErrorCode.NOT_AUTHENTICATED);
});
it("returns the admin behind a valid admin session", async function () {
if (!adminSecretConfigured) {
// biome-ignore lint/suspicious/noExplicitAny: see above.
(this as any).skip();
return;
}
const { email, password } = await seedAdmin();
const agent = request.agent(app);
await agent.post("/admin/auth/login").send({ email, password });
const res = await agent.get("/admin/auth/me");
expect(res.status).to.equal(200);
expect(res.body.email).to.equal(email);
});
it("stops returning the admin after logout", async function () {
if (!adminSecretConfigured) {
// biome-ignore lint/suspicious/noExplicitAny: see above.
(this as any).skip();
return;
}
const { email, password } = await seedAdmin();
const agent = request.agent(app);
await agent.post("/admin/auth/login").send({ email, password });
const logoutRes = await agent.post("/admin/auth/logout");
expect(logoutRes.status).to.equal(204);
const meRes = await agent.get("/admin/auth/me");
expect(meRes.status).to.equal(401);
});
it("does not accept an end-user session cookie as an admin session", async () => {
// An ordinary user logs in (sets the `session` cookie), then tries the
// admin surface with that same agent — `requireAdmin` reads a
// different cookie entirely, so this must 401 regardless of whether
// ADMIN_JWT_SECRET is configured.
const agent = request.agent(app);
await agent.post("/auth/signup").send(buildSignupPayload());
const res = await agent.get("/admin/auth/me");
expect(res.status).to.equal(401);
expect(res.body.code).to.equal(ErrorCode.NOT_AUTHENTICATED);
});
});
});

View file

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

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

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

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

@ -0,0 +1,298 @@
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();
const first = agent.post("/admin/tech-steps/retrain").send({});
// Let the first handler acquire the process-wide lock before the second starts.
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

@ -0,0 +1,204 @@
import type { DateTime } from "@batch-cooking/date-tools";
import { ErrorCode, type SignupInput } from "@batch-cooking/shared";
import { faker } from "@faker-js/faker";
import { expect } from "chai";
import request from "supertest";
import { createApp } from "../src/app.js";
import { prisma } from "../src/db/prisma.js";
import { TEST_REFERENCE_DATE } from "../test-support/reference-date.js";
import { resetDatabase } from "../test-support/reset-db.js";
/** See `auth.test.ts` — same rationale for generating rather than hardcoding. */
function buildSignupPayload(): SignupInput {
const firstName = faker.person.firstName();
const lastName = faker.person.lastName();
return {
firstName,
lastName,
email: faker.internet.email({ firstName, lastName }).toLowerCase(),
password: faker.internet.password({ length: 16 }),
};
}
/** `toISODate()` only returns `null` for an invalid `DateTime` — never the always-valid values here. */
function isoDate(date: DateTime): string {
const iso = date.toISODate();
if (iso === null) throw new Error("Unexpectedly invalid DateTime in a test helper");
return iso;
}
/** The fixed test "today", as the `YYYY-MM-DD` string the `?date=` query expects. */
function today(): string {
return isoDate(TEST_REFERENCE_DATE);
}
/** Resolves a reference row's id by its `reference-seed-data.ts` uid (also its DB `key`) — same helpers as `recipe.test.ts`. */
async function ingredientId(key: string): Promise<number> {
return (await prisma.ingredient.findFirstOrThrow({ where: { key } })).id;
}
async function unitId(key: string): Promise<number> {
return (await prisma.unit.findFirstOrThrow({ where: { key } })).id;
}
async function techStepId(key: string): Promise<number> {
return (await prisma.techStep.findFirstOrThrow({ where: { key } })).id;
}
describe("Cooking session", () => {
const app = createApp();
beforeEach(async () => {
await resetDatabase();
});
after(async () => {
await prisma.$disconnect();
});
describe("GET /cooking-session", () => {
it("rejects requests without a session cookie with 401 NOT_AUTHENTICATED", async () => {
const res = await request(app).get("/cooking-session").query({ date: today() });
expect(res.status).to.equal(401);
expect(res.body.code).to.equal(ErrorCode.NOT_AUTHENTICATED);
});
it("rejects a malformed date with 400 VALIDATION_ERROR", async () => {
const agent = request.agent(app);
await agent.post("/auth/signup").send(buildSignupPayload());
const res = await agent.get("/cooking-session").query({ date: "not-a-date" });
expect(res.status).to.equal(400);
expect(res.body.code).to.equal(ErrorCode.VALIDATION_ERROR);
});
it("returns an empty plan when the profile has no household", async () => {
const agent = request.agent(app);
await agent.post("/auth/signup").send(buildSignupPayload());
const res = await agent.get("/cooking-session").query({ date: today() });
expect(res.status).to.equal(200);
expect(res.body.recipes).to.deep.equal([]);
expect(res.body.phases).to.deep.equal([]);
});
it("returns an empty plan when no planning covers that week", async () => {
const agent = request.agent(app);
await agent.post("/auth/signup").send(buildSignupPayload());
await agent.post("/house").send({ name: "Chez moi" });
const res = await agent.get("/cooking-session").query({ date: today() });
expect(res.status).to.equal(200);
expect(res.body.phases).to.deep.equal([]);
});
it("pools an identical prep step from two planned recipes into one merged-prep task", async () => {
const agent = request.agent(app);
await agent.post("/auth/signup").send(buildSignupPayload());
const houseRes = await agent.post("/house").send({ name: "Chez moi" });
const houseId: number = houseRes.body.id;
const authorId: number = houseRes.body.adminId;
const onionId = await ingredientId("onion");
const pieceId = await unitId("piece");
const chopId = await techStepId("chop");
const simmerId = await techStepId("simmer");
/** A recipe: one pure-prep "chop onion" step, then one simmer step. */
async function makeRecipe(name: string, onionQty: number) {
return prisma.recipe.create({
data: {
name,
authorId,
portions: 4,
steps: {
create: [
{
order: 0,
description: "Émincer les oignons",
techSteps: {
create: [
{
techStepId: chopId,
order: 0,
ingredients: {
create: [
{
ingredientId: onionId,
quantity: onionQty,
unitId: pieceId,
start: 0,
end: 1,
},
],
},
},
],
},
},
{
order: 1,
description: "Faire mijoter",
techSteps: { create: [{ techStepId: simmerId, order: 0 }] },
},
],
},
},
});
}
const soupe = await makeRecipe("Soupe", 2);
const tarte = await makeRecipe("Tarte", 3);
const planning = await prisma.planning.create({
data: {
houseId,
startDate: TEST_REFERENCE_DATE.minus({ days: 2 }).toJSDate(),
finishDate: TEST_REFERENCE_DATE.plus({ days: 2 }).toJSDate(),
},
});
await prisma.planningItem.createMany({
data: [
{
planningId: planning.id,
weekDay: "lundi",
meal: "dejeuner",
recipeId: soupe.id,
portions: 4,
},
{
planningId: planning.id,
weekDay: "mardi",
meal: "diner",
recipeId: tarte.id,
portions: 4,
},
],
});
const res = await agent.get("/cooking-session").query({ date: today() });
expect(res.status).to.equal(200);
expect(res.body.recipes.map((r: { name: string }) => r.name)).to.have.members([
"Soupe",
"Tarte",
]);
const mise = res.body.phases[0];
expect(mise.kind).to.equal("mise-en-place");
const merged = mise.tasks.filter((t: { kind: string }) => t.kind === "merged-prep");
expect(merged).to.have.length(1);
expect(merged[0].technique.key).to.equal("chop");
expect(merged[0].ingredients[0].ingredient.key).to.equal("onion");
expect(merged[0].ingredients[0].quantity).to.equal(5);
expect(merged[0].sourceRecipes).to.have.length(2);
// The simmer steps land in a later phase, and one shows as background.
const later = res.body.phases.slice(1);
const backgrounds = later.flatMap((p: { background: unknown[] }) => p.background);
expect(backgrounds.length).to.be.greaterThan(0);
});
});
});

File diff suppressed because it is too large Load diff

View file

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

@ -0,0 +1,174 @@
// Mocks the API via cy.intercept — this job doesn't run a live backend (see
// .github/workflows/ci.yml); apps/api's own Mocha suite covers real
// `GET /cooking-session` behavior (including the optimizer) against a real
// database.
const authenticatedProfile = {
id: 1,
firstName: "Alice",
lastName: "Martin",
email: "alice@example.com",
tokenVersion: 0,
houseId: 1,
dietId: null,
};
// 2026-08-17 is a Monday — frozen so "this week" is deterministic.
const TODAY = new Date("2026-08-17T09:00:00Z");
/** Bare `IngredientView` — only `key` drives the page's label lookup. */
function ingredient(key: string) {
return {
id: 1,
key,
icon: "VEGETABLE",
category: "freshProduce",
subcategory: "vegetables",
reproducible: false,
allergens: [],
diets: [],
};
}
/** A plan with a pooled prep task in mise-en-place and a passive cook floated into a later phase. */
function planFixture() {
return {
startDate: "2026-08-17T00:00:00.000Z",
finishDate: "2026-08-23T00:00:00.000Z",
recipes: [
{ recipeId: 1, name: "Soupe à l'oignon", portions: 4 },
{ recipeId: 2, name: "Tarte à l'oignon", portions: 4 },
],
phases: [
{
index: 0,
kind: "mise-en-place",
background: [],
tasks: [
{
id: "prep:chop:onion",
kind: "merged-prep",
technique: { id: 1, key: "chop" },
description: null,
ingredients: [
{
ingredient: ingredient("onion"),
quantity: 5,
unit: { id: 2, key: "piece", type: "COUNT", toBaseFactor: 1 },
},
],
utensils: [{ id: 1, key: "knife" }],
sourceRecipes: [
{ recipeId: 1, name: "Soupe à l'oignon", portions: 4 },
{ recipeId: 2, name: "Tarte à l'oignon", portions: 4 },
],
originalSteps: [],
},
],
},
{
index: 1,
kind: "cooking",
background: [],
tasks: [
{
id: "step:0:1",
kind: "step",
technique: { id: 5, key: "simmer" },
description: "Faire mijoter le bouillon",
ingredients: [],
utensils: [],
sourceRecipes: [{ recipeId: 1, name: "Soupe à l'oignon", portions: 4 }],
originalSteps: [],
},
],
},
{
index: 2,
kind: "cooking",
background: [
{
id: "bg:step:0:1",
technique: { id: 5, key: "simmer" },
description: "Faire mijoter le bouillon",
recipeId: 1,
recipeName: "Soupe à l'oignon",
},
],
tasks: [
{
id: "step:1:3",
kind: "step",
technique: { id: 4, key: "bake" },
description: "Enfourner la tarte",
ingredients: [],
utensils: [],
sourceRecipes: [{ recipeId: 2, name: "Tarte à l'oignon", portions: 4 }],
originalSteps: [],
},
],
},
],
};
}
describe("Cooking session page", () => {
beforeEach(() => {
cy.viewport(1400, 900);
cy.clock(TODAY, ["Date"]);
cy.intercept("GET", "**/auth/me", { statusCode: 200, body: authenticatedProfile });
});
it("shows the empty message when nothing is planned that week", () => {
cy.intercept("GET", /\/cooking-session\?/, {
statusCode: 200,
body: {
startDate: "2026-08-17T00:00:00.000Z",
finishDate: "2026-08-23T00:00:00.000Z",
recipes: [],
phases: [],
},
});
cy.visit("/cuisiner");
cy.contains("h1", "Cuisiner cette semaine").should("be.visible");
cy.contains("Rien de planifié cette semaine à cuisiner").should("be.visible");
});
it("renders each phase, the pooled prep task, and the 'meanwhile' band", () => {
cy.intercept("GET", /\/cooking-session\?/, { statusCode: 200, body: planFixture() }).as(
"getPlan",
);
cy.visit("/cuisiner?date=2026-08-17");
cy.wait("@getPlan").its("request.url").should("include", "date=2026-08-17");
// Mise en place: one pooled prep task, flagged shared, naming both recipes.
cy.contains(".cooking-phase", "Mise en place").should("be.visible");
cy.get(".cooking-task--merged-prep")
.should("contain.text", "Hacher")
.and("contain.text", "Oignon")
.and("contain.text", "Mutualisé");
cy.contains(".cooking-task--merged-prep", "Soupe à l'oignon").should("exist");
// A later phase shows the simmering soup as still running in the background.
cy.contains(".cooking-phase__background", "Pendant ce temps")
.should("contain.text", "Faire mijoter le bouillon")
.and("contain.text", "Soupe à l'oignon");
// Recipe legend is present.
cy.contains(".cooking-session__legend-item", "Tarte à l'oignon").should("be.visible");
});
it("shows an error state when the request fails", () => {
cy.intercept("GET", /\/cooking-session\?/, {
statusCode: 500,
body: { code: 5000, message: "boom" },
});
cy.visit("/cuisiner");
cy.contains("Impossible de charger").should("be.visible");
});
});

View file

@ -0,0 +1,24 @@
Feature: Start cooking an optimized plan
As a member of a household with a planned week
I want to open an optimized cooking plan from my planning
So that shared preparation is pooled and I cook the week efficiently
Background:
Given I am signed in as "Alice" "Martin"
And my household id is 1
And today is frozen at "2026-08-17T09:00:00.000Z"
Scenario: The "Commencer à cuisiner" button is disabled while the week is empty
Given the planning request returns nothing
When I visit "/"
Then the "Commencer à cuisiner" button should be disabled
Scenario: Opening the plan from the planning shows the pooled prep in mise en place
Given the planning for this week has recipes "Soupe à l'oignon" and "Tarte à l'oignon"
And the cooking plan for "2026-08-17" pools "Hacher" of "Oignon" across both recipes
When I visit "/"
And I click the button "Commencer à cuisiner"
Then the URL should include "/cuisiner"
And I should see "Mise en place"
And the pooled prep task should mention "Hacher" and "Oignon"
And the pooled prep task should be flagged as shared

View file

@ -0,0 +1,101 @@
import { Given, Then } from "@badeball/cypress-cucumber-preprocessor";
/** Bare `IngredientView` — only `key` drives the page's `catalog.ingredients.*` lookup. */
function ingredient(key: string) {
return {
id: 1,
key,
icon: "VEGETABLE",
category: "freshProduce",
subcategory: "vegetables",
reproducible: false,
allergens: [],
diets: [],
};
}
Given(
"the planning for this week has recipes {string} and {string}",
(first: string, second: string) => {
cy.intercept("GET", /\/planning\?/, {
statusCode: 200,
body: {
id: 1,
startDate: "2026-08-17T00:00:00.000Z",
finishDate: "2026-08-23T00:00:00.000Z",
items: [
{
id: 1,
weekDay: "lundi",
meal: "dejeuner",
portions: 4,
recipe: { id: 1, name: first },
},
{ id: 2, weekDay: "mardi", meal: "diner", portions: 4, recipe: { id: 2, name: second } },
],
},
});
},
);
Given(
"the cooking plan for {string} pools {string} of {string} across both recipes",
(date: string, _techniqueLabel: string, _ingredientLabel: string) => {
// The page composes the headline itself from the technique/ingredient
// *keys* via i18n — `chop`→"Hacher", `onion`→"Oignon" — so the fixture
// carries keys; the `Then` step checks the rendered French labels the
// feature line names.
cy.intercept("GET", `**/cooking-session?date=${date}`, {
statusCode: 200,
body: {
startDate: `${date}T00:00:00.000Z`,
finishDate: "2026-08-23T00:00:00.000Z",
recipes: [
{ recipeId: 1, name: "Soupe à l'oignon", portions: 4 },
{ recipeId: 2, name: "Tarte à l'oignon", portions: 4 },
],
phases: [
{
index: 0,
kind: "mise-en-place",
background: [],
tasks: [
{
id: "prep:chop:onion",
kind: "merged-prep",
technique: { id: 1, key: "chop" },
description: null,
ingredients: [
{
ingredient: ingredient("onion"),
quantity: 5,
unit: { id: 2, key: "piece", type: "COUNT", toBaseFactor: 1 },
},
],
utensils: [],
sourceRecipes: [
{ recipeId: 1, name: "Soupe à l'oignon", portions: 4 },
{ recipeId: 2, name: "Tarte à l'oignon", portions: 4 },
],
originalSteps: [],
},
],
},
],
},
});
},
);
Then(
"the pooled prep task should mention {string} and {string}",
(techniqueLabel: string, ingredientLabel: string) => {
cy.get(".cooking-task--merged-prep")
.should("contain.text", techniqueLabel)
.and("contain.text", ingredientLabel);
},
);
Then("the pooled prep task should be flagged as shared", () => {
cy.get(".cooking-task--merged-prep").contains("Mutualisé").should("be.visible");
});

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