Compare commits

...

4 commits

Author SHA1 Message Date
57cb2b7b3d feat(admin): monitoring des microservices + heartbeat du worker LLM
Some checks failed
CI / test (push) Failing after 2m1s
CI / lint (push) Successful in 3m36s
CI / build (push) Successful in 2m22s
CI / e2e (push) Successful in 11m29s
CI / intent-service-test (push) Successful in 19m39s
PR 4 du chantier admin. Board de sante temps reel des dependances.

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

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-28 12:55:47 +02:00
b08081e48d feat(admin): metriques d'utilisation (derive DB + AnalyticsEvent)
Some checks failed
CI / lint (push) Failing after 36s
CI / build (push) Successful in 3m41s
CI / e2e (push) Successful in 11m54s
CI / intent-service-test (push) Successful in 19m33s
CI / test (push) Failing after 22m32s
PR 3 du chantier admin. Tableau de bord metriques : snapshot de compteurs
+ series temporelles journalieres.

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

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

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

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

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

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-28 12:46:54 +02:00
8a6143d54d feat(admin): scaffold de l'application d'administration apps/admin-web
Some checks failed
CI / test (push) Has been cancelled
CI / e2e (push) Has been cancelled
CI / intent-service-test (push) Has been cancelled
CI / lint (push) Has been cancelled
CI / build (push) Has been cancelled
Nouvelle app Vite/React independante (workspace apps/*), calquee sur
apps/web : port 5174, sa propre image Docker (nginx statique), son propre
domaine/deploiement.

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

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-28 12:14:50 +02:00
cf8ef26f63 feat(admin): fondation auth de l'application d'administration
All checks were successful
CI / lint (push) Successful in 1m4s
CI / intent-service-test (push) Successful in 10m52s
CI / build (push) Successful in 1m6s
CI / e2e (push) Successful in 5m36s
CI / test (push) Successful in 19m57s
Premiere brique de l'app d'admin independante : une surface /admin/*
ajoutee a apps/api, avec une authentification totalement distincte de
celle des utilisateurs.

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

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

View file

@ -15,6 +15,27 @@ JWT_SECRET=changeme-generate-a-real-random-secret-at-least-32-chars
# (docker-compose.yml), serving both the API and the built frontend. # (docker-compose.yml), serving both the API and the built frontend.
# APP_PORT=3000 # 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 # 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 # plain HTTP (no TLS in front of it). Left unset, the session cookie
# requires HTTPS (Secure attribute) as it should for a real deployment; # requires HTTPS (Secure attribute) as it should for a real deployment;

View file

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

3
.gitignore vendored
View file

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

View file

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

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

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,24 @@
import { Given } from "@badeball/cypress-cucumber-preprocessor";
const adminBody = {
id: 1,
email: "ops@example.com",
name: "Ops",
createdAt: "2026-08-01T00:00:00.000Z",
lastLoginAt: "2026-08-28T09:00:00.000Z",
};
Given("admin login fails with invalid credentials", () => {
cy.intercept("POST", "**/admin/auth/login", {
statusCode: 401,
body: { code: 4010, message: "Invalid email or password" },
});
});
Given("admin login succeeds as {string}", (name: string) => {
const body = { ...adminBody, name, email: `${name.toLowerCase()}@example.com` };
cy.intercept("POST", "**/admin/auth/login", { statusCode: 200, body });
// After navigate("/"), RequireAdmin re-checks the session — from now on it
// must report authenticated (last matching intercept wins).
cy.intercept("GET", "**/admin/auth/me", { statusCode: 200, body });
});

View file

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

View file

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

View file

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

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

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

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

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

View file

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

View file

@ -0,0 +1,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 (
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route
element={
<RequireAdmin>
<AdminLayout />
</RequireAdmin>
}
>
<Route path="/" element={<DashboardPage />} />
<Route path="/monitoring" element={<MonitoringPage />} />
<Route path="/corrections" element={<CorrectionsPage />} />
</Route>
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
);
}

View file

@ -0,0 +1,108 @@
import {
type AdminLoginInput,
type AdminUserView,
type ApiErrorResponse,
ErrorCode,
type MetricsView,
type MonitoringView,
} 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<string, string[] | undefined>;
public constructor(status: number, body: ApiErrorResponse) {
super(body.message);
this.name = "ApiError";
this.status = status;
this.code = body.code;
this.fieldErrors = body.details;
}
}
/**
* Thin fetch wrapper around the `/admin/*` endpoints same design as
* apps/web's `ApiClient` (a class for cohesion/extensibility, one shared
* stateless instance). Every request sends credentials so the
* `admin_session` httpOnly cookie round-trips.
*/
export class AdminApiClient {
/**
* Performs a JSON request against the admin API and returns the parsed body.
*
* @throws {ApiError} if the response status is not in the 2xx range.
*/
private async _request<TResponseBody>(
path: string,
options: RequestInit = {},
): Promise<TResponseBody> {
try {
const response = await fetch(`${ADMIN_API_BASE_URL}${path}`, {
...options,
credentials: "include",
headers: { "Content-Type": "application/json", ...options.headers },
});
if (!response.ok) {
const body = (await response.json().catch(() => null)) as ApiErrorResponse | null;
throw new ApiError(
response.status,
body ?? { code: ErrorCode.INTERNAL_ERROR, message: "Something went wrong" },
);
}
if (response.status === 204) {
return undefined as TResponseBody;
}
return (await response.json()) as TResponseBody;
} catch (err) {
// Rethrown as-is — callers surface it their own way; this is just the
// one place the fetch/`await` sits in a try/catch per the repo's rule.
throw err;
}
}
/** Verifies admin credentials and starts an admin session. */
public login(input: AdminLoginInput): Promise<AdminUserView> {
return this._request("/admin/auth/login", { method: "POST", body: JSON.stringify(input) });
}
/** Ends the current admin session. */
public logout(): Promise<void> {
return this._request("/admin/auth/logout", { method: "POST" });
}
/** Fetches the currently authenticated admin — rejects with `NOT_AUTHENTICATED` if there's no session. */
public me(): Promise<AdminUserView> {
return this._request("/admin/auth/me");
}
/** Usage metrics for the dashboard — snapshot totals + `days` (7365) of daily time series. */
public getMetrics(days: number): Promise<MetricsView> {
return this._request(`/admin/metrics?days=${days}`);
}
/** Live health of Postgres, the API, the intent-service and the LLM worker — polled by the monitoring board. */
public getMonitoring(): Promise<MonitoringView> {
return this._request("/admin/monitoring");
}
}
/** Single shared instance — stateless, same reasoning as apps/web's `apiClient`. */
export const adminApiClient = new AdminApiClient();

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,87 @@
{
"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.",
"loading": "Chargement…",
"loadError": "Impossible de charger les données, réessayez plus tard."
},
"login": {
"title": "Administration",
"emailLabel": "Email",
"passwordLabel": "Mot de passe",
"submit": "Se connecter",
"submitting": "Connexion…"
},
"nav": {
"dashboard": "Tableau de bord",
"monitoring": "Monitoring",
"corrections": "Corrections"
},
"layout": {
"logout": "Se déconnecter"
},
"dashboard": {
"title": "Tableau de bord",
"lead": "Métriques d'utilisation de l'application.",
"windowTotal": "{{n}} sur 30 j",
"recipesBySource": "Recettes importées par source",
"noImports": "Aucune recette importée.",
"events": "Évènements enregistrés (30 j)",
"kpi": {
"users": "Utilisateurs",
"households": "Foyers",
"activeHouseholds": "Foyers actifs",
"recipes": "Recettes",
"recipesImported": "Recettes importées",
"plannings": "Plannings",
"planningItems": "Créneaux planifiés",
"favorites": "Favoris",
"corrections": "Corrections",
"correctionsUnconsumed": "Corrections à traiter",
"trainingSuggestions": "Suggestions d'entraînement",
"admins": "Administrateurs"
},
"series": {
"signups": "Inscriptions",
"recipesCreated": "Recettes créées",
"planningItemsAdded": "Ajouts au planning",
"correctionsSubmitted": "Corrections soumises",
"trainingSuggestions": "Suggestions générées"
}
},
"monitoring": {
"title": "Monitoring",
"lead": "Santé des microservices et de la base de données.",
"lastChecked": "Dernière vérification à {{time}}",
"latency": "Latence",
"detail": "Détail",
"lastRun": "Dernier job",
"never": "jamais",
"jobFailed": "échec",
"status": {
"up": "OK",
"degraded": "Dégradé",
"down": "Hors service",
"unknown": "Inconnu"
},
"service": {
"postgres": "Base de données",
"api": "API",
"intent-service": "Service NLP (spaCy)",
"tech-step-llm-worker": "Worker LLM"
}
},
"corrections": {
"title": "Corrections",
"lead": "Tri des corrections utilisateur pour le ré-entraînement NLP."
}
}
}

View file

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

View file

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

View file

@ -0,0 +1,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 (
<div className="admin-page">
<h1 className="admin-page__title">{t("admin.corrections.title")}</h1>
<p className="admin-page__lead">{t("admin.corrections.lead")}</p>
<p className="admin-page__placeholder">{t("admin.common.comingSoon")}</p>
</div>
);
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -103,6 +103,10 @@ model UserProfile {
tokenVersion Int @default(0) @map("token_version") tokenVersion Int @default(0) @map("token_version")
houseId Int? @map("house_id") houseId Int? @map("house_id")
dietId Int? @map("diet_id") dietId Int? @map("diet_id")
/// See `Planning.createdAt` — same admin-metrics-only timestamp, added by
/// the `admin_metrics` migration for the dashboard's signup curve. No
/// application code reads it (auth doesn't need it).
createdAt DateTime @default(now()) @map("created_at")
house House? @relation("HouseMember", fields: [houseId], references: [id], onDelete: SetNull) house House? @relation("HouseMember", fields: [houseId], references: [id], onDelete: SetNull)
diet Diet? @relation(fields: [dietId], references: [id], onDelete: SetNull) diet Diet? @relation(fields: [dietId], references: [id], onDelete: SetNull)
@ -192,6 +196,12 @@ model Planning {
startDate DateTime @map("start_date") @db.Date startDate DateTime @map("start_date") @db.Date
finishDate DateTime @map("finish_date") @db.Date finishDate DateTime @map("finish_date") @db.Date
houseId Int @map("house_id") houseId Int @map("house_id")
/// When this planning row was first created. Added by the `admin_metrics`
/// migration purely for the admin dashboard's activity curves — no
/// application code reads it. Rows that predate the migration all get the
/// migration's own timestamp (same acceptable one-off skew as the
/// `ingredient_unit_catalog` migration), which is fine for a trend chart.
createdAt DateTime @default(now()) @map("created_at")
house House @relation(fields: [houseId], references: [id], onDelete: Cascade) house House @relation(fields: [houseId], references: [id], onDelete: Cascade)
items PlanningItem[] items PlanningItem[]
@ -206,6 +216,8 @@ model PlanningItem {
meal String meal String
recipeId Int @map("recipe_id") recipeId Int @map("recipe_id")
portions Int portions Int
/// See `Planning.createdAt` — same admin-metrics-only timestamp.
createdAt DateTime @default(now()) @map("created_at")
planning Planning @relation(fields: [planningId], references: [id], onDelete: Cascade) planning Planning @relation(fields: [planningId], references: [id], onDelete: Cascade)
recipe Recipe @relation(fields: [recipeId], references: [id]) recipe Recipe @relation(fields: [recipeId], references: [id])
@ -319,6 +331,10 @@ model Recipe {
/// the author had no household yet. /// the author had no household yet.
authorHouseId Int? @map("author_house_id") authorHouseId Int? @map("author_house_id")
visibility RecipeVisibility @default(PERSONAL) visibility RecipeVisibility @default(PERSONAL)
/// See `Planning.createdAt` — same admin-metrics-only timestamp, added by
/// the `admin_metrics` migration for the dashboard's "recipes created"
/// curve. No application code reads it.
createdAt DateTime @default(now()) @map("created_at")
author UserProfile @relation(fields: [authorId], references: [id]) author UserProfile @relation(fields: [authorId], references: [id])
authorHouse House? @relation(fields: [authorHouseId], references: [id], onDelete: SetNull) authorHouse House? @relation(fields: [authorHouseId], references: [id], onDelete: SetNull)
@ -897,3 +913,81 @@ model TechStepTrainingSuggestion {
@@map("tech_step_training_suggestion") @@map("tech_step_training_suggestion")
} }
// -----------------------------------------------------------------------------
// Admin application
// A separate operations app (usage metrics, microservice monitoring, tech-step
// correction triage) — see specs/backend-architecture.md's "admin" section.
// -----------------------------------------------------------------------------
/// An operator of the admin application (`apps/admin-web` / the `/admin/*`
/// API surface). Deliberately its **own** table with **no relation** to
/// `UserProfile`: admin access is a completely separate concern from being
/// an end user of the recipe app — a person can be one, both or neither,
/// and the two auth mechanisms (`requireAdmin` vs `requireAuth`, distinct
/// cookies, distinct JWT secrets) never overlap. No self-service signup —
/// the first row is created out-of-band by `src/scripts/create-admin.ts`,
/// and (for now) there's no in-app admin-management UI.
model AdminUser {
id Int @id @default(autoincrement())
email String @unique
/// argon2 hash of the password — same hashing as `UserProfile.passwordHash`
/// (`auth.service.ts`'s `hashOptions`, cheaper cost under NODE_ENV=test).
passwordHash String @map("password_hash")
/// Display name shown in the admin UI's account menu.
name String
/// Bumped to invalidate previously-issued admin JWTs — same mechanism as
/// `UserProfile.tokenVersion`, checked on every request by `requireAdmin`.
tokenVersion Int @default(0) @map("token_version")
createdAt DateTime @default(now()) @map("created_at")
/// Stamped on every successful login — a cheap "is this account still in
/// use" signal for the operator managing admins by hand.
lastLoginAt DateTime? @map("last_login_at")
@@map("admin_users")
}
/// One recorded product event, for the admin dashboard's usage metrics.
/// Written fire-and-forget by `lib/analytics.service.ts`'s `recordEvent`
/// from a handful of key service methods (signup, recipe import/create,
/// planning add, cooking-session open, tech-step correction, shopping-list
/// view) — never on the request's critical path, so a failed insert is
/// logged and swallowed, never surfaced to the user.
///
/// `type` is a free `String` (`"user.signup"`, `"recipe.imported"`…), not
/// an enum: adding a new event to instrument is a one-line call site
/// change with **no migration**. `actorId` is a `UserProfile.id` when
/// `actorType == "user"` but carries **no FK** — an event is an immutable
/// historical fact that must outlive the account it describes (a deleted
/// user's signup still counts on the curve). `context` is a small free
/// JSON blob (`{ sourceKey, recipeId, … }`) for slicing later; nothing
/// queries into it today.
model AnalyticsEvent {
id Int @id @default(autoincrement())
type String
actorType String @map("actor_type")
actorId Int? @map("actor_id")
context Json?
createdAt DateTime @default(now()) @map("created_at")
@@index([type, createdAt])
@@map("analytics_events")
}
/// Liveness/last-run record for a background worker that has no inbound
/// HTTP surface of its own — one row per worker (`workerKey`, today only
/// `"tech-step-llm-worker"`). The worker POSTs `/internal/tech-steps/heartbeat`
/// (`requireInternalWorker`) on boot, on every scheduler tick, and after
/// each job; the admin monitoring board reads this to show the worker as
/// up / stale / down and to surface its last job result. Upserted, never
/// accumulated — only the latest state matters.
model WorkerHeartbeat {
workerKey String @id @map("worker_key")
lastSeenAt DateTime @map("last_seen_at")
/// Set only by a `"job"` heartbeat — the last time the worker actually ran a job (vs. just a tick proving it's alive).
lastRunAt DateTime? @map("last_run_at")
/// Small JSON summary of that last job (`{ job, ok, counts }`).
lastResult Json? @map("last_result")
@@map("worker_heartbeats")
}

View file

@ -5,6 +5,7 @@ import type { Express, Request, Response } from "express";
import { env } from "./config/env.js"; import { env } from "./config/env.js";
import { errorLogger } from "./middlewares/error-logger.js"; import { errorLogger } from "./middlewares/error-logger.js";
import { requestLogger } from "./middlewares/request-logger.js"; import { requestLogger } from "./middlewares/request-logger.js";
import { adminRouter } from "./modules/admin/admin.routes.js";
import { authRouter } from "./modules/auth/auth.routes.js"; import { authRouter } from "./modules/auth/auth.routes.js";
import { houseRouter } from "./modules/house/house.routes.js"; import { houseRouter } from "./modules/house/house.routes.js";
import { techStepWorkerRouter } from "./modules/internal/tech-step-worker.routes.js"; import { techStepWorkerRouter } from "./modules/internal/tech-step-worker.routes.js";
@ -32,13 +33,19 @@ export function createServer(): ExpressServer {
// pipeline (its "finish" listener still fires for a request that never // pipeline (its "finish" listener still fires for a request that never
// makes it past CORS/body-parsing, not just ones that reach a route). // makes it past CORS/body-parsing, not just ones that reach a route).
server.addMiddleware(requestLogger); server.addMiddleware(requestLogger);
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) => { server.addRoute("get", "/health", (_req: Request, res: Response) => {
res.status(200).json({ status: "ok" }); res.status(200).json({ status: "ok" });
}); });
server.mountRouter("/auth", authRouter); server.mountRouter("/auth", authRouter);
// Admin application surface (`apps/admin-web`) — its own auth
// (`requireAdmin`, distinct cookie/secret), never the end-user session.
server.mountRouter("/admin", adminRouter);
server.mountRouter("/house", houseRouter); server.mountRouter("/house", houseRouter);
// Not user-facing — `services/tech-step-llm-worker` only, guarded by // Not user-facing — `services/tech-step-llm-worker` only, guarded by
// `requireInternalWorker` on every route within (see that router's own // `requireInternalWorker` on every route within (see that router's own

View file

@ -90,6 +90,27 @@ const envSchema = z.object({
* every technique detection request failing one at a time. * 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"), 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. */ /** Parsed, validated environment — import this instead of reading `process.env` directly anywhere else. */

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,17 @@
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`
* in `app.ts`. Every sub-router here is for `apps/admin-web` only
* `/admin/auth` is public (login), everything added later
* (`/admin/metrics`, `/admin/monitoring`, `/admin/tech-steps/*`) sits
* behind `requireAdmin` (`middlewares/require-admin.ts`).
*/
export const adminRouter = Router();
adminRouter.use("/auth", adminAuthRouter);
adminRouter.use("/metrics", adminMetricsRouter);
adminRouter.use("/monitoring", adminMonitoringRouter);

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -14,6 +14,7 @@ import {
} from "@batch-cooking/shared"; } from "@batch-cooking/shared";
import type { Prisma } from "@prisma/client"; import type { Prisma } from "@prisma/client";
import { prisma } from "../../db/prisma.js"; import { prisma } from "../../db/prisma.js";
import { analytics } from "../../lib/analytics.service.js";
import { import {
type TechStepMatch, type TechStepMatch,
techStepClassifier, techStepClassifier,
@ -616,6 +617,12 @@ async function createRecipeInternal(
}, },
include: recipeInclude(authorId), include: recipeInclude(authorId),
}); });
analytics.recordEvent(source === null ? "recipe.created" : "recipe.imported", {
actorId: authorId,
context: { recipeId: created.id, sourceId: source?.sourceId ?? null },
});
return toRecipeView(created); return toRecipeView(created);
} catch (err) { } catch (err) {
throw err; // see suitableForHouseholdWhere()'s catch comment above throw err; // see suitableForHouseholdWhere()'s catch comment above

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -48,6 +48,15 @@ services:
# default: `/internal/tech-steps/*` fails closed rather than open # default: `/internal/tech-steps/*` fails closed rather than open
# for a deployment that doesn't run the worker at all. # for a deployment that doesn't run the worker at all.
INTERNAL_WORKER_SECRET: ${INTERNAL_WORKER_SECRET:-} INTERNAL_WORKER_SECRET: ${INTERNAL_WORKER_SECRET:-}
# Admin 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 # Compose network service name, not localhost — same reasoning as
# DATABASE_URL above. Unlike INTERNAL_WORKER_SECRET, no `:-` fallback: # DATABASE_URL above. Unlike INTERNAL_WORKER_SECRET, no `:-` fallback:
# tech-step-intent-service is a core dependency (see its own entry # tech-step-intent-service is a core dependency (see its own entry
@ -128,6 +137,24 @@ services:
# Dockerfile doc comment on its VOLUME declaration. # Dockerfile doc comment on its VOLUME declaration.
- tech_step_llm_worker_models:/worker/models - 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: volumes:
postgres_data: postgres_data:
tech_step_llm_worker_models: tech_step_llm_worker_models:

View file

@ -13,8 +13,14 @@ export type HttpMethod = "get" | "post" | "put" | "patch" | "delete";
/** Options for {@link ExpressServer.setupCore}. */ /** Options for {@link ExpressServer.setupCore}. */
export interface ExpressServerCoreOptions { 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[];
} }
/** /**

View file

@ -7,6 +7,7 @@ export * from "./data/catalog-labels-en.js";
export * from "./data/catalog-labels-fr.js"; export * from "./data/catalog-labels-fr.js";
export * from "./errors/error-codes.js"; export * from "./errors/error-codes.js";
export * from "./schemas/account.js"; export * from "./schemas/account.js";
export * from "./schemas/admin.js";
export * from "./schemas/auth.js"; export * from "./schemas/auth.js";
export * from "./schemas/household.js"; export * from "./schemas/household.js";
export * from "./schemas/planning.js"; export * from "./schemas/planning.js";
@ -17,6 +18,7 @@ export * from "./schemas/shopping-list.js";
export * from "./schemas/sources.js"; export * from "./schemas/sources.js";
export * from "./schemas/tech-step-worker.js"; export * from "./schemas/tech-step-worker.js";
export * from "./tools/assert-is-never.js"; export * from "./tools/assert-is-never.js";
export * from "./types/admin.js";
export * from "./types/household.js"; export * from "./types/household.js";
export * from "./types/planning.js"; export * from "./types/planning.js";
export * from "./types/preferences.js"; export * from "./types/preferences.js";

View file

@ -0,0 +1,49 @@
import { z } from "zod";
// Shared between apps/api (server-side validation, source of truth) and
// apps/admin-web (client-side validation for instant feedback). Same
// rationale as schemas/auth.ts — one set of rules, French messages surfaced
// as-is in the admin login form.
/**
* Payload accepted by `POST /admin/auth/login`. Deliberately its own schema
* (not a re-export of `loginSchema`): the admin surface is a separate
* contract from the end-user one even though the shape currently matches.
*/
export const adminLoginSchema = z.object({
email: z.string().trim().toLowerCase().email("Email invalide"),
password: z.string().min(1, "Le mot de passe est requis"),
});
/** Inferred TS type for {@link adminLoginSchema}'s validated output. */
export type AdminLoginInput = z.infer<typeof adminLoginSchema>;
/**
* 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<typeof getMetricsSchema>;
/**
* 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<typeof workerHeartbeatSchema>;

View file

@ -0,0 +1,118 @@
/**
* Public shape of an admin operator, as returned by `GET /admin/auth/me`
* and `POST /admin/auth/login` never includes the password hash. Mirrors
* apps/api's `Omit<PrismaAdminUser, "passwordHash" | "tokenVersion">`,
* declared by hand rather than derived from the Prisma type (same reason as
* {@link SafeUserProfile}: apps/admin-web must not depend on
* `@prisma/client`). `createdAt`/`lastLoginAt` are ISO 8601 strings (JSON
* has no date type).
*/
export interface AdminUserView {
id: number;
email: string;
name: string;
createdAt: string;
lastLoginAt: string | null;
}
/** 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[];
}
/**
* 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>`. */
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<string, number> } | null;
}
/** Response of `GET /admin/monitoring` — an actively-probed status board. */
export interface MonitoringView {
generatedAt: string;
services: ServiceHealthView[];
}

View file

@ -15,6 +15,79 @@ importers:
specifier: ^5.7.2 specifier: ^5.7.2
version: 5.9.3 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: apps/api:
dependencies: dependencies:
'@batch-cooking/date-tools': '@batch-cooking/date-tools':
@ -1147,6 +1220,33 @@ packages:
'@types/cors@2.8.19': '@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} 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': '@types/estree@1.0.9':
resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==, tarball: https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz} 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} resolution: {integrity: sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==, tarball: https://registry.npmjs.org/clone/-/clone-1.0.4.tgz}
engines: {node: '>=0.8'} 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: 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} 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'} engines: {node: '>=7.0.0'}
@ -1727,6 +1831,50 @@ packages:
engines: {node: ^16.0.0 || ^18.0.0 || >=20.0.0} engines: {node: ^16.0.0 || ^18.0.0 || >=20.0.0}
hasBin: true 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: dashdash@1.14.1:
resolution: {integrity: sha512-jRFi8UDGo6j+odZiEpjazZaWqEal3w/basFjQHQEwVtZJGDpxbH1MeYluwCS8Xq5wmLJooDlMgvVarmWfGM44g==, tarball: https://registry.npmjs.org/dashdash/-/dashdash-1.14.1.tgz} resolution: {integrity: sha512-jRFi8UDGo6j+odZiEpjazZaWqEal3w/basFjQHQEwVtZJGDpxbH1MeYluwCS8Xq5wmLJooDlMgvVarmWfGM44g==, tarball: https://registry.npmjs.org/dashdash/-/dashdash-1.14.1.tgz}
engines: {node: '>=0.10'} 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} resolution: {integrity: sha512-9iE1PgSik9HeIIw2JO94IidnE3eBoQrFJ3w7sFuzSX4DpmZ3v5sZpUiV5Swcf6mQEF+Y0ru8Neo+p+nyh2J+hQ==, tarball: https://registry.npmjs.org/decamelize/-/decamelize-4.0.0.tgz}
engines: {node: '>=10'} 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: 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} resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==, tarball: https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz}
engines: {node: '>=6'} 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} resolution: {integrity: sha512-PJWHUb1RFevKCwaFA9RlG5tCd+FO5iRh9A8HEtkmBH2Li03iJriB6m6JIN4rGz3K3JLawI7/veA1xzRKP6ISBw==, tarball: https://registry.npmjs.org/diff/-/diff-7.0.0.tgz}
engines: {node: '>=0.3.1'} 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: dom-serializer@1.4.1:
resolution: {integrity: sha512-VHwB3KfrcOOkelEG2ZOfxqLZdfkil8PtJi4P8N2MMXucZq2yLp75ClViUlOVwyoHEDjYU433Aq+5zWP61+RGag==, tarball: https://registry.npmjs.org/dom-serializer/-/dom-serializer-1.4.1.tgz} 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: eventemitter2@6.4.7:
resolution: {integrity: sha512-tYUSVOGeQPKt/eC1ABfhHy5Xd96N3oIijJvN3O9+TsC28T5V9yX9oEfEK5faP0EFSNVOG97qtAS68GBrQB2hDg==, tarball: https://registry.npmjs.org/eventemitter2/-/eventemitter2-6.4.7.tgz} 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: execa@4.1.0:
resolution: {integrity: sha512-j5W0//W7f8UxAn8hXVnwG8tLwdiUy4FJLcSupCg6maBYZDpyBvTApK7KyuI4bKj8KOh1r2YH+6ucuYtJv1bTZA==, tarball: https://registry.npmjs.org/execa/-/execa-4.1.0.tgz} resolution: {integrity: sha512-j5W0//W7f8UxAn8hXVnwG8tLwdiUy4FJLcSupCg6maBYZDpyBvTApK7KyuI4bKj8KOh1r2YH+6ucuYtJv1bTZA==, tarball: https://registry.npmjs.org/execa/-/execa-4.1.0.tgz}
engines: {node: '>=10'} 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} 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} 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: fast-glob@3.3.3:
resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==, tarball: https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz} resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==, tarball: https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz}
engines: {node: '>=8.6.0'} 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} resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==, tarball: https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz}
engines: {node: '>= 0.4'} 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: 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} 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'} 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} resolution: {integrity: sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==, tarball: https://registry.npmjs.org/progress/-/progress-2.0.3.tgz}
engines: {node: '>=0.4.0'} 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: property-expr@2.0.6:
resolution: {integrity: sha512-SVtmxhRE/CGkn3eZY1T6pC8Nln6Fr/lu1mKSgRud0eC73whjGfoAogbn78LkD8aFL0zz3bAFerKSnOl7NlErBA==, tarball: https://registry.npmjs.org/property-expr/-/property-expr-2.0.6.tgz} 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: typescript:
optional: true 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: react-refresh@0.17.0:
resolution: {integrity: sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==, tarball: https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz} resolution: {integrity: sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==, tarball: https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
@ -3114,6 +3288,18 @@ packages:
react-dom: react-dom:
optional: true 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: 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} 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'} 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} resolution: {integrity: sha512-Kko+Y5XQ6fM+Ce3dq3m9YGxnacYZYl9cA1wZjaF3Vbry2L3i1qVg8+CAgNPsXRArPMUMCaOR7oa9Nqntc43JKA==, tarball: https://registry.npmjs.org/readdirp/-/readdirp-5.1.1.tgz}
engines: {node: '>= 20.19.0'} 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: reflect-metadata@0.2.2:
resolution: {integrity: sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==, tarball: https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz} 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: 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} 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: tinyglobby@0.2.17:
resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==, tarball: https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz} resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==, tarball: https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz}
engines: {node: '>=12.0.0'} 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} 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} 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: vite@5.4.21:
resolution: {integrity: sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==, tarball: https://registry.npmjs.org/vite/-/vite-5.4.21.tgz} 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} engines: {node: ^18.0.0 || >=20.0.0}
@ -4622,6 +4825,30 @@ snapshots:
dependencies: dependencies:
'@types/node': 22.20.1 '@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/estree@1.0.9': {}
'@types/express-serve-static-core@4.19.9': '@types/express-serve-static-core@4.19.9':
@ -5106,6 +5333,8 @@ snapshots:
clone@1.0.4: clone@1.0.4:
optional: true optional: true
clsx@2.1.1: {}
color-convert@2.0.1: color-convert@2.0.1:
dependencies: dependencies:
color-name: 1.1.4 color-name: 1.1.4
@ -5256,6 +5485,44 @@ snapshots:
untildify: 4.0.0 untildify: 4.0.0
yauzl: 2.10.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: dashdash@1.14.1:
dependencies: dependencies:
assert-plus: 1.0.0 assert-plus: 1.0.0
@ -5284,6 +5551,8 @@ snapshots:
decamelize@4.0.0: {} decamelize@4.0.0: {}
decimal.js-light@2.5.1: {}
deep-eql@5.0.2: {} deep-eql@5.0.2: {}
deep-equal@2.2.3: deep-equal@2.2.3:
@ -5411,6 +5680,11 @@ snapshots:
diff@7.0.0: {} diff@7.0.0: {}
dom-helpers@5.2.1:
dependencies:
'@babel/runtime': 7.29.7
csstype: 3.2.3
dom-serializer@1.4.1: dom-serializer@1.4.1:
dependencies: dependencies:
domelementtype: 2.3.0 domelementtype: 2.3.0
@ -5614,6 +5888,8 @@ snapshots:
eventemitter2@6.4.7: {} eventemitter2@6.4.7: {}
eventemitter3@4.0.7: {}
execa@4.1.0: execa@4.1.0:
dependencies: dependencies:
cross-spawn: 7.0.6 cross-spawn: 7.0.6
@ -5692,6 +5968,8 @@ snapshots:
extsprintf@1.3.0: {} extsprintf@1.3.0: {}
fast-equals@5.4.1: {}
fast-glob@3.3.3: fast-glob@3.3.3:
dependencies: dependencies:
'@nodelib/fs.stat': 2.0.5 '@nodelib/fs.stat': 2.0.5
@ -6037,6 +6315,8 @@ snapshots:
hasown: 2.0.4 hasown: 2.0.4
side-channel: 1.1.1 side-channel: 1.1.1
internmap@2.0.3: {}
ipaddr.js@1.9.1: {} ipaddr.js@1.9.1: {}
is-arguments@1.2.0: is-arguments@1.2.0:
@ -6673,6 +6953,12 @@ snapshots:
progress@2.0.3: {} 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: {} property-expr@2.0.6: {}
proxy-addr@2.0.7: proxy-addr@2.0.7:
@ -6736,6 +7022,10 @@ snapshots:
react-dom: 18.3.1(react@18.3.1) react-dom: 18.3.1(react@18.3.1)
typescript: 5.9.3 typescript: 5.9.3
react-is@16.13.1: {}
react-is@18.3.1: {}
react-refresh@0.17.0: {} react-refresh@0.17.0: {}
react-router-dom@7.18.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1): 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: optionalDependencies:
react-dom: 18.3.1(react@18.3.1) 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: react@18.3.1:
dependencies: dependencies:
loose-envify: 1.4.0 loose-envify: 1.4.0
@ -6784,6 +7091,23 @@ snapshots:
readdirp@5.1.1: {} 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: {} reflect-metadata@0.2.2: {}
regexp-match-indices@1.0.2: regexp-match-indices@1.0.2:
@ -7212,6 +7536,8 @@ snapshots:
tiny-case@1.0.3: {} tiny-case@1.0.3: {}
tiny-invariant@1.3.3: {}
tinyglobby@0.2.17: tinyglobby@0.2.17:
dependencies: dependencies:
fdir: 6.5.0(picomatch@4.0.5) fdir: 6.5.0(picomatch@4.0.5)
@ -7333,6 +7659,23 @@ snapshots:
core-util-is: 1.0.2 core-util-is: 1.0.2
extsprintf: 1.3.0 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): vite@5.4.21(@types/node@22.20.1)(sass@1.102.0):
dependencies: dependencies:
esbuild: 0.21.5 esbuild: 0.21.5

View file

@ -94,6 +94,35 @@ export function getPendingCorrections(limit: number): Promise<PendingCorrection[
return request(`/internal/tech-steps/pending-corrections?${params}`); return request(`/internal/tech-steps/pending-corrections?${params}`);
} }
/** Payload of `POST /internal/tech-steps/heartbeat` — mirrors `workerHeartbeatSchema` (`packages/shared`), duplicated here for the same "outside the workspace" reason as {@link AuditClause}. */
export interface HeartbeatPayload {
event: "boot" | "tick" | "job";
job?: string;
ok?: boolean;
counts?: Record<string, number>;
}
/**
* 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<void> {
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. */ /** 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( export function postTrainingSuggestions(
suggestions: TrainingSuggestionInput[], suggestions: TrainingSuggestionInput[],

View file

@ -1,6 +1,12 @@
import { postHeartbeat } from "./api-client.js";
import { env } from "./config.js"; import { env } from "./config.js";
import { runOnce, startScheduler } from "./scheduler.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 * Entrypoint `RUN_ONCE=true` runs both jobs a single time and exits
* (manual/CI-triggered invocation, `pnpm start`), otherwise starts the * (manual/CI-triggered invocation, `pnpm start`), otherwise starts the

View file

@ -1,4 +1,5 @@
import cron from "node-cron"; import cron from "node-cron";
import { postHeartbeat } from "./api-client.js";
import { env } from "./config.js"; import { env } from "./config.js";
import { runAuditLowConfidenceJob } from "./jobs/audit-low-confidence.js"; import { runAuditLowConfidenceJob } from "./jobs/audit-low-confidence.js";
import { runTransformCorrectionsJob } from "./jobs/transform-corrections.js"; import { runTransformCorrectionsJob } from "./jobs/transform-corrections.js";
@ -27,11 +28,23 @@ export async function runOnce(): Promise<void> {
limit: env.TECH_STEP_WORKER_BATCH_LIMIT, limit: env.TECH_STEP_WORKER_BATCH_LIMIT,
}); });
console.info(`[tech-step-llm-worker] audit-low-confidence: ${auditCount} suggestion(s)`); 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, { const correctionCount = await runTransformCorrectionsJob(llm, {
limit: env.TECH_STEP_WORKER_BATCH_LIMIT, limit: env.TECH_STEP_WORKER_BATCH_LIMIT,
}); });
console.info(`[tech-step-llm-worker] transform-corrections: ${correctionCount} suggestion(s)`); console.info(`[tech-step-llm-worker] transform-corrections: ${correctionCount} suggestion(s)`);
await postHeartbeat({
event: "job",
job: "transform-corrections",
ok: true,
counts: { suggestions: correctionCount },
});
} finally { } finally {
await llm.dispose(); await llm.dispose();
} }
@ -49,8 +62,11 @@ export async function runOnce(): Promise<void> {
export function startScheduler(): void { export function startScheduler(): void {
console.info(`[tech-step-llm-worker] scheduling runs on "${env.TECH_STEP_WORKER_CRON}"`); console.info(`[tech-step-llm-worker] scheduling runs on "${env.TECH_STEP_WORKER_CRON}"`);
cron.schedule(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) => { runOnce().catch((err: unknown) => {
console.error("[tech-step-llm-worker] scheduled run failed:", err); console.error("[tech-step-llm-worker] scheduled run failed:", err);
void postHeartbeat({ event: "job", ok: false });
}); });
}); });
} }

View file

@ -296,6 +296,65 @@ telles quelles par typage structurel.
--- ---
## `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=` 7365,
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).
**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).
---
## `reference` — catalogues publics (pas de session requise) ## `reference` — catalogues publics (pas de session requise)
Router `/reference` (`reference.routes.ts`/`.service.ts`) — **toutes les Router `/reference` (`reference.routes.ts`/`.service.ts`) — **toutes les