From c3d2ac7e8e2f8c0e8a411b795c20dad99b17255a Mon Sep 17 00:00:00 2001 From: Nicolas Date: Fri, 28 Aug 2026 12:02:19 +0200 Subject: [PATCH 1/6] feat(admin): fondation auth de l'application d'administration Premiere brique de l'app d'admin independante : une surface /admin/* ajoutee a apps/api, avec une authentification totalement distincte de celle des utilisateurs. - Table AdminUser isolee (aucune relation vers UserProfile), migration 20260828120000_admin_user. - lib/admin-jwt.ts : sign/verify d'un JWT admin, secret ADMIN_JWT_SECRET propre (jamais interchangeable avec JWT_SECRET). - middlewares/require-admin.ts : cookie admin_session dedie, re-check tokenVersion, echoue ferme si ADMIN_JWT_SECRET absent (posture requireInternalWorker). res.locals.adminUser type via AdminLocals. - modules/admin/ : admin-auth.{routes,service}.ts (POST /login, POST /logout, GET /me), admin.routes.ts agregateur monte /admin. Pas de signup expose. - lib/safe-admin.ts : mapping AdminUser -> AdminUserView (drop passwordHash + tokenVersion, dates ISO). - scripts/create-admin.ts : creation du 1er admin hors-bande (flags ou ADMIN_INITIAL_*). - CORS : setupCore accepte string[] ; app.ts autorise CORS_ORIGIN + ADMIN_CORS_ORIGIN. - Shared : schemas/admin.ts (adminLoginSchema), types/admin.ts (AdminUserView). - Env : ADMIN_JWT_SECRET (optionnel), ADMIN_COOKIE_NAME, ADMIN_CORS_ORIGIN, ADMIN_INITIAL_* ; .env.example, .env.test.example, docker-compose.yml, ci.yml mis a jour. - reset-db.ts truncate admin_users. - Tests Mocha admin-auth.test.ts : 400 sans body, 401 email inconnu / mauvais mdp, login OK (cookie pose, lastLoginAt, pas de hash/tokenVersion dans la reponse), /me derriere requireAdmin, logout, et un cookie `session` d'utilisateur normal ne donne pas acces a /admin/*. Co-Authored-By: Claude Sonnet 5 --- .env.example | 21 +++ .github/workflows/ci.yml | 4 + apps/api/.env.test.example | 7 + .../20260828120000_admin_user/migration.sql | 15 ++ apps/api/prisma/schema.prisma | 33 ++++ apps/api/src/app.ts | 9 +- apps/api/src/config/env.ts | 21 +++ apps/api/src/lib/admin-jwt.ts | 59 +++++++ apps/api/src/lib/safe-admin.ts | 20 +++ apps/api/src/middlewares/require-admin.ts | 71 +++++++++ .../src/modules/admin/admin-auth.routes.ts | 51 ++++++ .../src/modules/admin/admin-auth.service.ts | 59 +++++++ apps/api/src/modules/admin/admin.routes.ts | 13 ++ apps/api/src/scripts/create-admin.ts | 56 +++++++ apps/api/test-support/reset-db.ts | 3 +- apps/api/test/admin-auth.test.ts | 149 ++++++++++++++++++ docker-compose.yml | 9 ++ packages/express-tools/src/express-server.ts | 10 +- packages/shared/src/index.ts | 2 + packages/shared/src/schemas/admin.ts | 18 +++ packages/shared/src/types/admin.ts | 16 ++ 21 files changed, 642 insertions(+), 4 deletions(-) create mode 100644 apps/api/prisma/migrations/20260828120000_admin_user/migration.sql create mode 100644 apps/api/src/lib/admin-jwt.ts create mode 100644 apps/api/src/lib/safe-admin.ts create mode 100644 apps/api/src/middlewares/require-admin.ts create mode 100644 apps/api/src/modules/admin/admin-auth.routes.ts create mode 100644 apps/api/src/modules/admin/admin-auth.service.ts create mode 100644 apps/api/src/modules/admin/admin.routes.ts create mode 100644 apps/api/src/scripts/create-admin.ts create mode 100644 apps/api/test/admin-auth.test.ts create mode 100644 packages/shared/src/schemas/admin.ts create mode 100644 packages/shared/src/types/admin.ts diff --git a/.env.example b/.env.example index accdc43..469a3e2 100644 --- a/.env.example +++ b/.env.example @@ -15,6 +15,27 @@ JWT_SECRET=changeme-generate-a-real-random-secret-at-least-32-chars # (docker-compose.yml), serving both the API and the built frontend. # APP_PORT=3000 +# --- Admin application (apps/admin-web + the /admin/* API surface) --------- +# All optional: an instance that doesn't run the admin app needs none of +# these. `requireAdmin` fails closed when ADMIN_JWT_SECRET is unset, so +# leaving it out simply disables every /admin/* route. +# +# Secret for the admin session JWT — MUST be different from JWT_SECRET so an +# end-user token can never be replayed against /admin/*. Generate your own +# the same way as JWT_SECRET above. +# ADMIN_JWT_SECRET=changeme-generate-a-real-random-secret-at-least-32-chars +# Origin apps/admin-web is served from, added to the CORS allow-list. +# ADMIN_CORS_ORIGIN=http://localhost:5174 +# Host port for the Docker `admin-web` service (static nginx serving the +# built admin frontend). +# ADMIN_WEB_PORT=3001 +# Optional — read only by `src/scripts/create-admin.ts` when its --email / +# --password / --name flags are omitted (e.g. to bootstrap the first admin +# from inside the container). Never read by the running server. +# ADMIN_INITIAL_EMAIL=ops@example.com +# ADMIN_INITIAL_PASSWORD=changeme-at-least-8-chars +# ADMIN_INITIAL_NAME=Ops + # Optional — only set this to false if THIS deployment is served over # plain HTTP (no TLS in front of it). Left unset, the session cookie # requires HTTPS (Secure attribute) as it should for a real deployment; diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d128f7b..9be7cad 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -19,6 +19,10 @@ env: # exercise the success path (matching secret), not just the "unset" # rejection every environment that doesn't set this gets by default. INTERNAL_WORKER_SECRET: "ci-only-worker-secret-not-used-anywhere-else-32chars+" + # Same reasoning — lets admin-auth.test.ts exercise the admin login + # success path + the requireAdmin-guarded routes, not just the "no admin + # secret configured -> 401" path. + ADMIN_JWT_SECRET: "ci-only-admin-secret-not-used-anywhere-else-32chars+" # Shared between the `test` job's own uvicorn step (below) and apps/api's # IntentServiceClient — see the `test` job for why this can't be a # `services:` container like postgres above (GitHub Actions can only pull diff --git a/apps/api/.env.test.example b/apps/api/.env.test.example index 9c15d39..2bfd73f 100644 --- a/apps/api/.env.test.example +++ b/apps/api/.env.test.example @@ -27,3 +27,10 @@ INTENT_SERVICE_SECRET=changeme-generate-a-real-random-secret-at-least-32-chars # success path (a request with a matching secret); every other test runs # fine without it. Any value at least 32 chars works locally. # INTERNAL_WORKER_SECRET=changeme-generate-a-real-random-secret-at-least-32-chars + +# Optional — set to run admin-auth.test.ts's login success path and the +# requireAdmin-guarded routes (any value at least 32 chars). Left unset, +# those cases self-skip and only the "no secret configured -> 401" path +# runs. Same "optional in test, fail-closed at runtime" posture as +# INTERNAL_WORKER_SECRET above. +# ADMIN_JWT_SECRET=changeme-generate-a-real-random-secret-at-least-32-chars diff --git a/apps/api/prisma/migrations/20260828120000_admin_user/migration.sql b/apps/api/prisma/migrations/20260828120000_admin_user/migration.sql new file mode 100644 index 0000000..d9932ed --- /dev/null +++ b/apps/api/prisma/migrations/20260828120000_admin_user/migration.sql @@ -0,0 +1,15 @@ +-- CreateTable +CREATE TABLE "admin_users" ( + "id" SERIAL NOT NULL, + "email" TEXT NOT NULL, + "password_hash" TEXT NOT NULL, + "name" TEXT NOT NULL, + "token_version" INTEGER NOT NULL DEFAULT 0, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "last_login_at" TIMESTAMP(3), + + CONSTRAINT "admin_users_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "admin_users_email_key" ON "admin_users"("email"); diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index f2ca89f..d8e08da 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -897,3 +897,36 @@ model TechStepTrainingSuggestion { @@map("tech_step_training_suggestion") } + +// ----------------------------------------------------------------------------- +// Admin application +// A separate operations app (usage metrics, microservice monitoring, tech-step +// correction triage) — see specs/backend-architecture.md's "admin" section. +// ----------------------------------------------------------------------------- + +/// An operator of the admin application (`apps/admin-web` / the `/admin/*` +/// API surface). Deliberately its **own** table with **no relation** to +/// `UserProfile`: admin access is a completely separate concern from being +/// an end user of the recipe app — a person can be one, both or neither, +/// and the two auth mechanisms (`requireAdmin` vs `requireAuth`, distinct +/// cookies, distinct JWT secrets) never overlap. No self-service signup — +/// the first row is created out-of-band by `src/scripts/create-admin.ts`, +/// and (for now) there's no in-app admin-management UI. +model AdminUser { + id Int @id @default(autoincrement()) + email String @unique + /// argon2 hash of the password — same hashing as `UserProfile.passwordHash` + /// (`auth.service.ts`'s `hashOptions`, cheaper cost under NODE_ENV=test). + passwordHash String @map("password_hash") + /// Display name shown in the admin UI's account menu. + name String + /// Bumped to invalidate previously-issued admin JWTs — same mechanism as + /// `UserProfile.tokenVersion`, checked on every request by `requireAdmin`. + tokenVersion Int @default(0) @map("token_version") + createdAt DateTime @default(now()) @map("created_at") + /// Stamped on every successful login — a cheap "is this account still in + /// use" signal for the operator managing admins by hand. + lastLoginAt DateTime? @map("last_login_at") + + @@map("admin_users") +} diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index 028fc4b..b4b2fb2 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -5,6 +5,7 @@ import type { Express, Request, Response } from "express"; import { env } from "./config/env.js"; import { errorLogger } from "./middlewares/error-logger.js"; import { requestLogger } from "./middlewares/request-logger.js"; +import { adminRouter } from "./modules/admin/admin.routes.js"; import { authRouter } from "./modules/auth/auth.routes.js"; import { cookingSessionRouter } from "./modules/cooking-session/cooking-session.routes.js"; import { houseRouter } from "./modules/house/house.routes.js"; @@ -33,13 +34,19 @@ export function createServer(): ExpressServer { // pipeline (its "finish" listener still fires for a request that never // makes it past CORS/body-parsing, not just ones that reach a route). server.addMiddleware(requestLogger); - server.setupCore({ corsOrigin: env.CORS_ORIGIN }); + // Two allowed origins: the main app (`CORS_ORIGIN`) and the separate + // admin app (`ADMIN_CORS_ORIGIN`). The `cors` package matches an incoming + // `Origin` against any entry of the list. + server.setupCore({ corsOrigin: [env.CORS_ORIGIN, env.ADMIN_CORS_ORIGIN] }); server.addRoute("get", "/health", (_req: Request, res: Response) => { res.status(200).json({ status: "ok" }); }); server.mountRouter("/auth", authRouter); + // Admin application surface (`apps/admin-web`) — its own auth + // (`requireAdmin`, distinct cookie/secret), never the end-user session. + server.mountRouter("/admin", adminRouter); server.mountRouter("/house", houseRouter); // Not user-facing — `services/tech-step-llm-worker` only, guarded by // `requireInternalWorker` on every route within (see that router's own diff --git a/apps/api/src/config/env.ts b/apps/api/src/config/env.ts index 9224ecc..578c9de 100644 --- a/apps/api/src/config/env.ts +++ b/apps/api/src/config/env.ts @@ -90,6 +90,27 @@ const envSchema = z.object({ * every technique detection request failing one at a time. */ INTENT_SERVICE_SECRET: z.string().min(32, "INTENT_SERVICE_SECRET must be at least 32 characters"), + /** + * Secret used to sign/verify the **admin** session JWT (`lib/admin-jwt.ts`) + * — entirely separate from `JWT_SECRET`, so an end-user session token can + * never be replayed against `/admin/*` and vice versa. `.optional()` + * (unlike `JWT_SECRET`): an instance that doesn't run the admin app at + * all never needs it — but `requireAdmin` (`middlewares/require-admin.ts`) + * rejects every request outright when it's unset, so the surface fails + * closed, same posture as `INTERNAL_WORKER_SECRET`. + */ + ADMIN_JWT_SECRET: z + .string() + .min(32, "ADMIN_JWT_SECRET must be at least 32 characters") + .optional(), + /** Name of the httpOnly cookie carrying the admin session JWT — must differ from `AUTH_COOKIE_NAME` so the two sessions coexist in one browser. */ + ADMIN_COOKIE_NAME: z.string().default("admin_session"), + /** Origin `apps/admin-web` is served from — added to the CORS allow-list alongside `CORS_ORIGIN`. */ + ADMIN_CORS_ORIGIN: z.string().default("http://localhost:5174"), + /** Optional seed values read by `src/scripts/create-admin.ts` when its `--email`/`--password`/`--name` flags are omitted — never used by the running server. */ + ADMIN_INITIAL_EMAIL: z.string().optional(), + ADMIN_INITIAL_PASSWORD: z.string().optional(), + ADMIN_INITIAL_NAME: z.string().optional(), }); /** Parsed, validated environment — import this instead of reading `process.env` directly anywhere else. */ diff --git a/apps/api/src/lib/admin-jwt.ts b/apps/api/src/lib/admin-jwt.ts new file mode 100644 index 0000000..359bb2c --- /dev/null +++ b/apps/api/src/lib/admin-jwt.ts @@ -0,0 +1,59 @@ +import jwt from "jsonwebtoken"; +import { env } from "../config/env.js"; + +/** + * Decoded contents of an **admin** session JWT, once verified. Deliberately + * a separate token type from `lib/jwt.ts`'s `AuthTokenPayload`: the admin + * app authenticates against its own `AdminUser` table with its own secret + * (`ADMIN_JWT_SECRET`), so an end-user session token and an admin session + * token are never interchangeable. + */ +export interface AdminTokenPayload { + /** `AdminUser.id` this token authenticates. */ + adminUserId: number; + /** Snapshot of `AdminUser.tokenVersion` at sign time — re-checked against the DB on every request (see `requireAdmin`) to allow server-side invalidation. */ + tokenVersion: number; +} + +/** + * The admin JWT secret, or a thrown error if the instance never configured + * one. A misconfigured admin deployment surfaces as a loud 500 on login + * rather than a silently-unsigned token; a deployment that doesn't run the + * admin app at all never reaches here (nothing calls sign/verify), and + * `requireAdmin` independently fails closed on the same unset value. + */ +function adminSecret(): string { + if (env.ADMIN_JWT_SECRET === undefined) { + throw new Error("ADMIN_JWT_SECRET is not configured — cannot issue or verify admin sessions"); + } + return env.ADMIN_JWT_SECRET; +} + +/** Signs a new admin session JWT, expiring per `JWT_EXPIRES_IN` (shared with the end-user token — same "how long a session lasts" policy). */ +export function signAdminToken(payload: AdminTokenPayload): string { + return jwt.sign( + { sub: String(payload.adminUserId), tokenVersion: payload.tokenVersion }, + adminSecret(), + { expiresIn: env.JWT_EXPIRES_IN as jwt.SignOptions["expiresIn"] }, + ); +} + +/** + * Verifies an admin session JWT's signature/expiry and decodes it back + * into an {@link AdminTokenPayload}. + * + * @throws {Error} if the token is invalid/expired (from `jwt.verify`) or + * structurally malformed (missing/wrong-typed claims). + */ +export function verifyAdminToken(token: string): AdminTokenPayload { + const decoded = jwt.verify(token, adminSecret()); + const adminUserId = typeof decoded === "object" ? Number(decoded.sub) : Number.NaN; + if ( + typeof decoded !== "object" || + Number.isNaN(adminUserId) || + typeof decoded.tokenVersion !== "number" + ) { + throw new Error("Malformed admin token payload"); + } + return { adminUserId, tokenVersion: decoded.tokenVersion }; +} diff --git a/apps/api/src/lib/safe-admin.ts b/apps/api/src/lib/safe-admin.ts new file mode 100644 index 0000000..d03fbaf --- /dev/null +++ b/apps/api/src/lib/safe-admin.ts @@ -0,0 +1,20 @@ +import type { AdminUserView } from "@batch-cooking/shared"; +import type { AdminUser } from "@prisma/client"; + +/** + * Shapes a Prisma `AdminUser` into the {@link AdminUserView} sent to the + * admin client — drops `passwordHash` **and** `tokenVersion` (an internal + * invalidation counter the client never needs, unlike `SafeUserProfile` + * which does expose it), and serializes the two dates to ISO strings. The + * one place this security-relevant stripping happens, same role as + * `toSafeProfile` (`lib/safe-profile.ts`). + */ +export function toSafeAdmin(admin: AdminUser): AdminUserView { + return { + id: admin.id, + email: admin.email, + name: admin.name, + createdAt: admin.createdAt.toISOString(), + lastLoginAt: admin.lastLoginAt === null ? null : admin.lastLoginAt.toISOString(), + }; +} diff --git a/apps/api/src/middlewares/require-admin.ts b/apps/api/src/middlewares/require-admin.ts new file mode 100644 index 0000000..daf0569 --- /dev/null +++ b/apps/api/src/middlewares/require-admin.ts @@ -0,0 +1,71 @@ +import { HttpError } from "@batch-cooking/error-tools"; +import { type AdminUserView, ErrorCode } from "@batch-cooking/shared"; +import type { NextFunction, Request, Response } from "express"; +import { env } from "../config/env.js"; +import { prisma } from "../db/prisma.js"; +import { verifyAdminToken } from "../lib/admin-jwt.js"; +import { toSafeAdmin } from "../lib/safe-admin.js"; + +/** + * Shape of `res.locals` once {@link requireAdmin} has run successfully. + * Type a handler's response as `Response` to read + * `res.locals.adminUser` fully typed, no cast — same `res.locals` (not + * global `Request` augmentation) approach as {@link AuthLocals} + * (`require-auth.ts`). + */ +export interface AdminLocals { + /** The authenticated admin operator, resolved from the admin session cookie's JWT. */ + adminUser: AdminUserView; +} + +/** + * Express middleware guarding every `/admin/*` route — the operations app + * (`apps/admin-web`) authenticating as an `AdminUser`. Reads the admin + * session cookie (`ADMIN_COOKIE_NAME`, deliberately **not** the same cookie + * as end-user sessions), verifies the JWT against `ADMIN_JWT_SECRET` + * (a different secret than `JWT_SECRET`), and re-checks `tokenVersion` + * against the database so a stateless JWT can still be invalidated + * server-side. + * + * A completely separate mechanism from {@link requireAuth}, not layered on + * it: an end-user session token and an admin session token are never + * interchangeable in either direction. + * + * Fails closed: an unset `ADMIN_JWT_SECRET` (the default for any instance + * that doesn't run the admin app) makes {@link verifyAdminToken} throw, so + * every request is rejected rather than the surface left open — same + * posture as `requireInternalWorker`. + * + * @throws {HttpError} `401 NOT_AUTHENTICATED` for any failure — missing + * cookie, malformed/expired JWT, unknown admin, stale tokenVersion, or + * no secret configured. Never distinguishes the reason. + */ +export async function requireAdmin( + req: Request, + res: Response, + next: NextFunction, +) { + try { + const token = req.cookies?.[env.ADMIN_COOKIE_NAME]; + if (typeof token !== "string") { + throw new HttpError(401, ErrorCode.NOT_AUTHENTICATED, "Not authenticated"); + } + + const payload = verifyAdminToken(token); + const admin = await prisma.adminUser.findUnique({ where: { id: payload.adminUserId } }); + + if (!admin || admin.tokenVersion !== payload.tokenVersion) { + throw new HttpError(401, ErrorCode.NOT_AUTHENTICATED, "Not authenticated"); + } + + res.locals.adminUser = toSafeAdmin(admin); + next(); + } catch (err) { + if (err instanceof HttpError) { + next(err); + } else { + // Covers jwt.verify failures and the unset-secret throw from verifyAdminToken. + next(new HttpError(401, ErrorCode.NOT_AUTHENTICATED, "Not authenticated")); + } + } +} diff --git a/apps/api/src/modules/admin/admin-auth.routes.ts b/apps/api/src/modules/admin/admin-auth.routes.ts new file mode 100644 index 0000000..ca12ede --- /dev/null +++ b/apps/api/src/modules/admin/admin-auth.routes.ts @@ -0,0 +1,51 @@ +import { wrapAsyncHandler } from "@batch-cooking/express-tools"; +import { adminLoginSchema } from "@batch-cooking/shared"; +import { type CookieOptions, type Response, Router } from "express"; +import { env } from "../../config/env.js"; +import { type AdminLocals, requireAdmin } from "../../middlewares/require-admin.js"; +import { adminLogin } from "./admin-auth.service.js"; + +/** Router mounted at `/admin/auth` (via `admin.routes.ts`) — admin login, logout, current-admin. No signup: admins are created out-of-band (`src/scripts/create-admin.ts`). */ +export const adminAuthRouter = Router(); + +const SEVEN_DAYS_MS = 7 * 24 * 60 * 60 * 1000; + +/** + * Cookie options for the admin session — same shape as `auth.routes.ts`'s + * end-user cookie (httpOnly, `Secure` in production unless `COOKIE_SECURE` + * overrides, `SameSite=Lax`), just written under {@link env.ADMIN_COOKIE_NAME} + * so the two sessions never collide in one browser. + */ +const adminCookieOptions: CookieOptions = { + httpOnly: true, + secure: env.COOKIE_SECURE ?? env.NODE_ENV === "production", + sameSite: "lax", + maxAge: SEVEN_DAYS_MS, +}; + +// `res.clearCookie` sets its own expiry — passing `maxAge` alongside is +// deprecated as of Express 4.20, so the logout route reuses the options +// minus that one field (same trick as `auth.routes.ts`). +const { maxAge: _maxAge, ...clearAdminCookieOptions } = adminCookieOptions; + +/** Verifies admin credentials and starts an admin session. */ +adminAuthRouter.post( + "/login", + wrapAsyncHandler(async (req, res) => { + const input = adminLoginSchema.parse(req.body); + const { admin, token } = await adminLogin(input); + res.cookie(env.ADMIN_COOKIE_NAME, token, adminCookieOptions); + res.status(200).json(admin); + }), +); + +/** Ends the admin session by clearing the cookie. Stateless JWT — nothing to revoke server-side beyond bumping `tokenVersion` (no UI for that yet). */ +adminAuthRouter.post("/logout", (_req, res) => { + res.clearCookie(env.ADMIN_COOKIE_NAME, clearAdminCookieOptions); + res.status(204).end(); +}); + +/** Returns the currently authenticated admin. Behind `requireAdmin` — 401s if there's no valid admin session. */ +adminAuthRouter.get("/me", requireAdmin, (_req, res: Response) => { + res.status(200).json(res.locals.adminUser); +}); diff --git a/apps/api/src/modules/admin/admin-auth.service.ts b/apps/api/src/modules/admin/admin-auth.service.ts new file mode 100644 index 0000000..7833809 --- /dev/null +++ b/apps/api/src/modules/admin/admin-auth.service.ts @@ -0,0 +1,59 @@ +import { HttpError } from "@batch-cooking/error-tools"; +import { type AdminLoginInput, type AdminUserView, ErrorCode } from "@batch-cooking/shared"; +import argon2 from "argon2"; +import { env } from "../../config/env.js"; +import { prisma } from "../../db/prisma.js"; +import { signAdminToken } from "../../lib/admin-jwt.js"; +import { toSafeAdmin } from "../../lib/safe-admin.js"; + +/** Result of a successful admin login: the safe admin view plus the signed admin session JWT to set as a cookie. */ +interface AdminAuthResult { + admin: AdminUserView; + token: string; +} + +// Same reasoning as `auth.service.ts`'s `hashOptions`: argon2's real +// defaults are deliberately expensive; the test suite hashes/verifies +// against throwaway data many times per run, so a cheaper cost keeps it +// fast without weakening anything real. Never applies outside NODE_ENV=test. +const testHashOptions = { memoryCost: 8192, timeCost: 2, parallelism: 1 }; +const hashOptions = env.NODE_ENV === "test" ? testHashOptions : undefined; + +/** Exposed so `src/scripts/create-admin.ts` hashes exactly the same way `login` verifies. */ +export function hashAdminPassword(password: string): Promise { + return argon2.hash(password, hashOptions); +} + +/** + * Verifies an admin operator's credentials, stamps `lastLoginAt`, and + * issues a fresh admin session token. + * + * @throws {HttpError} `401 INVALID_CREDENTIALS` for either an unknown email + * or a wrong password — deliberately indistinguishable, same reasoning as + * `auth.service.ts`'s `login`. + */ +export async function adminLogin(input: AdminLoginInput): Promise { + try { + const admin = await prisma.adminUser.findUnique({ where: { email: input.email } }); + + if (!admin || !(await argon2.verify(admin.passwordHash, input.password))) { + throw new HttpError(401, ErrorCode.INVALID_CREDENTIALS, "Invalid email or password"); + } + + const updated = await prisma.adminUser.update({ + where: { id: admin.id }, + data: { lastLoginAt: new Date() }, + }); + + const token = signAdminToken({ + adminUserId: updated.id, + tokenVersion: updated.tokenVersion, + }); + return { admin: toSafeAdmin(updated), token }; + } catch (err) { + // Rethrown as-is — `wrapAsyncHandler`/the error middleware handles it, + // this service layer just isn't allowed a bare `await` per the repo's + // async/try-catch convention. + throw err; + } +} diff --git a/apps/api/src/modules/admin/admin.routes.ts b/apps/api/src/modules/admin/admin.routes.ts new file mode 100644 index 0000000..ce51e05 --- /dev/null +++ b/apps/api/src/modules/admin/admin.routes.ts @@ -0,0 +1,13 @@ +import { Router } from "express"; +import { adminAuthRouter } from "./admin-auth.routes.js"; + +/** + * Aggregator for the admin application's API surface, mounted at `/admin` + * in `app.ts`. Every sub-router here is for `apps/admin-web` only — + * `/admin/auth` is public (login), everything added later + * (`/admin/metrics`, `/admin/monitoring`, `/admin/tech-steps/*`) sits + * behind `requireAdmin` (`middlewares/require-admin.ts`). + */ +export const adminRouter = Router(); + +adminRouter.use("/auth", adminAuthRouter); diff --git a/apps/api/src/scripts/create-admin.ts b/apps/api/src/scripts/create-admin.ts new file mode 100644 index 0000000..d7bf977 --- /dev/null +++ b/apps/api/src/scripts/create-admin.ts @@ -0,0 +1,56 @@ +import { env } from "../config/env.js"; +import { prisma } from "../db/prisma.js"; +import { hashAdminPassword } from "../modules/admin/admin-auth.service.js"; + +/** + * Creates the first (or an additional) `AdminUser` for the admin + * application — there is no self-service admin signup, on purpose (see the + * `AdminUser` model doc comment in schema.prisma). + * + * pnpm --filter api exec tsx src/scripts/create-admin.ts \ + * --email=ops@example.com --password='...' --name='Ops' + * + * Each flag falls back to the matching `ADMIN_INITIAL_*` env var when + * omitted, so a deployment can bake the first admin's credentials into its + * environment and run this once from the container without passing args. + * Refuses (exit 1) if an `AdminUser` with that email already exists — + * changing an existing admin's password is a manual DB operation for now, + * not something this script does. + */ +function flag(name: string): string | undefined { + const prefix = `--${name}=`; + const arg = process.argv.find((value) => value.startsWith(prefix)); + return arg === undefined ? undefined : arg.slice(prefix.length); +} + +async function createAdmin(): Promise { + const email = (flag("email") ?? env.ADMIN_INITIAL_EMAIL)?.trim().toLowerCase(); + const password = flag("password") ?? env.ADMIN_INITIAL_PASSWORD; + const name = (flag("name") ?? env.ADMIN_INITIAL_NAME)?.trim(); + + if (!email || !password || !name) { + throw new Error( + "Missing required input. Provide --email, --password and --name (or set ADMIN_INITIAL_EMAIL / ADMIN_INITIAL_PASSWORD / ADMIN_INITIAL_NAME).", + ); + } + if (password.length < 8) { + throw new Error("Password must be at least 8 characters."); + } + + const existing = await prisma.adminUser.findUnique({ where: { email } }); + if (existing) { + throw new Error(`An admin with email "${email}" already exists (id ${existing.id}).`); + } + + const passwordHash = await hashAdminPassword(password); + const admin = await prisma.adminUser.create({ data: { email, name, passwordHash } }); + console.info(`Created admin #${admin.id} <${admin.email}> ("${admin.name}").`); +} + +createAdmin() + .then(() => prisma.$disconnect()) + .catch(async (err) => { + console.error(err instanceof Error ? err.message : err); + await prisma.$disconnect(); + process.exit(1); + }); diff --git a/apps/api/test-support/reset-db.ts b/apps/api/test-support/reset-db.ts index c12b1ac..6d98bd6 100644 --- a/apps/api/test-support/reset-db.ts +++ b/apps/api/test-support/reset-db.ts @@ -47,7 +47,8 @@ export async function resetDatabase() { "planning_item", "planning", "recipe_ingredient", "step_tech_step", "step", "tech_step", "recipe", "ingredients", "sources", "unit", - "user_profiles", "diet", "house" + "user_profiles", "diet", "house", + "admin_users" RESTART IDENTITY CASCADE; `); await seedReferenceData(prisma); diff --git a/apps/api/test/admin-auth.test.ts b/apps/api/test/admin-auth.test.ts new file mode 100644 index 0000000..4922318 --- /dev/null +++ b/apps/api/test/admin-auth.test.ts @@ -0,0 +1,149 @@ +import { ErrorCode, type SignupInput } from "@batch-cooking/shared"; +import { faker } from "@faker-js/faker"; +import { expect } from "chai"; +import request from "supertest"; +import { createApp } from "../src/app.js"; +import { env } from "../src/config/env.js"; +import { prisma } from "../src/db/prisma.js"; +import { hashAdminPassword } from "../src/modules/admin/admin-auth.service.js"; +import { resetDatabase } from "../test-support/reset-db.js"; + +/** See `auth.test.ts` — same rationale for generating rather than hardcoding. */ +function buildSignupPayload(): SignupInput { + const firstName = faker.person.firstName(); + const lastName = faker.person.lastName(); + return { + firstName, + lastName, + email: faker.internet.email({ firstName, lastName }).toLowerCase(), + password: faker.internet.password({ length: 16 }), + }; +} + +/** Inserts an `AdminUser` straight into the DB (no signup route exists) and returns its plaintext password. */ +async function seedAdmin(): Promise<{ email: string; password: string }> { + const email = faker.internet.email().toLowerCase(); + const password = faker.internet.password({ length: 16 }); + await prisma.adminUser.create({ + data: { email, name: faker.person.fullName(), passwordHash: await hashAdminPassword(password) }, + }); + return { email, password }; +} + +/** The admin login/verify path signs a JWT — self-skip those cases when no `ADMIN_JWT_SECRET` is configured (same posture as `tech-step-worker.routes.test.ts` with `INTERNAL_WORKER_SECRET`). */ +const adminSecretConfigured = env.ADMIN_JWT_SECRET !== undefined; + +describe("Admin auth", () => { + const app = createApp(); + + beforeEach(async () => { + await resetDatabase(); + }); + + after(async () => { + await prisma.$disconnect(); + }); + + describe("POST /admin/auth/login", () => { + it("rejects a missing body with 400 VALIDATION_ERROR", async () => { + const res = await request(app).post("/admin/auth/login").send({}); + expect(res.status).to.equal(400); + expect(res.body.code).to.equal(ErrorCode.VALIDATION_ERROR); + }); + + it("rejects an unknown email with 401 INVALID_CREDENTIALS", async () => { + const res = await request(app) + .post("/admin/auth/login") + .send({ email: "nobody@example.com", password: "whatever" }); + expect(res.status).to.equal(401); + expect(res.body.code).to.equal(ErrorCode.INVALID_CREDENTIALS); + }); + + it("rejects a wrong password with 401 INVALID_CREDENTIALS", async () => { + const { email } = await seedAdmin(); + const res = await request(app) + .post("/admin/auth/login") + .send({ email, password: "not-the-password" }); + expect(res.status).to.equal(401); + expect(res.body.code).to.equal(ErrorCode.INVALID_CREDENTIALS); + }); + + it("logs in with correct credentials, sets the admin cookie, stamps lastLoginAt, never leaks the hash", async function () { + if (!adminSecretConfigured) { + // biome-ignore lint/suspicious/noExplicitAny: mocha's `this.skip()` isn't typed without @types/mocha (not a dependency here). + (this as any).skip(); + return; + } + const { email, password } = await seedAdmin(); + + const res = await request(app).post("/admin/auth/login").send({ email, password }); + + expect(res.status).to.equal(200); + expect(res.body.email).to.equal(email); + expect(res.body).to.not.have.property("passwordHash"); + expect(res.body).to.not.have.property("tokenVersion"); + expect(res.body.lastLoginAt).to.be.a("string"); + + const setCookie = res.headers["set-cookie"]; + expect(Array.isArray(setCookie) ? setCookie.join(";") : String(setCookie)).to.include( + env.ADMIN_COOKIE_NAME, + ); + + const stored = await prisma.adminUser.findUniqueOrThrow({ where: { email } }); + expect(stored.lastLoginAt).to.not.equal(null); + }); + }); + + describe("GET /admin/auth/me", () => { + it("rejects a request with no admin cookie with 401 NOT_AUTHENTICATED", async () => { + const res = await request(app).get("/admin/auth/me"); + expect(res.status).to.equal(401); + expect(res.body.code).to.equal(ErrorCode.NOT_AUTHENTICATED); + }); + + it("returns the admin behind a valid admin session", async function () { + if (!adminSecretConfigured) { + // biome-ignore lint/suspicious/noExplicitAny: see above. + (this as any).skip(); + return; + } + const { email, password } = await seedAdmin(); + const agent = request.agent(app); + await agent.post("/admin/auth/login").send({ email, password }); + + const res = await agent.get("/admin/auth/me"); + expect(res.status).to.equal(200); + expect(res.body.email).to.equal(email); + }); + + it("stops returning the admin after logout", async function () { + if (!adminSecretConfigured) { + // biome-ignore lint/suspicious/noExplicitAny: see above. + (this as any).skip(); + return; + } + const { email, password } = await seedAdmin(); + const agent = request.agent(app); + await agent.post("/admin/auth/login").send({ email, password }); + + const logoutRes = await agent.post("/admin/auth/logout"); + expect(logoutRes.status).to.equal(204); + + const meRes = await agent.get("/admin/auth/me"); + expect(meRes.status).to.equal(401); + }); + + it("does not accept an end-user session cookie as an admin session", async () => { + // An ordinary user logs in (sets the `session` cookie), then tries the + // admin surface with that same agent — `requireAdmin` reads a + // different cookie entirely, so this must 401 regardless of whether + // ADMIN_JWT_SECRET is configured. + const agent = request.agent(app); + await agent.post("/auth/signup").send(buildSignupPayload()); + + const res = await agent.get("/admin/auth/me"); + expect(res.status).to.equal(401); + expect(res.body.code).to.equal(ErrorCode.NOT_AUTHENTICATED); + }); + }); +}); diff --git a/docker-compose.yml b/docker-compose.yml index d0fab73..e62058a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -48,6 +48,15 @@ services: # default: `/internal/tech-steps/*` fails closed rather than open # for a deployment that doesn't run the worker at all. INTERNAL_WORKER_SECRET: ${INTERNAL_WORKER_SECRET:-} + # Admin application (apps/admin-web + /admin/*). Both unset by default: + # `requireAdmin` fails closed without ADMIN_JWT_SECRET, so a stack + # that doesn't run the admin app simply has every /admin/* route 401. + # Must be a *different* secret than JWT_SECRET. + ADMIN_JWT_SECRET: ${ADMIN_JWT_SECRET:-} + # Public origin apps/admin-web is served from, added to the CORS + # allow-list alongside the main app. Defaults to the compose + # `admin-web` service's mapped host port. + ADMIN_CORS_ORIGIN: ${ADMIN_CORS_ORIGIN:-http://localhost:3001} # Compose network service name, not localhost — same reasoning as # DATABASE_URL above. Unlike INTERNAL_WORKER_SECRET, no `:-` fallback: # tech-step-intent-service is a core dependency (see its own entry diff --git a/packages/express-tools/src/express-server.ts b/packages/express-tools/src/express-server.ts index ceb1473..ce46149 100644 --- a/packages/express-tools/src/express-server.ts +++ b/packages/express-tools/src/express-server.ts @@ -13,8 +13,14 @@ export type HttpMethod = "get" | "post" | "put" | "patch" | "delete"; /** Options for {@link ExpressServer.setupCore}. */ export interface ExpressServerCoreOptions { - /** Origin allowed by CORS — must match wherever the frontend is served from. */ - corsOrigin: string; + /** + * Origin(s) allowed by CORS — a single origin, or a list when more than + * one frontend talks to this API from a different origin (e.g. the main + * app plus a separate admin app). Passed straight through to the `cors` + * package, which matches an incoming `Origin` against any entry of the + * list. + */ + corsOrigin: string | string[]; } /** diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 1034999..eae8306 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -7,6 +7,7 @@ export * from "./data/catalog-labels-en.js"; export * from "./data/catalog-labels-fr.js"; export * from "./errors/error-codes.js"; export * from "./schemas/account.js"; +export * from "./schemas/admin.js"; export * from "./schemas/auth.js"; export * from "./schemas/cooking-session.js"; export * from "./schemas/household.js"; @@ -18,6 +19,7 @@ export * from "./schemas/shopping-list.js"; export * from "./schemas/sources.js"; export * from "./schemas/tech-step-worker.js"; export * from "./tools/assert-is-never.js"; +export * from "./types/admin.js"; export * from "./types/cooking-session.js"; export * from "./types/household.js"; export * from "./types/planning.js"; diff --git a/packages/shared/src/schemas/admin.ts b/packages/shared/src/schemas/admin.ts new file mode 100644 index 0000000..f02cf08 --- /dev/null +++ b/packages/shared/src/schemas/admin.ts @@ -0,0 +1,18 @@ +import { z } from "zod"; + +// Shared between apps/api (server-side validation, source of truth) and +// apps/admin-web (client-side validation for instant feedback). Same +// rationale as schemas/auth.ts — one set of rules, French messages surfaced +// as-is in the admin login form. + +/** + * Payload accepted by `POST /admin/auth/login`. Deliberately its own schema + * (not a re-export of `loginSchema`): the admin surface is a separate + * contract from the end-user one even though the shape currently matches. + */ +export const adminLoginSchema = z.object({ + email: z.string().trim().toLowerCase().email("Email invalide"), + password: z.string().min(1, "Le mot de passe est requis"), +}); +/** Inferred TS type for {@link adminLoginSchema}'s validated output. */ +export type AdminLoginInput = z.infer; diff --git a/packages/shared/src/types/admin.ts b/packages/shared/src/types/admin.ts new file mode 100644 index 0000000..499e5eb --- /dev/null +++ b/packages/shared/src/types/admin.ts @@ -0,0 +1,16 @@ +/** + * Public shape of an admin operator, as returned by `GET /admin/auth/me` + * and `POST /admin/auth/login` — never includes the password hash. Mirrors + * apps/api's `Omit`, + * declared by hand rather than derived from the Prisma type (same reason as + * {@link SafeUserProfile}: apps/admin-web must not depend on + * `@prisma/client`). `createdAt`/`lastLoginAt` are ISO 8601 strings (JSON + * has no date type). + */ +export interface AdminUserView { + id: number; + email: string; + name: string; + createdAt: string; + lastLoginAt: string | null; +} -- 2.45.2 From c7301ea844596c036b029f80ac28379ccfef5d70 Mon Sep 17 00:00:00 2001 From: Nicolas Date: Fri, 28 Aug 2026 12:14:50 +0200 Subject: [PATCH 2/6] feat(admin): scaffold de l'application d'administration apps/admin-web Nouvelle app Vite/React independante (workspace apps/*), calquee sur apps/web : port 5174, sa propre image Docker (nginx statique), son propre domaine/deploiement. - AdminApiClient (VITE_ADMIN_API_URL, credentials: include) + ApiError. - AdminAuthContext / RequireAdmin : restaure la session admin via GET /admin/auth/me, garde de route (miroir de AuthContext/RequireAuth). - LoginPage (/login) : validation cliente via adminLoginSchema partage, erreurs traduites via ErrorMessageService. - AdminLayout : sidebar (Tableau de bord / Monitoring / Corrections) + deconnexion, rendu une fois autour du groupe RequireAdmin. - Pages Dashboard / Monitoring / Corrections en placeholder (remplies aux PR 3-5). - i18n fr (bloc admin.* + sous-ensemble errors.*), tokens _theme.scss copies de apps/web (extraction en package partage : suivi separe). - Dockerfile multi-stage (node build -> nginx:alpine) + nginx.conf (SPA fallback). Service admin-web dans docker-compose.yml (port ADMIN_WEB_PORT, VITE_ADMIN_API_URL en build arg). - Cypress : login.feature (KO -> message, OK -> dashboard) + admin-layout .cy.ts (redirection /login sans session, nav entre sections, logout). Job "Run admin-web E2E tests" ajoute a ci.yml. Co-Authored-By: Claude Sonnet 5 --- .github/workflows/ci.yml | 7 + apps/admin-web/.env.example | 6 + apps/admin-web/Dockerfile | 25 ++ apps/admin-web/cypress.config.ts | 28 ++ apps/admin-web/cypress/e2e/admin-layout.cy.ts | 57 +++ apps/admin-web/cypress/e2e/login.feature | 24 ++ apps/admin-web/cypress/e2e/login.ts | 24 ++ apps/admin-web/cypress/support/e2e.ts | 3 + .../support/step_definitions/common.steps.ts | 54 +++ apps/admin-web/index.html | 12 + apps/admin-web/nginx.conf | 20 + apps/admin-web/package.json | 42 +++ apps/admin-web/src/App.tsx | 34 ++ apps/admin-web/src/api/client.ts | 96 +++++ .../src/features/auth/AdminAuthContext.tsx | 71 ++++ .../src/features/auth/RequireAdmin.tsx | 21 ++ .../src/features/auth/admin-auth.scss | 84 +++++ apps/admin-web/src/i18n/i18n.ts | 22 ++ apps/admin-web/src/layouts/AdminLayout.scss | 108 ++++++ apps/admin-web/src/layouts/AdminLayout.tsx | 83 +++++ apps/admin-web/src/lib/zod-errors.ts | 18 + .../admin-web/src/locales/fr/translation.json | 42 +++ apps/admin-web/src/main.tsx | 24 ++ apps/admin-web/src/pages/admin-page.scss | 26 ++ .../src/pages/corrections/CorrectionsPage.tsx | 19 + .../src/pages/dashboard/DashboardPage.tsx | 17 + apps/admin-web/src/pages/login/LoginPage.tsx | 85 +++++ .../src/pages/monitoring/MonitoringPage.tsx | 18 + .../src/services/error-message.service.ts | 19 + apps/admin-web/src/styles/_theme.scss | 171 +++++++++ apps/admin-web/src/styles/global.scss | 149 ++++++++ apps/admin-web/src/vite-env.d.ts | 5 + apps/admin-web/tsconfig.app.json | 14 + apps/admin-web/tsconfig.json | 8 + apps/admin-web/tsconfig.node.json | 12 + apps/admin-web/vite.config.ts | 19 + docker-compose.yml | 18 + pnpm-lock.yaml | 343 ++++++++++++++++++ 38 files changed, 1828 insertions(+) create mode 100644 apps/admin-web/.env.example create mode 100644 apps/admin-web/Dockerfile create mode 100644 apps/admin-web/cypress.config.ts create mode 100644 apps/admin-web/cypress/e2e/admin-layout.cy.ts create mode 100644 apps/admin-web/cypress/e2e/login.feature create mode 100644 apps/admin-web/cypress/e2e/login.ts create mode 100644 apps/admin-web/cypress/support/e2e.ts create mode 100644 apps/admin-web/cypress/support/step_definitions/common.steps.ts create mode 100644 apps/admin-web/index.html create mode 100644 apps/admin-web/nginx.conf create mode 100644 apps/admin-web/package.json create mode 100644 apps/admin-web/src/App.tsx create mode 100644 apps/admin-web/src/api/client.ts create mode 100644 apps/admin-web/src/features/auth/AdminAuthContext.tsx create mode 100644 apps/admin-web/src/features/auth/RequireAdmin.tsx create mode 100644 apps/admin-web/src/features/auth/admin-auth.scss create mode 100644 apps/admin-web/src/i18n/i18n.ts create mode 100644 apps/admin-web/src/layouts/AdminLayout.scss create mode 100644 apps/admin-web/src/layouts/AdminLayout.tsx create mode 100644 apps/admin-web/src/lib/zod-errors.ts create mode 100644 apps/admin-web/src/locales/fr/translation.json create mode 100644 apps/admin-web/src/main.tsx create mode 100644 apps/admin-web/src/pages/admin-page.scss create mode 100644 apps/admin-web/src/pages/corrections/CorrectionsPage.tsx create mode 100644 apps/admin-web/src/pages/dashboard/DashboardPage.tsx create mode 100644 apps/admin-web/src/pages/login/LoginPage.tsx create mode 100644 apps/admin-web/src/pages/monitoring/MonitoringPage.tsx create mode 100644 apps/admin-web/src/services/error-message.service.ts create mode 100644 apps/admin-web/src/styles/_theme.scss create mode 100644 apps/admin-web/src/styles/global.scss create mode 100644 apps/admin-web/src/vite-env.d.ts create mode 100644 apps/admin-web/tsconfig.app.json create mode 100644 apps/admin-web/tsconfig.json create mode 100644 apps/admin-web/tsconfig.node.json create mode 100644 apps/admin-web/vite.config.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9be7cad..38fbe84 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -179,3 +179,10 @@ jobs: # `component.devServer`), unlike `e2e` above which needs the real app # running first. - run: pnpm --filter web cy:run:component + # The admin app's own Cypress suite (`apps/admin-web`) — its own dev + # server on :5174, all `/admin/*` calls mocked via `cy.intercept` + # (no live backend needed), same as the `web` e2e run above. + - name: Run admin-web E2E tests + env: + HOST: "0.0.0.0" + run: pnpm --filter admin-web e2e diff --git a/apps/admin-web/.env.example b/apps/admin-web/.env.example new file mode 100644 index 0000000..5559b08 --- /dev/null +++ b/apps/admin-web/.env.example @@ -0,0 +1,6 @@ +# Vite only exposes vars prefixed with VITE_ to client code. +# Base URL of the API's /admin/* surface. Empty string = same origin as the +# page (correct behind a shared reverse proxy). Native dev overrides it in +# apps/admin-web/.env since the Vite dev server (5174) and the API (3000) +# are different origins. +VITE_ADMIN_API_URL=http://localhost:3000 diff --git a/apps/admin-web/Dockerfile b/apps/admin-web/Dockerfile new file mode 100644 index 0000000..bf8d9a4 --- /dev/null +++ b/apps/admin-web/Dockerfile @@ -0,0 +1,25 @@ +# Its own image (not built into apps/api's) — the admin app is deployed +# independently of the main app. Build stage compiles the Vite bundle from +# the monorepo; runtime is a plain static nginx serving that bundle. +# +# Build context is the repo root (like apps/api/Dockerfile) — the workspace +# packages (@batch-cooking/shared, @batch-cooking/date-tools) must resolve. +FROM node:22-slim AS build +RUN corepack enable +WORKDIR /repo +# Skip Cypress's Electron binary download — this image never runs it. +ENV CYPRESS_INSTALL_BINARY=0 +COPY . . +RUN pnpm install --frozen-lockfile +# The admin bundle bakes in VITE_ADMIN_API_URL at build time. Default "" +# (same-origin — correct behind a shared reverse proxy); override with +# `--build-arg VITE_ADMIN_API_URL=https://api.example.com` when the admin +# app is served from a different origin than the API. +ARG VITE_ADMIN_API_URL="" +ENV VITE_ADMIN_API_URL=$VITE_ADMIN_API_URL +RUN pnpm --filter admin-web build + +FROM nginx:alpine AS runtime +COPY apps/admin-web/nginx.conf /etc/nginx/conf.d/default.conf +COPY --from=build /repo/apps/admin-web/dist /usr/share/nginx/html +EXPOSE 80 diff --git a/apps/admin-web/cypress.config.ts b/apps/admin-web/cypress.config.ts new file mode 100644 index 0000000..e799b01 --- /dev/null +++ b/apps/admin-web/cypress.config.ts @@ -0,0 +1,28 @@ +import { addCucumberPreprocessorPlugin } from "@badeball/cypress-cucumber-preprocessor"; +import { createEsbuildPlugin } from "@badeball/cypress-cucumber-preprocessor/esbuild"; +import createBundler from "@bahmutov/cypress-esbuild-preprocessor"; +import { defineConfig } from "cypress"; + +// Disable GPU for headless/sandboxed environments where no GPU device is +// available — same helper as apps/web's cypress.config.ts. +function disableGpu(on: Cypress.PluginEvents) { + on("before:browser:launch", (browser, launchOptions) => { + if (browser.family === "chromium") { + launchOptions.args.push("--disable-gpu", "--no-sandbox"); + } + return launchOptions; + }); +} + +export default defineConfig({ + e2e: { + baseUrl: "http://localhost:5174", + specPattern: ["cypress/e2e/**/*.cy.ts", "cypress/e2e/**/*.feature"], + async setupNodeEvents(on, config) { + disableGpu(on); + await addCucumberPreprocessorPlugin(on, config); + on("file:preprocessor", createBundler({ plugins: [createEsbuildPlugin(config)] })); + return config; + }, + }, +}); diff --git a/apps/admin-web/cypress/e2e/admin-layout.cy.ts b/apps/admin-web/cypress/e2e/admin-layout.cy.ts new file mode 100644 index 0000000..33cfff2 --- /dev/null +++ b/apps/admin-web/cypress/e2e/admin-layout.cy.ts @@ -0,0 +1,57 @@ +// Mocks the admin API via cy.intercept — no live backend (apps/api's Mocha +// suite covers real /admin/* behaviour). + +const adminBody = { + id: 1, + email: "ops@example.com", + name: "Ops", + createdAt: "2026-08-01T00:00:00.000Z", + lastLoginAt: "2026-08-28T09:00:00.000Z", +}; + +describe("Admin layout", () => { + it("redirects to /login when there is no admin session", () => { + cy.intercept("GET", "**/admin/auth/me", { + statusCode: 401, + body: { code: 4011, message: "no" }, + }); + cy.visit("/monitoring"); + cy.url().should("include", "/login"); + cy.contains("h1", "Administration").should("be.visible"); + }); + + it("shows the sidebar and navigates between the three sections", () => { + cy.intercept("GET", "**/admin/auth/me", { statusCode: 200, body: adminBody }); + cy.visit("/"); + + cy.contains("h1", "Tableau de bord").should("be.visible"); + cy.contains(".admin-sidebar__who", "Ops").should("be.visible"); + + cy.contains("nav a", "Monitoring").click(); + cy.url().should("include", "/monitoring"); + cy.contains("h1", "Monitoring").should("be.visible"); + cy.contains("nav a", "Monitoring").should("have.class", "active"); + + cy.contains("nav a", "Corrections").click(); + cy.url().should("include", "/corrections"); + cy.contains("h1", "Corrections").should("be.visible"); + + cy.contains("nav a", "Tableau de bord").click(); + cy.url().should("eq", `${Cypress.config().baseUrl}/`); + }); + + it("logs out back to /login", () => { + cy.intercept("GET", "**/admin/auth/me", { statusCode: 200, body: adminBody }); + cy.intercept("POST", "**/admin/auth/logout", { statusCode: 204 }); + cy.visit("/"); + + // Wait until the guarded layout has actually mounted before acting. + cy.contains("h1", "Tableau de bord").should("be.visible"); + + // Logout clears the in-memory admin state, which is what bounces the + // guard to /login — no fresh `me` round-trip involved, so nothing to + // re-stub here. + cy.contains("button", "Se déconnecter").click(); + cy.url().should("include", "/login"); + }); +}); diff --git a/apps/admin-web/cypress/e2e/login.feature b/apps/admin-web/cypress/e2e/login.feature new file mode 100644 index 0000000..27ba050 --- /dev/null +++ b/apps/admin-web/cypress/e2e/login.feature @@ -0,0 +1,24 @@ +Feature: Admin login + As an operator + I want to sign in to the admin application + So that I can reach the metrics, monitoring and correction-triage sections + + Scenario: A wrong password shows a translated error, no redirect + Given the admin session check returns unauthenticated + And admin login fails with invalid credentials + When I visit "/login" + And I fill in the "email" field with "ops@example.com" + And I fill in the "password" field with "wrong" + And I click the button "Se connecter" + Then I should see "Email ou mot de passe incorrect" + And the URL should include "/login" + + Scenario: A correct login lands on the dashboard + Given the admin session check returns unauthenticated + And admin login succeeds as "Ops" + When I visit "/login" + And I fill in the "email" field with "ops@example.com" + And I fill in the "password" field with "correct-horse" + And I click the button "Se connecter" + Then the URL should not include "/login" + And I should see the heading "Tableau de bord" diff --git a/apps/admin-web/cypress/e2e/login.ts b/apps/admin-web/cypress/e2e/login.ts new file mode 100644 index 0000000..36baea8 --- /dev/null +++ b/apps/admin-web/cypress/e2e/login.ts @@ -0,0 +1,24 @@ +import { Given } from "@badeball/cypress-cucumber-preprocessor"; + +const adminBody = { + id: 1, + email: "ops@example.com", + name: "Ops", + createdAt: "2026-08-01T00:00:00.000Z", + lastLoginAt: "2026-08-28T09:00:00.000Z", +}; + +Given("admin login fails with invalid credentials", () => { + cy.intercept("POST", "**/admin/auth/login", { + statusCode: 401, + body: { code: 4010, message: "Invalid email or password" }, + }); +}); + +Given("admin login succeeds as {string}", (name: string) => { + const body = { ...adminBody, name, email: `${name.toLowerCase()}@example.com` }; + cy.intercept("POST", "**/admin/auth/login", { statusCode: 200, body }); + // After navigate("/"), RequireAdmin re-checks the session — from now on it + // must report authenticated (last matching intercept wins). + cy.intercept("GET", "**/admin/auth/me", { statusCode: 200, body }); +}); diff --git a/apps/admin-web/cypress/support/e2e.ts b/apps/admin-web/cypress/support/e2e.ts new file mode 100644 index 0000000..62aa998 --- /dev/null +++ b/apps/admin-web/cypress/support/e2e.ts @@ -0,0 +1,3 @@ +// Cypress support file — global config and custom commands go here as the +// admin app grows. Same minimal starting point as apps/web's e2e.ts. +export {}; diff --git a/apps/admin-web/cypress/support/step_definitions/common.steps.ts b/apps/admin-web/cypress/support/step_definitions/common.steps.ts new file mode 100644 index 0000000..0ed80bf --- /dev/null +++ b/apps/admin-web/cypress/support/step_definitions/common.steps.ts @@ -0,0 +1,54 @@ +import { Given, Then, When } from "@badeball/cypress-cucumber-preprocessor"; + +// Steps shared across admin feature specs — navigation and generic UI +// assertions. Anything specific to one feature (its own API mocks, its own +// DOM structure) lives in that feature's own `.ts` step file. +// +// Every admin API call is mocked via `cy.intercept` — the Cypress suite +// never runs a live backend; apps/api's own Mocha suite covers real +// `/admin/*` behaviour. + +Given("the admin session check returns unauthenticated", () => { + cy.intercept("GET", "**/admin/auth/me", { statusCode: 401, body: { code: 4011, message: "no" } }); +}); + +Given("I am signed in as admin {string}", (name: string) => { + cy.intercept("GET", "**/admin/auth/me", { + statusCode: 200, + body: { + id: 1, + email: `${name.toLowerCase()}@example.com`, + name, + createdAt: "2026-08-01T00:00:00.000Z", + lastLoginAt: "2026-08-28T09:00:00.000Z", + }, + }); +}); + +When("I visit {string}", (path: string) => { + cy.visit(path); +}); + +When("I fill in the {string} field with {string}", (fieldId: string, value: string) => { + cy.get(`#${fieldId}`).clear().type(value); +}); + +When("I click the button {string}", (text: string) => { + cy.contains("button", text).click(); +}); + +Then("the URL should include {string}", (fragment: string) => { + cy.url().should("include", fragment); +}); + +Then("the URL should not include {string}", (fragment: string) => { + cy.url().should("not.include", fragment); +}); + +Then("I should see {string}", (text: string) => { + cy.contains(text).should("be.visible"); +}); + +Then("I should see the heading {string}", (text: string) => { + cy.contains("h1", text).should("be.visible"); +}); diff --git a/apps/admin-web/index.html b/apps/admin-web/index.html new file mode 100644 index 0000000..8c44b55 --- /dev/null +++ b/apps/admin-web/index.html @@ -0,0 +1,12 @@ + + + + + + batchCooking — Admin + + +
+ + + diff --git a/apps/admin-web/nginx.conf b/apps/admin-web/nginx.conf new file mode 100644 index 0000000..cf18ff6 --- /dev/null +++ b/apps/admin-web/nginx.conf @@ -0,0 +1,20 @@ +# Static host for the built admin SPA. Client-side routing (react-router) +# means any unknown path must fall back to index.html rather than 404 — +# same reason apps/api serves its own SPA that way. +server { + listen 80; + server_name _; + root /usr/share/nginx/html; + index index.html; + + location / { + try_files $uri $uri/ /index.html; + } + + # Long-cache the fingerprinted assets Vite emits; never cache the HTML + # entry point so a new deploy is picked up immediately. + location /assets/ { + expires 1y; + add_header Cache-Control "public, immutable"; + } +} diff --git a/apps/admin-web/package.json b/apps/admin-web/package.json new file mode 100644 index 0000000..fb4fabe --- /dev/null +++ b/apps/admin-web/package.json @@ -0,0 +1,42 @@ +{ + "name": "admin-web", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite --host 0.0.0.0 --port 5174", + "build": "tsc -b && vite build", + "preview": "vite preview --port 5174", + "test": "echo \"no unit tests yet\" && exit 0", + "cy:open": "cypress open", + "cy:run": "cypress run", + "e2e": "start-server-and-test dev http://localhost:5174 cy:run" + }, + "dependencies": { + "@batch-cooking/date-tools": "workspace:*", + "@batch-cooking/shared": "workspace:*", + "i18next": "^26.3.6", + "lucide-react": "^1.32.0", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-i18next": "^17.0.11", + "react-router-dom": "^7.18.2", + "recharts": "^2.15.0", + "zod": "^3.25.76" + }, + "devDependencies": { + "@badeball/cypress-cucumber-preprocessor": "22.2.0", + "@bahmutov/cypress-esbuild-preprocessor": "2.2.8", + "@cypress/vite-dev-server": "5.2.1", + "@types/node": "^22.9.0", + "@types/react": "^18.3.12", + "@types/react-dom": "^18.3.1", + "@vitejs/plugin-react": "^4.3.3", + "cypress": "13.17.0", + "esbuild": "0.21.5", + "sass": "^1.102.0", + "start-server-and-test": "^2.0.8", + "typescript": "^5.7.2", + "vite": "^5.4.11" + } +} diff --git a/apps/admin-web/src/App.tsx b/apps/admin-web/src/App.tsx new file mode 100644 index 0000000..ad9ba3d --- /dev/null +++ b/apps/admin-web/src/App.tsx @@ -0,0 +1,34 @@ +import { Navigate, Route, Routes } from "react-router-dom"; +import { RequireAdmin } from "./features/auth/RequireAdmin"; +import { AdminLayout } from "./layouts/AdminLayout"; +import { CorrectionsPage } from "./pages/corrections/CorrectionsPage"; +import { DashboardPage } from "./pages/dashboard/DashboardPage"; +import { LoginPage } from "./pages/login/LoginPage"; +import { MonitoringPage } from "./pages/monitoring/MonitoringPage"; + +/** + * Admin app route table. `/login` is the only unauthenticated route; + * everything else is nested under one `RequireAdmin` + `AdminLayout` parent + * (the guard + sidebar chrome applied once), same shape as apps/web's + * `App.tsx`. Unknown paths fall back to `/`, which redirects to `/login` + * when there's no admin session. + */ +export function App() { + return ( + + } /> + + + + } + > + } /> + } /> + } /> + + } /> + + ); +} diff --git a/apps/admin-web/src/api/client.ts b/apps/admin-web/src/api/client.ts new file mode 100644 index 0000000..132e39d --- /dev/null +++ b/apps/admin-web/src/api/client.ts @@ -0,0 +1,96 @@ +import { + type AdminLoginInput, + type AdminUserView, + type ApiErrorResponse, + ErrorCode, +} from "@batch-cooking/shared"; + +/** + * Base URL of the admin API surface, configurable via `VITE_ADMIN_API_URL` + * (see `.env.example`). Defaults to `""` (same origin) — correct behind a + * shared reverse proxy; native dev overrides it to `http://localhost:3000` + * in `apps/admin-web/.env` since the Vite dev server (5174) and the API + * (3000) are different origins. + */ +const ADMIN_API_BASE_URL: string = import.meta.env.VITE_ADMIN_API_URL ?? ""; + +/** + * Thrown by {@link AdminApiClient} on any non-2xx response — carries the + * same {@link ErrorCode} the API returned. Same shape as apps/web's + * `ApiError`; kept separate rather than shared so the two apps' transport + * layers stay independent. + */ +export class ApiError extends Error { + public readonly status: number; + public readonly code: ErrorCode; + public readonly fieldErrors?: Record; + + public constructor(status: number, body: ApiErrorResponse) { + super(body.message); + this.name = "ApiError"; + this.status = status; + this.code = body.code; + this.fieldErrors = body.details; + } +} + +/** + * Thin fetch wrapper around the `/admin/*` endpoints — same design as + * apps/web's `ApiClient` (a class for cohesion/extensibility, one shared + * stateless instance). Every request sends credentials so the + * `admin_session` httpOnly cookie round-trips. + */ +export class AdminApiClient { + /** + * Performs a JSON request against the admin API and returns the parsed body. + * + * @throws {ApiError} if the response status is not in the 2xx range. + */ + private async _request( + path: string, + options: RequestInit = {}, + ): Promise { + try { + const response = await fetch(`${ADMIN_API_BASE_URL}${path}`, { + ...options, + credentials: "include", + headers: { "Content-Type": "application/json", ...options.headers }, + }); + + if (!response.ok) { + const body = (await response.json().catch(() => null)) as ApiErrorResponse | null; + throw new ApiError( + response.status, + body ?? { code: ErrorCode.INTERNAL_ERROR, message: "Something went wrong" }, + ); + } + + if (response.status === 204) { + return undefined as TResponseBody; + } + return (await response.json()) as TResponseBody; + } catch (err) { + // Rethrown as-is — callers surface it their own way; this is just the + // one place the fetch/`await` sits in a try/catch per the repo's rule. + throw err; + } + } + + /** Verifies admin credentials and starts an admin session. */ + public login(input: AdminLoginInput): Promise { + return this._request("/admin/auth/login", { method: "POST", body: JSON.stringify(input) }); + } + + /** Ends the current admin session. */ + public logout(): Promise { + 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 { + return this._request("/admin/auth/me"); + } +} + +/** Single shared instance — stateless, same reasoning as apps/web's `apiClient`. */ +export const adminApiClient = new AdminApiClient(); diff --git a/apps/admin-web/src/features/auth/AdminAuthContext.tsx b/apps/admin-web/src/features/auth/AdminAuthContext.tsx new file mode 100644 index 0000000..12611bd --- /dev/null +++ b/apps/admin-web/src/features/auth/AdminAuthContext.tsx @@ -0,0 +1,71 @@ +import type { AdminLoginInput, AdminUserView } from "@batch-cooking/shared"; +import { createContext, type ReactNode, useCallback, useContext, useEffect, useState } from "react"; +import { adminApiClient } from "../../api/client"; + +/** Auth state/actions exposed via {@link useAdminAuth} — the admin-app counterpart of apps/web's `AuthContext`. */ +interface AdminAuthContextValue { + /** Currently authenticated admin, or `null` if no active session. */ + admin: AdminUserView | null; + /** True only while the initial `GET /admin/auth/me` check is pending — lets `RequireAdmin` avoid a premature redirect. */ + isLoading: boolean; + /** Verifies credentials and updates `admin` on success. Throws `ApiError` on failure. */ + login: (input: AdminLoginInput) => Promise; + /** Ends the session and clears `admin`. */ + logout: () => Promise; +} + +const AdminAuthContext = createContext(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(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 ( + + {children} + + ); +} + +/** 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; +} diff --git a/apps/admin-web/src/features/auth/RequireAdmin.tsx b/apps/admin-web/src/features/auth/RequireAdmin.tsx new file mode 100644 index 0000000..19e3fce --- /dev/null +++ b/apps/admin-web/src/features/auth/RequireAdmin.tsx @@ -0,0 +1,21 @@ +import type { ReactNode } from "react"; +import { Navigate } from "react-router-dom"; +import { useAdminAuth } from "./AdminAuthContext"; + +/** + * Route guard for every admin page. Renders nothing while the initial + * `GET /admin/auth/me` check is pending (avoids a flash-then-redirect); + * once resolved, renders `children` or redirects to `/login`. Mirror of + * apps/web's `RequireAuth`. + */ +export function RequireAdmin({ children }: { children: ReactNode }) { + const { admin, isLoading } = useAdminAuth(); + + if (isLoading) { + return null; + } + if (!admin) { + return ; + } + return <>{children}; +} diff --git a/apps/admin-web/src/features/auth/admin-auth.scss b/apps/admin-web/src/features/auth/admin-auth.scss new file mode 100644 index 0000000..a47fb41 --- /dev/null +++ b/apps/admin-web/src/features/auth/admin-auth.scss @@ -0,0 +1,84 @@ +// ============================================================================= +// Admin login card — the only unauthenticated screen. A centered card on a +// plain background, same language as apps/web's auth-form.scss (kept its own +// copy rather than shared, the two apps' chrome is independent). +// ============================================================================= + +.admin-auth-page { + min-height: 100vh; + display: grid; + place-items: center; + padding: var(--space-lg); + background: var(--color-background); +} + +.admin-auth-card { + width: 100%; + max-width: var(--max-width-form); + display: flex; + flex-direction: column; + gap: var(--space-sm); + padding: var(--space-xl); + background: var(--color-surface); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-md); + + h1 { + font-size: var(--font-size-xl); + margin-bottom: var(--space-sm); + } + + label { + font-size: var(--font-size-sm); + font-weight: 600; + color: var(--color-text-muted); + } + + input { + padding: var(--space-sm); + font-size: var(--font-size-base); + font-family: var(--font-body); + border: 1.5px solid var(--color-border); + border-radius: var(--radius-base); + background: var(--color-surface); + color: var(--color-text); + + &:focus-visible { + border-color: var(--color-primary); + } + } + + button[type="submit"] { + margin-top: var(--space-sm); + padding: var(--space-sm) var(--space-md); + font-size: var(--font-size-base); + font-weight: 600; + font-family: var(--font-body); + color: var(--color-surface); + background: var(--color-primary); + border: none; + border-radius: var(--radius-base); + cursor: pointer; + + &:hover { + background: var(--color-primary-hover); + } + + &:disabled { + opacity: 0.6; + cursor: not-allowed; + } + } + + .field-error { + margin: 0; + font-size: var(--font-size-xs); + color: var(--color-error); + } + + .form-error { + margin: var(--space-xs) 0 0; + font-size: var(--font-size-sm); + color: var(--color-error); + } +} diff --git a/apps/admin-web/src/i18n/i18n.ts b/apps/admin-web/src/i18n/i18n.ts new file mode 100644 index 0000000..96e7f52 --- /dev/null +++ b/apps/admin-web/src/i18n/i18n.ts @@ -0,0 +1,22 @@ +import i18next from "i18next"; +import { initReactI18next } from "react-i18next"; +import fr from "../locales/fr/translation.json"; + +/** + * i18next instance for the admin app, imported once for its side effect + * (`main.tsx`) before anything renders. Only French exists today — same + * setup as apps/web's `i18n/i18n.ts`, its own separate locale file so the + * two apps' copy never has to be kept identical. `packages/shared`'s + * `ErrorCode` member names double as keys under the `errors` namespace + * (see `services/error-message.service.ts`). + */ +void i18next.use(initReactI18next).init({ + resources: { + fr: { translation: fr }, + }, + lng: "fr", + fallbackLng: "fr", + interpolation: { escapeValue: false }, +}); + +export default i18next; diff --git a/apps/admin-web/src/layouts/AdminLayout.scss b/apps/admin-web/src/layouts/AdminLayout.scss new file mode 100644 index 0000000..ca14d96 --- /dev/null +++ b/apps/admin-web/src/layouts/AdminLayout.scss @@ -0,0 +1,108 @@ +// ============================================================================= +// Admin app shell — a fixed left sidebar + scrollable main content area. +// Simpler than apps/web's AppLayout (no collapsible rail, no nested submenu) +// — an internal ops tool, three sections. +// ============================================================================= + +.admin-layout { + display: flex; + min-height: 100vh; +} + +.admin-sidebar { + flex-shrink: 0; + width: 15rem; + display: flex; + flex-direction: column; + padding: var(--space-lg) var(--space-md); + background: var(--color-surface); + border-right: 1px solid var(--color-border); + + &__brand { + font-family: var(--font-display); + font-weight: 700; + font-size: var(--font-size-lg); + color: var(--color-text); + margin-bottom: var(--space-lg); + + span { + color: var(--color-accent); + } + } + + &__nav { + display: flex; + flex-direction: column; + gap: 0.15rem; + + a { + display: flex; + align-items: center; + gap: var(--space-sm); + padding: var(--space-sm); + border-radius: var(--radius-base); + color: var(--color-text-muted); + text-decoration: none; + font-size: var(--font-size-sm); + font-weight: 600; + + &:hover { + background: var(--color-surface-alt); + color: var(--color-text); + } + + &.active { + background: color-mix(in srgb, var(--color-primary) 12%, var(--color-surface)); + color: var(--color-primary); + } + } + } + + &__footer { + margin-top: auto; + display: flex; + flex-direction: column; + gap: var(--space-xs); + padding-top: var(--space-md); + border-top: 1px solid var(--color-border); + } + + &__who { + font-size: var(--font-size-sm); + font-weight: 600; + color: var(--color-text); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + &__footer button { + padding: var(--space-xs) var(--space-sm); + font-size: var(--font-size-sm); + font-family: var(--font-body); + color: var(--color-text-muted); + background: none; + border: 1px solid var(--color-border); + border-radius: var(--radius-base); + cursor: pointer; + text-align: left; + + &:hover { + color: var(--color-text); + border-color: var(--color-primary); + } + } + + &__version { + margin: 0; + font-size: var(--font-size-xs); + color: var(--color-text-muted); + } +} + +.admin-content { + flex: 1; + min-width: 0; + padding: var(--space-xl); + overflow: auto; +} diff --git a/apps/admin-web/src/layouts/AdminLayout.tsx b/apps/admin-web/src/layouts/AdminLayout.tsx new file mode 100644 index 0000000..341fce7 --- /dev/null +++ b/apps/admin-web/src/layouts/AdminLayout.tsx @@ -0,0 +1,83 @@ +import { Activity, LayoutDashboard, ListChecks } from "lucide-react"; +import { useState } from "react"; +import { useTranslation } from "react-i18next"; +import { NavLink, Outlet, useNavigate } from "react-router-dom"; +import { useAdminAuth } from "../features/auth/AdminAuthContext"; +import "./AdminLayout.scss"; + +/** + * One entry in the admin sidebar's nav. `key` maps to `admin.nav.` in + * the locale file — adding a section is one array entry plus one locale key. + */ +const NAV_ITEMS = [ + { to: "/", key: "dashboard", Icon: LayoutDashboard, end: true }, + { to: "/monitoring", key: "monitoring", Icon: Activity, end: false }, + { to: "/corrections", key: "corrections", Icon: ListChecks, end: false }, +] 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 ``. Mounted once as the parent of the + * whole `RequireAdmin`-guarded route group (see `App.tsx`), so `admin` is + * guaranteed non-null here. + */ +export function AdminLayout() { + const { t } = useTranslation(); + const { admin, logout } = useAdminAuth(); + const navigate = useNavigate(); + const [isLoggingOut, setIsLoggingOut] = useState(false); + + async function handleLogout() { + setIsLoggingOut(true); + try { + await logout(); + void navigate("/login"); + } catch { + // Even if the network call failed, the local session state was + // cleared optimistically enough for the guard to bounce to /login; + // nothing useful to show the operator here. + void navigate("/login"); + } + } + + return ( +
+ + +
+ +
+
+ ); +} diff --git a/apps/admin-web/src/lib/zod-errors.ts b/apps/admin-web/src/lib/zod-errors.ts new file mode 100644 index 0000000..ab9174b --- /dev/null +++ b/apps/admin-web/src/lib/zod-errors.ts @@ -0,0 +1,18 @@ +import type { ZodError } from "zod"; + +/** + * Flattens a zod validation error into `{ fieldName: firstMessage }` for + * inline display under each form field — verbatim copy of apps/web's + * `lib/zod-errors.ts` (only the first message per field, enough for the + * single-rule-per-field schemas used here). + */ +export function fieldErrorsFrom(error: ZodError): Record { + const fieldErrors = error.flatten().fieldErrors; + const firstMessagePerField: Record = {}; + for (const [field, messages] of Object.entries(fieldErrors)) { + if (messages?.[0]) { + firstMessagePerField[field] = messages[0]; + } + } + return firstMessagePerField; +} diff --git a/apps/admin-web/src/locales/fr/translation.json b/apps/admin-web/src/locales/fr/translation.json new file mode 100644 index 0000000..198b1d2 --- /dev/null +++ b/apps/admin-web/src/locales/fr/translation.json @@ -0,0 +1,42 @@ +{ + "errors": { + "VALIDATION_ERROR": "Erreur de validation", + "INVALID_CREDENTIALS": "Email ou mot de passe incorrect", + "NOT_AUTHENTICATED": "Vous devez être connecté", + "NOT_FOUND": "Ressource introuvable", + "TECH_STEP_NOT_FOUND": "Cette technique n'existe pas", + "INTERNAL_ERROR": "Une erreur est survenue, réessayez plus tard" + }, + "admin": { + "common": { + "comingSoon": "Section à venir." + }, + "login": { + "title": "Administration", + "emailLabel": "Email", + "passwordLabel": "Mot de passe", + "submit": "Se connecter", + "submitting": "Connexion…" + }, + "nav": { + "dashboard": "Tableau de bord", + "monitoring": "Monitoring", + "corrections": "Corrections" + }, + "layout": { + "logout": "Se déconnecter" + }, + "dashboard": { + "title": "Tableau de bord", + "lead": "Métriques d'utilisation de l'application." + }, + "monitoring": { + "title": "Monitoring", + "lead": "Santé des microservices et de la base de données." + }, + "corrections": { + "title": "Corrections", + "lead": "Tri des corrections utilisateur pour le ré-entraînement NLP." + } + } +} diff --git a/apps/admin-web/src/main.tsx b/apps/admin-web/src/main.tsx new file mode 100644 index 0000000..a713866 --- /dev/null +++ b/apps/admin-web/src/main.tsx @@ -0,0 +1,24 @@ +import { StrictMode } from "react"; +import { createRoot } from "react-dom/client"; +import { BrowserRouter } from "react-router-dom"; +import { App } from "./App"; +import { AdminAuthProvider } from "./features/auth/AdminAuthContext"; +// Side-effect import: initializes i18next before anything renders. +import "./i18n/i18n"; +// Global stylesheet (theme tokens + minimal reset) — the only non-colocated .scss import. +import "./styles/global.scss"; + +const rootElement = document.getElementById("root"); +if (!rootElement) { + throw new Error("Root element not found"); +} + +createRoot(rootElement).render( + + + + + + + , +); diff --git a/apps/admin-web/src/pages/admin-page.scss b/apps/admin-web/src/pages/admin-page.scss new file mode 100644 index 0000000..34eefbe --- /dev/null +++ b/apps/admin-web/src/pages/admin-page.scss @@ -0,0 +1,26 @@ +// ============================================================================= +// Shared chrome for every routed admin page — a page title and an optional +// lead paragraph. Individual pages add their own colocated .scss for their +// specific content (charts, tables, status board) on top. +// ============================================================================= + +.admin-page { + &__title { + font-size: var(--font-size-2xl); + margin-bottom: var(--space-xs); + } + + &__lead { + margin: 0 0 var(--space-lg); + color: var(--color-text-muted); + font-size: var(--font-size-md); + } + + &__placeholder { + padding: var(--space-xl); + border: 1px dashed var(--color-border); + border-radius: var(--radius-md); + color: var(--color-text-muted); + text-align: center; + } +} diff --git a/apps/admin-web/src/pages/corrections/CorrectionsPage.tsx b/apps/admin-web/src/pages/corrections/CorrectionsPage.tsx new file mode 100644 index 0000000..a197e9d --- /dev/null +++ b/apps/admin-web/src/pages/corrections/CorrectionsPage.tsx @@ -0,0 +1,19 @@ +import { useTranslation } from "react-i18next"; +import "../admin-page.scss"; + +/** + * Tech-step correction triage — review `TechStepTrainingSuggestion` / + * `StepTechStepCorrection`, mark applied/rejected, generate the + * `training_data.py` snippet, and trigger the F1 gate + backfill. Placeholder + * until PR 5 (correction triage + retrain). + */ +export function CorrectionsPage() { + const { t } = useTranslation(); + return ( +
+

{t("admin.corrections.title")}

+

{t("admin.corrections.lead")}

+

{t("admin.common.comingSoon")}

+
+ ); +} diff --git a/apps/admin-web/src/pages/dashboard/DashboardPage.tsx b/apps/admin-web/src/pages/dashboard/DashboardPage.tsx new file mode 100644 index 0000000..5eab1a5 --- /dev/null +++ b/apps/admin-web/src/pages/dashboard/DashboardPage.tsx @@ -0,0 +1,17 @@ +import { useTranslation } from "react-i18next"; +import "../admin-page.scss"; + +/** + * Usage-metrics dashboard — KPI tiles + trend charts fed by + * `GET /admin/metrics`. Placeholder until PR 3 (metrics) fills it in. + */ +export function DashboardPage() { + const { t } = useTranslation(); + return ( +
+

{t("admin.dashboard.title")}

+

{t("admin.dashboard.lead")}

+

{t("admin.common.comingSoon")}

+
+ ); +} diff --git a/apps/admin-web/src/pages/login/LoginPage.tsx b/apps/admin-web/src/pages/login/LoginPage.tsx new file mode 100644 index 0000000..f984391 --- /dev/null +++ b/apps/admin-web/src/pages/login/LoginPage.tsx @@ -0,0 +1,85 @@ +import { adminLoginSchema, ErrorCode } from "@batch-cooking/shared"; +import { type FormEvent, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { useNavigate } from "react-router-dom"; +import { ApiError } from "../../api/client"; +import { useAdminAuth } from "../../features/auth/AdminAuthContext"; +import "../../features/auth/admin-auth.scss"; +import { fieldErrorsFrom } from "../../lib/zod-errors"; +import { errorMessageService } from "../../services/error-message.service"; + +/** + * The admin login screen — the only unauthenticated route. Client-side + * validation via the shared `adminLoginSchema` (same rules the API + * enforces), then `POST /admin/auth/login`; any API failure is translated + * to a localized label via {@link ErrorMessageService}. Same structure as + * apps/web's `LoginPage`. + */ +export function LoginPage() { + const { login } = useAdminAuth(); + const navigate = useNavigate(); + const { t } = useTranslation(); + + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [fieldErrors, setFieldErrors] = useState>({}); + const [formError, setFormError] = useState(null); + const [isSubmitting, setIsSubmitting] = useState(false); + + async function handleSubmit(e: FormEvent) { + e.preventDefault(); + setFormError(null); + + const result = adminLoginSchema.safeParse({ email, password }); + if (!result.success) { + setFieldErrors(fieldErrorsFrom(result.error)); + return; + } + setFieldErrors({}); + + setIsSubmitting(true); + try { + await login(result.data); + void navigate("/"); + } catch (err) { + const code = err instanceof ApiError ? err.code : ErrorCode.INTERNAL_ERROR; + setFormError(errorMessageService.getLabel(code)); + } finally { + setIsSubmitting(false); + } + } + + return ( +
+
+

{t("admin.login.title")}

+ + + setEmail(e.target.value)} + autoComplete="email" + /> + {fieldErrors.email &&

{fieldErrors.email}

} + + + setPassword(e.target.value)} + autoComplete="current-password" + /> + {fieldErrors.password &&

{fieldErrors.password}

} + + {formError &&

{formError}

} + + +
+
+ ); +} diff --git a/apps/admin-web/src/pages/monitoring/MonitoringPage.tsx b/apps/admin-web/src/pages/monitoring/MonitoringPage.tsx new file mode 100644 index 0000000..f531842 --- /dev/null +++ b/apps/admin-web/src/pages/monitoring/MonitoringPage.tsx @@ -0,0 +1,18 @@ +import { useTranslation } from "react-i18next"; +import "../admin-page.scss"; + +/** + * Microservice health board — active probes of Postgres, the API, the + * intent-service and the LLM worker's heartbeat, fed by + * `GET /admin/monitoring`. Placeholder until PR 4 (monitoring). + */ +export function MonitoringPage() { + const { t } = useTranslation(); + return ( +
+

{t("admin.monitoring.title")}

+

{t("admin.monitoring.lead")}

+

{t("admin.common.comingSoon")}

+
+ ); +} diff --git a/apps/admin-web/src/services/error-message.service.ts b/apps/admin-web/src/services/error-message.service.ts new file mode 100644 index 0000000..e1ea838 --- /dev/null +++ b/apps/admin-web/src/services/error-message.service.ts @@ -0,0 +1,19 @@ +import { ErrorCode } from "@batch-cooking/shared"; +import i18n from "../i18n/i18n"; + +/** + * Localized label for an {@link ErrorCode} returned by the admin API — + * verbatim behaviour of apps/web's `ErrorMessageService`: reverse-maps the + * numeric enum value to its member name (`4010` → `"INVALID_CREDENTIALS"`) + * and looks it up under the `errors` namespace, falling back to + * `INTERNAL_ERROR` for a code this client doesn't recognise. + */ +export class ErrorMessageService { + public getLabel(code: ErrorCode): string { + const memberName = ErrorCode[code] ?? ErrorCode[ErrorCode.INTERNAL_ERROR]; + return i18n.t(`errors.${memberName}`); + } +} + +/** Single shared instance — stateless. */ +export const errorMessageService = new ErrorMessageService(); diff --git a/apps/admin-web/src/styles/_theme.scss b/apps/admin-web/src/styles/_theme.scss new file mode 100644 index 0000000..22b765d --- /dev/null +++ b/apps/admin-web/src/styles/_theme.scss @@ -0,0 +1,171 @@ +// NOTE: verbatim copy of apps/web/src/styles/_theme.scss. Extracting these +// tokens into a shared package (consumed by both apps) is tracked separately +// — keep the two files in sync by hand until then. +// ============================================================================= +// Design tokens — the single source of truth for colors, spacing, typography +// and other reusable values across the whole app. +// +// Exposed as CSS custom properties on :root (not plain SCSS variables) so +// they're available at *runtime*, not just compile time — this is what lets +// dark mode work below by simply redefining these variables instead of +// rebuilding the stylesheet. Every other .scss file should reference +// `var(--token-name)`, never a hardcoded color/size. +// +// Palette name: "Mise en Place" — a kitchen-operations identity (batch +// cooking as logistics: everything labeled and in its place before you +// start) rather than a food-blog one. See the design proposal for the full +// rationale: https://claude.ai/code/artifact/1db63af0-cfd1-4f77-9369-71ca6accd06f +// +// Import this partial once, globally (see global.scss) — never re-import it +// from a component-level .scss file, `:root` only needs to be declared once. +// ============================================================================= + +:root { + // --- Surfaces & ink --------------------------------------------------- + // Neutral surface: page background ("porcelaine") vs. the card surface + // content sits on, plus a recessed variant for panels/table headers. + --color-background: #eef2ed; + --color-surface: #ffffff; + --color-surface-alt: #e2e8e0; + // Text. + --color-text: #1f2a22; + --color-text-muted: #57685a; + --color-border: #c7d0c4; + + // --- Brand accents, each with one job -------------------------------- + // Basil — primary actions, links, brand presence. + --color-primary: #2e6b4a; + --color-primary-hover: #244f38; + // Vermillion — secondary accent for urgency/strong calls to action + // (e.g. a timer, "start session"). Never reused for allergen alerts + // below — those need their own, unambiguous color. + --color-accent: #cc4b26; + --color-accent-hover: #a83c1c; + // Turmeric — category/classification tags. + --color-tag: #c98a1b; + --color-tag-ink: #3a2c05; // pairs with a solid --color-tag fill only. + + // --- Feedback ----------------------------------------------------------- + --color-success: #2e6b4a; + --color-warning: #c98a1b; + --color-error: #b3271e; + + // --- Allergens / intolerances -------------------------------------------- + // A 3-tier food-safety scale, kept distinct from --color-error so an + // allergen warning is never confused with a form validation error: + // - critical (declared allergen): its own color, solid/inverted fill + // - moderate (intolerance): reuses --color-warning, tinted fill + // - trace ("may contain traces of…"): neutral, dashed outline + // The severity is carried by the FILL TREATMENT, not the hue alone, so + // it stays legible for color-blind users. See the design proposal's + // "Alertes & allergènes" section for the full component set. + --color-allergen: #a8123f; + --color-allergen-ink: #ffe9ef; // pairs with a solid --color-allergen fill only. + + // --- Spacing scale --------------------------------------------------------- + // Multiples of a 4px base unit — use these instead of ad hoc px values so + // spacing stays visually consistent as the app grows. + --space-xs: 0.25rem; // 4px + --space-sm: 0.5rem; // 8px + --space-md: 1rem; // 16px + --space-lg: 1.5rem; // 24px + --space-xl: 2rem; // 32px + --space-2xl: 3rem; // 48px — inter-section spacing + + // --- Typography -------------------------------------------------------- + // System font stacks only — no remote webfont, so there's zero loading + // latency and no flash of unstyled text, which fits an app meant to be + // used quickly under time pressure. Three roles: a condensed "label" + // face for headings/eyebrows, a humanist face for body copy, and a + // monospace for anything that lines up in columns (times, quantities). + --font-display: "Bahnschrift", "Arial Narrow", "Segoe UI", sans-serif; + --font-body: "Segoe UI", "Helvetica Neue", Arial, sans-serif; + --font-mono: "Cascadia Mono", Consolas, "SF Mono", "Liberation Mono", monospace; + + --font-size-xs: 0.75rem; // 12px — captions, meta + --font-size-sm: 0.875rem; // 14px — labels, secondary text + --font-size-base: 1rem; // 16px — body + --font-size-md: 1.125rem; // 18px — lead paragraph + --font-size-lg: 1.375rem; // 22px — H3 / card titles + --font-size-xl: 1.75rem; // 28px — H2 / section titles + --font-size-2xl: 2.25rem; // 36px — H1 / page titles + --font-size-3xl: 3rem; // 48px — display, exceptional use only + + // --- Shape / elevation -------------------------------------------------- + --radius-base: 4px; // controls (inputs, buttons) — deliberately flat + --radius-md: 10px; // cards + --radius-lg: 18px; // panels, modals + --radius-pill: 999px; // tags, badges + --max-width-form: 22rem; + + --shadow-sm: 0 1px 2px rgba(31, 42, 34, 0.08), 0 1px 1px rgba(31, 42, 34, 0.06); + --shadow-md: 0 6px 16px rgba(31, 42, 34, 0.12), 0 2px 6px rgba(31, 42, 34, 0.08); +} + +// Lets the browser pick sensible default colors (form controls, scrollbars) +// for whichever mode the user ends up in. +:root { + color-scheme: light dark; +} + +// --- Dark mode --------------------------------------------------------- +// Follows the OS/browser preference by default. Guarded with +// `:root:not([data-theme="light"])` so an explicit "light" choice (see +// apps/web's `ThemeContext`, `SYSTEM` = no `data-theme` attribute at all — +// this block then decides) can override a dark OS setting. +@media (prefers-color-scheme: dark) { + :root:not([data-theme="light"]) { + --color-background: #14181a; + --color-surface: #1c221e; + --color-surface-alt: #262e27; + --color-text: #edf1ea; + --color-text-muted: #a9b5a6; + --color-border: #384038; + + --color-primary: #5fae7e; + --color-primary-hover: #7cc496; + --color-accent: #ea7a48; + --color-accent-hover: #f0946c; + --color-tag: #e8b84b; + --color-tag-ink: #2a2005; + + --color-success: #5fae7e; + --color-warning: #e8b84b; + --color-error: #e5675a; + + --color-allergen: #e2547b; + --color-allergen-ink: #3a0416; + + --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.35); + --shadow-md: 0 8px 20px rgba(0, 0, 0, 0.45); + } +} + +// Mirrors the block above for an explicit "dark" choice (`ThemeContext` +// sets `data-theme="dark"` on ``), so it wins over the OS setting in +// both directions. +:root[data-theme="dark"] { + --color-background: #14181a; + --color-surface: #1c221e; + --color-surface-alt: #262e27; + --color-text: #edf1ea; + --color-text-muted: #a9b5a6; + --color-border: #384038; + + --color-primary: #5fae7e; + --color-primary-hover: #7cc496; + --color-accent: #ea7a48; + --color-accent-hover: #f0946c; + --color-tag: #e8b84b; + --color-tag-ink: #2a2005; + + --color-success: #5fae7e; + --color-warning: #e8b84b; + --color-error: #e5675a; + + --color-allergen: #e2547b; + --color-allergen-ink: #3a0416; + + --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.35); + --shadow-md: 0 8px 20px rgba(0, 0, 0, 0.45); +} diff --git a/apps/admin-web/src/styles/global.scss b/apps/admin-web/src/styles/global.scss new file mode 100644 index 0000000..dffae1e --- /dev/null +++ b/apps/admin-web/src/styles/global.scss @@ -0,0 +1,149 @@ +// ============================================================================= +// Global stylesheet — imported exactly once, in main.tsx. Contains only +// truly app-wide rules: the theme tokens and a minimal reset/base styling +// that every page inherits. Anything specific to one component or page +// belongs in a .scss file colocated next to that component/page instead. +// ============================================================================= + +@use "./theme"; + +// Include borders/padding in an element's declared width/height everywhere, +// rather than the browser default of adding them on top. +*, +*::before, +*::after { + box-sizing: border-box; +} + +// Minimal reset: remove the default body margin so pages can control their +// own layout without fighting the browser's default 8px margin. +body { + margin: 0; + font-family: var(--font-body); + font-size: var(--font-size-base); + line-height: 1.55; + color: var(--color-text); + background: var(--color-background); +} + +// Headings use the condensed "label" face app-wide — see _theme.scss for +// the rationale. `text-wrap: balance` avoids a lone short word wrapping +// onto its own line in multi-line titles. +h1, +h2, +h3, +h4, +h5, +h6 { + margin: 0; + font-family: var(--font-display); + font-weight: 700; + text-wrap: balance; +} + +// Default to the page-title size; a heading used as a smaller component +// title (e.g. the auth card's

) overrides this in its own stylesheet. +h1 { + font-size: var(--font-size-2xl); +} + +// A visible, consistent focus ring for keyboard navigation — the browser +// default varies a lot between elements and browsers. +:focus-visible { + outline: 2px solid var(--color-accent); + outline-offset: 2px; +} + +// Checkbox/radio appearance, app-wide — "selectable card" style: the native +// control itself is visually hidden (still real, focusable and +// screen-reader-visible — see the `input[type=...]` rule below, not +// `display: none`) and the whole label row it lives in becomes the +// interactive surface instead: a flat bordered box that fills in with a +// tinted background + primary border once selected, with a checkmark +// fading in on the leading edge. +// +// The base (unselected) look below is detected structurally with `:has()` +// — safe, since "does this label contain a checkbox/radio" never changes +// after mount. The *selected* look is instead driven by the `is-selected` +// class {@link CheckboxOption}/{@link RadioOption} (components/ui/) toggle +// in JS from the same boolean their caller already passes to `checked` — +// chaining a second `:has(:checked)` to react to that live state turned +// out to be unreliable across browsers, so this only needs one +// always-true `:has()`. +// +// Every checkbox/radio in the app goes through this one place (the allergy +// grid, the theme picker, anywhere future) rather than each feature styling +// its own — see profile-forms.scss / settings-pages.scss, which only +// arrange these within their own layout (grid vs. stacked list) and +// intentionally don't re-style the control/label look itself. +label:has(> input[type="checkbox"]), +label:has(> input[type="radio"]) { + position: relative; + display: flex; + align-items: center; + gap: var(--space-xs); + padding: var(--space-xs) var(--space-sm); + // Overrides the generic `label { font-weight: 600 }` base rule + // (profile-forms.scss) — without this, an *unselected* row reads just as + // bold as a selected one (only `.allergy-select__option` happened to set + // its own 400 already; `.theme-select__option` didn't, so its rows were + // all permanently bold until this was centralized here). + font-weight: 400; + border: 1.5px solid var(--color-border); + border-radius: var(--radius-base); + background: var(--color-surface); + cursor: pointer; + transition: + background-color 0.15s ease, + border-color 0.15s ease; + + &:hover { + border-color: var(--color-primary); + } + + &.is-selected { + border-color: var(--color-primary); + background: color-mix(in srgb, var(--color-primary) 12%, var(--color-surface)); + color: var(--color-primary); + font-weight: 600; + } + + &:has(:focus-visible) { + outline: 2px solid var(--color-accent); + outline-offset: 2px; + } +} + +// The control itself is removed from the visual flow — hidden the +// "sr-only" way (not `display: none`) so it stays focusable/tabbable and +// announced correctly by screen readers; the label above carries the +// entire visible selected/unchecked look. +input[type="checkbox"], +input[type="radio"] { + position: absolute; + width: 1px; + height: 1px; + margin: 0; + opacity: 0; +} + +// The checkmark — a real element (see components/ui/Checkbox.tsx / +// Radio.tsx) shown via the same `is-selected` class as the label's own +// look above, not a separate CSS-only trigger. Scaled in from nothing so +// toggling has a bit of motion. Same mark for both checkbox and radio: one +// consistent "selected" language app-wide rather than a checkmark here and +// a dot there. Sits first in the row (before the label text, per DOM +// order) — a classic "control on the left" layout rather than trailing. +.check-mark { + flex: none; + width: 0.9rem; + height: 0.9rem; + background: var(--color-primary); + clip-path: polygon(14% 44%, 0 65%, 50% 100%, 100% 16%, 80% 0%, 43% 62%); + transform: scale(0); + transition: transform 0.1s ease; +} + +.is-selected .check-mark { + transform: scale(1); +} diff --git a/apps/admin-web/src/vite-env.d.ts b/apps/admin-web/src/vite-env.d.ts new file mode 100644 index 0000000..a4754d7 --- /dev/null +++ b/apps/admin-web/src/vite-env.d.ts @@ -0,0 +1,5 @@ +/// + +// Injected by `define` in vite.config.ts, sourced from package.json's +// version field — rendered in AdminLayout.tsx. +declare const __APP_VERSION__: string; diff --git a/apps/admin-web/tsconfig.app.json b/apps/admin-web/tsconfig.app.json new file mode 100644 index 0000000..af5b6cb --- /dev/null +++ b/apps/admin-web/tsconfig.app.json @@ -0,0 +1,14 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "module": "ESNext", + "moduleResolution": "Bundler", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "jsx": "react-jsx", + "types": ["vite/client"], + "noEmit": true, + "composite": true, + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo" + }, + "include": ["src"] +} diff --git a/apps/admin-web/tsconfig.json b/apps/admin-web/tsconfig.json new file mode 100644 index 0000000..558996e --- /dev/null +++ b/apps/admin-web/tsconfig.json @@ -0,0 +1,8 @@ +{ + "compilerOptions": { + "module": "ESNext", + "moduleResolution": "Bundler" + }, + "files": [], + "references": [{ "path": "./tsconfig.app.json" }, { "path": "./tsconfig.node.json" }] +} diff --git a/apps/admin-web/tsconfig.node.json b/apps/admin-web/tsconfig.node.json new file mode 100644 index 0000000..a0686f8 --- /dev/null +++ b/apps/admin-web/tsconfig.node.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "Bundler", + "composite": true, + "noEmit": true, + "skipLibCheck": true, + "types": ["node"] + }, + "include": ["vite.config.ts"] +} diff --git a/apps/admin-web/vite.config.ts b/apps/admin-web/vite.config.ts new file mode 100644 index 0000000..9a52da9 --- /dev/null +++ b/apps/admin-web/vite.config.ts @@ -0,0 +1,19 @@ +import { readFileSync } from "node:fs"; +import react from "@vitejs/plugin-react"; +import { defineConfig } from "vite"; + +// Read once at config-eval time — same trick as apps/web's vite.config.ts. +const pkg = JSON.parse(readFileSync(new URL("./package.json", import.meta.url), "utf-8")); + +export default defineConfig({ + plugins: [react()], + server: { port: 5174 }, + css: { + preprocessorOptions: { + scss: { api: "modern-compiler" }, + }, + }, + define: { + __APP_VERSION__: JSON.stringify(pkg.version), + }, +}); diff --git a/docker-compose.yml b/docker-compose.yml index e62058a..64a8a02 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -137,6 +137,24 @@ services: # Dockerfile doc comment on its VOLUME declaration. - tech_step_llm_worker_models:/worker/models + # The admin application's frontend (apps/admin-web) — a static nginx image, + # entirely independent of `app` (its own build, its own URL). Talks to + # `app`'s /admin/* surface. Optional: a stack that doesn't need the admin + # app just omits this service. `VITE_ADMIN_API_URL` is baked in at build + # time — set it as a build arg when the admin app and the API sit on + # different public origins (default "" = same origin, for a shared proxy). + admin-web: + build: + context: . + dockerfile: apps/admin-web/Dockerfile + args: + VITE_ADMIN_API_URL: ${VITE_ADMIN_API_URL:-} + restart: unless-stopped + depends_on: + - app + ports: + - "${ADMIN_WEB_PORT:-3001}:80" + volumes: postgres_data: tech_step_llm_worker_models: diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c06548d..1f970c4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -15,6 +15,79 @@ importers: specifier: ^5.7.2 version: 5.9.3 + apps/admin-web: + dependencies: + '@batch-cooking/date-tools': + specifier: workspace:* + version: link:../../packages/date-tools + '@batch-cooking/shared': + specifier: workspace:* + version: link:../../packages/shared + i18next: + specifier: ^26.3.6 + version: 26.3.6(typescript@5.9.3) + lucide-react: + specifier: ^1.32.0 + version: 1.32.0(react@18.3.1) + react: + specifier: ^18.3.1 + version: 18.3.1 + react-dom: + specifier: ^18.3.1 + version: 18.3.1(react@18.3.1) + react-i18next: + specifier: ^17.0.11 + version: 17.0.11(i18next@26.3.6(typescript@5.9.3))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3) + react-router-dom: + specifier: ^7.18.2 + version: 7.18.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + recharts: + specifier: ^2.15.0 + version: 2.15.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + zod: + specifier: ^3.25.76 + version: 3.25.76 + devDependencies: + '@badeball/cypress-cucumber-preprocessor': + specifier: 22.2.0 + version: 22.2.0(@babel/core@7.29.7)(cypress@13.17.0)(typescript@5.9.3) + '@bahmutov/cypress-esbuild-preprocessor': + specifier: 2.2.8 + version: 2.2.8(esbuild@0.21.5) + '@cypress/vite-dev-server': + specifier: 5.2.1 + version: 5.2.1 + '@types/node': + specifier: ^22.9.0 + version: 22.20.1 + '@types/react': + specifier: ^18.3.12 + version: 18.3.31 + '@types/react-dom': + specifier: ^18.3.1 + version: 18.3.7(@types/react@18.3.31) + '@vitejs/plugin-react': + specifier: ^4.3.3 + version: 4.7.0(vite@5.4.21(@types/node@22.20.1)(sass@1.102.0)) + cypress: + specifier: 13.17.0 + version: 13.17.0 + esbuild: + specifier: 0.21.5 + version: 0.21.5 + sass: + specifier: ^1.102.0 + version: 1.102.0 + start-server-and-test: + specifier: ^2.0.8 + version: 2.1.5 + typescript: + specifier: ^5.7.2 + version: 5.9.3 + vite: + specifier: ^5.4.11 + version: 5.4.21(@types/node@22.20.1)(sass@1.102.0) + apps/api: dependencies: '@batch-cooking/date-tools': @@ -1147,6 +1220,33 @@ packages: '@types/cors@2.8.19': resolution: {integrity: sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==, tarball: https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz} + '@types/d3-array@3.2.2': + resolution: {integrity: sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==, tarball: https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz} + + '@types/d3-color@3.1.3': + resolution: {integrity: sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==, tarball: https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz} + + '@types/d3-ease@3.0.2': + resolution: {integrity: sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==, tarball: https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz} + + '@types/d3-interpolate@3.0.4': + resolution: {integrity: sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==, tarball: https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz} + + '@types/d3-path@3.1.1': + resolution: {integrity: sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==, tarball: https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz} + + '@types/d3-scale@4.0.9': + resolution: {integrity: sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==, tarball: https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz} + + '@types/d3-shape@3.2.0': + resolution: {integrity: sha512-kVd74ta9eof3eJOvbNd1vGKS/XERRyQbT26Og63hIsvDO84cjD5gEOhsXf26w3FSoNlPVz84DOFcKv/oou+fMw==, tarball: https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.2.0.tgz} + + '@types/d3-time@3.0.4': + resolution: {integrity: sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==, tarball: https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz} + + '@types/d3-timer@3.0.2': + resolution: {integrity: sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==, tarball: https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz} + '@types/estree@1.0.9': resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==, tarball: https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz} @@ -1589,6 +1689,10 @@ packages: resolution: {integrity: sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==, tarball: https://registry.npmjs.org/clone/-/clone-1.0.4.tgz} engines: {node: '>=0.8'} + clsx@2.1.1: + resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==, tarball: https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz} + engines: {node: '>=6'} + color-convert@2.0.1: resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==, tarball: https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz} engines: {node: '>=7.0.0'} @@ -1727,6 +1831,50 @@ packages: engines: {node: ^16.0.0 || ^18.0.0 || >=20.0.0} hasBin: true + d3-array@3.2.4: + resolution: {integrity: sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==, tarball: https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz} + engines: {node: '>=12'} + + d3-color@3.1.0: + resolution: {integrity: sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==, tarball: https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz} + engines: {node: '>=12'} + + d3-ease@3.0.1: + resolution: {integrity: sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==, tarball: https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz} + engines: {node: '>=12'} + + d3-format@3.1.2: + resolution: {integrity: sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==, tarball: https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz} + engines: {node: '>=12'} + + d3-interpolate@3.0.1: + resolution: {integrity: sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==, tarball: https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz} + engines: {node: '>=12'} + + d3-path@3.1.0: + resolution: {integrity: sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==, tarball: https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz} + engines: {node: '>=12'} + + d3-scale@4.0.2: + resolution: {integrity: sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==, tarball: https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz} + engines: {node: '>=12'} + + d3-shape@3.2.0: + resolution: {integrity: sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==, tarball: https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz} + engines: {node: '>=12'} + + d3-time-format@4.1.0: + resolution: {integrity: sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==, tarball: https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz} + engines: {node: '>=12'} + + d3-time@3.1.0: + resolution: {integrity: sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==, tarball: https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz} + engines: {node: '>=12'} + + d3-timer@3.0.1: + resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==, tarball: https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz} + engines: {node: '>=12'} + dashdash@1.14.1: resolution: {integrity: sha512-jRFi8UDGo6j+odZiEpjazZaWqEal3w/basFjQHQEwVtZJGDpxbH1MeYluwCS8Xq5wmLJooDlMgvVarmWfGM44g==, tarball: https://registry.npmjs.org/dashdash/-/dashdash-1.14.1.tgz} engines: {node: '>=0.10'} @@ -1772,6 +1920,9 @@ packages: resolution: {integrity: sha512-9iE1PgSik9HeIIw2JO94IidnE3eBoQrFJ3w7sFuzSX4DpmZ3v5sZpUiV5Swcf6mQEF+Y0ru8Neo+p+nyh2J+hQ==, tarball: https://registry.npmjs.org/decamelize/-/decamelize-4.0.0.tgz} engines: {node: '>=10'} + decimal.js-light@2.5.1: + resolution: {integrity: sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==, tarball: https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz} + deep-eql@5.0.2: resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==, tarball: https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz} engines: {node: '>=6'} @@ -1873,6 +2024,9 @@ packages: resolution: {integrity: sha512-PJWHUb1RFevKCwaFA9RlG5tCd+FO5iRh9A8HEtkmBH2Li03iJriB6m6JIN4rGz3K3JLawI7/veA1xzRKP6ISBw==, tarball: https://registry.npmjs.org/diff/-/diff-7.0.0.tgz} engines: {node: '>=0.3.1'} + dom-helpers@5.2.1: + resolution: {integrity: sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==, tarball: https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz} + dom-serializer@1.4.1: resolution: {integrity: sha512-VHwB3KfrcOOkelEG2ZOfxqLZdfkil8PtJi4P8N2MMXucZq2yLp75ClViUlOVwyoHEDjYU433Aq+5zWP61+RGag==, tarball: https://registry.npmjs.org/dom-serializer/-/dom-serializer-1.4.1.tgz} @@ -2032,6 +2186,9 @@ packages: eventemitter2@6.4.7: resolution: {integrity: sha512-tYUSVOGeQPKt/eC1ABfhHy5Xd96N3oIijJvN3O9+TsC28T5V9yX9oEfEK5faP0EFSNVOG97qtAS68GBrQB2hDg==, tarball: https://registry.npmjs.org/eventemitter2/-/eventemitter2-6.4.7.tgz} + eventemitter3@4.0.7: + resolution: {integrity: sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==, tarball: https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz} + execa@4.1.0: resolution: {integrity: sha512-j5W0//W7f8UxAn8hXVnwG8tLwdiUy4FJLcSupCg6maBYZDpyBvTApK7KyuI4bKj8KOh1r2YH+6ucuYtJv1bTZA==, tarball: https://registry.npmjs.org/execa/-/execa-4.1.0.tgz} engines: {node: '>=10'} @@ -2060,6 +2217,10 @@ packages: resolution: {integrity: sha512-11Ndz7Nv+mvAC1j0ktTa7fAb0vLyGGX+rMHNBYQviQDGU0Hw7lhctJANqbPhu9nV9/izT/IntTgZ7Im/9LJs9g==, tarball: https://registry.npmjs.org/extsprintf/-/extsprintf-1.3.0.tgz} engines: {'0': node >=0.6.0} + fast-equals@5.4.1: + resolution: {integrity: sha512-DjlFSM5Pk9cGcL0q5QXl66eGzx0N6szNgaswwc5ZphlBohjTVJSnGgI+rJVOgOi65qUoQnDZN4nDqi33udtydQ==, tarball: https://registry.npmjs.org/fast-equals/-/fast-equals-5.4.1.tgz} + engines: {node: '>=6.0.0'} + fast-glob@3.3.3: resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==, tarball: https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz} engines: {node: '>=8.6.0'} @@ -2367,6 +2528,10 @@ packages: resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==, tarball: https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz} engines: {node: '>= 0.4'} + internmap@2.0.3: + resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==, tarball: https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz} + engines: {node: '>=12'} + ipaddr.js@1.9.1: resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==, tarball: https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz} engines: {node: '>= 0.10'} @@ -3025,6 +3190,9 @@ packages: resolution: {integrity: sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==, tarball: https://registry.npmjs.org/progress/-/progress-2.0.3.tgz} engines: {node: '>=0.4.0'} + prop-types@15.8.1: + resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==, tarball: https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz} + property-expr@2.0.6: resolution: {integrity: sha512-SVtmxhRE/CGkn3eZY1T6pC8Nln6Fr/lu1mKSgRud0eC73whjGfoAogbn78LkD8aFL0zz3bAFerKSnOl7NlErBA==, tarball: https://registry.npmjs.org/property-expr/-/property-expr-2.0.6.tgz} @@ -3093,6 +3261,12 @@ packages: typescript: optional: true + react-is@16.13.1: + resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==, tarball: https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz} + + react-is@18.3.1: + resolution: {integrity: sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==, tarball: https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz} + react-refresh@0.17.0: resolution: {integrity: sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==, tarball: https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz} engines: {node: '>=0.10.0'} @@ -3114,6 +3288,18 @@ packages: react-dom: optional: true + react-smooth@4.0.4: + resolution: {integrity: sha512-gnGKTpYwqL0Iii09gHobNolvX4Kiq4PKx6eWBCYYix+8cdw+cGo3do906l1NBPKkSWx1DghC1dlWG9L2uGd61Q==, tarball: https://registry.npmjs.org/react-smooth/-/react-smooth-4.0.4.tgz} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + react-transition-group@4.4.5: + resolution: {integrity: sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==, tarball: https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz} + peerDependencies: + react: '>=16.6.0' + react-dom: '>=16.6.0' + react@18.3.1: resolution: {integrity: sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==, tarball: https://registry.npmjs.org/react/-/react-18.3.1.tgz} engines: {node: '>=0.10.0'} @@ -3142,6 +3328,17 @@ packages: resolution: {integrity: sha512-Kko+Y5XQ6fM+Ce3dq3m9YGxnacYZYl9cA1wZjaF3Vbry2L3i1qVg8+CAgNPsXRArPMUMCaOR7oa9Nqntc43JKA==, tarball: https://registry.npmjs.org/readdirp/-/readdirp-5.1.1.tgz} engines: {node: '>= 20.19.0'} + recharts-scale@0.4.5: + resolution: {integrity: sha512-kivNFO+0OcUNu7jQquLXAxz1FIwZj8nrj+YkOKc5694NbjCvcT6aSZiIzNzd2Kul4o4rTto8QVR9lMNtxD4G1w==, tarball: https://registry.npmjs.org/recharts-scale/-/recharts-scale-0.4.5.tgz} + + recharts@2.15.4: + resolution: {integrity: sha512-UT/q6fwS3c1dHbXv2uFgYJ9BMFHu3fwnd7AYZaEQhXuYQ4hgsxLvsUXzGdKeZrW5xopzDCvuA2N41WJ88I7zIw==, tarball: https://registry.npmjs.org/recharts/-/recharts-2.15.4.tgz} + engines: {node: '>=14'} + deprecated: 1.x and 2.x branches are no longer active. Bump to Recharts v3 to receive latest features and bugfixes. See https://github.com/recharts/recharts/wiki/3.0-migration-guide + peerDependencies: + react: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + reflect-metadata@0.2.2: resolution: {integrity: sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==, tarball: https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz} @@ -3484,6 +3681,9 @@ packages: tiny-case@1.0.3: resolution: {integrity: sha512-Eet/eeMhkO6TX8mnUteS9zgPbUMQa4I6Kkp5ORiBD5476/m+PIRiumP5tmh5ioJpH7k51Kehawy2UDfsnxxY8Q==, tarball: https://registry.npmjs.org/tiny-case/-/tiny-case-1.0.3.tgz} + tiny-invariant@1.3.3: + resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==, tarball: https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz} + tinyglobby@0.2.17: resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==, tarball: https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz} engines: {node: '>=12.0.0'} @@ -3646,6 +3846,9 @@ packages: resolution: {integrity: sha512-ZZKSmDAEFOijERBLkmYfJ+vmk3w+7hOLYDNkRCuRuMJGEmqYNCNLyBBFwWKVMhfwaEF3WOd0Zlw86U/WC/+nYw==, tarball: https://registry.npmjs.org/verror/-/verror-1.10.0.tgz} engines: {'0': node >=0.6.0} + victory-vendor@36.9.2: + resolution: {integrity: sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ==, tarball: https://registry.npmjs.org/victory-vendor/-/victory-vendor-36.9.2.tgz} + vite@5.4.21: resolution: {integrity: sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==, tarball: https://registry.npmjs.org/vite/-/vite-5.4.21.tgz} engines: {node: ^18.0.0 || >=20.0.0} @@ -4622,6 +4825,30 @@ snapshots: dependencies: '@types/node': 22.20.1 + '@types/d3-array@3.2.2': {} + + '@types/d3-color@3.1.3': {} + + '@types/d3-ease@3.0.2': {} + + '@types/d3-interpolate@3.0.4': + dependencies: + '@types/d3-color': 3.1.3 + + '@types/d3-path@3.1.1': {} + + '@types/d3-scale@4.0.9': + dependencies: + '@types/d3-time': 3.0.4 + + '@types/d3-shape@3.2.0': + dependencies: + '@types/d3-path': 3.1.1 + + '@types/d3-time@3.0.4': {} + + '@types/d3-timer@3.0.2': {} + '@types/estree@1.0.9': {} '@types/express-serve-static-core@4.19.9': @@ -5106,6 +5333,8 @@ snapshots: clone@1.0.4: optional: true + clsx@2.1.1: {} + color-convert@2.0.1: dependencies: color-name: 1.1.4 @@ -5256,6 +5485,44 @@ snapshots: untildify: 4.0.0 yauzl: 2.10.0 + d3-array@3.2.4: + dependencies: + internmap: 2.0.3 + + d3-color@3.1.0: {} + + d3-ease@3.0.1: {} + + d3-format@3.1.2: {} + + d3-interpolate@3.0.1: + dependencies: + d3-color: 3.1.0 + + d3-path@3.1.0: {} + + d3-scale@4.0.2: + dependencies: + d3-array: 3.2.4 + d3-format: 3.1.2 + d3-interpolate: 3.0.1 + d3-time: 3.1.0 + d3-time-format: 4.1.0 + + d3-shape@3.2.0: + dependencies: + d3-path: 3.1.0 + + d3-time-format@4.1.0: + dependencies: + d3-time: 3.1.0 + + d3-time@3.1.0: + dependencies: + d3-array: 3.2.4 + + d3-timer@3.0.1: {} + dashdash@1.14.1: dependencies: assert-plus: 1.0.0 @@ -5284,6 +5551,8 @@ snapshots: decamelize@4.0.0: {} + decimal.js-light@2.5.1: {} + deep-eql@5.0.2: {} deep-equal@2.2.3: @@ -5411,6 +5680,11 @@ snapshots: diff@7.0.0: {} + dom-helpers@5.2.1: + dependencies: + '@babel/runtime': 7.29.7 + csstype: 3.2.3 + dom-serializer@1.4.1: dependencies: domelementtype: 2.3.0 @@ -5614,6 +5888,8 @@ snapshots: eventemitter2@6.4.7: {} + eventemitter3@4.0.7: {} + execa@4.1.0: dependencies: cross-spawn: 7.0.6 @@ -5692,6 +5968,8 @@ snapshots: extsprintf@1.3.0: {} + fast-equals@5.4.1: {} + fast-glob@3.3.3: dependencies: '@nodelib/fs.stat': 2.0.5 @@ -6037,6 +6315,8 @@ snapshots: hasown: 2.0.4 side-channel: 1.1.1 + internmap@2.0.3: {} + ipaddr.js@1.9.1: {} is-arguments@1.2.0: @@ -6673,6 +6953,12 @@ snapshots: progress@2.0.3: {} + prop-types@15.8.1: + dependencies: + loose-envify: 1.4.0 + object-assign: 4.1.1 + react-is: 16.13.1 + property-expr@2.0.6: {} proxy-addr@2.0.7: @@ -6736,6 +7022,10 @@ snapshots: react-dom: 18.3.1(react@18.3.1) typescript: 5.9.3 + react-is@16.13.1: {} + + react-is@18.3.1: {} + react-refresh@0.17.0: {} react-router-dom@7.18.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1): @@ -6752,6 +7042,23 @@ snapshots: optionalDependencies: react-dom: 18.3.1(react@18.3.1) + react-smooth@4.0.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + dependencies: + fast-equals: 5.4.1 + prop-types: 15.8.1 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + react-transition-group: 4.4.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + + react-transition-group@4.4.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + dependencies: + '@babel/runtime': 7.29.7 + dom-helpers: 5.2.1 + loose-envify: 1.4.0 + prop-types: 15.8.1 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + react@18.3.1: dependencies: loose-envify: 1.4.0 @@ -6784,6 +7091,23 @@ snapshots: readdirp@5.1.1: {} + recharts-scale@0.4.5: + dependencies: + decimal.js-light: 2.5.1 + + recharts@2.15.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + dependencies: + clsx: 2.1.1 + eventemitter3: 4.0.7 + lodash: 4.18.1 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + react-is: 18.3.1 + react-smooth: 4.0.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + recharts-scale: 0.4.5 + tiny-invariant: 1.3.3 + victory-vendor: 36.9.2 + reflect-metadata@0.2.2: {} regexp-match-indices@1.0.2: @@ -7212,6 +7536,8 @@ snapshots: tiny-case@1.0.3: {} + tiny-invariant@1.3.3: {} + tinyglobby@0.2.17: dependencies: fdir: 6.5.0(picomatch@4.0.5) @@ -7333,6 +7659,23 @@ snapshots: core-util-is: 1.0.2 extsprintf: 1.3.0 + victory-vendor@36.9.2: + dependencies: + '@types/d3-array': 3.2.2 + '@types/d3-ease': 3.0.2 + '@types/d3-interpolate': 3.0.4 + '@types/d3-scale': 4.0.9 + '@types/d3-shape': 3.2.0 + '@types/d3-time': 3.0.4 + '@types/d3-timer': 3.0.2 + d3-array: 3.2.4 + d3-ease: 3.0.1 + d3-interpolate: 3.0.1 + d3-scale: 4.0.2 + d3-shape: 3.2.0 + d3-time: 3.1.0 + d3-timer: 3.0.1 + vite@5.4.21(@types/node@22.20.1)(sass@1.102.0): dependencies: esbuild: 0.21.5 -- 2.45.2 From 3a416ea9552cff99a92965fc839316e144d50099 Mon Sep 17 00:00:00 2001 From: Nicolas Date: Fri, 28 Aug 2026 12:46:54 +0200 Subject: [PATCH 3/6] feat(admin): metriques d'utilisation (derive DB + AnalyticsEvent) PR 3 du chantier admin. Tableau de bord metriques : snapshot de compteurs + series temporelles journalieres. Schema (migration admin_metrics) : - AnalyticsEvent (type String libre, actorType/actorId sans FK, context Json, index [type, created_at]) + WorkerHeartbeat (cable en PR 4). - colonnes createdAt @default(now()) sur UserProfile / Recipe / Planning / PlanningItem (lecture admin uniquement ; lignes existantes = timestamp de la migration). Instrumentation (lib/analytics.service.ts, fire-and-forget) : - analytics.recordEvent(type, {actorId?, context?}) : retourne void, insert detache, echec loggue+avale, jamais de latence sur la requete. - points d'appel : user.signup, recipe.created, recipe.imported, planning.item_added, tech_step.correction_submitted, shopping_list.viewed. API : GET /admin/metrics?days= (7-365, defaut 30, requireAdmin) -> admin-metrics.service.ts. bucketByDay pur (zero-remplissage, teste sans base). MetricsView dans packages/shared. Front : DashboardPage (tuiles KPI + un graphe recharts par serie + listes recettes-par-source / evenements), logique pure dans dashboard.ts, i18n admin.dashboard.*. AdminApiClient.getMetrics. reset-db.ts truncate analytics_events + worker_heartbeats. Tests : Mocha admin-metrics.test.ts (bucketByDay pur x2 verts ; snapshot, series zero-remplies, event user.signup fire-and-forget) ; Cypress dashboard.cy.ts (2 verts). specs/backend-architecture.md : section admin. Co-Authored-By: Claude Sonnet 5 --- apps/admin-web/cypress/e2e/dashboard.cy.ts | 95 +++++++ apps/admin-web/src/api/client.ts | 6 + .../admin-web/src/locales/fr/translation.json | 31 ++- .../src/pages/dashboard/DashboardPage.tsx | 164 +++++++++++- .../src/pages/dashboard/dashboard-page.scss | 103 ++++++++ .../src/pages/dashboard/dashboard.ts | 51 ++++ .../migration.sql | 32 +++ apps/api/prisma/schema.prisma | 75 +++++- apps/api/src/lib/analytics.service.ts | 65 +++++ .../src/modules/admin/admin-metrics.routes.ts | 22 ++ .../modules/admin/admin-metrics.service.ts | 242 ++++++++++++++++++ apps/api/src/modules/admin/admin.routes.ts | 2 + apps/api/src/modules/auth/auth.service.ts | 3 + .../src/modules/planning/planning.service.ts | 6 + .../recipe-tech-step-correction.service.ts | 11 + apps/api/src/modules/recipe/recipe.service.ts | 7 + .../shopping-list/shopping-list.routes.ts | 2 + apps/api/test-support/reset-db.ts | 2 +- apps/api/test/admin-metrics.test.ts | 149 +++++++++++ packages/shared/src/schemas/admin.ts | 12 + packages/shared/src/types/admin.ts | 71 +++++ specs/backend-architecture.md | 45 ++++ 22 files changed, 1183 insertions(+), 13 deletions(-) create mode 100644 apps/admin-web/cypress/e2e/dashboard.cy.ts create mode 100644 apps/admin-web/src/pages/dashboard/dashboard-page.scss create mode 100644 apps/admin-web/src/pages/dashboard/dashboard.ts create mode 100644 apps/api/prisma/migrations/20260828130000_admin_metrics/migration.sql create mode 100644 apps/api/src/lib/analytics.service.ts create mode 100644 apps/api/src/modules/admin/admin-metrics.routes.ts create mode 100644 apps/api/src/modules/admin/admin-metrics.service.ts create mode 100644 apps/api/test/admin-metrics.test.ts diff --git a/apps/admin-web/cypress/e2e/dashboard.cy.ts b/apps/admin-web/cypress/e2e/dashboard.cy.ts new file mode 100644 index 0000000..e731c23 --- /dev/null +++ b/apps/admin-web/cypress/e2e/dashboard.cy.ts @@ -0,0 +1,95 @@ +// Mocks the admin API via cy.intercept — no live backend. + +const adminBody = { + id: 1, + email: "ops@example.com", + name: "Ops", + createdAt: "2026-08-01T00:00:00.000Z", + lastLoginAt: "2026-08-28T09:00:00.000Z", +}; + +/** A 3-day series helper for the fixture. */ +function series(counts: number[]) { + return counts.map((count, i) => ({ + date: `2026-08-${String(10 + i).padStart(2, "0")}`, + count, + })); +} + +function metricsFixture() { + return { + generatedAt: "2026-08-28T09:00:00.000Z", + rangeDays: 30, + snapshot: { + admins: 2, + users: 42, + households: 15, + activeHouseholds: 9, + recipes: 120, + recipesManual: 30, + recipesImported: 90, + recipesBySource: [ + { key: "themealdb", label: "TheMealDB", count: 60 }, + { key: "marmiton", label: "Marmiton", count: 30 }, + ], + plannings: 18, + planningItems: 210, + steps: 640, + detectedTechniques: 900, + favorites: 55, + corrections: 12, + correctionsUnconsumed: 4, + correctionsRemoval: 2, + trainingSuggestions: 8, + trainingSuggestionsByStatus: [{ key: "pending", label: "pending", count: 8 }], + trainingSuggestionsBySourceType: [{ key: "correction", label: "correction", count: 8 }], + }, + series: { + signups: series([1, 3, 2]), + recipesCreated: series([0, 2, 1]), + planningItemsAdded: series([4, 1, 5]), + correctionsSubmitted: series([0, 0, 1]), + trainingSuggestions: series([0, 1, 0]), + }, + events: [{ type: "user.signup", buckets: series([1, 3, 2]) }], + }; +} + +describe("Admin dashboard", () => { + beforeEach(() => { + cy.viewport(1400, 900); + cy.intercept("GET", "**/admin/auth/me", { statusCode: 200, body: adminBody }); + }); + + it("renders KPI tiles, a chart per series, and the breakdown lists", () => { + cy.intercept("GET", "**/admin/metrics*", { statusCode: 200, body: metricsFixture() }).as( + "getMetrics", + ); + cy.visit("/"); + cy.wait("@getMetrics").its("request.url").should("include", "days=30"); + + // KPI tiles — value + label. + cy.contains(".kpi-tile", "Utilisateurs").should("contain.text", "42"); + cy.contains(".kpi-tile", "Recettes importées").should("contain.text", "90"); + cy.contains(".kpi-tile", "Corrections à traiter").should("contain.text", "4"); + + // One chart card per instrumented series. + cy.get(".chart-card").should("have.length", 5); + cy.contains(".chart-card", "Inscriptions").should("contain.text", "6 sur 30 j"); + + // Breakdown lists. + cy.contains(".breakdown", "Recettes importées par source") + .should("contain.text", "TheMealDB") + .and("contain.text", "Marmiton"); + cy.contains(".breakdown", "Évènements enregistrés").should("contain.text", "user.signup"); + }); + + it("shows an error state when the metrics request fails", () => { + cy.intercept("GET", "**/admin/metrics*", { + statusCode: 500, + body: { code: 5000, message: "x" }, + }); + cy.visit("/"); + cy.contains("Impossible de charger").should("be.visible"); + }); +}); diff --git a/apps/admin-web/src/api/client.ts b/apps/admin-web/src/api/client.ts index 132e39d..ed7fb21 100644 --- a/apps/admin-web/src/api/client.ts +++ b/apps/admin-web/src/api/client.ts @@ -3,6 +3,7 @@ import { type AdminUserView, type ApiErrorResponse, ErrorCode, + type MetricsView, } from "@batch-cooking/shared"; /** @@ -90,6 +91,11 @@ export class AdminApiClient { public me(): Promise { 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 { + return this._request(`/admin/metrics?days=${days}`); + } } /** Single shared instance — stateless, same reasoning as apps/web's `apiClient`. */ diff --git a/apps/admin-web/src/locales/fr/translation.json b/apps/admin-web/src/locales/fr/translation.json index 198b1d2..297aebf 100644 --- a/apps/admin-web/src/locales/fr/translation.json +++ b/apps/admin-web/src/locales/fr/translation.json @@ -9,7 +9,9 @@ }, "admin": { "common": { - "comingSoon": "Section à venir." + "comingSoon": "Section à venir.", + "loading": "Chargement…", + "loadError": "Impossible de charger les données, réessayez plus tard." }, "login": { "title": "Administration", @@ -28,7 +30,32 @@ }, "dashboard": { "title": "Tableau de bord", - "lead": "Métriques d'utilisation de l'application." + "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", diff --git a/apps/admin-web/src/pages/dashboard/DashboardPage.tsx b/apps/admin-web/src/pages/dashboard/DashboardPage.tsx index 5eab1a5..56f66e8 100644 --- a/apps/admin-web/src/pages/dashboard/DashboardPage.tsx +++ b/apps/admin-web/src/pages/dashboard/DashboardPage.tsx @@ -1,17 +1,175 @@ +import type { MetricsTimeBucket, MetricsView } from "@batch-cooking/shared"; +import { useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; +import { + CartesianGrid, + Line, + LineChart, + ResponsiveContainer, + Tooltip, + XAxis, + YAxis, +} from "recharts"; +import { adminApiClient } from "../../api/client"; import "../admin-page.scss"; +import "./dashboard-page.scss"; +import { formatCount, kpiTiles, seriesTotal, shortDay } from "./dashboard"; + +/** Load state for `GET /admin/metrics` — same discriminated-union shape as apps/web's page states. */ +type DashboardState = + | { status: "loading" } + | { status: "loaded"; metrics: MetricsView } + | { status: "error" }; + +const RANGE_DAYS = 30; /** - * Usage-metrics dashboard — KPI tiles + trend charts fed by - * `GET /admin/metrics`. Placeholder until PR 3 (metrics) fills it in. + * 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({ 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 (

{t("admin.dashboard.title")}

{t("admin.dashboard.lead")}

-

{t("admin.common.comingSoon")}

+ + {state.status === "loading" && ( +

{t("admin.common.loading")}

+ )} + {state.status === "error" && ( +

{t("admin.common.loadError")}

+ )} + {state.status === "loaded" && } +
+ ); +} + +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 ( + <> +
+ {kpiTiles(snapshot).map((tile) => ( +
+ {formatCount(tile.value)} + {t(`admin.dashboard.kpi.${tile.labelKey}`)} +
+ ))} +
+ +
+ {charts.map(({ key, buckets }) => ( +
+
+

{t(`admin.dashboard.series.${key}`)}

+ + {t("admin.dashboard.windowTotal", { n: seriesTotal(buckets) })} + +
+ +
+ ))} +
+ +
+

{t("admin.dashboard.recipesBySource")}

+ {snapshot.recipesBySource.length === 0 ? ( +

{t("admin.dashboard.noImports")}

+ ) : ( +
    + {snapshot.recipesBySource.map((row) => ( +
  • + {row.label} + {formatCount(row.count)} +
  • + ))} +
+ )} +
+ + {metrics.events.length > 0 && ( +
+

{t("admin.dashboard.events")}

+
    + {metrics.events.map((event) => ( +
  • + {event.type} + {formatCount(seriesTotal(event.buckets))} +
  • + ))} +
+
+ )} + + ); +} + +/** 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 ( +
+ + + + + + + + +
); } diff --git a/apps/admin-web/src/pages/dashboard/dashboard-page.scss b/apps/admin-web/src/pages/dashboard/dashboard-page.scss new file mode 100644 index 0000000..e31c564 --- /dev/null +++ b/apps/admin-web/src/pages/dashboard/dashboard-page.scss @@ -0,0 +1,103 @@ +// ============================================================================= +// DashboardPage — KPI tile grid + a grid of small trend charts + a couple of +// breakdown lists. Colocated with DashboardPage.tsx. +// ============================================================================= + +.kpi-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(9rem, 1fr)); + gap: var(--space-sm); + margin-bottom: var(--space-xl); +} + +.kpi-tile { + display: flex; + flex-direction: column; + gap: 0.15rem; + padding: var(--space-md); + background: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + + &__value { + font-family: var(--font-display); + font-size: var(--font-size-xl); + font-weight: 700; + color: var(--color-text); + font-variant-numeric: tabular-nums; + } + + &__label { + font-size: var(--font-size-xs); + color: var(--color-text-muted); + } +} + +.chart-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(20rem, 1fr)); + gap: var(--space-md); + margin-bottom: var(--space-xl); +} + +.chart-card { + padding: var(--space-md); + background: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + + &__head { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: var(--space-sm); + margin-bottom: var(--space-sm); + + h2 { + font-size: var(--font-size-md); + } + } + + &__total { + font-size: var(--font-size-sm); + color: var(--color-text-muted); + font-variant-numeric: tabular-nums; + } +} + +.breakdown { + margin-bottom: var(--space-lg); + + h2 { + font-size: var(--font-size-md); + margin-bottom: var(--space-sm); + } + + &__list { + list-style: none; + margin: 0; + padding: 0; + background: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + max-width: 30rem; + + li { + display: flex; + justify-content: space-between; + gap: var(--space-md); + padding: var(--space-sm) var(--space-md); + border-bottom: 1px solid var(--color-border); + font-size: var(--font-size-sm); + + &:last-child { + border-bottom: none; + } + + span:last-child { + font-variant-numeric: tabular-nums; + color: var(--color-text-muted); + } + } + } +} diff --git a/apps/admin-web/src/pages/dashboard/dashboard.ts b/apps/admin-web/src/pages/dashboard/dashboard.ts new file mode 100644 index 0000000..f2a8e8f --- /dev/null +++ b/apps/admin-web/src/pages/dashboard/dashboard.ts @@ -0,0 +1,51 @@ +import type { MetricsSnapshotView, MetricsTimeBucket } from "@batch-cooking/shared"; + +/** + * Pure helpers for `DashboardPage` — number/date formatting and the tile + * list, kept out of the `.tsx` (repo convention: no derivation logic in a + * component file) so they're trivially testable. + */ + +/** French-grouped integer, e.g. `1234` → `"1 234"`. */ +export function formatCount(value: number): string { + return value.toLocaleString("fr-FR"); +} + +/** `"2026-08-28"` → `"28/08"` for a compact chart axis tick. */ +export function shortDay(isoDate: string): string { + const [, month, day] = isoDate.split("-"); + return `${day}/${month}`; +} + +/** Sum of a time series — the "total over the window" figure shown next to each chart. */ +export function seriesTotal(buckets: MetricsTimeBucket[]): number { + return buckets.reduce((sum, bucket) => sum + bucket.count, 0); +} + +/** One KPI tile: an i18n label key and the snapshot value it reads. */ +export interface KpiTile { + labelKey: string; + value: number; +} + +/** + * The dashboard's KPI tiles, in display order. `labelKey` resolves under + * `admin.dashboard.kpi.*`. Kept here (not inline in JSX) so the set is one + * list to reorder/extend. + */ +export function kpiTiles(snapshot: MetricsSnapshotView): KpiTile[] { + return [ + { labelKey: "users", value: snapshot.users }, + { labelKey: "households", value: snapshot.households }, + { labelKey: "activeHouseholds", value: snapshot.activeHouseholds }, + { labelKey: "recipes", value: snapshot.recipes }, + { labelKey: "recipesImported", value: snapshot.recipesImported }, + { labelKey: "plannings", value: snapshot.plannings }, + { labelKey: "planningItems", value: snapshot.planningItems }, + { labelKey: "favorites", value: snapshot.favorites }, + { labelKey: "corrections", value: snapshot.corrections }, + { labelKey: "correctionsUnconsumed", value: snapshot.correctionsUnconsumed }, + { labelKey: "trainingSuggestions", value: snapshot.trainingSuggestions }, + { labelKey: "admins", value: snapshot.admins }, + ]; +} diff --git a/apps/api/prisma/migrations/20260828130000_admin_metrics/migration.sql b/apps/api/prisma/migrations/20260828130000_admin_metrics/migration.sql new file mode 100644 index 0000000..473e744 --- /dev/null +++ b/apps/api/prisma/migrations/20260828130000_admin_metrics/migration.sql @@ -0,0 +1,32 @@ +-- AlterTable: usage-metrics timestamps. Existing rows adopt the migration's +-- own timestamp (acceptable one-off skew for trend charts — same posture as +-- the ingredient_unit_catalog migration). +ALTER TABLE "user_profiles" ADD COLUMN "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP; +ALTER TABLE "recipe" ADD COLUMN "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP; +ALTER TABLE "planning" ADD COLUMN "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP; +ALTER TABLE "planning_item" ADD COLUMN "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP; + +-- CreateTable +CREATE TABLE "analytics_events" ( + "id" SERIAL NOT NULL, + "type" TEXT NOT NULL, + "actor_type" TEXT NOT NULL, + "actor_id" INTEGER, + "context" JSONB, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "analytics_events_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "analytics_events_type_created_at_idx" ON "analytics_events"("type", "created_at"); + +-- CreateTable +CREATE TABLE "worker_heartbeats" ( + "worker_key" TEXT NOT NULL, + "last_seen_at" TIMESTAMP(3) NOT NULL, + "last_run_at" TIMESTAMP(3), + "last_result" JSONB, + + CONSTRAINT "worker_heartbeats_pkey" PRIMARY KEY ("worker_key") +); diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index d8e08da..d9f373c 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -100,9 +100,13 @@ model UserProfile { passwordHash String @map("password_hash") /// Bumped to invalidate previously-issued JWTs (e.g. on password change). /// Not in the original spec doc — required for stateless JWT auth. - tokenVersion Int @default(0) @map("token_version") - houseId Int? @map("house_id") - dietId Int? @map("diet_id") + tokenVersion Int @default(0) @map("token_version") + houseId Int? @map("house_id") + dietId Int? @map("diet_id") + /// See `Planning.createdAt` — same admin-metrics-only timestamp, added by + /// the `admin_metrics` migration for the dashboard's signup curve. No + /// application code reads it (auth doesn't need it). + createdAt DateTime @default(now()) @map("created_at") house House? @relation("HouseMember", fields: [houseId], references: [id], onDelete: SetNull) diet Diet? @relation(fields: [dietId], references: [id], onDelete: SetNull) @@ -192,6 +196,12 @@ model Planning { startDate DateTime @map("start_date") @db.Date finishDate DateTime @map("finish_date") @db.Date houseId Int @map("house_id") + /// When this planning row was first created. Added by the `admin_metrics` + /// migration purely for the admin dashboard's activity curves — no + /// application code reads it. Rows that predate the migration all get the + /// migration's own timestamp (same acceptable one-off skew as the + /// `ingredient_unit_catalog` migration), which is fine for a trend chart. + createdAt DateTime @default(now()) @map("created_at") house House @relation(fields: [houseId], references: [id], onDelete: Cascade) items PlanningItem[] @@ -200,12 +210,14 @@ model Planning { } model PlanningItem { - id Int @id @default(autoincrement()) - planningId Int @map("planning_id") - weekDay String @map("week_day") + id Int @id @default(autoincrement()) + planningId Int @map("planning_id") + weekDay String @map("week_day") meal String - recipeId Int @map("recipe_id") + recipeId Int @map("recipe_id") portions Int + /// See `Planning.createdAt` — same admin-metrics-only timestamp. + createdAt DateTime @default(now()) @map("created_at") planning Planning @relation(fields: [planningId], references: [id], onDelete: Cascade) recipe Recipe @relation(fields: [recipeId], references: [id]) @@ -319,6 +331,10 @@ model Recipe { /// the author had no household yet. authorHouseId Int? @map("author_house_id") visibility RecipeVisibility @default(PERSONAL) + /// See `Planning.createdAt` — same admin-metrics-only timestamp, added by + /// the `admin_metrics` migration for the dashboard's "recipes created" + /// curve. No application code reads it. + createdAt DateTime @default(now()) @map("created_at") author UserProfile @relation(fields: [authorId], references: [id]) authorHouse House? @relation(fields: [authorHouseId], references: [id], onDelete: SetNull) @@ -930,3 +946,48 @@ model AdminUser { @@map("admin_users") } + +/// One recorded product event, for the admin dashboard's usage metrics. +/// Written fire-and-forget by `lib/analytics.service.ts`'s `recordEvent` +/// from a handful of key service methods (signup, recipe import/create, +/// planning add, cooking-session open, tech-step correction, shopping-list +/// view) — never on the request's critical path, so a failed insert is +/// logged and swallowed, never surfaced to the user. +/// +/// `type` is a free `String` (`"user.signup"`, `"recipe.imported"`…), not +/// an enum: adding a new event to instrument is a one-line call site +/// change with **no migration**. `actorId` is a `UserProfile.id` when +/// `actorType == "user"` but carries **no FK** — an event is an immutable +/// historical fact that must outlive the account it describes (a deleted +/// user's signup still counts on the curve). `context` is a small free +/// JSON blob (`{ sourceKey, recipeId, … }`) for slicing later; nothing +/// queries into it today. +model AnalyticsEvent { + id Int @id @default(autoincrement()) + type String + actorType String @map("actor_type") + actorId Int? @map("actor_id") + context Json? + createdAt DateTime @default(now()) @map("created_at") + + @@index([type, createdAt]) + @@map("analytics_events") +} + +/// Liveness/last-run record for a background worker that has no inbound +/// HTTP surface of its own — one row per worker (`workerKey`, today only +/// `"tech-step-llm-worker"`). The worker POSTs `/internal/tech-steps/heartbeat` +/// (`requireInternalWorker`) on boot, on every scheduler tick, and after +/// each job; the admin monitoring board reads this to show the worker as +/// up / stale / down and to surface its last job result. Upserted, never +/// accumulated — only the latest state matters. +model WorkerHeartbeat { + workerKey String @id @map("worker_key") + lastSeenAt DateTime @map("last_seen_at") + /// Set only by a `"job"` heartbeat — the last time the worker actually ran a job (vs. just a tick proving it's alive). + lastRunAt DateTime? @map("last_run_at") + /// Small JSON summary of that last job (`{ job, ok, counts }`). + lastResult Json? @map("last_result") + + @@map("worker_heartbeats") +} diff --git a/apps/api/src/lib/analytics.service.ts b/apps/api/src/lib/analytics.service.ts new file mode 100644 index 0000000..66270e1 --- /dev/null +++ b/apps/api/src/lib/analytics.service.ts @@ -0,0 +1,65 @@ +import type { Prisma } from "@prisma/client"; +import { prisma } from "../db/prisma.js"; +import { logger } from "./logger.service.js"; + +/** Who caused an {@link AnalyticsEvent}. `"user"` pairs with an `actorId` (`UserProfile.id`); `"system"` is a background job; `"anon"` is an unauthenticated request. */ +export type AnalyticsActorType = "user" | "system" | "anon"; + +/** Optional context for {@link AnalyticsService.recordEvent}. */ +export interface RecordEventOptions { + /** `UserProfile.id` — set together with `actorType: "user"` (the default when this is present). */ + actorId?: number; + /** Overrides the inferred actor type (`"user"` when `actorId` is set, else `"anon"`). */ + actorType?: AnalyticsActorType; + /** Small free-form blob for later slicing (`{ sourceKey, recipeId, … }`) — nothing queries into it today. */ + context?: Prisma.InputJsonValue; +} + +/** + * Records product usage events for the admin dashboard's metrics (see the + * `AnalyticsEvent` model doc comment). A class rather than a bare function + * — same convention as `LoggerService`/`ErrorHandlerService`: `public` + * `recordEvent` is the API, `_insert` is the internal it fans out to. + * + * **Fire-and-forget by contract**: `recordEvent` returns `void`, not a + * promise. The insert runs detached, and a failure is logged at `warn` and + * swallowed — analytics must never add latency to, or fail, the request + * that triggered it. Call sites therefore never `await` it. + */ +export class AnalyticsService { + public recordEvent(type: string, options: RecordEventOptions = {}): void { + const actorType: AnalyticsActorType = + options.actorType ?? (options.actorId !== undefined ? "user" : "anon"); + + void this._insert(type, actorType, options).catch((err: unknown) => { + logger.warn("Analytics event insert failed", { + eventType: type, + error: err instanceof Error ? err.message : String(err), + }); + }); + } + + private async _insert( + type: string, + actorType: AnalyticsActorType, + options: RecordEventOptions, + ): Promise { + 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(); diff --git a/apps/api/src/modules/admin/admin-metrics.routes.ts b/apps/api/src/modules/admin/admin-metrics.routes.ts new file mode 100644 index 0000000..3bed532 --- /dev/null +++ b/apps/api/src/modules/admin/admin-metrics.routes.ts @@ -0,0 +1,22 @@ +import { wrapAsyncHandler } from "@batch-cooking/express-tools"; +import { getMetricsSchema } from "@batch-cooking/shared"; +import { Router } from "express"; +import { requireAdmin } from "../../middlewares/require-admin.js"; +import { getMetrics } from "./admin-metrics.service.js"; + +/** Router mounted at `/admin/metrics` (via `admin.routes.ts`) — every route behind {@link requireAdmin}. */ +export const adminMetricsRouter = Router(); + +/** + * Returns the admin dashboard's usage metrics — a `snapshot` of current + * totals plus `?days=` (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)); + }), +); diff --git a/apps/api/src/modules/admin/admin-metrics.service.ts b/apps/api/src/modules/admin/admin-metrics.service.ts new file mode 100644 index 0000000..fb838cc --- /dev/null +++ b/apps/api/src/modules/admin/admin-metrics.service.ts @@ -0,0 +1,242 @@ +import type { + MetricsBreakdownRow, + MetricsEventSeries, + MetricsSnapshotView, + MetricsTimeBucket, + MetricsView, +} from "@batch-cooking/shared"; +import { prisma } from "../../db/prisma.js"; + +/** UTC `YYYY-MM-DD` for a `Date` — the bucket key used by {@link bucketByDay}. */ +function utcDayKey(date: Date): string { + return date.toISOString().slice(0, 10); +} + +/** Start-of-day (UTC) `Date` that is `daysAgo` days before `from`. */ +function startOfUtcDay(from: Date, daysAgo: number): Date { + return new Date(Date.UTC(from.getUTCFullYear(), from.getUTCMonth(), from.getUTCDate() - daysAgo)); +} + +/** + * Buckets `dates` into `days` consecutive daily counts starting at `since` + * (a start-of-UTC-day `Date`). Every day in the window is present, days + * with no matching date carry `count: 0`. Pure/synchronous — factored out + * so the bucketing is unit-testable without a database, same split as + * `aggregateShoppingList`. + */ +export function bucketByDay(dates: Date[], since: Date, days: number): MetricsTimeBucket[] { + const counts = new Map(); + 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 { + 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 { + 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(); + 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 + } +} diff --git a/apps/api/src/modules/admin/admin.routes.ts b/apps/api/src/modules/admin/admin.routes.ts index ce51e05..75ffe8e 100644 --- a/apps/api/src/modules/admin/admin.routes.ts +++ b/apps/api/src/modules/admin/admin.routes.ts @@ -1,5 +1,6 @@ import { Router } from "express"; import { adminAuthRouter } from "./admin-auth.routes.js"; +import { adminMetricsRouter } from "./admin-metrics.routes.js"; /** * Aggregator for the admin application's API surface, mounted at `/admin` @@ -11,3 +12,4 @@ import { adminAuthRouter } from "./admin-auth.routes.js"; export const adminRouter = Router(); adminRouter.use("/auth", adminAuthRouter); +adminRouter.use("/metrics", adminMetricsRouter); diff --git a/apps/api/src/modules/auth/auth.service.ts b/apps/api/src/modules/auth/auth.service.ts index 85d0c76..58dd0fb 100644 --- a/apps/api/src/modules/auth/auth.service.ts +++ b/apps/api/src/modules/auth/auth.service.ts @@ -8,6 +8,7 @@ import { import argon2 from "argon2"; import { env } from "../../config/env.js"; import { prisma } from "../../db/prisma.js"; +import { analytics } from "../../lib/analytics.service.js"; import { signAuthToken } from "../../lib/jwt.js"; import { toSafeProfile } from "../../lib/safe-profile.js"; import { leaveCurrentHouse } from "../house/house.service.js"; @@ -58,6 +59,8 @@ export async function signup(input: SignupInput): Promise { }, }); + analytics.recordEvent("user.signup", { actorId: profile.id }); + const token = signAuthToken({ userProfileId: profile.id, tokenVersion: profile.tokenVersion, diff --git a/apps/api/src/modules/planning/planning.service.ts b/apps/api/src/modules/planning/planning.service.ts index 23146e1..2fcaaf9 100644 --- a/apps/api/src/modules/planning/planning.service.ts +++ b/apps/api/src/modules/planning/planning.service.ts @@ -7,6 +7,7 @@ import { type PlanningView, } from "@batch-cooking/shared"; import { prisma } from "../../db/prisma.js"; +import { analytics } from "../../lib/analytics.service.js"; import { assertRecipeVisible } from "../recipe/recipe.service.js"; /** @@ -157,6 +158,11 @@ export async function addPlanningItem( include: { recipe: { select: { id: true, name: true } } }, }); + analytics.recordEvent("planning.item_added", { + actorId: viewerId, + context: { recipeId: input.recipeId, portions: input.portions }, + }); + return { id: item.id, weekDay: item.weekDay, diff --git a/apps/api/src/modules/recipe/recipe-tech-step-correction.service.ts b/apps/api/src/modules/recipe/recipe-tech-step-correction.service.ts index a43a8fb..b106f2c 100644 --- a/apps/api/src/modules/recipe/recipe-tech-step-correction.service.ts +++ b/apps/api/src/modules/recipe/recipe-tech-step-correction.service.ts @@ -7,6 +7,7 @@ import { } from "@batch-cooking/shared"; import type { Prisma } from "@prisma/client"; import { prisma } from "../../db/prisma.js"; +import { analytics } from "../../lib/analytics.service.js"; import { assertRecipeVisible, toStepTechStepViews } from "./recipe.service.js"; /** @@ -472,6 +473,16 @@ export async function submitTechStepCorrection( return { correction: createdCorrection, techSteps: freshTechSteps }; }); + analytics.recordEvent("tech_step.correction_submitted", { + actorId: correctorId, + context: { + recipeId, + stepId, + previousTechStepId: input.previousTechStepId ?? null, + correctedTechStepId: input.correctedTechStepId ?? null, + }, + }); + return { correction: toCorrectionView(correction), techSteps: toStepTechStepViews(techSteps) }; } catch (err) { throw err; // see loadVisibleStepOrThrow's catch comment diff --git a/apps/api/src/modules/recipe/recipe.service.ts b/apps/api/src/modules/recipe/recipe.service.ts index 9b7e6c6..42f02ad 100644 --- a/apps/api/src/modules/recipe/recipe.service.ts +++ b/apps/api/src/modules/recipe/recipe.service.ts @@ -14,6 +14,7 @@ import { } from "@batch-cooking/shared"; import type { Prisma } from "@prisma/client"; import { prisma } from "../../db/prisma.js"; +import { analytics } from "../../lib/analytics.service.js"; import { type TechStepMatch, techStepClassifier, @@ -616,6 +617,12 @@ async function createRecipeInternal( }, include: recipeInclude(authorId), }); + + analytics.recordEvent(source === null ? "recipe.created" : "recipe.imported", { + actorId: authorId, + context: { recipeId: created.id, sourceId: source?.sourceId ?? null }, + }); + return toRecipeView(created); } catch (err) { throw err; // see suitableForHouseholdWhere()'s catch comment above diff --git a/apps/api/src/modules/shopping-list/shopping-list.routes.ts b/apps/api/src/modules/shopping-list/shopping-list.routes.ts index dae9ab3..6880d43 100644 --- a/apps/api/src/modules/shopping-list/shopping-list.routes.ts +++ b/apps/api/src/modules/shopping-list/shopping-list.routes.ts @@ -3,6 +3,7 @@ import { HttpError } from "@batch-cooking/error-tools"; import { wrapAsyncHandler } from "@batch-cooking/express-tools"; import { ErrorCode, getShoppingListSchema } from "@batch-cooking/shared"; import { Router } from "express"; +import { analytics } from "../../lib/analytics.service.js"; import { type AuthLocals, requireAuth } from "../../middlewares/require-auth.js"; import { getShoppingListForDate } from "./shopping-list.service.js"; @@ -31,6 +32,7 @@ shoppingListRouter.get( } const shoppingList = await getShoppingListForDate(res.locals.userProfile.houseId, date); + analytics.recordEvent("shopping_list.viewed", { actorId: res.locals.userProfile.id }); res.status(200).json(shoppingList); }), ); diff --git a/apps/api/test-support/reset-db.ts b/apps/api/test-support/reset-db.ts index 6d98bd6..c543730 100644 --- a/apps/api/test-support/reset-db.ts +++ b/apps/api/test-support/reset-db.ts @@ -48,7 +48,7 @@ export async function resetDatabase() { "recipe_ingredient", "step_tech_step", "step", "tech_step", "recipe", "ingredients", "sources", "unit", "user_profiles", "diet", "house", - "admin_users" + "admin_users", "analytics_events", "worker_heartbeats" RESTART IDENTITY CASCADE; `); await seedReferenceData(prisma); diff --git a/apps/api/test/admin-metrics.test.ts b/apps/api/test/admin-metrics.test.ts new file mode 100644 index 0000000..c7ba930 --- /dev/null +++ b/apps/api/test/admin-metrics.test.ts @@ -0,0 +1,149 @@ +import type { SignupInput } from "@batch-cooking/shared"; +import { faker } from "@faker-js/faker"; +import { expect } from "chai"; +import request from "supertest"; +import { createApp } from "../src/app.js"; +import { env } from "../src/config/env.js"; +import { prisma } from "../src/db/prisma.js"; +import { hashAdminPassword } from "../src/modules/admin/admin-auth.service.js"; +import { bucketByDay } from "../src/modules/admin/admin-metrics.service.js"; +import { resetDatabase } from "../test-support/reset-db.js"; + +function buildSignupPayload(): SignupInput { + const firstName = faker.person.firstName(); + const lastName = faker.person.lastName(); + return { + firstName, + lastName, + email: faker.internet.email({ firstName, lastName }).toLowerCase(), + password: faker.internet.password({ length: 16 }), + }; +} + +async function seedAdmin(): Promise<{ email: string; password: string }> { + const email = faker.internet.email().toLowerCase(); + const password = faker.internet.password({ length: 16 }); + await prisma.adminUser.create({ + data: { email, name: faker.person.fullName(), passwordHash: await hashAdminPassword(password) }, + }); + return { email, password }; +} + +/** Retries `check` until it stops throwing or `timeoutMs` elapses — `analytics.recordEvent` writes its row fire-and-forget, so a test observing it has to poll briefly. */ +async function eventually(check: () => Promise, timeoutMs = 2000): Promise { + 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); + }); + }); + }); +}); diff --git a/packages/shared/src/schemas/admin.ts b/packages/shared/src/schemas/admin.ts index f02cf08..2b7fb28 100644 --- a/packages/shared/src/schemas/admin.ts +++ b/packages/shared/src/schemas/admin.ts @@ -16,3 +16,15 @@ export const adminLoginSchema = z.object({ }); /** Inferred TS type for {@link adminLoginSchema}'s validated output. */ export type AdminLoginInput = z.infer; + +/** + * Query params for `GET /admin/metrics` — `?days=` bounds how far back the + * time-series go (and how many daily buckets they carry). Coerced from the + * query string; clamped to a sane window so a huge value can't make the + * dashboard scan the whole history. + */ +export const getMetricsSchema = z.object({ + days: z.coerce.number().int().min(7).max(365).default(30), +}); +/** Inferred TS type for {@link getMetricsSchema}'s validated output. */ +export type GetMetricsInput = z.infer; diff --git a/packages/shared/src/types/admin.ts b/packages/shared/src/types/admin.ts index 499e5eb..364139e 100644 --- a/packages/shared/src/types/admin.ts +++ b/packages/shared/src/types/admin.ts @@ -14,3 +14,74 @@ export interface AdminUserView { createdAt: string; lastLoginAt: string | null; } + +/** One `{ key, count }` breakdown row — e.g. recipes per source, corrections per status. */ +export interface MetricsBreakdownRow { + key: string; + /** Human-readable label when the key isn't self-explanatory (a source's display name); otherwise equal to `key`. */ + label: string; + count: number; +} + +/** + * Point-in-time totals for the admin dashboard's KPI tiles — every field is + * a `COUNT` against the current database, not a time series. See + * `apps/api`'s `admin-metrics.service.ts`. + */ +export interface MetricsSnapshotView { + admins: number; + users: number; + households: number; + /** Households with at least one `Planning` row. */ + activeHouseholds: number; + recipes: number; + recipesManual: number; + recipesImported: number; + /** Imported recipes grouped by their `Source` (`key` = source key, `label` = display name). */ + recipesBySource: MetricsBreakdownRow[]; + plannings: number; + planningItems: number; + steps: number; + detectedTechniques: number; + favorites: number; + corrections: number; + /** Corrections not yet turned into a `TechStepTrainingSuggestion` (`consumedAt IS NULL`). */ + correctionsUnconsumed: number; + /** Corrections that assert "no technique here" (`correctedTechStepId IS NULL`) — never become suggestions. */ + correctionsRemoval: number; + trainingSuggestions: number; + trainingSuggestionsByStatus: MetricsBreakdownRow[]; + trainingSuggestionsBySourceType: MetricsBreakdownRow[]; +} + +/** One day of a metrics time series. `date` is `YYYY-MM-DD` (UTC day). Days with no activity are present with `count: 0`. */ +export interface MetricsTimeBucket { + date: string; + count: number; +} + +/** One instrumented `AnalyticsEvent` type, bucketed by day over the requested window. */ +export interface MetricsEventSeries { + type: string; + buckets: MetricsTimeBucket[]; +} + +/** + * Response of `GET /admin/metrics`. `snapshot` is "right now"; `series` and + * `events` cover the last `rangeDays` days as daily buckets (zero-filled). + * `series` is derived from the `createdAt` columns added by the + * `admin_metrics` migration; `events` from the `AnalyticsEvent` table. + */ +export interface MetricsView { + generatedAt: string; + rangeDays: number; + snapshot: MetricsSnapshotView; + series: { + signups: MetricsTimeBucket[]; + recipesCreated: MetricsTimeBucket[]; + planningItemsAdded: MetricsTimeBucket[]; + correctionsSubmitted: MetricsTimeBucket[]; + trainingSuggestions: MetricsTimeBucket[]; + }; + events: MetricsEventSeries[]; +} diff --git a/specs/backend-architecture.md b/specs/backend-architecture.md index 5d2e6ff..8195788 100644 --- a/specs/backend-architecture.md +++ b/specs/backend-architecture.md @@ -347,6 +347,51 @@ identique (aucune conversion — même posture que `ShoppingListItemView`). --- +## `admin` — application d'administration (`/admin/*`) + +Surface d'exploitation servie à `apps/admin-web` (frontend Vite **séparé**, +URL/déploiement propres). Vit dans `apps/api` (qui reste seul propriétaire du +schéma) mais avec une **authentification totalement distincte** de celle des +utilisateurs. + +**Auth** (`middlewares/require-admin.ts`, `lib/admin-jwt.ts`) — table +`AdminUser` isolée (aucune relation vers `UserProfile`), cookie +`ADMIN_COOKIE_NAME` (`admin_session`, ≠ `session`), secret `ADMIN_JWT_SECRET` +(≠ `JWT_SECRET`). `requireAdmin` re-check `tokenVersion` en base comme +`requireAuth`, **échoue fermé** si `ADMIN_JWT_SECRET` est absent (posture +`requireInternalWorker`). Aucun signup exposé — le 1ᵉʳ admin est créé +hors-bande par `src/scripts/create-admin.ts` (flags ou `ADMIN_INITIAL_*`). +`res.locals.adminUser` typé `AdminLocals`. CORS : `setupCore` accepte +`string[]`, `app.ts` autorise `CORS_ORIGIN` + `ADMIN_CORS_ORIGIN`. + +Router agrégateur `modules/admin/admin.routes.ts` monté `/admin` : +`/admin/auth` (`login`/`logout`/`me`), `/admin/metrics` (ci-dessous). + +**Métriques** (`admin-metrics.service.ts`, `GET /admin/metrics?days=` 7–365, +défaut 30) — `MetricsView` = `snapshot` (des `count`s : utilisateurs, foyers, +foyers actifs, recettes manuelles/importées + ventilation par source, +plannings, créneaux, corrections par état, suggestions par statut/source…) + +`series` (buckets journaliers zéro-remplis) + `events` (rollup +`AnalyticsEvent` par type/jour). Les séries sont dérivées de colonnes +`createdAt` ajoutées par la migration `admin_metrics` à `UserProfile` / +`Recipe` / `Planning` / `PlanningItem` (aucun code applicatif ne les lit ; +lignes préexistantes = timestamp de la migration). `bucketByDay` est +pur/testable sans base. + +**Instrumentation** (`lib/analytics.service.ts`) — `analytics.recordEvent(type, { actorId?, context? })` +**fire-and-forget** : retourne `void`, insère détaché, un échec est loggué +`warn` et avalé (jamais de latence ni d'échec sur la requête appelante). `type` +est un `String` libre (`"user.signup"`, `"recipe.imported"`, `"recipe.created"`, +`"planning.item_added"`, `"tech_step.correction_submitted"`, +`"shopping_list.viewed"` aujourd'hui) — ajouter un évènement = un appel d'une +ligne, **sans migration**. `AnalyticsEvent.actorId` n'a **pas** de FK (un +évènement est un fait historique qui survit au compte qu'il décrit). + +*(La table `WorkerHeartbeat` est créée par la même migration mais n'est +câblée que par le monitoring — section à venir.)* + +--- + ## `reference` — catalogues publics (pas de session requise) Router `/reference` (`reference.routes.ts`/`.service.ts`) — **toutes les -- 2.45.2 From 29432e5bf2e9e994fc6eead9a172705dcf9c46fd Mon Sep 17 00:00:00 2001 From: Nicolas Date: Fri, 28 Aug 2026 12:55:47 +0200 Subject: [PATCH 4/6] feat(admin): monitoring des microservices + heartbeat du worker LLM PR 4 du chantier admin. Board de sante temps reel des dependances. - POST /internal/tech-steps/heartbeat (requireInternalWorker) -> recordWorkerHeartbeat : upsert WorkerHeartbeat (cle fixe "tech-step-llm-worker"), lastRunAt/lastResult pour un ping "job". Schema workerHeartbeatSchema dans packages/shared. - services/tech-step-llm-worker : api-client.postHeartbeat (best-effort, ne throw jamais) appele au boot (index.ts), a chaque tick et apres chaque job (scheduler.ts, avec job/ok/counts). - admin-monitoring.service.ts + GET /admin/monitoring (requireAdmin) : sonde active bornee (~2 s) de Postgres (SELECT 1), l'API (uptime/RSS), tech-step-intent-service (/health), et le worker via son heartbeat. Statut up/degraded/down/unknown ; une sonde down ne casse ni les autres ni l'endpoint. Seuils worker : > 8 j degraded, > 21 j down. - MonitoringView / ServiceHealthView dans packages/shared. - Front : MonitoringPage (grille de cartes coloree par statut, re-poll 15 s), logique pure monitoring.ts, i18n admin.monitoring.*, AdminApiClient.getMonitoring. - Tests : Mocha admin-monitoring.test.ts (heartbeat 401/400/upsert job+boot ; GET /admin/monitoring 401, board 4 cibles, worker unknown sans heartbeat puis up apres) ; Cypress monitoring.cy.ts (2 verts). Worker mocha : 6/6 toujours verts. - specs/backend-architecture.md : section monitoring. .gitignore : apps/admin-web/cypress/{screenshots,videos,downloads}. Co-Authored-By: Claude Sonnet 5 --- .gitignore | 3 + apps/admin-web/cypress/e2e/monitoring.cy.ts | 83 +++++++++ apps/admin-web/src/api/client.ts | 6 + .../admin-web/src/locales/fr/translation.json | 20 +- .../src/pages/monitoring/MonitoringPage.tsx | 100 +++++++++- .../src/pages/monitoring/monitoring-page.scss | 98 ++++++++++ .../src/pages/monitoring/monitoring.ts | 19 ++ .../modules/admin/admin-monitoring.routes.ts | 20 ++ .../modules/admin/admin-monitoring.service.ts | 174 ++++++++++++++++++ apps/api/src/modules/admin/admin.routes.ts | 2 + .../internal/tech-step-worker.routes.ts | 18 ++ .../internal/tech-step-worker.service.ts | 43 +++++ apps/api/test/admin-monitoring.test.ts | 160 ++++++++++++++++ packages/shared/src/schemas/admin.ts | 19 ++ packages/shared/src/types/admin.ts | 31 ++++ .../tech-step-llm-worker/src/api-client.ts | 29 +++ services/tech-step-llm-worker/src/index.ts | 6 + .../tech-step-llm-worker/src/scheduler.ts | 16 ++ specs/backend-architecture.md | 18 +- 19 files changed, 858 insertions(+), 7 deletions(-) create mode 100644 apps/admin-web/cypress/e2e/monitoring.cy.ts create mode 100644 apps/admin-web/src/pages/monitoring/monitoring-page.scss create mode 100644 apps/admin-web/src/pages/monitoring/monitoring.ts create mode 100644 apps/api/src/modules/admin/admin-monitoring.routes.ts create mode 100644 apps/api/src/modules/admin/admin-monitoring.service.ts create mode 100644 apps/api/test/admin-monitoring.test.ts diff --git a/.gitignore b/.gitignore index 0df7f12..bdcfe13 100644 --- a/.gitignore +++ b/.gitignore @@ -163,3 +163,6 @@ tmp-mockups/ apps/web/cypress/screenshots/ apps/web/cypress/videos/ apps/web/cypress/downloads/ +apps/admin-web/cypress/screenshots/ +apps/admin-web/cypress/videos/ +apps/admin-web/cypress/downloads/ diff --git a/apps/admin-web/cypress/e2e/monitoring.cy.ts b/apps/admin-web/cypress/e2e/monitoring.cy.ts new file mode 100644 index 0000000..4b14571 --- /dev/null +++ b/apps/admin-web/cypress/e2e/monitoring.cy.ts @@ -0,0 +1,83 @@ +// Mocks the admin API via cy.intercept — no live backend. + +const adminBody = { + id: 1, + email: "ops@example.com", + name: "Ops", + createdAt: "2026-08-01T00:00:00.000Z", + lastLoginAt: "2026-08-28T09:00:00.000Z", +}; + +function monitoringFixture() { + return { + generatedAt: "2026-08-28T09:15:00.000Z", + services: [ + { + key: "postgres", + status: "up", + latencyMs: 3.2, + detail: null, + checkedAt: "2026-08-28T09:15:00.000Z", + }, + { + key: "api", + status: "up", + latencyMs: 0, + detail: "uptime 3 h 12 min · RSS 120 Mo", + checkedAt: "2026-08-28T09:15:00.000Z", + }, + { + key: "intent-service", + status: "down", + latencyMs: null, + detail: "fetch failed", + checkedAt: "2026-08-28T09:15:00.000Z", + }, + { + key: "tech-step-llm-worker", + status: "degraded", + latencyMs: null, + detail: "dernier battement il y a 9 j", + checkedAt: "2026-08-28T09:15:00.000Z", + lastRunAt: "2026-08-19T03:00:00.000Z", + lastResult: { job: "audit-low-confidence", ok: true, counts: { suggestions: 2 } }, + }, + ], + }; +} + +describe("Admin monitoring", () => { + beforeEach(() => { + cy.viewport(1400, 900); + cy.intercept("GET", "**/admin/auth/me", { statusCode: 200, body: adminBody }); + }); + + it("renders one card per service with its status and details", () => { + cy.intercept("GET", "**/admin/monitoring", { statusCode: 200, body: monitoringFixture() }).as( + "getMonitoring", + ); + cy.visit("/monitoring"); + cy.wait("@getMonitoring"); + + cy.get(".monitoring-card").should("have.length", 4); + + cy.contains(".monitoring-card", "Base de données") + .should("have.class", "monitoring-card--up") + .and("contain.text", "3.2 ms"); + cy.contains(".monitoring-card", "Service NLP (spaCy)") + .should("have.class", "monitoring-card--down") + .and("contain.text", "Hors service"); + cy.contains(".monitoring-card", "Worker LLM") + .should("have.class", "monitoring-card--degraded") + .and("contain.text", "audit-low-confidence"); + }); + + it("shows an error state when the request fails", () => { + cy.intercept("GET", "**/admin/monitoring", { + statusCode: 500, + body: { code: 5000, message: "x" }, + }); + cy.visit("/monitoring"); + cy.contains("Impossible de charger").should("be.visible"); + }); +}); diff --git a/apps/admin-web/src/api/client.ts b/apps/admin-web/src/api/client.ts index ed7fb21..288fc68 100644 --- a/apps/admin-web/src/api/client.ts +++ b/apps/admin-web/src/api/client.ts @@ -4,6 +4,7 @@ import { type ApiErrorResponse, ErrorCode, type MetricsView, + type MonitoringView, } from "@batch-cooking/shared"; /** @@ -96,6 +97,11 @@ export class AdminApiClient { public getMetrics(days: number): Promise { 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 { + return this._request("/admin/monitoring"); + } } /** Single shared instance — stateless, same reasoning as apps/web's `apiClient`. */ diff --git a/apps/admin-web/src/locales/fr/translation.json b/apps/admin-web/src/locales/fr/translation.json index 297aebf..11bc033 100644 --- a/apps/admin-web/src/locales/fr/translation.json +++ b/apps/admin-web/src/locales/fr/translation.json @@ -59,7 +59,25 @@ }, "monitoring": { "title": "Monitoring", - "lead": "Santé des microservices et de la base de données." + "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", diff --git a/apps/admin-web/src/pages/monitoring/MonitoringPage.tsx b/apps/admin-web/src/pages/monitoring/MonitoringPage.tsx index f531842..302c93f 100644 --- a/apps/admin-web/src/pages/monitoring/MonitoringPage.tsx +++ b/apps/admin-web/src/pages/monitoring/MonitoringPage.tsx @@ -1,18 +1,110 @@ +import type { MonitoringView, ServiceHealthView } from "@batch-cooking/shared"; +import { useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; +import { adminApiClient } from "../../api/client"; import "../admin-page.scss"; +import "./monitoring-page.scss"; +import { clockTime, POLL_INTERVAL_MS, statusModifier } from "./monitoring"; + +type MonitoringState = + | { status: "loading" } + | { status: "loaded"; data: MonitoringView } + | { status: "error" }; /** - * Microservice health board — active probes of Postgres, the API, the - * intent-service and the LLM worker's heartbeat, fed by - * `GET /admin/monitoring`. Placeholder until PR 4 (monitoring). + * 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({ 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 (

{t("admin.monitoring.title")}

{t("admin.monitoring.lead")}

-

{t("admin.common.comingSoon")}

+ + {state.status === "loading" && ( +

{t("admin.common.loading")}

+ )} + {state.status === "error" && ( +

{t("admin.common.loadError")}

+ )} + {state.status === "loaded" && ( + <> +

+ {t("admin.monitoring.lastChecked", { time: clockTime(state.data.generatedAt) })} +

+
+ {state.data.services.map((service) => ( + + ))} +
+ + )}
); } + +function ServiceCard({ service }: { service: ServiceHealthView }) { + const { t } = useTranslation(); + return ( +
+
+
+
+ {service.latencyMs !== null && ( +
+
{t("admin.monitoring.latency")}
+
{service.latencyMs} ms
+
+ )} + {service.detail && ( +
+
{t("admin.monitoring.detail")}
+
{service.detail}
+
+ )} + {service.lastRunAt !== undefined && ( +
+
{t("admin.monitoring.lastRun")}
+
+ {service.lastRunAt ? clockTime(service.lastRunAt) : t("admin.monitoring.never")} + {service.lastResult?.job ? ` · ${service.lastResult.job}` : ""} + {service.lastResult?.ok === false ? ` · ${t("admin.monitoring.jobFailed")}` : ""} +
+
+ )} +
+
+ ); +} diff --git a/apps/admin-web/src/pages/monitoring/monitoring-page.scss b/apps/admin-web/src/pages/monitoring/monitoring-page.scss new file mode 100644 index 0000000..d9fe572 --- /dev/null +++ b/apps/admin-web/src/pages/monitoring/monitoring-page.scss @@ -0,0 +1,98 @@ +// ============================================================================= +// MonitoringPage — a grid of service health cards, one per probed target. +// Status drives a coloured left border + dot. +// ============================================================================= + +.monitoring-checked { + margin: 0 0 var(--space-md); + font-size: var(--font-size-sm); + color: var(--color-text-muted); +} + +.monitoring-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(18rem, 1fr)); + gap: var(--space-md); +} + +.monitoring-card { + padding: var(--space-md); + background: var(--color-surface); + border: 1px solid var(--color-border); + border-left: 4px solid var(--color-border); + border-radius: var(--radius-md); + + --status-color: var(--color-text-muted); + &--up { + --status-color: var(--color-success); + } + &--degraded { + --status-color: var(--color-warning); + } + &--down { + --status-color: var(--color-error); + } + &--unknown { + --status-color: var(--color-text-muted); + } + + border-left-color: var(--status-color); + + &__head { + display: flex; + align-items: center; + gap: var(--space-sm); + margin-bottom: var(--space-sm); + + h2 { + flex: 1; + min-width: 0; + font-size: var(--font-size-md); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + } + + &__dot { + flex-shrink: 0; + width: 0.6rem; + height: 0.6rem; + border-radius: 50%; + background: var(--status-color); + } + + &__status { + flex-shrink: 0; + font-size: var(--font-size-xs); + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--status-color); + } + + &__meta { + margin: 0; + display: flex; + flex-direction: column; + gap: var(--space-xs); + + div { + display: flex; + gap: var(--space-sm); + font-size: var(--font-size-sm); + } + + dt { + flex-shrink: 0; + color: var(--color-text-muted); + min-width: 5rem; + } + + dd { + margin: 0; + color: var(--color-text); + word-break: break-word; + } + } +} diff --git a/apps/admin-web/src/pages/monitoring/monitoring.ts b/apps/admin-web/src/pages/monitoring/monitoring.ts new file mode 100644 index 0000000..215bf8b --- /dev/null +++ b/apps/admin-web/src/pages/monitoring/monitoring.ts @@ -0,0 +1,19 @@ +import type { ServiceStatus } from "@batch-cooking/shared"; + +/** + * Pure helpers for `MonitoringPage` — kept out of the `.tsx` per repo + * convention. + */ + +/** CSS modifier suffix for a status pill (`monitoring-card--up`, etc.). */ +export function statusModifier(status: ServiceStatus): string { + return status; +} + +/** How often the board re-polls `GET /admin/monitoring`, in ms. */ +export const POLL_INTERVAL_MS = 15_000; + +/** `"2026-08-28T09:00:00.000Z"` → `"09:00:00"` (local time) for the "last checked" line. */ +export function clockTime(iso: string): string { + return new Date(iso).toLocaleTimeString("fr-FR"); +} diff --git a/apps/api/src/modules/admin/admin-monitoring.routes.ts b/apps/api/src/modules/admin/admin-monitoring.routes.ts new file mode 100644 index 0000000..62e6be3 --- /dev/null +++ b/apps/api/src/modules/admin/admin-monitoring.routes.ts @@ -0,0 +1,20 @@ +import { wrapAsyncHandler } from "@batch-cooking/express-tools"; +import { Router } from "express"; +import { requireAdmin } from "../../middlewares/require-admin.js"; +import { getMonitoring } from "./admin-monitoring.service.js"; + +/** Router mounted at `/admin/monitoring` (via `admin.routes.ts`) — behind {@link requireAdmin}. */ +export const adminMonitoringRouter = Router(); + +/** + * Actively probes Postgres, the API, `tech-step-intent-service` and the LLM + * worker's heartbeat, returning a {@link MonitoringView} status board (see + * {@link getMonitoring}). No params; the admin UI polls it on an interval. + */ +adminMonitoringRouter.get( + "/", + requireAdmin, + wrapAsyncHandler(async (_req, res) => { + res.status(200).json(await getMonitoring()); + }), +); diff --git a/apps/api/src/modules/admin/admin-monitoring.service.ts b/apps/api/src/modules/admin/admin-monitoring.service.ts new file mode 100644 index 0000000..db0ffd8 --- /dev/null +++ b/apps/api/src/modules/admin/admin-monitoring.service.ts @@ -0,0 +1,174 @@ +import type { MonitoringView, ServiceHealthView, ServiceStatus } from "@batch-cooking/shared"; +import { env } from "../../config/env.js"; +import { prisma } from "../../db/prisma.js"; + +/** How long each outbound probe (Postgres query, intent-service HTTP) is allowed to take before it counts as `down`. */ +const PROBE_TIMEOUT_MS = 2000; + +/** + * Heartbeat-age thresholds for the LLM worker. Its default cron is weekly + * (`TECH_STEP_WORKER_CRON`, `0 3 * * 0`), and it also pings on boot/tick — + * so no ping for **8 days** means it likely missed its last scheduled fire + * (`degraded`), and none for **3 weeks** means it's almost certainly not + * running at all (`down`). + */ +const WORKER_STALE_AFTER_MS = 8 * 24 * 60 * 60 * 1000; +const WORKER_DOWN_AFTER_MS = 21 * 24 * 60 * 60 * 1000; + +const WORKER_KEY = "tech-step-llm-worker"; + +function nowIso(): string { + return new Date().toISOString(); +} + +function roundMs(value: number): number { + return Math.round(value * 10) / 10; +} + +function errMessage(err: unknown): string { + return err instanceof Error ? err.message : String(err); +} + +/** `12500` → `"il y a 12 s"`, `90000` → `"il y a 1 min"`, `172800000` → `"il y a 2 j"`. */ +function formatAgo(ms: number): string { + const s = Math.round(ms / 1000); + if (s < 60) return `il y a ${s} s`; + const m = Math.round(s / 60); + if (m < 60) return `il y a ${m} min`; + const h = Math.round(m / 60); + if (h < 48) return `il y a ${h} h`; + return `il y a ${Math.round(h / 24)} j`; +} + +/** `process.uptime()` seconds → `"3 h 12 min"` / `"5 min"` / `"42 s"`. */ +function formatUptime(seconds: number): string { + const s = Math.floor(seconds); + if (s < 60) return `${s} s`; + const m = Math.floor(s / 60); + if (m < 60) return `${m} min`; + const h = Math.floor(m / 60); + return `${h} h ${m % 60} min`; +} + +/** `prisma.$queryRaw\`SELECT 1\`` with a bounded timeout — the DB connectivity probe. */ +async function probePostgres(): Promise { + 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 { + 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 { + 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 { + 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 + } +} diff --git a/apps/api/src/modules/admin/admin.routes.ts b/apps/api/src/modules/admin/admin.routes.ts index 75ffe8e..3d69e58 100644 --- a/apps/api/src/modules/admin/admin.routes.ts +++ b/apps/api/src/modules/admin/admin.routes.ts @@ -1,6 +1,7 @@ import { Router } from "express"; import { adminAuthRouter } from "./admin-auth.routes.js"; import { adminMetricsRouter } from "./admin-metrics.routes.js"; +import { adminMonitoringRouter } from "./admin-monitoring.routes.js"; /** * Aggregator for the admin application's API surface, mounted at `/admin` @@ -13,3 +14,4 @@ export const adminRouter = Router(); adminRouter.use("/auth", adminAuthRouter); adminRouter.use("/metrics", adminMetricsRouter); +adminRouter.use("/monitoring", adminMonitoringRouter); diff --git a/apps/api/src/modules/internal/tech-step-worker.routes.ts b/apps/api/src/modules/internal/tech-step-worker.routes.ts index 748cd9a..f309096 100644 --- a/apps/api/src/modules/internal/tech-step-worker.routes.ts +++ b/apps/api/src/modules/internal/tech-step-worker.routes.ts @@ -3,12 +3,14 @@ import { auditBatchQuerySchema, submitTrainingSuggestionsSchema, workerBatchQuerySchema, + workerHeartbeatSchema, } from "@batch-cooking/shared"; import { Router } from "express"; import { requireInternalWorker } from "../../middlewares/require-internal-worker.js"; import { getAuditBatch, getPendingCorrections, + recordWorkerHeartbeat, submitTrainingSuggestions, } from "./tech-step-worker.service.js"; @@ -47,3 +49,19 @@ techStepWorkerRouter.post( res.status(201).json(await submitTrainingSuggestions(input)); }), ); + +/** + * Liveness ping from the worker (which has no inbound HTTP surface of its + * own) — upserts its `WorkerHeartbeat` row so the admin monitoring board + * can show it as up / stale / down and surface its last job result. Sent + * on boot, on every scheduler tick, and after each job. + */ +techStepWorkerRouter.post( + "/heartbeat", + requireInternalWorker, + wrapAsyncHandler(async (req, res) => { + const input = workerHeartbeatSchema.parse(req.body); + await recordWorkerHeartbeat(input); + res.status(200).json({ ok: true }); + }), +); diff --git a/apps/api/src/modules/internal/tech-step-worker.service.ts b/apps/api/src/modules/internal/tech-step-worker.service.ts index 607c0f1..7b58541 100644 --- a/apps/api/src/modules/internal/tech-step-worker.service.ts +++ b/apps/api/src/modules/internal/tech-step-worker.service.ts @@ -4,7 +4,9 @@ import { type PendingTechStepCorrectionView, type SubmitTrainingSuggestionsInput, type TechStepAuditClauseView, + type WorkerHeartbeatInput, } from "@batch-cooking/shared"; +import type { Prisma } from "@prisma/client"; import { prisma } from "../../db/prisma.js"; import { CONFIDENCE_THRESHOLD, @@ -199,3 +201,44 @@ export async function submitTrainingSuggestions( throw err; // see recipe.service.ts's equivalent catch comment } } + +/** + * The one worker with a `WorkerHeartbeat` row today — a fixed key, not + * something the caller supplies (only one worker exists, and letting it + * name itself would just be a spoofing surface behind the same shared + * secret). + */ +const WORKER_KEY = "tech-step-llm-worker"; + +/** + * Upserts `services/tech-step-llm-worker`'s heartbeat row (see + * `POST /internal/tech-steps/heartbeat`). Every ping bumps `lastSeenAt`; a + * `"job"` ping also records `lastRunAt` + a small `lastResult` summary so + * the admin monitoring board can show what the worker last did and whether + * it worked. + */ +export async function recordWorkerHeartbeat(input: WorkerHeartbeatInput): Promise { + 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 + } +} diff --git a/apps/api/test/admin-monitoring.test.ts b/apps/api/test/admin-monitoring.test.ts new file mode 100644 index 0000000..fcc8e8c --- /dev/null +++ b/apps/api/test/admin-monitoring.test.ts @@ -0,0 +1,160 @@ +import { faker } from "@faker-js/faker"; +import { expect } from "chai"; +import request from "supertest"; +import { createApp } from "../src/app.js"; +import { env } from "../src/config/env.js"; +import { prisma } from "../src/db/prisma.js"; +import { hashAdminPassword } from "../src/modules/admin/admin-auth.service.js"; +import { resetDatabase } from "../test-support/reset-db.js"; + +const SECRET_HEADER = "X-Internal-Worker-Secret"; +const VALID_STATUSES = ["up", "degraded", "down", "unknown"]; + +async function seedAdmin(): Promise<{ email: string; password: string }> { + const email = faker.internet.email().toLowerCase(); + const password = faker.internet.password({ length: 16 }); + await prisma.adminUser.create({ + data: { email, name: faker.person.fullName(), passwordHash: await hashAdminPassword(password) }, + }); + return { email, password }; +} + +const adminSecretConfigured = env.ADMIN_JWT_SECRET !== undefined; +const workerSecretConfigured = env.INTERNAL_WORKER_SECRET !== undefined; + +describe("Admin monitoring", () => { + const app = createApp(); + + beforeEach(async () => { + await resetDatabase(); + }); + + after(async () => { + await prisma.$disconnect(); + }); + + describe("POST /internal/tech-steps/heartbeat", () => { + it("rejects a request with no worker secret with 401", async () => { + const res = await request(app).post("/internal/tech-steps/heartbeat").send({ event: "boot" }); + expect(res.status).to.equal(401); + }); + + it("rejects a malformed body with 400", async function () { + if (!workerSecretConfigured) { + // biome-ignore lint/suspicious/noExplicitAny: mocha's `this.skip()` isn't typed here. + (this as any).skip(); + return; + } + const res = await request(app) + .post("/internal/tech-steps/heartbeat") + .set(SECRET_HEADER, env.INTERNAL_WORKER_SECRET as string) + .send({ event: "not-a-real-event" }); + expect(res.status).to.equal(400); + }); + + it("upserts the worker heartbeat, recording lastRunAt/lastResult for a job ping", async function () { + if (!workerSecretConfigured) { + // biome-ignore lint/suspicious/noExplicitAny: see above. + (this as any).skip(); + return; + } + const res = await request(app) + .post("/internal/tech-steps/heartbeat") + .set(SECRET_HEADER, env.INTERNAL_WORKER_SECRET as string) + .send({ + event: "job", + job: "audit-low-confidence", + ok: true, + counts: { suggestions: 3 }, + }); + expect(res.status).to.equal(200); + expect(res.body).to.deep.equal({ ok: true }); + + const stored = await prisma.workerHeartbeat.findUniqueOrThrow({ + where: { workerKey: "tech-step-llm-worker" }, + }); + expect(stored.lastRunAt).to.not.equal(null); + expect(stored.lastResult).to.deep.equal({ + job: "audit-low-confidence", + ok: true, + counts: { suggestions: 3 }, + }); + }); + + it("leaves lastRunAt null for a boot/tick ping", async function () { + if (!workerSecretConfigured) { + // biome-ignore lint/suspicious/noExplicitAny: see above. + (this as any).skip(); + return; + } + await request(app) + .post("/internal/tech-steps/heartbeat") + .set(SECRET_HEADER, env.INTERNAL_WORKER_SECRET as string) + .send({ event: "boot" }); + + const stored = await prisma.workerHeartbeat.findUniqueOrThrow({ + where: { workerKey: "tech-step-llm-worker" }, + }); + expect(stored.lastSeenAt).to.be.instanceOf(Date); + expect(stored.lastRunAt).to.equal(null); + }); + }); + + describe("GET /admin/monitoring", () => { + it("rejects a request with no admin session with 401", async () => { + const res = await request(app).get("/admin/monitoring"); + expect(res.status).to.equal(401); + }); + + it("returns a status board covering all four targets, never crashing on an unreachable probe", async function () { + if (!adminSecretConfigured) { + // biome-ignore lint/suspicious/noExplicitAny: see above. + (this as any).skip(); + return; + } + const { email, password } = await seedAdmin(); + const agent = request.agent(app); + await agent.post("/admin/auth/login").send({ email, password }); + + const res = await agent.get("/admin/monitoring"); + expect(res.status).to.equal(200); + + const keys = res.body.services.map((s: { key: string }) => s.key); + expect(keys).to.have.members(["postgres", "api", "intent-service", "tech-step-llm-worker"]); + for (const service of res.body.services) { + expect(VALID_STATUSES).to.include(service.status); + } + + const byKey = Object.fromEntries(res.body.services.map((s: { key: string }) => [s.key, s])); + // The DB is up during the test run, and the API is answering us. + expect(byKey.postgres.status).to.equal("up"); + expect(byKey.api.status).to.equal("up"); + // No heartbeat has ever been recorded (resetDatabase truncated it). + expect(byKey["tech-step-llm-worker"].status).to.equal("unknown"); + }); + + it("reports the worker as up once it has sent a recent heartbeat", async function () { + if (!adminSecretConfigured || !workerSecretConfigured) { + // biome-ignore lint/suspicious/noExplicitAny: see above. + (this as any).skip(); + return; + } + await request(app) + .post("/internal/tech-steps/heartbeat") + .set(SECRET_HEADER, env.INTERNAL_WORKER_SECRET as string) + .send({ event: "job", job: "transform-corrections", ok: true, counts: { suggestions: 0 } }); + + const { email, password } = await seedAdmin(); + const agent = request.agent(app); + await agent.post("/admin/auth/login").send({ email, password }); + + const res = await agent.get("/admin/monitoring"); + const worker = res.body.services.find( + (s: { key: string }) => s.key === "tech-step-llm-worker", + ); + expect(worker.status).to.equal("up"); + expect(worker.lastResult.job).to.equal("transform-corrections"); + expect(worker.lastRunAt).to.be.a("string"); + }); + }); +}); diff --git a/packages/shared/src/schemas/admin.ts b/packages/shared/src/schemas/admin.ts index 2b7fb28..6086d13 100644 --- a/packages/shared/src/schemas/admin.ts +++ b/packages/shared/src/schemas/admin.ts @@ -28,3 +28,22 @@ export const getMetricsSchema = z.object({ }); /** Inferred TS type for {@link getMetricsSchema}'s validated output. */ export type GetMetricsInput = z.infer; + +/** + * Payload of `POST /internal/tech-steps/heartbeat` — `services/tech-step-llm-worker` + * (which has no inbound HTTP of its own) reporting that it's alive. Sent on + * `boot`, on every scheduler `tick`, and after each `job` (with that job's + * name, outcome and counts). The worker key is fixed server-side (only one + * worker exists), so it isn't in the payload. + */ +export const workerHeartbeatSchema = z.object({ + event: z.enum(["boot", "tick", "job"]), + /** The job that just ran — present only when `event === "job"`. */ + job: z.string().max(100).optional(), + /** Whether that job succeeded — present only when `event === "job"`. */ + ok: z.boolean().optional(), + /** Small `{ label: number }` summary of that job (e.g. `{ suggestions: 3 }`). */ + counts: z.record(z.string(), z.number()).optional(), +}); +/** Inferred TS type for {@link workerHeartbeatSchema}'s validated output. */ +export type WorkerHeartbeatInput = z.infer; diff --git a/packages/shared/src/types/admin.ts b/packages/shared/src/types/admin.ts index 364139e..886ce1e 100644 --- a/packages/shared/src/types/admin.ts +++ b/packages/shared/src/types/admin.ts @@ -85,3 +85,34 @@ export interface MetricsView { }; events: MetricsEventSeries[]; } + +/** + * Health of one thing the admin monitoring board watches: + * - `"up"` — reachable and healthy. + * - `"degraded"` — reachable but not fully OK (e.g. the worker's last heartbeat + * is old, or a job it ran failed). + * - `"down"` — unreachable / erroring. + * - `"unknown"` — never observed (e.g. the worker has never sent a heartbeat). + */ +export type ServiceStatus = "up" | "degraded" | "down" | "unknown"; + +/** One row of `GET /admin/monitoring` — a single probed target. */ +export interface ServiceHealthView { + /** Stable id — `"postgres"`, `"api"`, `"intent-service"`, `"tech-step-llm-worker"`. Resolved to a label client-side via `admin.monitoring.service.`. */ + key: string; + status: ServiceStatus; + /** Round-trip of the probe in ms, or `null` when there was nothing to time (the worker, read from a stored heartbeat). */ + latencyMs: number | null; + /** Short human-readable extra (`"HTTP 200"`, `"uptime 3h 12m"`, `"dernier battement il y a 9 j"`). */ + detail: string | null; + checkedAt: string; + /** Worker only — when it last actually ran a job, and that job's summary. */ + lastRunAt?: string | null; + lastResult?: { job?: string; ok?: boolean; counts?: Record } | null; +} + +/** Response of `GET /admin/monitoring` — an actively-probed status board. */ +export interface MonitoringView { + generatedAt: string; + services: ServiceHealthView[]; +} diff --git a/services/tech-step-llm-worker/src/api-client.ts b/services/tech-step-llm-worker/src/api-client.ts index 2512fa5..80106f3 100644 --- a/services/tech-step-llm-worker/src/api-client.ts +++ b/services/tech-step-llm-worker/src/api-client.ts @@ -94,6 +94,35 @@ export function getPendingCorrections(limit: number): Promise; +} + +/** + * Best-effort liveness ping to `apps/api` so the admin monitoring board can + * see this worker (which has no inbound HTTP surface of its own). Sent on + * boot, on every scheduler tick, and after each job. **Never throws** — a + * failed heartbeat must never break or abort a run; it's logged and + * swallowed here. + */ +export async function postHeartbeat(payload: HeartbeatPayload): Promise { + try { + await request("/internal/tech-steps/heartbeat", { + method: "POST", + body: JSON.stringify(payload), + }); + } catch (err) { + console.warn( + "[tech-step-llm-worker] heartbeat failed:", + err instanceof Error ? err.message : err, + ); + } +} + /** Submits a batch of suggestions — a no-op (resolves immediately) if `suggestions` is empty, so a job with nothing to report doesn't need its own guard at every call site. */ export function postTrainingSuggestions( suggestions: TrainingSuggestionInput[], diff --git a/services/tech-step-llm-worker/src/index.ts b/services/tech-step-llm-worker/src/index.ts index 873f992..0eda857 100644 --- a/services/tech-step-llm-worker/src/index.ts +++ b/services/tech-step-llm-worker/src/index.ts @@ -1,6 +1,12 @@ +import { postHeartbeat } from "./api-client.js"; import { env } from "./config.js"; import { runOnce, startScheduler } from "./scheduler.js"; +// Tell `apps/api` we're alive as early as possible — before either the +// one-shot run or the cron loop — so the admin monitoring board reflects a +// fresh deploy immediately, not only after the first scheduled fire. +await postHeartbeat({ event: "boot" }); + /** * Entrypoint — `RUN_ONCE=true` runs both jobs a single time and exits * (manual/CI-triggered invocation, `pnpm start`), otherwise starts the diff --git a/services/tech-step-llm-worker/src/scheduler.ts b/services/tech-step-llm-worker/src/scheduler.ts index 6540601..3e882e1 100644 --- a/services/tech-step-llm-worker/src/scheduler.ts +++ b/services/tech-step-llm-worker/src/scheduler.ts @@ -1,4 +1,5 @@ import cron from "node-cron"; +import { postHeartbeat } from "./api-client.js"; import { env } from "./config.js"; import { runAuditLowConfidenceJob } from "./jobs/audit-low-confidence.js"; import { runTransformCorrectionsJob } from "./jobs/transform-corrections.js"; @@ -27,11 +28,23 @@ export async function runOnce(): Promise { limit: env.TECH_STEP_WORKER_BATCH_LIMIT, }); console.info(`[tech-step-llm-worker] audit-low-confidence: ${auditCount} suggestion(s)`); + await postHeartbeat({ + event: "job", + job: "audit-low-confidence", + ok: true, + counts: { suggestions: auditCount }, + }); const correctionCount = await runTransformCorrectionsJob(llm, { limit: env.TECH_STEP_WORKER_BATCH_LIMIT, }); console.info(`[tech-step-llm-worker] transform-corrections: ${correctionCount} suggestion(s)`); + await postHeartbeat({ + event: "job", + job: "transform-corrections", + ok: true, + counts: { suggestions: correctionCount }, + }); } finally { await llm.dispose(); } @@ -49,8 +62,11 @@ export async function runOnce(): Promise { export function startScheduler(): void { console.info(`[tech-step-llm-worker] scheduling runs on "${env.TECH_STEP_WORKER_CRON}"`); cron.schedule(env.TECH_STEP_WORKER_CRON, () => { + // Prove liveness even for a fire that then fails inside `runOnce`. + void postHeartbeat({ event: "tick" }); runOnce().catch((err: unknown) => { console.error("[tech-step-llm-worker] scheduled run failed:", err); + void postHeartbeat({ event: "job", ok: false }); }); }); } diff --git a/specs/backend-architecture.md b/specs/backend-architecture.md index 8195788..57a5e9b 100644 --- a/specs/backend-architecture.md +++ b/specs/backend-architecture.md @@ -387,8 +387,22 @@ est un `String` libre (`"user.signup"`, `"recipe.imported"`, `"recipe.created"`, ligne, **sans migration**. `AnalyticsEvent.actorId` n'a **pas** de FK (un évènement est un fait historique qui survit au compte qu'il décrit). -*(La table `WorkerHeartbeat` est créée par la même migration mais n'est -câblée que par le monitoring — section à venir.)* +**Monitoring** (`admin-monitoring.service.ts`, `GET /admin/monitoring`, +`requireAdmin`) — sonde active, chaque cible bornée à ~2 s, latence mesurée : +Postgres (`SELECT 1`), l'API elle-même (uptime/RSS), `tech-step-intent-service` +(`GET /health`, sans secret), et le **worker LLM** via sa ligne +`WorkerHeartbeat`. `MonitoringView { services: ServiceHealthView[] }`, statut +`up | degraded | down | unknown` ; une sonde `down` ne fait jamais échouer les +autres ni l'endpoint. Le front (`MonitoringPage`) re-poll toutes les 15 s. + +Le worker (`services/tech-step-llm-worker`) n'a **aucune** surface HTTP +entrante — il devient observable via `POST /internal/tech-steps/heartbeat` +(`requireInternalWorker`, `tech-step-worker.service.ts`'s +`recordWorkerHeartbeat` → upsert `WorkerHeartbeat`, clé fixe +`"tech-step-llm-worker"`). Le worker l'appelle au boot, à chaque tick du +scheduler, et après chaque job (avec `job`/`ok`/`counts`) — best-effort, un +heartbeat en échec ne casse jamais un run. Seuils d'âge : > 8 j ⇒ `degraded`, +> 21 j ⇒ `down` (cron par défaut hebdomadaire). --- -- 2.45.2 From fcaecb4e07918743720928da7ac3b51e9f50ead2 Mon Sep 17 00:00:00 2001 From: Nicolas Date: Fri, 28 Aug 2026 13:06:55 +0200 Subject: [PATCH 5/6] feat(admin): tri des corrections + declenchement du gate F1/backfill PR 5 (derniere) du chantier admin. Remplace le duo CLI list-pending-training-suggestions.ts / retrain-tech-steps.ts par une UI. API (admin-tech-steps.service.ts, routes /admin/tech-steps/*, requireAdmin) : - GET /suggestions : TechStepTrainingSuggestion filtrees, groupees par technique, enrichies du contexte de la correction source. - GET /corrections : corrections brutes filtrables, incluant les suppressions correctedTechStepId:null invisibles ailleurs. - PATCH /suggestions/:id : edite synonymes/phrases et/ou status. - GET /training-data-snippet : bloc training_data.py a coller (lecture seule). - POST /retrain : runTechStepEvalSuite() (gate F1 vs MIN_OVERALL_F1) puis si passe backfillTechSteps() + marquage applied/rejected. Verrou memoire -> 409 RETRAIN_ALREADY_RUNNING. Gate echoue -> 200 gatePassed:false. N'edite pas le .py ni ne redemarre l'intent-service (manuel). Shared : nouveau ErrorCode RETRAIN_ALREADY_RUNNING (4023, + cle i18n apps/web), schemas (list*/update*/retrain*/snippet), types (TrainingSuggestion*/Correction*/RetrainResultView...). Front : CorrectionsPage (onglets Suggestions / Corrections brutes, bandeau caveat permanent, cartes editables + Appliquer/Rejeter, panneau snippet, panneau gate F1). Logique pure corrections.ts. i18n admin.corrections.*. AdminApiClient : 5 methodes. Tests : Mocha admin-tech-steps.test.ts (401 partout, groupement+filtre, PATCH 400/404/ok, corrections incluant removals, snippet, retrain shape + 409 concurrent) ; Cypress corrections.cy.ts (4 verts). Admin-web Cypress 13/13. specs/backend-architecture.md : section tri + retrain. Co-Authored-By: Claude Sonnet 5 --- apps/admin-web/cypress/e2e/corrections.cy.ts | 159 ++++++++ apps/admin-web/src/api/client.ts | 61 +++ .../admin-web/src/locales/fr/translation.json | 43 +- .../src/pages/corrections/CorrectionsPage.tsx | 386 +++++++++++++++++- .../pages/corrections/corrections-page.scss | 266 ++++++++++++ .../src/pages/corrections/corrections.ts | 23 ++ .../modules/admin/admin-tech-steps.routes.ts | 80 ++++ .../modules/admin/admin-tech-steps.service.ts | 325 +++++++++++++++ apps/api/src/modules/admin/admin.routes.ts | 2 + apps/api/test/admin-tech-steps.test.ts | 298 ++++++++++++++ apps/web/src/locales/fr/translation.json | 3 +- packages/shared/src/errors/error-codes.ts | 2 + packages/shared/src/schemas/admin.ts | 57 +++ packages/shared/src/types/admin.ts | 80 ++++ specs/backend-architecture.md | 23 ++ 15 files changed, 1801 insertions(+), 7 deletions(-) create mode 100644 apps/admin-web/cypress/e2e/corrections.cy.ts create mode 100644 apps/admin-web/src/pages/corrections/corrections-page.scss create mode 100644 apps/admin-web/src/pages/corrections/corrections.ts create mode 100644 apps/api/src/modules/admin/admin-tech-steps.routes.ts create mode 100644 apps/api/src/modules/admin/admin-tech-steps.service.ts create mode 100644 apps/api/test/admin-tech-steps.test.ts diff --git a/apps/admin-web/cypress/e2e/corrections.cy.ts b/apps/admin-web/cypress/e2e/corrections.cy.ts new file mode 100644 index 0000000..e38cd98 --- /dev/null +++ b/apps/admin-web/cypress/e2e/corrections.cy.ts @@ -0,0 +1,159 @@ +// Mocks the admin API via cy.intercept — no live backend. + +const adminBody = { + id: 1, + email: "ops@example.com", + name: "Ops", + createdAt: "2026-08-01T00:00:00.000Z", + lastLoginAt: "2026-08-28T09:00:00.000Z", +}; + +function suggestionGroups() { + return [ + { + techStepKey: "simmer", + suggestions: [ + { + id: 11, + techStepKey: "simmer", + locale: "fr", + suggestedSynonyms: ["frémir"], + suggestedUtterances: ["laisser cuire tout doucement"], + sourceType: "correction", + status: "pending", + createdAt: "2026-08-20T00:00:00.000Z", + sourceCorrection: { + id: 5, + recipeId: 2, + stepId: 7, + clauseText: "faire mijoter la sauce", + previousTechStepKey: "cook", + correctedTechStepKey: "simmer", + }, + }, + ], + }, + ]; +} + +function corrections() { + return [ + { + id: 5, + recipeId: 2, + stepId: 7, + stepDescription: "Faire mijoter la sauce 20 min.", + clauseText: "faire mijoter la sauce", + start: 0, + end: 21, + previousTechStepKey: "cook", + correctedTechStepKey: "simmer", + createdAt: "2026-08-20T00:00:00.000Z", + consumedAt: null, + }, + { + id: 6, + recipeId: 3, + stepId: 9, + stepDescription: "Réserver au frais.", + clauseText: "Réserver au frais", + start: 0, + end: 17, + previousTechStepKey: "setAside", + correctedTechStepKey: null, + createdAt: "2026-08-19T00:00:00.000Z", + consumedAt: null, + }, + ]; +} + +describe("Admin corrections triage", () => { + beforeEach(() => { + cy.viewport(1400, 1000); + cy.intercept("GET", "**/admin/auth/me", { statusCode: 200, body: adminBody }); + cy.intercept("GET", "**/admin/tech-steps/suggestions*", { + statusCode: 200, + body: suggestionGroups(), + }).as("getSuggestions"); + cy.intercept("GET", "**/admin/tech-steps/corrections*", { + statusCode: 200, + body: corrections(), + }).as("getCorrections"); + }); + + it("shows the caveat, groups suggestions by technique, and applies one", () => { + cy.intercept("PATCH", "**/admin/tech-steps/suggestions/11", { + statusCode: 200, + body: { ...suggestionGroups()[0].suggestions[0], status: "applied" }, + }).as("patch"); + + cy.visit("/corrections"); + cy.wait("@getSuggestions"); + + cy.contains(".corrections-caveat", "training_data.py").should("be.visible"); + cy.contains(".suggestion-group h2", "simmer").should("be.visible"); + cy.contains(".suggestion-card", "faire mijoter la sauce").should( + "contain.text", + "cook → simmer", + ); + + cy.contains(".suggestion-card button", "Appliquer").click(); + cy.wait("@patch").its("request.body").should("deep.equal", { status: "applied" }); + }); + + it("generates a training_data.py snippet", () => { + cy.intercept("GET", "**/admin/tech-steps/training-data-snippet*", { + statusCode: 200, + body: { + techStepKey: "simmer", + locale: "fr", + status: "applied", + suggestionCount: 2, + synonyms: ["frémir", "réduire"], + utterances: [], + snippet: + '# simmer (fr) — 2 suggestion(s) "applied"\n"synonyms": [\n "frémir",\n "réduire",\n],', + }, + }).as("getSnippet"); + + cy.visit("/corrections"); + cy.get(".corrections-panel input").type("simmer"); + cy.contains(".corrections-panel button", "Générer").click(); + cy.wait("@getSnippet"); + cy.get(".corrections-snippet").should("contain.value", '"synonyms": ['); + }); + + it("runs the F1 gate and shows the result", () => { + cy.intercept("POST", "**/admin/tech-steps/retrain", { + statusCode: 200, + body: { + f1: 0.83, + precision: 0.8, + recall: 0.86, + minF1: 0.8, + gatePassed: true, + backfilled: { total: 120, changed: 4 }, + marked: { applied: 0, rejected: 0 }, + }, + }).as("retrain"); + + cy.visit("/corrections"); + cy.contains(".corrections-panel--retrain button", "Lancer").click(); + cy.wait("@retrain"); + cy.contains(".retrain-result", "F1 0.830") + .should("have.class", "retrain-result--ok") + .and("contain.text", "4/120"); + }); + + it("lists raw corrections including the removals, on the second tab", () => { + cy.visit("/corrections"); + cy.contains(".corrections-tabs button", "Corrections brutes").click(); + cy.wait("@getCorrections"); + + cy.get(".corrections-table tbody tr").should("have.length", 2); + cy.contains(".corrections-table tr", "Réserver au frais").should( + "contain.text", + "setAside → ∅", + ); + }); +}); diff --git a/apps/admin-web/src/api/client.ts b/apps/admin-web/src/api/client.ts index 288fc68..b81b7a2 100644 --- a/apps/admin-web/src/api/client.ts +++ b/apps/admin-web/src/api/client.ts @@ -2,11 +2,26 @@ import { type AdminLoginInput, type AdminUserView, type ApiErrorResponse, + type CorrectionAdminView, ErrorCode, type MetricsView, type MonitoringView, + 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 { + const entries = Object.entries(params).filter( + (entry): entry is [string, string] => entry[1] !== undefined && entry[1] !== "", + ); + return entries.length === 0 ? "" : `?${new URLSearchParams(entries).toString()}`; +} + /** * Base URL of the admin API surface, configurable via `VITE_ADMIN_API_URL` * (see `.env.example`). Defaults to `""` (same origin) — correct behind a @@ -102,6 +117,52 @@ export class AdminApiClient { public getMonitoring(): Promise { 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 { + 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 { + 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 { + 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 { + 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 { + return this._request("/admin/tech-steps/retrain", { + method: "POST", + body: JSON.stringify(body), + }); + } } /** Single shared instance — stateless, same reasoning as apps/web's `apiClient`. */ diff --git a/apps/admin-web/src/locales/fr/translation.json b/apps/admin-web/src/locales/fr/translation.json index 11bc033..bcf8f94 100644 --- a/apps/admin-web/src/locales/fr/translation.json +++ b/apps/admin-web/src/locales/fr/translation.json @@ -5,6 +5,7 @@ "NOT_AUTHENTICATED": "Vous devez être connecté", "NOT_FOUND": "Ressource introuvable", "TECH_STEP_NOT_FOUND": "Cette technique n'existe pas", + "RETRAIN_ALREADY_RUNNING": "Un ré-entraînement est déjà en cours", "INTERNAL_ERROR": "Une erreur est survenue, réessayez plus tard" }, "admin": { @@ -81,7 +82,47 @@ }, "corrections": { "title": "Corrections", - "lead": "Tri des corrections utilisateur pour le ré-entraînement NLP." + "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" + } } } } diff --git a/apps/admin-web/src/pages/corrections/CorrectionsPage.tsx b/apps/admin-web/src/pages/corrections/CorrectionsPage.tsx index a197e9d..02c8c59 100644 --- a/apps/admin-web/src/pages/corrections/CorrectionsPage.tsx +++ b/apps/admin-web/src/pages/corrections/CorrectionsPage.tsx @@ -1,19 +1,395 @@ +import { + type CorrectionAdminView, + ErrorCode, + type RetrainResultView, + type TrainingSuggestionAdminView, + type TrainingSuggestionGroupView, +} from "@batch-cooking/shared"; +import { useCallback, useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; +import { ApiError, adminApiClient } from "../../api/client"; +import { errorMessageService } from "../../services/error-message.service"; import "../admin-page.scss"; +import "./corrections-page.scss"; +import { linesToList, listsDiffer, listToLines } from "./corrections"; + +type Tab = "suggestions" | "corrections"; /** - * Tech-step correction triage — review `TechStepTrainingSuggestion` / - * `StepTechStepCorrection`, mark applied/rejected, generate the - * `training_data.py` snippet, and trigger the F1 gate + backfill. Placeholder - * until PR 5 (correction triage + retrain). + * 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("suggestions"); + return (

{t("admin.corrections.title")}

{t("admin.corrections.lead")}

-

{t("admin.common.comingSoon")}

+ +

{t("admin.corrections.caveat")}

+ +
+ + +
+ + {tab === "suggestions" ? : } +
+ ); +} + +// --- 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({ 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 ( +
+ + + +
+ + +
+ + {state.status === "loading" && ( +

{t("admin.common.loading")}

+ )} + {state.status === "error" && ( +

{t("admin.common.loadError")}

+ )} + {state.status === "loaded" && state.groups.length === 0 && ( +

{t("admin.corrections.noSuggestions")}

+ )} + {state.status === "loaded" && + state.groups.map((group) => ( +
+

{group.techStepKey}

+ {group.suggestions.map((suggestion) => ( + + ))} +
+ ))} +
+ ); +} + +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(null); + + const dirty = + listsDiffer(linesToList(synonyms), suggestion.suggestedSynonyms) || + listsDiffer(linesToList(utterances), suggestion.suggestedUtterances); + + async function patch(body: Parameters[1]) { + setBusy(true); + setError(null); + try { + await adminApiClient.updateSuggestion(suggestion.id, body); + onMutated(); + } catch (err) { + setError( + errorMessageService.getLabel(err instanceof ApiError ? err.code : ErrorCode.INTERNAL_ERROR), + ); + } finally { + setBusy(false); + } + } + + return ( +
+
+ + #{suggestion.id} · {suggestion.locale} · {suggestion.sourceType} ·{" "} + {suggestion.status} + +
+ + {suggestion.sourceCorrection && ( +

+ + « {suggestion.sourceCorrection.clauseText} » + {" "} + {suggestion.sourceCorrection.previousTechStepKey ?? "∅"} →{" "} + {suggestion.sourceCorrection.correctedTechStepKey ?? "∅"} +

+ )} + +