Compare commits
1 commit
main
...
feat/admin
| Author | SHA1 | Date | |
|---|---|---|---|
| cf8ef26f63 |
115 changed files with 164 additions and 9788 deletions
11
.env.example
11
.env.example
|
|
@ -15,17 +15,20 @@ JWT_SECRET=changeme-generate-a-real-random-secret-at-least-32-chars
|
||||||
# (docker-compose.yml), serving both the API and the built frontend.
|
# (docker-compose.yml), serving both the API and the built frontend.
|
||||||
# APP_PORT=3000
|
# APP_PORT=3000
|
||||||
|
|
||||||
# --- Admin surface (apps/web's /admin/* routes + the /admin/* API) --------
|
# --- Admin application (apps/admin-web + the /admin/* API surface) ---------
|
||||||
# All optional: an instance that doesn't run the admin app needs none of
|
# 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
|
# these. `requireAdmin` fails closed when ADMIN_JWT_SECRET is unset, so
|
||||||
# leaving it out simply disables every /admin/* route. The admin UI is
|
# leaving it out simply disables every /admin/* route.
|
||||||
# served by the same app/container as the rest of the frontend — no
|
|
||||||
# separate origin, so no CORS entry of its own.
|
|
||||||
#
|
#
|
||||||
# Secret for the admin session JWT — MUST be different from JWT_SECRET so an
|
# 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
|
# end-user token can never be replayed against /admin/*. Generate your own
|
||||||
# the same way as JWT_SECRET above.
|
# the same way as JWT_SECRET above.
|
||||||
# ADMIN_JWT_SECRET=changeme-generate-a-real-random-secret-at-least-32-chars
|
# 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 /
|
# Optional — read only by `src/scripts/create-admin.ts` when its --email /
|
||||||
# --password / --name flags are omitted (e.g. to bootstrap the first admin
|
# --password / --name flags are omitted (e.g. to bootstrap the first admin
|
||||||
# from inside the container). Never read by the running server.
|
# from inside the container). Never read by the running server.
|
||||||
|
|
|
||||||
6
.github/workflows/ci.yml
vendored
6
.github/workflows/ci.yml
vendored
|
|
@ -58,16 +58,14 @@ jobs:
|
||||||
POSTGRES_USER: ci
|
POSTGRES_USER: ci
|
||||||
POSTGRES_PASSWORD: ci
|
POSTGRES_PASSWORD: ci
|
||||||
POSTGRES_DB: batchcooking_ci
|
POSTGRES_DB: batchcooking_ci
|
||||||
|
ports:
|
||||||
|
- 5433:5432
|
||||||
options: >-
|
options: >-
|
||||||
--health-cmd pg_isready
|
--health-cmd pg_isready
|
||||||
--health-interval 5s
|
--health-interval 5s
|
||||||
--health-timeout 5s
|
--health-timeout 5s
|
||||||
--health-retries 10
|
--health-retries 10
|
||||||
steps:
|
steps:
|
||||||
- name: Install curl and dependencies
|
|
||||||
run: |
|
|
||||||
apt-get update && apt-get install -y curl
|
|
||||||
|
|
||||||
- uses: https://github.com/actions/checkout@v4
|
- uses: https://github.com/actions/checkout@v4
|
||||||
|
|
||||||
- uses: pnpm/action-setup@v4
|
- uses: pnpm/action-setup@v4
|
||||||
|
|
|
||||||
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -163,6 +163,3 @@ tmp-mockups/
|
||||||
apps/web/cypress/screenshots/
|
apps/web/cypress/screenshots/
|
||||||
apps/web/cypress/videos/
|
apps/web/cypress/videos/
|
||||||
apps/web/cypress/downloads/
|
apps/web/cypress/downloads/
|
||||||
apps/admin-web/cypress/screenshots/
|
|
||||||
apps/admin-web/cypress/videos/
|
|
||||||
apps/admin-web/cypress/downloads/
|
|
||||||
|
|
|
||||||
|
|
@ -25,13 +25,3 @@ INTENT_SERVICE_SECRET=changeme-generate-a-real-random-secret-at-least-32-chars
|
||||||
# Generate your own the same way as JWT_SECRET above; must match the
|
# Generate your own the same way as JWT_SECRET above; must match the
|
||||||
# worker's own INTERNAL_WORKER_SECRET.
|
# worker's own INTERNAL_WORKER_SECRET.
|
||||||
# INTERNAL_WORKER_SECRET=changeme-generate-a-real-random-secret-at-least-32-chars
|
# INTERNAL_WORKER_SECRET=changeme-generate-a-real-random-secret-at-least-32-chars
|
||||||
|
|
||||||
# Only needed to use the admin surface (apps/web's /admin/* routes) — every
|
|
||||||
# /admin/* request 401s while unset (`requireAdmin` fails closed). MUST be a
|
|
||||||
# different value than JWT_SECRET. Generate your own the same way.
|
|
||||||
# ADMIN_JWT_SECRET=changeme-a-different-random-secret-at-least-32-chars
|
|
||||||
# Read only by `src/scripts/create-admin.ts` when its --email/--password/
|
|
||||||
# --name flags are omitted — never by the running server.
|
|
||||||
# ADMIN_INITIAL_EMAIL=ops@example.com
|
|
||||||
# ADMIN_INITIAL_PASSWORD=changeme-at-least-8-chars
|
|
||||||
# ADMIN_INITIAL_NAME=Ops
|
|
||||||
|
|
|
||||||
|
|
@ -1,32 +0,0 @@
|
||||||
-- AlterTable: usage-metrics timestamps. Existing rows adopt the migration's
|
|
||||||
-- own timestamp (acceptable one-off skew for trend charts — same posture as
|
|
||||||
-- the ingredient_unit_catalog migration).
|
|
||||||
ALTER TABLE "user_profiles" ADD COLUMN "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP;
|
|
||||||
ALTER TABLE "recipe" ADD COLUMN "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP;
|
|
||||||
ALTER TABLE "planning" ADD COLUMN "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP;
|
|
||||||
ALTER TABLE "planning_item" ADD COLUMN "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP;
|
|
||||||
|
|
||||||
-- CreateTable
|
|
||||||
CREATE TABLE "analytics_events" (
|
|
||||||
"id" SERIAL NOT NULL,
|
|
||||||
"type" TEXT NOT NULL,
|
|
||||||
"actor_type" TEXT NOT NULL,
|
|
||||||
"actor_id" INTEGER,
|
|
||||||
"context" JSONB,
|
|
||||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
|
|
||||||
CONSTRAINT "analytics_events_pkey" PRIMARY KEY ("id")
|
|
||||||
);
|
|
||||||
|
|
||||||
-- CreateIndex
|
|
||||||
CREATE INDEX "analytics_events_type_created_at_idx" ON "analytics_events"("type", "created_at");
|
|
||||||
|
|
||||||
-- CreateTable
|
|
||||||
CREATE TABLE "worker_heartbeats" (
|
|
||||||
"worker_key" TEXT NOT NULL,
|
|
||||||
"last_seen_at" TIMESTAMP(3) NOT NULL,
|
|
||||||
"last_run_at" TIMESTAMP(3),
|
|
||||||
"last_result" JSONB,
|
|
||||||
|
|
||||||
CONSTRAINT "worker_heartbeats_pkey" PRIMARY KEY ("worker_key")
|
|
||||||
);
|
|
||||||
|
|
@ -1,14 +0,0 @@
|
||||||
-- AlterTable: off-catalog ingredient "placeholder" rows. Every existing row
|
|
||||||
-- is a real seeded catalog entry, so the flag defaults to false and the four
|
|
||||||
-- new nullable columns stay NULL for them — no backfill needed.
|
|
||||||
ALTER TABLE "ingredients" ADD COLUMN "is_placeholder" BOOLEAN NOT NULL DEFAULT false;
|
|
||||||
ALTER TABLE "ingredients" ADD COLUMN "display_name" TEXT;
|
|
||||||
ALTER TABLE "ingredients" ADD COLUMN "created_by_id" INTEGER;
|
|
||||||
ALTER TABLE "ingredients" ADD COLUMN "created_at" TIMESTAMP(3);
|
|
||||||
ALTER TABLE "ingredients" ADD COLUMN "reviewed_at" TIMESTAMP(3);
|
|
||||||
|
|
||||||
-- CreateIndex
|
|
||||||
CREATE INDEX "ingredients_is_placeholder_idx" ON "ingredients"("is_placeholder");
|
|
||||||
|
|
||||||
-- AddForeignKey
|
|
||||||
ALTER TABLE "ingredients" ADD CONSTRAINT "ingredients_created_by_id_fkey" FOREIGN KEY ("created_by_id") REFERENCES "user_profiles"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
|
||||||
|
|
@ -103,10 +103,6 @@ model UserProfile {
|
||||||
tokenVersion Int @default(0) @map("token_version")
|
tokenVersion Int @default(0) @map("token_version")
|
||||||
houseId Int? @map("house_id")
|
houseId Int? @map("house_id")
|
||||||
dietId Int? @map("diet_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)
|
house House? @relation("HouseMember", fields: [houseId], references: [id], onDelete: SetNull)
|
||||||
diet Diet? @relation(fields: [dietId], references: [id], onDelete: SetNull)
|
diet Diet? @relation(fields: [dietId], references: [id], onDelete: SetNull)
|
||||||
|
|
@ -129,9 +125,6 @@ model UserProfile {
|
||||||
/// view a recipe may correct its tech-step matches, not just its author —
|
/// view a recipe may correct its tech-step matches, not just its author —
|
||||||
/// see `StepTechStepCorrection.correctorId`).
|
/// see `StepTechStepCorrection.correctorId`).
|
||||||
techStepCorrections StepTechStepCorrection[]
|
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")
|
@@map("user_profiles")
|
||||||
}
|
}
|
||||||
|
|
@ -199,12 +192,6 @@ model Planning {
|
||||||
startDate DateTime @map("start_date") @db.Date
|
startDate DateTime @map("start_date") @db.Date
|
||||||
finishDate DateTime @map("finish_date") @db.Date
|
finishDate DateTime @map("finish_date") @db.Date
|
||||||
houseId Int @map("house_id")
|
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)
|
house House @relation(fields: [houseId], references: [id], onDelete: Cascade)
|
||||||
items PlanningItem[]
|
items PlanningItem[]
|
||||||
|
|
@ -219,8 +206,6 @@ model PlanningItem {
|
||||||
meal String
|
meal String
|
||||||
recipeId Int @map("recipe_id")
|
recipeId Int @map("recipe_id")
|
||||||
portions Int
|
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)
|
planning Planning @relation(fields: [planningId], references: [id], onDelete: Cascade)
|
||||||
recipe Recipe @relation(fields: [recipeId], references: [id])
|
recipe Recipe @relation(fields: [recipeId], references: [id])
|
||||||
|
|
@ -334,10 +319,6 @@ model Recipe {
|
||||||
/// the author had no household yet.
|
/// the author had no household yet.
|
||||||
authorHouseId Int? @map("author_house_id")
|
authorHouseId Int? @map("author_house_id")
|
||||||
visibility RecipeVisibility @default(PERSONAL)
|
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])
|
author UserProfile @relation(fields: [authorId], references: [id])
|
||||||
authorHouse House? @relation(fields: [authorHouseId], references: [id], onDelete: SetNull)
|
authorHouse House? @relation(fields: [authorHouseId], references: [id], onDelete: SetNull)
|
||||||
|
|
@ -533,38 +514,6 @@ model Ingredient {
|
||||||
/// ingredient↔recipe linking in the database, the UI only pre-fills the
|
/// ingredient↔recipe linking in the database, the UI only pre-fills the
|
||||||
/// catalog's own search with this ingredient's name).
|
/// catalog's own search with this ingredient's name).
|
||||||
reproducible Boolean @default(false)
|
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[]
|
recipes RecipeIngredient[]
|
||||||
allergies IngredientAllergy[]
|
allergies IngredientAllergy[]
|
||||||
|
|
@ -575,10 +524,7 @@ model Ingredient {
|
||||||
/// Mentions of this ingredient detected in a step's free text alongside a
|
/// Mentions of this ingredient detected in a step's free text alongside a
|
||||||
/// technique — see `StepTechStepIngredient`.
|
/// technique — see `StepTechStepIngredient`.
|
||||||
stepTechSteps 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")
|
@@map("ingredients")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -984,48 +930,3 @@ model AdminUser {
|
||||||
|
|
||||||
@@map("admin_users")
|
@@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")
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,6 @@ import { errorLogger } from "./middlewares/error-logger.js";
|
||||||
import { requestLogger } from "./middlewares/request-logger.js";
|
import { requestLogger } from "./middlewares/request-logger.js";
|
||||||
import { adminRouter } from "./modules/admin/admin.routes.js";
|
import { adminRouter } from "./modules/admin/admin.routes.js";
|
||||||
import { authRouter } from "./modules/auth/auth.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 { houseRouter } from "./modules/house/house.routes.js";
|
||||||
import { techStepWorkerRouter } from "./modules/internal/tech-step-worker.routes.js";
|
import { techStepWorkerRouter } from "./modules/internal/tech-step-worker.routes.js";
|
||||||
import { planningRouter } from "./modules/planning/planning.routes.js";
|
import { planningRouter } from "./modules/planning/planning.routes.js";
|
||||||
|
|
@ -34,20 +33,18 @@ export function createServer(): ExpressServer {
|
||||||
// pipeline (its "finish" listener still fires for a request that never
|
// pipeline (its "finish" listener still fires for a request that never
|
||||||
// makes it past CORS/body-parsing, not just ones that reach a route).
|
// makes it past CORS/body-parsing, not just ones that reach a route).
|
||||||
server.addMiddleware(requestLogger);
|
server.addMiddleware(requestLogger);
|
||||||
// One allowed origin: the app (`CORS_ORIGIN`). The admin surface
|
// Two allowed origins: the main app (`CORS_ORIGIN`) and the separate
|
||||||
// (`/admin/*`) is served by this same API and consumed by `apps/web`'s
|
// admin app (`ADMIN_CORS_ORIGIN`). The `cors` package matches an incoming
|
||||||
// own `/admin/*` routes — same origin as the rest of the app, so no
|
// `Origin` against any entry of the list.
|
||||||
// extra CORS entry.
|
server.setupCore({ corsOrigin: [env.CORS_ORIGIN, env.ADMIN_CORS_ORIGIN] });
|
||||||
server.setupCore({ corsOrigin: env.CORS_ORIGIN });
|
|
||||||
|
|
||||||
server.addRoute("get", "/health", (_req: Request, res: Response) => {
|
server.addRoute("get", "/health", (_req: Request, res: Response) => {
|
||||||
res.status(200).json({ status: "ok" });
|
res.status(200).json({ status: "ok" });
|
||||||
});
|
});
|
||||||
|
|
||||||
server.mountRouter("/auth", authRouter);
|
server.mountRouter("/auth", authRouter);
|
||||||
// Admin application surface (`apps/web`'s `/admin/*` routes) — its own
|
// Admin application surface (`apps/admin-web`) — its own auth
|
||||||
// auth (`requireAdmin`, distinct cookie/secret), never the end-user
|
// (`requireAdmin`, distinct cookie/secret), never the end-user session.
|
||||||
// session.
|
|
||||||
server.mountRouter("/admin", adminRouter);
|
server.mountRouter("/admin", adminRouter);
|
||||||
server.mountRouter("/house", houseRouter);
|
server.mountRouter("/house", houseRouter);
|
||||||
// Not user-facing — `services/tech-step-llm-worker` only, guarded by
|
// Not user-facing — `services/tech-step-llm-worker` only, guarded by
|
||||||
|
|
@ -62,7 +59,6 @@ export function createServer(): ExpressServer {
|
||||||
server.mountRouter("/recipes", recipeRouter);
|
server.mountRouter("/recipes", recipeRouter);
|
||||||
server.mountRouter("/reference", referenceRouter);
|
server.mountRouter("/reference", referenceRouter);
|
||||||
server.mountRouter("/shopping-list", shoppingListRouter);
|
server.mountRouter("/shopping-list", shoppingListRouter);
|
||||||
server.mountRouter("/cooking-session", cookingSessionRouter);
|
|
||||||
server.mountRouter("/sources", sourcesRouter);
|
server.mountRouter("/sources", sourcesRouter);
|
||||||
|
|
||||||
// Serves the built frontend (production Docker image only — see
|
// Serves the built frontend (production Docker image only — see
|
||||||
|
|
|
||||||
|
|
@ -105,6 +105,8 @@ const envSchema = z.object({
|
||||||
.optional(),
|
.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. */
|
/** 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"),
|
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. */
|
/** 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_EMAIL: z.string().optional(),
|
||||||
ADMIN_INITIAL_PASSWORD: z.string().optional(),
|
ADMIN_INITIAL_PASSWORD: z.string().optional(),
|
||||||
|
|
|
||||||
|
|
@ -1,65 +0,0 @@
|
||||||
import type { Prisma } from "@prisma/client";
|
|
||||||
import { prisma } from "../db/prisma.js";
|
|
||||||
import { logger } from "./logger.service.js";
|
|
||||||
|
|
||||||
/** Who caused an {@link AnalyticsEvent}. `"user"` pairs with an `actorId` (`UserProfile.id`); `"system"` is a background job; `"anon"` is an unauthenticated request. */
|
|
||||||
export type AnalyticsActorType = "user" | "system" | "anon";
|
|
||||||
|
|
||||||
/** Optional context for {@link AnalyticsService.recordEvent}. */
|
|
||||||
export interface RecordEventOptions {
|
|
||||||
/** `UserProfile.id` — set together with `actorType: "user"` (the default when this is present). */
|
|
||||||
actorId?: number;
|
|
||||||
/** Overrides the inferred actor type (`"user"` when `actorId` is set, else `"anon"`). */
|
|
||||||
actorType?: AnalyticsActorType;
|
|
||||||
/** Small free-form blob for later slicing (`{ sourceKey, recipeId, … }`) — nothing queries into it today. */
|
|
||||||
context?: Prisma.InputJsonValue;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Records product usage events for the admin dashboard's metrics (see the
|
|
||||||
* `AnalyticsEvent` model doc comment). A class rather than a bare function
|
|
||||||
* — same convention as `LoggerService`/`ErrorHandlerService`: `public`
|
|
||||||
* `recordEvent` is the API, `_insert` is the internal it fans out to.
|
|
||||||
*
|
|
||||||
* **Fire-and-forget by contract**: `recordEvent` returns `void`, not a
|
|
||||||
* promise. The insert runs detached, and a failure is logged at `warn` and
|
|
||||||
* swallowed — analytics must never add latency to, or fail, the request
|
|
||||||
* that triggered it. Call sites therefore never `await` it.
|
|
||||||
*/
|
|
||||||
export class AnalyticsService {
|
|
||||||
public recordEvent(type: string, options: RecordEventOptions = {}): void {
|
|
||||||
const actorType: AnalyticsActorType =
|
|
||||||
options.actorType ?? (options.actorId !== undefined ? "user" : "anon");
|
|
||||||
|
|
||||||
void this._insert(type, actorType, options).catch((err: unknown) => {
|
|
||||||
logger.warn("Analytics event insert failed", {
|
|
||||||
eventType: type,
|
|
||||||
error: err instanceof Error ? err.message : String(err),
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private async _insert(
|
|
||||||
type: string,
|
|
||||||
actorType: AnalyticsActorType,
|
|
||||||
options: RecordEventOptions,
|
|
||||||
): Promise<void> {
|
|
||||||
try {
|
|
||||||
await prisma.analyticsEvent.create({
|
|
||||||
data: {
|
|
||||||
type,
|
|
||||||
actorType,
|
|
||||||
actorId: options.actorId ?? null,
|
|
||||||
context: options.context,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
// Rethrown so `recordEvent`'s `.catch` above logs it — this layer
|
|
||||||
// just isn't allowed a bare `await` per the repo's convention.
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Single shared instance — stateless, same reasoning as `logger`. */
|
|
||||||
export const analytics = new AnalyticsService();
|
|
||||||
|
|
@ -1,511 +0,0 @@
|
||||||
import type {
|
|
||||||
CookingBackgroundTaskView,
|
|
||||||
CookingPhaseKind,
|
|
||||||
CookingPhaseView,
|
|
||||||
CookingSessionRecipeRef,
|
|
||||||
CookingTaskIngredientView,
|
|
||||||
CookingTaskView,
|
|
||||||
TechStepView,
|
|
||||||
UtensilView,
|
|
||||||
} from "@batch-cooking/shared";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The pure core of the "Calcul batch-cooking" module (`specs/batch-cooking-architecture.md`):
|
|
||||||
* takes the week's planned recipes — already resolved to reference views by
|
|
||||||
* `cooking-session.service.ts` — and reorganizes their steps into an ordered
|
|
||||||
* sequence of {@link CookingPhaseView}s that pools shared preparation and
|
|
||||||
* interleaves the recipes so passive cooks (simmer, braise, bake…) run in
|
|
||||||
* the background while the cook does active work from another recipe.
|
|
||||||
*
|
|
||||||
* Pure and synchronous, no database access — same `matchXxx()` pure /
|
|
||||||
* `loadXxx()` DB-backed split as `ingredient-matcher.ts` /
|
|
||||||
* `tech-step-matcher.ts` / `shopping-list.service.ts`'s
|
|
||||||
* `aggregateShoppingList`, so the whole optimization is unit-testable
|
|
||||||
* without a Postgres round-trip.
|
|
||||||
*
|
|
||||||
* v1 scope (see the plan / spec): preparation is the only thing *merged*
|
|
||||||
* across recipes — a `chop`/`peel`/… technique applied to the same
|
|
||||||
* ingredient by two or more recipes, in a step that does nothing but prep,
|
|
||||||
* collapses into a single {@link CookingTaskView} of `kind: "merged-prep"`.
|
|
||||||
* Cooking steps themselves are never merged (no "same oven, same
|
|
||||||
* temperature" reasoning yet); they're only *reordered* for parallelism.
|
|
||||||
*/
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Technique keys (`TechStep.key`, see `reference-seed-data.ts`'s
|
|
||||||
* `TECH_STEPS`) that are pure knife/prep work on an ingredient — the only
|
|
||||||
* techniques v1 pools across recipes. A *step* counts as prep only when
|
|
||||||
* **every** technique it mentions is in here (see {@link isPurePrepStep}):
|
|
||||||
* "émincer les oignons" merges, "faire revenir les oignons émincés" does
|
|
||||||
* not (its `panFry` keeps it a cooking step).
|
|
||||||
*/
|
|
||||||
const PREP_TECHNIQUES: ReadonlySet<string> = new Set([
|
|
||||||
"chop",
|
|
||||||
"peel",
|
|
||||||
"mince",
|
|
||||||
"julienne",
|
|
||||||
"brunoise",
|
|
||||||
"concasse",
|
|
||||||
"paysanne",
|
|
||||||
"mirepoix",
|
|
||||||
"zest",
|
|
||||||
"score",
|
|
||||||
"pod",
|
|
||||||
"shellEgg",
|
|
||||||
"hollowOut",
|
|
||||||
"filet",
|
|
||||||
"disgorge",
|
|
||||||
"sift",
|
|
||||||
"dustWithFlour",
|
|
||||||
"peelBlanch",
|
|
||||||
]);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* How much of the cook's attention a technique needs once it's under way —
|
|
||||||
* the axis that makes parallelism possible.
|
|
||||||
*
|
|
||||||
* - `"SETUP"` — a short active trigger, then it looks after itself: preheat
|
|
||||||
* the oven, bring a pot of water to the boil. Pooled into the first
|
|
||||||
* ("mise en place") phase so it's running before it's needed.
|
|
||||||
* - `"PASSIVE"` — unattended once started (simmer, braise, bake, marinate,
|
|
||||||
* rest…). Scheduled, then floated into every following phase's
|
|
||||||
* `background` until the step that consumes it comes up.
|
|
||||||
* - anything not listed here, or a step with no detected technique at all,
|
|
||||||
* is treated as `"ACTIVE"` — hands-on, occupies the cook.
|
|
||||||
*/
|
|
||||||
const SETUP_TECHNIQUES: ReadonlySet<string> = new Set(["preheat", "boil", "bainMarie"]);
|
|
||||||
|
|
||||||
/** See {@link SETUP_TECHNIQUES}. */
|
|
||||||
const PASSIVE_TECHNIQUES: ReadonlySet<string> = new Set([
|
|
||||||
"simmer",
|
|
||||||
"bake",
|
|
||||||
"roast",
|
|
||||||
"braise",
|
|
||||||
"marinate",
|
|
||||||
"rest",
|
|
||||||
"proof",
|
|
||||||
"confit",
|
|
||||||
"reduce",
|
|
||||||
"blindBake",
|
|
||||||
"compote",
|
|
||||||
"smother",
|
|
||||||
"setGel",
|
|
||||||
"pasteurize",
|
|
||||||
"appertize",
|
|
||||||
"poach",
|
|
||||||
"sweat",
|
|
||||||
"glaze",
|
|
||||||
]);
|
|
||||||
|
|
||||||
/** Attention class of a single step — see {@link SETUP_TECHNIQUES}. */
|
|
||||||
type Attention = "SETUP" | "PASSIVE" | "ACTIVE";
|
|
||||||
|
|
||||||
/** One technique occurrence within a step, already scaled to the planned portions. */
|
|
||||||
interface OptimizerTechStepInput {
|
|
||||||
techStep: TechStepView;
|
|
||||||
order: number;
|
|
||||||
ingredients: CookingTaskIngredientView[];
|
|
||||||
utensils: UtensilView[];
|
|
||||||
}
|
|
||||||
|
|
||||||
/** One recipe step, as handed to {@link optimizeCookingPlan}. */
|
|
||||||
interface OptimizerStepInput {
|
|
||||||
stepId: number;
|
|
||||||
order: number;
|
|
||||||
description: string;
|
|
||||||
techSteps: OptimizerTechStepInput[];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* One planned recipe, as handed to {@link optimizeCookingPlan}. `portions`
|
|
||||||
* is the planning slot's own count and `recipePortions` the recipe's
|
|
||||||
* as-written yield — quantities are scaled by `portions / recipePortions`
|
|
||||||
* (see {@link scaleOf}). The same recipe planned twice at different portion
|
|
||||||
* counts arrives as two entries with the same `recipeId`; that's
|
|
||||||
* intentional (two real cooking jobs), and merged-prep still pools their
|
|
||||||
* knife work back together.
|
|
||||||
*/
|
|
||||||
interface OptimizerRecipeInput {
|
|
||||||
recipeId: number;
|
|
||||||
name: string;
|
|
||||||
portions: number;
|
|
||||||
recipePortions: number;
|
|
||||||
steps: OptimizerStepInput[];
|
|
||||||
}
|
|
||||||
|
|
||||||
/** {@link optimizeCookingPlan}'s result — the date-range/legend wrapper is added by the service. */
|
|
||||||
interface OptimizeCookingPlanResult {
|
|
||||||
recipes: CookingSessionRecipeRef[];
|
|
||||||
phases: CookingPhaseView[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export type {
|
|
||||||
OptimizeCookingPlanResult,
|
|
||||||
OptimizerRecipeInput,
|
|
||||||
OptimizerStepInput,
|
|
||||||
OptimizerTechStepInput,
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Portion scale factor for a recipe — guards a missing/zero as-written yield (bad data) by falling back to 1× rather than dividing by zero. */
|
|
||||||
function scaleOf(recipe: OptimizerRecipeInput): number {
|
|
||||||
if (!recipe.recipePortions || recipe.recipePortions <= 0) return 1;
|
|
||||||
return recipe.portions / recipe.recipePortions;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** A step normalized for scheduling — techniques scaled, attention resolved, ingredients/utensils unioned across its technique clauses. */
|
|
||||||
interface NormalizedStep {
|
|
||||||
/** Stable within one response: `step:<recipeIndex>:<stepId>` (the index disambiguates the same recipe planned twice). */
|
|
||||||
taskId: string;
|
|
||||||
recipeIndex: number;
|
|
||||||
recipe: CookingSessionRecipeRef;
|
|
||||||
stepId: number;
|
|
||||||
order: number;
|
|
||||||
description: string;
|
|
||||||
techSteps: OptimizerTechStepInput[];
|
|
||||||
attention: Attention;
|
|
||||||
isPurePrep: boolean;
|
|
||||||
dominantTechnique: TechStepView | null;
|
|
||||||
ingredients: CookingTaskIngredientView[];
|
|
||||||
utensils: UtensilView[];
|
|
||||||
/** Set once merged-prep extraction absorbs this step wholesale (all its prep pooled elsewhere) — it then produces no standalone task. */
|
|
||||||
absorbed: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Sums two ingredient lines only when it's unambiguous — same unit id and both quantities known; otherwise the pooled line carries no number (see `ShoppingListItemView`'s "don't guess a conversion" rule). */
|
|
||||||
function poolIngredient(lines: CookingTaskIngredientView[]): {
|
|
||||||
quantity: number | null;
|
|
||||||
unit: CookingTaskIngredientView["unit"];
|
|
||||||
} {
|
|
||||||
const first = lines[0];
|
|
||||||
if (!first) return { quantity: null, unit: null };
|
|
||||||
const unitId = first.unit?.id ?? null;
|
|
||||||
let total = 0;
|
|
||||||
for (const line of lines) {
|
|
||||||
if (line.quantity === null || (line.unit?.id ?? null) !== unitId) {
|
|
||||||
return { quantity: null, unit: null };
|
|
||||||
}
|
|
||||||
total += line.quantity;
|
|
||||||
}
|
|
||||||
return { quantity: total, unit: first.unit };
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Unions ingredient lines by `(ingredientId, unitId)`, summing quantities within a group the same careful way as {@link poolIngredient}. */
|
|
||||||
function unionIngredients(lines: CookingTaskIngredientView[]): CookingTaskIngredientView[] {
|
|
||||||
const groups = new Map<string, CookingTaskIngredientView[]>();
|
|
||||||
for (const line of lines) {
|
|
||||||
const key = `${line.ingredient.id}:${line.unit?.id ?? "x"}`;
|
|
||||||
const group = groups.get(key);
|
|
||||||
if (group) group.push(line);
|
|
||||||
else groups.set(key, [line]);
|
|
||||||
}
|
|
||||||
const out: CookingTaskIngredientView[] = [];
|
|
||||||
for (const group of groups.values()) {
|
|
||||||
const head = group[0];
|
|
||||||
if (!head) continue;
|
|
||||||
const pooled = poolIngredient(group);
|
|
||||||
out.push({ ingredient: head.ingredient, quantity: pooled.quantity, unit: pooled.unit });
|
|
||||||
}
|
|
||||||
return out.sort((a, b) => a.ingredient.key.localeCompare(b.ingredient.key));
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Unions utensils by id, keeping a stable order by key. */
|
|
||||||
function unionUtensils(utensils: UtensilView[]): UtensilView[] {
|
|
||||||
const byId = new Map<number, UtensilView>();
|
|
||||||
for (const utensil of utensils) byId.set(utensil.id, utensil);
|
|
||||||
return [...byId.values()].sort((a, b) => a.key.localeCompare(b.key));
|
|
||||||
}
|
|
||||||
|
|
||||||
/** A step is pure prep only if it has techniques and every one of them is in {@link PREP_TECHNIQUES}. */
|
|
||||||
function isPurePrepStep(techSteps: OptimizerTechStepInput[]): boolean {
|
|
||||||
return techSteps.length > 0 && techSteps.every((ts) => PREP_TECHNIQUES.has(ts.techStep.key));
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Resolves a step's {@link Attention} — SETUP wins, then a *trailing* passive technique, else ACTIVE (see {@link SETUP_TECHNIQUES}). */
|
|
||||||
function attentionOf(techSteps: OptimizerTechStepInput[]): Attention {
|
|
||||||
if (techSteps.some((ts) => SETUP_TECHNIQUES.has(ts.techStep.key))) return "SETUP";
|
|
||||||
const last = techSteps[techSteps.length - 1];
|
|
||||||
if (last && PASSIVE_TECHNIQUES.has(last.techStep.key)) return "PASSIVE";
|
|
||||||
return "ACTIVE";
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Turns one recipe's raw steps into {@link NormalizedStep}s — scales quantities, resolves attention, unions per-clause ingredients/utensils up to the step. */
|
|
||||||
function normalizeRecipe(recipe: OptimizerRecipeInput, recipeIndex: number): NormalizedStep[] {
|
|
||||||
const scale = scaleOf(recipe);
|
|
||||||
const recipeRef: CookingSessionRecipeRef = {
|
|
||||||
recipeId: recipe.recipeId,
|
|
||||||
name: recipe.name,
|
|
||||||
portions: recipe.portions,
|
|
||||||
};
|
|
||||||
|
|
||||||
return [...recipe.steps]
|
|
||||||
.sort((a, b) => a.order - b.order)
|
|
||||||
.map((step) => {
|
|
||||||
const techSteps: OptimizerTechStepInput[] = [...step.techSteps]
|
|
||||||
.sort((a, b) => a.order - b.order)
|
|
||||||
.map((ts) => ({
|
|
||||||
techStep: ts.techStep,
|
|
||||||
order: ts.order,
|
|
||||||
ingredients: ts.ingredients.map((line) => ({
|
|
||||||
ingredient: line.ingredient,
|
|
||||||
quantity: line.quantity === null ? null : line.quantity * scale,
|
|
||||||
unit: line.unit,
|
|
||||||
})),
|
|
||||||
utensils: ts.utensils,
|
|
||||||
}));
|
|
||||||
|
|
||||||
const lastTech = techSteps[techSteps.length - 1];
|
|
||||||
return {
|
|
||||||
taskId: `step:${recipeIndex}:${step.stepId}`,
|
|
||||||
recipeIndex,
|
|
||||||
recipe: recipeRef,
|
|
||||||
stepId: step.stepId,
|
|
||||||
order: step.order,
|
|
||||||
description: step.description,
|
|
||||||
techSteps,
|
|
||||||
attention: attentionOf(techSteps),
|
|
||||||
isPurePrep: isPurePrepStep(techSteps),
|
|
||||||
dominantTechnique: lastTech ? lastTech.techStep : null,
|
|
||||||
ingredients: unionIngredients(techSteps.flatMap((ts) => ts.ingredients)),
|
|
||||||
utensils: unionUtensils(techSteps.flatMap((ts) => ts.utensils)),
|
|
||||||
absorbed: false,
|
|
||||||
} satisfies NormalizedStep;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The prep signature of a pure-prep step — sorted `<techniqueKey>:<ingredientId>` pairs; two steps with the same signature do identical knife work and can be pooled. */
|
|
||||||
function prepSignature(step: NormalizedStep): string {
|
|
||||||
const pairs: string[] = [];
|
|
||||||
for (const ts of step.techSteps) {
|
|
||||||
for (const line of ts.ingredients) {
|
|
||||||
pairs.push(`${ts.techStep.key}:${line.ingredient.id}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return [...new Set(pairs)].sort().join("+");
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Builds one {@link CookingTaskView} from a normalized step run as written. */
|
|
||||||
function stepToTask(step: NormalizedStep): CookingTaskView {
|
|
||||||
return {
|
|
||||||
id: step.taskId,
|
|
||||||
kind: "step",
|
|
||||||
technique: step.dominantTechnique,
|
|
||||||
description: step.description,
|
|
||||||
ingredients: step.ingredients,
|
|
||||||
utensils: step.utensils,
|
|
||||||
sourceRecipes: [step.recipe],
|
|
||||||
originalSteps: [
|
|
||||||
{
|
|
||||||
recipeId: step.recipe.recipeId,
|
|
||||||
recipeName: step.recipe.name,
|
|
||||||
description: step.description,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Builds the running-in-the-background status line for a passive step already scheduled in an earlier phase. */
|
|
||||||
function stepToBackground(step: NormalizedStep): CookingBackgroundTaskView {
|
|
||||||
return {
|
|
||||||
id: `bg:${step.taskId}`,
|
|
||||||
technique: step.dominantTechnique,
|
|
||||||
description: step.description,
|
|
||||||
recipeId: step.recipe.recipeId,
|
|
||||||
recipeName: step.recipe.name,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Pools pure-prep steps that do the *exact same* knife work (same
|
|
||||||
* {@link prepSignature}) in two or more distinct recipes into one
|
|
||||||
* `merged-prep` {@link CookingTaskView}, and marks every contributing step
|
|
||||||
* `absorbed` so it produces no standalone task. A pure-prep step whose
|
|
||||||
* signature is unique (only one recipe needs it) is left untouched — it
|
|
||||||
* still lands in the mise-en-place phase, just as its own step task.
|
|
||||||
*
|
|
||||||
* Returns the merged tasks in a stable order (by id).
|
|
||||||
*/
|
|
||||||
function extractMergedPrep(steps: NormalizedStep[]): CookingTaskView[] {
|
|
||||||
const bySignature = new Map<string, NormalizedStep[]>();
|
|
||||||
for (const step of steps) {
|
|
||||||
if (!step.isPurePrep) continue;
|
|
||||||
const signature = prepSignature(step);
|
|
||||||
if (signature === "") continue;
|
|
||||||
const group = bySignature.get(signature);
|
|
||||||
if (group) group.push(step);
|
|
||||||
else bySignature.set(signature, [step]);
|
|
||||||
}
|
|
||||||
|
|
||||||
const merged: CookingTaskView[] = [];
|
|
||||||
for (const [signature, group] of bySignature) {
|
|
||||||
const recipeIndexes = new Set(group.map((s) => s.recipeIndex));
|
|
||||||
if (recipeIndexes.size < 2) continue;
|
|
||||||
|
|
||||||
for (const step of group) step.absorbed = true;
|
|
||||||
|
|
||||||
// Every contributing clause's ingredient lines, pooled per ingredient.
|
|
||||||
const allLines = group.flatMap((s) => s.techSteps.flatMap((ts) => ts.ingredients));
|
|
||||||
const ingredients = unionIngredients(allLines);
|
|
||||||
const utensils = unionUtensils(group.flatMap((s) => s.utensils));
|
|
||||||
|
|
||||||
// Dominant technique of the pool = the first pair's technique (v1
|
|
||||||
// signatures are almost always a single `<technique>:<ingredient>`
|
|
||||||
// pair; a multi-pair signature just takes the earliest).
|
|
||||||
const firstTech = group[0]?.techSteps[0]?.techStep ?? null;
|
|
||||||
const firstIngredientKey = ingredients[0]?.ingredient.key ?? signature;
|
|
||||||
|
|
||||||
// Distinct source recipes / original step texts, in input order.
|
|
||||||
const sourceRecipes: CookingSessionRecipeRef[] = [];
|
|
||||||
const seenRecipe = new Set<number>();
|
|
||||||
const originalSteps: CookingTaskView["originalSteps"] = [];
|
|
||||||
for (const step of [...group].sort((a, b) => a.recipeIndex - b.recipeIndex)) {
|
|
||||||
if (!seenRecipe.has(step.recipeIndex)) {
|
|
||||||
seenRecipe.add(step.recipeIndex);
|
|
||||||
sourceRecipes.push(step.recipe);
|
|
||||||
}
|
|
||||||
originalSteps.push({
|
|
||||||
recipeId: step.recipe.recipeId,
|
|
||||||
recipeName: step.recipe.name,
|
|
||||||
description: step.description,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
merged.push({
|
|
||||||
id: `prep:${firstTech ? firstTech.key : "prep"}:${firstIngredientKey}`,
|
|
||||||
kind: "merged-prep",
|
|
||||||
technique: firstTech,
|
|
||||||
description: null,
|
|
||||||
ingredients,
|
|
||||||
utensils,
|
|
||||||
sourceRecipes,
|
|
||||||
originalSteps,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return merged.sort((a, b) => a.id.localeCompare(b.id));
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Maps the input recipe list to its display legend, de-duplicating an exact `(recipeId, portions)` repeat. */
|
|
||||||
function toRecipeLegend(recipes: OptimizerRecipeInput[]): CookingSessionRecipeRef[] {
|
|
||||||
const seen = new Set<string>();
|
|
||||||
const out: CookingSessionRecipeRef[] = [];
|
|
||||||
for (const recipe of recipes) {
|
|
||||||
const key = `${recipe.recipeId}:${recipe.portions}`;
|
|
||||||
if (seen.has(key)) continue;
|
|
||||||
seen.add(key);
|
|
||||||
out.push({ recipeId: recipe.recipeId, name: recipe.name, portions: recipe.portions });
|
|
||||||
}
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** A phase is `"finishing"` when everything left in it is plating; otherwise it's a normal `"cooking"` phase. */
|
|
||||||
function cookingPhaseKind(tasks: CookingTaskView[]): CookingPhaseKind {
|
|
||||||
return tasks.every((task) => task.technique?.key === "plate") ? "finishing" : "cooking";
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* See the file header. Given the week's planned recipes (already resolved
|
|
||||||
* to reference views), returns the display legend plus the ordered phases:
|
|
||||||
*
|
|
||||||
* 1. **Mise en place** (`"mise-en-place"`) — every `merged-prep` task, then
|
|
||||||
* every leftover pure-prep step, then every `SETUP` step. Omitted
|
|
||||||
* entirely if it would be empty.
|
|
||||||
* 2. **Cooking** (`"cooking"` / `"finishing"`) — the recipes interleaved:
|
|
||||||
* each phase pops the next remaining step of every recipe that still has
|
|
||||||
* one (passive-cook steps first, so long cooks start early). A passive
|
|
||||||
* step scheduled in one phase is echoed in every later phase's
|
|
||||||
* `background` until that recipe's next step is popped.
|
|
||||||
*/
|
|
||||||
export function optimizeCookingPlan(recipes: OptimizerRecipeInput[]): OptimizeCookingPlanResult {
|
|
||||||
const legend = toRecipeLegend(recipes);
|
|
||||||
const normalized = recipes.map((recipe, index) => normalizeRecipe(recipe, index));
|
|
||||||
const allSteps = normalized.flat();
|
|
||||||
|
|
||||||
const mergedPrep = extractMergedPrep(allSteps);
|
|
||||||
|
|
||||||
const phases: CookingPhaseView[] = [];
|
|
||||||
|
|
||||||
// Phase 0 — mise en place.
|
|
||||||
const miseTasks: CookingTaskView[] = [...mergedPrep];
|
|
||||||
for (const step of allSteps) {
|
|
||||||
if (step.absorbed) continue;
|
|
||||||
if (step.isPurePrep || step.attention === "SETUP") {
|
|
||||||
miseTasks.push(stepToTask(step));
|
|
||||||
step.absorbed = true; // consumed here, not again in the cooking loop
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (miseTasks.length > 0) {
|
|
||||||
phases.push({ index: 0, kind: "mise-en-place", tasks: miseTasks, background: [] });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cooking phases — one "next step of each recipe" per phase. `hold` keeps
|
|
||||||
// a recipe out of the *next* phase right after it starts a passive cook,
|
|
||||||
// so another recipe's active work fills that phase and the passive cook
|
|
||||||
// shows up as `background` there instead of being immediately followed by
|
|
||||||
// its own next step.
|
|
||||||
const queues = normalized.map((steps) => ({
|
|
||||||
remaining: steps.filter((s) => !s.absorbed),
|
|
||||||
cursor: 0,
|
|
||||||
hold: 0,
|
|
||||||
}));
|
|
||||||
/** Passive steps started in an earlier phase, keyed by recipe index, still "cooking". */
|
|
||||||
const runningPassive = new Map<number, NormalizedStep>();
|
|
||||||
|
|
||||||
while (queues.some((queue) => queue.cursor < queue.remaining.length)) {
|
|
||||||
const phaseSteps: NormalizedStep[] = [];
|
|
||||||
queues.forEach((queue, recipeIndex) => {
|
|
||||||
const next = queue.remaining[queue.cursor];
|
|
||||||
if (!next) return;
|
|
||||||
if (queue.hold > 0) {
|
|
||||||
// Still tending its passive cook this phase — leave it in
|
|
||||||
// `runningPassive` so it renders as background, don't advance.
|
|
||||||
queue.hold--;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// This recipe is advancing — whatever passive cook it had going is
|
|
||||||
// now being tended to, so it stops showing as background.
|
|
||||||
runningPassive.delete(recipeIndex);
|
|
||||||
phaseSteps.push(next);
|
|
||||||
queue.cursor++;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Every recipe with steps left is holding on a passive cook — break the
|
|
||||||
// stall by releasing all holds and letting the next iteration advance.
|
|
||||||
if (phaseSteps.length === 0) {
|
|
||||||
for (const queue of queues) queue.hold = 0;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Background = passive cooks from earlier phases not yet resolved above.
|
|
||||||
const background = [...runningPassive.values()].map(stepToBackground);
|
|
||||||
|
|
||||||
// Start the long cooks first within the phase.
|
|
||||||
phaseSteps.sort((a, b) => {
|
|
||||||
const rank = (s: NormalizedStep) => (s.attention === "PASSIVE" ? 0 : 1);
|
|
||||||
return rank(a) - rank(b) || a.recipeIndex - b.recipeIndex;
|
|
||||||
});
|
|
||||||
|
|
||||||
const tasks = phaseSteps.map(stepToTask);
|
|
||||||
phases.push({
|
|
||||||
index: phases.length,
|
|
||||||
kind: cookingPhaseKind(tasks),
|
|
||||||
tasks,
|
|
||||||
background,
|
|
||||||
});
|
|
||||||
|
|
||||||
for (const step of phaseSteps) {
|
|
||||||
if (step.attention === "PASSIVE") {
|
|
||||||
runningPassive.set(step.recipeIndex, step);
|
|
||||||
const queue = queues[step.recipeIndex];
|
|
||||||
if (queue) queue.hold = 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// `index` was set from `phases.length` as we went; re-stamp so it always
|
|
||||||
// matches the final array position even if phase 0 was skipped.
|
|
||||||
phases.forEach((phase, index) => {
|
|
||||||
phase.index = index;
|
|
||||||
});
|
|
||||||
|
|
||||||
return { recipes: legend, phases };
|
|
||||||
}
|
|
||||||
|
|
@ -427,11 +427,6 @@ export async function loadIngredientCatalog(locale = "en"): Promise<IngredientMa
|
||||||
const labels = INGREDIENT_LABELS_BY_LOCALE[locale] ?? {};
|
const labels = INGREDIENT_LABELS_BY_LOCALE[locale] ?? {};
|
||||||
const synonyms = INGREDIENT_LABEL_SYNONYMS_BY_LOCALE[locale] ?? {};
|
const synonyms = INGREDIENT_LABEL_SYNONYMS_BY_LOCALE[locale] ?? {};
|
||||||
const ingredients = await prisma.ingredient.findMany({
|
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 },
|
select: { id: true, key: true },
|
||||||
});
|
});
|
||||||
const catalog: IngredientMatchEntry[] = [];
|
const catalog: IngredientMatchEntry[] = [];
|
||||||
|
|
|
||||||
|
|
@ -19,8 +19,8 @@ export interface AdminLocals {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Express middleware guarding every `/admin/*` route — the operations UI
|
* Express middleware guarding every `/admin/*` route — the operations app
|
||||||
* (`apps/web`'s `/admin/*` routes) authenticating as an `AdminUser`. Reads the admin
|
* (`apps/admin-web`) authenticating as an `AdminUser`. Reads the admin
|
||||||
* session cookie (`ADMIN_COOKIE_NAME`, deliberately **not** the same cookie
|
* session cookie (`ADMIN_COOKIE_NAME`, deliberately **not** the same cookie
|
||||||
* as end-user sessions), verifies the JWT against `ADMIN_JWT_SECRET`
|
* as end-user sessions), verifies the JWT against `ADMIN_JWT_SECRET`
|
||||||
* (a different secret than `JWT_SECRET`), and re-checks `tokenVersion`
|
* (a different secret than `JWT_SECRET`), and re-checks `tokenVersion`
|
||||||
|
|
|
||||||
|
|
@ -1,44 +0,0 @@
|
||||||
import { wrapAsyncHandler } from "@batch-cooking/express-tools";
|
|
||||||
import { listPlaceholdersQuerySchema, markPlaceholdersReviewedSchema } from "@batch-cooking/shared";
|
|
||||||
import { Router } from "express";
|
|
||||||
import { requireAdmin } from "../../middlewares/require-admin.js";
|
|
||||||
import {
|
|
||||||
listPlaceholderGroups,
|
|
||||||
markPlaceholdersReviewed,
|
|
||||||
pruneOrphanPlaceholders,
|
|
||||||
} from "./admin-catalog.service.js";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Router mounted at `/admin/catalog` (via `admin.routes.ts`) — every route
|
|
||||||
* behind {@link requireAdmin}. Surfaces the off-catalog ingredient
|
|
||||||
* "placeholders" users typed when the seeded catalog fell short, so a
|
|
||||||
* maintainer can see what's missing and mark gaps as handled.
|
|
||||||
*/
|
|
||||||
export const adminCatalogRouter = Router();
|
|
||||||
|
|
||||||
adminCatalogRouter.get(
|
|
||||||
"/placeholders",
|
|
||||||
requireAdmin,
|
|
||||||
wrapAsyncHandler(async (req, res) => {
|
|
||||||
res.status(200).json(await listPlaceholderGroups(listPlaceholdersQuerySchema.parse(req.query)));
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
adminCatalogRouter.patch(
|
|
||||||
"/placeholders/mark-reviewed",
|
|
||||||
requireAdmin,
|
|
||||||
wrapAsyncHandler(async (req, res) => {
|
|
||||||
res
|
|
||||||
.status(200)
|
|
||||||
.json(await markPlaceholdersReviewed(markPlaceholdersReviewedSchema.parse(req.body)));
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
/** Deletes placeholder rows no recipe references any more — see {@link pruneOrphanPlaceholders}. */
|
|
||||||
adminCatalogRouter.post(
|
|
||||||
"/placeholders/prune-orphans",
|
|
||||||
requireAdmin,
|
|
||||||
wrapAsyncHandler(async (_req, res) => {
|
|
||||||
res.status(200).json(await pruneOrphanPlaceholders());
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
@ -1,156 +0,0 @@
|
||||||
import type {
|
|
||||||
CatalogPlaceholderGroupView,
|
|
||||||
ListPlaceholdersQuery,
|
|
||||||
MarkPlaceholdersReviewedInput,
|
|
||||||
PruneOrphansResultView,
|
|
||||||
} from "@batch-cooking/shared";
|
|
||||||
import { prisma } from "../../db/prisma.js";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Grouping key for two placeholder spellings that mean the same missing
|
|
||||||
* ingredient — lower-cased, accent-stripped, punctuation-neutralised,
|
|
||||||
* whitespace-collapsed. "Piment d'Espelette", "piment d espelette" and
|
|
||||||
* "PIMENT D'ESPELETTE" all normalise to `"piment d espelette"`, so the
|
|
||||||
* admin view shows one gap, not three. Pure (no DB) — unit-tested on its
|
|
||||||
* own, same split convention as `matchXxx()` vs `loadXxx()` elsewhere.
|
|
||||||
*/
|
|
||||||
export function normalizePlaceholderName(raw: string): string {
|
|
||||||
return raw
|
|
||||||
.normalize("NFD")
|
|
||||||
.replace(/\p{Diacritic}/gu, "")
|
|
||||||
.toLowerCase()
|
|
||||||
.replace(/[^\p{Letter}\p{Number}]+/gu, " ")
|
|
||||||
.trim()
|
|
||||||
.replace(/\s+/g, " ");
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Prisma `include` for the placeholder query — up to a few `RecipeIngredient` links per row, each with just enough of its recipe for the "seen in…" preview and the distinct-recipe count. */
|
|
||||||
const placeholderInclude = {
|
|
||||||
recipes: {
|
|
||||||
take: 5,
|
|
||||||
include: { recipe: { select: { id: true, name: true } } },
|
|
||||||
},
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Every placeholder `Ingredient` (see `Ingredient.isPlaceholder` in
|
|
||||||
* schema.prisma), grouped by {@link normalizePlaceholderName} so a
|
|
||||||
* maintainer reviews one row per *missing ingredient* rather than one per
|
|
||||||
* recipe line. Ordered by recipe impact (most-requested gap first), then
|
|
||||||
* name.
|
|
||||||
*
|
|
||||||
* `query.reviewed` selects which side to show: omitted / `"false"` drops
|
|
||||||
* groups whose every row has already been triaged (`reviewedAt` set) — the
|
|
||||||
* default working list; `"true"` keeps only those fully-triaged groups.
|
|
||||||
*/
|
|
||||||
export async function listPlaceholderGroups(
|
|
||||||
query: ListPlaceholdersQuery,
|
|
||||||
): Promise<CatalogPlaceholderGroupView[]> {
|
|
||||||
try {
|
|
||||||
const rows = await prisma.ingredient.findMany({
|
|
||||||
where: { isPlaceholder: true },
|
|
||||||
include: placeholderInclude,
|
|
||||||
orderBy: { createdAt: "asc" },
|
|
||||||
});
|
|
||||||
|
|
||||||
/** Accumulator per normalized name — mutated in the loop, shaped into the view after. */
|
|
||||||
interface GroupAccumulator {
|
|
||||||
normalizedName: string;
|
|
||||||
displayNames: Set<string>;
|
|
||||||
ingredientIds: number[];
|
|
||||||
recipeIds: Set<number>;
|
|
||||||
sampleRecipes: Map<number, string>;
|
|
||||||
firstSeenAt: Date | null;
|
|
||||||
allReviewed: boolean;
|
|
||||||
}
|
|
||||||
const groups = new Map<string, GroupAccumulator>();
|
|
||||||
|
|
||||||
for (const row of rows) {
|
|
||||||
const name = row.displayName ?? "";
|
|
||||||
const normalizedName = normalizePlaceholderName(name);
|
|
||||||
let group = groups.get(normalizedName);
|
|
||||||
if (!group) {
|
|
||||||
group = {
|
|
||||||
normalizedName,
|
|
||||||
displayNames: new Set(),
|
|
||||||
ingredientIds: [],
|
|
||||||
recipeIds: new Set(),
|
|
||||||
sampleRecipes: new Map(),
|
|
||||||
firstSeenAt: null,
|
|
||||||
allReviewed: true,
|
|
||||||
};
|
|
||||||
groups.set(normalizedName, group);
|
|
||||||
}
|
|
||||||
if (name.length > 0) group.displayNames.add(name);
|
|
||||||
group.ingredientIds.push(row.id);
|
|
||||||
for (const link of row.recipes) {
|
|
||||||
group.recipeIds.add(link.recipe.id);
|
|
||||||
if (group.sampleRecipes.size < 5) group.sampleRecipes.set(link.recipe.id, link.recipe.name);
|
|
||||||
}
|
|
||||||
if (row.createdAt && (group.firstSeenAt === null || row.createdAt < group.firstSeenAt)) {
|
|
||||||
group.firstSeenAt = row.createdAt;
|
|
||||||
}
|
|
||||||
if (row.reviewedAt === null) group.allReviewed = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// `reviewed=true` → the archive of handled gaps; anything else → the
|
|
||||||
// working list of gaps still to look at.
|
|
||||||
const wantReviewed = query.reviewed === "true";
|
|
||||||
return [...groups.values()]
|
|
||||||
.filter((group) => group.allReviewed === wantReviewed)
|
|
||||||
.map((group) => ({
|
|
||||||
normalizedName: group.normalizedName,
|
|
||||||
displayNames: [...group.displayNames].sort((a, b) => a.localeCompare(b, "fr")),
|
|
||||||
ingredientIds: group.ingredientIds,
|
|
||||||
recipeCount: group.recipeIds.size,
|
|
||||||
sampleRecipes: [...group.sampleRecipes.entries()].map(([id, name]) => ({ id, name })),
|
|
||||||
firstSeenAt: group.firstSeenAt?.toISOString() ?? null,
|
|
||||||
allReviewed: group.allReviewed,
|
|
||||||
}))
|
|
||||||
.sort(
|
|
||||||
(a, b) => b.recipeCount - a.recipeCount || a.normalizedName.localeCompare(b.normalizedName),
|
|
||||||
);
|
|
||||||
} catch (err) {
|
|
||||||
throw err; // see recipe.service.ts's equivalent catch comment
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Stamps `reviewedAt` on the given placeholder ids — a maintainer has seen
|
|
||||||
* this gap (and, if it warranted it, added the real catalog entry by hand;
|
|
||||||
* this endpoint never touches the catalog itself). Scoped to
|
|
||||||
* `isPlaceholder: true` so a stray real id is a silent no-op, not a
|
|
||||||
* mislabel. Returns how many rows were actually stamped.
|
|
||||||
*/
|
|
||||||
export async function markPlaceholdersReviewed(
|
|
||||||
input: MarkPlaceholdersReviewedInput,
|
|
||||||
): Promise<{ reviewed: number }> {
|
|
||||||
try {
|
|
||||||
const { count } = await prisma.ingredient.updateMany({
|
|
||||||
where: { id: { in: input.ingredientIds }, isPlaceholder: true },
|
|
||||||
data: { reviewedAt: new Date() },
|
|
||||||
});
|
|
||||||
return { reviewed: count };
|
|
||||||
} catch (err) {
|
|
||||||
throw err; // see recipe.service.ts's equivalent catch comment
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Deletes placeholder rows no recipe references any more — the debris left
|
|
||||||
* when a recipe edit drops a placeholder line (the `RecipeIngredient` row
|
|
||||||
* goes, the `Ingredient` row doesn't). A manual GC (button in the admin
|
|
||||||
* catalog view, or `scripts/prune-orphan-placeholders.ts`) rather than a
|
|
||||||
* cascade: a placeholder is still evidence of a catalog gap even with no
|
|
||||||
* live recipe, so dropping it is a deliberate call, not automatic.
|
|
||||||
*/
|
|
||||||
export async function pruneOrphanPlaceholders(): Promise<PruneOrphansResultView> {
|
|
||||||
try {
|
|
||||||
const { count } = await prisma.ingredient.deleteMany({
|
|
||||||
where: { isPlaceholder: true, recipes: { none: {} } },
|
|
||||||
});
|
|
||||||
return { deleted: count };
|
|
||||||
} catch (err) {
|
|
||||||
throw err; // see recipe.service.ts's equivalent catch comment
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,22 +0,0 @@
|
||||||
import { wrapAsyncHandler } from "@batch-cooking/express-tools";
|
|
||||||
import { getMetricsSchema } from "@batch-cooking/shared";
|
|
||||||
import { Router } from "express";
|
|
||||||
import { requireAdmin } from "../../middlewares/require-admin.js";
|
|
||||||
import { getMetrics } from "./admin-metrics.service.js";
|
|
||||||
|
|
||||||
/** Router mounted at `/admin/metrics` (via `admin.routes.ts`) — every route behind {@link requireAdmin}. */
|
|
||||||
export const adminMetricsRouter = Router();
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns the admin dashboard's usage metrics — a `snapshot` of current
|
|
||||||
* totals plus `?days=` (7–365, 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));
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
@ -1,242 +0,0 @@
|
||||||
import type {
|
|
||||||
MetricsBreakdownRow,
|
|
||||||
MetricsEventSeries,
|
|
||||||
MetricsSnapshotView,
|
|
||||||
MetricsTimeBucket,
|
|
||||||
MetricsView,
|
|
||||||
} from "@batch-cooking/shared";
|
|
||||||
import { prisma } from "../../db/prisma.js";
|
|
||||||
|
|
||||||
/** UTC `YYYY-MM-DD` for a `Date` — the bucket key used by {@link bucketByDay}. */
|
|
||||||
function utcDayKey(date: Date): string {
|
|
||||||
return date.toISOString().slice(0, 10);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Start-of-day (UTC) `Date` that is `daysAgo` days before `from`. */
|
|
||||||
function startOfUtcDay(from: Date, daysAgo: number): Date {
|
|
||||||
return new Date(Date.UTC(from.getUTCFullYear(), from.getUTCMonth(), from.getUTCDate() - daysAgo));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Buckets `dates` into `days` consecutive daily counts starting at `since`
|
|
||||||
* (a start-of-UTC-day `Date`). Every day in the window is present, days
|
|
||||||
* with no matching date carry `count: 0`. Pure/synchronous — factored out
|
|
||||||
* so the bucketing is unit-testable without a database, same split as
|
|
||||||
* `aggregateShoppingList`.
|
|
||||||
*/
|
|
||||||
export function bucketByDay(dates: Date[], since: Date, days: number): MetricsTimeBucket[] {
|
|
||||||
const counts = new Map<string, number>();
|
|
||||||
for (let i = 0; i < days; i++) {
|
|
||||||
const day = new Date(since.getTime() + i * 86_400_000);
|
|
||||||
counts.set(utcDayKey(day), 0);
|
|
||||||
}
|
|
||||||
for (const date of dates) {
|
|
||||||
const key = utcDayKey(date);
|
|
||||||
const current = counts.get(key);
|
|
||||||
if (current !== undefined) counts.set(key, current + 1);
|
|
||||||
}
|
|
||||||
return [...counts.entries()]
|
|
||||||
.sort(([a], [b]) => a.localeCompare(b))
|
|
||||||
.map(([date, count]) => ({ date, count }));
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Shapes a Prisma `groupBy ... _count` result into the frontend's `{ key, label, count }` rows, sorted by count desc. */
|
|
||||||
function toBreakdown(
|
|
||||||
rows: { key: string | null; count: number }[],
|
|
||||||
labelFor: (key: string) => string = (key) => key,
|
|
||||||
): MetricsBreakdownRow[] {
|
|
||||||
return rows
|
|
||||||
.map(({ key, count }) => {
|
|
||||||
const resolved = key ?? "unknown";
|
|
||||||
return { key: resolved, label: labelFor(resolved), count };
|
|
||||||
})
|
|
||||||
.sort((a, b) => b.count - a.count);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Every point-in-time `COUNT` for the KPI tiles — see {@link MetricsSnapshotView}. */
|
|
||||||
async function getSnapshot(): Promise<MetricsSnapshotView> {
|
|
||||||
try {
|
|
||||||
const [
|
|
||||||
admins,
|
|
||||||
users,
|
|
||||||
households,
|
|
||||||
activeHouseholdGroups,
|
|
||||||
recipes,
|
|
||||||
recipesManual,
|
|
||||||
recipesImported,
|
|
||||||
recipeBySourceGroups,
|
|
||||||
sources,
|
|
||||||
plannings,
|
|
||||||
planningItems,
|
|
||||||
steps,
|
|
||||||
detectedTechniques,
|
|
||||||
favorites,
|
|
||||||
corrections,
|
|
||||||
correctionsUnconsumed,
|
|
||||||
correctionsRemoval,
|
|
||||||
trainingSuggestions,
|
|
||||||
suggestionStatusGroups,
|
|
||||||
suggestionSourceTypeGroups,
|
|
||||||
] = await Promise.all([
|
|
||||||
prisma.adminUser.count(),
|
|
||||||
prisma.userProfile.count(),
|
|
||||||
prisma.house.count(),
|
|
||||||
prisma.planning.groupBy({ by: ["houseId"] }),
|
|
||||||
prisma.recipe.count(),
|
|
||||||
prisma.recipe.count({ where: { sourceId: null } }),
|
|
||||||
prisma.recipe.count({ where: { sourceId: { not: null } } }),
|
|
||||||
prisma.recipe.groupBy({
|
|
||||||
by: ["sourceId"],
|
|
||||||
where: { sourceId: { not: null } },
|
|
||||||
_count: { _all: true },
|
|
||||||
}),
|
|
||||||
prisma.source.findMany({ select: { id: true, key: true, name: true } }),
|
|
||||||
prisma.planning.count(),
|
|
||||||
prisma.planningItem.count(),
|
|
||||||
prisma.step.count(),
|
|
||||||
prisma.stepTechStep.count(),
|
|
||||||
prisma.recipeFavorite.count(),
|
|
||||||
prisma.stepTechStepCorrection.count(),
|
|
||||||
prisma.stepTechStepCorrection.count({ where: { consumedAt: null } }),
|
|
||||||
prisma.stepTechStepCorrection.count({ where: { correctedTechStepId: null } }),
|
|
||||||
prisma.techStepTrainingSuggestion.count(),
|
|
||||||
prisma.techStepTrainingSuggestion.groupBy({ by: ["status"], _count: { _all: true } }),
|
|
||||||
prisma.techStepTrainingSuggestion.groupBy({ by: ["sourceType"], _count: { _all: true } }),
|
|
||||||
]);
|
|
||||||
|
|
||||||
const sourceById = new Map(sources.map((source) => [source.id, source]));
|
|
||||||
|
|
||||||
return {
|
|
||||||
admins,
|
|
||||||
users,
|
|
||||||
households,
|
|
||||||
activeHouseholds: activeHouseholdGroups.length,
|
|
||||||
recipes,
|
|
||||||
recipesManual,
|
|
||||||
recipesImported,
|
|
||||||
recipesBySource: toBreakdown(
|
|
||||||
recipeBySourceGroups.map((group) => ({
|
|
||||||
key: group.sourceId === null ? null : (sourceById.get(group.sourceId)?.key ?? null),
|
|
||||||
count: group._count._all,
|
|
||||||
})),
|
|
||||||
(key) => {
|
|
||||||
const source = sources.find((s) => s.key === key);
|
|
||||||
return source ? source.name : key;
|
|
||||||
},
|
|
||||||
),
|
|
||||||
plannings,
|
|
||||||
planningItems,
|
|
||||||
steps,
|
|
||||||
detectedTechniques,
|
|
||||||
favorites,
|
|
||||||
corrections,
|
|
||||||
correctionsUnconsumed,
|
|
||||||
correctionsRemoval,
|
|
||||||
trainingSuggestions,
|
|
||||||
trainingSuggestionsByStatus: toBreakdown(
|
|
||||||
suggestionStatusGroups.map((group) => ({ key: group.status, count: group._count._all })),
|
|
||||||
),
|
|
||||||
trainingSuggestionsBySourceType: toBreakdown(
|
|
||||||
suggestionSourceTypeGroups.map((group) => ({
|
|
||||||
key: group.sourceType,
|
|
||||||
count: group._count._all,
|
|
||||||
})),
|
|
||||||
),
|
|
||||||
};
|
|
||||||
} catch (err) {
|
|
||||||
throw err; // see recipe.service.ts's equivalent catch comment
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Builds the admin dashboard's full metrics payload — a `snapshot` of
|
|
||||||
* current totals plus `rangeDays` days of daily time series, derived from
|
|
||||||
* the `createdAt` columns the `admin_metrics` migration added and from the
|
|
||||||
* `AnalyticsEvent` table. `days` is the caller-validated `?days=` value
|
|
||||||
* (see `getMetricsSchema`, 7–365).
|
|
||||||
*/
|
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,20 +0,0 @@
|
||||||
import { wrapAsyncHandler } from "@batch-cooking/express-tools";
|
|
||||||
import { Router } from "express";
|
|
||||||
import { requireAdmin } from "../../middlewares/require-admin.js";
|
|
||||||
import { getMonitoring } from "./admin-monitoring.service.js";
|
|
||||||
|
|
||||||
/** Router mounted at `/admin/monitoring` (via `admin.routes.ts`) — behind {@link requireAdmin}. */
|
|
||||||
export const adminMonitoringRouter = Router();
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Actively probes Postgres, the API, `tech-step-intent-service` and the LLM
|
|
||||||
* worker's heartbeat, returning a {@link MonitoringView} status board (see
|
|
||||||
* {@link getMonitoring}). No params; the admin UI polls it on an interval.
|
|
||||||
*/
|
|
||||||
adminMonitoringRouter.get(
|
|
||||||
"/",
|
|
||||||
requireAdmin,
|
|
||||||
wrapAsyncHandler(async (_req, res) => {
|
|
||||||
res.status(200).json(await getMonitoring());
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
@ -1,174 +0,0 @@
|
||||||
import type { MonitoringView, ServiceHealthView, ServiceStatus } from "@batch-cooking/shared";
|
|
||||||
import { env } from "../../config/env.js";
|
|
||||||
import { prisma } from "../../db/prisma.js";
|
|
||||||
|
|
||||||
/** How long each outbound probe (Postgres query, intent-service HTTP) is allowed to take before it counts as `down`. */
|
|
||||||
const PROBE_TIMEOUT_MS = 2000;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Heartbeat-age thresholds for the LLM worker. Its default cron is weekly
|
|
||||||
* (`TECH_STEP_WORKER_CRON`, `0 3 * * 0`), and it also pings on boot/tick —
|
|
||||||
* so no ping for **8 days** means it likely missed its last scheduled fire
|
|
||||||
* (`degraded`), and none for **3 weeks** means it's almost certainly not
|
|
||||||
* running at all (`down`).
|
|
||||||
*/
|
|
||||||
const WORKER_STALE_AFTER_MS = 8 * 24 * 60 * 60 * 1000;
|
|
||||||
const WORKER_DOWN_AFTER_MS = 21 * 24 * 60 * 60 * 1000;
|
|
||||||
|
|
||||||
const WORKER_KEY = "tech-step-llm-worker";
|
|
||||||
|
|
||||||
function nowIso(): string {
|
|
||||||
return new Date().toISOString();
|
|
||||||
}
|
|
||||||
|
|
||||||
function roundMs(value: number): number {
|
|
||||||
return Math.round(value * 10) / 10;
|
|
||||||
}
|
|
||||||
|
|
||||||
function errMessage(err: unknown): string {
|
|
||||||
return err instanceof Error ? err.message : String(err);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** `12500` → `"il y a 12 s"`, `90000` → `"il y a 1 min"`, `172800000` → `"il y a 2 j"`. */
|
|
||||||
function formatAgo(ms: number): string {
|
|
||||||
const s = Math.round(ms / 1000);
|
|
||||||
if (s < 60) return `il y a ${s} s`;
|
|
||||||
const m = Math.round(s / 60);
|
|
||||||
if (m < 60) return `il y a ${m} min`;
|
|
||||||
const h = Math.round(m / 60);
|
|
||||||
if (h < 48) return `il y a ${h} h`;
|
|
||||||
return `il y a ${Math.round(h / 24)} j`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** `process.uptime()` seconds → `"3 h 12 min"` / `"5 min"` / `"42 s"`. */
|
|
||||||
function formatUptime(seconds: number): string {
|
|
||||||
const s = Math.floor(seconds);
|
|
||||||
if (s < 60) return `${s} s`;
|
|
||||||
const m = Math.floor(s / 60);
|
|
||||||
if (m < 60) return `${m} min`;
|
|
||||||
const h = Math.floor(m / 60);
|
|
||||||
return `${h} h ${m % 60} min`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** `prisma.$queryRaw\`SELECT 1\`` with a bounded timeout — the DB connectivity probe. */
|
|
||||||
async function probePostgres(): Promise<ServiceHealthView> {
|
|
||||||
const start = performance.now();
|
|
||||||
try {
|
|
||||||
// `$queryRaw` doesn't take an AbortSignal — bound it with a race instead.
|
|
||||||
await Promise.race([
|
|
||||||
prisma.$queryRaw`SELECT 1`,
|
|
||||||
new Promise((_resolve, reject) =>
|
|
||||||
setTimeout(() => reject(new Error("timeout")), PROBE_TIMEOUT_MS),
|
|
||||||
),
|
|
||||||
]);
|
|
||||||
return {
|
|
||||||
key: "postgres",
|
|
||||||
status: "up",
|
|
||||||
latencyMs: roundMs(performance.now() - start),
|
|
||||||
detail: null,
|
|
||||||
checkedAt: nowIso(),
|
|
||||||
};
|
|
||||||
} catch (err) {
|
|
||||||
return {
|
|
||||||
key: "postgres",
|
|
||||||
status: "down",
|
|
||||||
latencyMs: null,
|
|
||||||
detail: errMessage(err),
|
|
||||||
checkedAt: nowIso(),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The API itself — trivially "up" (it's answering), reported with its process uptime/memory. */
|
|
||||||
function probeApi(): ServiceHealthView {
|
|
||||||
const mem = process.memoryUsage();
|
|
||||||
return {
|
|
||||||
key: "api",
|
|
||||||
status: "up",
|
|
||||||
latencyMs: 0,
|
|
||||||
detail: `uptime ${formatUptime(process.uptime())} · RSS ${Math.round(mem.rss / 1_000_000)} Mo`,
|
|
||||||
checkedAt: nowIso(),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/** `GET {INTENT_SERVICE_BASE_URL}/health` — no secret needed on that route (see the service's `routes/health.py`). */
|
|
||||||
async function probeIntentService(): Promise<ServiceHealthView> {
|
|
||||||
const start = performance.now();
|
|
||||||
try {
|
|
||||||
const res = await fetch(`${env.INTENT_SERVICE_BASE_URL}/health`, {
|
|
||||||
signal: AbortSignal.timeout(PROBE_TIMEOUT_MS),
|
|
||||||
});
|
|
||||||
const latencyMs = roundMs(performance.now() - start);
|
|
||||||
return {
|
|
||||||
key: "intent-service",
|
|
||||||
status: res.ok ? "up" : "degraded",
|
|
||||||
latencyMs,
|
|
||||||
detail: `HTTP ${res.status}`,
|
|
||||||
checkedAt: nowIso(),
|
|
||||||
};
|
|
||||||
} catch (err) {
|
|
||||||
return {
|
|
||||||
key: "intent-service",
|
|
||||||
status: "down",
|
|
||||||
latencyMs: null,
|
|
||||||
detail: errMessage(err),
|
|
||||||
checkedAt: nowIso(),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Reads the LLM worker's stored `WorkerHeartbeat` (it has no HTTP surface to probe directly) and grades it by age + last job outcome. */
|
|
||||||
async function probeWorker(): Promise<ServiceHealthView> {
|
|
||||||
const heartbeat = await prisma.workerHeartbeat.findUnique({ where: { workerKey: WORKER_KEY } });
|
|
||||||
if (!heartbeat) {
|
|
||||||
return {
|
|
||||||
key: WORKER_KEY,
|
|
||||||
status: "unknown",
|
|
||||||
latencyMs: null,
|
|
||||||
detail: "aucun battement reçu",
|
|
||||||
checkedAt: nowIso(),
|
|
||||||
lastRunAt: null,
|
|
||||||
lastResult: null,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const ageMs = Date.now() - heartbeat.lastSeenAt.getTime();
|
|
||||||
const lastResult = (heartbeat.lastResult ?? null) as ServiceHealthView["lastResult"];
|
|
||||||
|
|
||||||
let status: ServiceStatus = "up";
|
|
||||||
if (ageMs > WORKER_DOWN_AFTER_MS) status = "down";
|
|
||||||
else if (ageMs > WORKER_STALE_AFTER_MS || lastResult?.ok === false) status = "degraded";
|
|
||||||
|
|
||||||
return {
|
|
||||||
key: WORKER_KEY,
|
|
||||||
status,
|
|
||||||
latencyMs: null,
|
|
||||||
detail: `dernier battement ${formatAgo(ageMs)}`,
|
|
||||||
checkedAt: nowIso(),
|
|
||||||
lastRunAt: heartbeat.lastRunAt?.toISOString() ?? null,
|
|
||||||
lastResult,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Actively probes every dependency the admin monitoring board watches —
|
|
||||||
* Postgres, the API itself, `tech-step-intent-service` (`/health`), and the
|
|
||||||
* LLM worker (via its stored heartbeat). Each probe is independent and
|
|
||||||
* bounded ({@link PROBE_TIMEOUT_MS}); one being `down` never fails the
|
|
||||||
* others or the endpoint.
|
|
||||||
*/
|
|
||||||
export async function getMonitoring(): Promise<MonitoringView> {
|
|
||||||
try {
|
|
||||||
const [postgres, intentService, worker] = await Promise.all([
|
|
||||||
probePostgres(),
|
|
||||||
probeIntentService(),
|
|
||||||
probeWorker(),
|
|
||||||
]);
|
|
||||||
return {
|
|
||||||
generatedAt: nowIso(),
|
|
||||||
services: [postgres, probeApi(), intentService, worker],
|
|
||||||
};
|
|
||||||
} catch (err) {
|
|
||||||
throw err; // see recipe.service.ts's equivalent catch comment
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,80 +0,0 @@
|
||||||
import { HttpError } from "@batch-cooking/error-tools";
|
|
||||||
import { wrapAsyncHandler } from "@batch-cooking/express-tools";
|
|
||||||
import {
|
|
||||||
ErrorCode,
|
|
||||||
listCorrectionsQuerySchema,
|
|
||||||
listSuggestionsQuerySchema,
|
|
||||||
retrainRequestSchema,
|
|
||||||
trainingDataSnippetQuerySchema,
|
|
||||||
updateTrainingSuggestionSchema,
|
|
||||||
} from "@batch-cooking/shared";
|
|
||||||
import { Router } from "express";
|
|
||||||
import { requireAdmin } from "../../middlewares/require-admin.js";
|
|
||||||
import {
|
|
||||||
getTrainingDataSnippet,
|
|
||||||
listCorrections,
|
|
||||||
listSuggestions,
|
|
||||||
runRetrain,
|
|
||||||
updateSuggestion,
|
|
||||||
} from "./admin-tech-steps.service.js";
|
|
||||||
|
|
||||||
/** Router mounted at `/admin/tech-steps` (via `admin.routes.ts`) — every route behind {@link requireAdmin}. Correction/suggestion triage + the retrain trigger. */
|
|
||||||
export const adminTechStepsRouter = Router();
|
|
||||||
|
|
||||||
adminTechStepsRouter.get(
|
|
||||||
"/suggestions",
|
|
||||||
requireAdmin,
|
|
||||||
wrapAsyncHandler(async (req, res) => {
|
|
||||||
res.status(200).json(await listSuggestions(listSuggestionsQuerySchema.parse(req.query)));
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
adminTechStepsRouter.get(
|
|
||||||
"/corrections",
|
|
||||||
requireAdmin,
|
|
||||||
wrapAsyncHandler(async (req, res) => {
|
|
||||||
res.status(200).json(await listCorrections(listCorrectionsQuerySchema.parse(req.query)));
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
adminTechStepsRouter.get(
|
|
||||||
"/training-data-snippet",
|
|
||||||
requireAdmin,
|
|
||||||
wrapAsyncHandler(async (req, res) => {
|
|
||||||
res
|
|
||||||
.status(200)
|
|
||||||
.json(await getTrainingDataSnippet(trainingDataSnippetQuerySchema.parse(req.query)));
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
adminTechStepsRouter.patch(
|
|
||||||
"/suggestions/:id",
|
|
||||||
requireAdmin,
|
|
||||||
wrapAsyncHandler(async (req, res) => {
|
|
||||||
const id = Number(req.params.id);
|
|
||||||
if (!Number.isInteger(id) || id <= 0) {
|
|
||||||
throw new HttpError(
|
|
||||||
400,
|
|
||||||
ErrorCode.VALIDATION_ERROR,
|
|
||||||
`Not a valid suggestion id: ${req.params.id}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const input = updateTrainingSuggestionSchema.parse(req.body);
|
|
||||||
res.status(200).json(await updateSuggestion(id, input));
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Runs the F1 regression gate then (if it passes) the full step backfill,
|
|
||||||
* and marks the given suggestion ids — see {@link runRetrain}. Long-running
|
|
||||||
* and process-locked: `409 RETRAIN_ALREADY_RUNNING` if one is already
|
|
||||||
* underway.
|
|
||||||
*/
|
|
||||||
adminTechStepsRouter.post(
|
|
||||||
"/retrain",
|
|
||||||
requireAdmin,
|
|
||||||
wrapAsyncHandler(async (req, res) => {
|
|
||||||
const input = retrainRequestSchema.parse(req.body);
|
|
||||||
res.status(200).json(await runRetrain(input));
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
@ -1,325 +0,0 @@
|
||||||
import { HttpError } from "@batch-cooking/error-tools";
|
|
||||||
import {
|
|
||||||
type CorrectionAdminView,
|
|
||||||
ErrorCode,
|
|
||||||
type ListCorrectionsQuery,
|
|
||||||
type ListSuggestionsQuery,
|
|
||||||
type RetrainRequestInput,
|
|
||||||
type RetrainResultView,
|
|
||||||
type TrainingDataSnippetQuery,
|
|
||||||
type TrainingDataSnippetView,
|
|
||||||
type TrainingSuggestionAdminView,
|
|
||||||
type TrainingSuggestionGroupView,
|
|
||||||
type UpdateTrainingSuggestionInput,
|
|
||||||
} from "@batch-cooking/shared";
|
|
||||||
import { prisma } from "../../db/prisma.js";
|
|
||||||
import {
|
|
||||||
MIN_OVERALL_F1,
|
|
||||||
runTechStepEvalSuite,
|
|
||||||
} from "../../lib/recipe-matching/tech-step-eval-runner.js";
|
|
||||||
import { backfillTechSteps } from "../../scripts/backfill-tech-steps.js";
|
|
||||||
|
|
||||||
/** How many raw corrections `listCorrections` returns per call — the browser is a triage view, not an export. */
|
|
||||||
const CORRECTIONS_PAGE_SIZE = 200;
|
|
||||||
|
|
||||||
const suggestionInclude = {
|
|
||||||
techStep: { select: { key: true } },
|
|
||||||
sourceCorrection: {
|
|
||||||
include: {
|
|
||||||
step: { select: { id: true, recipeId: true, description: true } },
|
|
||||||
previousTechStep: { select: { key: true } },
|
|
||||||
correctedTechStep: { select: { key: true } },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
type SuggestionRow = Awaited<
|
|
||||||
ReturnType<
|
|
||||||
typeof prisma.techStepTrainingSuggestion.findFirstOrThrow<{ include: typeof suggestionInclude }>
|
|
||||||
>
|
|
||||||
>;
|
|
||||||
|
|
||||||
/** Shapes one Prisma suggestion row (with {@link suggestionInclude}) into its admin view. */
|
|
||||||
function toSuggestionView(row: SuggestionRow): TrainingSuggestionAdminView {
|
|
||||||
const correction = row.sourceCorrection;
|
|
||||||
return {
|
|
||||||
id: row.id,
|
|
||||||
techStepKey: row.techStep.key,
|
|
||||||
locale: row.locale,
|
|
||||||
suggestedSynonyms: row.suggestedSynonyms,
|
|
||||||
suggestedUtterances: row.suggestedUtterances,
|
|
||||||
sourceType: row.sourceType,
|
|
||||||
status: row.status,
|
|
||||||
createdAt: row.createdAt.toISOString(),
|
|
||||||
sourceCorrection: correction
|
|
||||||
? {
|
|
||||||
id: correction.id,
|
|
||||||
recipeId: correction.step.recipeId,
|
|
||||||
stepId: correction.step.id,
|
|
||||||
clauseText: correction.step.description.slice(correction.start, correction.end),
|
|
||||||
previousTechStepKey: correction.previousTechStep?.key ?? null,
|
|
||||||
correctedTechStepKey: correction.correctedTechStep?.key ?? null,
|
|
||||||
}
|
|
||||||
: null,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Every `TechStepTrainingSuggestion` matching the (all-optional) filters,
|
|
||||||
* grouped by technique key — same "one block per technique" organisation
|
|
||||||
* as `list-pending-training-suggestions.ts`'s CLI report, which this UI
|
|
||||||
* replaces.
|
|
||||||
*/
|
|
||||||
export async function listSuggestions(
|
|
||||||
query: ListSuggestionsQuery,
|
|
||||||
): Promise<TrainingSuggestionGroupView[]> {
|
|
||||||
try {
|
|
||||||
const rows = await prisma.techStepTrainingSuggestion.findMany({
|
|
||||||
where: {
|
|
||||||
...(query.status ? { status: query.status } : {}),
|
|
||||||
...(query.sourceType ? { sourceType: query.sourceType } : {}),
|
|
||||||
...(query.locale ? { locale: query.locale } : {}),
|
|
||||||
...(query.techStepKey ? { techStep: { key: query.techStepKey } } : {}),
|
|
||||||
},
|
|
||||||
orderBy: [{ techStepId: "asc" }, { createdAt: "asc" }],
|
|
||||||
include: suggestionInclude,
|
|
||||||
});
|
|
||||||
|
|
||||||
const byKey = new Map<string, TrainingSuggestionAdminView[]>();
|
|
||||||
for (const row of rows) {
|
|
||||||
const view = toSuggestionView(row);
|
|
||||||
const group = byKey.get(view.techStepKey);
|
|
||||||
if (group) group.push(view);
|
|
||||||
else byKey.set(view.techStepKey, [view]);
|
|
||||||
}
|
|
||||||
return [...byKey.entries()]
|
|
||||||
.sort(([a], [b]) => a.localeCompare(b))
|
|
||||||
.map(([techStepKey, suggestions]) => ({ techStepKey, suggestions }));
|
|
||||||
} catch (err) {
|
|
||||||
throw err; // see recipe.service.ts's equivalent catch comment
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Raw `StepTechStepCorrection`s for the admin browser — newest first,
|
|
||||||
* capped at {@link CORRECTIONS_PAGE_SIZE}. Unlike the worker's own
|
|
||||||
* `getPendingCorrections`, this **includes** the `correctedTechStepId IS NULL`
|
|
||||||
* removals ("no technique here") that never become suggestions and are
|
|
||||||
* otherwise invisible.
|
|
||||||
*/
|
|
||||||
export async function listCorrections(query: ListCorrectionsQuery): Promise<CorrectionAdminView[]> {
|
|
||||||
try {
|
|
||||||
const consumedFilter =
|
|
||||||
query.consumed === "true"
|
|
||||||
? { consumedAt: { not: null } }
|
|
||||||
: query.consumed === "false"
|
|
||||||
? { consumedAt: null }
|
|
||||||
: {};
|
|
||||||
const correctedFilter =
|
|
||||||
query.hasCorrectedTechStep === "true"
|
|
||||||
? { correctedTechStepId: { not: null } }
|
|
||||||
: query.hasCorrectedTechStep === "false"
|
|
||||||
? { correctedTechStepId: null }
|
|
||||||
: {};
|
|
||||||
|
|
||||||
const rows = await prisma.stepTechStepCorrection.findMany({
|
|
||||||
where: { ...consumedFilter, ...correctedFilter },
|
|
||||||
orderBy: { createdAt: "desc" },
|
|
||||||
take: CORRECTIONS_PAGE_SIZE,
|
|
||||||
include: {
|
|
||||||
step: { select: { id: true, recipeId: true, description: true } },
|
|
||||||
previousTechStep: { select: { key: true } },
|
|
||||||
correctedTechStep: { select: { key: true } },
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return rows.map((row) => ({
|
|
||||||
id: row.id,
|
|
||||||
recipeId: row.step.recipeId,
|
|
||||||
stepId: row.step.id,
|
|
||||||
stepDescription: row.step.description,
|
|
||||||
clauseText: row.step.description.slice(row.start, row.end),
|
|
||||||
start: row.start,
|
|
||||||
end: row.end,
|
|
||||||
previousTechStepKey: row.previousTechStep?.key ?? null,
|
|
||||||
correctedTechStepKey: row.correctedTechStep?.key ?? null,
|
|
||||||
createdAt: row.createdAt.toISOString(),
|
|
||||||
consumedAt: row.consumedAt?.toISOString() ?? null,
|
|
||||||
}));
|
|
||||||
} catch (err) {
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Curates one suggestion — edit its proposed synonyms/utterances and/or
|
|
||||||
* flip its `status`. At least one field must be present.
|
|
||||||
*
|
|
||||||
* @throws {HttpError} `400 VALIDATION_ERROR` if the body is empty.
|
|
||||||
* @throws {HttpError} `404 NOT_FOUND` if `id` matches no suggestion.
|
|
||||||
*/
|
|
||||||
export async function updateSuggestion(
|
|
||||||
id: number,
|
|
||||||
input: UpdateTrainingSuggestionInput,
|
|
||||||
): Promise<TrainingSuggestionAdminView> {
|
|
||||||
try {
|
|
||||||
if (
|
|
||||||
input.status === undefined &&
|
|
||||||
input.suggestedSynonyms === undefined &&
|
|
||||||
input.suggestedUtterances === undefined
|
|
||||||
) {
|
|
||||||
throw new HttpError(400, ErrorCode.VALIDATION_ERROR, "Nothing to update");
|
|
||||||
}
|
|
||||||
|
|
||||||
const existing = await prisma.techStepTrainingSuggestion.findUnique({ where: { id } });
|
|
||||||
if (!existing) {
|
|
||||||
throw new HttpError(404, ErrorCode.NOT_FOUND, `Training suggestion ${id} not found`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const updated = await prisma.techStepTrainingSuggestion.update({
|
|
||||||
where: { id },
|
|
||||||
data: {
|
|
||||||
...(input.status !== undefined ? { status: input.status } : {}),
|
|
||||||
...(input.suggestedSynonyms !== undefined
|
|
||||||
? { suggestedSynonyms: input.suggestedSynonyms }
|
|
||||||
: {}),
|
|
||||||
...(input.suggestedUtterances !== undefined
|
|
||||||
? { suggestedUtterances: input.suggestedUtterances }
|
|
||||||
: {}),
|
|
||||||
},
|
|
||||||
include: suggestionInclude,
|
|
||||||
});
|
|
||||||
return toSuggestionView(updated);
|
|
||||||
} catch (err) {
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Deduplicates while preserving first-seen order — for pooling synonyms/utterances across suggestions. */
|
|
||||||
function dedupe(values: string[]): string[] {
|
|
||||||
return [...new Set(values.map((value) => value.trim()).filter((value) => value.length > 0))];
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Indents each entry as a Python list literal line (4-space, trailing comma) — the shape `training_data.py`'s blocks use. */
|
|
||||||
function pythonListBody(entries: string[]): string {
|
|
||||||
return entries.map((entry) => ` ${JSON.stringify(entry)},`).join("\n");
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Aggregates the synonyms/utterances of every suggestion matching
|
|
||||||
* `techStepKey` + `locale` + `status` into a ready-to-paste
|
|
||||||
* `training_data.py` block. **Read-only** — editing that Python file and
|
|
||||||
* restarting the intent-service stay a manual maintainer step.
|
|
||||||
*/
|
|
||||||
export async function getTrainingDataSnippet(
|
|
||||||
query: TrainingDataSnippetQuery,
|
|
||||||
): Promise<TrainingDataSnippetView> {
|
|
||||||
try {
|
|
||||||
const rows = await prisma.techStepTrainingSuggestion.findMany({
|
|
||||||
where: {
|
|
||||||
locale: query.locale,
|
|
||||||
status: query.status,
|
|
||||||
techStep: { key: query.techStepKey },
|
|
||||||
},
|
|
||||||
select: { suggestedSynonyms: true, suggestedUtterances: true },
|
|
||||||
});
|
|
||||||
|
|
||||||
const synonyms = dedupe(rows.flatMap((row) => row.suggestedSynonyms));
|
|
||||||
const utterances = dedupe(rows.flatMap((row) => row.suggestedUtterances));
|
|
||||||
|
|
||||||
const snippet = [
|
|
||||||
`# ${query.techStepKey} (${query.locale}) — ${rows.length} suggestion(s) "${query.status}"`,
|
|
||||||
`"synonyms": [`,
|
|
||||||
pythonListBody(synonyms),
|
|
||||||
`],`,
|
|
||||||
`"utterances": [`,
|
|
||||||
pythonListBody(utterances),
|
|
||||||
`],`,
|
|
||||||
]
|
|
||||||
.filter((line) => line.length > 0)
|
|
||||||
.join("\n");
|
|
||||||
|
|
||||||
return {
|
|
||||||
techStepKey: query.techStepKey,
|
|
||||||
locale: query.locale,
|
|
||||||
status: query.status,
|
|
||||||
suggestionCount: rows.length,
|
|
||||||
synonyms,
|
|
||||||
utterances,
|
|
||||||
snippet,
|
|
||||||
};
|
|
||||||
} catch (err) {
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Process-wide lock — the F1 gate + full backfill is heavy and must never run twice concurrently. */
|
|
||||||
let retrainInProgress = false;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Runs the training-corpus regression gate then, if it passes, backfills
|
|
||||||
* every step and marks the given suggestion ids — the same three steps as
|
|
||||||
* `scripts/retrain-tech-steps.ts`, callable from the admin UI.
|
|
||||||
*
|
|
||||||
* **Only meaningful after** a maintainer has hand-edited
|
|
||||||
* `services/tech-step-intent-service/intent_service/training_data.py` **and
|
|
||||||
* restarted that service** (it trains once at boot) — this endpoint can do
|
|
||||||
* neither, and the admin UI states that prominently.
|
|
||||||
*
|
|
||||||
* A failed gate returns `gatePassed: false` with no backfill / no marking
|
|
||||||
* (HTTP 200 — it's an expected outcome to show the operator, not an error).
|
|
||||||
*
|
|
||||||
* @throws {HttpError} `409 RETRAIN_ALREADY_RUNNING` if a retrain is already in flight.
|
|
||||||
*/
|
|
||||||
export async function runRetrain(input: RetrainRequestInput): Promise<RetrainResultView> {
|
|
||||||
if (retrainInProgress) {
|
|
||||||
throw new HttpError(
|
|
||||||
409,
|
|
||||||
ErrorCode.RETRAIN_ALREADY_RUNNING,
|
|
||||||
"A retrain (F1 gate + backfill) is already running",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
retrainInProgress = true;
|
|
||||||
try {
|
|
||||||
const { overall } = await runTechStepEvalSuite();
|
|
||||||
const gatePassed = overall.f1 >= MIN_OVERALL_F1;
|
|
||||||
|
|
||||||
let backfilled: RetrainResultView["backfilled"] = null;
|
|
||||||
const marked = { applied: 0, rejected: 0 };
|
|
||||||
|
|
||||||
if (gatePassed) {
|
|
||||||
backfilled = await backfillTechSteps();
|
|
||||||
|
|
||||||
const appliedIds = input.appliedIds ?? [];
|
|
||||||
const rejectedIds = input.rejectedIds ?? [];
|
|
||||||
if (appliedIds.length > 0) {
|
|
||||||
const { count } = await prisma.techStepTrainingSuggestion.updateMany({
|
|
||||||
where: { id: { in: appliedIds } },
|
|
||||||
data: { status: "applied" },
|
|
||||||
});
|
|
||||||
marked.applied = count;
|
|
||||||
}
|
|
||||||
if (rejectedIds.length > 0) {
|
|
||||||
const { count } = await prisma.techStepTrainingSuggestion.updateMany({
|
|
||||||
where: { id: { in: rejectedIds } },
|
|
||||||
data: { status: "rejected" },
|
|
||||||
});
|
|
||||||
marked.rejected = count;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
f1: overall.f1,
|
|
||||||
precision: overall.precision,
|
|
||||||
recall: overall.recall,
|
|
||||||
minF1: MIN_OVERALL_F1,
|
|
||||||
gatePassed,
|
|
||||||
backfilled,
|
|
||||||
marked,
|
|
||||||
};
|
|
||||||
} catch (err) {
|
|
||||||
throw err; // see recipe.service.ts's equivalent catch comment
|
|
||||||
} finally {
|
|
||||||
retrainInProgress = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,22 +1,13 @@
|
||||||
import { Router } from "express";
|
import { Router } from "express";
|
||||||
import { adminAuthRouter } from "./admin-auth.routes.js";
|
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`
|
* Aggregator for the admin application's API surface, mounted at `/admin`
|
||||||
* in `app.ts`. Every sub-router here backs `apps/web`'s `/admin/*` routes
|
* in `app.ts`. Every sub-router here is for `apps/admin-web` only —
|
||||||
* only — `/admin/auth` is public (login), everything added later
|
* `/admin/auth` is public (login), everything added later
|
||||||
* (`/admin/metrics`, `/admin/monitoring`, `/admin/tech-steps/*`,
|
* (`/admin/metrics`, `/admin/monitoring`, `/admin/tech-steps/*`) sits
|
||||||
* `/admin/catalog/*`) sits behind `requireAdmin`
|
* behind `requireAdmin` (`middlewares/require-admin.ts`).
|
||||||
* (`middlewares/require-admin.ts`).
|
|
||||||
*/
|
*/
|
||||||
export const adminRouter = Router();
|
export const adminRouter = Router();
|
||||||
|
|
||||||
adminRouter.use("/auth", adminAuthRouter);
|
adminRouter.use("/auth", adminAuthRouter);
|
||||||
adminRouter.use("/metrics", adminMetricsRouter);
|
|
||||||
adminRouter.use("/monitoring", adminMonitoringRouter);
|
|
||||||
adminRouter.use("/tech-steps", adminTechStepsRouter);
|
|
||||||
adminRouter.use("/catalog", adminCatalogRouter);
|
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,6 @@ import {
|
||||||
import argon2 from "argon2";
|
import argon2 from "argon2";
|
||||||
import { env } from "../../config/env.js";
|
import { env } from "../../config/env.js";
|
||||||
import { prisma } from "../../db/prisma.js";
|
import { prisma } from "../../db/prisma.js";
|
||||||
import { analytics } from "../../lib/analytics.service.js";
|
|
||||||
import { signAuthToken } from "../../lib/jwt.js";
|
import { signAuthToken } from "../../lib/jwt.js";
|
||||||
import { toSafeProfile } from "../../lib/safe-profile.js";
|
import { toSafeProfile } from "../../lib/safe-profile.js";
|
||||||
import { leaveCurrentHouse } from "../house/house.service.js";
|
import { leaveCurrentHouse } from "../house/house.service.js";
|
||||||
|
|
@ -59,8 +58,6 @@ export async function signup(input: SignupInput): Promise<AuthResult> {
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
analytics.recordEvent("user.signup", { actorId: profile.id });
|
|
||||||
|
|
||||||
const token = signAuthToken({
|
const token = signAuthToken({
|
||||||
userProfileId: profile.id,
|
userProfileId: profile.id,
|
||||||
tokenVersion: profile.tokenVersion,
|
tokenVersion: profile.tokenVersion,
|
||||||
|
|
|
||||||
|
|
@ -1,37 +0,0 @@
|
||||||
import { parseDateOnly } from "@batch-cooking/date-tools";
|
|
||||||
import { HttpError } from "@batch-cooking/error-tools";
|
|
||||||
import { wrapAsyncHandler } from "@batch-cooking/express-tools";
|
|
||||||
import { ErrorCode, getCookingSessionSchema } from "@batch-cooking/shared";
|
|
||||||
import { Router } from "express";
|
|
||||||
import { type AuthLocals, requireAuth } from "../../middlewares/require-auth.js";
|
|
||||||
import { getCookingPlanForDate } from "./cooking-session.service.js";
|
|
||||||
|
|
||||||
/** Router mounted at `/cooking-session` in app.ts. */
|
|
||||||
export const cookingSessionRouter = Router();
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns the authenticated user's household's optimized cooking plan for
|
|
||||||
* the week covering `?date=` (`YYYY-MM-DD`) — every recipe planned that
|
|
||||||
* week reorganized into ordered phases (see {@link getCookingPlanForDate}).
|
|
||||||
* Always `200`, never `null` — no household or nothing planned that week
|
|
||||||
* both come back as a normal `OptimizedCookingPlanView` with empty
|
|
||||||
* `recipes`/`phases`. Same request contract as `GET /shopping-list`.
|
|
||||||
*/
|
|
||||||
cookingSessionRouter.get(
|
|
||||||
"/",
|
|
||||||
requireAuth,
|
|
||||||
wrapAsyncHandler<unknown, AuthLocals>(async (req, res) => {
|
|
||||||
const input = getCookingSessionSchema.parse(req.query);
|
|
||||||
const date = parseDateOnly(input.date);
|
|
||||||
if (date === null) {
|
|
||||||
throw new HttpError(
|
|
||||||
400,
|
|
||||||
ErrorCode.VALIDATION_ERROR,
|
|
||||||
`Not a real calendar date: ${input.date}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const plan = await getCookingPlanForDate(res.locals.userProfile.houseId, date);
|
|
||||||
res.status(200).json(plan);
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
@ -1,168 +0,0 @@
|
||||||
import { type DateTime, getWeekStart, toDateOnly } from "@batch-cooking/date-tools";
|
|
||||||
import type { CookingTaskIngredientView, OptimizedCookingPlanView } from "@batch-cooking/shared";
|
|
||||||
import type { Prisma } from "@prisma/client";
|
|
||||||
import { prisma } from "../../db/prisma.js";
|
|
||||||
import {
|
|
||||||
type OptimizerRecipeInput,
|
|
||||||
type OptimizerStepInput,
|
|
||||||
optimizeCookingPlan,
|
|
||||||
} from "../../lib/recipe-matching/cooking-optimizer.js";
|
|
||||||
import { toIngredientView, toUnitView } from "../recipe/recipe.service.js";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Prisma `include` for a `Planning` query that needs, for every item, its
|
|
||||||
* recipe's ordered steps with the full detected-technique tree — the raw
|
|
||||||
* material the optimizer works on (see `cooking-optimizer.ts`). It's the
|
|
||||||
* `steps` sub-tree of `recipe.service.ts`'s own `recipeInclude`, resolved
|
|
||||||
* the same way so {@link toIngredientView}/{@link toUnitView} can be reused
|
|
||||||
* as-is; deliberately narrower than a full `RecipeView` fetch (no
|
|
||||||
* diets/favorites/recipe-level ingredient list — the optimizer reads
|
|
||||||
* quantities off the technique clauses, not the recipe header).
|
|
||||||
*/
|
|
||||||
function cookingSessionPlanningInclude() {
|
|
||||||
return {
|
|
||||||
items: {
|
|
||||||
include: {
|
|
||||||
recipe: {
|
|
||||||
select: {
|
|
||||||
id: true,
|
|
||||||
name: true,
|
|
||||||
portions: true,
|
|
||||||
steps: {
|
|
||||||
orderBy: { order: "asc" },
|
|
||||||
include: {
|
|
||||||
techSteps: {
|
|
||||||
orderBy: { order: "asc" },
|
|
||||||
include: {
|
|
||||||
techStep: true,
|
|
||||||
ingredients: {
|
|
||||||
include: {
|
|
||||||
ingredient: {
|
|
||||||
include: {
|
|
||||||
allergies: { include: { allergy: { include: { category: true } } } },
|
|
||||||
diets: { include: { diet: true } },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
unit: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
utensils: { include: { utensil: true } },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
} satisfies Prisma.PlanningInclude;
|
|
||||||
}
|
|
||||||
|
|
||||||
type PlanningWithSteps = Prisma.PlanningGetPayload<{
|
|
||||||
include: ReturnType<typeof cookingSessionPlanningInclude>;
|
|
||||||
}>;
|
|
||||||
type PlanningItemWithSteps = PlanningWithSteps["items"][number];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Maps one planning item's recipe (with {@link cookingSessionPlanningInclude})
|
|
||||||
* to the optimizer's pure input shape — ingredient/unit/technique/utensil
|
|
||||||
* rows resolved to their reference views here so the optimizer itself never
|
|
||||||
* touches Prisma. `Decimal` quantities become plain numbers (same
|
|
||||||
* `Number(...)` conversion as `recipe.service.ts`'s own view mappers); an
|
|
||||||
* unresolved-unit line keeps `unit: null`.
|
|
||||||
*/
|
|
||||||
function toOptimizerRecipe(item: PlanningItemWithSteps): OptimizerRecipeInput {
|
|
||||||
const steps: OptimizerStepInput[] = item.recipe.steps.map((step) => ({
|
|
||||||
stepId: step.id,
|
|
||||||
order: step.order,
|
|
||||||
description: step.description,
|
|
||||||
techSteps: step.techSteps.map((techStep) => {
|
|
||||||
const ingredients: CookingTaskIngredientView[] = techStep.ingredients.map((line) => ({
|
|
||||||
ingredient: toIngredientView(line.ingredient),
|
|
||||||
quantity: line.quantity === null ? null : Number(line.quantity),
|
|
||||||
unit: line.unit === null ? null : toUnitView(line.unit),
|
|
||||||
}));
|
|
||||||
return {
|
|
||||||
techStep: { id: techStep.techStep.id, key: techStep.techStep.key },
|
|
||||||
order: techStep.order,
|
|
||||||
ingredients,
|
|
||||||
utensils: techStep.utensils.map(({ utensil }) => ({ id: utensil.id, key: utensil.key })),
|
|
||||||
};
|
|
||||||
}),
|
|
||||||
}));
|
|
||||||
|
|
||||||
return {
|
|
||||||
recipeId: item.recipe.id,
|
|
||||||
name: item.recipe.name,
|
|
||||||
// The slot's own portion count vs. the recipe's as-written yield — the
|
|
||||||
// optimizer scales technique-clause quantities by the ratio, same
|
|
||||||
// reasoning as `shopping-list.service.ts`'s `aggregateShoppingList`.
|
|
||||||
portions: item.portions,
|
|
||||||
recipePortions: item.recipe.portions,
|
|
||||||
steps,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Builds the household's optimized cooking plan for the week covering
|
|
||||||
* `date` — every recipe planned that week, reorganized into ordered phases
|
|
||||||
* that pool shared prep and float passive cooks into the background (see
|
|
||||||
* `cooking-optimizer.ts`). `date` follows the same convention as
|
|
||||||
* `planning.service.ts`'s `getPlanningForDate` (a caller-parsed `?date=`,
|
|
||||||
* not necessarily a Monday).
|
|
||||||
*
|
|
||||||
* Like `getShoppingListForDate` and unlike `getPlanningForDate`, this
|
|
||||||
* **never** returns `null` — no household and "no planning covers this week
|
|
||||||
* yet" both degrade to an empty `phases`/`recipes` on an otherwise normal
|
|
||||||
* {@link OptimizedCookingPlanView} (the week's date range is always
|
|
||||||
* computable from `date` alone).
|
|
||||||
*/
|
|
||||||
export async function getCookingPlanForDate(
|
|
||||||
houseId: number | null,
|
|
||||||
date: DateTime,
|
|
||||||
): Promise<OptimizedCookingPlanView> {
|
|
||||||
try {
|
|
||||||
const weekStart = getWeekStart(toDateOnly(date));
|
|
||||||
const weekFinish = weekStart.plus({ days: 6 });
|
|
||||||
const emptyPlan: OptimizedCookingPlanView = {
|
|
||||||
startDate: weekStart.toJSDate().toISOString(),
|
|
||||||
finishDate: weekFinish.toJSDate().toISOString(),
|
|
||||||
recipes: [],
|
|
||||||
phases: [],
|
|
||||||
};
|
|
||||||
|
|
||||||
if (houseId === null) {
|
|
||||||
return emptyPlan;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Same "covering range" lookup as getShoppingListForDate — see
|
|
||||||
// getPlanningForDate's doc comment for the UTC-midnight `Date` rationale.
|
|
||||||
const dateOnly = toDateOnly(date).toJSDate();
|
|
||||||
const planning = await prisma.planning.findFirst({
|
|
||||||
where: {
|
|
||||||
houseId,
|
|
||||||
startDate: { lte: dateOnly },
|
|
||||||
finishDate: { gte: dateOnly },
|
|
||||||
},
|
|
||||||
orderBy: { startDate: "desc" },
|
|
||||||
include: cookingSessionPlanningInclude(),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!planning) {
|
|
||||||
return emptyPlan;
|
|
||||||
}
|
|
||||||
|
|
||||||
const { recipes, phases } = optimizeCookingPlan(planning.items.map(toOptimizerRecipe));
|
|
||||||
return {
|
|
||||||
startDate: planning.startDate.toISOString(),
|
|
||||||
finishDate: planning.finishDate.toISOString(),
|
|
||||||
recipes,
|
|
||||||
phases,
|
|
||||||
};
|
|
||||||
} catch (err) {
|
|
||||||
// Rethrown as-is — `wrapAsyncHandler`/the error middleware handles it,
|
|
||||||
// this service layer just isn't allowed a bare `await` per the repo's
|
|
||||||
// async/try-catch convention.
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -3,14 +3,12 @@ import {
|
||||||
auditBatchQuerySchema,
|
auditBatchQuerySchema,
|
||||||
submitTrainingSuggestionsSchema,
|
submitTrainingSuggestionsSchema,
|
||||||
workerBatchQuerySchema,
|
workerBatchQuerySchema,
|
||||||
workerHeartbeatSchema,
|
|
||||||
} from "@batch-cooking/shared";
|
} from "@batch-cooking/shared";
|
||||||
import { Router } from "express";
|
import { Router } from "express";
|
||||||
import { requireInternalWorker } from "../../middlewares/require-internal-worker.js";
|
import { requireInternalWorker } from "../../middlewares/require-internal-worker.js";
|
||||||
import {
|
import {
|
||||||
getAuditBatch,
|
getAuditBatch,
|
||||||
getPendingCorrections,
|
getPendingCorrections,
|
||||||
recordWorkerHeartbeat,
|
|
||||||
submitTrainingSuggestions,
|
submitTrainingSuggestions,
|
||||||
} from "./tech-step-worker.service.js";
|
} from "./tech-step-worker.service.js";
|
||||||
|
|
||||||
|
|
@ -49,19 +47,3 @@ techStepWorkerRouter.post(
|
||||||
res.status(201).json(await submitTrainingSuggestions(input));
|
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 });
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
|
||||||
|
|
@ -4,9 +4,7 @@ import {
|
||||||
type PendingTechStepCorrectionView,
|
type PendingTechStepCorrectionView,
|
||||||
type SubmitTrainingSuggestionsInput,
|
type SubmitTrainingSuggestionsInput,
|
||||||
type TechStepAuditClauseView,
|
type TechStepAuditClauseView,
|
||||||
type WorkerHeartbeatInput,
|
|
||||||
} from "@batch-cooking/shared";
|
} from "@batch-cooking/shared";
|
||||||
import type { Prisma } from "@prisma/client";
|
|
||||||
import { prisma } from "../../db/prisma.js";
|
import { prisma } from "../../db/prisma.js";
|
||||||
import {
|
import {
|
||||||
CONFIDENCE_THRESHOLD,
|
CONFIDENCE_THRESHOLD,
|
||||||
|
|
@ -201,44 +199,3 @@ export async function submitTrainingSuggestions(
|
||||||
throw err; // see recipe.service.ts's equivalent catch comment
|
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,6 @@ import {
|
||||||
type PlanningView,
|
type PlanningView,
|
||||||
} from "@batch-cooking/shared";
|
} from "@batch-cooking/shared";
|
||||||
import { prisma } from "../../db/prisma.js";
|
import { prisma } from "../../db/prisma.js";
|
||||||
import { analytics } from "../../lib/analytics.service.js";
|
|
||||||
import { assertRecipeVisible } from "../recipe/recipe.service.js";
|
import { assertRecipeVisible } from "../recipe/recipe.service.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -158,11 +157,6 @@ export async function addPlanningItem(
|
||||||
include: { recipe: { select: { id: true, name: true } } },
|
include: { recipe: { select: { id: true, name: true } } },
|
||||||
});
|
});
|
||||||
|
|
||||||
analytics.recordEvent("planning.item_added", {
|
|
||||||
actorId: viewerId,
|
|
||||||
context: { recipeId: input.recipeId, portions: input.portions },
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: item.id,
|
id: item.id,
|
||||||
weekDay: item.weekDay,
|
weekDay: item.weekDay,
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,6 @@ import {
|
||||||
} from "@batch-cooking/shared";
|
} from "@batch-cooking/shared";
|
||||||
import type { Prisma } from "@prisma/client";
|
import type { Prisma } from "@prisma/client";
|
||||||
import { prisma } from "../../db/prisma.js";
|
import { prisma } from "../../db/prisma.js";
|
||||||
import { analytics } from "../../lib/analytics.service.js";
|
|
||||||
import { assertRecipeVisible, toStepTechStepViews } from "./recipe.service.js";
|
import { assertRecipeVisible, toStepTechStepViews } from "./recipe.service.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -473,16 +472,6 @@ export async function submitTechStepCorrection(
|
||||||
return { correction: createdCorrection, techSteps: freshTechSteps };
|
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) };
|
return { correction: toCorrectionView(correction), techSteps: toStepTechStepViews(techSteps) };
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
throw err; // see loadVisibleStepOrThrow's catch comment
|
throw err; // see loadVisibleStepOrThrow's catch comment
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
import { randomUUID } from "node:crypto";
|
|
||||||
import { HttpError } from "@batch-cooking/error-tools";
|
import { HttpError } from "@batch-cooking/error-tools";
|
||||||
import {
|
import {
|
||||||
type AllergyView,
|
type AllergyView,
|
||||||
|
|
@ -15,7 +14,6 @@ import {
|
||||||
} from "@batch-cooking/shared";
|
} from "@batch-cooking/shared";
|
||||||
import type { Prisma } from "@prisma/client";
|
import type { Prisma } from "@prisma/client";
|
||||||
import { prisma } from "../../db/prisma.js";
|
import { prisma } from "../../db/prisma.js";
|
||||||
import { analytics } from "../../lib/analytics.service.js";
|
|
||||||
import {
|
import {
|
||||||
type TechStepMatch,
|
type TechStepMatch,
|
||||||
techStepClassifier,
|
techStepClassifier,
|
||||||
|
|
@ -110,12 +108,6 @@ export function toIngredientView(ingredient: IngredientWithDetails): IngredientV
|
||||||
kind: allergy.category.kind,
|
kind: allergy.category.kind,
|
||||||
})),
|
})),
|
||||||
diets: ingredient.diets.map(({ diet }) => ({ id: diet.id, key: diet.key })),
|
diets: ingredient.diets.map(({ diet }) => ({ id: diet.id, key: diet.key })),
|
||||||
// A placeholder line round-trips as a normal `Ingredient` with a real
|
|
||||||
// id — the frontend tells it apart by this flag (badge, no
|
|
||||||
// allergen/diet info) and shows `displayName` verbatim instead of
|
|
||||||
// looking up an i18n label that doesn't exist for a `placeholder:` key.
|
|
||||||
isPlaceholder: ingredient.isPlaceholder,
|
|
||||||
displayName: ingredient.displayName,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -549,69 +541,6 @@ async function matchStepsTechSteps<T extends { description: string }>(
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** One resolved ingredient line — every `placeholderName` has been turned into a real (placeholder) `ingredientId`, ready for a `RecipeIngredient` create. */
|
|
||||||
interface ResolvedIngredientLine {
|
|
||||||
ingredientId: number;
|
|
||||||
quantity: number;
|
|
||||||
unitId: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Turns each `input.ingredients` line into a {@link ResolvedIngredientLine}:
|
|
||||||
* a line that already carries an `ingredientId` (a catalog pick, or an
|
|
||||||
* existing placeholder round-tripping through an edit) passes through
|
|
||||||
* unchanged; a `placeholderName` line gets a fresh placeholder `Ingredient`
|
|
||||||
* row (`isPlaceholder: true`, a generated `placeholder:<uuid>` key, the
|
|
||||||
* typed text as `displayName`, stamped with `authorId`/now) created via
|
|
||||||
* `tx` — so it rolls back together with the recipe if anything later in the
|
|
||||||
* same transaction fails, never leaving an orphan behind.
|
|
||||||
*
|
|
||||||
* Returns the created placeholders separately so the caller can emit one
|
|
||||||
* `ingredient.placeholder_created` analytics event per row *after* the
|
|
||||||
* transaction commits (the recipe id it wants in the event context doesn't
|
|
||||||
* exist yet in here).
|
|
||||||
*/
|
|
||||||
async function resolveIngredientLines(
|
|
||||||
lines: CreateRecipeInput["ingredients"],
|
|
||||||
authorId: number,
|
|
||||||
tx: Prisma.TransactionClient,
|
|
||||||
): Promise<{
|
|
||||||
resolved: ResolvedIngredientLine[];
|
|
||||||
createdPlaceholders: { id: number; name: string }[];
|
|
||||||
}> {
|
|
||||||
try {
|
|
||||||
const resolved: ResolvedIngredientLine[] = [];
|
|
||||||
const createdPlaceholders: { id: number; name: string }[] = [];
|
|
||||||
for (const line of lines) {
|
|
||||||
if (line.ingredientId !== undefined) {
|
|
||||||
resolved.push({
|
|
||||||
ingredientId: line.ingredientId,
|
|
||||||
quantity: line.quantity,
|
|
||||||
unitId: line.unitId,
|
|
||||||
});
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
// `recipeIngredientInputSchema`'s refine guarantees the other branch.
|
|
||||||
const name = (line.placeholderName ?? "").trim();
|
|
||||||
const placeholder = await tx.ingredient.create({
|
|
||||||
data: {
|
|
||||||
key: `placeholder:${randomUUID()}`,
|
|
||||||
isPlaceholder: true,
|
|
||||||
displayName: name,
|
|
||||||
createdById: authorId,
|
|
||||||
createdAt: new Date(),
|
|
||||||
},
|
|
||||||
select: { id: true },
|
|
||||||
});
|
|
||||||
resolved.push({ ingredientId: placeholder.id, quantity: line.quantity, unitId: line.unitId });
|
|
||||||
createdPlaceholders.push({ id: placeholder.id, name });
|
|
||||||
}
|
|
||||||
return { resolved, createdPlaceholders };
|
|
||||||
} catch (err) {
|
|
||||||
throw err; // see suitableForHouseholdWhere()'s catch comment above
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function createRecipeInternal(
|
async function createRecipeInternal(
|
||||||
input: CreateRecipeInput,
|
input: CreateRecipeInput,
|
||||||
authorId: number,
|
authorId: number,
|
||||||
|
|
@ -619,9 +548,7 @@ async function createRecipeInternal(
|
||||||
source: { sourceId: number; externalId: string; locale: string } | null,
|
source: { sourceId: number; externalId: string; locale: string } | null,
|
||||||
): Promise<RecipeView> {
|
): Promise<RecipeView> {
|
||||||
try {
|
try {
|
||||||
await assertIngredientsExist(
|
await assertIngredientsExist(input.ingredients.map((i) => i.ingredientId));
|
||||||
input.ingredients.map((i) => i.ingredientId).filter((id): id is number => id !== undefined),
|
|
||||||
);
|
|
||||||
await assertUnitsExist(input.ingredients.map((i) => i.unitId));
|
await assertUnitsExist(input.ingredients.map((i) => i.unitId));
|
||||||
await assertDietsExist(input.dietIds);
|
await assertDietsExist(input.dietIds);
|
||||||
// Matched up front (one call per step, in parallel) rather than inline
|
// Matched up front (one call per step, in parallel) rather than inline
|
||||||
|
|
@ -634,16 +561,7 @@ async function createRecipeInternal(
|
||||||
source?.locale ?? DEFAULT_TECH_STEP_LOCALE,
|
source?.locale ?? DEFAULT_TECH_STEP_LOCALE,
|
||||||
);
|
);
|
||||||
|
|
||||||
// One transaction so the free-text placeholder `Ingredient` rows and the
|
const created = await prisma.recipe.create({
|
||||||
// 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,
|
|
||||||
tx,
|
|
||||||
);
|
|
||||||
const created = await tx.recipe.create({
|
|
||||||
data: {
|
data: {
|
||||||
name: input.name,
|
name: input.name,
|
||||||
description: input.description ?? null,
|
description: input.description ?? null,
|
||||||
|
|
@ -655,10 +573,10 @@ async function createRecipeInternal(
|
||||||
sourceId: source?.sourceId ?? null,
|
sourceId: source?.sourceId ?? null,
|
||||||
externalId: source?.externalId ?? null,
|
externalId: source?.externalId ?? null,
|
||||||
ingredients: {
|
ingredients: {
|
||||||
create: resolved.map((line) => ({
|
create: input.ingredients.map((ingredient) => ({
|
||||||
ingredientId: line.ingredientId,
|
ingredientId: ingredient.ingredientId,
|
||||||
quantity: line.quantity,
|
quantity: ingredient.quantity,
|
||||||
unitId: line.unitId,
|
unitId: ingredient.unitId,
|
||||||
})),
|
})),
|
||||||
},
|
},
|
||||||
steps: {
|
steps: {
|
||||||
|
|
@ -698,20 +616,6 @@ async function createRecipeInternal(
|
||||||
},
|
},
|
||||||
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);
|
return toRecipeView(created);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
throw err; // see suitableForHouseholdWhere()'s catch comment above
|
throw err; // see suitableForHouseholdWhere()'s catch comment above
|
||||||
|
|
@ -739,30 +643,16 @@ export async function updateRecipe(
|
||||||
): Promise<RecipeView> {
|
): Promise<RecipeView> {
|
||||||
try {
|
try {
|
||||||
await assertIsAuthor(id, viewerId, viewerHouseId);
|
await assertIsAuthor(id, viewerId, viewerHouseId);
|
||||||
await assertIngredientsExist(
|
await assertIngredientsExist(input.ingredients.map((i) => i.ingredientId));
|
||||||
input.ingredients
|
|
||||||
.map((i) => i.ingredientId)
|
|
||||||
.filter((ingredientId): ingredientId is number => ingredientId !== undefined),
|
|
||||||
);
|
|
||||||
await assertUnitsExist(input.ingredients.map((i) => i.unitId));
|
await assertUnitsExist(input.ingredients.map((i) => i.unitId));
|
||||||
await assertDietsExist(input.dietIds);
|
await assertDietsExist(input.dietIds);
|
||||||
const stepsWithTechSteps = await matchStepsTechSteps(input.steps, DEFAULT_TECH_STEP_LOCALE);
|
const stepsWithTechSteps = await matchStepsTechSteps(input.steps, DEFAULT_TECH_STEP_LOCALE);
|
||||||
|
|
||||||
// Interactive transaction (not the array form) so any new free-text
|
await prisma.$transaction([
|
||||||
// placeholder rows are created in the same atomic unit as the
|
prisma.recipeIngredient.deleteMany({ where: { recipeId: id } }),
|
||||||
// delete+recreate of the recipe's content. An *existing* placeholder
|
prisma.step.deleteMany({ where: { recipeId: id } }),
|
||||||
// line round-trips by its real `ingredientId` and is left untouched;
|
prisma.recipeDiet.deleteMany({ where: { recipeId: id } }),
|
||||||
// only a brand-new `placeholderName` line creates a row here.
|
prisma.recipe.update({
|
||||||
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 },
|
where: { id },
|
||||||
data: {
|
data: {
|
||||||
name: input.name,
|
name: input.name,
|
||||||
|
|
@ -771,10 +661,10 @@ export async function updateRecipe(
|
||||||
portions: input.portions,
|
portions: input.portions,
|
||||||
visibility: input.visibility,
|
visibility: input.visibility,
|
||||||
ingredients: {
|
ingredients: {
|
||||||
create: resolved.map((line) => ({
|
create: input.ingredients.map((ingredient) => ({
|
||||||
ingredientId: line.ingredientId,
|
ingredientId: ingredient.ingredientId,
|
||||||
quantity: line.quantity,
|
quantity: ingredient.quantity,
|
||||||
unitId: line.unitId,
|
unitId: ingredient.unitId,
|
||||||
})),
|
})),
|
||||||
},
|
},
|
||||||
steps: {
|
steps: {
|
||||||
|
|
@ -796,16 +686,8 @@ export async function updateRecipe(
|
||||||
},
|
},
|
||||||
diets: { create: input.dietIds.map((dietId) => ({ dietId })) },
|
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));
|
return toRecipeView(await findRecipeOrThrow(id, viewerId));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|
|
||||||
|
|
@ -133,16 +133,10 @@ export async function getSources(): Promise<SourceView[]> {
|
||||||
* and compatible diet regimes (see `IngredientDiet`) — same flattening
|
* and compatible diet regimes (see `IngredientDiet`) — same flattening
|
||||||
* approach as {@link getAllergies}. Ingredients with no linked
|
* approach as {@link getAllergies}. Ingredients with no linked
|
||||||
* allergen/diet come back with `allergens: []`/`diets: []`.
|
* 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[]> {
|
export async function getIngredients(): Promise<IngredientView[]> {
|
||||||
try {
|
try {
|
||||||
const ingredients = await prisma.ingredient.findMany({
|
const ingredients = await prisma.ingredient.findMany({
|
||||||
where: { isPlaceholder: false },
|
|
||||||
include: {
|
include: {
|
||||||
allergies: { include: { allergy: { include: { category: true } } } },
|
allergies: { include: { allergy: { include: { category: true } } } },
|
||||||
diets: { include: { diet: true } },
|
diets: { include: { diet: true } },
|
||||||
|
|
@ -165,9 +159,6 @@ export async function getIngredients(): Promise<IngredientView[]> {
|
||||||
id: diet.id,
|
id: diet.id,
|
||||||
key: diet.key,
|
key: diet.key,
|
||||||
})),
|
})),
|
||||||
// Always a real catalog row here (placeholders are filtered out above).
|
|
||||||
isPlaceholder: false,
|
|
||||||
displayName: null,
|
|
||||||
}));
|
}));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
throw err; // see getDiets()'s catch comment above
|
throw err; // see getDiets()'s catch comment above
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,6 @@ import { HttpError } from "@batch-cooking/error-tools";
|
||||||
import { wrapAsyncHandler } from "@batch-cooking/express-tools";
|
import { wrapAsyncHandler } from "@batch-cooking/express-tools";
|
||||||
import { ErrorCode, getShoppingListSchema } from "@batch-cooking/shared";
|
import { ErrorCode, getShoppingListSchema } from "@batch-cooking/shared";
|
||||||
import { Router } from "express";
|
import { Router } from "express";
|
||||||
import { analytics } from "../../lib/analytics.service.js";
|
|
||||||
import { type AuthLocals, requireAuth } from "../../middlewares/require-auth.js";
|
import { type AuthLocals, requireAuth } from "../../middlewares/require-auth.js";
|
||||||
import { getShoppingListForDate } from "./shopping-list.service.js";
|
import { getShoppingListForDate } from "./shopping-list.service.js";
|
||||||
|
|
||||||
|
|
@ -32,7 +31,6 @@ shoppingListRouter.get(
|
||||||
}
|
}
|
||||||
|
|
||||||
const shoppingList = await getShoppingListForDate(res.locals.userProfile.houseId, date);
|
const shoppingList = await getShoppingListForDate(res.locals.userProfile.houseId, date);
|
||||||
analytics.recordEvent("shopping_list.viewed", { actorId: res.locals.userProfile.id });
|
|
||||||
res.status(200).json(shoppingList);
|
res.status(200).json(shoppingList);
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,27 +0,0 @@
|
||||||
import { prisma } from "../db/prisma.js";
|
|
||||||
import { pruneOrphanPlaceholders } from "../modules/admin/admin-catalog.service.js";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Deletes every placeholder `Ingredient` row (`Ingredient.isPlaceholder`)
|
|
||||||
* that no recipe references any more — the debris left behind when a recipe
|
|
||||||
* edit drops a placeholder line (the `RecipeIngredient` row goes, the
|
|
||||||
* `Ingredient` row stays). The admin catalog view has a button for this
|
|
||||||
* too; this script is the same operation for a cron / one-off cleanup:
|
|
||||||
*
|
|
||||||
* pnpm --filter api exec tsx src/scripts/prune-orphan-placeholders.ts
|
|
||||||
*
|
|
||||||
* Delegates to `admin-catalog.service.ts` so the "what counts as an orphan"
|
|
||||||
* rule lives in exactly one place.
|
|
||||||
*/
|
|
||||||
async function main(): Promise<void> {
|
|
||||||
const { deleted } = await pruneOrphanPlaceholders();
|
|
||||||
console.info(`Pruned ${deleted} orphan placeholder ingredient(s).`);
|
|
||||||
}
|
|
||||||
|
|
||||||
main()
|
|
||||||
.then(() => prisma.$disconnect())
|
|
||||||
.catch(async (err) => {
|
|
||||||
console.error(err);
|
|
||||||
await prisma.$disconnect();
|
|
||||||
process.exit(1);
|
|
||||||
});
|
|
||||||
|
|
@ -48,7 +48,7 @@ export async function resetDatabase() {
|
||||||
"recipe_ingredient", "step_tech_step", "step", "tech_step",
|
"recipe_ingredient", "step_tech_step", "step", "tech_step",
|
||||||
"recipe", "ingredients", "sources", "unit",
|
"recipe", "ingredients", "sources", "unit",
|
||||||
"user_profiles", "diet", "house",
|
"user_profiles", "diet", "house",
|
||||||
"admin_users", "analytics_events", "worker_heartbeats"
|
"admin_users"
|
||||||
RESTART IDENTITY CASCADE;
|
RESTART IDENTITY CASCADE;
|
||||||
`);
|
`);
|
||||||
await seedReferenceData(prisma);
|
await seedReferenceData(prisma);
|
||||||
|
|
|
||||||
|
|
@ -1,32 +0,0 @@
|
||||||
import { expect } from "chai";
|
|
||||||
import { normalizePlaceholderName } from "../src/modules/admin/admin-catalog.service.js";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Pure unit tests for {@link normalizePlaceholderName} — the grouping key
|
|
||||||
* that collapses near-duplicate placeholder spellings into one catalog gap.
|
|
||||||
* No database, so this file can run standalone (`mocha --no-config`) as
|
|
||||||
* well as inside the full suite.
|
|
||||||
*/
|
|
||||||
describe("normalizePlaceholderName", () => {
|
|
||||||
it("lower-cases, strips accents and collapses whitespace", () => {
|
|
||||||
expect(normalizePlaceholderName(" Piment d'Espelette ")).to.equal("piment d espelette");
|
|
||||||
expect(normalizePlaceholderName("PIMENT D’ESPELETTE")).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("");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,141 +0,0 @@
|
||||||
import { faker } from "@faker-js/faker";
|
|
||||||
import { expect } from "chai";
|
|
||||||
import request from "supertest";
|
|
||||||
import { createApp } from "../src/app.js";
|
|
||||||
import { prisma } from "../src/db/prisma.js";
|
|
||||||
import { hashAdminPassword } from "../src/modules/admin/admin-auth.service.js";
|
|
||||||
import { resetDatabase } from "../test-support/reset-db.js";
|
|
||||||
|
|
||||||
async function seedAdmin(): Promise<{ email: string; password: string }> {
|
|
||||||
const email = faker.internet.email().toLowerCase();
|
|
||||||
const password = faker.internet.password({ length: 16 });
|
|
||||||
await prisma.adminUser.create({
|
|
||||||
data: { email, name: faker.person.fullName(), passwordHash: await hashAdminPassword(password) },
|
|
||||||
});
|
|
||||||
return { email, password };
|
|
||||||
}
|
|
||||||
|
|
||||||
/** A recipe with one placeholder ingredient line whose `displayName` is `name`. Returns the placeholder `Ingredient` id. */
|
|
||||||
async function seedPlaceholderRecipe(name: string): Promise<number> {
|
|
||||||
const author = await prisma.userProfile.create({
|
|
||||||
data: {
|
|
||||||
firstName: "T",
|
|
||||||
lastName: "A",
|
|
||||||
email: `${faker.string.uuid()}@example.test`,
|
|
||||||
passwordHash: "x",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const unit = await prisma.unit.findFirstOrThrow({ where: { key: "piece" } });
|
|
||||||
const placeholder = await prisma.ingredient.create({
|
|
||||||
data: {
|
|
||||||
key: `placeholder:${faker.string.uuid()}`,
|
|
||||||
isPlaceholder: true,
|
|
||||||
displayName: name,
|
|
||||||
createdById: author.id,
|
|
||||||
createdAt: new Date(),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
await prisma.recipe.create({
|
|
||||||
data: {
|
|
||||||
name: faker.lorem.words(3),
|
|
||||||
authorId: author.id,
|
|
||||||
portions: 2,
|
|
||||||
ingredients: { create: [{ ingredientId: placeholder.id, quantity: 1, unitId: unit.id }] },
|
|
||||||
},
|
|
||||||
});
|
|
||||||
return placeholder.id;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* `/admin/catalog/*` — the off-catalog ingredient review. Every route is
|
|
||||||
* behind `requireAdmin`; the list groups placeholder rows by normalized
|
|
||||||
* name, `mark-reviewed` stamps `reviewedAt`, `prune-orphans` deletes rows
|
|
||||||
* no recipe references any more.
|
|
||||||
*/
|
|
||||||
describe("Admin catalog — off-catalog ingredients", () => {
|
|
||||||
const app = createApp();
|
|
||||||
|
|
||||||
beforeEach(async () => {
|
|
||||||
await resetDatabase();
|
|
||||||
});
|
|
||||||
|
|
||||||
after(async () => {
|
|
||||||
await prisma.$disconnect();
|
|
||||||
});
|
|
||||||
|
|
||||||
async function adminAgent() {
|
|
||||||
const { email, password } = await seedAdmin();
|
|
||||||
const agent = request.agent(app);
|
|
||||||
await agent.post("/admin/auth/login").send({ email, password });
|
|
||||||
return agent;
|
|
||||||
}
|
|
||||||
|
|
||||||
it("rejects every route without an admin session", async () => {
|
|
||||||
const get = await request(app).get("/admin/catalog/placeholders");
|
|
||||||
expect(get.status).to.equal(401);
|
|
||||||
const patch = await request(app)
|
|
||||||
.patch("/admin/catalog/placeholders/mark-reviewed")
|
|
||||||
.send({ ingredientIds: [1] });
|
|
||||||
expect(patch.status).to.equal(401);
|
|
||||||
const post = await request(app).post("/admin/catalog/placeholders/prune-orphans");
|
|
||||||
expect(post.status).to.equal(401);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("groups two spellings of the same missing ingredient into one row", async () => {
|
|
||||||
await seedPlaceholderRecipe("Piment d'Espelette");
|
|
||||||
await seedPlaceholderRecipe("piment d espelette");
|
|
||||||
await seedPlaceholderRecipe("Sumac");
|
|
||||||
|
|
||||||
const agent = await adminAgent();
|
|
||||||
const res = await agent.get("/admin/catalog/placeholders");
|
|
||||||
expect(res.status).to.equal(200);
|
|
||||||
expect(res.body).to.have.length(2);
|
|
||||||
|
|
||||||
const espelette = res.body.find(
|
|
||||||
(g: { normalizedName: string }) => g.normalizedName === "piment d espelette",
|
|
||||||
);
|
|
||||||
expect(espelette.recipeCount).to.equal(2);
|
|
||||||
expect(espelette.ingredientIds).to.have.length(2);
|
|
||||||
expect(espelette.displayNames).to.have.members(["Piment d'Espelette", "piment d espelette"]);
|
|
||||||
// Impact-ordered: the 2-recipe gap before the 1-recipe one.
|
|
||||||
expect(res.body[0].normalizedName).to.equal("piment d espelette");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("mark-reviewed stamps reviewedAt and moves the group out of the default list", async () => {
|
|
||||||
const id = await seedPlaceholderRecipe("Galanga");
|
|
||||||
const agent = await adminAgent();
|
|
||||||
|
|
||||||
const patched = await agent
|
|
||||||
.patch("/admin/catalog/placeholders/mark-reviewed")
|
|
||||||
.send({ ingredientIds: [id] });
|
|
||||||
expect(patched.status).to.equal(200);
|
|
||||||
expect(patched.body.reviewed).to.equal(1);
|
|
||||||
expect(
|
|
||||||
(await prisma.ingredient.findUniqueOrThrow({ where: { id } })).reviewedAt,
|
|
||||||
).to.be.an.instanceOf(Date);
|
|
||||||
|
|
||||||
const pending = await agent.get("/admin/catalog/placeholders");
|
|
||||||
expect(pending.body).to.have.length(0);
|
|
||||||
const reviewed = await agent.get("/admin/catalog/placeholders").query({ reviewed: "true" });
|
|
||||||
expect(reviewed.body).to.have.length(1);
|
|
||||||
expect(reviewed.body[0].allReviewed).to.equal(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("prune-orphans deletes only placeholder rows with no recipe left", async () => {
|
|
||||||
await seedPlaceholderRecipe("Encore utilisé");
|
|
||||||
await prisma.ingredient.create({
|
|
||||||
data: {
|
|
||||||
key: `placeholder:${faker.string.uuid()}`,
|
|
||||||
isPlaceholder: true,
|
|
||||||
displayName: "Orphelin",
|
|
||||||
createdAt: new Date(),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const agent = await adminAgent();
|
|
||||||
const res = await agent.post("/admin/catalog/placeholders/prune-orphans");
|
|
||||||
expect(res.status).to.equal(200);
|
|
||||||
expect(res.body.deleted).to.equal(1);
|
|
||||||
expect(await prisma.ingredient.count({ where: { isPlaceholder: true } })).to.equal(1);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,149 +0,0 @@
|
||||||
import type { SignupInput } from "@batch-cooking/shared";
|
|
||||||
import { faker } from "@faker-js/faker";
|
|
||||||
import { expect } from "chai";
|
|
||||||
import request from "supertest";
|
|
||||||
import { createApp } from "../src/app.js";
|
|
||||||
import { env } from "../src/config/env.js";
|
|
||||||
import { prisma } from "../src/db/prisma.js";
|
|
||||||
import { hashAdminPassword } from "../src/modules/admin/admin-auth.service.js";
|
|
||||||
import { bucketByDay } from "../src/modules/admin/admin-metrics.service.js";
|
|
||||||
import { resetDatabase } from "../test-support/reset-db.js";
|
|
||||||
|
|
||||||
function buildSignupPayload(): SignupInput {
|
|
||||||
const firstName = faker.person.firstName();
|
|
||||||
const lastName = faker.person.lastName();
|
|
||||||
return {
|
|
||||||
firstName,
|
|
||||||
lastName,
|
|
||||||
email: faker.internet.email({ firstName, lastName }).toLowerCase(),
|
|
||||||
password: faker.internet.password({ length: 16 }),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async function seedAdmin(): Promise<{ email: string; password: string }> {
|
|
||||||
const email = faker.internet.email().toLowerCase();
|
|
||||||
const password = faker.internet.password({ length: 16 });
|
|
||||||
await prisma.adminUser.create({
|
|
||||||
data: { email, name: faker.person.fullName(), passwordHash: await hashAdminPassword(password) },
|
|
||||||
});
|
|
||||||
return { email, password };
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Retries `check` until it stops throwing or `timeoutMs` elapses — `analytics.recordEvent` writes its row fire-and-forget, so a test observing it has to poll briefly. */
|
|
||||||
async function eventually(check: () => Promise<void>, timeoutMs = 2000): Promise<void> {
|
|
||||||
const start = Date.now();
|
|
||||||
for (;;) {
|
|
||||||
try {
|
|
||||||
await check();
|
|
||||||
return;
|
|
||||||
} catch (err) {
|
|
||||||
if (Date.now() - start > timeoutMs) throw err;
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const adminSecretConfigured = env.ADMIN_JWT_SECRET !== undefined;
|
|
||||||
|
|
||||||
describe("Admin metrics", () => {
|
|
||||||
describe("bucketByDay (pure)", () => {
|
|
||||||
const since = new Date("2026-08-01T00:00:00.000Z");
|
|
||||||
|
|
||||||
it("returns one zero-filled bucket per day, in date order", () => {
|
|
||||||
const result = bucketByDay([], since, 3);
|
|
||||||
expect(result).to.deep.equal([
|
|
||||||
{ date: "2026-08-01", count: 0 },
|
|
||||||
{ date: "2026-08-02", count: 0 },
|
|
||||||
{ date: "2026-08-03", count: 0 },
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("counts dates into their UTC day and ignores dates outside the window", () => {
|
|
||||||
const result = bucketByDay(
|
|
||||||
[
|
|
||||||
new Date("2026-08-01T09:00:00Z"),
|
|
||||||
new Date("2026-08-01T23:30:00Z"),
|
|
||||||
new Date("2026-08-03T00:00:00Z"),
|
|
||||||
new Date("2026-07-31T23:59:59Z"), // before the window
|
|
||||||
new Date("2026-08-10T00:00:00Z"), // after the window
|
|
||||||
],
|
|
||||||
since,
|
|
||||||
3,
|
|
||||||
);
|
|
||||||
expect(result).to.deep.equal([
|
|
||||||
{ date: "2026-08-01", count: 2 },
|
|
||||||
{ date: "2026-08-02", count: 0 },
|
|
||||||
{ date: "2026-08-03", count: 1 },
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("GET /admin/metrics", () => {
|
|
||||||
const app = createApp();
|
|
||||||
|
|
||||||
beforeEach(async () => {
|
|
||||||
await resetDatabase();
|
|
||||||
});
|
|
||||||
|
|
||||||
after(async () => {
|
|
||||||
await prisma.$disconnect();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rejects a request with no admin session with 401", async () => {
|
|
||||||
const res = await request(app).get("/admin/metrics");
|
|
||||||
expect(res.status).to.equal(401);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns a snapshot reflecting seeded data, plus zero-filled series", async function () {
|
|
||||||
if (!adminSecretConfigured) {
|
|
||||||
// biome-ignore lint/suspicious/noExplicitAny: mocha's `this.skip()` isn't typed here.
|
|
||||||
(this as any).skip();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const { email, password } = await seedAdmin();
|
|
||||||
|
|
||||||
// Two end users sign up (also emits `user.signup` analytics events).
|
|
||||||
const userA = request.agent(app);
|
|
||||||
const userB = request.agent(app);
|
|
||||||
await userA.post("/auth/signup").send(buildSignupPayload());
|
|
||||||
await userB.post("/auth/signup").send(buildSignupPayload());
|
|
||||||
|
|
||||||
const adminAgent = request.agent(app);
|
|
||||||
await adminAgent.post("/admin/auth/login").send({ email, password });
|
|
||||||
|
|
||||||
const res = await adminAgent.get("/admin/metrics").query({ days: 14 });
|
|
||||||
expect(res.status).to.equal(200);
|
|
||||||
expect(res.body.rangeDays).to.equal(14);
|
|
||||||
expect(res.body.snapshot.users).to.equal(2);
|
|
||||||
expect(res.body.snapshot.admins).to.equal(1);
|
|
||||||
expect(res.body.snapshot.recipes).to.equal(0);
|
|
||||||
|
|
||||||
// 14 daily buckets, each series zero-filled to that length.
|
|
||||||
expect(res.body.series.signups).to.have.length(14);
|
|
||||||
expect(
|
|
||||||
res.body.series.signups.every((b: { count: number }) => typeof b.count === "number"),
|
|
||||||
).to.equal(true);
|
|
||||||
// Two signups today → the last bucket counts them.
|
|
||||||
const signupTotal = res.body.series.signups.reduce(
|
|
||||||
(sum: number, b: { count: number }) => sum + b.count,
|
|
||||||
0,
|
|
||||||
);
|
|
||||||
expect(signupTotal).to.equal(2);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("records a user.signup analytics event (fire-and-forget, never blocks signup)", async () => {
|
|
||||||
const agent = request.agent(app);
|
|
||||||
const signupRes = await agent.post("/auth/signup").send(buildSignupPayload());
|
|
||||||
expect(signupRes.status).to.equal(201);
|
|
||||||
|
|
||||||
// `>= 1`, not `=== 1`: `recordEvent` is fire-and-forget, so an insert
|
|
||||||
// from an earlier test's signup could in principle land in this
|
|
||||||
// window too — the point here is that the instrumentation fires and
|
|
||||||
// the signup itself was never blocked by it.
|
|
||||||
await eventually(async () => {
|
|
||||||
const count = await prisma.analyticsEvent.count({ where: { type: "user.signup" } });
|
|
||||||
expect(count).to.be.greaterThan(0);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,160 +0,0 @@
|
||||||
import { faker } from "@faker-js/faker";
|
|
||||||
import { expect } from "chai";
|
|
||||||
import request from "supertest";
|
|
||||||
import { createApp } from "../src/app.js";
|
|
||||||
import { env } from "../src/config/env.js";
|
|
||||||
import { prisma } from "../src/db/prisma.js";
|
|
||||||
import { hashAdminPassword } from "../src/modules/admin/admin-auth.service.js";
|
|
||||||
import { resetDatabase } from "../test-support/reset-db.js";
|
|
||||||
|
|
||||||
const SECRET_HEADER = "X-Internal-Worker-Secret";
|
|
||||||
const VALID_STATUSES = ["up", "degraded", "down", "unknown"];
|
|
||||||
|
|
||||||
async function seedAdmin(): Promise<{ email: string; password: string }> {
|
|
||||||
const email = faker.internet.email().toLowerCase();
|
|
||||||
const password = faker.internet.password({ length: 16 });
|
|
||||||
await prisma.adminUser.create({
|
|
||||||
data: { email, name: faker.person.fullName(), passwordHash: await hashAdminPassword(password) },
|
|
||||||
});
|
|
||||||
return { email, password };
|
|
||||||
}
|
|
||||||
|
|
||||||
const adminSecretConfigured = env.ADMIN_JWT_SECRET !== undefined;
|
|
||||||
const workerSecretConfigured = env.INTERNAL_WORKER_SECRET !== undefined;
|
|
||||||
|
|
||||||
describe("Admin monitoring", () => {
|
|
||||||
const app = createApp();
|
|
||||||
|
|
||||||
beforeEach(async () => {
|
|
||||||
await resetDatabase();
|
|
||||||
});
|
|
||||||
|
|
||||||
after(async () => {
|
|
||||||
await prisma.$disconnect();
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("POST /internal/tech-steps/heartbeat", () => {
|
|
||||||
it("rejects a request with no worker secret with 401", async () => {
|
|
||||||
const res = await request(app).post("/internal/tech-steps/heartbeat").send({ event: "boot" });
|
|
||||||
expect(res.status).to.equal(401);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rejects a malformed body with 400", async function () {
|
|
||||||
if (!workerSecretConfigured) {
|
|
||||||
// biome-ignore lint/suspicious/noExplicitAny: mocha's `this.skip()` isn't typed here.
|
|
||||||
(this as any).skip();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const res = await request(app)
|
|
||||||
.post("/internal/tech-steps/heartbeat")
|
|
||||||
.set(SECRET_HEADER, env.INTERNAL_WORKER_SECRET as string)
|
|
||||||
.send({ event: "not-a-real-event" });
|
|
||||||
expect(res.status).to.equal(400);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("upserts the worker heartbeat, recording lastRunAt/lastResult for a job ping", async function () {
|
|
||||||
if (!workerSecretConfigured) {
|
|
||||||
// biome-ignore lint/suspicious/noExplicitAny: see above.
|
|
||||||
(this as any).skip();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const res = await request(app)
|
|
||||||
.post("/internal/tech-steps/heartbeat")
|
|
||||||
.set(SECRET_HEADER, env.INTERNAL_WORKER_SECRET as string)
|
|
||||||
.send({
|
|
||||||
event: "job",
|
|
||||||
job: "audit-low-confidence",
|
|
||||||
ok: true,
|
|
||||||
counts: { suggestions: 3 },
|
|
||||||
});
|
|
||||||
expect(res.status).to.equal(200);
|
|
||||||
expect(res.body).to.deep.equal({ ok: true });
|
|
||||||
|
|
||||||
const stored = await prisma.workerHeartbeat.findUniqueOrThrow({
|
|
||||||
where: { workerKey: "tech-step-llm-worker" },
|
|
||||||
});
|
|
||||||
expect(stored.lastRunAt).to.not.equal(null);
|
|
||||||
expect(stored.lastResult).to.deep.equal({
|
|
||||||
job: "audit-low-confidence",
|
|
||||||
ok: true,
|
|
||||||
counts: { suggestions: 3 },
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("leaves lastRunAt null for a boot/tick ping", async function () {
|
|
||||||
if (!workerSecretConfigured) {
|
|
||||||
// biome-ignore lint/suspicious/noExplicitAny: see above.
|
|
||||||
(this as any).skip();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
await request(app)
|
|
||||||
.post("/internal/tech-steps/heartbeat")
|
|
||||||
.set(SECRET_HEADER, env.INTERNAL_WORKER_SECRET as string)
|
|
||||||
.send({ event: "boot" });
|
|
||||||
|
|
||||||
const stored = await prisma.workerHeartbeat.findUniqueOrThrow({
|
|
||||||
where: { workerKey: "tech-step-llm-worker" },
|
|
||||||
});
|
|
||||||
expect(stored.lastSeenAt).to.be.instanceOf(Date);
|
|
||||||
expect(stored.lastRunAt).to.equal(null);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("GET /admin/monitoring", () => {
|
|
||||||
it("rejects a request with no admin session with 401", async () => {
|
|
||||||
const res = await request(app).get("/admin/monitoring");
|
|
||||||
expect(res.status).to.equal(401);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns a status board covering all four targets, never crashing on an unreachable probe", async function () {
|
|
||||||
if (!adminSecretConfigured) {
|
|
||||||
// biome-ignore lint/suspicious/noExplicitAny: see above.
|
|
||||||
(this as any).skip();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const { email, password } = await seedAdmin();
|
|
||||||
const agent = request.agent(app);
|
|
||||||
await agent.post("/admin/auth/login").send({ email, password });
|
|
||||||
|
|
||||||
const res = await agent.get("/admin/monitoring");
|
|
||||||
expect(res.status).to.equal(200);
|
|
||||||
|
|
||||||
const keys = res.body.services.map((s: { key: string }) => s.key);
|
|
||||||
expect(keys).to.have.members(["postgres", "api", "intent-service", "tech-step-llm-worker"]);
|
|
||||||
for (const service of res.body.services) {
|
|
||||||
expect(VALID_STATUSES).to.include(service.status);
|
|
||||||
}
|
|
||||||
|
|
||||||
const byKey = Object.fromEntries(res.body.services.map((s: { key: string }) => [s.key, s]));
|
|
||||||
// The DB is up during the test run, and the API is answering us.
|
|
||||||
expect(byKey.postgres.status).to.equal("up");
|
|
||||||
expect(byKey.api.status).to.equal("up");
|
|
||||||
// No heartbeat has ever been recorded (resetDatabase truncated it).
|
|
||||||
expect(byKey["tech-step-llm-worker"].status).to.equal("unknown");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("reports the worker as up once it has sent a recent heartbeat", async function () {
|
|
||||||
if (!adminSecretConfigured || !workerSecretConfigured) {
|
|
||||||
// biome-ignore lint/suspicious/noExplicitAny: see above.
|
|
||||||
(this as any).skip();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
await request(app)
|
|
||||||
.post("/internal/tech-steps/heartbeat")
|
|
||||||
.set(SECRET_HEADER, env.INTERNAL_WORKER_SECRET as string)
|
|
||||||
.send({ event: "job", job: "transform-corrections", ok: true, counts: { suggestions: 0 } });
|
|
||||||
|
|
||||||
const { email, password } = await seedAdmin();
|
|
||||||
const agent = request.agent(app);
|
|
||||||
await agent.post("/admin/auth/login").send({ email, password });
|
|
||||||
|
|
||||||
const res = await agent.get("/admin/monitoring");
|
|
||||||
const worker = res.body.services.find(
|
|
||||||
(s: { key: string }) => s.key === "tech-step-llm-worker",
|
|
||||||
);
|
|
||||||
expect(worker.status).to.equal("up");
|
|
||||||
expect(worker.lastResult.job).to.equal("transform-corrections");
|
|
||||||
expect(worker.lastRunAt).to.be.a("string");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,308 +0,0 @@
|
||||||
import { ErrorCode } from "@batch-cooking/shared";
|
|
||||||
import { faker } from "@faker-js/faker";
|
|
||||||
import { expect } from "chai";
|
|
||||||
import request from "supertest";
|
|
||||||
import { createApp } from "../src/app.js";
|
|
||||||
import { env } from "../src/config/env.js";
|
|
||||||
import { prisma } from "../src/db/prisma.js";
|
|
||||||
import { hashAdminPassword } from "../src/modules/admin/admin-auth.service.js";
|
|
||||||
import { resetDatabase } from "../test-support/reset-db.js";
|
|
||||||
|
|
||||||
async function seedAdmin(): Promise<{ email: string; password: string }> {
|
|
||||||
const email = faker.internet.email().toLowerCase();
|
|
||||||
const password = faker.internet.password({ length: 16 });
|
|
||||||
await prisma.adminUser.create({
|
|
||||||
data: { email, name: faker.person.fullName(), passwordHash: await hashAdminPassword(password) },
|
|
||||||
});
|
|
||||||
return { email, password };
|
|
||||||
}
|
|
||||||
|
|
||||||
async function techStepId(key: string): Promise<number> {
|
|
||||||
return (await prisma.techStep.findFirstOrThrow({ where: { key } })).id;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** A recipe + one step + one correction on it, optionally already turned into a suggestion. */
|
|
||||||
async function seedCorrectionAndSuggestion(options: {
|
|
||||||
clause: string;
|
|
||||||
correctedKey: string | null;
|
|
||||||
withSuggestion?: { status: string; synonyms: string[] };
|
|
||||||
}) {
|
|
||||||
const author = await prisma.userProfile.create({
|
|
||||||
data: {
|
|
||||||
firstName: "T",
|
|
||||||
lastName: "A",
|
|
||||||
email: `${faker.string.uuid()}@example.test`,
|
|
||||||
passwordHash: "x",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const recipe = await prisma.recipe.create({
|
|
||||||
data: {
|
|
||||||
name: "R",
|
|
||||||
authorId: author.id,
|
|
||||||
portions: 4,
|
|
||||||
steps: { create: [{ description: options.clause, order: 0 }] },
|
|
||||||
},
|
|
||||||
include: { steps: true },
|
|
||||||
});
|
|
||||||
const step = recipe.steps[0];
|
|
||||||
if (!step) throw new Error("expected a step");
|
|
||||||
|
|
||||||
const correctedKey = options.correctedKey;
|
|
||||||
const correction = await prisma.stepTechStepCorrection.create({
|
|
||||||
data: {
|
|
||||||
stepId: step.id,
|
|
||||||
correctorId: author.id,
|
|
||||||
start: 0,
|
|
||||||
end: options.clause.length,
|
|
||||||
previousTechStepId: null,
|
|
||||||
correctedTechStepId: correctedKey === null ? null : await techStepId(correctedKey),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
let suggestion: { id: number } | null = null;
|
|
||||||
if (options.withSuggestion && correctedKey !== null) {
|
|
||||||
suggestion = await prisma.techStepTrainingSuggestion.create({
|
|
||||||
data: {
|
|
||||||
techStepId: await techStepId(correctedKey),
|
|
||||||
locale: "fr",
|
|
||||||
suggestedSynonyms: options.withSuggestion.synonyms,
|
|
||||||
suggestedUtterances: [],
|
|
||||||
sourceType: "correction",
|
|
||||||
sourceCorrectionId: correction.id,
|
|
||||||
status: options.withSuggestion.status,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return { recipeId: recipe.id, stepId: step.id, correctionId: correction.id, suggestion };
|
|
||||||
}
|
|
||||||
|
|
||||||
const adminSecretConfigured = env.ADMIN_JWT_SECRET !== undefined;
|
|
||||||
|
|
||||||
describe("Admin tech-steps triage", () => {
|
|
||||||
const app = createApp();
|
|
||||||
|
|
||||||
beforeEach(async () => {
|
|
||||||
await resetDatabase();
|
|
||||||
});
|
|
||||||
|
|
||||||
after(async () => {
|
|
||||||
await prisma.$disconnect();
|
|
||||||
});
|
|
||||||
|
|
||||||
async function adminAgent() {
|
|
||||||
const { email, password } = await seedAdmin();
|
|
||||||
const agent = request.agent(app);
|
|
||||||
await agent.post("/admin/auth/login").send({ email, password });
|
|
||||||
return agent;
|
|
||||||
}
|
|
||||||
|
|
||||||
it("rejects every route without an admin session", async () => {
|
|
||||||
for (const path of [
|
|
||||||
"/admin/tech-steps/suggestions",
|
|
||||||
"/admin/tech-steps/corrections",
|
|
||||||
"/admin/tech-steps/training-data-snippet?techStepKey=simmer",
|
|
||||||
]) {
|
|
||||||
const res = await request(app).get(path);
|
|
||||||
expect(res.status, path).to.equal(401);
|
|
||||||
}
|
|
||||||
const post = await request(app).post("/admin/tech-steps/retrain").send({});
|
|
||||||
expect(post.status).to.equal(401);
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("GET /suggestions", () => {
|
|
||||||
it("groups suggestions by technique and filters by status", async function () {
|
|
||||||
if (!adminSecretConfigured) {
|
|
||||||
// biome-ignore lint/suspicious/noExplicitAny: mocha's `this.skip()` isn't typed here.
|
|
||||||
(this as any).skip();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
await seedCorrectionAndSuggestion({
|
|
||||||
clause: "Faire mijoter",
|
|
||||||
correctedKey: "simmer",
|
|
||||||
withSuggestion: { status: "pending", synonyms: ["laisser frémir"] },
|
|
||||||
});
|
|
||||||
await seedCorrectionAndSuggestion({
|
|
||||||
clause: "Émincer les oignons",
|
|
||||||
correctedKey: "chop",
|
|
||||||
withSuggestion: { status: "applied", synonyms: ["ciseler"] },
|
|
||||||
});
|
|
||||||
|
|
||||||
const agent = await adminAgent();
|
|
||||||
|
|
||||||
const all = await agent.get("/admin/tech-steps/suggestions");
|
|
||||||
expect(all.status).to.equal(200);
|
|
||||||
expect(all.body.map((g: { techStepKey: string }) => g.techStepKey)).to.have.members([
|
|
||||||
"chop",
|
|
||||||
"simmer",
|
|
||||||
]);
|
|
||||||
const simmerGroup = all.body.find((g: { techStepKey: string }) => g.techStepKey === "simmer");
|
|
||||||
expect(simmerGroup.suggestions[0].sourceCorrection.clauseText).to.equal("Faire mijoter");
|
|
||||||
|
|
||||||
const pendingOnly = await agent
|
|
||||||
.get("/admin/tech-steps/suggestions")
|
|
||||||
.query({ status: "pending" });
|
|
||||||
expect(pendingOnly.body).to.have.length(1);
|
|
||||||
expect(pendingOnly.body[0].techStepKey).to.equal("simmer");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("PATCH /suggestions/:id", () => {
|
|
||||||
it("rejects an empty body with 400", async function () {
|
|
||||||
if (!adminSecretConfigured) {
|
|
||||||
// biome-ignore lint/suspicious/noExplicitAny: see above.
|
|
||||||
(this as any).skip();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const { suggestion } = await seedCorrectionAndSuggestion({
|
|
||||||
clause: "Faire mijoter",
|
|
||||||
correctedKey: "simmer",
|
|
||||||
withSuggestion: { status: "pending", synonyms: ["x"] },
|
|
||||||
});
|
|
||||||
const agent = await adminAgent();
|
|
||||||
const res = await agent.patch(`/admin/tech-steps/suggestions/${suggestion?.id}`).send({});
|
|
||||||
expect(res.status).to.equal(400);
|
|
||||||
expect(res.body.code).to.equal(ErrorCode.VALIDATION_ERROR);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("404s an unknown id", async function () {
|
|
||||||
if (!adminSecretConfigured) {
|
|
||||||
// biome-ignore lint/suspicious/noExplicitAny: see above.
|
|
||||||
(this as any).skip();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const agent = await adminAgent();
|
|
||||||
const res = await agent
|
|
||||||
.patch("/admin/tech-steps/suggestions/999999")
|
|
||||||
.send({ status: "applied" });
|
|
||||||
expect(res.status).to.equal(404);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("flips the status and edits the synonyms, reflected in a later GET", async function () {
|
|
||||||
if (!adminSecretConfigured) {
|
|
||||||
// biome-ignore lint/suspicious/noExplicitAny: see above.
|
|
||||||
(this as any).skip();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const { suggestion } = await seedCorrectionAndSuggestion({
|
|
||||||
clause: "Faire mijoter",
|
|
||||||
correctedKey: "simmer",
|
|
||||||
withSuggestion: { status: "pending", synonyms: ["frémir"] },
|
|
||||||
});
|
|
||||||
const agent = await adminAgent();
|
|
||||||
|
|
||||||
const patched = await agent
|
|
||||||
.patch(`/admin/tech-steps/suggestions/${suggestion?.id}`)
|
|
||||||
.send({ status: "applied", suggestedSynonyms: ["frémir", "mijoter doucement"] });
|
|
||||||
expect(patched.status).to.equal(200);
|
|
||||||
expect(patched.body.status).to.equal("applied");
|
|
||||||
expect(patched.body.suggestedSynonyms).to.deep.equal(["frémir", "mijoter doucement"]);
|
|
||||||
|
|
||||||
const stored = await prisma.techStepTrainingSuggestion.findUniqueOrThrow({
|
|
||||||
where: { id: suggestion?.id },
|
|
||||||
});
|
|
||||||
expect(stored.status).to.equal("applied");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("GET /corrections", () => {
|
|
||||||
it("includes the 'no technique here' removals", async function () {
|
|
||||||
if (!adminSecretConfigured) {
|
|
||||||
// biome-ignore lint/suspicious/noExplicitAny: see above.
|
|
||||||
(this as any).skip();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
await seedCorrectionAndSuggestion({ clause: "Rien ici", correctedKey: null });
|
|
||||||
await seedCorrectionAndSuggestion({ clause: "Faire mijoter", correctedKey: "simmer" });
|
|
||||||
|
|
||||||
const agent = await adminAgent();
|
|
||||||
const res = await agent.get("/admin/tech-steps/corrections");
|
|
||||||
expect(res.status).to.equal(200);
|
|
||||||
expect(res.body).to.have.length(2);
|
|
||||||
|
|
||||||
const removals = await agent
|
|
||||||
.get("/admin/tech-steps/corrections")
|
|
||||||
.query({ hasCorrectedTechStep: "false" });
|
|
||||||
expect(removals.body).to.have.length(1);
|
|
||||||
expect(removals.body[0].clauseText).to.equal("Rien ici");
|
|
||||||
expect(removals.body[0].correctedTechStepKey).to.equal(null);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("GET /training-data-snippet", () => {
|
|
||||||
it("aggregates the applied suggestions' synonyms into a paste-ready block", async function () {
|
|
||||||
if (!adminSecretConfigured) {
|
|
||||||
// biome-ignore lint/suspicious/noExplicitAny: see above.
|
|
||||||
(this as any).skip();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
await seedCorrectionAndSuggestion({
|
|
||||||
clause: "Faire mijoter",
|
|
||||||
correctedKey: "simmer",
|
|
||||||
withSuggestion: { status: "applied", synonyms: ["frémir", "réduire à feu doux"] },
|
|
||||||
});
|
|
||||||
const agent = await adminAgent();
|
|
||||||
const res = await agent
|
|
||||||
.get("/admin/tech-steps/training-data-snippet")
|
|
||||||
.query({ techStepKey: "simmer", locale: "fr", status: "applied" });
|
|
||||||
expect(res.status).to.equal(200);
|
|
||||||
expect(res.body.suggestionCount).to.equal(1);
|
|
||||||
expect(res.body.synonyms).to.deep.equal(["frémir", "réduire à feu doux"]);
|
|
||||||
expect(res.body.snippet).to.include('"frémir"');
|
|
||||||
expect(res.body.snippet).to.include('"synonyms": [');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("POST /retrain", () => {
|
|
||||||
it("runs the F1 gate and returns its result shape (needs tech-step-intent-service)", async function () {
|
|
||||||
if (!adminSecretConfigured) {
|
|
||||||
// biome-ignore lint/suspicious/noExplicitAny: see above.
|
|
||||||
(this as any).skip();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this.timeout(60000);
|
|
||||||
const agent = await adminAgent();
|
|
||||||
const res = await agent.post("/admin/tech-steps/retrain").send({});
|
|
||||||
expect(res.status).to.equal(200);
|
|
||||||
expect(res.body).to.have.keys([
|
|
||||||
"f1",
|
|
||||||
"precision",
|
|
||||||
"recall",
|
|
||||||
"minF1",
|
|
||||||
"gatePassed",
|
|
||||||
"backfilled",
|
|
||||||
"marked",
|
|
||||||
]);
|
|
||||||
expect(res.body.minF1).to.equal(0.8);
|
|
||||||
if (res.body.gatePassed) {
|
|
||||||
expect(res.body.backfilled).to.have.keys(["total", "changed"]);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns 409 RETRAIN_ALREADY_RUNNING while one is in flight", async function () {
|
|
||||||
if (!adminSecretConfigured) {
|
|
||||||
// biome-ignore lint/suspicious/noExplicitAny: see above.
|
|
||||||
(this as any).skip();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this.timeout(60000);
|
|
||||||
const agent = await adminAgent();
|
|
||||||
// supertest requests are lazy — they only dispatch when awaited/then'd.
|
|
||||||
// Attaching the `.then` here is what actually fires the first request,
|
|
||||||
// so its handler acquires the process-wide lock before the second one
|
|
||||||
// (100ms later) checks it. Swallow its result/rejection — this test
|
|
||||||
// only asserts on the second request.
|
|
||||||
const first = agent
|
|
||||||
.post("/admin/tech-steps/retrain")
|
|
||||||
.send({})
|
|
||||||
.then(
|
|
||||||
(res) => res,
|
|
||||||
(err) => err,
|
|
||||||
);
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
||||||
const second = await agent.post("/admin/tech-steps/retrain").send({});
|
|
||||||
expect(second.status).to.equal(409);
|
|
||||||
expect(second.body.code).to.equal(ErrorCode.RETRAIN_ALREADY_RUNNING);
|
|
||||||
await first;
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,214 +0,0 @@
|
||||||
import type { DateTime } from "@batch-cooking/date-tools";
|
|
||||||
import { ErrorCode, type SignupInput } from "@batch-cooking/shared";
|
|
||||||
import { faker } from "@faker-js/faker";
|
|
||||||
import { expect } from "chai";
|
|
||||||
import request from "supertest";
|
|
||||||
import { createApp } from "../src/app.js";
|
|
||||||
import { prisma } from "../src/db/prisma.js";
|
|
||||||
import { TEST_REFERENCE_DATE } from "../test-support/reference-date.js";
|
|
||||||
import { resetDatabase } from "../test-support/reset-db.js";
|
|
||||||
|
|
||||||
/** See `auth.test.ts` — same rationale for generating rather than hardcoding. */
|
|
||||||
function buildSignupPayload(): SignupInput {
|
|
||||||
const firstName = faker.person.firstName();
|
|
||||||
const lastName = faker.person.lastName();
|
|
||||||
return {
|
|
||||||
firstName,
|
|
||||||
lastName,
|
|
||||||
email: faker.internet.email({ firstName, lastName }).toLowerCase(),
|
|
||||||
password: faker.internet.password({ length: 16 }),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/** `toISODate()` only returns `null` for an invalid `DateTime` — never the always-valid values here. */
|
|
||||||
function isoDate(date: DateTime): string {
|
|
||||||
const iso = date.toISODate();
|
|
||||||
if (iso === null) throw new Error("Unexpectedly invalid DateTime in a test helper");
|
|
||||||
return iso;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The fixed test "today", as the `YYYY-MM-DD` string the `?date=` query expects. */
|
|
||||||
function today(): string {
|
|
||||||
return isoDate(TEST_REFERENCE_DATE);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Resolves a reference row's id by its `reference-seed-data.ts` uid (also its DB `key`) — same helpers as `recipe.test.ts`. */
|
|
||||||
async function ingredientId(key: string): Promise<number> {
|
|
||||||
return (await prisma.ingredient.findFirstOrThrow({ where: { key } })).id;
|
|
||||||
}
|
|
||||||
async function unitId(key: string): Promise<number> {
|
|
||||||
return (await prisma.unit.findFirstOrThrow({ where: { key } })).id;
|
|
||||||
}
|
|
||||||
async function techStepId(key: string): Promise<number> {
|
|
||||||
return (await prisma.techStep.findFirstOrThrow({ where: { key } })).id;
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("Cooking session", () => {
|
|
||||||
const app = createApp();
|
|
||||||
|
|
||||||
beforeEach(async () => {
|
|
||||||
await resetDatabase();
|
|
||||||
});
|
|
||||||
|
|
||||||
after(async () => {
|
|
||||||
await prisma.$disconnect();
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("GET /cooking-session", () => {
|
|
||||||
it("rejects requests without a session cookie with 401 NOT_AUTHENTICATED", async () => {
|
|
||||||
const res = await request(app).get("/cooking-session").query({ date: today() });
|
|
||||||
|
|
||||||
expect(res.status).to.equal(401);
|
|
||||||
expect(res.body.code).to.equal(ErrorCode.NOT_AUTHENTICATED);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rejects a malformed date with 400 VALIDATION_ERROR", async () => {
|
|
||||||
const agent = request.agent(app);
|
|
||||||
await agent.post("/auth/signup").send(buildSignupPayload());
|
|
||||||
|
|
||||||
const res = await agent.get("/cooking-session").query({ date: "not-a-date" });
|
|
||||||
|
|
||||||
expect(res.status).to.equal(400);
|
|
||||||
expect(res.body.code).to.equal(ErrorCode.VALIDATION_ERROR);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns an empty plan when the profile has no household", async () => {
|
|
||||||
const agent = request.agent(app);
|
|
||||||
await agent.post("/auth/signup").send(buildSignupPayload());
|
|
||||||
|
|
||||||
const res = await agent.get("/cooking-session").query({ date: today() });
|
|
||||||
|
|
||||||
expect(res.status).to.equal(200);
|
|
||||||
expect(res.body.recipes).to.deep.equal([]);
|
|
||||||
expect(res.body.phases).to.deep.equal([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns an empty plan when no planning covers that week", async () => {
|
|
||||||
const agent = request.agent(app);
|
|
||||||
await agent.post("/auth/signup").send(buildSignupPayload());
|
|
||||||
await agent.post("/house").send({ name: "Chez moi" });
|
|
||||||
|
|
||||||
const res = await agent.get("/cooking-session").query({ date: today() });
|
|
||||||
|
|
||||||
expect(res.status).to.equal(200);
|
|
||||||
expect(res.body.phases).to.deep.equal([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("pools an identical prep step from two planned recipes into one merged-prep task", async () => {
|
|
||||||
const agent = request.agent(app);
|
|
||||||
await agent.post("/auth/signup").send(buildSignupPayload());
|
|
||||||
const houseRes = await agent.post("/house").send({ name: "Chez moi" });
|
|
||||||
const houseId: number = houseRes.body.id;
|
|
||||||
const authorId: number = houseRes.body.adminId;
|
|
||||||
|
|
||||||
const onionId = await ingredientId("onion");
|
|
||||||
const pieceId = await unitId("piece");
|
|
||||||
const chopId = await techStepId("chop");
|
|
||||||
const simmerId = await techStepId("simmer");
|
|
||||||
const mixId = await techStepId("mix");
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A recipe: one pure-prep "chop onion" step, then (optionally) an
|
|
||||||
* active "mix" step, then one simmer step. The `withActiveStep` recipe
|
|
||||||
* is still doing hands-on work in the phase after its simmer starts, so
|
|
||||||
* the other recipe's simmer floats into that phase as `background`.
|
|
||||||
*/
|
|
||||||
async function makeRecipe(name: string, onionQty: number, withActiveStep = false) {
|
|
||||||
const chopStep = {
|
|
||||||
order: 0,
|
|
||||||
description: "Émincer les oignons",
|
|
||||||
techSteps: {
|
|
||||||
create: [
|
|
||||||
{
|
|
||||||
techStepId: chopId,
|
|
||||||
order: 0,
|
|
||||||
ingredients: {
|
|
||||||
create: [
|
|
||||||
{
|
|
||||||
ingredientId: onionId,
|
|
||||||
quantity: onionQty,
|
|
||||||
unitId: pieceId,
|
|
||||||
start: 0,
|
|
||||||
end: 1,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
};
|
|
||||||
const activeStep = {
|
|
||||||
order: 1,
|
|
||||||
description: "Mélanger l'appareil",
|
|
||||||
techSteps: { create: [{ techStepId: mixId, order: 0 }] },
|
|
||||||
};
|
|
||||||
const simmerStep = {
|
|
||||||
order: withActiveStep ? 2 : 1,
|
|
||||||
description: "Faire mijoter",
|
|
||||||
techSteps: { create: [{ techStepId: simmerId, order: 0 }] },
|
|
||||||
};
|
|
||||||
return prisma.recipe.create({
|
|
||||||
data: {
|
|
||||||
name,
|
|
||||||
authorId,
|
|
||||||
portions: 4,
|
|
||||||
steps: {
|
|
||||||
create: withActiveStep ? [chopStep, activeStep, simmerStep] : [chopStep, simmerStep],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const soupe = await makeRecipe("Soupe", 2);
|
|
||||||
const tarte = await makeRecipe("Tarte", 3, true);
|
|
||||||
|
|
||||||
const planning = await prisma.planning.create({
|
|
||||||
data: {
|
|
||||||
houseId,
|
|
||||||
startDate: TEST_REFERENCE_DATE.minus({ days: 2 }).toJSDate(),
|
|
||||||
finishDate: TEST_REFERENCE_DATE.plus({ days: 2 }).toJSDate(),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
await prisma.planningItem.createMany({
|
|
||||||
data: [
|
|
||||||
{
|
|
||||||
planningId: planning.id,
|
|
||||||
weekDay: "lundi",
|
|
||||||
meal: "dejeuner",
|
|
||||||
recipeId: soupe.id,
|
|
||||||
portions: 4,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
planningId: planning.id,
|
|
||||||
weekDay: "mardi",
|
|
||||||
meal: "diner",
|
|
||||||
recipeId: tarte.id,
|
|
||||||
portions: 4,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
const res = await agent.get("/cooking-session").query({ date: today() });
|
|
||||||
|
|
||||||
expect(res.status).to.equal(200);
|
|
||||||
expect(res.body.recipes.map((r: { name: string }) => r.name)).to.have.members([
|
|
||||||
"Soupe",
|
|
||||||
"Tarte",
|
|
||||||
]);
|
|
||||||
|
|
||||||
const mise = res.body.phases[0];
|
|
||||||
expect(mise.kind).to.equal("mise-en-place");
|
|
||||||
const merged = mise.tasks.filter((t: { kind: string }) => t.kind === "merged-prep");
|
|
||||||
expect(merged).to.have.length(1);
|
|
||||||
expect(merged[0].technique.key).to.equal("chop");
|
|
||||||
expect(merged[0].ingredients[0].ingredient.key).to.equal("onion");
|
|
||||||
expect(merged[0].ingredients[0].quantity).to.equal(5);
|
|
||||||
expect(merged[0].sourceRecipes).to.have.length(2);
|
|
||||||
|
|
||||||
// The simmer steps land in a later phase, and one shows as background.
|
|
||||||
const later = res.body.phases.slice(1);
|
|
||||||
const backgrounds = later.flatMap((p: { background: unknown[] }) => p.background);
|
|
||||||
expect(backgrounds.length).to.be.greaterThan(0);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,196 +0,0 @@
|
||||||
import type { SignupInput } from "@batch-cooking/shared";
|
|
||||||
import { ErrorCode } from "@batch-cooking/shared";
|
|
||||||
import { faker } from "@faker-js/faker";
|
|
||||||
import { expect } from "chai";
|
|
||||||
import request from "supertest";
|
|
||||||
import { createApp } from "../src/app.js";
|
|
||||||
import { prisma } from "../src/db/prisma.js";
|
|
||||||
import { resetDatabase } from "../test-support/reset-db.js";
|
|
||||||
|
|
||||||
/** See `auth.test.ts` — generated, never a real-looking person. */
|
|
||||||
function buildSignupPayload(): SignupInput {
|
|
||||||
const firstName = faker.person.firstName();
|
|
||||||
const lastName = faker.person.lastName();
|
|
||||||
return {
|
|
||||||
firstName,
|
|
||||||
lastName,
|
|
||||||
email: faker.internet.email({ firstName, lastName }).toLowerCase(),
|
|
||||||
password: faker.internet.password({ length: 16 }),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Resolves a reference unit's id by its seed uid (also its DB `key`). */
|
|
||||||
async function unitId(key: string): Promise<number> {
|
|
||||||
return (await prisma.unit.findFirstOrThrow({ where: { key } })).id;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Resolves a reference ingredient's id by its seed uid. */
|
|
||||||
async function ingredientId(key: string): Promise<number> {
|
|
||||||
return (await prisma.ingredient.findFirstOrThrow({ where: { key } })).id;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The off-catalog ("placeholder") ingredient escape hatch on `POST /recipes`
|
|
||||||
* / `PATCH /recipes/:id` — a line with `placeholderName` instead of
|
|
||||||
* `ingredientId` creates a dedicated `Ingredient` row
|
|
||||||
* (`isPlaceholder: true`) so the recipe still saves, and that row is
|
|
||||||
* surfaced (with its `displayName`) inside the recipe but never in the
|
|
||||||
* browsable catalog.
|
|
||||||
*/
|
|
||||||
describe("Recipes — off-catalog placeholder ingredients", () => {
|
|
||||||
const app = createApp();
|
|
||||||
|
|
||||||
async function signup(): Promise<{ agent: ReturnType<typeof request.agent>; profileId: number }> {
|
|
||||||
const agent = request.agent(app);
|
|
||||||
const res = await agent.post("/auth/signup").send(buildSignupPayload());
|
|
||||||
return { agent, profileId: res.body.id };
|
|
||||||
}
|
|
||||||
|
|
||||||
beforeEach(async () => {
|
|
||||||
await resetDatabase();
|
|
||||||
});
|
|
||||||
|
|
||||||
after(async () => {
|
|
||||||
await prisma.$disconnect();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("creates a placeholder Ingredient row for a `placeholderName` line and returns it inside the recipe", async () => {
|
|
||||||
const { agent, profileId } = await signup();
|
|
||||||
const piece = await unitId("piece");
|
|
||||||
const tomato = await ingredientId("tomato");
|
|
||||||
|
|
||||||
const res = await agent.post("/recipes").send({
|
|
||||||
name: "Poulet basquaise",
|
|
||||||
portions: 4,
|
|
||||||
dietIds: [],
|
|
||||||
ingredients: [
|
|
||||||
{ ingredientId: tomato, quantity: 3, unitId: piece },
|
|
||||||
{ placeholderName: "Piment d'Espelette", quantity: 1, unitId: piece },
|
|
||||||
],
|
|
||||||
steps: [{ description: "Tout mélanger" }],
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(res.status).to.equal(201);
|
|
||||||
expect(res.body.ingredients).to.have.length(2);
|
|
||||||
|
|
||||||
const placeholderLine = res.body.ingredients.find(
|
|
||||||
(line: { ingredient: { isPlaceholder: boolean } }) => line.ingredient.isPlaceholder,
|
|
||||||
);
|
|
||||||
expect(placeholderLine, "a placeholder line is present").to.not.equal(undefined);
|
|
||||||
expect(placeholderLine.ingredient.displayName).to.equal("Piment d'Espelette");
|
|
||||||
expect(placeholderLine.ingredient.key).to.match(/^placeholder:/);
|
|
||||||
expect(placeholderLine.ingredient.allergens).to.deep.equal([]);
|
|
||||||
expect(placeholderLine.quantity).to.equal(1);
|
|
||||||
|
|
||||||
const row = await prisma.ingredient.findUniqueOrThrow({
|
|
||||||
where: { id: placeholderLine.ingredient.id },
|
|
||||||
});
|
|
||||||
expect(row.isPlaceholder).to.equal(true);
|
|
||||||
expect(row.displayName).to.equal("Piment d'Espelette");
|
|
||||||
expect(row.createdById).to.equal(profileId);
|
|
||||||
expect(row.createdAt).to.be.an.instanceOf(Date);
|
|
||||||
expect(row.reviewedAt).to.equal(null);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("never lists placeholder rows in GET /reference/ingredients", async () => {
|
|
||||||
const { agent } = await signup();
|
|
||||||
const piece = await unitId("piece");
|
|
||||||
|
|
||||||
await agent.post("/recipes").send({
|
|
||||||
name: "Test",
|
|
||||||
portions: 2,
|
|
||||||
dietIds: [],
|
|
||||||
ingredients: [{ placeholderName: "Feuille de combava", quantity: 1, unitId: piece }],
|
|
||||||
steps: [{ description: "x" }],
|
|
||||||
});
|
|
||||||
|
|
||||||
const reference = await agent.get("/reference/ingredients");
|
|
||||||
expect(reference.status).to.equal(200);
|
|
||||||
expect(
|
|
||||||
reference.body.some(
|
|
||||||
(i: { isPlaceholder?: boolean; displayName?: string }) =>
|
|
||||||
i.isPlaceholder === true || i.displayName === "Feuille de combava",
|
|
||||||
),
|
|
||||||
).to.equal(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("reuses the existing placeholder row on edit (no duplicate) and drops it when the line is removed", async () => {
|
|
||||||
const { agent } = await signup();
|
|
||||||
const piece = await unitId("piece");
|
|
||||||
const tomato = await ingredientId("tomato");
|
|
||||||
|
|
||||||
const created = await agent.post("/recipes").send({
|
|
||||||
name: "Édition",
|
|
||||||
portions: 2,
|
|
||||||
dietIds: [],
|
|
||||||
ingredients: [{ placeholderName: "Sumac", quantity: 1, unitId: piece }],
|
|
||||||
steps: [{ description: "x" }],
|
|
||||||
});
|
|
||||||
const placeholderId = created.body.ingredients[0].ingredient.id;
|
|
||||||
|
|
||||||
// Re-submit the same recipe, keeping the placeholder line by its real id.
|
|
||||||
const edited = await agent.patch(`/recipes/${created.body.id}`).send({
|
|
||||||
name: "Édition",
|
|
||||||
portions: 2,
|
|
||||||
dietIds: [],
|
|
||||||
ingredients: [
|
|
||||||
{ ingredientId: placeholderId, quantity: 2, unitId: piece },
|
|
||||||
{ ingredientId: tomato, quantity: 1, unitId: piece },
|
|
||||||
],
|
|
||||||
steps: [{ description: "x" }],
|
|
||||||
});
|
|
||||||
expect(edited.status).to.equal(200);
|
|
||||||
expect(await prisma.ingredient.count({ where: { isPlaceholder: true } })).to.equal(1);
|
|
||||||
|
|
||||||
// Now edit again, dropping the placeholder line entirely.
|
|
||||||
await agent.patch(`/recipes/${created.body.id}`).send({
|
|
||||||
name: "Édition",
|
|
||||||
portions: 2,
|
|
||||||
dietIds: [],
|
|
||||||
ingredients: [{ ingredientId: tomato, quantity: 1, unitId: piece }],
|
|
||||||
steps: [{ description: "x" }],
|
|
||||||
});
|
|
||||||
// The row is now an orphan (kept on purpose — the admin catalog view
|
|
||||||
// prunes it), but no *new* placeholder was created.
|
|
||||||
expect(await prisma.ingredient.count({ where: { isPlaceholder: true } })).to.equal(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rejects a line carrying both ingredientId and placeholderName with 400", async () => {
|
|
||||||
const { agent } = await signup();
|
|
||||||
const piece = await unitId("piece");
|
|
||||||
const tomato = await ingredientId("tomato");
|
|
||||||
|
|
||||||
const res = await agent.post("/recipes").send({
|
|
||||||
name: "Invalide",
|
|
||||||
portions: 2,
|
|
||||||
dietIds: [],
|
|
||||||
ingredients: [
|
|
||||||
{ ingredientId: tomato, placeholderName: "Tomate", quantity: 1, unitId: piece },
|
|
||||||
],
|
|
||||||
steps: [{ description: "x" }],
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(res.status).to.equal(400);
|
|
||||||
expect(res.body.code).to.equal(ErrorCode.VALIDATION_ERROR);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("allows two placeholder lines with the same text (each becomes its own row)", async () => {
|
|
||||||
const { agent } = await signup();
|
|
||||||
const piece = await unitId("piece");
|
|
||||||
|
|
||||||
const res = await agent.post("/recipes").send({
|
|
||||||
name: "Doublons libres",
|
|
||||||
portions: 2,
|
|
||||||
dietIds: [],
|
|
||||||
ingredients: [
|
|
||||||
{ placeholderName: "Herbes de garrigue", quantity: 1, unitId: piece },
|
|
||||||
{ placeholderName: "Herbes de garrigue", quantity: 2, unitId: piece },
|
|
||||||
],
|
|
||||||
steps: [{ description: "x" }],
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(res.status).to.equal(201);
|
|
||||||
expect(res.body.ingredients).to.have.length(2);
|
|
||||||
expect(await prisma.ingredient.count({ where: { isPlaceholder: true } })).to.equal(2);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -95,8 +95,6 @@ describe("Reference data", () => {
|
||||||
"reproducible",
|
"reproducible",
|
||||||
"allergens",
|
"allergens",
|
||||||
"diets",
|
"diets",
|
||||||
"isPlaceholder",
|
|
||||||
"displayName",
|
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,99 +0,0 @@
|
||||||
// Mocks the admin API via cy.intercept — no live backend.
|
|
||||||
|
|
||||||
const adminBody = {
|
|
||||||
id: 1,
|
|
||||||
email: "ops@example.com",
|
|
||||||
name: "Ops",
|
|
||||||
createdAt: "2026-08-01T00:00:00.000Z",
|
|
||||||
lastLoginAt: "2026-08-28T09:00:00.000Z",
|
|
||||||
};
|
|
||||||
|
|
||||||
function pendingGroups() {
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
normalizedName: "piment d espelette",
|
|
||||||
displayNames: ["Piment d'Espelette", "piment d espelette"],
|
|
||||||
ingredientIds: [11, 12],
|
|
||||||
recipeCount: 2,
|
|
||||||
sampleRecipes: [
|
|
||||||
{ id: 1, name: "Poulet basquaise" },
|
|
||||||
{ id: 2, name: "Piperade" },
|
|
||||||
],
|
|
||||||
firstSeenAt: "2026-08-20T10:00:00.000Z",
|
|
||||||
allReviewed: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
normalizedName: "sumac",
|
|
||||||
displayNames: ["Sumac"],
|
|
||||||
ingredientIds: [13],
|
|
||||||
recipeCount: 1,
|
|
||||||
sampleRecipes: [{ id: 3, name: "Fattoush" }],
|
|
||||||
firstSeenAt: "2026-08-22T10:00:00.000Z",
|
|
||||||
allReviewed: false,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("Admin catalog — off-catalog ingredients", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
cy.viewport(1400, 900);
|
|
||||||
cy.intercept("GET", "**/admin/auth/me", { statusCode: 200, body: adminBody });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("lists placeholder groups newest-impact first with their recipe count and spelling variants", () => {
|
|
||||||
cy.intercept("GET", "**/admin/catalog/placeholders*", {
|
|
||||||
statusCode: 200,
|
|
||||||
body: pendingGroups(),
|
|
||||||
}).as("getPlaceholders");
|
|
||||||
cy.visit("/admin/catalogue");
|
|
||||||
cy.wait("@getPlaceholders");
|
|
||||||
|
|
||||||
cy.get(".catalog-card").should("have.length", 2);
|
|
||||||
cy.get(".catalog-card").first().should("contain.text", "Piment d'Espelette");
|
|
||||||
cy.contains(".catalog-card", "Piment d'Espelette")
|
|
||||||
.should("contain.text", "2 recette")
|
|
||||||
.and("contain.text", "piment d espelette")
|
|
||||||
.and("contain.text", "Poulet basquaise");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("marks a group reviewed and reloads the list", () => {
|
|
||||||
cy.intercept("GET", "**/admin/catalog/placeholders*", {
|
|
||||||
statusCode: 200,
|
|
||||||
body: pendingGroups(),
|
|
||||||
}).as("getPlaceholders");
|
|
||||||
cy.intercept("PATCH", "**/admin/catalog/placeholders/mark-reviewed", {
|
|
||||||
statusCode: 200,
|
|
||||||
body: { reviewed: 1 },
|
|
||||||
}).as("markReviewed");
|
|
||||||
|
|
||||||
cy.visit("/admin/catalogue");
|
|
||||||
cy.wait("@getPlaceholders");
|
|
||||||
|
|
||||||
cy.contains(".catalog-card", "Sumac").contains("button", "Marquer comme traité").click();
|
|
||||||
|
|
||||||
cy.wait("@markReviewed")
|
|
||||||
.its("request.body")
|
|
||||||
.should("deep.equal", { ingredientIds: [13] });
|
|
||||||
// The page re-fetches the list after the PATCH.
|
|
||||||
cy.get("@getPlaceholders.all").should("have.length.greaterThan", 1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("switches to the reviewed archive tab", () => {
|
|
||||||
// Regex, not a glob: the two calls differ only by the `?reviewed=true`
|
|
||||||
// query, and minimatch's `?` is itself a wildcard — a glob can't tell
|
|
||||||
// them apart reliably.
|
|
||||||
cy.intercept("GET", /\/admin\/catalog\/placeholders$/, {
|
|
||||||
statusCode: 200,
|
|
||||||
body: pendingGroups(),
|
|
||||||
});
|
|
||||||
cy.intercept("GET", /\/admin\/catalog\/placeholders\?reviewed=true$/, {
|
|
||||||
statusCode: 200,
|
|
||||||
body: [],
|
|
||||||
}).as("getReviewed");
|
|
||||||
|
|
||||||
cy.visit("/admin/catalogue");
|
|
||||||
cy.contains(".catalog-tabs button", "Traités").click();
|
|
||||||
cy.wait("@getReviewed");
|
|
||||||
cy.contains("Aucun ingrédient hors-catalogue").should("be.visible");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,159 +0,0 @@
|
||||||
// Mocks the admin API via cy.intercept — no live backend.
|
|
||||||
|
|
||||||
const adminBody = {
|
|
||||||
id: 1,
|
|
||||||
email: "ops@example.com",
|
|
||||||
name: "Ops",
|
|
||||||
createdAt: "2026-08-01T00:00:00.000Z",
|
|
||||||
lastLoginAt: "2026-08-28T09:00:00.000Z",
|
|
||||||
};
|
|
||||||
|
|
||||||
function suggestionGroups() {
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
techStepKey: "simmer",
|
|
||||||
suggestions: [
|
|
||||||
{
|
|
||||||
id: 11,
|
|
||||||
techStepKey: "simmer",
|
|
||||||
locale: "fr",
|
|
||||||
suggestedSynonyms: ["frémir"],
|
|
||||||
suggestedUtterances: ["laisser cuire tout doucement"],
|
|
||||||
sourceType: "correction",
|
|
||||||
status: "pending",
|
|
||||||
createdAt: "2026-08-20T00:00:00.000Z",
|
|
||||||
sourceCorrection: {
|
|
||||||
id: 5,
|
|
||||||
recipeId: 2,
|
|
||||||
stepId: 7,
|
|
||||||
clauseText: "faire mijoter la sauce",
|
|
||||||
previousTechStepKey: "cook",
|
|
||||||
correctedTechStepKey: "simmer",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
function corrections() {
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
id: 5,
|
|
||||||
recipeId: 2,
|
|
||||||
stepId: 7,
|
|
||||||
stepDescription: "Faire mijoter la sauce 20 min.",
|
|
||||||
clauseText: "faire mijoter la sauce",
|
|
||||||
start: 0,
|
|
||||||
end: 21,
|
|
||||||
previousTechStepKey: "cook",
|
|
||||||
correctedTechStepKey: "simmer",
|
|
||||||
createdAt: "2026-08-20T00:00:00.000Z",
|
|
||||||
consumedAt: null,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 6,
|
|
||||||
recipeId: 3,
|
|
||||||
stepId: 9,
|
|
||||||
stepDescription: "Réserver au frais.",
|
|
||||||
clauseText: "Réserver au frais",
|
|
||||||
start: 0,
|
|
||||||
end: 17,
|
|
||||||
previousTechStepKey: "setAside",
|
|
||||||
correctedTechStepKey: null,
|
|
||||||
createdAt: "2026-08-19T00:00:00.000Z",
|
|
||||||
consumedAt: null,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("Admin corrections triage", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
cy.viewport(1400, 1000);
|
|
||||||
cy.intercept("GET", "**/admin/auth/me", { statusCode: 200, body: adminBody });
|
|
||||||
cy.intercept("GET", "**/admin/tech-steps/suggestions*", {
|
|
||||||
statusCode: 200,
|
|
||||||
body: suggestionGroups(),
|
|
||||||
}).as("getSuggestions");
|
|
||||||
cy.intercept("GET", "**/admin/tech-steps/corrections*", {
|
|
||||||
statusCode: 200,
|
|
||||||
body: corrections(),
|
|
||||||
}).as("getCorrections");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("shows the caveat, groups suggestions by technique, and applies one", () => {
|
|
||||||
cy.intercept("PATCH", "**/admin/tech-steps/suggestions/11", {
|
|
||||||
statusCode: 200,
|
|
||||||
body: { ...suggestionGroups()[0].suggestions[0], status: "applied" },
|
|
||||||
}).as("patch");
|
|
||||||
|
|
||||||
cy.visit("/admin/corrections");
|
|
||||||
cy.wait("@getSuggestions");
|
|
||||||
|
|
||||||
cy.contains(".corrections-caveat", "training_data.py").should("be.visible");
|
|
||||||
cy.contains(".suggestion-group h2", "simmer").should("be.visible");
|
|
||||||
cy.contains(".suggestion-card", "faire mijoter la sauce").should(
|
|
||||||
"contain.text",
|
|
||||||
"cook → simmer",
|
|
||||||
);
|
|
||||||
|
|
||||||
cy.contains(".suggestion-card button", "Appliquer").click();
|
|
||||||
cy.wait("@patch").its("request.body").should("deep.equal", { status: "applied" });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("generates a training_data.py snippet", () => {
|
|
||||||
cy.intercept("GET", "**/admin/tech-steps/training-data-snippet*", {
|
|
||||||
statusCode: 200,
|
|
||||||
body: {
|
|
||||||
techStepKey: "simmer",
|
|
||||||
locale: "fr",
|
|
||||||
status: "applied",
|
|
||||||
suggestionCount: 2,
|
|
||||||
synonyms: ["frémir", "réduire"],
|
|
||||||
utterances: [],
|
|
||||||
snippet:
|
|
||||||
'# simmer (fr) — 2 suggestion(s) "applied"\n"synonyms": [\n "frémir",\n "réduire",\n],',
|
|
||||||
},
|
|
||||||
}).as("getSnippet");
|
|
||||||
|
|
||||||
cy.visit("/admin/corrections");
|
|
||||||
cy.get(".corrections-panel input").type("simmer");
|
|
||||||
cy.contains(".corrections-panel button", "Générer").click();
|
|
||||||
cy.wait("@getSnippet");
|
|
||||||
cy.get(".corrections-snippet").should("contain.value", '"synonyms": [');
|
|
||||||
});
|
|
||||||
|
|
||||||
it("runs the F1 gate and shows the result", () => {
|
|
||||||
cy.intercept("POST", "**/admin/tech-steps/retrain", {
|
|
||||||
statusCode: 200,
|
|
||||||
body: {
|
|
||||||
f1: 0.83,
|
|
||||||
precision: 0.8,
|
|
||||||
recall: 0.86,
|
|
||||||
minF1: 0.8,
|
|
||||||
gatePassed: true,
|
|
||||||
backfilled: { total: 120, changed: 4 },
|
|
||||||
marked: { applied: 0, rejected: 0 },
|
|
||||||
},
|
|
||||||
}).as("retrain");
|
|
||||||
|
|
||||||
cy.visit("/admin/corrections");
|
|
||||||
cy.contains(".corrections-panel--retrain button", "Lancer").click();
|
|
||||||
cy.wait("@retrain");
|
|
||||||
cy.contains(".retrain-result", "F1 0.830")
|
|
||||||
.should("have.class", "retrain-result--ok")
|
|
||||||
.and("contain.text", "4/120");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("lists raw corrections including the removals, on the second tab", () => {
|
|
||||||
cy.visit("/admin/corrections");
|
|
||||||
cy.contains(".corrections-tabs button", "Corrections brutes").click();
|
|
||||||
cy.wait("@getCorrections");
|
|
||||||
|
|
||||||
cy.get(".corrections-table tbody tr").should("have.length", 2);
|
|
||||||
cy.contains(".corrections-table tr", "Réserver au frais").should(
|
|
||||||
"contain.text",
|
|
||||||
"setAside → ∅",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,95 +0,0 @@
|
||||||
// Mocks the admin API via cy.intercept — no live backend.
|
|
||||||
|
|
||||||
const adminBody = {
|
|
||||||
id: 1,
|
|
||||||
email: "ops@example.com",
|
|
||||||
name: "Ops",
|
|
||||||
createdAt: "2026-08-01T00:00:00.000Z",
|
|
||||||
lastLoginAt: "2026-08-28T09:00:00.000Z",
|
|
||||||
};
|
|
||||||
|
|
||||||
/** A 3-day series helper for the fixture. */
|
|
||||||
function series(counts: number[]) {
|
|
||||||
return counts.map((count, i) => ({
|
|
||||||
date: `2026-08-${String(10 + i).padStart(2, "0")}`,
|
|
||||||
count,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
function metricsFixture() {
|
|
||||||
return {
|
|
||||||
generatedAt: "2026-08-28T09:00:00.000Z",
|
|
||||||
rangeDays: 30,
|
|
||||||
snapshot: {
|
|
||||||
admins: 2,
|
|
||||||
users: 42,
|
|
||||||
households: 15,
|
|
||||||
activeHouseholds: 9,
|
|
||||||
recipes: 120,
|
|
||||||
recipesManual: 30,
|
|
||||||
recipesImported: 90,
|
|
||||||
recipesBySource: [
|
|
||||||
{ key: "themealdb", label: "TheMealDB", count: 60 },
|
|
||||||
{ key: "marmiton", label: "Marmiton", count: 30 },
|
|
||||||
],
|
|
||||||
plannings: 18,
|
|
||||||
planningItems: 210,
|
|
||||||
steps: 640,
|
|
||||||
detectedTechniques: 900,
|
|
||||||
favorites: 55,
|
|
||||||
corrections: 12,
|
|
||||||
correctionsUnconsumed: 4,
|
|
||||||
correctionsRemoval: 2,
|
|
||||||
trainingSuggestions: 8,
|
|
||||||
trainingSuggestionsByStatus: [{ key: "pending", label: "pending", count: 8 }],
|
|
||||||
trainingSuggestionsBySourceType: [{ key: "correction", label: "correction", count: 8 }],
|
|
||||||
},
|
|
||||||
series: {
|
|
||||||
signups: series([1, 3, 2]),
|
|
||||||
recipesCreated: series([0, 2, 1]),
|
|
||||||
planningItemsAdded: series([4, 1, 5]),
|
|
||||||
correctionsSubmitted: series([0, 0, 1]),
|
|
||||||
trainingSuggestions: series([0, 1, 0]),
|
|
||||||
},
|
|
||||||
events: [{ type: "user.signup", buckets: series([1, 3, 2]) }],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("Admin dashboard", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
cy.viewport(1400, 900);
|
|
||||||
cy.intercept("GET", "**/admin/auth/me", { statusCode: 200, body: adminBody });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("renders KPI tiles, a chart per series, and the breakdown lists", () => {
|
|
||||||
cy.intercept("GET", "**/admin/metrics*", { statusCode: 200, body: metricsFixture() }).as(
|
|
||||||
"getMetrics",
|
|
||||||
);
|
|
||||||
cy.visit("/admin");
|
|
||||||
cy.wait("@getMetrics").its("request.url").should("include", "days=30");
|
|
||||||
|
|
||||||
// KPI tiles — value + label.
|
|
||||||
cy.contains(".kpi-tile", "Utilisateurs").should("contain.text", "42");
|
|
||||||
cy.contains(".kpi-tile", "Recettes importées").should("contain.text", "90");
|
|
||||||
cy.contains(".kpi-tile", "Corrections à traiter").should("contain.text", "4");
|
|
||||||
|
|
||||||
// One chart card per instrumented series.
|
|
||||||
cy.get(".chart-card").should("have.length", 5);
|
|
||||||
cy.contains(".chart-card", "Inscriptions").should("contain.text", "6 sur 30 j");
|
|
||||||
|
|
||||||
// Breakdown lists.
|
|
||||||
cy.contains(".breakdown", "Recettes importées par source")
|
|
||||||
.should("contain.text", "TheMealDB")
|
|
||||||
.and("contain.text", "Marmiton");
|
|
||||||
cy.contains(".breakdown", "Évènements enregistrés").should("contain.text", "user.signup");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("shows an error state when the metrics request fails", () => {
|
|
||||||
cy.intercept("GET", "**/admin/metrics*", {
|
|
||||||
statusCode: 500,
|
|
||||||
body: { code: 5000, message: "x" },
|
|
||||||
});
|
|
||||||
cy.visit("/admin");
|
|
||||||
cy.contains("Impossible de charger").should("be.visible");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,62 +0,0 @@
|
||||||
// Mocks the admin API via cy.intercept — no live backend (apps/api's Mocha
|
|
||||||
// suite covers real /admin/* behaviour).
|
|
||||||
|
|
||||||
const adminBody = {
|
|
||||||
id: 1,
|
|
||||||
email: "ops@example.com",
|
|
||||||
name: "Ops",
|
|
||||||
createdAt: "2026-08-01T00:00:00.000Z",
|
|
||||||
lastLoginAt: "2026-08-28T09:00:00.000Z",
|
|
||||||
};
|
|
||||||
|
|
||||||
describe("Admin layout", () => {
|
|
||||||
it("redirects to /login when there is no admin session", () => {
|
|
||||||
cy.intercept("GET", "**/admin/auth/me", {
|
|
||||||
statusCode: 401,
|
|
||||||
body: { code: 4011, message: "no" },
|
|
||||||
});
|
|
||||||
cy.visit("/admin/monitoring");
|
|
||||||
cy.url().should("include", "/admin/login");
|
|
||||||
cy.contains("h1", "Administration").should("be.visible");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("shows the sidebar and navigates between the sections", () => {
|
|
||||||
cy.intercept("GET", "**/admin/auth/me", { statusCode: 200, body: adminBody });
|
|
||||||
cy.visit("/admin");
|
|
||||||
|
|
||||||
cy.contains("h1", "Tableau de bord").should("be.visible");
|
|
||||||
cy.contains(".admin-sidebar__who", "Ops").should("be.visible");
|
|
||||||
|
|
||||||
cy.contains("nav a", "Monitoring").click();
|
|
||||||
cy.url().should("include", "/admin/monitoring");
|
|
||||||
cy.contains("h1", "Monitoring").should("be.visible");
|
|
||||||
cy.contains("nav a", "Monitoring").should("have.class", "active");
|
|
||||||
|
|
||||||
cy.contains("nav a", "Corrections").click();
|
|
||||||
cy.url().should("include", "/admin/corrections");
|
|
||||||
cy.contains("h1", "Corrections").should("be.visible");
|
|
||||||
|
|
||||||
cy.intercept("GET", "**/admin/catalog/placeholders*", { statusCode: 200, body: [] });
|
|
||||||
cy.contains("nav a", "Catalogue").click();
|
|
||||||
cy.url().should("include", "/admin/catalogue");
|
|
||||||
cy.contains("h1", "Ingrédients hors-catalogue").should("be.visible");
|
|
||||||
|
|
||||||
cy.contains("nav a", "Tableau de bord").click();
|
|
||||||
cy.url().should("eq", `${Cypress.config().baseUrl}/admin`);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("logs out back to /login", () => {
|
|
||||||
cy.intercept("GET", "**/admin/auth/me", { statusCode: 200, body: adminBody });
|
|
||||||
cy.intercept("POST", "**/admin/auth/logout", { statusCode: 204 });
|
|
||||||
cy.visit("/admin");
|
|
||||||
|
|
||||||
// Wait until the guarded layout has actually mounted before acting.
|
|
||||||
cy.contains("h1", "Tableau de bord").should("be.visible");
|
|
||||||
|
|
||||||
// Logout clears the in-memory admin state, which is what bounces the
|
|
||||||
// guard to /login — no fresh `me` round-trip involved, so nothing to
|
|
||||||
// re-stub here.
|
|
||||||
cy.contains("button", "Se déconnecter").click();
|
|
||||||
cy.url().should("include", "/admin/login");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,24 +0,0 @@
|
||||||
Feature: Admin login
|
|
||||||
As an operator
|
|
||||||
I want to sign in to the admin application
|
|
||||||
So that I can reach the metrics, monitoring and correction-triage sections
|
|
||||||
|
|
||||||
Scenario: A wrong password shows a translated error, no redirect
|
|
||||||
Given the admin session check returns unauthenticated
|
|
||||||
And admin login fails with invalid credentials
|
|
||||||
When I visit "/admin/login"
|
|
||||||
And I fill in the "email" field with "ops@example.com"
|
|
||||||
And I fill in the "password" field with "wrong"
|
|
||||||
And I click the button "Se connecter"
|
|
||||||
Then I should see "Email ou mot de passe incorrect"
|
|
||||||
And the URL should include "/admin/login"
|
|
||||||
|
|
||||||
Scenario: A correct login lands on the dashboard
|
|
||||||
Given the admin session check returns unauthenticated
|
|
||||||
And admin login succeeds as "Ops"
|
|
||||||
When I visit "/admin/login"
|
|
||||||
And I fill in the "email" field with "ops@example.com"
|
|
||||||
And I fill in the "password" field with "correct-horse"
|
|
||||||
And I click the button "Se connecter"
|
|
||||||
Then the URL should not include "/admin/login"
|
|
||||||
And I should see the heading "Tableau de bord"
|
|
||||||
|
|
@ -1,35 +0,0 @@
|
||||||
import { Given } from "@badeball/cypress-cucumber-preprocessor";
|
|
||||||
|
|
||||||
// Admin-specific steps for `admin-login.feature`. Generic navigation / form
|
|
||||||
// steps ("I visit", "I fill in the … field", "I click the button", "I
|
|
||||||
// should see …") are reused from `support/step_definitions/common.steps.ts`;
|
|
||||||
// only the `/admin/*` API mocks live here. Every admin call is stubbed via
|
|
||||||
// `cy.intercept` — this suite never runs a live backend (apps/api's Mocha
|
|
||||||
// suite covers real `/admin/*` behaviour).
|
|
||||||
|
|
||||||
const adminBody = {
|
|
||||||
id: 1,
|
|
||||||
email: "ops@example.com",
|
|
||||||
name: "Ops",
|
|
||||||
createdAt: "2026-08-01T00:00:00.000Z",
|
|
||||||
lastLoginAt: "2026-08-28T09:00:00.000Z",
|
|
||||||
};
|
|
||||||
|
|
||||||
Given("the admin session check returns unauthenticated", () => {
|
|
||||||
cy.intercept("GET", "**/admin/auth/me", { statusCode: 401, body: { code: 4011, message: "no" } });
|
|
||||||
});
|
|
||||||
|
|
||||||
Given("admin login fails with invalid credentials", () => {
|
|
||||||
cy.intercept("POST", "**/admin/auth/login", {
|
|
||||||
statusCode: 401,
|
|
||||||
body: { code: 4010, message: "Invalid email or password" },
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
Given("admin login succeeds as {string}", (name: string) => {
|
|
||||||
const body = { ...adminBody, name, email: `${name.toLowerCase()}@example.com` };
|
|
||||||
cy.intercept("POST", "**/admin/auth/login", { statusCode: 200, body });
|
|
||||||
// After navigate("/admin"), RequireAdmin re-checks the session — from now
|
|
||||||
// on it must report authenticated (last matching intercept wins).
|
|
||||||
cy.intercept("GET", "**/admin/auth/me", { statusCode: 200, body });
|
|
||||||
});
|
|
||||||
|
|
@ -1,95 +0,0 @@
|
||||||
// Mocks the admin API via cy.intercept — no live backend.
|
|
||||||
|
|
||||||
const adminBody = {
|
|
||||||
id: 1,
|
|
||||||
email: "ops@example.com",
|
|
||||||
name: "Ops",
|
|
||||||
createdAt: "2026-08-01T00:00:00.000Z",
|
|
||||||
lastLoginAt: "2026-08-28T09:00:00.000Z",
|
|
||||||
};
|
|
||||||
|
|
||||||
function monitoringFixture() {
|
|
||||||
return {
|
|
||||||
generatedAt: "2026-08-28T09:15:00.000Z",
|
|
||||||
services: [
|
|
||||||
{
|
|
||||||
key: "postgres",
|
|
||||||
status: "up",
|
|
||||||
latencyMs: 3.2,
|
|
||||||
detail: null,
|
|
||||||
checkedAt: "2026-08-28T09:15:00.000Z",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "api",
|
|
||||||
status: "up",
|
|
||||||
latencyMs: 0,
|
|
||||||
detail: "uptime 3 h 12 min · RSS 120 Mo",
|
|
||||||
checkedAt: "2026-08-28T09:15:00.000Z",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "intent-service",
|
|
||||||
status: "down",
|
|
||||||
latencyMs: null,
|
|
||||||
detail: "fetch failed",
|
|
||||||
checkedAt: "2026-08-28T09:15:00.000Z",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "tech-step-llm-worker",
|
|
||||||
status: "degraded",
|
|
||||||
latencyMs: null,
|
|
||||||
detail: "dernier battement il y a 9 j",
|
|
||||||
checkedAt: "2026-08-28T09:15:00.000Z",
|
|
||||||
lastRunAt: "2026-08-19T03:00:00.000Z",
|
|
||||||
lastResult: { job: "audit-low-confidence", ok: true, counts: { suggestions: 2 } },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("Admin monitoring", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
cy.viewport(1400, 900);
|
|
||||||
cy.intercept("GET", "**/admin/auth/me", { statusCode: 200, body: adminBody });
|
|
||||||
});
|
|
||||||
|
|
||||||
// The page route `/admin/monitoring` and the API path `GET /admin/monitoring`
|
|
||||||
// are identical — a plain `**/admin/monitoring` glob would also match the
|
|
||||||
// `cy.visit()` document request (and whether that's same-origin or not
|
|
||||||
// depends on `VITE_API_URL`, which isn't set in CI). `resourceType: "fetch"`
|
|
||||||
// pins the intercept to the `AdminApiClient` XHR only.
|
|
||||||
const monitoringApi = {
|
|
||||||
method: "GET",
|
|
||||||
url: "**/admin/monitoring",
|
|
||||||
resourceType: "fetch",
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
it("renders one card per service with its status and details", () => {
|
|
||||||
cy.intercept(monitoringApi, {
|
|
||||||
statusCode: 200,
|
|
||||||
body: monitoringFixture(),
|
|
||||||
}).as("getMonitoring");
|
|
||||||
cy.visit("/admin/monitoring");
|
|
||||||
cy.wait("@getMonitoring");
|
|
||||||
|
|
||||||
cy.get(".monitoring-card").should("have.length", 4);
|
|
||||||
|
|
||||||
cy.contains(".monitoring-card", "Base de données")
|
|
||||||
.should("have.class", "monitoring-card--up")
|
|
||||||
.and("contain.text", "3.2 ms");
|
|
||||||
cy.contains(".monitoring-card", "Service NLP (spaCy)")
|
|
||||||
.should("have.class", "monitoring-card--down")
|
|
||||||
.and("contain.text", "Hors service");
|
|
||||||
cy.contains(".monitoring-card", "Worker LLM")
|
|
||||||
.should("have.class", "monitoring-card--degraded")
|
|
||||||
.and("contain.text", "audit-low-confidence");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("shows an error state when the request fails", () => {
|
|
||||||
cy.intercept(monitoringApi, {
|
|
||||||
statusCode: 500,
|
|
||||||
body: { code: 5000, message: "x" },
|
|
||||||
});
|
|
||||||
cy.visit("/admin/monitoring");
|
|
||||||
cy.contains("Impossible de charger").should("be.visible");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,174 +0,0 @@
|
||||||
// Mocks the API via cy.intercept — this job doesn't run a live backend (see
|
|
||||||
// .github/workflows/ci.yml); apps/api's own Mocha suite covers real
|
|
||||||
// `GET /cooking-session` behavior (including the optimizer) against a real
|
|
||||||
// database.
|
|
||||||
|
|
||||||
const authenticatedProfile = {
|
|
||||||
id: 1,
|
|
||||||
firstName: "Alice",
|
|
||||||
lastName: "Martin",
|
|
||||||
email: "alice@example.com",
|
|
||||||
tokenVersion: 0,
|
|
||||||
houseId: 1,
|
|
||||||
dietId: null,
|
|
||||||
};
|
|
||||||
|
|
||||||
// 2026-08-17 is a Monday — frozen so "this week" is deterministic.
|
|
||||||
const TODAY = new Date("2026-08-17T09:00:00Z");
|
|
||||||
|
|
||||||
/** Bare `IngredientView` — only `key` drives the page's label lookup. */
|
|
||||||
function ingredient(key: string) {
|
|
||||||
return {
|
|
||||||
id: 1,
|
|
||||||
key,
|
|
||||||
icon: "VEGETABLE",
|
|
||||||
category: "freshProduce",
|
|
||||||
subcategory: "vegetables",
|
|
||||||
reproducible: false,
|
|
||||||
allergens: [],
|
|
||||||
diets: [],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/** A plan with a pooled prep task in mise-en-place and a passive cook floated into a later phase. */
|
|
||||||
function planFixture() {
|
|
||||||
return {
|
|
||||||
startDate: "2026-08-17T00:00:00.000Z",
|
|
||||||
finishDate: "2026-08-23T00:00:00.000Z",
|
|
||||||
recipes: [
|
|
||||||
{ recipeId: 1, name: "Soupe à l'oignon", portions: 4 },
|
|
||||||
{ recipeId: 2, name: "Tarte à l'oignon", portions: 4 },
|
|
||||||
],
|
|
||||||
phases: [
|
|
||||||
{
|
|
||||||
index: 0,
|
|
||||||
kind: "mise-en-place",
|
|
||||||
background: [],
|
|
||||||
tasks: [
|
|
||||||
{
|
|
||||||
id: "prep:chop:onion",
|
|
||||||
kind: "merged-prep",
|
|
||||||
technique: { id: 1, key: "chop" },
|
|
||||||
description: null,
|
|
||||||
ingredients: [
|
|
||||||
{
|
|
||||||
ingredient: ingredient("onion"),
|
|
||||||
quantity: 5,
|
|
||||||
unit: { id: 2, key: "piece", type: "COUNT", toBaseFactor: 1 },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
utensils: [{ id: 1, key: "knife" }],
|
|
||||||
sourceRecipes: [
|
|
||||||
{ recipeId: 1, name: "Soupe à l'oignon", portions: 4 },
|
|
||||||
{ recipeId: 2, name: "Tarte à l'oignon", portions: 4 },
|
|
||||||
],
|
|
||||||
originalSteps: [],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
index: 1,
|
|
||||||
kind: "cooking",
|
|
||||||
background: [],
|
|
||||||
tasks: [
|
|
||||||
{
|
|
||||||
id: "step:0:1",
|
|
||||||
kind: "step",
|
|
||||||
technique: { id: 5, key: "simmer" },
|
|
||||||
description: "Faire mijoter le bouillon",
|
|
||||||
ingredients: [],
|
|
||||||
utensils: [],
|
|
||||||
sourceRecipes: [{ recipeId: 1, name: "Soupe à l'oignon", portions: 4 }],
|
|
||||||
originalSteps: [],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
index: 2,
|
|
||||||
kind: "cooking",
|
|
||||||
background: [
|
|
||||||
{
|
|
||||||
id: "bg:step:0:1",
|
|
||||||
technique: { id: 5, key: "simmer" },
|
|
||||||
description: "Faire mijoter le bouillon",
|
|
||||||
recipeId: 1,
|
|
||||||
recipeName: "Soupe à l'oignon",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
tasks: [
|
|
||||||
{
|
|
||||||
id: "step:1:3",
|
|
||||||
kind: "step",
|
|
||||||
technique: { id: 4, key: "bake" },
|
|
||||||
description: "Enfourner la tarte",
|
|
||||||
ingredients: [],
|
|
||||||
utensils: [],
|
|
||||||
sourceRecipes: [{ recipeId: 2, name: "Tarte à l'oignon", portions: 4 }],
|
|
||||||
originalSteps: [],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("Cooking session page", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
cy.viewport(1400, 900);
|
|
||||||
cy.clock(TODAY, ["Date"]);
|
|
||||||
cy.intercept("GET", "**/auth/me", { statusCode: 200, body: authenticatedProfile });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("shows the empty message when nothing is planned that week", () => {
|
|
||||||
cy.intercept("GET", /\/cooking-session\?/, {
|
|
||||||
statusCode: 200,
|
|
||||||
body: {
|
|
||||||
startDate: "2026-08-17T00:00:00.000Z",
|
|
||||||
finishDate: "2026-08-23T00:00:00.000Z",
|
|
||||||
recipes: [],
|
|
||||||
phases: [],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
cy.visit("/cuisiner");
|
|
||||||
|
|
||||||
cy.contains("h1", "Cuisiner cette semaine").should("be.visible");
|
|
||||||
cy.contains("Rien de planifié cette semaine à cuisiner").should("be.visible");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("renders each phase, the pooled prep task, and the 'meanwhile' band", () => {
|
|
||||||
cy.intercept("GET", /\/cooking-session\?/, { statusCode: 200, body: planFixture() }).as(
|
|
||||||
"getPlan",
|
|
||||||
);
|
|
||||||
|
|
||||||
cy.visit("/cuisiner?date=2026-08-17");
|
|
||||||
cy.wait("@getPlan").its("request.url").should("include", "date=2026-08-17");
|
|
||||||
|
|
||||||
// Mise en place: one pooled prep task, flagged shared, naming both recipes.
|
|
||||||
cy.contains(".cooking-phase", "Mise en place").should("be.visible");
|
|
||||||
cy.get(".cooking-task--merged-prep")
|
|
||||||
.should("contain.text", "Hacher")
|
|
||||||
.and("contain.text", "Oignon")
|
|
||||||
.and("contain.text", "Mutualisé");
|
|
||||||
cy.contains(".cooking-task--merged-prep", "Soupe à l'oignon").should("exist");
|
|
||||||
|
|
||||||
// A later phase shows the simmering soup as still running in the background.
|
|
||||||
cy.contains(".cooking-phase__background", "Pendant ce temps")
|
|
||||||
.should("contain.text", "Faire mijoter le bouillon")
|
|
||||||
.and("contain.text", "Soupe à l'oignon");
|
|
||||||
|
|
||||||
// Recipe legend is present.
|
|
||||||
cy.contains(".cooking-session__legend-item", "Tarte à l'oignon").should("be.visible");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("shows an error state when the request fails", () => {
|
|
||||||
cy.intercept("GET", /\/cooking-session\?/, {
|
|
||||||
statusCode: 500,
|
|
||||||
body: { code: 5000, message: "boom" },
|
|
||||||
});
|
|
||||||
|
|
||||||
cy.visit("/cuisiner");
|
|
||||||
|
|
||||||
cy.contains("Impossible de charger").should("be.visible");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,24 +0,0 @@
|
||||||
Feature: Start cooking an optimized plan
|
|
||||||
As a member of a household with a planned week
|
|
||||||
I want to open an optimized cooking plan from my planning
|
|
||||||
So that shared preparation is pooled and I cook the week efficiently
|
|
||||||
|
|
||||||
Background:
|
|
||||||
Given I am signed in as "Alice" "Martin"
|
|
||||||
And my household id is 1
|
|
||||||
And today is frozen at "2026-08-17T09:00:00.000Z"
|
|
||||||
|
|
||||||
Scenario: The "Commencer à cuisiner" button is disabled while the week is empty
|
|
||||||
Given the planning request returns nothing
|
|
||||||
When I visit "/"
|
|
||||||
Then the "Commencer à cuisiner" button should be disabled
|
|
||||||
|
|
||||||
Scenario: Opening the plan from the planning shows the pooled prep in mise en place
|
|
||||||
Given the planning for this week has recipes "Soupe à l'oignon" and "Tarte à l'oignon"
|
|
||||||
And the cooking plan for "2026-08-17" pools "Hacher" of "Oignon" across both recipes
|
|
||||||
When I visit "/"
|
|
||||||
And I click the button "Commencer à cuisiner"
|
|
||||||
Then the URL should include "/cuisiner"
|
|
||||||
And I should see "Mise en place"
|
|
||||||
And the pooled prep task should mention "Hacher" and "Oignon"
|
|
||||||
And the pooled prep task should be flagged as shared
|
|
||||||
|
|
@ -1,101 +0,0 @@
|
||||||
import { Given, Then } from "@badeball/cypress-cucumber-preprocessor";
|
|
||||||
|
|
||||||
/** Bare `IngredientView` — only `key` drives the page's `catalog.ingredients.*` lookup. */
|
|
||||||
function ingredient(key: string) {
|
|
||||||
return {
|
|
||||||
id: 1,
|
|
||||||
key,
|
|
||||||
icon: "VEGETABLE",
|
|
||||||
category: "freshProduce",
|
|
||||||
subcategory: "vegetables",
|
|
||||||
reproducible: false,
|
|
||||||
allergens: [],
|
|
||||||
diets: [],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
Given(
|
|
||||||
"the planning for this week has recipes {string} and {string}",
|
|
||||||
(first: string, second: string) => {
|
|
||||||
cy.intercept("GET", /\/planning\?/, {
|
|
||||||
statusCode: 200,
|
|
||||||
body: {
|
|
||||||
id: 1,
|
|
||||||
startDate: "2026-08-17T00:00:00.000Z",
|
|
||||||
finishDate: "2026-08-23T00:00:00.000Z",
|
|
||||||
items: [
|
|
||||||
{
|
|
||||||
id: 1,
|
|
||||||
weekDay: "lundi",
|
|
||||||
meal: "dejeuner",
|
|
||||||
portions: 4,
|
|
||||||
recipe: { id: 1, name: first },
|
|
||||||
},
|
|
||||||
{ id: 2, weekDay: "mardi", meal: "diner", portions: 4, recipe: { id: 2, name: second } },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
Given(
|
|
||||||
"the cooking plan for {string} pools {string} of {string} across both recipes",
|
|
||||||
(date: string, _techniqueLabel: string, _ingredientLabel: string) => {
|
|
||||||
// The page composes the headline itself from the technique/ingredient
|
|
||||||
// *keys* via i18n — `chop`→"Hacher", `onion`→"Oignon" — so the fixture
|
|
||||||
// carries keys; the `Then` step checks the rendered French labels the
|
|
||||||
// feature line names.
|
|
||||||
cy.intercept("GET", `**/cooking-session?date=${date}`, {
|
|
||||||
statusCode: 200,
|
|
||||||
body: {
|
|
||||||
startDate: `${date}T00:00:00.000Z`,
|
|
||||||
finishDate: "2026-08-23T00:00:00.000Z",
|
|
||||||
recipes: [
|
|
||||||
{ recipeId: 1, name: "Soupe à l'oignon", portions: 4 },
|
|
||||||
{ recipeId: 2, name: "Tarte à l'oignon", portions: 4 },
|
|
||||||
],
|
|
||||||
phases: [
|
|
||||||
{
|
|
||||||
index: 0,
|
|
||||||
kind: "mise-en-place",
|
|
||||||
background: [],
|
|
||||||
tasks: [
|
|
||||||
{
|
|
||||||
id: "prep:chop:onion",
|
|
||||||
kind: "merged-prep",
|
|
||||||
technique: { id: 1, key: "chop" },
|
|
||||||
description: null,
|
|
||||||
ingredients: [
|
|
||||||
{
|
|
||||||
ingredient: ingredient("onion"),
|
|
||||||
quantity: 5,
|
|
||||||
unit: { id: 2, key: "piece", type: "COUNT", toBaseFactor: 1 },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
utensils: [],
|
|
||||||
sourceRecipes: [
|
|
||||||
{ recipeId: 1, name: "Soupe à l'oignon", portions: 4 },
|
|
||||||
{ recipeId: 2, name: "Tarte à l'oignon", portions: 4 },
|
|
||||||
],
|
|
||||||
originalSteps: [],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
Then(
|
|
||||||
"the pooled prep task should mention {string} and {string}",
|
|
||||||
(techniqueLabel: string, ingredientLabel: string) => {
|
|
||||||
cy.get(".cooking-task--merged-prep")
|
|
||||||
.should("contain.text", techniqueLabel)
|
|
||||||
.and("contain.text", ingredientLabel);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
Then("the pooled prep task should be flagged as shared", () => {
|
|
||||||
cy.get(".cooking-task--merged-prep").contains("Mutualisé").should("be.visible");
|
|
||||||
});
|
|
||||||
|
|
@ -111,43 +111,6 @@ describe("Planning grid", () => {
|
||||||
cy.contains("th.today .day-date", "17").should("be.visible");
|
cy.contains("th.today .day-date", "17").should("be.visible");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("disables 'Commencer à cuisiner' on an empty week and enables + navigates it once a recipe is planned", () => {
|
|
||||||
cy.intercept("GET", /\/planning\?/, { statusCode: 200, body: null });
|
|
||||||
cy.visit("/");
|
|
||||||
cy.contains("button", "Commencer à cuisiner").should("be.disabled");
|
|
||||||
|
|
||||||
cy.intercept("GET", /\/planning\?/, {
|
|
||||||
statusCode: 200,
|
|
||||||
body: {
|
|
||||||
id: 1,
|
|
||||||
startDate: "2026-08-17T00:00:00.000Z",
|
|
||||||
finishDate: "2026-08-23T00:00:00.000Z",
|
|
||||||
items: [
|
|
||||||
{
|
|
||||||
id: 1,
|
|
||||||
weekDay: "mardi",
|
|
||||||
meal: "diner",
|
|
||||||
portions: 4,
|
|
||||||
recipe: { id: 1, name: "Ratatouille" },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
cy.intercept("GET", /\/cooking-session\?/, {
|
|
||||||
statusCode: 200,
|
|
||||||
body: {
|
|
||||||
startDate: "2026-08-17T00:00:00.000Z",
|
|
||||||
finishDate: "2026-08-23T00:00:00.000Z",
|
|
||||||
recipes: [],
|
|
||||||
phases: [],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
cy.visit("/");
|
|
||||||
cy.contains("button", "Commencer à cuisiner").should("not.be.disabled").click();
|
|
||||||
cy.url().should("include", "/cuisiner");
|
|
||||||
cy.url().should("include", "date=2026-08-17");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("shows a loading state, then an error state when the request fails", () => {
|
it("shows a loading state, then an error state when the request fails", () => {
|
||||||
cy.intercept("GET", /\/planning\?/, {
|
cy.intercept("GET", /\/planning\?/, {
|
||||||
statusCode: 500,
|
statusCode: 500,
|
||||||
|
|
|
||||||
|
|
@ -45,21 +45,6 @@ Feature: Recipe form — associating ingredients
|
||||||
And I add a step
|
And I add a step
|
||||||
Then there should be 2 step editor items
|
Then there should be 2 step editor items
|
||||||
|
|
||||||
Scenario: Adds an off-catalog ingredient as a free-text placeholder when nothing in the picker matches
|
|
||||||
Given creating the recipe will succeed and return id 43
|
|
||||||
When I visit "/recettes/nouvelle"
|
|
||||||
And I fill in the "recipe-name" field with "Poulet basquaise"
|
|
||||||
And I search the ingredient picker for "piment d'espelette"
|
|
||||||
Then the picker should offer to add "piment d'espelette" as a placeholder
|
|
||||||
When I add "piment d'espelette" as a placeholder ingredient
|
|
||||||
Then the recipe should include the placeholder ingredient "piment d'espelette"
|
|
||||||
When I fill in the ingredient's quantity with "1" and unit "unité"
|
|
||||||
And I add a step
|
|
||||||
And I fill in the step description with "Tout mélanger."
|
|
||||||
And I click the button "Enregistrer"
|
|
||||||
Then the recipe creation request should have included a placeholder ingredient "piment d'espelette" with quantity 1 and unitId 1
|
|
||||||
And the URL should include "/recettes/43"
|
|
||||||
|
|
||||||
Scenario: Excludes an already-selected ingredient from the picker, and removing it brings it back
|
Scenario: Excludes an already-selected ingredient from the picker, and removing it brings it back
|
||||||
When I visit "/recettes/nouvelle"
|
When I visit "/recettes/nouvelle"
|
||||||
And I select the ingredient "Carotte" from the picker
|
And I select the ingredient "Carotte" from the picker
|
||||||
|
|
|
||||||
|
|
@ -70,31 +70,6 @@ Then(
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
Then("the picker should offer to add {string} as a placeholder", (name: string) => {
|
|
||||||
cy.get(".ingredient-picker__add-placeholder").should("contain.text", name);
|
|
||||||
});
|
|
||||||
|
|
||||||
When("I add {string} as a placeholder ingredient", () => {
|
|
||||||
// The search field already holds the query from the previous step, so the
|
|
||||||
// "add « … »" button carries the right name.
|
|
||||||
cy.get(".ingredient-picker__add-placeholder").click();
|
|
||||||
});
|
|
||||||
|
|
||||||
Then("the recipe should include the placeholder ingredient {string}", (name: string) => {
|
|
||||||
cy.contains(".ingredient-row__name", name)
|
|
||||||
.find(".ingredient-row__placeholder-badge")
|
|
||||||
.should("be.visible");
|
|
||||||
});
|
|
||||||
|
|
||||||
Then(
|
|
||||||
"the recipe creation request should have included a placeholder ingredient {string} with quantity {int} and unitId {int}",
|
|
||||||
(placeholderName: string, quantity: number, unitId: number) => {
|
|
||||||
cy.wait("@createRecipe")
|
|
||||||
.its("request.body.ingredients")
|
|
||||||
.should("deep.equal", [{ placeholderName, quantity, unitId }]);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
Given("recipe 7 exists with an egg omelette", () => {
|
Given("recipe 7 exists with an egg omelette", () => {
|
||||||
const existingRecipe = {
|
const existingRecipe = {
|
||||||
id: 7,
|
id: 7,
|
||||||
|
|
|
||||||
|
|
@ -273,10 +273,6 @@ Then("the {string} button should not be disabled", (text: string) => {
|
||||||
cy.contains("button", text).should("not.be.disabled");
|
cy.contains("button", text).should("not.be.disabled");
|
||||||
});
|
});
|
||||||
|
|
||||||
Then("the {string} button should be disabled", (text: string) => {
|
|
||||||
cy.contains("button", text).should("be.disabled");
|
|
||||||
});
|
|
||||||
|
|
||||||
When("I open the account menu", () => {
|
When("I open the account menu", () => {
|
||||||
cy.get(".app-sidebar__account-toggle").click();
|
cy.get(".app-sidebar__account-toggle").click();
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,6 @@
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
"react-i18next": "^17.0.11",
|
"react-i18next": "^17.0.11",
|
||||||
"react-router-dom": "^7.18.2",
|
"react-router-dom": "^7.18.2",
|
||||||
"recharts": "^2.15.0",
|
|
||||||
"zod": "^3.25.76"
|
"zod": "^3.25.76"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|
|
||||||
|
|
@ -1,18 +1,9 @@
|
||||||
import { Navigate, Outlet, Route, Routes } from "react-router-dom";
|
import { Navigate, Route, Routes } from "react-router-dom";
|
||||||
import { AdminAuthProvider } from "./features/admin/AdminAuthContext";
|
|
||||||
import { RequireAdmin } from "./features/admin/RequireAdmin";
|
|
||||||
import { RedirectIfAuthenticated } from "./features/auth/RedirectIfAuthenticated";
|
import { RedirectIfAuthenticated } from "./features/auth/RedirectIfAuthenticated";
|
||||||
import { RequireAuth } from "./features/auth/RequireAuth";
|
import { RequireAuth } from "./features/auth/RequireAuth";
|
||||||
import { AdminLayout } from "./layouts/AdminLayout";
|
|
||||||
import { AppLayout } from "./layouts/AppLayout";
|
import { AppLayout } from "./layouts/AppLayout";
|
||||||
import { CatalogPage as AdminCatalogPage } from "./pages/admin/catalog/CatalogPage";
|
|
||||||
import { CorrectionsPage as AdminCorrectionsPage } from "./pages/admin/corrections/CorrectionsPage";
|
|
||||||
import { DashboardPage as AdminDashboardPage } from "./pages/admin/dashboard/DashboardPage";
|
|
||||||
import { AdminLoginPage } from "./pages/admin/login/AdminLoginPage";
|
|
||||||
import { MonitoringPage as AdminMonitoringPage } from "./pages/admin/monitoring/MonitoringPage";
|
|
||||||
import { LoginPage } from "./pages/auth/LoginPage";
|
import { LoginPage } from "./pages/auth/LoginPage";
|
||||||
import { SignupPage } from "./pages/auth/SignupPage";
|
import { SignupPage } from "./pages/auth/SignupPage";
|
||||||
import { CookingSessionPage } from "./pages/cooking-session/CookingSessionPage";
|
|
||||||
import { OnboardingAllergensPage } from "./pages/onboarding/OnboardingAllergensPage";
|
import { OnboardingAllergensPage } from "./pages/onboarding/OnboardingAllergensPage";
|
||||||
import { OnboardingDietPage } from "./pages/onboarding/OnboardingDietPage";
|
import { OnboardingDietPage } from "./pages/onboarding/OnboardingDietPage";
|
||||||
import { OnboardingHouseholdPage } from "./pages/onboarding/OnboardingHouseholdPage";
|
import { OnboardingHouseholdPage } from "./pages/onboarding/OnboardingHouseholdPage";
|
||||||
|
|
@ -51,15 +42,6 @@ import { ShoppingListPage } from "./pages/shopping-list/ShoppingListPage";
|
||||||
* `/onboarding/sources` is conditional — only reached when the `foyer` step
|
* `/onboarding/sources` is conditional — only reached when the `foyer` step
|
||||||
* created/joined a household (see `OnboardingHouseholdPage`'s `goToNextStep`);
|
* created/joined a household (see `OnboardingHouseholdPage`'s `goToNextStep`);
|
||||||
* skipped otherwise, straight to `/onboarding/allergenes`.
|
* skipped otherwise, straight to `/onboarding/allergenes`.
|
||||||
*
|
|
||||||
* `/admin/*` is the internal admin surface (metrics, monitoring, correction
|
|
||||||
* triage, off-catalog ingredient review). It's its own top-level group,
|
|
||||||
* wrapped in {@link AdminAuthProvider} so the `GET /admin/auth/me` session
|
|
||||||
* probe only runs under `/admin` — the admin session (cookie `admin_session`,
|
|
||||||
* `ADMIN_JWT_SECRET`) is entirely separate from the end-user one, so this is
|
|
||||||
* never nested under `RequireAuth`. `/admin/login` is the only
|
|
||||||
* unauthenticated admin route; everything else sits under `RequireAdmin` +
|
|
||||||
* `AdminLayout`.
|
|
||||||
*/
|
*/
|
||||||
export function App() {
|
export function App() {
|
||||||
return (
|
return (
|
||||||
|
|
@ -83,7 +65,6 @@ export function App() {
|
||||||
<Route path="/recettes/:id" element={<RecipesPage />} />
|
<Route path="/recettes/:id" element={<RecipesPage />} />
|
||||||
<Route path="/recettes/:id/modifier" element={<RecipeFormPage />} />
|
<Route path="/recettes/:id/modifier" element={<RecipeFormPage />} />
|
||||||
<Route path="/liste-de-courses" element={<ShoppingListPage />} />
|
<Route path="/liste-de-courses" element={<ShoppingListPage />} />
|
||||||
<Route path="/cuisiner" element={<CookingSessionPage />} />
|
|
||||||
<Route path="/parametres/compte" element={<AccountSettingsPage />} />
|
<Route path="/parametres/compte" element={<AccountSettingsPage />} />
|
||||||
<Route path="/parametres/preferences" element={<PreferencesPage />} />
|
<Route path="/parametres/preferences" element={<PreferencesPage />} />
|
||||||
<Route path="/parametres/foyer" element={<HouseholdSettingsPage />} />
|
<Route path="/parametres/foyer" element={<HouseholdSettingsPage />} />
|
||||||
|
|
@ -139,28 +120,6 @@ export function App() {
|
||||||
</RedirectIfAuthenticated>
|
</RedirectIfAuthenticated>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Route
|
|
||||||
element={
|
|
||||||
<AdminAuthProvider>
|
|
||||||
<Outlet />
|
|
||||||
</AdminAuthProvider>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Route path="/admin/login" element={<AdminLoginPage />} />
|
|
||||||
<Route
|
|
||||||
path="/admin"
|
|
||||||
element={
|
|
||||||
<RequireAdmin>
|
|
||||||
<AdminLayout />
|
|
||||||
</RequireAdmin>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Route index element={<AdminDashboardPage />} />
|
|
||||||
<Route path="monitoring" element={<AdminMonitoringPage />} />
|
|
||||||
<Route path="corrections" element={<AdminCorrectionsPage />} />
|
|
||||||
<Route path="catalogue" element={<AdminCatalogPage />} />
|
|
||||||
</Route>
|
|
||||||
</Route>
|
|
||||||
<Route path="*" element={<Navigate to="/" replace />} />
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,192 +0,0 @@
|
||||||
import {
|
|
||||||
type AdminLoginInput,
|
|
||||||
type AdminUserView,
|
|
||||||
type ApiErrorResponse,
|
|
||||||
type CatalogPlaceholderGroupView,
|
|
||||||
type CorrectionAdminView,
|
|
||||||
ErrorCode,
|
|
||||||
type MarkPlaceholdersReviewedInput,
|
|
||||||
type MetricsView,
|
|
||||||
type MonitoringView,
|
|
||||||
type PruneOrphansResultView,
|
|
||||||
type RetrainRequestInput,
|
|
||||||
type RetrainResultView,
|
|
||||||
type TrainingDataSnippetView,
|
|
||||||
type TrainingSuggestionAdminView,
|
|
||||||
type TrainingSuggestionGroupView,
|
|
||||||
type UpdateTrainingSuggestionInput,
|
|
||||||
} from "@batch-cooking/shared";
|
|
||||||
|
|
||||||
/** Builds a `?a=b&c=d` string from defined values only. */
|
|
||||||
function query(params: Record<string, string | undefined>): string {
|
|
||||||
const entries = Object.entries(params).filter(
|
|
||||||
(entry): entry is [string, string] => entry[1] !== undefined && entry[1] !== "",
|
|
||||||
);
|
|
||||||
return entries.length === 0 ? "" : `?${new URLSearchParams(entries).toString()}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Base URL of the API — the same `VITE_API_URL` the user-facing
|
|
||||||
* {@link apiClient} reads (see `api/client.ts`). Defaults to `""` (same
|
|
||||||
* origin) — correct behind a shared reverse proxy; native dev overrides it
|
|
||||||
* to `http://localhost:3000` in `apps/web/.env`. The `/admin/*` surface is
|
|
||||||
* served by the same API process as the rest of the app.
|
|
||||||
*/
|
|
||||||
const ADMIN_API_BASE_URL: string = import.meta.env.VITE_API_URL ?? "";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Thrown by {@link AdminApiClient} on any non-2xx response — carries the
|
|
||||||
* {@link ErrorCode} the API returned. Distinct from `api/client.ts`'s
|
|
||||||
* `ApiError` (same shape) so a file importing both never has a name clash;
|
|
||||||
* the admin transport layer stays independent of the user-facing one.
|
|
||||||
*/
|
|
||||||
export class AdminApiError extends Error {
|
|
||||||
public readonly status: number;
|
|
||||||
public readonly code: ErrorCode;
|
|
||||||
public readonly fieldErrors?: Record<string, string[] | undefined>;
|
|
||||||
|
|
||||||
public constructor(status: number, body: ApiErrorResponse) {
|
|
||||||
super(body.message);
|
|
||||||
this.name = "AdminApiError";
|
|
||||||
this.status = status;
|
|
||||||
this.code = body.code;
|
|
||||||
this.fieldErrors = body.details;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Thin fetch wrapper around the `/admin/*` endpoints — same design as
|
|
||||||
* apps/web's `ApiClient` (a class for cohesion/extensibility, one shared
|
|
||||||
* stateless instance). Every request sends credentials so the
|
|
||||||
* `admin_session` httpOnly cookie round-trips.
|
|
||||||
*/
|
|
||||||
export class AdminApiClient {
|
|
||||||
/**
|
|
||||||
* Performs a JSON request against the admin API and returns the parsed body.
|
|
||||||
*
|
|
||||||
* @throws {AdminApiError} if the response status is not in the 2xx range.
|
|
||||||
*/
|
|
||||||
private async _request<TResponseBody>(
|
|
||||||
path: string,
|
|
||||||
options: RequestInit = {},
|
|
||||||
): Promise<TResponseBody> {
|
|
||||||
try {
|
|
||||||
const response = await fetch(`${ADMIN_API_BASE_URL}${path}`, {
|
|
||||||
...options,
|
|
||||||
credentials: "include",
|
|
||||||
headers: { "Content-Type": "application/json", ...options.headers },
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const body = (await response.json().catch(() => null)) as ApiErrorResponse | null;
|
|
||||||
throw new AdminApiError(
|
|
||||||
response.status,
|
|
||||||
body ?? { code: ErrorCode.INTERNAL_ERROR, message: "Something went wrong" },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (response.status === 204) {
|
|
||||||
return undefined as TResponseBody;
|
|
||||||
}
|
|
||||||
return (await response.json()) as TResponseBody;
|
|
||||||
} catch (err) {
|
|
||||||
// Rethrown as-is — callers surface it their own way; this is just the
|
|
||||||
// one place the fetch/`await` sits in a try/catch per the repo's rule.
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Verifies admin credentials and starts an admin session. */
|
|
||||||
public login(input: AdminLoginInput): Promise<AdminUserView> {
|
|
||||||
return this._request("/admin/auth/login", { method: "POST", body: JSON.stringify(input) });
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Ends the current admin session. */
|
|
||||||
public logout(): Promise<void> {
|
|
||||||
return this._request("/admin/auth/logout", { method: "POST" });
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Fetches the currently authenticated admin — rejects with `NOT_AUTHENTICATED` if there's no session. */
|
|
||||||
public me(): Promise<AdminUserView> {
|
|
||||||
return this._request("/admin/auth/me");
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Usage metrics for the dashboard — snapshot totals + `days` (7–365) 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();
|
|
||||||
|
|
@ -9,7 +9,6 @@ import {
|
||||||
type HouseView,
|
type HouseView,
|
||||||
type IngredientView,
|
type IngredientView,
|
||||||
type LoginInput,
|
type LoginInput,
|
||||||
type OptimizedCookingPlanView,
|
|
||||||
type PlanningItemView,
|
type PlanningItemView,
|
||||||
type PlanningView,
|
type PlanningView,
|
||||||
type PreferencesView,
|
type PreferencesView,
|
||||||
|
|
@ -178,19 +177,6 @@ export class ApiClient {
|
||||||
return this._request(`/shopping-list?date=${date}`);
|
return this._request(`/shopping-list?date=${date}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetches the current user's household's optimized cooking plan for the
|
|
||||||
* week covering `date` (`YYYY-MM-DD`) — every recipe planned that week
|
|
||||||
* reorganized into ordered phases (shared prep pooled, passive cooks
|
|
||||||
* floated into the background). Like {@link getShoppingListForWeek} and
|
|
||||||
* unlike {@link getPlanningForWeek}, never resolves to `null`: no
|
|
||||||
* household or nothing planned both come back as a normal plan with
|
|
||||||
* empty `recipes`/`phases`.
|
|
||||||
*/
|
|
||||||
public getCookingPlanForWeek(date: string): Promise<OptimizedCookingPlanView> {
|
|
||||||
return this._request(`/cooking-session?date=${date}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Reference list of dietary regimes to pick from (signup wizard, `/foyer`). Public — no session required. */
|
/** Reference list of dietary regimes to pick from (signup wizard, `/foyer`). Public — no session required. */
|
||||||
public getDiets(): Promise<DietView[]> {
|
public getDiets(): Promise<DietView[]> {
|
||||||
return this._request("/reference/diets");
|
return this._request("/reference/diets");
|
||||||
|
|
|
||||||
|
|
@ -1,71 +0,0 @@
|
||||||
import type { AdminLoginInput, AdminUserView } from "@batch-cooking/shared";
|
|
||||||
import { createContext, type ReactNode, useCallback, useContext, useEffect, useState } from "react";
|
|
||||||
import { adminApiClient } from "../../api/admin-client";
|
|
||||||
|
|
||||||
/** Auth state/actions exposed via {@link useAdminAuth} — the admin-app counterpart of apps/web's `AuthContext`. */
|
|
||||||
interface AdminAuthContextValue {
|
|
||||||
/** Currently authenticated admin, or `null` if no active session. */
|
|
||||||
admin: AdminUserView | null;
|
|
||||||
/** True only while the initial `GET /admin/auth/me` check is pending — lets `RequireAdmin` avoid a premature redirect. */
|
|
||||||
isLoading: boolean;
|
|
||||||
/** Verifies credentials and updates `admin` on success. Throws `ApiError` on failure. */
|
|
||||||
login: (input: AdminLoginInput) => Promise<void>;
|
|
||||||
/** Ends the session and clears `admin`. */
|
|
||||||
logout: () => Promise<void>;
|
|
||||||
}
|
|
||||||
|
|
||||||
const AdminAuthContext = createContext<AdminAuthContextValue | null>(null);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Provides admin authentication state to the whole app. On mount, calls
|
|
||||||
* `GET /admin/auth/me` once to restore the session from the `admin_session`
|
|
||||||
* httpOnly cookie (if any) — same "reload keeps you logged in" behaviour as
|
|
||||||
* apps/web's `AuthProvider`.
|
|
||||||
*/
|
|
||||||
export function AdminAuthProvider({ children }: { children: ReactNode }) {
|
|
||||||
const [admin, setAdmin] = useState<AdminUserView | null>(null);
|
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
adminApiClient
|
|
||||||
.me()
|
|
||||||
.then(setAdmin)
|
|
||||||
// No/invalid session — the normal state for a first visit, not an error.
|
|
||||||
.catch(() => setAdmin(null))
|
|
||||||
.finally(() => setIsLoading(false));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const login = useCallback(async (input: AdminLoginInput) => {
|
|
||||||
try {
|
|
||||||
setAdmin(await adminApiClient.login(input));
|
|
||||||
} catch (err) {
|
|
||||||
// Rethrown as-is — `LoginPage`'s submit handler catches and displays
|
|
||||||
// it; this callback just isn't allowed a bare `await`.
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const logout = useCallback(async () => {
|
|
||||||
try {
|
|
||||||
await adminApiClient.logout();
|
|
||||||
setAdmin(null);
|
|
||||||
} catch (err) {
|
|
||||||
throw err; // see login()'s catch comment
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<AdminAuthContext.Provider value={{ admin, isLoading, login, logout }}>
|
|
||||||
{children}
|
|
||||||
</AdminAuthContext.Provider>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Reads the current admin auth state/actions. Must be called within an {@link AdminAuthProvider}. */
|
|
||||||
export function useAdminAuth(): AdminAuthContextValue {
|
|
||||||
const ctx = useContext(AdminAuthContext);
|
|
||||||
if (!ctx) {
|
|
||||||
throw new Error("useAdminAuth must be used within an AdminAuthProvider");
|
|
||||||
}
|
|
||||||
return ctx;
|
|
||||||
}
|
|
||||||
|
|
@ -1,22 +0,0 @@
|
||||||
import type { ReactNode } from "react";
|
|
||||||
import { Navigate } from "react-router-dom";
|
|
||||||
import { useAdminAuth } from "./AdminAuthContext";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Route guard for every admin page. Renders nothing while the initial
|
|
||||||
* `GET /admin/auth/me` check is pending (avoids a flash-then-redirect);
|
|
||||||
* once resolved, renders `children` or redirects to `/admin/login`. Mirror
|
|
||||||
* of `RequireAuth` (`features/auth/`), but keyed to the separate admin
|
|
||||||
* session (`admin_session` cookie), not the user one.
|
|
||||||
*/
|
|
||||||
export function RequireAdmin({ children }: { children: ReactNode }) {
|
|
||||||
const { admin, isLoading } = useAdminAuth();
|
|
||||||
|
|
||||||
if (isLoading) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
if (!admin) {
|
|
||||||
return <Navigate to="/admin/login" replace />;
|
|
||||||
}
|
|
||||||
return <>{children}</>;
|
|
||||||
}
|
|
||||||
|
|
@ -1,84 +0,0 @@
|
||||||
// =============================================================================
|
|
||||||
// Admin login card — the only unauthenticated screen. A centered card on a
|
|
||||||
// plain background, same language as apps/web's auth-form.scss (kept its own
|
|
||||||
// copy rather than shared, the two apps' chrome is independent).
|
|
||||||
// =============================================================================
|
|
||||||
|
|
||||||
.admin-auth-page {
|
|
||||||
min-height: 100vh;
|
|
||||||
display: grid;
|
|
||||||
place-items: center;
|
|
||||||
padding: var(--space-lg);
|
|
||||||
background: var(--color-background);
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-auth-card {
|
|
||||||
width: 100%;
|
|
||||||
max-width: var(--max-width-form);
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: var(--space-sm);
|
|
||||||
padding: var(--space-xl);
|
|
||||||
background: var(--color-surface);
|
|
||||||
border-radius: var(--radius-lg);
|
|
||||||
box-shadow: var(--shadow-md);
|
|
||||||
|
|
||||||
h1 {
|
|
||||||
font-size: var(--font-size-xl);
|
|
||||||
margin-bottom: var(--space-sm);
|
|
||||||
}
|
|
||||||
|
|
||||||
label {
|
|
||||||
font-size: var(--font-size-sm);
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
input {
|
|
||||||
padding: var(--space-sm);
|
|
||||||
font-size: var(--font-size-base);
|
|
||||||
font-family: var(--font-body);
|
|
||||||
border: 1.5px solid var(--color-border);
|
|
||||||
border-radius: var(--radius-base);
|
|
||||||
background: var(--color-surface);
|
|
||||||
color: var(--color-text);
|
|
||||||
|
|
||||||
&:focus-visible {
|
|
||||||
border-color: var(--color-primary);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
button[type="submit"] {
|
|
||||||
margin-top: var(--space-sm);
|
|
||||||
padding: var(--space-sm) var(--space-md);
|
|
||||||
font-size: var(--font-size-base);
|
|
||||||
font-weight: 600;
|
|
||||||
font-family: var(--font-body);
|
|
||||||
color: var(--color-surface);
|
|
||||||
background: var(--color-primary);
|
|
||||||
border: none;
|
|
||||||
border-radius: var(--radius-base);
|
|
||||||
cursor: pointer;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
background: var(--color-primary-hover);
|
|
||||||
}
|
|
||||||
|
|
||||||
&:disabled {
|
|
||||||
opacity: 0.6;
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.field-error {
|
|
||||||
margin: 0;
|
|
||||||
font-size: var(--font-size-xs);
|
|
||||||
color: var(--color-error);
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-error {
|
|
||||||
margin: var(--space-xs) 0 0;
|
|
||||||
font-size: var(--font-size-sm);
|
|
||||||
color: var(--color-error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -17,7 +17,6 @@ import { Dialog } from "../../components/ui/Dialog";
|
||||||
import { errorMessageService } from "../../services/error-message.service";
|
import { errorMessageService } from "../../services/error-message.service";
|
||||||
import { DietTagSelect } from "../recipes/badges/DietTagSelect";
|
import { DietTagSelect } from "../recipes/badges/DietTagSelect";
|
||||||
import { IngredientPicker } from "../recipes/ingredients/IngredientPicker";
|
import { IngredientPicker } from "../recipes/ingredients/IngredientPicker";
|
||||||
import { ingredientLabel } from "../recipes/ingredients/ingredient-label";
|
|
||||||
import { RecipeDetailPanel, type RecipeDetailState } from "../recipes/RecipeDetailPanel";
|
import { RecipeDetailPanel, type RecipeDetailState } from "../recipes/RecipeDetailPanel";
|
||||||
import { RecipeTable } from "../recipes/RecipeTable";
|
import { RecipeTable } from "../recipes/RecipeTable";
|
||||||
import {
|
import {
|
||||||
|
|
@ -448,7 +447,7 @@ export function RecipePickerDialog({
|
||||||
<div className="recipe-picker__chips">
|
<div className="recipe-picker__chips">
|
||||||
{selectedIngredients.map((ingredient) => (
|
{selectedIngredients.map((ingredient) => (
|
||||||
<span key={ingredient.id} className="filter-chip">
|
<span key={ingredient.id} className="filter-chip">
|
||||||
{ingredientLabel(ingredient, t)}
|
{t(`catalog.ingredients.${ingredient.key}`)}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,6 @@ import { IngredientPicker } from "../recipes/ingredients/IngredientPicker";
|
||||||
// picker" visual family, even though this field lives in the profile
|
// picker" visual family, even though this field lives in the profile
|
||||||
// feature).
|
// feature).
|
||||||
import { IngredientTypeIcon } from "../recipes/ingredients/ingredient-icons";
|
import { IngredientTypeIcon } from "../recipes/ingredients/ingredient-icons";
|
||||||
import { ingredientLabel } from "../recipes/ingredients/ingredient-label";
|
|
||||||
import "../recipes/recipes.scss";
|
import "../recipes/recipes.scss";
|
||||||
import "./profile-forms.scss";
|
import "./profile-forms.scss";
|
||||||
|
|
||||||
|
|
@ -55,7 +54,7 @@ export function DislikedIngredientsField({
|
||||||
<span aria-hidden="true">
|
<span aria-hidden="true">
|
||||||
<IngredientTypeIcon icon={ingredient.icon} />
|
<IngredientTypeIcon icon={ingredient.icon} />
|
||||||
</span>
|
</span>
|
||||||
{ingredientLabel(ingredient, t)}
|
{t(`catalog.ingredients.${ingredient.key}`)}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => remove(ingredient.id)}
|
onClick={() => remove(ingredient.id)}
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,6 @@ import { SourceLinkIcon } from "../../layouts/nav-icons";
|
||||||
import { errorMessageService } from "../../services/error-message.service";
|
import { errorMessageService } from "../../services/error-message.service";
|
||||||
import { AllergenBadges } from "./badges/AllergenBadges";
|
import { AllergenBadges } from "./badges/AllergenBadges";
|
||||||
import { FavoriteStarButton } from "./badges/FavoriteStarButton";
|
import { FavoriteStarButton } from "./badges/FavoriteStarButton";
|
||||||
import { ingredientLabel } from "./ingredients/ingredient-label";
|
|
||||||
import { StepDescription } from "./steps/StepDescription";
|
import { StepDescription } from "./steps/StepDescription";
|
||||||
import "./recipes.scss";
|
import "./recipes.scss";
|
||||||
|
|
||||||
|
|
@ -181,7 +180,7 @@ export function RecipeDetailPanel({
|
||||||
<ul className="disliked-badges">
|
<ul className="disliked-badges">
|
||||||
{dislikedIngredients.map((ingredient) => (
|
{dislikedIngredients.map((ingredient) => (
|
||||||
<li key={ingredient.id} className="disliked-badge">
|
<li key={ingredient.id} className="disliked-badge">
|
||||||
🚫 {ingredientLabel(ingredient, t)}
|
🚫 {t(`catalog.ingredients.${ingredient.key}`)}
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,6 @@ import { AllergenBadges } from "../badges/AllergenBadges";
|
||||||
import { DietBadges } from "../badges/DietBadges";
|
import { DietBadges } from "../badges/DietBadges";
|
||||||
import { ReproducibleBadge } from "../badges/ReproducibleBadge";
|
import { ReproducibleBadge } from "../badges/ReproducibleBadge";
|
||||||
import { CategoryIcon, IngredientTypeIcon, SubcategoryIcon } from "./ingredient-icons";
|
import { CategoryIcon, IngredientTypeIcon, SubcategoryIcon } from "./ingredient-icons";
|
||||||
import { ingredientLabel } from "./ingredient-label";
|
|
||||||
import "../recipes.scss";
|
import "../recipes.scss";
|
||||||
|
|
||||||
/** "No filter at this level" — a UI-only pseudo-value, never sent to/received from the API (see {@link INGREDIENT_CATEGORIES}/{@link INGREDIENT_CATEGORY_SUBCATEGORIES} for the real, closed sets). */
|
/** "No filter at this level" — a UI-only pseudo-value, never sent to/received from the API (see {@link INGREDIENT_CATEGORIES}/{@link INGREDIENT_CATEGORY_SUBCATEGORIES} for the real, closed sets). */
|
||||||
|
|
@ -41,25 +40,15 @@ const ALL = "ALL" as const;
|
||||||
* just browsing/searching by name. A labeled checkbox menu rather than two
|
* just browsing/searching by name. A labeled checkbox menu rather than two
|
||||||
* bare icon-only toggle buttons — those turned out too ambiguous on their
|
* bare icon-only toggle buttons — those turned out too ambiguous on their
|
||||||
* own (unclear what each icon meant without a label attached).
|
* own (unclear what each icon meant without a label attached).
|
||||||
*
|
|
||||||
* `onAddPlaceholder`, when passed, turns the "aucun ingrédient trouvé"
|
|
||||||
* empty state into an escape hatch: with a non-empty search query, an
|
|
||||||
* "ajouter « … » comme ingrédient à compléter" button lets the user add a
|
|
||||||
* free-text line so the catalog gap doesn't block them (see
|
|
||||||
* `Ingredient.isPlaceholder` in schema.prisma). Omitted on pickers where
|
|
||||||
* that makes no sense — a profile's disliked-ingredients field, a search
|
|
||||||
* filter.
|
|
||||||
*/
|
*/
|
||||||
export function IngredientPicker({
|
export function IngredientPicker({
|
||||||
ingredients,
|
ingredients,
|
||||||
excludeIds,
|
excludeIds,
|
||||||
onSelect,
|
onSelect,
|
||||||
onAddPlaceholder,
|
|
||||||
}: {
|
}: {
|
||||||
ingredients: IngredientView[];
|
ingredients: IngredientView[];
|
||||||
excludeIds: number[];
|
excludeIds: number[];
|
||||||
onSelect: (ingredient: IngredientView) => void;
|
onSelect: (ingredient: IngredientView) => void;
|
||||||
onAddPlaceholder?: (name: string) => void;
|
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [category, setCategory] = useState<IngredientCategory | typeof ALL>(ALL);
|
const [category, setCategory] = useState<IngredientCategory | typeof ALL>(ALL);
|
||||||
|
|
@ -91,26 +80,17 @@ export function IngredientPicker({
|
||||||
// key — searching "œuf" should find "Œuf" the way it always has, not
|
// key — searching "œuf" should find "Œuf" the way it always has, not
|
||||||
// require typing its key.
|
// require typing its key.
|
||||||
if (normalizedQuery.length > 0) {
|
if (normalizedQuery.length > 0) {
|
||||||
const label = ingredientLabel(ingredient, t).toLowerCase();
|
const label = t(`catalog.ingredients.${ingredient.key}`).toLowerCase();
|
||||||
if (!label.includes(normalizedQuery)) return false;
|
if (!label.includes(normalizedQuery)) return false;
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
|
|
||||||
const trimmedQuery = query.trim();
|
|
||||||
|
|
||||||
function handleSelect(ingredient: IngredientView) {
|
function handleSelect(ingredient: IngredientView) {
|
||||||
onSelect(ingredient);
|
onSelect(ingredient);
|
||||||
setQuery("");
|
setQuery("");
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleAddPlaceholder() {
|
|
||||||
if (onAddPlaceholder && trimmedQuery.length > 0) {
|
|
||||||
onAddPlaceholder(trimmedQuery);
|
|
||||||
setQuery("");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="ingredient-picker">
|
<div className="ingredient-picker">
|
||||||
<div className="ingredient-picker__search">
|
<div className="ingredient-picker__search">
|
||||||
|
|
@ -191,18 +171,7 @@ export function IngredientPicker({
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{visible.length === 0 ? (
|
{visible.length === 0 ? (
|
||||||
<div className="ingredient-picker__empty">
|
<p className="ingredient-picker__empty">{t("recipes.form.noIngredientFound")}</p>
|
||||||
<p>{t("recipes.form.noIngredientFound")}</p>
|
|
||||||
{onAddPlaceholder && trimmedQuery.length > 0 && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="ingredient-picker__add-placeholder"
|
|
||||||
onClick={handleAddPlaceholder}
|
|
||||||
>
|
|
||||||
{t("recipes.form.addPlaceholderButton", { name: trimmedQuery })}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
) : (
|
) : (
|
||||||
<div className="ingredient-picker__grid">
|
<div className="ingredient-picker__grid">
|
||||||
{visible.map((ingredient) => (
|
{visible.map((ingredient) => (
|
||||||
|
|
@ -215,7 +184,9 @@ export function IngredientPicker({
|
||||||
<span className="ingredient-picker__card-icon" aria-hidden="true">
|
<span className="ingredient-picker__card-icon" aria-hidden="true">
|
||||||
<IngredientTypeIcon icon={ingredient.icon} />
|
<IngredientTypeIcon icon={ingredient.icon} />
|
||||||
</span>
|
</span>
|
||||||
<span className="ingredient-picker__card-name">{ingredientLabel(ingredient, t)}</span>
|
<span className="ingredient-picker__card-name">
|
||||||
|
{t(`catalog.ingredients.${ingredient.key}`)}
|
||||||
|
</span>
|
||||||
{showAllergens && <AllergenBadges allergens={ingredient.allergens} />}
|
{showAllergens && <AllergenBadges allergens={ingredient.allergens} />}
|
||||||
{showDiets && <DietBadges diets={ingredient.diets} />}
|
{showDiets && <DietBadges diets={ingredient.diets} />}
|
||||||
{showReproducible && <ReproducibleBadge reproducible={ingredient.reproducible} />}
|
{showReproducible && <ReproducibleBadge reproducible={ingredient.reproducible} />}
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,6 @@ import { AllergenBadges } from "../badges/AllergenBadges";
|
||||||
import { DietBadges } from "../badges/DietBadges";
|
import { DietBadges } from "../badges/DietBadges";
|
||||||
import { ReproducibleBadge } from "../badges/ReproducibleBadge";
|
import { ReproducibleBadge } from "../badges/ReproducibleBadge";
|
||||||
import { IngredientTypeIcon } from "./ingredient-icons";
|
import { IngredientTypeIcon } from "./ingredient-icons";
|
||||||
import { ingredientLabel } from "./ingredient-label";
|
|
||||||
import "../recipes.scss";
|
import "../recipes.scss";
|
||||||
|
|
||||||
/** One selected ingredient line in the recipe form — the ingredient itself (picked via `IngredientPicker`) plus its quantity/unit for this recipe. Quantity is kept as a raw string while editing (not parsed to a number until submit) so an in-progress/invalid value doesn't fight the input; `unit` picks from `unitsCatalog` (a closed reference list, see `Unit` in schema.prisma) rather than free text. */
|
/** One selected ingredient line in the recipe form — the ingredient itself (picked via `IngredientPicker`) plus its quantity/unit for this recipe. Quantity is kept as a raw string while editing (not parsed to a number until submit) so an in-progress/invalid value doesn't fight the input; `unit` picks from `unitsCatalog` (a closed reference list, see `Unit` in schema.prisma) rather than free text. */
|
||||||
|
|
@ -37,25 +36,13 @@ export function IngredientRow({
|
||||||
// them, or why).
|
// them, or why).
|
||||||
const unitMissing = unitId === null;
|
const unitMissing = unitId === null;
|
||||||
const needsAttention = unitMissing || duplicate;
|
const needsAttention = unitMissing || duplicate;
|
||||||
// A free-text placeholder line (the catalog had nothing matching) — shown
|
|
||||||
// with an "à compléter" badge and none of the allergen/diet/reproducible
|
|
||||||
// badges, which carry no meaning until a maintainer promotes it to a real
|
|
||||||
// catalog entry. Its quantity/unit still work exactly like any other line.
|
|
||||||
const isPlaceholder = ingredient.isPlaceholder;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<li className={needsAttention ? "ingredient-row ingredient-row--incomplete" : "ingredient-row"}>
|
<li className={needsAttention ? "ingredient-row ingredient-row--incomplete" : "ingredient-row"}>
|
||||||
<span className="ingredient-row__icon" aria-hidden="true">
|
<span className="ingredient-row__icon" aria-hidden="true">
|
||||||
<IngredientTypeIcon icon={ingredient.icon} />
|
<IngredientTypeIcon icon={ingredient.icon} />
|
||||||
</span>
|
</span>
|
||||||
<span className="ingredient-row__name">
|
<span className="ingredient-row__name">{t(`catalog.ingredients.${ingredient.key}`)}</span>
|
||||||
{ingredientLabel(ingredient, t)}
|
|
||||||
{isPlaceholder && (
|
|
||||||
<span className="ingredient-row__placeholder-badge">
|
|
||||||
{t("recipes.form.placeholderBadge")}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
min="0"
|
min="0"
|
||||||
|
|
@ -81,16 +68,12 @@ export function IngredientRow({
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
{!isPlaceholder && (
|
|
||||||
<>
|
|
||||||
<AllergenBadges allergens={ingredient.allergens} />
|
<AllergenBadges allergens={ingredient.allergens} />
|
||||||
<DietBadges diets={ingredient.diets} />
|
<DietBadges diets={ingredient.diets} />
|
||||||
<ReproducibleBadge
|
<ReproducibleBadge
|
||||||
reproducible={ingredient.reproducible}
|
reproducible={ingredient.reproducible}
|
||||||
searchLabel={ingredientLabel(ingredient, t)}
|
searchLabel={t(`catalog.ingredients.${ingredient.key}`)}
|
||||||
/>
|
/>
|
||||||
</>
|
|
||||||
)}
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="ingredient-row__remove"
|
className="ingredient-row__remove"
|
||||||
|
|
|
||||||
|
|
@ -1,26 +0,0 @@
|
||||||
import type { IngredientView } from "@batch-cooking/shared";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The display name for an ingredient line.
|
|
||||||
*
|
|
||||||
* A real catalog ingredient has no name in the database — its French label
|
|
||||||
* lives in i18n under `catalog.ingredients.<key>` — so it's resolved
|
|
||||||
* through `t`. A **placeholder** (`IngredientView.isPlaceholder`, the
|
|
||||||
* free text a user typed when the catalog fell short — see
|
|
||||||
* `Ingredient.isPlaceholder` in schema.prisma) has its name stored on the
|
|
||||||
* row as `displayName` and no i18n key at all, so it's shown verbatim.
|
|
||||||
*
|
|
||||||
* Every place that used to inline `t(\`catalog.ingredients.${ingredient.key}\`)`
|
|
||||||
* goes through this instead, so a placeholder never renders as the raw,
|
|
||||||
* missing translation key `catalog.ingredients.placeholder:<uuid>`.
|
|
||||||
*
|
|
||||||
* `t` is passed in (rather than calling `useTranslation` here) so this
|
|
||||||
* stays a plain function usable from non-component code and unit-testable
|
|
||||||
* without mounting i18next — same split as `shopping-list.ts`.
|
|
||||||
*/
|
|
||||||
export function ingredientLabel(
|
|
||||||
ingredient: Pick<IngredientView, "key" | "displayName">,
|
|
||||||
t: (key: string) => string,
|
|
||||||
): string {
|
|
||||||
return ingredient.displayName ?? t(`catalog.ingredients.${ingredient.key}`);
|
|
||||||
}
|
|
||||||
|
|
@ -1,50 +0,0 @@
|
||||||
import type { IngredientView } from "@batch-cooking/shared";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Sentinel `id` for a placeholder ingredient line the user just added in
|
|
||||||
* the recipe form but that has **no database row yet** — the API creates
|
|
||||||
* the real `Ingredient` row (with a real id) when the recipe is saved (see
|
|
||||||
* `recipe.service.ts`'s `resolveIngredientLines`). Never sent to the API:
|
|
||||||
* the submit payload uses `placeholderName`, not `ingredientId`, for these
|
|
||||||
* lines. Kept out of `excludeIds`/duplicate checks by testing
|
|
||||||
* `isPlaceholder` rather than the id.
|
|
||||||
*/
|
|
||||||
export const UNSAVED_PLACEHOLDER_ID = 0;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Builds the in-memory {@link IngredientView} for a brand-new placeholder
|
|
||||||
* line (the "add « … » as an ingredient to complete" action in
|
|
||||||
* `IngredientPicker`). Shaped exactly like what the API returns for a
|
|
||||||
* placeholder once persisted — `isPlaceholder: true`, the typed text as
|
|
||||||
* `displayName`, a generic `JAR` icon, default aisle, no allergen/diet
|
|
||||||
* info — so `IngredientRow` renders it through the same path as an
|
|
||||||
* edit-loaded placeholder with no special-casing.
|
|
||||||
*/
|
|
||||||
export function makePlaceholderIngredientView(name: string): IngredientView {
|
|
||||||
return {
|
|
||||||
id: UNSAVED_PLACEHOLDER_ID,
|
|
||||||
key: "",
|
|
||||||
icon: "JAR",
|
|
||||||
category: "dryGoods",
|
|
||||||
subcategory: "other",
|
|
||||||
reproducible: false,
|
|
||||||
allergens: [],
|
|
||||||
diets: [],
|
|
||||||
isPlaceholder: true,
|
|
||||||
displayName: name,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Whether this line is a placeholder the user just added that has **no
|
|
||||||
* database row yet** — the submit payload must describe it with
|
|
||||||
* `placeholderName` (asking the API to create the row) rather than
|
|
||||||
* `ingredientId`. An *edit-loaded* placeholder fails this test: it already
|
|
||||||
* has a real id and round-trips as a normal `{ ingredientId }` line, so
|
|
||||||
* editing a recipe never spawns a duplicate placeholder row.
|
|
||||||
*/
|
|
||||||
export function isUnsavedPlaceholder(
|
|
||||||
ingredient: Pick<IngredientView, "id" | "isPlaceholder">,
|
|
||||||
): boolean {
|
|
||||||
return ingredient.isPlaceholder && ingredient.id === UNSAVED_PLACEHOLDER_ID;
|
|
||||||
}
|
|
||||||
|
|
@ -1218,19 +1218,6 @@
|
||||||
margin-right: auto;
|
margin-right: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Marks a free-text placeholder line (nothing in the catalog matched) —
|
|
||||||
// the ingredient still needs a real catalog entry created by a
|
|
||||||
// maintainer, but the line is usable (quantity/unit) in the meantime.
|
|
||||||
&__placeholder-badge {
|
|
||||||
margin-left: var(--space-xs);
|
|
||||||
padding: 0 var(--space-xs);
|
|
||||||
font-size: var(--font-size-xs);
|
|
||||||
font-weight: 500;
|
|
||||||
border-radius: var(--radius-base);
|
|
||||||
background: var(--color-surface-alt);
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
&__quantity {
|
&__quantity {
|
||||||
width: 5rem;
|
width: 5rem;
|
||||||
}
|
}
|
||||||
|
|
@ -1525,37 +1512,11 @@
|
||||||
font-size: var(--font-size-sm);
|
font-size: var(--font-size-sm);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Now a container (not a bare `<p>`) — the "aucun ingrédient trouvé"
|
|
||||||
// message plus, when the picker allows it, an "ajouter « … » comme
|
|
||||||
// ingrédient à compléter" escape-hatch button stacked under it.
|
|
||||||
&__empty {
|
&__empty {
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--space-sm);
|
|
||||||
padding: var(--space-md);
|
padding: var(--space-md);
|
||||||
font-size: var(--font-size-sm);
|
font-size: var(--font-size-sm);
|
||||||
color: var(--color-text-muted);
|
color: var(--color-text-muted);
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
|
||||||
p {
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&__add-placeholder {
|
|
||||||
padding: var(--space-xs) var(--space-md);
|
|
||||||
font-size: var(--font-size-sm);
|
|
||||||
cursor: pointer;
|
|
||||||
border: 1px dashed var(--color-border);
|
|
||||||
border-radius: var(--radius-base);
|
|
||||||
background: var(--color-surface);
|
|
||||||
color: var(--color-text);
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
border-color: var(--color-accent);
|
|
||||||
color: var(--color-accent);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -19,17 +19,13 @@ import { errorMessageService } from "../../../services/error-message.service";
|
||||||
import { DietTagSelect } from "../badges/DietTagSelect";
|
import { DietTagSelect } from "../badges/DietTagSelect";
|
||||||
import { IngredientPicker } from "../ingredients/IngredientPicker";
|
import { IngredientPicker } from "../ingredients/IngredientPicker";
|
||||||
import { IngredientRow } from "../ingredients/IngredientRow";
|
import { IngredientRow } from "../ingredients/IngredientRow";
|
||||||
import {
|
|
||||||
isUnsavedPlaceholder,
|
|
||||||
makePlaceholderIngredientView,
|
|
||||||
} from "../ingredients/placeholder-ingredient";
|
|
||||||
import { type StepDraft, StepListEditor } from "../steps/StepListEditor";
|
import { type StepDraft, StepListEditor } from "../steps/StepListEditor";
|
||||||
import "../recipes.scss";
|
import "../recipes.scss";
|
||||||
|
|
||||||
/** In display order — mirrors `RecipeVisibility` (schema.prisma/shared types). Same list as `RecipeFormPage`. */
|
/** In display order — mirrors `RecipeVisibility` (schema.prisma/shared types). Same list as `RecipeFormPage`. */
|
||||||
const VISIBILITY_OPTIONS: RecipeVisibility[] = ["PERSONAL", "HOUSE", "PUBLIC"];
|
const VISIBILITY_OPTIONS: RecipeVisibility[] = ["PERSONAL", "HOUSE", "PUBLIC"];
|
||||||
|
|
||||||
/** A resolved ingredient line — identical shape to `RecipeFormPage`'s own `IngredientLine` (`ingredient` may be a synthetic placeholder view, see there). */
|
/** A resolved ingredient line — identical shape to `RecipeFormPage`'s own `IngredientLine`. */
|
||||||
interface IngredientLine {
|
interface IngredientLine {
|
||||||
key: string;
|
key: string;
|
||||||
ingredient: IngredientView;
|
ingredient: IngredientView;
|
||||||
|
|
@ -37,7 +33,7 @@ interface IngredientLine {
|
||||||
unitId: number | null;
|
unitId: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** One draft line that didn't resolve to a real `Ingredient` on preview (`DraftRecipeIngredientView.ingredient === null`) — still needs a person to pick the right one, keep it as a free-text placeholder, or discard it, before this recipe can be saved. */
|
/** One draft line that didn't resolve to a real `Ingredient` on preview (`DraftRecipeIngredientView.ingredient === null`) — still needs a person to pick the right one, or discard it, before this recipe can be saved. */
|
||||||
interface UnresolvedIngredientLine {
|
interface UnresolvedIngredientLine {
|
||||||
key: string;
|
key: string;
|
||||||
rawText: string;
|
rawText: string;
|
||||||
|
|
@ -211,26 +207,6 @@ export function RecipeImportForm({
|
||||||
setResolvingKey(null);
|
setResolvingKey(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Keeps an unresolved line as a free-text placeholder — the catalog had nothing matching, but the ingredient shouldn't be silently dropped (see `Ingredient.isPlaceholder` in schema.prisma). Its unit still needs picking, same as a resolved line. */
|
|
||||||
function keepUnresolvedAsPlaceholder(unresolvedKey: string) {
|
|
||||||
setUnresolvedIngredients((lines) => {
|
|
||||||
const line = lines.find((l) => l.key === unresolvedKey);
|
|
||||||
if (line) {
|
|
||||||
setIngredientLines((resolved) => [
|
|
||||||
...resolved,
|
|
||||||
{
|
|
||||||
key: makeClientKey(),
|
|
||||||
ingredient: makePlaceholderIngredientView(line.rawText.trim()),
|
|
||||||
quantity: line.quantity,
|
|
||||||
unitId: null,
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
return lines.filter((l) => l.key !== unresolvedKey);
|
|
||||||
});
|
|
||||||
setResolvingKey((current) => (current === unresolvedKey ? null : current));
|
|
||||||
}
|
|
||||||
|
|
||||||
function discardUnresolvedIngredient(key: string) {
|
function discardUnresolvedIngredient(key: string) {
|
||||||
setUnresolvedIngredients((lines) => lines.filter((line) => line.key !== key));
|
setUnresolvedIngredients((lines) => lines.filter((line) => line.key !== key));
|
||||||
setResolvingKey((current) => (current === key ? null : current));
|
setResolvingKey((current) => (current === key ? null : current));
|
||||||
|
|
@ -254,10 +230,6 @@ export function RecipeImportForm({
|
||||||
const seen = new Set<number>();
|
const seen = new Set<number>();
|
||||||
const duplicates = new Set<number>();
|
const duplicates = new Set<number>();
|
||||||
for (const line of ingredientLines) {
|
for (const line of ingredientLines) {
|
||||||
// Placeholder lines have no catalog id (all share the sentinel 0) and
|
|
||||||
// each becomes its own fresh row server-side — two of them never
|
|
||||||
// collide on `RecipeIngredient`'s `(recipeId, ingredientId)` key.
|
|
||||||
if (line.ingredient.isPlaceholder) continue;
|
|
||||||
if (seen.has(line.ingredient.id)) duplicates.add(line.ingredient.id);
|
if (seen.has(line.ingredient.id)) duplicates.add(line.ingredient.id);
|
||||||
seen.add(line.ingredient.id);
|
seen.add(line.ingredient.id);
|
||||||
}
|
}
|
||||||
|
|
@ -288,12 +260,7 @@ export function RecipeImportForm({
|
||||||
visibility,
|
visibility,
|
||||||
dietIds,
|
dietIds,
|
||||||
ingredients: ingredientLines.map((line) => ({
|
ingredients: ingredientLines.map((line) => ({
|
||||||
// Free-text lines (nothing in the catalog matched) submit as
|
ingredientId: line.ingredient.id,
|
||||||
// `placeholderName` so the API creates the placeholder row — same
|
|
||||||
// branch as RecipeFormPage's identical submit.
|
|
||||||
...(isUnsavedPlaceholder(line.ingredient)
|
|
||||||
? { placeholderName: line.ingredient.displayName ?? "" }
|
|
||||||
: { ingredientId: line.ingredient.id }),
|
|
||||||
quantity: Number(line.quantity),
|
quantity: Number(line.quantity),
|
||||||
// `canSubmit` already requires every line to have a unit picked —
|
// `canSubmit` already requires every line to have a unit picked —
|
||||||
// same "?? 0, the schema rejects it if ever reached" reasoning as
|
// same "?? 0, the schema rejects it if ever reached" reasoning as
|
||||||
|
|
@ -364,9 +331,7 @@ export function RecipeImportForm({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const selectedIds = ingredientLines
|
const selectedIds = ingredientLines.map((line) => line.ingredient.id);
|
||||||
.filter((line) => !line.ingredient.isPlaceholder)
|
|
||||||
.map((line) => line.ingredient.id);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form className="recipe-form" onSubmit={handleSubmit} noValidate>
|
<form className="recipe-form" onSubmit={handleSubmit} noValidate>
|
||||||
|
|
@ -456,9 +421,6 @@ export function RecipeImportForm({
|
||||||
>
|
>
|
||||||
{t("recipes.sources.import.resolveButton")}
|
{t("recipes.sources.import.resolveButton")}
|
||||||
</button>
|
</button>
|
||||||
<button type="button" onClick={() => keepUnresolvedAsPlaceholder(line.key)}>
|
|
||||||
{t("recipes.sources.import.keepAsPlaceholderButton")}
|
|
||||||
</button>
|
|
||||||
<button type="button" onClick={() => discardUnresolvedIngredient(line.key)}>
|
<button type="button" onClick={() => discardUnresolvedIngredient(line.key)}>
|
||||||
{t("recipes.sources.import.discardButton")}
|
{t("recipes.sources.import.discardButton")}
|
||||||
</button>
|
</button>
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,6 @@ import { useEffect, useRef, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { ApiError, apiClient } from "../../../api/client";
|
import { ApiError, apiClient } from "../../../api/client";
|
||||||
import { errorMessageService } from "../../../services/error-message.service";
|
import { errorMessageService } from "../../../services/error-message.service";
|
||||||
import { ingredientLabel } from "../ingredients/ingredient-label";
|
|
||||||
import { CatalogSearchPicker } from "./CatalogSearchPicker";
|
import { CatalogSearchPicker } from "./CatalogSearchPicker";
|
||||||
import type { TextSelectionRange } from "./use-text-selection";
|
import type { TextSelectionRange } from "./use-text-selection";
|
||||||
|
|
||||||
|
|
@ -350,7 +349,7 @@ export function TechStepCorrectionPopover({
|
||||||
<CatalogSearchPicker
|
<CatalogSearchPicker
|
||||||
items={(catalogs?.ingredients ?? []).map((ingredient) => ({
|
items={(catalogs?.ingredients ?? []).map((ingredient) => ({
|
||||||
id: ingredient.id,
|
id: ingredient.id,
|
||||||
label: ingredientLabel(ingredient, t),
|
label: t(`catalog.ingredients.${ingredient.key}`),
|
||||||
}))}
|
}))}
|
||||||
onSelect={setPickedIngredientId}
|
onSelect={setPickedIngredientId}
|
||||||
placeholder={t("recipes.form.searchIngredientPlaceholder")}
|
placeholder={t("recipes.form.searchIngredientPlaceholder")}
|
||||||
|
|
@ -448,7 +447,7 @@ export function TechStepCorrectionPopover({
|
||||||
const view = ingredientById.get(ingredient.ingredientId);
|
const view = ingredientById.get(ingredient.ingredientId);
|
||||||
const unit =
|
const unit =
|
||||||
ingredient.unitId !== null ? unitById.get(ingredient.unitId) : undefined;
|
ingredient.unitId !== null ? unitById.get(ingredient.unitId) : undefined;
|
||||||
const label = view ? ingredientLabel(view, t) : "…";
|
const label = view ? t(`catalog.ingredients.${view.key}`) : "…";
|
||||||
return (
|
return (
|
||||||
// biome-ignore lint/suspicious/noArrayIndexKey: `pendingIngredients` has no other stable identity (an ingredient can appear more than once, each with its own span) — always fully rebuilt on add/remove, never reordered in place.
|
// biome-ignore lint/suspicious/noArrayIndexKey: `pendingIngredients` has no other stable identity (an ingredient can appear more than once, each with its own span) — always fully rebuilt on add/remove, never reordered in place.
|
||||||
<li key={index} className="tech-step-correction-popover__chip">
|
<li key={index} className="tech-step-correction-popover__chip">
|
||||||
|
|
|
||||||
|
|
@ -1,108 +0,0 @@
|
||||||
// =============================================================================
|
|
||||||
// Admin app shell — a fixed left sidebar + scrollable main content area.
|
|
||||||
// Simpler than apps/web's AppLayout (no collapsible rail, no nested submenu)
|
|
||||||
// — an internal ops tool, three sections.
|
|
||||||
// =============================================================================
|
|
||||||
|
|
||||||
.admin-layout {
|
|
||||||
display: flex;
|
|
||||||
min-height: 100vh;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-sidebar {
|
|
||||||
flex-shrink: 0;
|
|
||||||
width: 15rem;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
padding: var(--space-lg) var(--space-md);
|
|
||||||
background: var(--color-surface);
|
|
||||||
border-right: 1px solid var(--color-border);
|
|
||||||
|
|
||||||
&__brand {
|
|
||||||
font-family: var(--font-display);
|
|
||||||
font-weight: 700;
|
|
||||||
font-size: var(--font-size-lg);
|
|
||||||
color: var(--color-text);
|
|
||||||
margin-bottom: var(--space-lg);
|
|
||||||
|
|
||||||
span {
|
|
||||||
color: var(--color-accent);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&__nav {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.15rem;
|
|
||||||
|
|
||||||
a {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--space-sm);
|
|
||||||
padding: var(--space-sm);
|
|
||||||
border-radius: var(--radius-base);
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
text-decoration: none;
|
|
||||||
font-size: var(--font-size-sm);
|
|
||||||
font-weight: 600;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
background: var(--color-surface-alt);
|
|
||||||
color: var(--color-text);
|
|
||||||
}
|
|
||||||
|
|
||||||
&.active {
|
|
||||||
background: color-mix(in srgb, var(--color-primary) 12%, var(--color-surface));
|
|
||||||
color: var(--color-primary);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&__footer {
|
|
||||||
margin-top: auto;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: var(--space-xs);
|
|
||||||
padding-top: var(--space-md);
|
|
||||||
border-top: 1px solid var(--color-border);
|
|
||||||
}
|
|
||||||
|
|
||||||
&__who {
|
|
||||||
font-size: var(--font-size-sm);
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--color-text);
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
&__footer button {
|
|
||||||
padding: var(--space-xs) var(--space-sm);
|
|
||||||
font-size: var(--font-size-sm);
|
|
||||||
font-family: var(--font-body);
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
background: none;
|
|
||||||
border: 1px solid var(--color-border);
|
|
||||||
border-radius: var(--radius-base);
|
|
||||||
cursor: pointer;
|
|
||||||
text-align: left;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
color: var(--color-text);
|
|
||||||
border-color: var(--color-primary);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&__version {
|
|
||||||
margin: 0;
|
|
||||||
font-size: var(--font-size-xs);
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-content {
|
|
||||||
flex: 1;
|
|
||||||
min-width: 0;
|
|
||||||
padding: var(--space-xl);
|
|
||||||
overflow: auto;
|
|
||||||
}
|
|
||||||
|
|
@ -1,86 +0,0 @@
|
||||||
import { Activity, LayoutDashboard, ListChecks, PackageSearch } from "lucide-react";
|
|
||||||
import { useState } from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { NavLink, Outlet, useNavigate } from "react-router-dom";
|
|
||||||
import { useAdminAuth } from "../features/admin/AdminAuthContext";
|
|
||||||
import "./AdminLayout.scss";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* One entry in the admin sidebar's nav. `key` maps to `admin.nav.<key>` in
|
|
||||||
* the locale file — adding a section is one array entry plus one locale key.
|
|
||||||
* Paths are absolute under `/admin` (the admin route group lives inside
|
|
||||||
* `apps/web`'s `App.tsx`, mounted at `/admin`).
|
|
||||||
*/
|
|
||||||
const NAV_ITEMS = [
|
|
||||||
{ to: "/admin", key: "dashboard", Icon: LayoutDashboard, end: true },
|
|
||||||
{ to: "/admin/monitoring", key: "monitoring", Icon: Activity, end: false },
|
|
||||||
{ to: "/admin/corrections", key: "corrections", Icon: ListChecks, end: false },
|
|
||||||
{ to: "/admin/catalogue", key: "catalog", Icon: PackageSearch, end: false },
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Shell for every authenticated admin page: a fixed sidebar (brand, section
|
|
||||||
* nav, the signed-in admin's name + logout) plus a main area rendering the
|
|
||||||
* matched child route via `<Outlet />`. Mounted once as the parent of the
|
|
||||||
* whole `RequireAdmin`-guarded route group (see `App.tsx`), so `admin` is
|
|
||||||
* guaranteed non-null here.
|
|
||||||
*/
|
|
||||||
export function AdminLayout() {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const { admin, logout } = useAdminAuth();
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const [isLoggingOut, setIsLoggingOut] = useState(false);
|
|
||||||
|
|
||||||
async function handleLogout() {
|
|
||||||
setIsLoggingOut(true);
|
|
||||||
try {
|
|
||||||
await logout();
|
|
||||||
void navigate("/admin/login");
|
|
||||||
} catch {
|
|
||||||
// Even if the network call failed, the local session state was
|
|
||||||
// cleared optimistically enough for the guard to bounce to /login;
|
|
||||||
// nothing useful to show the operator here.
|
|
||||||
void navigate("/admin/login");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="admin-layout">
|
|
||||||
<aside className="admin-sidebar">
|
|
||||||
<div className="admin-sidebar__brand">
|
|
||||||
batchCooking <span>Admin</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<nav className="admin-sidebar__nav">
|
|
||||||
{NAV_ITEMS.map(({ to, key, Icon, end }) => (
|
|
||||||
<NavLink
|
|
||||||
key={to}
|
|
||||||
to={to}
|
|
||||||
end={end}
|
|
||||||
className={({ isActive }) => (isActive ? "active" : undefined)}
|
|
||||||
>
|
|
||||||
<Icon size={18} aria-hidden="true" />
|
|
||||||
<span>{t(`admin.nav.${key}`)}</span>
|
|
||||||
</NavLink>
|
|
||||||
))}
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
<div className="admin-sidebar__footer">
|
|
||||||
<span className="admin-sidebar__who" title={admin?.email}>
|
|
||||||
{admin?.name}
|
|
||||||
</span>
|
|
||||||
<button type="button" onClick={handleLogout} disabled={isLoggingOut}>
|
|
||||||
{t("admin.layout.logout")}
|
|
||||||
</button>
|
|
||||||
<p className="admin-sidebar__version" aria-hidden="true">
|
|
||||||
v{__APP_VERSION__}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</aside>
|
|
||||||
|
|
||||||
<main className="admin-content">
|
|
||||||
<Outlet />
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -46,8 +46,7 @@
|
||||||
"TECH_STEP_NOT_FOUND": "Cette technique n'existe pas",
|
"TECH_STEP_NOT_FOUND": "Cette technique n'existe pas",
|
||||||
"UTENSIL_NOT_FOUND": "Un des ustensiles sélectionnés n'existe pas",
|
"UTENSIL_NOT_FOUND": "Un des ustensiles sélectionnés n'existe pas",
|
||||||
"INVALID_CORRECTION_SPAN": "La sélection ne correspond plus au texte de l'étape",
|
"INVALID_CORRECTION_SPAN": "La sélection ne correspond plus au texte de l'étape",
|
||||||
"INTERNAL_ERROR": "Une erreur est survenue, réessayez plus tard",
|
"INTERNAL_ERROR": "Une erreur est survenue, réessayez plus tard"
|
||||||
"RETRAIN_ALREADY_RUNNING": "Un ré-entraînement est déjà en cours"
|
|
||||||
},
|
},
|
||||||
"auth": {
|
"auth": {
|
||||||
"login": {
|
"login": {
|
||||||
|
|
@ -122,7 +121,6 @@
|
||||||
},
|
},
|
||||||
"planning": {
|
"planning": {
|
||||||
"title": "Planning de la semaine",
|
"title": "Planning de la semaine",
|
||||||
"startCooking": "Commencer à cuisiner",
|
|
||||||
"loading": "Chargement du planning…",
|
"loading": "Chargement du planning…",
|
||||||
"meals": {
|
"meals": {
|
||||||
"petit-dejeuner": "Petit-déjeuner",
|
"petit-dejeuner": "Petit-déjeuner",
|
||||||
|
|
@ -217,9 +215,8 @@
|
||||||
"planningHint": "Cette recette sera automatiquement ajoutée à votre planning une fois importée.",
|
"planningHint": "Cette recette sera automatiquement ajoutée à votre planning une fois importée.",
|
||||||
"loadError": "Impossible de charger cette recette pour le moment.",
|
"loadError": "Impossible de charger cette recette pour le moment.",
|
||||||
"unresolvedTitle": "Ingrédients à compléter",
|
"unresolvedTitle": "Ingrédients à compléter",
|
||||||
"unresolvedHint": "Ces lignes n'ont pas été reconnues automatiquement — choisissez le bon ingrédient, gardez le texte tel quel à compléter plus tard, ou retirez-les.",
|
"unresolvedHint": "Ces lignes n'ont pas été reconnues automatiquement — choisissez le bon ingrédient, ou retirez-les.",
|
||||||
"resolveButton": "Choisir un ingrédient",
|
"resolveButton": "Choisir un ingrédient",
|
||||||
"keepAsPlaceholderButton": "Garder tel quel",
|
|
||||||
"discardButton": "Retirer cette ligne",
|
"discardButton": "Retirer cette ligne",
|
||||||
"submit": "Importer",
|
"submit": "Importer",
|
||||||
"submitting": "Import en cours…",
|
"submitting": "Import en cours…",
|
||||||
|
|
@ -256,8 +253,6 @@
|
||||||
"allCategories": "Tout",
|
"allCategories": "Tout",
|
||||||
"allSubcategories": "Tout",
|
"allSubcategories": "Tout",
|
||||||
"noIngredientFound": "Aucun ingrédient trouvé.",
|
"noIngredientFound": "Aucun ingrédient trouvé.",
|
||||||
"addPlaceholderButton": "Ajouter « {{name}} » comme ingrédient à compléter",
|
|
||||||
"placeholderBadge": "à compléter",
|
|
||||||
"category": {
|
"category": {
|
||||||
"freshProduce": "Produits frais",
|
"freshProduce": "Produits frais",
|
||||||
"meatAndSeafood": "Boucherie & poissonnerie",
|
"meatAndSeafood": "Boucherie & poissonnerie",
|
||||||
|
|
@ -315,28 +310,6 @@
|
||||||
"loading": "Chargement de la liste de courses…",
|
"loading": "Chargement de la liste de courses…",
|
||||||
"empty": "Aucun ingrédient à acheter pour cette semaine — ajoutez des recettes à votre planning."
|
"empty": "Aucun ingrédient à acheter pour cette semaine — ajoutez des recettes à votre planning."
|
||||||
},
|
},
|
||||||
"cookingSession": {
|
|
||||||
"title": "Cuisiner cette semaine",
|
|
||||||
"subtitle": "Toutes les étapes de la semaine, regroupées et réordonnées pour cuisiner efficacement.",
|
|
||||||
"loading": "Optimisation du plan de cuisine…",
|
|
||||||
"empty": "Rien de planifié cette semaine à cuisiner — ajoutez des recettes à votre planning.",
|
|
||||||
"recipesLegend": "Recettes de la semaine",
|
|
||||||
"phase": {
|
|
||||||
"label": "Étape {{index}}",
|
|
||||||
"mise-en-place": "Mise en place",
|
|
||||||
"cooking": "Cuisson",
|
|
||||||
"finishing": "Dressage"
|
|
||||||
},
|
|
||||||
"background": {
|
|
||||||
"title": "Pendant ce temps"
|
|
||||||
},
|
|
||||||
"task": {
|
|
||||||
"mergedPrepLabel": "{{technique}} : {{items}}",
|
|
||||||
"forRecipes": "pour {{recipes}}",
|
|
||||||
"utensils": "Ustensiles",
|
|
||||||
"sharedBadge": "Mutualisé"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"account": {
|
"account": {
|
||||||
"title": "Compte",
|
"title": "Compte",
|
||||||
"identity": {
|
"identity": {
|
||||||
|
|
@ -1124,142 +1097,5 @@
|
||||||
"palmSugar": "Sucre de palme",
|
"palmSugar": "Sucre de palme",
|
||||||
"caneSyrup": "Sirop de sucre de canne"
|
"caneSyrup": "Sirop de sucre de canne"
|
||||||
}
|
}
|
||||||
},
|
|
||||||
"admin": {
|
|
||||||
"common": {
|
|
||||||
"comingSoon": "Section à venir.",
|
|
||||||
"loading": "Chargement…",
|
|
||||||
"loadError": "Impossible de charger les données, réessayez plus tard."
|
|
||||||
},
|
|
||||||
"login": {
|
|
||||||
"title": "Administration",
|
|
||||||
"emailLabel": "Email",
|
|
||||||
"passwordLabel": "Mot de passe",
|
|
||||||
"submit": "Se connecter",
|
|
||||||
"submitting": "Connexion…"
|
|
||||||
},
|
|
||||||
"nav": {
|
|
||||||
"dashboard": "Tableau de bord",
|
|
||||||
"monitoring": "Monitoring",
|
|
||||||
"corrections": "Corrections",
|
|
||||||
"catalog": "Catalogue"
|
|
||||||
},
|
|
||||||
"layout": {
|
|
||||||
"logout": "Se déconnecter"
|
|
||||||
},
|
|
||||||
"dashboard": {
|
|
||||||
"title": "Tableau de bord",
|
|
||||||
"lead": "Métriques d'utilisation de l'application.",
|
|
||||||
"windowTotal": "{{n}} sur 30 j",
|
|
||||||
"recipesBySource": "Recettes importées par source",
|
|
||||||
"noImports": "Aucune recette importée.",
|
|
||||||
"events": "Évènements enregistrés (30 j)",
|
|
||||||
"kpi": {
|
|
||||||
"users": "Utilisateurs",
|
|
||||||
"households": "Foyers",
|
|
||||||
"activeHouseholds": "Foyers actifs",
|
|
||||||
"recipes": "Recettes",
|
|
||||||
"recipesImported": "Recettes importées",
|
|
||||||
"plannings": "Plannings",
|
|
||||||
"planningItems": "Créneaux planifiés",
|
|
||||||
"favorites": "Favoris",
|
|
||||||
"corrections": "Corrections",
|
|
||||||
"correctionsUnconsumed": "Corrections à traiter",
|
|
||||||
"trainingSuggestions": "Suggestions d'entraînement",
|
|
||||||
"admins": "Administrateurs"
|
|
||||||
},
|
|
||||||
"series": {
|
|
||||||
"signups": "Inscriptions",
|
|
||||||
"recipesCreated": "Recettes créées",
|
|
||||||
"planningItemsAdded": "Ajouts au planning",
|
|
||||||
"correctionsSubmitted": "Corrections soumises",
|
|
||||||
"trainingSuggestions": "Suggestions générées"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"monitoring": {
|
|
||||||
"title": "Monitoring",
|
|
||||||
"lead": "Santé des microservices et de la base de données.",
|
|
||||||
"lastChecked": "Dernière vérification à {{time}}",
|
|
||||||
"latency": "Latence",
|
|
||||||
"detail": "Détail",
|
|
||||||
"lastRun": "Dernier job",
|
|
||||||
"never": "jamais",
|
|
||||||
"jobFailed": "échec",
|
|
||||||
"status": {
|
|
||||||
"up": "OK",
|
|
||||||
"degraded": "Dégradé",
|
|
||||||
"down": "Hors service",
|
|
||||||
"unknown": "Inconnu"
|
|
||||||
},
|
|
||||||
"service": {
|
|
||||||
"postgres": "Base de données",
|
|
||||||
"api": "API",
|
|
||||||
"intent-service": "Service NLP (spaCy)",
|
|
||||||
"tech-step-llm-worker": "Worker LLM"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"corrections": {
|
|
||||||
"title": "Corrections",
|
|
||||||
"lead": "Tri des corrections utilisateur pour le ré-entraînement NLP.",
|
|
||||||
"caveat": "Le gate F1 + backfill n'a de sens qu'APRÈS avoir édité training_data.py à la main et redémarré le service NLP (il ne s'entraîne qu'au démarrage). Cet écran ne peut faire ni l'un ni l'autre.",
|
|
||||||
"noSuggestions": "Aucune suggestion pour ces filtres.",
|
|
||||||
"synonyms": "Synonymes proposés (un par ligne)",
|
|
||||||
"utterances": "Phrases proposées (une par ligne)",
|
|
||||||
"save": "Enregistrer",
|
|
||||||
"apply": "Appliquer",
|
|
||||||
"reject": "Rejeter",
|
|
||||||
"tab": {
|
|
||||||
"suggestions": "Suggestions",
|
|
||||||
"corrections": "Corrections brutes"
|
|
||||||
},
|
|
||||||
"filter": {
|
|
||||||
"status": "Statut",
|
|
||||||
"source": "Source",
|
|
||||||
"consumed": "Consommée",
|
|
||||||
"hasCorrected": "Technique corrigée",
|
|
||||||
"any": "Toutes",
|
|
||||||
"yes": "Oui",
|
|
||||||
"no": "Non"
|
|
||||||
},
|
|
||||||
"snippet": {
|
|
||||||
"title": "Snippet training_data.py",
|
|
||||||
"help": "Agrège les synonymes/phrases des suggestions « applied » d'une technique, au format à coller dans training_data.py.",
|
|
||||||
"keyPlaceholder": "clé de technique (ex. simmer)",
|
|
||||||
"generate": "Générer"
|
|
||||||
},
|
|
||||||
"retrain": {
|
|
||||||
"title": "Gate F1 + backfill",
|
|
||||||
"help": "Lance l'évaluation de régression F1 puis, si elle passe, recalcule les techniques de toutes les étapes.",
|
|
||||||
"run": "Lancer",
|
|
||||||
"running": "En cours…",
|
|
||||||
"passed": "OK — {{changed}}/{{total}} étape(s) recalculée(s)",
|
|
||||||
"failed": "Échec du gate — aucun backfill"
|
|
||||||
},
|
|
||||||
"col": {
|
|
||||||
"clause": "Clause",
|
|
||||||
"change": "Changement",
|
|
||||||
"created": "Créée",
|
|
||||||
"consumed": "Consommée"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"catalog": {
|
|
||||||
"title": "Ingrédients hors-catalogue",
|
|
||||||
"lead": "Ingrédients saisis en texte libre par les utilisateurs parce que le catalogue ne les couvrait pas. Regroupés par nom normalisé — à promouvoir dans reference-seed-data.ts + les locales, à la main.",
|
|
||||||
"empty": "Aucun ingrédient hors-catalogue.",
|
|
||||||
"tab": {
|
|
||||||
"pending": "À traiter",
|
|
||||||
"reviewed": "Traités"
|
|
||||||
},
|
|
||||||
"recipeCount": "{{count}} recette(s)",
|
|
||||||
"alsoWritten": "Aussi écrit : {{variants}}",
|
|
||||||
"seenIn": "Vu dans :",
|
|
||||||
"firstSeen": "Première fois le {{date}}",
|
|
||||||
"markReviewed": "Marquer comme traité",
|
|
||||||
"marking": "…",
|
|
||||||
"pruneOrphans": "Purger les orphelins",
|
|
||||||
"pruning": "Purge…",
|
|
||||||
"prunedNone": "Aucun placeholder orphelin à purger.",
|
|
||||||
"pruned": "{{count}} placeholder(s) orphelin(s) supprimé(s)."
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,26 +0,0 @@
|
||||||
// =============================================================================
|
|
||||||
// Shared chrome for every routed admin page — a page title and an optional
|
|
||||||
// lead paragraph. Individual pages add their own colocated .scss for their
|
|
||||||
// specific content (charts, tables, status board) on top.
|
|
||||||
// =============================================================================
|
|
||||||
|
|
||||||
.admin-page {
|
|
||||||
&__title {
|
|
||||||
font-size: var(--font-size-2xl);
|
|
||||||
margin-bottom: var(--space-xs);
|
|
||||||
}
|
|
||||||
|
|
||||||
&__lead {
|
|
||||||
margin: 0 0 var(--space-lg);
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
font-size: var(--font-size-md);
|
|
||||||
}
|
|
||||||
|
|
||||||
&__placeholder {
|
|
||||||
padding: var(--space-xl);
|
|
||||||
border: 1px dashed var(--color-border);
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,179 +0,0 @@
|
||||||
import type { CatalogPlaceholderGroupView } from "@batch-cooking/shared";
|
|
||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { adminApiClient } from "../../../api/admin-client";
|
|
||||||
import "../admin-page.scss";
|
|
||||||
import "./catalog-page.scss";
|
|
||||||
import { type CatalogTab, formatDate, reviewedParam, splitSpellings } from "./catalog";
|
|
||||||
|
|
||||||
type CatalogState =
|
|
||||||
| { status: "loading" }
|
|
||||||
| { status: "loaded"; groups: CatalogPlaceholderGroupView[] }
|
|
||||||
| { status: "error" };
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Off-catalog ingredient review. Lists every placeholder `Ingredient` (the
|
|
||||||
* free text users typed when the seeded catalog fell short — see
|
|
||||||
* `Ingredient.isPlaceholder` in the API schema), grouped by normalized
|
|
||||||
* name, so a maintainer sees what the catalog is missing and how many
|
|
||||||
* recipes are waiting on it. Actions are deliberately minimal: mark a gap
|
|
||||||
* as handled, or purge rows no recipe references any more. Actually adding
|
|
||||||
* the catalog entry stays a manual edit of `reference-seed-data.ts` + the
|
|
||||||
* locale files.
|
|
||||||
*/
|
|
||||||
export function CatalogPage() {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const [tab, setTab] = useState<CatalogTab>("pending");
|
|
||||||
const [state, setState] = useState<CatalogState>({ status: "loading" });
|
|
||||||
const [pendingIds, setPendingIds] = useState<number[] | null>(null);
|
|
||||||
const [pruneMessage, setPruneMessage] = useState<string | null>(null);
|
|
||||||
const [isPruning, setIsPruning] = useState(false);
|
|
||||||
|
|
||||||
// Monotonic id of the most recent `load()` call — a response from an
|
|
||||||
// earlier one (switching tabs fast, or React 18 StrictMode's double-mount
|
|
||||||
// firing the effect twice) must not clobber the current tab's data.
|
|
||||||
const requestSeq = useRef(0);
|
|
||||||
|
|
||||||
const load = useCallback((forTab: CatalogTab) => {
|
|
||||||
const seq = ++requestSeq.current;
|
|
||||||
setState({ status: "loading" });
|
|
||||||
adminApiClient
|
|
||||||
.getPlaceholders(reviewedParam(forTab))
|
|
||||||
.then((groups) => {
|
|
||||||
if (seq === requestSeq.current) setState({ status: "loaded", groups });
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
if (seq === requestSeq.current) setState({ status: "error" });
|
|
||||||
});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
load(tab);
|
|
||||||
}, [tab, load]);
|
|
||||||
|
|
||||||
function markReviewed(group: CatalogPlaceholderGroupView) {
|
|
||||||
setPendingIds(group.ingredientIds);
|
|
||||||
adminApiClient
|
|
||||||
.markPlaceholdersReviewed({ ingredientIds: group.ingredientIds })
|
|
||||||
.then(() => load(tab))
|
|
||||||
.catch(() => load(tab))
|
|
||||||
.finally(() => setPendingIds(null));
|
|
||||||
}
|
|
||||||
|
|
||||||
function pruneOrphans() {
|
|
||||||
setIsPruning(true);
|
|
||||||
setPruneMessage(null);
|
|
||||||
adminApiClient
|
|
||||||
.pruneOrphanPlaceholders()
|
|
||||||
.then(({ deleted }) => {
|
|
||||||
setPruneMessage(
|
|
||||||
deleted === 0
|
|
||||||
? t("admin.catalog.prunedNone")
|
|
||||||
: t("admin.catalog.pruned", { count: deleted }),
|
|
||||||
);
|
|
||||||
load(tab);
|
|
||||||
})
|
|
||||||
.catch(() => setPruneMessage(t("admin.common.loadError")))
|
|
||||||
.finally(() => setIsPruning(false));
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="admin-page">
|
|
||||||
<h1 className="admin-page__title">{t("admin.catalog.title")}</h1>
|
|
||||||
<p className="admin-page__lead">{t("admin.catalog.lead")}</p>
|
|
||||||
|
|
||||||
<div className="catalog-toolbar">
|
|
||||||
<div className="catalog-tabs" role="tablist">
|
|
||||||
{(["pending", "reviewed"] as const).map((value) => (
|
|
||||||
<button
|
|
||||||
key={value}
|
|
||||||
type="button"
|
|
||||||
role="tab"
|
|
||||||
aria-selected={tab === value}
|
|
||||||
className={tab === value ? "active" : undefined}
|
|
||||||
onClick={() => setTab(value)}
|
|
||||||
>
|
|
||||||
{t(`admin.catalog.tab.${value}`)}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<button type="button" onClick={pruneOrphans} disabled={isPruning}>
|
|
||||||
{isPruning ? t("admin.catalog.pruning") : t("admin.catalog.pruneOrphans")}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{pruneMessage && <p className="catalog-prune-message">{pruneMessage}</p>}
|
|
||||||
|
|
||||||
{state.status === "loading" && (
|
|
||||||
<p className="admin-page__placeholder">{t("admin.common.loading")}</p>
|
|
||||||
)}
|
|
||||||
{state.status === "error" && (
|
|
||||||
<p className="admin-page__placeholder">{t("admin.common.loadError")}</p>
|
|
||||||
)}
|
|
||||||
{state.status === "loaded" &&
|
|
||||||
(state.groups.length === 0 ? (
|
|
||||||
<p className="admin-page__placeholder">{t("admin.catalog.empty")}</p>
|
|
||||||
) : (
|
|
||||||
<ul className="catalog-list">
|
|
||||||
{state.groups.map((group) => (
|
|
||||||
<PlaceholderGroupCard
|
|
||||||
key={group.normalizedName}
|
|
||||||
group={group}
|
|
||||||
busy={pendingIds === group.ingredientIds}
|
|
||||||
onMarkReviewed={() => markReviewed(group)}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function PlaceholderGroupCard({
|
|
||||||
group,
|
|
||||||
busy,
|
|
||||||
onMarkReviewed,
|
|
||||||
}: {
|
|
||||||
group: CatalogPlaceholderGroupView;
|
|
||||||
busy: boolean;
|
|
||||||
onMarkReviewed: () => void;
|
|
||||||
}) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const { headline, variants } = splitSpellings(group);
|
|
||||||
const firstSeen = formatDate(group.firstSeenAt);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<li className="catalog-card">
|
|
||||||
<div className="catalog-card__head">
|
|
||||||
<h2 className="catalog-card__name">{headline}</h2>
|
|
||||||
<span className="catalog-card__count">
|
|
||||||
{t("admin.catalog.recipeCount", { count: group.recipeCount })}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{variants.length > 0 && (
|
|
||||||
<p className="catalog-card__variants">
|
|
||||||
{t("admin.catalog.alsoWritten", { variants: variants.join(" · ") })}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{group.sampleRecipes.length > 0 && (
|
|
||||||
<p className="catalog-card__recipes">
|
|
||||||
{t("admin.catalog.seenIn")} {group.sampleRecipes.map((recipe) => recipe.name).join(", ")}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="catalog-card__foot">
|
|
||||||
{firstSeen && (
|
|
||||||
<span className="catalog-card__seen">
|
|
||||||
{t("admin.catalog.firstSeen", { date: firstSeen })}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{!group.allReviewed && (
|
|
||||||
<button type="button" onClick={onMarkReviewed} disabled={busy}>
|
|
||||||
{busy ? t("admin.catalog.marking") : t("admin.catalog.markReviewed")}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,144 +0,0 @@
|
||||||
// =============================================================================
|
|
||||||
// CatalogPage — the off-catalog ingredient review: a pending/reviewed tab
|
|
||||||
// switch + "purge orphans" action, then one card per grouped placeholder.
|
|
||||||
// Mirrors CorrectionsPage's tab/card vocabulary so the admin app stays
|
|
||||||
// visually consistent.
|
|
||||||
// =============================================================================
|
|
||||||
|
|
||||||
.catalog-toolbar {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: var(--space-md);
|
|
||||||
margin-bottom: var(--space-md);
|
|
||||||
|
|
||||||
// Right-hand "purge orphans" button — a secondary/destructive action, so
|
|
||||||
// outlined rather than filled like the primary actions elsewhere.
|
|
||||||
> button {
|
|
||||||
padding: var(--space-xs) var(--space-md);
|
|
||||||
font-family: var(--font-body);
|
|
||||||
font-size: var(--font-size-sm);
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
background: var(--color-surface);
|
|
||||||
border: 1px solid var(--color-border);
|
|
||||||
border-radius: var(--radius-base);
|
|
||||||
cursor: pointer;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
border-color: var(--color-error);
|
|
||||||
color: var(--color-error);
|
|
||||||
}
|
|
||||||
&:disabled {
|
|
||||||
opacity: 0.6;
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.catalog-tabs {
|
|
||||||
display: flex;
|
|
||||||
gap: var(--space-xs);
|
|
||||||
|
|
||||||
button {
|
|
||||||
padding: var(--space-sm) var(--space-md);
|
|
||||||
font-family: var(--font-body);
|
|
||||||
font-size: var(--font-size-sm);
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
background: none;
|
|
||||||
border: 1px solid var(--color-border);
|
|
||||||
border-radius: var(--radius-base);
|
|
||||||
cursor: pointer;
|
|
||||||
|
|
||||||
&.active {
|
|
||||||
color: var(--color-primary);
|
|
||||||
border-color: var(--color-primary);
|
|
||||||
background: color-mix(in srgb, var(--color-primary) 10%, var(--color-surface));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.catalog-prune-message {
|
|
||||||
margin: 0 0 var(--space-md);
|
|
||||||
font-size: var(--font-size-sm);
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.catalog-list {
|
|
||||||
list-style: none;
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: var(--space-sm);
|
|
||||||
}
|
|
||||||
|
|
||||||
.catalog-card {
|
|
||||||
padding: var(--space-md);
|
|
||||||
background: var(--color-surface);
|
|
||||||
border: 1px solid var(--color-border);
|
|
||||||
border-left: 4px solid var(--color-accent);
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
|
|
||||||
&__head {
|
|
||||||
display: flex;
|
|
||||||
align-items: baseline;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: var(--space-sm);
|
|
||||||
}
|
|
||||||
|
|
||||||
&__name {
|
|
||||||
font-size: var(--font-size-md);
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
&__count {
|
|
||||||
flex-shrink: 0;
|
|
||||||
font-size: var(--font-size-xs);
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
&__variants,
|
|
||||||
&__recipes {
|
|
||||||
margin: var(--space-xs) 0 0;
|
|
||||||
font-size: var(--font-size-sm);
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
&__foot {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: var(--space-sm);
|
|
||||||
margin-top: var(--space-sm);
|
|
||||||
}
|
|
||||||
|
|
||||||
&__seen {
|
|
||||||
font-size: var(--font-size-xs);
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
&__foot button {
|
|
||||||
padding: var(--space-xs) var(--space-md);
|
|
||||||
font-family: var(--font-body);
|
|
||||||
font-size: var(--font-size-sm);
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--color-surface);
|
|
||||||
background: var(--color-primary);
|
|
||||||
border: none;
|
|
||||||
border-radius: var(--radius-base);
|
|
||||||
cursor: pointer;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
background: var(--color-primary-hover);
|
|
||||||
}
|
|
||||||
&:disabled {
|
|
||||||
opacity: 0.6;
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,34 +0,0 @@
|
||||||
import type { CatalogPlaceholderGroupView } from "@batch-cooking/shared";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Pure helpers for `CatalogPage` — kept out of the `.tsx` per repo
|
|
||||||
* convention, unit-tested on their own.
|
|
||||||
*/
|
|
||||||
|
|
||||||
/** Which server-side list a UI tab maps to — `pending` sends no `reviewed` param (the working list), `reviewed` sends `reviewed=true` (the archive). */
|
|
||||||
export type CatalogTab = "pending" | "reviewed";
|
|
||||||
|
|
||||||
/** `CatalogTab` → the `reviewed` query value `adminApiClient.getPlaceholders` expects. */
|
|
||||||
export function reviewedParam(tab: CatalogTab): "true" | undefined {
|
|
||||||
return tab === "reviewed" ? "true" : undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Splits a group's spellings into the one to show as the card title and the
|
|
||||||
* rest to list as "aussi écrit : …". The API already sorts `displayNames`
|
|
||||||
* alphabetically and none is more canonical than another, so the first is
|
|
||||||
* as good a headline as any — the point of the group is that they're the
|
|
||||||
* same missing ingredient.
|
|
||||||
*/
|
|
||||||
export function splitSpellings(group: CatalogPlaceholderGroupView): {
|
|
||||||
headline: string;
|
|
||||||
variants: string[];
|
|
||||||
} {
|
|
||||||
const [headline = group.normalizedName, ...variants] = group.displayNames;
|
|
||||||
return { headline, variants };
|
|
||||||
}
|
|
||||||
|
|
||||||
/** `"2026-08-28T09:00:00.000Z"` → `"28/08/2026"` for the "première fois le …" line. `null` → `null`. */
|
|
||||||
export function formatDate(iso: string | null): string | null {
|
|
||||||
return iso === null ? null : new Date(iso).toLocaleDateString("fr-FR");
|
|
||||||
}
|
|
||||||
|
|
@ -1,401 +0,0 @@
|
||||||
import {
|
|
||||||
type CorrectionAdminView,
|
|
||||||
ErrorCode,
|
|
||||||
type RetrainResultView,
|
|
||||||
type TrainingSuggestionAdminView,
|
|
||||||
type TrainingSuggestionGroupView,
|
|
||||||
} from "@batch-cooking/shared";
|
|
||||||
import { useCallback, useEffect, useState } from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { AdminApiError, adminApiClient } from "../../../api/admin-client";
|
|
||||||
import { errorMessageService } from "../../../services/error-message.service";
|
|
||||||
import "../admin-page.scss";
|
|
||||||
import "./corrections-page.scss";
|
|
||||||
import { linesToList, listsDiffer, listToLines } from "./corrections";
|
|
||||||
|
|
||||||
type Tab = "suggestions" | "corrections";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Tech-step correction triage. Two tabs — curated `TrainingSuggestion`s and
|
|
||||||
* raw `StepTechStepCorrection`s — plus the snippet generator and the F1
|
|
||||||
* gate + backfill trigger. Replaces the `list-pending-training-suggestions.ts`
|
|
||||||
* / `retrain-tech-steps.ts` CLI pair.
|
|
||||||
*/
|
|
||||||
export function CorrectionsPage() {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const [tab, setTab] = useState<Tab>("suggestions");
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="admin-page">
|
|
||||||
<h1 className="admin-page__title">{t("admin.corrections.title")}</h1>
|
|
||||||
<p className="admin-page__lead">{t("admin.corrections.lead")}</p>
|
|
||||||
|
|
||||||
<p className="corrections-caveat">{t("admin.corrections.caveat")}</p>
|
|
||||||
|
|
||||||
<div className="corrections-tabs">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={tab === "suggestions" ? "active" : undefined}
|
|
||||||
onClick={() => setTab("suggestions")}
|
|
||||||
>
|
|
||||||
{t("admin.corrections.tab.suggestions")}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={tab === "corrections" ? "active" : undefined}
|
|
||||||
onClick={() => setTab("corrections")}
|
|
||||||
>
|
|
||||||
{t("admin.corrections.tab.corrections")}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{tab === "suggestions" ? <SuggestionsTab /> : <CorrectionsTab />}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Suggestions tab -------------------------------------------------------
|
|
||||||
|
|
||||||
type SuggestionsState =
|
|
||||||
| { status: "loading" }
|
|
||||||
| { status: "loaded"; groups: TrainingSuggestionGroupView[] }
|
|
||||||
| { status: "error" };
|
|
||||||
|
|
||||||
function SuggestionsTab() {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const [statusFilter, setStatusFilter] = useState("");
|
|
||||||
const [sourceFilter, setSourceFilter] = useState("");
|
|
||||||
const [state, setState] = useState<SuggestionsState>({ status: "loading" });
|
|
||||||
|
|
||||||
const load = useCallback(() => {
|
|
||||||
setState({ status: "loading" });
|
|
||||||
adminApiClient
|
|
||||||
.getSuggestions({
|
|
||||||
status: statusFilter || undefined,
|
|
||||||
sourceType: sourceFilter || undefined,
|
|
||||||
})
|
|
||||||
.then((groups) => setState({ status: "loaded", groups }))
|
|
||||||
.catch(() => setState({ status: "error" }));
|
|
||||||
}, [statusFilter, sourceFilter]);
|
|
||||||
|
|
||||||
useEffect(load, [load]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="suggestions-tab">
|
|
||||||
<RetrainPanel />
|
|
||||||
<SnippetPanel />
|
|
||||||
|
|
||||||
<div className="corrections-filters">
|
|
||||||
<label>
|
|
||||||
{t("admin.corrections.filter.status")}
|
|
||||||
<select value={statusFilter} onChange={(e) => setStatusFilter(e.target.value)}>
|
|
||||||
<option value="">{t("admin.corrections.filter.any")}</option>
|
|
||||||
<option value="pending">pending</option>
|
|
||||||
<option value="applied">applied</option>
|
|
||||||
<option value="rejected">rejected</option>
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
{t("admin.corrections.filter.source")}
|
|
||||||
<select value={sourceFilter} onChange={(e) => setSourceFilter(e.target.value)}>
|
|
||||||
<option value="">{t("admin.corrections.filter.any")}</option>
|
|
||||||
<option value="correction">correction</option>
|
|
||||||
<option value="llm_audit">llm_audit</option>
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{state.status === "loading" && (
|
|
||||||
<p className="admin-page__placeholder">{t("admin.common.loading")}</p>
|
|
||||||
)}
|
|
||||||
{state.status === "error" && (
|
|
||||||
<p className="admin-page__placeholder">{t("admin.common.loadError")}</p>
|
|
||||||
)}
|
|
||||||
{state.status === "loaded" && state.groups.length === 0 && (
|
|
||||||
<p className="admin-page__placeholder">{t("admin.corrections.noSuggestions")}</p>
|
|
||||||
)}
|
|
||||||
{state.status === "loaded" &&
|
|
||||||
state.groups.map((group) => (
|
|
||||||
<section key={group.techStepKey} className="suggestion-group">
|
|
||||||
<h2>{group.techStepKey}</h2>
|
|
||||||
{group.suggestions.map((suggestion) => (
|
|
||||||
<SuggestionCard key={suggestion.id} suggestion={suggestion} onMutated={load} />
|
|
||||||
))}
|
|
||||||
</section>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function SuggestionCard({
|
|
||||||
suggestion,
|
|
||||||
onMutated,
|
|
||||||
}: {
|
|
||||||
suggestion: TrainingSuggestionAdminView;
|
|
||||||
onMutated: () => void;
|
|
||||||
}) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const [synonyms, setSynonyms] = useState(listToLines(suggestion.suggestedSynonyms));
|
|
||||||
const [utterances, setUtterances] = useState(listToLines(suggestion.suggestedUtterances));
|
|
||||||
const [busy, setBusy] = useState(false);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const dirty =
|
|
||||||
listsDiffer(linesToList(synonyms), suggestion.suggestedSynonyms) ||
|
|
||||||
listsDiffer(linesToList(utterances), suggestion.suggestedUtterances);
|
|
||||||
|
|
||||||
async function patch(body: Parameters<typeof adminApiClient.updateSuggestion>[1]) {
|
|
||||||
setBusy(true);
|
|
||||||
setError(null);
|
|
||||||
try {
|
|
||||||
await adminApiClient.updateSuggestion(suggestion.id, body);
|
|
||||||
onMutated();
|
|
||||||
} catch (err) {
|
|
||||||
setError(
|
|
||||||
errorMessageService.getLabel(
|
|
||||||
err instanceof AdminApiError ? err.code : ErrorCode.INTERNAL_ERROR,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
} finally {
|
|
||||||
setBusy(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<article className={`suggestion-card suggestion-card--${suggestion.status}`}>
|
|
||||||
<header className="suggestion-card__head">
|
|
||||||
<span className="suggestion-card__meta">
|
|
||||||
#{suggestion.id} · {suggestion.locale} · {suggestion.sourceType} ·{" "}
|
|
||||||
<strong>{suggestion.status}</strong>
|
|
||||||
</span>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
{suggestion.sourceCorrection && (
|
|
||||||
<p className="suggestion-card__source">
|
|
||||||
<span className="suggestion-card__clause">
|
|
||||||
« {suggestion.sourceCorrection.clauseText} »
|
|
||||||
</span>{" "}
|
|
||||||
{suggestion.sourceCorrection.previousTechStepKey ?? "∅"} →{" "}
|
|
||||||
{suggestion.sourceCorrection.correctedTechStepKey ?? "∅"}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<label>
|
|
||||||
{t("admin.corrections.synonyms")}
|
|
||||||
<textarea value={synonyms} onChange={(e) => setSynonyms(e.target.value)} rows={3} />
|
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
{t("admin.corrections.utterances")}
|
|
||||||
<textarea value={utterances} onChange={(e) => setUtterances(e.target.value)} rows={3} />
|
|
||||||
</label>
|
|
||||||
|
|
||||||
{error && <p className="suggestion-card__error">{error}</p>}
|
|
||||||
|
|
||||||
<div className="suggestion-card__actions">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
disabled={busy || !dirty}
|
|
||||||
onClick={() =>
|
|
||||||
patch({
|
|
||||||
suggestedSynonyms: linesToList(synonyms),
|
|
||||||
suggestedUtterances: linesToList(utterances),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{t("admin.corrections.save")}
|
|
||||||
</button>
|
|
||||||
<button type="button" disabled={busy} onClick={() => patch({ status: "applied" })}>
|
|
||||||
{t("admin.corrections.apply")}
|
|
||||||
</button>
|
|
||||||
<button type="button" disabled={busy} onClick={() => patch({ status: "rejected" })}>
|
|
||||||
{t("admin.corrections.reject")}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</article>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Snippet + retrain panels ------------------------------------------------
|
|
||||||
|
|
||||||
function SnippetPanel() {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const [techStepKey, setTechStepKey] = useState("");
|
|
||||||
const [snippet, setSnippet] = useState<string | null>(null);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
async function generate() {
|
|
||||||
setError(null);
|
|
||||||
setSnippet(null);
|
|
||||||
try {
|
|
||||||
const result = await adminApiClient.getTrainingDataSnippet({
|
|
||||||
techStepKey: techStepKey.trim(),
|
|
||||||
status: "applied",
|
|
||||||
});
|
|
||||||
setSnippet(result.snippet);
|
|
||||||
} catch (err) {
|
|
||||||
setError(
|
|
||||||
errorMessageService.getLabel(
|
|
||||||
err instanceof AdminApiError ? err.code : ErrorCode.INTERNAL_ERROR,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<section className="corrections-panel">
|
|
||||||
<h2>{t("admin.corrections.snippet.title")}</h2>
|
|
||||||
<p>{t("admin.corrections.snippet.help")}</p>
|
|
||||||
<div className="corrections-panel__row">
|
|
||||||
<input
|
|
||||||
value={techStepKey}
|
|
||||||
onChange={(e) => setTechStepKey(e.target.value)}
|
|
||||||
placeholder={t("admin.corrections.snippet.keyPlaceholder")}
|
|
||||||
/>
|
|
||||||
<button type="button" disabled={techStepKey.trim().length === 0} onClick={generate}>
|
|
||||||
{t("admin.corrections.snippet.generate")}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{error && <p className="suggestion-card__error">{error}</p>}
|
|
||||||
{snippet !== null && (
|
|
||||||
<textarea className="corrections-snippet" readOnly rows={10} value={snippet} />
|
|
||||||
)}
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function RetrainPanel() {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const [busy, setBusy] = useState(false);
|
|
||||||
const [result, setResult] = useState<RetrainResultView | null>(null);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
async function run() {
|
|
||||||
setBusy(true);
|
|
||||||
setError(null);
|
|
||||||
setResult(null);
|
|
||||||
try {
|
|
||||||
setResult(await adminApiClient.retrain({}));
|
|
||||||
} catch (err) {
|
|
||||||
setError(
|
|
||||||
errorMessageService.getLabel(
|
|
||||||
err instanceof AdminApiError ? err.code : ErrorCode.INTERNAL_ERROR,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
} finally {
|
|
||||||
setBusy(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<section className="corrections-panel corrections-panel--retrain">
|
|
||||||
<h2>{t("admin.corrections.retrain.title")}</h2>
|
|
||||||
<p>{t("admin.corrections.retrain.help")}</p>
|
|
||||||
<button type="button" disabled={busy} onClick={run}>
|
|
||||||
{busy ? t("admin.corrections.retrain.running") : t("admin.corrections.retrain.run")}
|
|
||||||
</button>
|
|
||||||
{error && <p className="suggestion-card__error">{error}</p>}
|
|
||||||
{result && (
|
|
||||||
<p className={`retrain-result retrain-result--${result.gatePassed ? "ok" : "fail"}`}>
|
|
||||||
F1 {result.f1.toFixed(3)} / {result.minF1} —{" "}
|
|
||||||
{result.gatePassed
|
|
||||||
? t("admin.corrections.retrain.passed", {
|
|
||||||
total: result.backfilled?.total ?? 0,
|
|
||||||
changed: result.backfilled?.changed ?? 0,
|
|
||||||
})
|
|
||||||
: t("admin.corrections.retrain.failed")}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Raw corrections tab --------------------------------------------------
|
|
||||||
|
|
||||||
type CorrectionsState =
|
|
||||||
| { status: "loading" }
|
|
||||||
| { status: "loaded"; rows: CorrectionAdminView[] }
|
|
||||||
| { status: "error" };
|
|
||||||
|
|
||||||
function CorrectionsTab() {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const [consumed, setConsumed] = useState("");
|
|
||||||
const [hasCorrected, setHasCorrected] = useState("");
|
|
||||||
const [state, setState] = useState<CorrectionsState>({ status: "loading" });
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
let cancelled = false;
|
|
||||||
setState({ status: "loading" });
|
|
||||||
adminApiClient
|
|
||||||
.getCorrections({
|
|
||||||
consumed: consumed || undefined,
|
|
||||||
hasCorrectedTechStep: hasCorrected || undefined,
|
|
||||||
})
|
|
||||||
.then((rows) => {
|
|
||||||
if (!cancelled) setState({ status: "loaded", rows });
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
if (!cancelled) setState({ status: "error" });
|
|
||||||
});
|
|
||||||
return () => {
|
|
||||||
cancelled = true;
|
|
||||||
};
|
|
||||||
}, [consumed, hasCorrected]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="corrections-tab">
|
|
||||||
<div className="corrections-filters">
|
|
||||||
<label>
|
|
||||||
{t("admin.corrections.filter.consumed")}
|
|
||||||
<select value={consumed} onChange={(e) => setConsumed(e.target.value)}>
|
|
||||||
<option value="">{t("admin.corrections.filter.any")}</option>
|
|
||||||
<option value="true">{t("admin.corrections.filter.yes")}</option>
|
|
||||||
<option value="false">{t("admin.corrections.filter.no")}</option>
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
{t("admin.corrections.filter.hasCorrected")}
|
|
||||||
<select value={hasCorrected} onChange={(e) => setHasCorrected(e.target.value)}>
|
|
||||||
<option value="">{t("admin.corrections.filter.any")}</option>
|
|
||||||
<option value="true">{t("admin.corrections.filter.yes")}</option>
|
|
||||||
<option value="false">{t("admin.corrections.filter.no")}</option>
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{state.status === "loading" && (
|
|
||||||
<p className="admin-page__placeholder">{t("admin.common.loading")}</p>
|
|
||||||
)}
|
|
||||||
{state.status === "error" && (
|
|
||||||
<p className="admin-page__placeholder">{t("admin.common.loadError")}</p>
|
|
||||||
)}
|
|
||||||
{state.status === "loaded" && (
|
|
||||||
<div className="corrections-table-wrap">
|
|
||||||
<table className="corrections-table">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>#</th>
|
|
||||||
<th>{t("admin.corrections.col.clause")}</th>
|
|
||||||
<th>{t("admin.corrections.col.change")}</th>
|
|
||||||
<th>{t("admin.corrections.col.created")}</th>
|
|
||||||
<th>{t("admin.corrections.col.consumed")}</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{state.rows.map((row) => (
|
|
||||||
<tr key={row.id}>
|
|
||||||
<td>{row.id}</td>
|
|
||||||
<td className="corrections-table__clause">« {row.clauseText} »</td>
|
|
||||||
<td>
|
|
||||||
{row.previousTechStepKey ?? "∅"} → {row.correctedTechStepKey ?? "∅"}
|
|
||||||
</td>
|
|
||||||
<td>{new Date(row.createdAt).toLocaleDateString("fr-FR")}</td>
|
|
||||||
<td>{row.consumedAt ? "✓" : "—"}</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,266 +0,0 @@
|
||||||
// =============================================================================
|
|
||||||
// CorrectionsPage — a caveat banner, two tabs, filter rows, suggestion cards
|
|
||||||
// with editable textareas, the snippet + retrain panels, and the raw
|
|
||||||
// corrections table.
|
|
||||||
// =============================================================================
|
|
||||||
|
|
||||||
.corrections-caveat {
|
|
||||||
margin: 0 0 var(--space-lg);
|
|
||||||
padding: var(--space-sm) var(--space-md);
|
|
||||||
border-left: 4px solid var(--color-warning);
|
|
||||||
background: color-mix(in srgb, var(--color-warning) 12%, var(--color-surface));
|
|
||||||
border-radius: var(--radius-base);
|
|
||||||
font-size: var(--font-size-sm);
|
|
||||||
color: var(--color-text);
|
|
||||||
}
|
|
||||||
|
|
||||||
.corrections-tabs {
|
|
||||||
display: flex;
|
|
||||||
gap: var(--space-xs);
|
|
||||||
margin-bottom: var(--space-lg);
|
|
||||||
|
|
||||||
button {
|
|
||||||
padding: var(--space-sm) var(--space-md);
|
|
||||||
font-family: var(--font-body);
|
|
||||||
font-size: var(--font-size-sm);
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
background: none;
|
|
||||||
border: 1px solid var(--color-border);
|
|
||||||
border-radius: var(--radius-base);
|
|
||||||
cursor: pointer;
|
|
||||||
|
|
||||||
&.active {
|
|
||||||
color: var(--color-primary);
|
|
||||||
border-color: var(--color-primary);
|
|
||||||
background: color-mix(in srgb, var(--color-primary) 10%, var(--color-surface));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.corrections-filters {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: var(--space-md);
|
|
||||||
margin-bottom: var(--space-md);
|
|
||||||
|
|
||||||
label {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.15rem;
|
|
||||||
font-size: var(--font-size-xs);
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
select {
|
|
||||||
padding: var(--space-xs) var(--space-sm);
|
|
||||||
font-family: var(--font-body);
|
|
||||||
border: 1px solid var(--color-border);
|
|
||||||
border-radius: var(--radius-base);
|
|
||||||
background: var(--color-surface);
|
|
||||||
color: var(--color-text);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.corrections-panel {
|
|
||||||
margin-bottom: var(--space-lg);
|
|
||||||
padding: var(--space-md);
|
|
||||||
background: var(--color-surface);
|
|
||||||
border: 1px solid var(--color-border);
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
|
|
||||||
h2 {
|
|
||||||
font-size: var(--font-size-md);
|
|
||||||
margin-bottom: var(--space-xs);
|
|
||||||
}
|
|
||||||
|
|
||||||
p {
|
|
||||||
margin: 0 0 var(--space-sm);
|
|
||||||
font-size: var(--font-size-sm);
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
&--retrain {
|
|
||||||
border-left: 4px solid var(--color-accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
&__row {
|
|
||||||
display: flex;
|
|
||||||
gap: var(--space-sm);
|
|
||||||
|
|
||||||
input {
|
|
||||||
flex: 1;
|
|
||||||
padding: var(--space-xs) var(--space-sm);
|
|
||||||
font-family: var(--font-body);
|
|
||||||
border: 1px solid var(--color-border);
|
|
||||||
border-radius: var(--radius-base);
|
|
||||||
background: var(--color-surface);
|
|
||||||
color: var(--color-text);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
button {
|
|
||||||
padding: var(--space-xs) var(--space-md);
|
|
||||||
font-family: var(--font-body);
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--color-surface);
|
|
||||||
background: var(--color-primary);
|
|
||||||
border: none;
|
|
||||||
border-radius: var(--radius-base);
|
|
||||||
cursor: pointer;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
background: var(--color-primary-hover);
|
|
||||||
}
|
|
||||||
&:disabled {
|
|
||||||
opacity: 0.6;
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.corrections-snippet {
|
|
||||||
width: 100%;
|
|
||||||
margin-top: var(--space-sm);
|
|
||||||
padding: var(--space-sm);
|
|
||||||
font-family: var(--font-mono);
|
|
||||||
font-size: var(--font-size-xs);
|
|
||||||
border: 1px solid var(--color-border);
|
|
||||||
border-radius: var(--radius-base);
|
|
||||||
background: var(--color-surface-alt);
|
|
||||||
color: var(--color-text);
|
|
||||||
resize: vertical;
|
|
||||||
}
|
|
||||||
|
|
||||||
.retrain-result {
|
|
||||||
margin-top: var(--space-sm);
|
|
||||||
font-weight: 600;
|
|
||||||
&--ok {
|
|
||||||
color: var(--color-success);
|
|
||||||
}
|
|
||||||
&--fail {
|
|
||||||
color: var(--color-error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.suggestion-group {
|
|
||||||
margin-bottom: var(--space-lg);
|
|
||||||
|
|
||||||
h2 {
|
|
||||||
font-size: var(--font-size-md);
|
|
||||||
margin-bottom: var(--space-sm);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.suggestion-card {
|
|
||||||
margin-bottom: var(--space-sm);
|
|
||||||
padding: var(--space-md);
|
|
||||||
background: var(--color-surface);
|
|
||||||
border: 1px solid var(--color-border);
|
|
||||||
border-left: 4px solid var(--color-border);
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
|
|
||||||
&--applied {
|
|
||||||
border-left-color: var(--color-success);
|
|
||||||
}
|
|
||||||
&--rejected {
|
|
||||||
border-left-color: var(--color-error);
|
|
||||||
}
|
|
||||||
&--pending {
|
|
||||||
border-left-color: var(--color-warning);
|
|
||||||
}
|
|
||||||
|
|
||||||
&__meta {
|
|
||||||
font-size: var(--font-size-xs);
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
&__source {
|
|
||||||
margin: var(--space-xs) 0 var(--space-sm);
|
|
||||||
font-size: var(--font-size-sm);
|
|
||||||
}
|
|
||||||
|
|
||||||
&__clause {
|
|
||||||
font-style: italic;
|
|
||||||
color: var(--color-text);
|
|
||||||
}
|
|
||||||
|
|
||||||
label {
|
|
||||||
display: block;
|
|
||||||
margin-bottom: var(--space-sm);
|
|
||||||
font-size: var(--font-size-xs);
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
textarea {
|
|
||||||
width: 100%;
|
|
||||||
margin-top: 0.15rem;
|
|
||||||
padding: var(--space-xs) var(--space-sm);
|
|
||||||
font-family: var(--font-mono);
|
|
||||||
font-size: var(--font-size-xs);
|
|
||||||
border: 1px solid var(--color-border);
|
|
||||||
border-radius: var(--radius-base);
|
|
||||||
background: var(--color-surface);
|
|
||||||
color: var(--color-text);
|
|
||||||
resize: vertical;
|
|
||||||
}
|
|
||||||
|
|
||||||
&__error {
|
|
||||||
margin: 0 0 var(--space-sm);
|
|
||||||
font-size: var(--font-size-sm);
|
|
||||||
color: var(--color-error);
|
|
||||||
}
|
|
||||||
|
|
||||||
&__actions {
|
|
||||||
display: flex;
|
|
||||||
gap: var(--space-sm);
|
|
||||||
|
|
||||||
button {
|
|
||||||
padding: var(--space-xs) var(--space-md);
|
|
||||||
font-family: var(--font-body);
|
|
||||||
font-size: var(--font-size-sm);
|
|
||||||
font-weight: 600;
|
|
||||||
border: 1px solid var(--color-border);
|
|
||||||
border-radius: var(--radius-base);
|
|
||||||
background: var(--color-surface);
|
|
||||||
color: var(--color-text);
|
|
||||||
cursor: pointer;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
border-color: var(--color-primary);
|
|
||||||
}
|
|
||||||
&:disabled {
|
|
||||||
opacity: 0.5;
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.corrections-table-wrap {
|
|
||||||
overflow-x: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.corrections-table {
|
|
||||||
width: 100%;
|
|
||||||
border-collapse: collapse;
|
|
||||||
font-size: var(--font-size-sm);
|
|
||||||
|
|
||||||
th,
|
|
||||||
td {
|
|
||||||
padding: var(--space-xs) var(--space-sm);
|
|
||||||
border-bottom: 1px solid var(--color-border);
|
|
||||||
text-align: left;
|
|
||||||
vertical-align: top;
|
|
||||||
}
|
|
||||||
|
|
||||||
th {
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
&__clause {
|
|
||||||
font-style: italic;
|
|
||||||
max-width: 28rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,23 +0,0 @@
|
||||||
/**
|
|
||||||
* Pure helpers for `CorrectionsPage` — the editable synonym/utterance
|
|
||||||
* fields are one-per-line textareas, so these convert between that and the
|
|
||||||
* `string[]` the API wants. Kept out of the `.tsx` per repo convention.
|
|
||||||
*/
|
|
||||||
|
|
||||||
/** Textarea value (one entry per line) → trimmed, non-empty `string[]`. */
|
|
||||||
export function linesToList(text: string): string[] {
|
|
||||||
return text
|
|
||||||
.split("\n")
|
|
||||||
.map((line) => line.trim())
|
|
||||||
.filter((line) => line.length > 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** `string[]` → textarea value (one entry per line). */
|
|
||||||
export function listToLines(values: string[]): string {
|
|
||||||
return values.join("\n");
|
|
||||||
}
|
|
||||||
|
|
||||||
/** True when two string lists differ (order-sensitive) — gates the "save" button. */
|
|
||||||
export function listsDiffer(a: string[], b: string[]): boolean {
|
|
||||||
return a.length !== b.length || a.some((value, i) => value !== b[i]);
|
|
||||||
}
|
|
||||||
|
|
@ -1,175 +0,0 @@
|
||||||
import type { MetricsTimeBucket, MetricsView } from "@batch-cooking/shared";
|
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import {
|
|
||||||
CartesianGrid,
|
|
||||||
Line,
|
|
||||||
LineChart,
|
|
||||||
ResponsiveContainer,
|
|
||||||
Tooltip,
|
|
||||||
XAxis,
|
|
||||||
YAxis,
|
|
||||||
} from "recharts";
|
|
||||||
import { adminApiClient } from "../../../api/admin-client";
|
|
||||||
import "../admin-page.scss";
|
|
||||||
import "./dashboard-page.scss";
|
|
||||||
import { formatCount, kpiTiles, seriesTotal, shortDay } from "./dashboard";
|
|
||||||
|
|
||||||
/** Load state for `GET /admin/metrics` — same discriminated-union shape as apps/web's page states. */
|
|
||||||
type DashboardState =
|
|
||||||
| { status: "loading" }
|
|
||||||
| { status: "loaded"; metrics: MetricsView }
|
|
||||||
| { status: "error" };
|
|
||||||
|
|
||||||
const RANGE_DAYS = 30;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Usage-metrics dashboard. KPI tiles from the snapshot, then a small line
|
|
||||||
* chart per instrumented time series over the last {@link RANGE_DAYS} days.
|
|
||||||
* Fed by `GET /admin/metrics`; read-only, refetched only on mount.
|
|
||||||
*/
|
|
||||||
export function DashboardPage() {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const [state, setState] = useState<DashboardState>({ status: "loading" });
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
let cancelled = false;
|
|
||||||
setState({ status: "loading" });
|
|
||||||
adminApiClient
|
|
||||||
.getMetrics(RANGE_DAYS)
|
|
||||||
.then((metrics) => {
|
|
||||||
if (!cancelled) setState({ status: "loaded", metrics });
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
if (!cancelled) setState({ status: "error" });
|
|
||||||
});
|
|
||||||
return () => {
|
|
||||||
cancelled = true;
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="admin-page">
|
|
||||||
<h1 className="admin-page__title">{t("admin.dashboard.title")}</h1>
|
|
||||||
<p className="admin-page__lead">{t("admin.dashboard.lead")}</p>
|
|
||||||
|
|
||||||
{state.status === "loading" && (
|
|
||||||
<p className="admin-page__placeholder">{t("admin.common.loading")}</p>
|
|
||||||
)}
|
|
||||||
{state.status === "error" && (
|
|
||||||
<p className="admin-page__placeholder">{t("admin.common.loadError")}</p>
|
|
||||||
)}
|
|
||||||
{state.status === "loaded" && <DashboardBody metrics={state.metrics} />}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function DashboardBody({ metrics }: { metrics: MetricsView }) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const { snapshot, series } = metrics;
|
|
||||||
|
|
||||||
const charts: { key: string; buckets: MetricsTimeBucket[] }[] = [
|
|
||||||
{ key: "signups", buckets: series.signups },
|
|
||||||
{ key: "recipesCreated", buckets: series.recipesCreated },
|
|
||||||
{ key: "planningItemsAdded", buckets: series.planningItemsAdded },
|
|
||||||
{ key: "correctionsSubmitted", buckets: series.correctionsSubmitted },
|
|
||||||
{ key: "trainingSuggestions", buckets: series.trainingSuggestions },
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<section className="kpi-grid">
|
|
||||||
{kpiTiles(snapshot).map((tile) => (
|
|
||||||
<div className="kpi-tile" key={tile.labelKey}>
|
|
||||||
<span className="kpi-tile__value">{formatCount(tile.value)}</span>
|
|
||||||
<span className="kpi-tile__label">{t(`admin.dashboard.kpi.${tile.labelKey}`)}</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section className="chart-grid">
|
|
||||||
{charts.map(({ key, buckets }) => (
|
|
||||||
<article className="chart-card" key={key}>
|
|
||||||
<header className="chart-card__head">
|
|
||||||
<h2>{t(`admin.dashboard.series.${key}`)}</h2>
|
|
||||||
<span className="chart-card__total">
|
|
||||||
{t("admin.dashboard.windowTotal", { n: seriesTotal(buckets) })}
|
|
||||||
</span>
|
|
||||||
</header>
|
|
||||||
<TrendChart buckets={buckets} />
|
|
||||||
</article>
|
|
||||||
))}
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section className="breakdown">
|
|
||||||
<h2>{t("admin.dashboard.recipesBySource")}</h2>
|
|
||||||
{snapshot.recipesBySource.length === 0 ? (
|
|
||||||
<p className="admin-page__placeholder">{t("admin.dashboard.noImports")}</p>
|
|
||||||
) : (
|
|
||||||
<ul className="breakdown__list">
|
|
||||||
{snapshot.recipesBySource.map((row) => (
|
|
||||||
<li key={row.key}>
|
|
||||||
<span>{row.label}</span>
|
|
||||||
<span>{formatCount(row.count)}</span>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
)}
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{metrics.events.length > 0 && (
|
|
||||||
<section className="breakdown">
|
|
||||||
<h2>{t("admin.dashboard.events")}</h2>
|
|
||||||
<ul className="breakdown__list">
|
|
||||||
{metrics.events.map((event) => (
|
|
||||||
<li key={event.type}>
|
|
||||||
<span>{event.type}</span>
|
|
||||||
<span>{formatCount(seriesTotal(event.buckets))}</span>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</section>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** A compact 30-day line chart for one metrics series. */
|
|
||||||
function TrendChart({ buckets }: { buckets: MetricsTimeBucket[] }) {
|
|
||||||
const data = buckets.map((bucket) => ({ day: shortDay(bucket.date), count: bucket.count }));
|
|
||||||
return (
|
|
||||||
<div className="chart-card__body">
|
|
||||||
<ResponsiveContainer width="100%" height={160}>
|
|
||||||
<LineChart data={data} margin={{ top: 4, right: 8, bottom: 0, left: -20 }}>
|
|
||||||
<CartesianGrid strokeDasharray="3 3" stroke="var(--color-border)" />
|
|
||||||
<XAxis
|
|
||||||
dataKey="day"
|
|
||||||
tick={{ fontSize: 10, fill: "var(--color-text-muted)" }}
|
|
||||||
interval="preserveStartEnd"
|
|
||||||
minTickGap={24}
|
|
||||||
/>
|
|
||||||
<YAxis
|
|
||||||
allowDecimals={false}
|
|
||||||
width={40}
|
|
||||||
tick={{ fontSize: 10, fill: "var(--color-text-muted)" }}
|
|
||||||
/>
|
|
||||||
<Tooltip
|
|
||||||
contentStyle={{
|
|
||||||
background: "var(--color-surface)",
|
|
||||||
border: "1px solid var(--color-border)",
|
|
||||||
borderRadius: 8,
|
|
||||||
fontSize: 12,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Line
|
|
||||||
type="monotone"
|
|
||||||
dataKey="count"
|
|
||||||
stroke="var(--color-primary)"
|
|
||||||
strokeWidth={2}
|
|
||||||
dot={false}
|
|
||||||
/>
|
|
||||||
</LineChart>
|
|
||||||
</ResponsiveContainer>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,103 +0,0 @@
|
||||||
// =============================================================================
|
|
||||||
// DashboardPage — KPI tile grid + a grid of small trend charts + a couple of
|
|
||||||
// breakdown lists. Colocated with DashboardPage.tsx.
|
|
||||||
// =============================================================================
|
|
||||||
|
|
||||||
.kpi-grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(auto-fill, minmax(9rem, 1fr));
|
|
||||||
gap: var(--space-sm);
|
|
||||||
margin-bottom: var(--space-xl);
|
|
||||||
}
|
|
||||||
|
|
||||||
.kpi-tile {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.15rem;
|
|
||||||
padding: var(--space-md);
|
|
||||||
background: var(--color-surface);
|
|
||||||
border: 1px solid var(--color-border);
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
|
|
||||||
&__value {
|
|
||||||
font-family: var(--font-display);
|
|
||||||
font-size: var(--font-size-xl);
|
|
||||||
font-weight: 700;
|
|
||||||
color: var(--color-text);
|
|
||||||
font-variant-numeric: tabular-nums;
|
|
||||||
}
|
|
||||||
|
|
||||||
&__label {
|
|
||||||
font-size: var(--font-size-xs);
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.chart-grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(auto-fill, minmax(20rem, 1fr));
|
|
||||||
gap: var(--space-md);
|
|
||||||
margin-bottom: var(--space-xl);
|
|
||||||
}
|
|
||||||
|
|
||||||
.chart-card {
|
|
||||||
padding: var(--space-md);
|
|
||||||
background: var(--color-surface);
|
|
||||||
border: 1px solid var(--color-border);
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
|
|
||||||
&__head {
|
|
||||||
display: flex;
|
|
||||||
align-items: baseline;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: var(--space-sm);
|
|
||||||
margin-bottom: var(--space-sm);
|
|
||||||
|
|
||||||
h2 {
|
|
||||||
font-size: var(--font-size-md);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&__total {
|
|
||||||
font-size: var(--font-size-sm);
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
font-variant-numeric: tabular-nums;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.breakdown {
|
|
||||||
margin-bottom: var(--space-lg);
|
|
||||||
|
|
||||||
h2 {
|
|
||||||
font-size: var(--font-size-md);
|
|
||||||
margin-bottom: var(--space-sm);
|
|
||||||
}
|
|
||||||
|
|
||||||
&__list {
|
|
||||||
list-style: none;
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
background: var(--color-surface);
|
|
||||||
border: 1px solid var(--color-border);
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
max-width: 30rem;
|
|
||||||
|
|
||||||
li {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: var(--space-md);
|
|
||||||
padding: var(--space-sm) var(--space-md);
|
|
||||||
border-bottom: 1px solid var(--color-border);
|
|
||||||
font-size: var(--font-size-sm);
|
|
||||||
|
|
||||||
&:last-child {
|
|
||||||
border-bottom: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
span:last-child {
|
|
||||||
font-variant-numeric: tabular-nums;
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,51 +0,0 @@
|
||||||
import type { MetricsSnapshotView, MetricsTimeBucket } from "@batch-cooking/shared";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Pure helpers for `DashboardPage` — number/date formatting and the tile
|
|
||||||
* list, kept out of the `.tsx` (repo convention: no derivation logic in a
|
|
||||||
* component file) so they're trivially testable.
|
|
||||||
*/
|
|
||||||
|
|
||||||
/** French-grouped integer, e.g. `1234` → `"1 234"`. */
|
|
||||||
export function formatCount(value: number): string {
|
|
||||||
return value.toLocaleString("fr-FR");
|
|
||||||
}
|
|
||||||
|
|
||||||
/** `"2026-08-28"` → `"28/08"` for a compact chart axis tick. */
|
|
||||||
export function shortDay(isoDate: string): string {
|
|
||||||
const [, month, day] = isoDate.split("-");
|
|
||||||
return `${day}/${month}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Sum of a time series — the "total over the window" figure shown next to each chart. */
|
|
||||||
export function seriesTotal(buckets: MetricsTimeBucket[]): number {
|
|
||||||
return buckets.reduce((sum, bucket) => sum + bucket.count, 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** One KPI tile: an i18n label key and the snapshot value it reads. */
|
|
||||||
export interface KpiTile {
|
|
||||||
labelKey: string;
|
|
||||||
value: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The dashboard's KPI tiles, in display order. `labelKey` resolves under
|
|
||||||
* `admin.dashboard.kpi.*`. Kept here (not inline in JSX) so the set is one
|
|
||||||
* list to reorder/extend.
|
|
||||||
*/
|
|
||||||
export function kpiTiles(snapshot: MetricsSnapshotView): KpiTile[] {
|
|
||||||
return [
|
|
||||||
{ labelKey: "users", value: snapshot.users },
|
|
||||||
{ labelKey: "households", value: snapshot.households },
|
|
||||||
{ labelKey: "activeHouseholds", value: snapshot.activeHouseholds },
|
|
||||||
{ labelKey: "recipes", value: snapshot.recipes },
|
|
||||||
{ labelKey: "recipesImported", value: snapshot.recipesImported },
|
|
||||||
{ labelKey: "plannings", value: snapshot.plannings },
|
|
||||||
{ labelKey: "planningItems", value: snapshot.planningItems },
|
|
||||||
{ labelKey: "favorites", value: snapshot.favorites },
|
|
||||||
{ labelKey: "corrections", value: snapshot.corrections },
|
|
||||||
{ labelKey: "correctionsUnconsumed", value: snapshot.correctionsUnconsumed },
|
|
||||||
{ labelKey: "trainingSuggestions", value: snapshot.trainingSuggestions },
|
|
||||||
{ labelKey: "admins", value: snapshot.admins },
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
@ -1,85 +0,0 @@
|
||||||
import { adminLoginSchema, ErrorCode } from "@batch-cooking/shared";
|
|
||||||
import { type FormEvent, useState } from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { useNavigate } from "react-router-dom";
|
|
||||||
import { AdminApiError } from "../../../api/admin-client";
|
|
||||||
import { useAdminAuth } from "../../../features/admin/AdminAuthContext";
|
|
||||||
import "../../../features/admin/admin-auth.scss";
|
|
||||||
import { fieldErrorsFrom } from "../../../lib/zod-errors";
|
|
||||||
import { errorMessageService } from "../../../services/error-message.service";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The admin login screen — the only unauthenticated route under `/admin`.
|
|
||||||
* Client-side validation via the shared `adminLoginSchema` (same rules the
|
|
||||||
* API enforces), then `POST /admin/auth/login`; any API failure is
|
|
||||||
* translated to a localized label via {@link ErrorMessageService}. Same
|
|
||||||
* structure as the user-facing `LoginPage` (`pages/auth/`).
|
|
||||||
*/
|
|
||||||
export function AdminLoginPage() {
|
|
||||||
const { login } = useAdminAuth();
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
const [email, setEmail] = useState("");
|
|
||||||
const [password, setPassword] = useState("");
|
|
||||||
const [fieldErrors, setFieldErrors] = useState<Record<string, string>>({});
|
|
||||||
const [formError, setFormError] = useState<string | null>(null);
|
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
|
||||||
|
|
||||||
async function handleSubmit(e: FormEvent) {
|
|
||||||
e.preventDefault();
|
|
||||||
setFormError(null);
|
|
||||||
|
|
||||||
const result = adminLoginSchema.safeParse({ email, password });
|
|
||||||
if (!result.success) {
|
|
||||||
setFieldErrors(fieldErrorsFrom(result.error));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setFieldErrors({});
|
|
||||||
|
|
||||||
setIsSubmitting(true);
|
|
||||||
try {
|
|
||||||
await login(result.data);
|
|
||||||
void navigate("/admin");
|
|
||||||
} catch (err) {
|
|
||||||
const code = err instanceof AdminApiError ? err.code : ErrorCode.INTERNAL_ERROR;
|
|
||||||
setFormError(errorMessageService.getLabel(code));
|
|
||||||
} finally {
|
|
||||||
setIsSubmitting(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<main className="admin-auth-page">
|
|
||||||
<form className="admin-auth-card" onSubmit={handleSubmit} noValidate>
|
|
||||||
<h1>{t("admin.login.title")}</h1>
|
|
||||||
|
|
||||||
<label htmlFor="email">{t("admin.login.emailLabel")}</label>
|
|
||||||
<input
|
|
||||||
id="email"
|
|
||||||
type="email"
|
|
||||||
value={email}
|
|
||||||
onChange={(e) => setEmail(e.target.value)}
|
|
||||||
autoComplete="email"
|
|
||||||
/>
|
|
||||||
{fieldErrors.email && <p className="field-error">{fieldErrors.email}</p>}
|
|
||||||
|
|
||||||
<label htmlFor="password">{t("admin.login.passwordLabel")}</label>
|
|
||||||
<input
|
|
||||||
id="password"
|
|
||||||
type="password"
|
|
||||||
value={password}
|
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
|
||||||
autoComplete="current-password"
|
|
||||||
/>
|
|
||||||
{fieldErrors.password && <p className="field-error">{fieldErrors.password}</p>}
|
|
||||||
|
|
||||||
{formError && <p className="form-error">{formError}</p>}
|
|
||||||
|
|
||||||
<button type="submit" disabled={isSubmitting}>
|
|
||||||
{isSubmitting ? t("admin.login.submitting") : t("admin.login.submit")}
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</main>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,110 +0,0 @@
|
||||||
import type { MonitoringView, ServiceHealthView } from "@batch-cooking/shared";
|
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { adminApiClient } from "../../../api/admin-client";
|
|
||||||
import "../admin-page.scss";
|
|
||||||
import "./monitoring-page.scss";
|
|
||||||
import { clockTime, POLL_INTERVAL_MS, statusModifier } from "./monitoring";
|
|
||||||
|
|
||||||
type MonitoringState =
|
|
||||||
| { status: "loading" }
|
|
||||||
| { status: "loaded"; data: MonitoringView }
|
|
||||||
| { status: "error" };
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Microservice health board. Fetches `GET /admin/monitoring` on mount and
|
|
||||||
* re-polls every {@link POLL_INTERVAL_MS} ms. One card per probed target
|
|
||||||
* (Postgres, API, intent-service, LLM worker), coloured by status.
|
|
||||||
*/
|
|
||||||
export function MonitoringPage() {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const [state, setState] = useState<MonitoringState>({ status: "loading" });
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
let cancelled = false;
|
|
||||||
|
|
||||||
function load() {
|
|
||||||
adminApiClient
|
|
||||||
.getMonitoring()
|
|
||||||
.then((data) => {
|
|
||||||
if (!cancelled) setState({ status: "loaded", data });
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
if (!cancelled)
|
|
||||||
setState((prev) => (prev.status === "loaded" ? prev : { status: "error" }));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
load();
|
|
||||||
const timer = setInterval(load, POLL_INTERVAL_MS);
|
|
||||||
return () => {
|
|
||||||
cancelled = true;
|
|
||||||
clearInterval(timer);
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="admin-page">
|
|
||||||
<h1 className="admin-page__title">{t("admin.monitoring.title")}</h1>
|
|
||||||
<p className="admin-page__lead">{t("admin.monitoring.lead")}</p>
|
|
||||||
|
|
||||||
{state.status === "loading" && (
|
|
||||||
<p className="admin-page__placeholder">{t("admin.common.loading")}</p>
|
|
||||||
)}
|
|
||||||
{state.status === "error" && (
|
|
||||||
<p className="admin-page__placeholder">{t("admin.common.loadError")}</p>
|
|
||||||
)}
|
|
||||||
{state.status === "loaded" && (
|
|
||||||
<>
|
|
||||||
<p className="monitoring-checked">
|
|
||||||
{t("admin.monitoring.lastChecked", { time: clockTime(state.data.generatedAt) })}
|
|
||||||
</p>
|
|
||||||
<div className="monitoring-grid">
|
|
||||||
{state.data.services.map((service) => (
|
|
||||||
<ServiceCard key={service.key} service={service} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ServiceCard({ service }: { service: ServiceHealthView }) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
return (
|
|
||||||
<article className={`monitoring-card monitoring-card--${statusModifier(service.status)}`}>
|
|
||||||
<header className="monitoring-card__head">
|
|
||||||
<span className="monitoring-card__dot" aria-hidden="true" />
|
|
||||||
<h2>{t(`admin.monitoring.service.${service.key}`, { defaultValue: service.key })}</h2>
|
|
||||||
<span className="monitoring-card__status">
|
|
||||||
{t(`admin.monitoring.status.${service.status}`)}
|
|
||||||
</span>
|
|
||||||
</header>
|
|
||||||
<dl className="monitoring-card__meta">
|
|
||||||
{service.latencyMs !== null && (
|
|
||||||
<div>
|
|
||||||
<dt>{t("admin.monitoring.latency")}</dt>
|
|
||||||
<dd>{service.latencyMs} ms</dd>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{service.detail && (
|
|
||||||
<div>
|
|
||||||
<dt>{t("admin.monitoring.detail")}</dt>
|
|
||||||
<dd>{service.detail}</dd>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{service.lastRunAt !== undefined && (
|
|
||||||
<div>
|
|
||||||
<dt>{t("admin.monitoring.lastRun")}</dt>
|
|
||||||
<dd>
|
|
||||||
{service.lastRunAt ? clockTime(service.lastRunAt) : t("admin.monitoring.never")}
|
|
||||||
{service.lastResult?.job ? ` · ${service.lastResult.job}` : ""}
|
|
||||||
{service.lastResult?.ok === false ? ` · ${t("admin.monitoring.jobFailed")}` : ""}
|
|
||||||
</dd>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</dl>
|
|
||||||
</article>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,98 +0,0 @@
|
||||||
// =============================================================================
|
|
||||||
// MonitoringPage — a grid of service health cards, one per probed target.
|
|
||||||
// Status drives a coloured left border + dot.
|
|
||||||
// =============================================================================
|
|
||||||
|
|
||||||
.monitoring-checked {
|
|
||||||
margin: 0 0 var(--space-md);
|
|
||||||
font-size: var(--font-size-sm);
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.monitoring-grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(auto-fill, minmax(18rem, 1fr));
|
|
||||||
gap: var(--space-md);
|
|
||||||
}
|
|
||||||
|
|
||||||
.monitoring-card {
|
|
||||||
padding: var(--space-md);
|
|
||||||
background: var(--color-surface);
|
|
||||||
border: 1px solid var(--color-border);
|
|
||||||
border-left: 4px solid var(--color-border);
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
|
|
||||||
--status-color: var(--color-text-muted);
|
|
||||||
&--up {
|
|
||||||
--status-color: var(--color-success);
|
|
||||||
}
|
|
||||||
&--degraded {
|
|
||||||
--status-color: var(--color-warning);
|
|
||||||
}
|
|
||||||
&--down {
|
|
||||||
--status-color: var(--color-error);
|
|
||||||
}
|
|
||||||
&--unknown {
|
|
||||||
--status-color: var(--color-text-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
border-left-color: var(--status-color);
|
|
||||||
|
|
||||||
&__head {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--space-sm);
|
|
||||||
margin-bottom: var(--space-sm);
|
|
||||||
|
|
||||||
h2 {
|
|
||||||
flex: 1;
|
|
||||||
min-width: 0;
|
|
||||||
font-size: var(--font-size-md);
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&__dot {
|
|
||||||
flex-shrink: 0;
|
|
||||||
width: 0.6rem;
|
|
||||||
height: 0.6rem;
|
|
||||||
border-radius: 50%;
|
|
||||||
background: var(--status-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
&__status {
|
|
||||||
flex-shrink: 0;
|
|
||||||
font-size: var(--font-size-xs);
|
|
||||||
font-weight: 700;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.04em;
|
|
||||||
color: var(--status-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
&__meta {
|
|
||||||
margin: 0;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: var(--space-xs);
|
|
||||||
|
|
||||||
div {
|
|
||||||
display: flex;
|
|
||||||
gap: var(--space-sm);
|
|
||||||
font-size: var(--font-size-sm);
|
|
||||||
}
|
|
||||||
|
|
||||||
dt {
|
|
||||||
flex-shrink: 0;
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
min-width: 5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
dd {
|
|
||||||
margin: 0;
|
|
||||||
color: var(--color-text);
|
|
||||||
word-break: break-word;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,19 +0,0 @@
|
||||||
import type { ServiceStatus } from "@batch-cooking/shared";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Pure helpers for `MonitoringPage` — kept out of the `.tsx` per repo
|
|
||||||
* convention.
|
|
||||||
*/
|
|
||||||
|
|
||||||
/** CSS modifier suffix for a status pill (`monitoring-card--up`, etc.). */
|
|
||||||
export function statusModifier(status: ServiceStatus): string {
|
|
||||||
return status;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** How often the board re-polls `GET /admin/monitoring`, in ms. */
|
|
||||||
export const POLL_INTERVAL_MS = 15_000;
|
|
||||||
|
|
||||||
/** `"2026-08-28T09:00:00.000Z"` → `"09:00:00"` (local time) for the "last checked" line. */
|
|
||||||
export function clockTime(iso: string): string {
|
|
||||||
return new Date(iso).toLocaleTimeString("fr-FR");
|
|
||||||
}
|
|
||||||
|
|
@ -1,188 +0,0 @@
|
||||||
import { DateTime, formatDateOnly, getWeekStart, parseDateOnly } from "@batch-cooking/date-tools";
|
|
||||||
import type {
|
|
||||||
CookingBackgroundTaskView,
|
|
||||||
CookingPhaseView,
|
|
||||||
CookingTaskView,
|
|
||||||
OptimizedCookingPlanView,
|
|
||||||
} from "@batch-cooking/shared";
|
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { useSearchParams } from "react-router-dom";
|
|
||||||
import { apiClient } from "../../api/client";
|
|
||||||
import { WeekNavigator } from "../../features/planning/WeekNavigator";
|
|
||||||
import { taskHeadline, taskRecipeNames } from "./cooking-session";
|
|
||||||
import "./cooking-session-page.scss";
|
|
||||||
|
|
||||||
/** Load state for the `GET /cooking-session` call — same discriminated-union shape as `ShoppingListPage`'s own state. */
|
|
||||||
type CookingSessionState =
|
|
||||||
| { status: "loading" }
|
|
||||||
| { status: "loaded"; plan: OptimizedCookingPlanView }
|
|
||||||
| { status: "error" };
|
|
||||||
|
|
||||||
/**
|
|
||||||
* "Cuisiner cette semaine" — routed at `/cuisiner`, reached from the
|
|
||||||
* planning page's "Commencer à cuisiner" button. Shows the household's week
|
|
||||||
* of planned recipes reorganized by the backend optimizer
|
|
||||||
* (`GET /cooking-session`, see the API's `cooking-optimizer.ts`) into
|
|
||||||
* ordered phases: a mise-en-place that pools shared prep, then cooking
|
|
||||||
* phases that interleave the recipes with passive cooks shown as running
|
|
||||||
* in the background.
|
|
||||||
*
|
|
||||||
* The week comes from a `?date=` query param (set by the planning button so
|
|
||||||
* the two pages stay on the same week); absent/invalid falls back to the
|
|
||||||
* current week. Read-only and recomputed on every visit — no progress
|
|
||||||
* state to keep in sync, same design stance as `ShoppingListPage`.
|
|
||||||
*/
|
|
||||||
export function CookingSessionPage() {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const [searchParams] = useSearchParams();
|
|
||||||
const [weekStart, setWeekStart] = useState<DateTime>(() => {
|
|
||||||
const fromQuery = parseDateOnly(searchParams.get("date") ?? "");
|
|
||||||
return getWeekStart(fromQuery ?? DateTime.utc());
|
|
||||||
});
|
|
||||||
const [state, setState] = useState<CookingSessionState>({ status: "loading" });
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
let cancelled = false;
|
|
||||||
setState({ status: "loading" });
|
|
||||||
|
|
||||||
apiClient
|
|
||||||
.getCookingPlanForWeek(formatDateOnly(weekStart))
|
|
||||||
.then((plan) => {
|
|
||||||
if (!cancelled) setState({ status: "loaded", plan });
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
if (!cancelled) setState({ status: "error" });
|
|
||||||
});
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
cancelled = true;
|
|
||||||
};
|
|
||||||
}, [weekStart]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="cooking-session-page">
|
|
||||||
<div className="cooking-session-page__header">
|
|
||||||
<h1>{t("cookingSession.title")}</h1>
|
|
||||||
<WeekNavigator weekStart={weekStart} onChangeWeek={setWeekStart} />
|
|
||||||
</div>
|
|
||||||
<p className="cooking-session-page__subtitle">{t("cookingSession.subtitle")}</p>
|
|
||||||
|
|
||||||
{state.status === "loading" && (
|
|
||||||
<p className="cooking-session-page__status">{t("cookingSession.loading")}</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{state.status === "error" && (
|
|
||||||
<p className="cooking-session-page__status cooking-session-page__status--error">
|
|
||||||
{t("common.loadError")}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{state.status === "loaded" && <CookingPlan plan={state.plan} />}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The plan body — the recipe legend then every phase, or the empty-week message. */
|
|
||||||
function CookingPlan({ plan }: { plan: OptimizedCookingPlanView }) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
if (plan.phases.length === 0) {
|
|
||||||
return <p className="cooking-session-page__status">{t("cookingSession.empty")}</p>;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="cooking-session">
|
|
||||||
<section className="cooking-session__legend" aria-label={t("cookingSession.recipesLegend")}>
|
|
||||||
{plan.recipes.map((recipe) => (
|
|
||||||
<span
|
|
||||||
key={`${recipe.recipeId}-${recipe.portions}`}
|
|
||||||
className="cooking-session__legend-item"
|
|
||||||
>
|
|
||||||
{recipe.name} · ×{recipe.portions}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{plan.phases.map((phase) => (
|
|
||||||
<PhaseSection key={phase.index} phase={phase} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** One phase: its background band (if any) then its task cards. */
|
|
||||||
function PhaseSection({ phase }: { phase: CookingPhaseView }) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<section className={`cooking-phase cooking-phase--${phase.kind}`}>
|
|
||||||
<h2 className="cooking-phase__title">
|
|
||||||
<span className="cooking-phase__index">
|
|
||||||
{t("cookingSession.phase.label", { index: phase.index + 1 })}
|
|
||||||
</span>
|
|
||||||
<span className="cooking-phase__kind">{t(`cookingSession.phase.${phase.kind}`)}</span>
|
|
||||||
</h2>
|
|
||||||
|
|
||||||
{phase.background.length > 0 && (
|
|
||||||
<div className="cooking-phase__background">
|
|
||||||
<span className="cooking-phase__background-title">
|
|
||||||
{t("cookingSession.background.title")}
|
|
||||||
</span>
|
|
||||||
<ul>
|
|
||||||
{phase.background.map((task) => (
|
|
||||||
<li key={task.id}>
|
|
||||||
<BackgroundLine task={task} />
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<ul className="cooking-phase__tasks">
|
|
||||||
{phase.tasks.map((task) => (
|
|
||||||
<li key={task.id}>
|
|
||||||
<TaskCard task={task} />
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** A single actionable task — a merged-prep pool or a plain recipe step. */
|
|
||||||
function TaskCard({ task }: { task: CookingTaskView }) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<article className={`cooking-task cooking-task--${task.kind}`}>
|
|
||||||
<p className="cooking-task__headline">
|
|
||||||
{taskHeadline(task, t)}
|
|
||||||
{task.kind === "merged-prep" && (
|
|
||||||
<span className="cooking-task__badge">{t("cookingSession.task.sharedBadge")}</span>
|
|
||||||
)}
|
|
||||||
</p>
|
|
||||||
<p className="cooking-task__recipes">
|
|
||||||
{t("cookingSession.task.forRecipes", { recipes: taskRecipeNames(task) })}
|
|
||||||
</p>
|
|
||||||
{task.utensils.length > 0 && (
|
|
||||||
<p className="cooking-task__utensils">
|
|
||||||
{t("cookingSession.task.utensils")} :{" "}
|
|
||||||
{task.utensils.map((utensil) => t(`catalog.utensils.${utensil.key}`)).join(", ")}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</article>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** One "meanwhile, X is cooking" line inside a phase's background band. */
|
|
||||||
function BackgroundLine({ task }: { task: CookingBackgroundTaskView }) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const technique = task.technique ? `${t(`catalog.techSteps.${task.technique.key}`)} — ` : "";
|
|
||||||
return (
|
|
||||||
<span>
|
|
||||||
{technique}
|
|
||||||
{task.description} <em>({task.recipeName})</em>
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,164 +0,0 @@
|
||||||
// =============================================================================
|
|
||||||
// Styles specific to CookingSessionPage — colocated next to
|
|
||||||
// CookingSessionPage.tsx since nothing else uses these classes. Same page
|
|
||||||
// shell/status conventions as shopping-list-page.scss
|
|
||||||
// (`__header`/`__status`); below it, a vertical stack of phase sections
|
|
||||||
// each holding a "meanwhile" band and a list of task cards.
|
|
||||||
// =============================================================================
|
|
||||||
|
|
||||||
.cooking-session-page {
|
|
||||||
height: 100%;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
|
|
||||||
&__header {
|
|
||||||
flex-shrink: 0;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: var(--space-md);
|
|
||||||
margin-bottom: var(--space-sm);
|
|
||||||
}
|
|
||||||
|
|
||||||
&__subtitle {
|
|
||||||
flex-shrink: 0;
|
|
||||||
margin: 0 0 var(--space-lg);
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
font-size: var(--font-size-md);
|
|
||||||
}
|
|
||||||
|
|
||||||
&__status {
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
font-size: var(--font-size-md);
|
|
||||||
}
|
|
||||||
|
|
||||||
&__status--error {
|
|
||||||
color: var(--color-error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Scrollable plan body --------------------------------------------------
|
|
||||||
.cooking-session {
|
|
||||||
flex: 1;
|
|
||||||
min-height: 0;
|
|
||||||
overflow: auto;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: var(--space-lg);
|
|
||||||
|
|
||||||
// Recipe legend — one chip per planned recipe/portion pairing.
|
|
||||||
&__legend {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: var(--space-xs);
|
|
||||||
}
|
|
||||||
|
|
||||||
&__legend-item {
|
|
||||||
padding: 0.15rem var(--space-sm);
|
|
||||||
border-radius: var(--radius-pill);
|
|
||||||
background: var(--color-surface);
|
|
||||||
box-shadow: var(--shadow-sm);
|
|
||||||
font-size: var(--font-size-sm);
|
|
||||||
color: var(--color-text);
|
|
||||||
font-variant-numeric: tabular-nums;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- One phase ----------------------------------------------------------------
|
|
||||||
.cooking-phase {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: var(--space-sm);
|
|
||||||
|
|
||||||
&__title {
|
|
||||||
display: flex;
|
|
||||||
align-items: baseline;
|
|
||||||
gap: var(--space-sm);
|
|
||||||
margin: 0;
|
|
||||||
font-size: var(--font-size-md);
|
|
||||||
}
|
|
||||||
|
|
||||||
&__index {
|
|
||||||
font-weight: 700;
|
|
||||||
color: var(--color-text);
|
|
||||||
}
|
|
||||||
|
|
||||||
&__kind {
|
|
||||||
font-size: var(--font-size-sm);
|
|
||||||
font-weight: 600;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.04em;
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
// "Pendant ce temps" — passive cooks still running from earlier phases.
|
|
||||||
&__background {
|
|
||||||
border-left: 3px solid var(--color-border);
|
|
||||||
padding: var(--space-xs) var(--space-md);
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
font-size: var(--font-size-sm);
|
|
||||||
|
|
||||||
&-title {
|
|
||||||
display: block;
|
|
||||||
font-weight: 600;
|
|
||||||
margin-bottom: 0.15rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
ul {
|
|
||||||
margin: 0;
|
|
||||||
padding-left: var(--space-md);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&__tasks {
|
|
||||||
list-style: none;
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: var(--space-sm);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- One task card ----------------------------------------------------------
|
|
||||||
.cooking-task {
|
|
||||||
background: var(--color-surface);
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
box-shadow: var(--shadow-sm);
|
|
||||||
padding: var(--space-sm) var(--space-md);
|
|
||||||
|
|
||||||
// A pooled prep task is the headline feature of this page — give it a
|
|
||||||
// subtle accent border so it stands out from plain recipe steps.
|
|
||||||
&--merged-prep {
|
|
||||||
border-left: 3px solid var(--color-accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
&__headline {
|
|
||||||
margin: 0;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: var(--space-xs);
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--color-text);
|
|
||||||
}
|
|
||||||
|
|
||||||
&__badge {
|
|
||||||
padding: 0.05rem var(--space-xs);
|
|
||||||
border-radius: var(--radius-pill);
|
|
||||||
background: var(--color-accent);
|
|
||||||
color: var(--color-surface);
|
|
||||||
font-size: var(--font-size-xs);
|
|
||||||
font-weight: 700;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.03em;
|
|
||||||
}
|
|
||||||
|
|
||||||
&__recipes,
|
|
||||||
&__utensils {
|
|
||||||
margin: 0.2rem 0 0;
|
|
||||||
font-size: var(--font-size-sm);
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,56 +0,0 @@
|
||||||
import type { CookingTaskIngredientView, CookingTaskView } from "@batch-cooking/shared";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Minimal shape of `react-i18next`'s `t` — just what this module needs.
|
|
||||||
* Passed in rather than importing `useTranslation` here so these helpers
|
|
||||||
* stay pure functions the page (and a unit test) can call without mounting
|
|
||||||
* i18next, the same "logic extracted from the .tsx" split as
|
|
||||||
* `shopping-list.ts`'s `groupShoppingListItems`.
|
|
||||||
*/
|
|
||||||
export type TranslateFn = (key: string, options?: Record<string, unknown>) => string;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Formats a task quantity for display — French conventions, at most 2
|
|
||||||
* decimals so a scaled/pooled float never shows a trailing-digit artifact
|
|
||||||
* (`"149.99999999999997"`). Same rule as `shopping-list.ts`'s
|
|
||||||
* `formatShoppingListQuantity`.
|
|
||||||
*/
|
|
||||||
export function formatCookingQuantity(quantity: number): string {
|
|
||||||
return quantity.toLocaleString("fr-FR", { maximumFractionDigits: 2 });
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* One ingredient line as a human string — `"3 oignon"`, `"200 g farine"`,
|
|
||||||
* or just `"sel"` when the source clause carried no measurable amount
|
|
||||||
* (`quantity`/`unit` both `null`, see {@link CookingTaskIngredientView}).
|
|
||||||
* Labels are resolved through the same `catalog.*` i18n keys as everywhere
|
|
||||||
* else.
|
|
||||||
*/
|
|
||||||
export function formatIngredientLine(line: CookingTaskIngredientView, t: TranslateFn): string {
|
|
||||||
const name = t(`catalog.ingredients.${line.ingredient.key}`);
|
|
||||||
if (line.quantity === null) return name;
|
|
||||||
const amount = formatCookingQuantity(line.quantity);
|
|
||||||
const unit = line.unit === null ? "" : `${t(`catalog.units.${line.unit.key}`)} `;
|
|
||||||
return `${amount} ${unit}${name}`.trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The headline shown on a task card:
|
|
||||||
* - `merged-prep` — `"Émincer : 3 oignon, 200 g carotte"` (technique label +
|
|
||||||
* its pooled ingredient lines), built from the
|
|
||||||
* `cookingSession.task.mergedPrepLabel` template.
|
|
||||||
* - `step` — the original recipe step text, verbatim.
|
|
||||||
*/
|
|
||||||
export function taskHeadline(task: CookingTaskView, t: TranslateFn): string {
|
|
||||||
if (task.kind === "step") return task.description ?? "";
|
|
||||||
const technique = task.technique
|
|
||||||
? t(`catalog.techSteps.${task.technique.key}`)
|
|
||||||
: t("cookingSession.phase.mise-en-place");
|
|
||||||
const items = task.ingredients.map((line) => formatIngredientLine(line, t)).join(", ");
|
|
||||||
return t("cookingSession.task.mergedPrepLabel", { technique, items });
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Comma-joined names of the recipes a task belongs to — one for a `step`, several for a pooled `merged-prep`. */
|
|
||||||
export function taskRecipeNames(task: CookingTaskView): string {
|
|
||||||
return task.sourceRecipes.map((recipe) => recipe.name).join(", ");
|
|
||||||
}
|
|
||||||
|
|
@ -8,7 +8,6 @@ import {
|
||||||
} from "@batch-cooking/shared";
|
} from "@batch-cooking/shared";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useNavigate } from "react-router-dom";
|
|
||||||
import { apiClient } from "../../api/client";
|
import { apiClient } from "../../api/client";
|
||||||
import { type PlanningSlot, RecipePickerDialog } from "../../features/planning/RecipePickerDialog";
|
import { type PlanningSlot, RecipePickerDialog } from "../../features/planning/RecipePickerDialog";
|
||||||
import { WeekNavigator } from "../../features/planning/WeekNavigator";
|
import { WeekNavigator } from "../../features/planning/WeekNavigator";
|
||||||
|
|
@ -38,7 +37,6 @@ const BAND_END_MEALS: ReadonlySet<Meal> = new Set(["collation", "dejeuner", "gou
|
||||||
*/
|
*/
|
||||||
export function PlanningPage() {
|
export function PlanningPage() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const navigate = useNavigate();
|
|
||||||
const [weekStart, setWeekStart] = useState<DateTime>(() => getWeekStart(DateTime.utc()));
|
const [weekStart, setWeekStart] = useState<DateTime>(() => getWeekStart(DateTime.utc()));
|
||||||
const [state, setState] = useState<PlanningState>({ status: "loading" });
|
const [state, setState] = useState<PlanningState>({ status: "loading" });
|
||||||
// The slot a `RecipePickerDialog` is currently open for — `null` means
|
// The slot a `RecipePickerDialog` is currently open for — `null` means
|
||||||
|
|
@ -103,23 +101,11 @@ export function PlanningPage() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Enabled only once we know the week has at least one planned recipe —
|
|
||||||
// "cuisiner" an empty week would just land on the page's own empty state.
|
|
||||||
const hasPlannedRecipes = state.status === "loaded" && (state.planning?.items.length ?? 0) > 0;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="planning-page">
|
<div className="planning-page">
|
||||||
<div className="planning-page__header">
|
<div className="planning-page__header">
|
||||||
<h1>{t("planning.title")}</h1>
|
<h1>{t("planning.title")}</h1>
|
||||||
<WeekNavigator weekStart={weekStart} onChangeWeek={setWeekStart} />
|
<WeekNavigator weekStart={weekStart} onChangeWeek={setWeekStart} />
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="planning-page__cook-btn"
|
|
||||||
disabled={!hasPlannedRecipes}
|
|
||||||
onClick={() => navigate(`/cuisiner?date=${formatDateOnly(weekStart)}`)}
|
|
||||||
>
|
|
||||||
{t("planning.startCooking")}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{state.status === "loading" && (
|
{state.status === "loading" && (
|
||||||
|
|
|
||||||
|
|
@ -36,29 +36,6 @@
|
||||||
&__status--error {
|
&__status--error {
|
||||||
color: var(--color-error);
|
color: var(--color-error);
|
||||||
}
|
}
|
||||||
|
|
||||||
// "Commencer à cuisiner" — same solid-primary treatment as the recipe
|
|
||||||
// picker's confirm button (features/planning/recipe-picker-dialog.scss).
|
|
||||||
&__cook-btn {
|
|
||||||
background: var(--color-primary);
|
|
||||||
color: var(--color-surface);
|
|
||||||
border: none;
|
|
||||||
border-radius: var(--radius-base);
|
|
||||||
padding: var(--space-sm) var(--space-md);
|
|
||||||
font-family: var(--font-body);
|
|
||||||
font-size: var(--font-size-sm);
|
|
||||||
font-weight: 600;
|
|
||||||
cursor: pointer;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
background: var(--color-primary-hover);
|
|
||||||
}
|
|
||||||
|
|
||||||
&:disabled {
|
|
||||||
opacity: 0.6;
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- The grid itself --------------------------------------------------------
|
// --- The grid itself --------------------------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -14,10 +14,6 @@ import { ApiError, apiClient } from "../../api/client";
|
||||||
import { DietTagSelect } from "../../features/recipes/badges/DietTagSelect";
|
import { DietTagSelect } from "../../features/recipes/badges/DietTagSelect";
|
||||||
import { IngredientPicker } from "../../features/recipes/ingredients/IngredientPicker";
|
import { IngredientPicker } from "../../features/recipes/ingredients/IngredientPicker";
|
||||||
import { IngredientRow } from "../../features/recipes/ingredients/IngredientRow";
|
import { IngredientRow } from "../../features/recipes/ingredients/IngredientRow";
|
||||||
import {
|
|
||||||
isUnsavedPlaceholder,
|
|
||||||
makePlaceholderIngredientView,
|
|
||||||
} from "../../features/recipes/ingredients/placeholder-ingredient";
|
|
||||||
import { type StepDraft, StepListEditor } from "../../features/recipes/steps/StepListEditor";
|
import { type StepDraft, StepListEditor } from "../../features/recipes/steps/StepListEditor";
|
||||||
import "../../features/recipes/recipes.scss";
|
import "../../features/recipes/recipes.scss";
|
||||||
import { makeClientKey } from "../../lib/client-key";
|
import { makeClientKey } from "../../lib/client-key";
|
||||||
|
|
@ -26,19 +22,7 @@ import { errorMessageService } from "../../services/error-message.service";
|
||||||
/** In display order — mirrors `RecipeVisibility` (schema.prisma/shared types). */
|
/** In display order — mirrors `RecipeVisibility` (schema.prisma/shared types). */
|
||||||
const VISIBILITY_OPTIONS: RecipeVisibility[] = ["PERSONAL", "HOUSE", "PUBLIC"];
|
const VISIBILITY_OPTIONS: RecipeVisibility[] = ["PERSONAL", "HOUSE", "PUBLIC"];
|
||||||
|
|
||||||
/**
|
/** One selected ingredient line — `key` is a client-only stable identity, same reasoning as `StepDraft`. `unitId` is `null` until the user picks one (no default — unlike `portions`, there's no single "usually right" unit across every ingredient); `canSubmit` gates on every line having one set before allowing save. */
|
||||||
* One selected ingredient line — `key` is a client-only stable identity,
|
|
||||||
* same reasoning as `StepDraft`. `unitId` is `null` until the user picks
|
|
||||||
* one (no default — unlike `portions`, there's no single "usually right"
|
|
||||||
* unit across every ingredient); `canSubmit` gates on every line having one
|
|
||||||
* set before allowing save.
|
|
||||||
*
|
|
||||||
* `ingredient` is a real `IngredientView` for a catalog pick or an
|
|
||||||
* edit-loaded placeholder, or a synthetic one (see
|
|
||||||
* `makePlaceholderIngredientView`) for a brand-new free-text placeholder
|
|
||||||
* the user added because the catalog fell short — the latter submits as
|
|
||||||
* `placeholderName`, not `ingredientId` (see {@link isUnsavedPlaceholder}).
|
|
||||||
*/
|
|
||||||
interface IngredientLine {
|
interface IngredientLine {
|
||||||
key: string;
|
key: string;
|
||||||
ingredient: IngredientView;
|
ingredient: IngredientView;
|
||||||
|
|
@ -141,19 +125,6 @@ export function RecipeFormPage() {
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Adds a free-text placeholder line — the escape hatch when nothing in the catalog matches (see `IngredientPicker`'s `onAddPlaceholder`). */
|
|
||||||
function addPlaceholderIngredient(name: string) {
|
|
||||||
setIngredientLines((lines) => [
|
|
||||||
...lines,
|
|
||||||
{
|
|
||||||
key: makeClientKey(),
|
|
||||||
ingredient: makePlaceholderIngredientView(name),
|
|
||||||
quantity: "",
|
|
||||||
unitId: null,
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateIngredientLine(
|
function updateIngredientLine(
|
||||||
key: string,
|
key: string,
|
||||||
patch: Partial<Pick<IngredientLine, "quantity" | "unitId">>,
|
patch: Partial<Pick<IngredientLine, "quantity" | "unitId">>,
|
||||||
|
|
@ -198,13 +169,7 @@ export function RecipeFormPage() {
|
||||||
visibility,
|
visibility,
|
||||||
dietIds,
|
dietIds,
|
||||||
ingredients: ingredientLines.map((line) => ({
|
ingredients: ingredientLines.map((line) => ({
|
||||||
// A just-added free-text line has no catalog id yet — ask the API to
|
ingredientId: line.ingredient.id,
|
||||||
// create the placeholder row via `placeholderName`. An edit-loaded
|
|
||||||
// placeholder already has a real id and goes through `ingredientId`
|
|
||||||
// like any other line (so re-saving never duplicates it).
|
|
||||||
...(isUnsavedPlaceholder(line.ingredient)
|
|
||||||
? { placeholderName: line.ingredient.displayName ?? "" }
|
|
||||||
: { ingredientId: line.ingredient.id }),
|
|
||||||
quantity: Number(line.quantity),
|
quantity: Number(line.quantity),
|
||||||
// `canSubmit` already requires every line to have a unit picked
|
// `canSubmit` already requires every line to have a unit picked
|
||||||
// before the button is enabled — `?? 0` is just to satisfy the
|
// before the button is enabled — `?? 0` is just to satisfy the
|
||||||
|
|
@ -256,12 +221,7 @@ export function RecipeFormPage() {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Placeholder lines carry no real catalog id (a brand-new one is id 0, an
|
const selectedIds = ingredientLines.map((line) => line.ingredient.id);
|
||||||
// edit-loaded one isn't in the browsable catalog anyway), so they never
|
|
||||||
// belong in the picker's "already picked, hide it" set.
|
|
||||||
const selectedIds = ingredientLines
|
|
||||||
.filter((line) => !line.ingredient.isPlaceholder)
|
|
||||||
.map((line) => line.ingredient.id);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form className="recipe-form" onSubmit={handleSubmit} noValidate>
|
<form className="recipe-form" onSubmit={handleSubmit} noValidate>
|
||||||
|
|
@ -332,7 +292,6 @@ export function RecipeFormPage() {
|
||||||
ingredients={ingredientsCatalog}
|
ingredients={ingredientsCatalog}
|
||||||
excludeIds={selectedIds}
|
excludeIds={selectedIds}
|
||||||
onSelect={addIngredient}
|
onSelect={addIngredient}
|
||||||
onAddPlaceholder={addPlaceholderIngredient}
|
|
||||||
/>
|
/>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,6 @@ import {
|
||||||
CategoryIcon,
|
CategoryIcon,
|
||||||
IngredientTypeIcon,
|
IngredientTypeIcon,
|
||||||
} from "../../features/recipes/ingredients/ingredient-icons";
|
} from "../../features/recipes/ingredients/ingredient-icons";
|
||||||
import { ingredientLabel } from "../../features/recipes/ingredients/ingredient-label";
|
|
||||||
import { formatShoppingListQuantity, groupShoppingListItems } from "./shopping-list";
|
import { formatShoppingListQuantity, groupShoppingListItems } from "./shopping-list";
|
||||||
import "./shopping-list-page.scss";
|
import "./shopping-list-page.scss";
|
||||||
|
|
||||||
|
|
@ -81,7 +80,9 @@ function ShoppingListItems({ items }: { items: ShoppingListItemView[] }) {
|
||||||
return <p className="shopping-list-page__status">{t("shoppingList.empty")}</p>;
|
return <p className="shopping-list-page__status">{t("shoppingList.empty")}</p>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const groups = groupShoppingListItems(items, (item) => ingredientLabel(item.ingredient, t));
|
const groups = groupShoppingListItems(items, (item) =>
|
||||||
|
t(`catalog.ingredients.${item.ingredient.key}`),
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="shopping-list">
|
<div className="shopping-list">
|
||||||
|
|
@ -98,7 +99,7 @@ function ShoppingListItems({ items }: { items: ShoppingListItemView[] }) {
|
||||||
<IngredientTypeIcon icon={item.ingredient.icon} />
|
<IngredientTypeIcon icon={item.ingredient.icon} />
|
||||||
</span>
|
</span>
|
||||||
<span className="shopping-list__item-name">
|
<span className="shopping-list__item-name">
|
||||||
{ingredientLabel(item.ingredient, t)}
|
{t(`catalog.ingredients.${item.ingredient.key}`)}
|
||||||
</span>
|
</span>
|
||||||
<span className="shopping-list__item-quantity">
|
<span className="shopping-list__item-quantity">
|
||||||
{formatShoppingListQuantity(item.quantity)} {t(`catalog.units.${item.unit.key}`)}
|
{formatShoppingListQuantity(item.quantity)} {t(`catalog.units.${item.unit.key}`)}
|
||||||
|
|
|
||||||
|
|
@ -48,12 +48,15 @@ services:
|
||||||
# default: `/internal/tech-steps/*` fails closed rather than open
|
# default: `/internal/tech-steps/*` fails closed rather than open
|
||||||
# for a deployment that doesn't run the worker at all.
|
# for a deployment that doesn't run the worker at all.
|
||||||
INTERNAL_WORKER_SECRET: ${INTERNAL_WORKER_SECRET:-}
|
INTERNAL_WORKER_SECRET: ${INTERNAL_WORKER_SECRET:-}
|
||||||
# Admin surface (apps/web's /admin/* routes + the API's /admin/*).
|
# Admin application (apps/admin-web + /admin/*). Both unset by default:
|
||||||
# Unset by default: `requireAdmin` fails closed without
|
# `requireAdmin` fails closed without ADMIN_JWT_SECRET, so a stack
|
||||||
# ADMIN_JWT_SECRET, so a stack that doesn't need the admin app simply
|
# that doesn't run the admin app simply has every /admin/* route 401.
|
||||||
# has every /admin/* route 401. Must be a *different* secret than
|
# Must be a *different* secret than JWT_SECRET.
|
||||||
# JWT_SECRET.
|
|
||||||
ADMIN_JWT_SECRET: ${ADMIN_JWT_SECRET:-}
|
ADMIN_JWT_SECRET: ${ADMIN_JWT_SECRET:-}
|
||||||
|
# Public origin apps/admin-web is served from, added to the CORS
|
||||||
|
# allow-list alongside the main app. Defaults to the compose
|
||||||
|
# `admin-web` service's mapped host port.
|
||||||
|
ADMIN_CORS_ORIGIN: ${ADMIN_CORS_ORIGIN:-http://localhost:3001}
|
||||||
# Compose network service name, not localhost — same reasoning as
|
# Compose network service name, not localhost — same reasoning as
|
||||||
# DATABASE_URL above. Unlike INTERNAL_WORKER_SECRET, no `:-` fallback:
|
# DATABASE_URL above. Unlike INTERNAL_WORKER_SECRET, no `:-` fallback:
|
||||||
# tech-step-intent-service is a core dependency (see its own entry
|
# tech-step-intent-service is a core dependency (see its own entry
|
||||||
|
|
|
||||||
|
|
@ -40,8 +40,6 @@ export enum ErrorCode {
|
||||||
RECIPE_IN_USE = 4021,
|
RECIPE_IN_USE = 4021,
|
||||||
/** `POST /sources/:sourceKey/import/:externalId` attempted on an item already imported (a `Recipe` already exists for that `sourceId`/`externalId` pair). */
|
/** `POST /sources/:sourceKey/import/:externalId` attempted on an item already imported (a `Recipe` already exists for that `sourceId`/`externalId` pair). */
|
||||||
RECIPE_ALREADY_IMPORTED = 4022,
|
RECIPE_ALREADY_IMPORTED = 4022,
|
||||||
/** `POST /admin/tech-steps/retrain` attempted while a previous retrain (F1 gate + backfill) is still running — it holds a process-wide lock so two can't overlap. */
|
|
||||||
RETRAIN_ALREADY_RUNNING = 4023,
|
|
||||||
/** A household action reserved to its admin (delete the household, remove a member) attempted by a non-admin member. */
|
/** A household action reserved to its admin (delete the household, remove a member) attempted by a non-admin member. */
|
||||||
NOT_HOUSE_ADMIN = 4030,
|
NOT_HOUSE_ADMIN = 4030,
|
||||||
/** `PATCH /recipes/:id` or `DELETE /recipes/:id` attempted by someone other than the recipe's author — visibility controls reading, not writing. */
|
/** `PATCH /recipes/:id` or `DELETE /recipes/:id` attempted by someone other than the recipe's author — visibility controls reading, not writing. */
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue