From ba131bd8c87c43e8fb6fcb0420c25df8f0464c39 Mon Sep 17 00:00:00 2001 From: Nicolas Date: Sat, 5 Sep 2026 13:48:07 +0200 Subject: [PATCH] =?UTF-8?q?fix(admin):=20isole=20l'API=20admin=20sous=20/a?= =?UTF-8?q?dmin/api=20pour=20=C3=A9viter=20la=20collision=20avec=20les=20p?= =?UTF-8?q?ages=20front?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Un rafraîchissement (F5) sur /admin/monitoring affichait le JSON brut de l'API au lieu de la page — même chose à craindre pour tout futur /admin/* GET sans segment supplémentaire. Cause : `server.mountRouter("/admin", adminRouter)` (app.ts) montait l'API admin exactement sous /admin, la même racine que les pages /admin/* d'apps/web, servies depuis la même origine/le même process en prod (image Docker unique). Express matche par chemin, sans égard au Accept — `GET /admin/monitoring` (route API) et la page du même nom étaient en conflit direct ; l'API gagnait systématiquement (montée avant serveStaticFrontend), donc chaque F5 sur cette page recevait le JSON de l'API plutôt que index.html. Seule /admin/monitoring collidait réellement aujourd'hui (seul sous-routeur admin à répondre GET sur sa racine bare) mais le mécanisme touche toute future route admin nommée comme sa page — d'où un correctif structurel plutôt qu'un renommage ponctuel. Correctif : l'API admin est montée sous /admin/api (app.ts), jamais bare /admin. Toute la surface admin bouge en conséquence (auth/metrics/monitoring/tech-steps/catalog) ; les pages front (/admin/monitoring, /admin/catalogue, /admin/corrections...) sont inchangées, seul admin-client.ts change de base URL. Mis à jour en cohérence : commentaires JSDoc citant les routes (apps/api + apps/web + packages/shared), specs/backend-architecture.md, specs/batch-cooking-modele.md, les intercepts Cypress (globs + un matcher regex qui avait échappé au premier passage) et les chemins supertest du suite Mocha admin-*.test.ts. Simplifie au passage admin-monitoring.cy.ts : le contournement resourceType ajouté en #32 pour cette même collision n'a plus lieu d'être une fois les chemins distincts (retour à un intercept déclaratif simple) — le bug Cypress/Chrome qu'il avait mis au jour reste noté en commentaire pour référence future. Vérifié : pnpm lint clean ; build api+web clean ; suite e2e complète 28/28 fichiers, 116/116 tests verts (dont les 16 tests admin-*). Suite Mocha apps/api non exécutable cette session (Postgres/Docker injoignable) — chemins mis à jour mécaniquement en cohérence avec le nouveau mount point, à faire tourner en CI/à la prochaine session avec accès DB. Co-Authored-By: Claude Sonnet 5 --- apps/api/src/app.ts | 16 +++++-- .../src/modules/admin/admin-auth.routes.ts | 2 +- .../src/modules/admin/admin-catalog.routes.ts | 2 +- .../src/modules/admin/admin-metrics.routes.ts | 2 +- .../modules/admin/admin-monitoring.routes.ts | 2 +- .../modules/admin/admin-tech-steps.routes.ts | 2 +- apps/api/src/modules/admin/admin.routes.ts | 12 +++-- apps/api/test/admin-auth.test.ts | 26 +++++----- apps/api/test/admin-catalog.test.ts | 20 ++++---- apps/api/test/admin-metrics.test.ts | 8 ++-- apps/api/test/admin-monitoring.test.ts | 12 ++--- apps/api/test/admin-tech-steps.test.ts | 32 ++++++------- apps/web/cypress/e2e/admin-catalog.cy.ts | 12 ++--- apps/web/cypress/e2e/admin-corrections.cy.ts | 12 ++--- apps/web/cypress/e2e/admin-dashboard.cy.ts | 6 +-- apps/web/cypress/e2e/admin-layout.cy.ts | 10 ++-- apps/web/cypress/e2e/admin-login.ts | 11 +++-- apps/web/cypress/e2e/admin-monitoring.cy.ts | 36 +++++--------- apps/web/src/App.tsx | 2 +- apps/web/src/api/admin-client.ts | 47 +++++++++++-------- .../src/features/admin/AdminAuthContext.tsx | 4 +- apps/web/src/features/admin/RequireAdmin.tsx | 2 +- .../pages/admin/dashboard/DashboardPage.tsx | 4 +- .../src/pages/admin/login/AdminLoginPage.tsx | 2 +- .../pages/admin/monitoring/MonitoringPage.tsx | 2 +- .../src/pages/admin/monitoring/monitoring.ts | 2 +- packages/shared/src/errors/error-codes.ts | 2 +- packages/shared/src/schemas/admin.ts | 18 +++---- packages/shared/src/types/admin.ts | 20 ++++---- specs/backend-architecture.md | 23 +++++---- specs/batch-cooking-modele.md | 2 +- 31 files changed, 184 insertions(+), 169 deletions(-) diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index 569c361..9154300 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -45,10 +45,18 @@ export function createServer(): ExpressServer { }); server.mountRouter("/auth", authRouter); - // Admin application surface (`apps/web`'s `/admin/*` routes) — its own - // auth (`requireAdmin`, distinct cookie/secret), never the end-user - // session. - server.mountRouter("/admin", adminRouter); + // Admin application surface — its own auth (`requireAdmin`, distinct + // cookie/secret), never the end-user session. Mounted at `/admin/api`, + // *not* bare `/admin`: apps/web's own admin pages live at `/admin/*` too + // (`/admin/monitoring`, `/admin/login`...), and this API is served from + // the same origin/process as that frontend (see `serveStaticFrontend` + // below). A bare `/admin` mount put `GET /admin/monitoring` (API) and the + // page route `/admin/monitoring` in direct conflict — Express matches by + // path alone, so a hard navigation/refresh on that page was answered by + // this router's own handler (raw JSON) instead of falling through to the + // SPA's `index.html`. The `/api` segment guarantees no current or future + // admin page path can ever collide with an admin API path again. + server.mountRouter("/admin/api", adminRouter); server.mountRouter("/house", houseRouter); // Not user-facing — `services/tech-step-llm-worker` only, guarded by // `requireInternalWorker` on every route within (see that router's own diff --git a/apps/api/src/modules/admin/admin-auth.routes.ts b/apps/api/src/modules/admin/admin-auth.routes.ts index ca12ede..523f4a7 100644 --- a/apps/api/src/modules/admin/admin-auth.routes.ts +++ b/apps/api/src/modules/admin/admin-auth.routes.ts @@ -5,7 +5,7 @@ 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`). */ +/** Router mounted at `/admin/api/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; diff --git a/apps/api/src/modules/admin/admin-catalog.routes.ts b/apps/api/src/modules/admin/admin-catalog.routes.ts index 33b6f72..6376be7 100644 --- a/apps/api/src/modules/admin/admin-catalog.routes.ts +++ b/apps/api/src/modules/admin/admin-catalog.routes.ts @@ -9,7 +9,7 @@ import { } from "./admin-catalog.service.js"; /** - * Router mounted at `/admin/catalog` (via `admin.routes.ts`) — every route + * Router mounted at `/admin/api/catalog` (via `admin.routes.ts`) — every route * behind {@link requireAdmin}. Surfaces the off-catalog ingredient * "placeholders" users typed when the seeded catalog fell short, so a * maintainer can see what's missing and mark gaps as handled. diff --git a/apps/api/src/modules/admin/admin-metrics.routes.ts b/apps/api/src/modules/admin/admin-metrics.routes.ts index 3bed532..1d83fee 100644 --- a/apps/api/src/modules/admin/admin-metrics.routes.ts +++ b/apps/api/src/modules/admin/admin-metrics.routes.ts @@ -4,7 +4,7 @@ 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}. */ +/** Router mounted at `/admin/api/metrics` (via `admin.routes.ts`) — every route behind {@link requireAdmin}. */ export const adminMetricsRouter = Router(); /** diff --git a/apps/api/src/modules/admin/admin-monitoring.routes.ts b/apps/api/src/modules/admin/admin-monitoring.routes.ts index 62e6be3..bc07113 100644 --- a/apps/api/src/modules/admin/admin-monitoring.routes.ts +++ b/apps/api/src/modules/admin/admin-monitoring.routes.ts @@ -3,7 +3,7 @@ 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}. */ +/** Router mounted at `/admin/api/monitoring` (via `admin.routes.ts`) — behind {@link requireAdmin}. */ export const adminMonitoringRouter = Router(); /** diff --git a/apps/api/src/modules/admin/admin-tech-steps.routes.ts b/apps/api/src/modules/admin/admin-tech-steps.routes.ts index 9264fb9..a7ffddf 100644 --- a/apps/api/src/modules/admin/admin-tech-steps.routes.ts +++ b/apps/api/src/modules/admin/admin-tech-steps.routes.ts @@ -18,7 +18,7 @@ import { updateSuggestion, } from "./admin-tech-steps.service.js"; -/** Router mounted at `/admin/tech-steps` (via `admin.routes.ts`) — every route behind {@link requireAdmin}. Correction/suggestion triage + the retrain trigger. */ +/** Router mounted at `/admin/api/tech-steps` (via `admin.routes.ts`) — every route behind {@link requireAdmin}. Correction/suggestion triage + the retrain trigger. */ export const adminTechStepsRouter = Router(); adminTechStepsRouter.get( diff --git a/apps/api/src/modules/admin/admin.routes.ts b/apps/api/src/modules/admin/admin.routes.ts index 5b65cc2..7994653 100644 --- a/apps/api/src/modules/admin/admin.routes.ts +++ b/apps/api/src/modules/admin/admin.routes.ts @@ -6,11 +6,13 @@ import { adminMonitoringRouter } from "./admin-monitoring.routes.js"; import { adminTechStepsRouter } from "./admin-tech-steps.routes.js"; /** - * Aggregator for the admin application's API surface, mounted at `/admin` - * in `app.ts`. Every sub-router here backs `apps/web`'s `/admin/*` routes - * only — `/admin/auth` is public (login), everything added later - * (`/admin/metrics`, `/admin/monitoring`, `/admin/tech-steps/*`, - * `/admin/catalog/*`) sits behind `requireAdmin` + * Aggregator for the admin application's API surface, mounted at + * `/admin/api` in `app.ts` (not bare `/admin` — see that mount point's own + * comment for why: it would collide with apps/web's `/admin/*` page + * routes). Every sub-router here backs one of those pages — + * `/admin/api/auth` is public (login), everything added later + * (`/admin/api/metrics`, `/admin/api/monitoring`, `/admin/api/tech-steps/*`, + * `/admin/api/catalog/*`) sits behind `requireAdmin` * (`middlewares/require-admin.ts`). */ export const adminRouter = Router(); diff --git a/apps/api/test/admin-auth.test.ts b/apps/api/test/admin-auth.test.ts index 4922318..0d59119 100644 --- a/apps/api/test/admin-auth.test.ts +++ b/apps/api/test/admin-auth.test.ts @@ -44,16 +44,16 @@ describe("Admin auth", () => { await prisma.$disconnect(); }); - describe("POST /admin/auth/login", () => { + describe("POST /admin/api/auth/login", () => { it("rejects a missing body with 400 VALIDATION_ERROR", async () => { - const res = await request(app).post("/admin/auth/login").send({}); + const res = await request(app).post("/admin/api/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") + .post("/admin/api/auth/login") .send({ email: "nobody@example.com", password: "whatever" }); expect(res.status).to.equal(401); expect(res.body.code).to.equal(ErrorCode.INVALID_CREDENTIALS); @@ -62,7 +62,7 @@ describe("Admin auth", () => { it("rejects a wrong password with 401 INVALID_CREDENTIALS", async () => { const { email } = await seedAdmin(); const res = await request(app) - .post("/admin/auth/login") + .post("/admin/api/auth/login") .send({ email, password: "not-the-password" }); expect(res.status).to.equal(401); expect(res.body.code).to.equal(ErrorCode.INVALID_CREDENTIALS); @@ -76,7 +76,7 @@ describe("Admin auth", () => { } const { email, password } = await seedAdmin(); - const res = await request(app).post("/admin/auth/login").send({ email, password }); + const res = await request(app).post("/admin/api/auth/login").send({ email, password }); expect(res.status).to.equal(200); expect(res.body.email).to.equal(email); @@ -94,9 +94,9 @@ describe("Admin auth", () => { }); }); - describe("GET /admin/auth/me", () => { + describe("GET /admin/api/auth/me", () => { it("rejects a request with no admin cookie with 401 NOT_AUTHENTICATED", async () => { - const res = await request(app).get("/admin/auth/me"); + const res = await request(app).get("/admin/api/auth/me"); expect(res.status).to.equal(401); expect(res.body.code).to.equal(ErrorCode.NOT_AUTHENTICATED); }); @@ -109,9 +109,9 @@ describe("Admin auth", () => { } const { email, password } = await seedAdmin(); const agent = request.agent(app); - await agent.post("/admin/auth/login").send({ email, password }); + await agent.post("/admin/api/auth/login").send({ email, password }); - const res = await agent.get("/admin/auth/me"); + const res = await agent.get("/admin/api/auth/me"); expect(res.status).to.equal(200); expect(res.body.email).to.equal(email); }); @@ -124,12 +124,12 @@ describe("Admin auth", () => { } const { email, password } = await seedAdmin(); const agent = request.agent(app); - await agent.post("/admin/auth/login").send({ email, password }); + await agent.post("/admin/api/auth/login").send({ email, password }); - const logoutRes = await agent.post("/admin/auth/logout"); + const logoutRes = await agent.post("/admin/api/auth/logout"); expect(logoutRes.status).to.equal(204); - const meRes = await agent.get("/admin/auth/me"); + const meRes = await agent.get("/admin/api/auth/me"); expect(meRes.status).to.equal(401); }); @@ -141,7 +141,7 @@ describe("Admin auth", () => { const agent = request.agent(app); await agent.post("/auth/signup").send(buildSignupPayload()); - const res = await agent.get("/admin/auth/me"); + const res = await agent.get("/admin/api/auth/me"); expect(res.status).to.equal(401); expect(res.body.code).to.equal(ErrorCode.NOT_AUTHENTICATED); }); diff --git a/apps/api/test/admin-catalog.test.ts b/apps/api/test/admin-catalog.test.ts index dffe82a..b1c79d6 100644 --- a/apps/api/test/admin-catalog.test.ts +++ b/apps/api/test/admin-catalog.test.ts @@ -47,7 +47,7 @@ async function seedPlaceholderRecipe(name: string): Promise { } /** - * `/admin/catalog/*` — the off-catalog ingredient review. Every route is + * `/admin/api/catalog/*` — the off-catalog ingredient review. Every route is * behind `requireAdmin`; the list groups placeholder rows by normalized * name, `mark-reviewed` stamps `reviewedAt`, `prune-orphans` deletes rows * no recipe references any more. @@ -66,18 +66,18 @@ describe("Admin catalog — off-catalog ingredients", () => { async function adminAgent() { const { email, password } = await seedAdmin(); const agent = request.agent(app); - await agent.post("/admin/auth/login").send({ email, password }); + await agent.post("/admin/api/auth/login").send({ email, password }); return agent; } it("rejects every route without an admin session", async () => { - const get = await request(app).get("/admin/catalog/placeholders"); + const get = await request(app).get("/admin/api/catalog/placeholders"); expect(get.status).to.equal(401); const patch = await request(app) - .patch("/admin/catalog/placeholders/mark-reviewed") + .patch("/admin/api/catalog/placeholders/mark-reviewed") .send({ ingredientIds: [1] }); expect(patch.status).to.equal(401); - const post = await request(app).post("/admin/catalog/placeholders/prune-orphans"); + const post = await request(app).post("/admin/api/catalog/placeholders/prune-orphans"); expect(post.status).to.equal(401); }); @@ -87,7 +87,7 @@ describe("Admin catalog — off-catalog ingredients", () => { await seedPlaceholderRecipe("Sumac"); const agent = await adminAgent(); - const res = await agent.get("/admin/catalog/placeholders"); + const res = await agent.get("/admin/api/catalog/placeholders"); expect(res.status).to.equal(200); expect(res.body).to.have.length(2); @@ -106,7 +106,7 @@ describe("Admin catalog — off-catalog ingredients", () => { const agent = await adminAgent(); const patched = await agent - .patch("/admin/catalog/placeholders/mark-reviewed") + .patch("/admin/api/catalog/placeholders/mark-reviewed") .send({ ingredientIds: [id] }); expect(patched.status).to.equal(200); expect(patched.body.reviewed).to.equal(1); @@ -114,9 +114,9 @@ describe("Admin catalog — off-catalog ingredients", () => { (await prisma.ingredient.findUniqueOrThrow({ where: { id } })).reviewedAt, ).to.be.an.instanceOf(Date); - const pending = await agent.get("/admin/catalog/placeholders"); + const pending = await agent.get("/admin/api/catalog/placeholders"); expect(pending.body).to.have.length(0); - const reviewed = await agent.get("/admin/catalog/placeholders").query({ reviewed: "true" }); + const reviewed = await agent.get("/admin/api/catalog/placeholders").query({ reviewed: "true" }); expect(reviewed.body).to.have.length(1); expect(reviewed.body[0].allReviewed).to.equal(true); }); @@ -133,7 +133,7 @@ describe("Admin catalog — off-catalog ingredients", () => { }); const agent = await adminAgent(); - const res = await agent.post("/admin/catalog/placeholders/prune-orphans"); + const res = await agent.post("/admin/api/catalog/placeholders/prune-orphans"); expect(res.status).to.equal(200); expect(res.body.deleted).to.equal(1); expect(await prisma.ingredient.count({ where: { isPlaceholder: true } })).to.equal(1); diff --git a/apps/api/test/admin-metrics.test.ts b/apps/api/test/admin-metrics.test.ts index c7ba930..67dbf4b 100644 --- a/apps/api/test/admin-metrics.test.ts +++ b/apps/api/test/admin-metrics.test.ts @@ -78,7 +78,7 @@ describe("Admin metrics", () => { }); }); - describe("GET /admin/metrics", () => { + describe("GET /admin/api/metrics", () => { const app = createApp(); beforeEach(async () => { @@ -90,7 +90,7 @@ describe("Admin metrics", () => { }); it("rejects a request with no admin session with 401", async () => { - const res = await request(app).get("/admin/metrics"); + const res = await request(app).get("/admin/api/metrics"); expect(res.status).to.equal(401); }); @@ -109,9 +109,9 @@ describe("Admin metrics", () => { await userB.post("/auth/signup").send(buildSignupPayload()); const adminAgent = request.agent(app); - await adminAgent.post("/admin/auth/login").send({ email, password }); + await adminAgent.post("/admin/api/auth/login").send({ email, password }); - const res = await adminAgent.get("/admin/metrics").query({ days: 14 }); + const res = await adminAgent.get("/admin/api/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); diff --git a/apps/api/test/admin-monitoring.test.ts b/apps/api/test/admin-monitoring.test.ts index fcc8e8c..80d3767 100644 --- a/apps/api/test/admin-monitoring.test.ts +++ b/apps/api/test/admin-monitoring.test.ts @@ -100,9 +100,9 @@ describe("Admin monitoring", () => { }); }); - describe("GET /admin/monitoring", () => { + describe("GET /admin/api/monitoring", () => { it("rejects a request with no admin session with 401", async () => { - const res = await request(app).get("/admin/monitoring"); + const res = await request(app).get("/admin/api/monitoring"); expect(res.status).to.equal(401); }); @@ -114,9 +114,9 @@ describe("Admin monitoring", () => { } const { email, password } = await seedAdmin(); const agent = request.agent(app); - await agent.post("/admin/auth/login").send({ email, password }); + await agent.post("/admin/api/auth/login").send({ email, password }); - const res = await agent.get("/admin/monitoring"); + const res = await agent.get("/admin/api/monitoring"); expect(res.status).to.equal(200); const keys = res.body.services.map((s: { key: string }) => s.key); @@ -146,9 +146,9 @@ describe("Admin monitoring", () => { const { email, password } = await seedAdmin(); const agent = request.agent(app); - await agent.post("/admin/auth/login").send({ email, password }); + await agent.post("/admin/api/auth/login").send({ email, password }); - const res = await agent.get("/admin/monitoring"); + const res = await agent.get("/admin/api/monitoring"); const worker = res.body.services.find( (s: { key: string }) => s.key === "tech-step-llm-worker", ); diff --git a/apps/api/test/admin-tech-steps.test.ts b/apps/api/test/admin-tech-steps.test.ts index b99f633..8e3e0de 100644 --- a/apps/api/test/admin-tech-steps.test.ts +++ b/apps/api/test/admin-tech-steps.test.ts @@ -92,20 +92,20 @@ describe("Admin tech-steps triage", () => { async function adminAgent() { const { email, password } = await seedAdmin(); const agent = request.agent(app); - await agent.post("/admin/auth/login").send({ email, password }); + await agent.post("/admin/api/auth/login").send({ email, password }); return agent; } it("rejects every route without an admin session", async () => { for (const path of [ - "/admin/tech-steps/suggestions", - "/admin/tech-steps/corrections", - "/admin/tech-steps/training-data-snippet?techStepKey=simmer", + "/admin/api/tech-steps/suggestions", + "/admin/api/tech-steps/corrections", + "/admin/api/tech-steps/training-data-snippet?techStepKey=simmer", ]) { const res = await request(app).get(path); expect(res.status, path).to.equal(401); } - const post = await request(app).post("/admin/tech-steps/retrain").send({}); + const post = await request(app).post("/admin/api/tech-steps/retrain").send({}); expect(post.status).to.equal(401); }); @@ -129,7 +129,7 @@ describe("Admin tech-steps triage", () => { const agent = await adminAgent(); - const all = await agent.get("/admin/tech-steps/suggestions"); + const all = await agent.get("/admin/api/tech-steps/suggestions"); expect(all.status).to.equal(200); expect(all.body.map((g: { techStepKey: string }) => g.techStepKey)).to.have.members([ "chop", @@ -139,7 +139,7 @@ describe("Admin tech-steps triage", () => { expect(simmerGroup.suggestions[0].sourceCorrection.clauseText).to.equal("Faire mijoter"); const pendingOnly = await agent - .get("/admin/tech-steps/suggestions") + .get("/admin/api/tech-steps/suggestions") .query({ status: "pending" }); expect(pendingOnly.body).to.have.length(1); expect(pendingOnly.body[0].techStepKey).to.equal("simmer"); @@ -159,7 +159,7 @@ describe("Admin tech-steps triage", () => { withSuggestion: { status: "pending", synonyms: ["x"] }, }); const agent = await adminAgent(); - const res = await agent.patch(`/admin/tech-steps/suggestions/${suggestion?.id}`).send({}); + const res = await agent.patch(`/admin/api/tech-steps/suggestions/${suggestion?.id}`).send({}); expect(res.status).to.equal(400); expect(res.body.code).to.equal(ErrorCode.VALIDATION_ERROR); }); @@ -172,7 +172,7 @@ describe("Admin tech-steps triage", () => { } const agent = await adminAgent(); const res = await agent - .patch("/admin/tech-steps/suggestions/999999") + .patch("/admin/api/tech-steps/suggestions/999999") .send({ status: "applied" }); expect(res.status).to.equal(404); }); @@ -191,7 +191,7 @@ describe("Admin tech-steps triage", () => { const agent = await adminAgent(); const patched = await agent - .patch(`/admin/tech-steps/suggestions/${suggestion?.id}`) + .patch(`/admin/api/tech-steps/suggestions/${suggestion?.id}`) .send({ status: "applied", suggestedSynonyms: ["frémir", "mijoter doucement"] }); expect(patched.status).to.equal(200); expect(patched.body.status).to.equal("applied"); @@ -215,12 +215,12 @@ describe("Admin tech-steps triage", () => { await seedCorrectionAndSuggestion({ clause: "Faire mijoter", correctedKey: "simmer" }); const agent = await adminAgent(); - const res = await agent.get("/admin/tech-steps/corrections"); + const res = await agent.get("/admin/api/tech-steps/corrections"); expect(res.status).to.equal(200); expect(res.body).to.have.length(2); const removals = await agent - .get("/admin/tech-steps/corrections") + .get("/admin/api/tech-steps/corrections") .query({ hasCorrectedTechStep: "false" }); expect(removals.body).to.have.length(1); expect(removals.body[0].clauseText).to.equal("Rien ici"); @@ -242,7 +242,7 @@ describe("Admin tech-steps triage", () => { }); const agent = await adminAgent(); const res = await agent - .get("/admin/tech-steps/training-data-snippet") + .get("/admin/api/tech-steps/training-data-snippet") .query({ techStepKey: "simmer", locale: "fr", status: "applied" }); expect(res.status).to.equal(200); expect(res.body.suggestionCount).to.equal(1); @@ -261,7 +261,7 @@ describe("Admin tech-steps triage", () => { } this.timeout(60000); const agent = await adminAgent(); - const res = await agent.post("/admin/tech-steps/retrain").send({}); + const res = await agent.post("/admin/api/tech-steps/retrain").send({}); expect(res.status).to.equal(200); expect(res.body).to.have.keys([ "f1", @@ -292,14 +292,14 @@ describe("Admin tech-steps triage", () => { // (100ms later) checks it. Swallow its result/rejection — this test // only asserts on the second request. const first = agent - .post("/admin/tech-steps/retrain") + .post("/admin/api/tech-steps/retrain") .send({}) .then( (res) => res, (err) => err, ); await new Promise((resolve) => setTimeout(resolve, 100)); - const second = await agent.post("/admin/tech-steps/retrain").send({}); + const second = await agent.post("/admin/api/tech-steps/retrain").send({}); expect(second.status).to.equal(409); expect(second.body.code).to.equal(ErrorCode.RETRAIN_ALREADY_RUNNING); await first; diff --git a/apps/web/cypress/e2e/admin-catalog.cy.ts b/apps/web/cypress/e2e/admin-catalog.cy.ts index 34b9537..cb1ed04 100644 --- a/apps/web/cypress/e2e/admin-catalog.cy.ts +++ b/apps/web/cypress/e2e/admin-catalog.cy.ts @@ -37,11 +37,11 @@ function pendingGroups() { describe("Admin catalog — off-catalog ingredients", () => { beforeEach(() => { cy.viewport(1400, 900); - cy.intercept("GET", "**/admin/auth/me", { statusCode: 200, body: adminBody }); + cy.intercept("GET", "**/admin/api/auth/me", { statusCode: 200, body: adminBody }); }); it("lists placeholder groups newest-impact first with their recipe count and spelling variants", () => { - cy.intercept("GET", "**/admin/catalog/placeholders*", { + cy.intercept("GET", "**/admin/api/catalog/placeholders*", { statusCode: 200, body: pendingGroups(), }).as("getPlaceholders"); @@ -57,11 +57,11 @@ describe("Admin catalog — off-catalog ingredients", () => { }); it("marks a group reviewed and reloads the list", () => { - cy.intercept("GET", "**/admin/catalog/placeholders*", { + cy.intercept("GET", "**/admin/api/catalog/placeholders*", { statusCode: 200, body: pendingGroups(), }).as("getPlaceholders"); - cy.intercept("PATCH", "**/admin/catalog/placeholders/mark-reviewed", { + cy.intercept("PATCH", "**/admin/api/catalog/placeholders/mark-reviewed", { statusCode: 200, body: { reviewed: 1 }, }).as("markReviewed"); @@ -82,11 +82,11 @@ describe("Admin catalog — off-catalog ingredients", () => { // Regex, not a glob: the two calls differ only by the `?reviewed=true` // query, and minimatch's `?` is itself a wildcard — a glob can't tell // them apart reliably. - cy.intercept("GET", /\/admin\/catalog\/placeholders$/, { + cy.intercept("GET", /\/admin\/api\/catalog\/placeholders$/, { statusCode: 200, body: pendingGroups(), }); - cy.intercept("GET", /\/admin\/catalog\/placeholders\?reviewed=true$/, { + cy.intercept("GET", /\/admin\/api\/catalog\/placeholders\?reviewed=true$/, { statusCode: 200, body: [], }).as("getReviewed"); diff --git a/apps/web/cypress/e2e/admin-corrections.cy.ts b/apps/web/cypress/e2e/admin-corrections.cy.ts index 1195e7c..a6a29f9 100644 --- a/apps/web/cypress/e2e/admin-corrections.cy.ts +++ b/apps/web/cypress/e2e/admin-corrections.cy.ts @@ -70,19 +70,19 @@ function corrections() { describe("Admin corrections triage", () => { beforeEach(() => { cy.viewport(1400, 1000); - cy.intercept("GET", "**/admin/auth/me", { statusCode: 200, body: adminBody }); - cy.intercept("GET", "**/admin/tech-steps/suggestions*", { + cy.intercept("GET", "**/admin/api/auth/me", { statusCode: 200, body: adminBody }); + cy.intercept("GET", "**/admin/api/tech-steps/suggestions*", { statusCode: 200, body: suggestionGroups(), }).as("getSuggestions"); - cy.intercept("GET", "**/admin/tech-steps/corrections*", { + cy.intercept("GET", "**/admin/api/tech-steps/corrections*", { statusCode: 200, body: corrections(), }).as("getCorrections"); }); it("shows the caveat, groups suggestions by technique, and applies one", () => { - cy.intercept("PATCH", "**/admin/tech-steps/suggestions/11", { + cy.intercept("PATCH", "**/admin/api/tech-steps/suggestions/11", { statusCode: 200, body: { ...suggestionGroups()[0].suggestions[0], status: "applied" }, }).as("patch"); @@ -102,7 +102,7 @@ describe("Admin corrections triage", () => { }); it("generates a training_data.py snippet", () => { - cy.intercept("GET", "**/admin/tech-steps/training-data-snippet*", { + cy.intercept("GET", "**/admin/api/tech-steps/training-data-snippet*", { statusCode: 200, body: { techStepKey: "simmer", @@ -124,7 +124,7 @@ describe("Admin corrections triage", () => { }); it("runs the F1 gate and shows the result", () => { - cy.intercept("POST", "**/admin/tech-steps/retrain", { + cy.intercept("POST", "**/admin/api/tech-steps/retrain", { statusCode: 200, body: { f1: 0.83, diff --git a/apps/web/cypress/e2e/admin-dashboard.cy.ts b/apps/web/cypress/e2e/admin-dashboard.cy.ts index b7487b1..6e56ec2 100644 --- a/apps/web/cypress/e2e/admin-dashboard.cy.ts +++ b/apps/web/cypress/e2e/admin-dashboard.cy.ts @@ -58,11 +58,11 @@ function metricsFixture() { describe("Admin dashboard", () => { beforeEach(() => { cy.viewport(1400, 900); - cy.intercept("GET", "**/admin/auth/me", { statusCode: 200, body: adminBody }); + cy.intercept("GET", "**/admin/api/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( + cy.intercept("GET", "**/admin/api/metrics*", { statusCode: 200, body: metricsFixture() }).as( "getMetrics", ); cy.visit("/admin"); @@ -85,7 +85,7 @@ describe("Admin dashboard", () => { }); it("shows an error state when the metrics request fails", () => { - cy.intercept("GET", "**/admin/metrics*", { + cy.intercept("GET", "**/admin/api/metrics*", { statusCode: 500, body: { code: 5000, message: "x" }, }); diff --git a/apps/web/cypress/e2e/admin-layout.cy.ts b/apps/web/cypress/e2e/admin-layout.cy.ts index 4e4d34e..ffdca2b 100644 --- a/apps/web/cypress/e2e/admin-layout.cy.ts +++ b/apps/web/cypress/e2e/admin-layout.cy.ts @@ -11,7 +11,7 @@ const adminBody = { describe("Admin layout", () => { it("redirects to /login when there is no admin session", () => { - cy.intercept("GET", "**/admin/auth/me", { + cy.intercept("GET", "**/admin/api/auth/me", { statusCode: 401, body: { code: 4011, message: "no" }, }); @@ -21,7 +21,7 @@ describe("Admin layout", () => { }); it("shows the sidebar and navigates between the sections", () => { - cy.intercept("GET", "**/admin/auth/me", { statusCode: 200, body: adminBody }); + cy.intercept("GET", "**/admin/api/auth/me", { statusCode: 200, body: adminBody }); cy.visit("/admin"); cy.contains("h1", "Tableau de bord").should("be.visible"); @@ -36,7 +36,7 @@ describe("Admin layout", () => { cy.url().should("include", "/admin/corrections"); cy.contains("h1", "Corrections").should("be.visible"); - cy.intercept("GET", "**/admin/catalog/placeholders*", { statusCode: 200, body: [] }); + cy.intercept("GET", "**/admin/api/catalog/placeholders*", { statusCode: 200, body: [] }); cy.contains("nav a", "Catalogue").click(); cy.url().should("include", "/admin/catalogue"); cy.contains("h1", "Ingrédients hors-catalogue").should("be.visible"); @@ -46,8 +46,8 @@ describe("Admin layout", () => { }); 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.intercept("GET", "**/admin/api/auth/me", { statusCode: 200, body: adminBody }); + cy.intercept("POST", "**/admin/api/auth/logout", { statusCode: 204 }); cy.visit("/admin"); // Wait until the guarded layout has actually mounted before acting. diff --git a/apps/web/cypress/e2e/admin-login.ts b/apps/web/cypress/e2e/admin-login.ts index 0d11ebc..16cf454 100644 --- a/apps/web/cypress/e2e/admin-login.ts +++ b/apps/web/cypress/e2e/admin-login.ts @@ -16,11 +16,14 @@ const adminBody = { }; Given("the admin session check returns unauthenticated", () => { - cy.intercept("GET", "**/admin/auth/me", { statusCode: 401, body: { code: 4011, message: "no" } }); + cy.intercept("GET", "**/admin/api/auth/me", { + statusCode: 401, + body: { code: 4011, message: "no" }, + }); }); Given("admin login fails with invalid credentials", () => { - cy.intercept("POST", "**/admin/auth/login", { + cy.intercept("POST", "**/admin/api/auth/login", { statusCode: 401, body: { code: 4010, message: "Invalid email or password" }, }); @@ -28,8 +31,8 @@ Given("admin login fails with invalid credentials", () => { 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 }); + cy.intercept("POST", "**/admin/api/auth/login", { statusCode: 200, body }); // After navigate("/admin"), RequireAdmin re-checks the session — from now // on it must report authenticated (last matching intercept wins). - cy.intercept("GET", "**/admin/auth/me", { statusCode: 200, body }); + cy.intercept("GET", "**/admin/api/auth/me", { statusCode: 200, body }); }); diff --git a/apps/web/cypress/e2e/admin-monitoring.cy.ts b/apps/web/cypress/e2e/admin-monitoring.cy.ts index 733e184..b5faa28 100644 --- a/apps/web/cypress/e2e/admin-monitoring.cy.ts +++ b/apps/web/cypress/e2e/admin-monitoring.cy.ts @@ -49,33 +49,21 @@ function monitoringFixture() { describe("Admin monitoring", () => { beforeEach(() => { cy.viewport(1400, 900); - cy.intercept("GET", "**/admin/auth/me", { statusCode: 200, body: adminBody }); + cy.intercept("GET", "**/admin/api/auth/me", { statusCode: 200, body: adminBody }); }); - // The page route `/admin/monitoring` and the API path `GET /admin/monitoring` - // are identical — a plain `**/admin/monitoring` glob also matches the - // `cy.visit()` document request (and whether that's same-origin or not - // depends on `VITE_API_URL`, which isn't set in CI). - // - // This used to be disambiguated with a declarative `resourceType: "fetch"` - // route matcher, but that stopped working (Cypress 13.17.0 on this - // machine's Chrome 152): the *declarative* `resourceType` matcher no - // longer catches this request at all — confirmed by logging every request - // Cypress hands to a `{resourceType: "fetch"}` intercept, which only ever - // saw `/admin/auth/me`, never `/admin/monitoring` — even though the very - // same request's own `req.resourceType`, read *inside* a handler matched - // by url/method alone, reliably reads back `"fetch"`. So the request *is* - // correctly typed; only the pre-filtering by that type is broken. Reading - // `req.resourceType` inside the handler and branching manually (as below) - // isn't affected by that bug and reproduced correctly in every check. + // Plain URL/method matcher is enough here: the API's `/admin/api/monitoring` + // can no longer collide with the page route `/admin/monitoring` (see + // `mountRouter("/admin/api", ...)`'s comment in the API's `app.ts` for the + // incident that made that distinction necessary in the first place). This + // used to need a `resourceType`-based workaround for that collision, which + // also surfaced an unrelated Cypress bug: on this machine's Chrome build, a + // *declarative* `resourceType` route matcher silently never caught a + // request whose own `req.resourceType` genuinely was that type — worth + // remembering if a `resourceType` matcher elsewhere in this suite ever + // starts failing the same way. function interceptMonitoringApi(response: { statusCode: number; body: unknown }) { - return cy.intercept("GET", "**/admin/monitoring", (req) => { - if (req.resourceType === "document") { - req.continue(); - return; - } - req.reply(response); - }); + return cy.intercept("GET", "**/admin/api/monitoring", response); } it("renders one card per service with its status and details", () => { diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index cb00316..85f4b79 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -54,7 +54,7 @@ import { ShoppingListPage } from "./pages/shopping-list/ShoppingListPage"; * * `/admin/*` is the internal admin surface (metrics, monitoring, correction * triage, off-catalog ingredient review). It's its own top-level group, - * wrapped in {@link AdminAuthProvider} so the `GET /admin/auth/me` session + * wrapped in {@link AdminAuthProvider} so the `GET /admin/api/auth/me` session * probe only runs under `/admin` — the admin session (cookie `admin_session`, * `ADMIN_JWT_SECRET`) is entirely separate from the end-user one, so this is * never nested under `RequireAuth`. `/admin/login` is the only diff --git a/apps/web/src/api/admin-client.ts b/apps/web/src/api/admin-client.ts index 37947e0..4a11b2c 100644 --- a/apps/web/src/api/admin-client.ts +++ b/apps/web/src/api/admin-client.ts @@ -26,13 +26,20 @@ function query(params: Record): string { } /** - * Base URL of the API — the same `VITE_API_URL` the user-facing - * {@link apiClient} reads (see `api/client.ts`). Defaults to `""` (same - * origin) — correct behind a shared reverse proxy; native dev overrides it - * to `http://localhost:3000` in `apps/web/.env`. The `/admin/*` surface is - * served by the same API process as the rest of the app. + * Base URL of the admin API — the same `VITE_API_URL` the user-facing + * {@link apiClient} reads (see `api/client.ts`), plus the `/admin/api` + * prefix every route below is relative to. That prefix (not just bare + * `/admin`) exists because this API is served from the same origin/process + * as apps/web's own `/admin/*` page routes (`/admin/monitoring`, + * `/admin/login`...) — a bare `/admin` mount put `GET /admin/monitoring` + * (API) and the page at that same path in direct conflict, so a hard + * refresh on the monitoring page got answered with raw JSON instead of the + * SPA (see `mountRouter("/admin/api", ...)`'s own comment in the API's + * `app.ts` for the full incident). Defaults to `/admin/api` (same origin) + * — correct behind a shared reverse proxy; native dev overrides + * `VITE_API_URL` to `http://localhost:3000` in `apps/web/.env`. */ -const ADMIN_API_BASE_URL: string = import.meta.env.VITE_API_URL ?? ""; +const ADMIN_API_BASE_URL: string = `${import.meta.env.VITE_API_URL ?? ""}/admin/api`; /** * Thrown by {@link AdminApiClient} on any non-2xx response — carries the @@ -55,7 +62,7 @@ export class AdminApiError extends Error { } /** - * Thin fetch wrapper around the `/admin/*` endpoints — same design as + * Thin fetch wrapper around the `/admin/api/*` 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. @@ -98,27 +105,27 @@ export class AdminApiClient { /** Verifies admin credentials and starts an admin session. */ public login(input: AdminLoginInput): Promise { - return this._request("/admin/auth/login", { method: "POST", body: JSON.stringify(input) }); + return this._request("/auth/login", { method: "POST", body: JSON.stringify(input) }); } /** Ends the current admin session. */ public logout(): Promise { - return this._request("/admin/auth/logout", { method: "POST" }); + return this._request("/auth/logout", { method: "POST" }); } /** Fetches the currently authenticated admin — rejects with `NOT_AUTHENTICATED` if there's no session. */ public me(): Promise { - return this._request("/admin/auth/me"); + return this._request("/auth/me"); } /** Usage metrics for the dashboard — snapshot totals + `days` (7–365) of daily time series. */ public getMetrics(days: number): Promise { - return this._request(`/admin/metrics?days=${days}`); + return this._request(`/metrics?days=${days}`); } /** Live health of Postgres, the API, the intent-service and the LLM worker — polled by the monitoring board. */ public getMonitoring(): Promise { - return this._request("/admin/monitoring"); + return this._request("/monitoring"); } /** Training suggestions, grouped by technique, filtered by the given (all-optional) criteria. */ @@ -128,7 +135,7 @@ export class AdminApiClient { techStepKey?: string; locale?: string; }): Promise { - return this._request(`/admin/tech-steps/suggestions${query(filters)}`); + return this._request(`/tech-steps/suggestions${query(filters)}`); } /** Raw user corrections, including the "no technique here" removals. */ @@ -136,7 +143,7 @@ export class AdminApiClient { consumed?: string; hasCorrectedTechStep?: string; }): Promise { - return this._request(`/admin/tech-steps/corrections${query(filters)}`); + return this._request(`/tech-steps/corrections${query(filters)}`); } /** Edits a suggestion's proposed synonyms/utterances and/or its status. */ @@ -144,7 +151,7 @@ export class AdminApiClient { id: number, body: UpdateTrainingSuggestionInput, ): Promise { - return this._request(`/admin/tech-steps/suggestions/${id}`, { + return this._request(`/tech-steps/suggestions/${id}`, { method: "PATCH", body: JSON.stringify(body), }); @@ -156,12 +163,12 @@ export class AdminApiClient { locale?: string; status?: string; }): Promise { - return this._request(`/admin/tech-steps/training-data-snippet${query(params)}`); + return this._request(`/tech-steps/training-data-snippet${query(params)}`); } /** Runs the F1 gate + backfill (+ marks suggestion ids). Rejects with `RETRAIN_ALREADY_RUNNING` if one is in flight. */ public retrain(body: RetrainRequestInput): Promise { - return this._request("/admin/tech-steps/retrain", { + return this._request("/tech-steps/retrain", { method: "POST", body: JSON.stringify(body), }); @@ -169,14 +176,14 @@ export class AdminApiClient { /** Off-catalog ingredient "placeholders" users typed, grouped by normalized name. `reviewed` omitted/`"false"` = the still-to-triage list, `"true"` = the archive. */ public getPlaceholders(reviewed?: "true" | "false"): Promise { - return this._request(`/admin/catalog/placeholders${query({ reviewed })}`); + return this._request(`/catalog/placeholders${query({ reviewed })}`); } /** Marks the given placeholder ingredient ids as triaged (`reviewedAt`). */ public markPlaceholdersReviewed( body: MarkPlaceholdersReviewedInput, ): Promise<{ reviewed: number }> { - return this._request("/admin/catalog/placeholders/mark-reviewed", { + return this._request("/catalog/placeholders/mark-reviewed", { method: "PATCH", body: JSON.stringify(body), }); @@ -184,7 +191,7 @@ export class AdminApiClient { /** Deletes placeholder rows no recipe references any more. */ public pruneOrphanPlaceholders(): Promise { - return this._request("/admin/catalog/placeholders/prune-orphans", { method: "POST" }); + return this._request("/catalog/placeholders/prune-orphans", { method: "POST" }); } } diff --git a/apps/web/src/features/admin/AdminAuthContext.tsx b/apps/web/src/features/admin/AdminAuthContext.tsx index 2decd90..c726ff5 100644 --- a/apps/web/src/features/admin/AdminAuthContext.tsx +++ b/apps/web/src/features/admin/AdminAuthContext.tsx @@ -6,7 +6,7 @@ import { adminApiClient } from "../../api/admin-client"; 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. */ + /** True only while the initial `GET /admin/api/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; @@ -18,7 +18,7 @@ const AdminAuthContext = createContext(null); /** * Provides admin authentication state to the whole app. On mount, calls - * `GET /admin/auth/me` once to restore the session from the `admin_session` + * `GET /admin/api/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`. */ diff --git a/apps/web/src/features/admin/RequireAdmin.tsx b/apps/web/src/features/admin/RequireAdmin.tsx index ba7e4bb..ec2acee 100644 --- a/apps/web/src/features/admin/RequireAdmin.tsx +++ b/apps/web/src/features/admin/RequireAdmin.tsx @@ -4,7 +4,7 @@ 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); + * `GET /admin/api/auth/me` check is pending (avoids a flash-then-redirect); * once resolved, renders `children` or redirects to `/admin/login`. Mirror * of `RequireAuth` (`features/auth/`), but keyed to the separate admin * session (`admin_session` cookie), not the user one. diff --git a/apps/web/src/pages/admin/dashboard/DashboardPage.tsx b/apps/web/src/pages/admin/dashboard/DashboardPage.tsx index 9515ba8..6faac6f 100644 --- a/apps/web/src/pages/admin/dashboard/DashboardPage.tsx +++ b/apps/web/src/pages/admin/dashboard/DashboardPage.tsx @@ -15,7 +15,7 @@ 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. */ +/** Load state for `GET /admin/api/metrics` — same discriminated-union shape as apps/web's page states. */ type DashboardState = | { status: "loading" } | { status: "loaded"; metrics: MetricsView } @@ -26,7 +26,7 @@ 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. + * Fed by `GET /admin/api/metrics`; read-only, refetched only on mount. */ export function DashboardPage() { const { t } = useTranslation(); diff --git a/apps/web/src/pages/admin/login/AdminLoginPage.tsx b/apps/web/src/pages/admin/login/AdminLoginPage.tsx index 4ac0f24..e2485e5 100644 --- a/apps/web/src/pages/admin/login/AdminLoginPage.tsx +++ b/apps/web/src/pages/admin/login/AdminLoginPage.tsx @@ -11,7 +11,7 @@ import { errorMessageService } from "../../../services/error-message.service"; /** * The admin login screen — the only unauthenticated route under `/admin`. * Client-side validation via the shared `adminLoginSchema` (same rules the - * API enforces), then `POST /admin/auth/login`; any API failure is + * API enforces), then `POST /admin/api/auth/login`; any API failure is * translated to a localized label via {@link ErrorMessageService}. Same * structure as the user-facing `LoginPage` (`pages/auth/`). */ diff --git a/apps/web/src/pages/admin/monitoring/MonitoringPage.tsx b/apps/web/src/pages/admin/monitoring/MonitoringPage.tsx index 0e1564b..cf80284 100644 --- a/apps/web/src/pages/admin/monitoring/MonitoringPage.tsx +++ b/apps/web/src/pages/admin/monitoring/MonitoringPage.tsx @@ -12,7 +12,7 @@ type MonitoringState = | { status: "error" }; /** - * Microservice health board. Fetches `GET /admin/monitoring` on mount and + * Microservice health board. Fetches `GET /admin/api/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. */ diff --git a/apps/web/src/pages/admin/monitoring/monitoring.ts b/apps/web/src/pages/admin/monitoring/monitoring.ts index 215bf8b..95344db 100644 --- a/apps/web/src/pages/admin/monitoring/monitoring.ts +++ b/apps/web/src/pages/admin/monitoring/monitoring.ts @@ -10,7 +10,7 @@ export function statusModifier(status: ServiceStatus): string { return status; } -/** How often the board re-polls `GET /admin/monitoring`, in ms. */ +/** How often the board re-polls `GET /admin/api/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. */ diff --git a/packages/shared/src/errors/error-codes.ts b/packages/shared/src/errors/error-codes.ts index a3f5a29..cb62ce2 100644 --- a/packages/shared/src/errors/error-codes.ts +++ b/packages/shared/src/errors/error-codes.ts @@ -40,7 +40,7 @@ export enum ErrorCode { RECIPE_IN_USE = 4021, /** `POST /sources/:sourceKey/import/:externalId` attempted on an item already imported (a `Recipe` already exists for that `sourceId`/`externalId` pair). */ RECIPE_ALREADY_IMPORTED = 4022, - /** `POST /admin/tech-steps/retrain` attempted while a previous retrain (F1 gate + backfill) is still running — it holds a process-wide lock so two can't overlap. */ + /** `POST /admin/api/tech-steps/retrain` attempted while a previous retrain (F1 gate + backfill) is still running — it holds a process-wide lock so two can't overlap. */ RETRAIN_ALREADY_RUNNING = 4023, /** A household action reserved to its admin (delete the household, remove a member) attempted by a non-admin member. */ NOT_HOUSE_ADMIN = 4030, diff --git a/packages/shared/src/schemas/admin.ts b/packages/shared/src/schemas/admin.ts index 2a12be0..6c00561 100644 --- a/packages/shared/src/schemas/admin.ts +++ b/packages/shared/src/schemas/admin.ts @@ -6,7 +6,7 @@ import { z } from "zod"; // as-is in the admin login form. /** - * Payload accepted by `POST /admin/auth/login`. Deliberately its own schema + * Payload accepted by `POST /admin/api/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. */ @@ -18,7 +18,7 @@ export const adminLoginSchema = z.object({ export type AdminLoginInput = z.infer; /** - * Query params for `GET /admin/metrics` — `?days=` bounds how far back the + * Query params for `GET /admin/api/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. @@ -53,7 +53,7 @@ export const TRAINING_SUGGESTION_STATUSES = ["pending", "applied", "rejected"] a /** One of {@link TRAINING_SUGGESTION_STATUSES}. */ export type TrainingSuggestionStatus = (typeof TRAINING_SUGGESTION_STATUSES)[number]; -/** Query params for `GET /admin/tech-steps/suggestions` — every filter optional. */ +/** Query params for `GET /admin/api/tech-steps/suggestions` — every filter optional. */ export const listSuggestionsQuerySchema = z.object({ status: z.enum(TRAINING_SUGGESTION_STATUSES).optional(), sourceType: z.enum(["correction", "llm_audit"]).optional(), @@ -63,7 +63,7 @@ export const listSuggestionsQuerySchema = z.object({ /** Inferred TS type for {@link listSuggestionsQuerySchema}. */ export type ListSuggestionsQuery = z.infer; -/** Query params for `GET /admin/tech-steps/corrections`. `consumed`/`hasCorrectedTechStep` are tri-state (omitted = no filter). */ +/** Query params for `GET /admin/api/tech-steps/corrections`. `consumed`/`hasCorrectedTechStep` are tri-state (omitted = no filter). */ export const listCorrectionsQuerySchema = z.object({ consumed: z.enum(["true", "false"]).optional(), hasCorrectedTechStep: z.enum(["true", "false"]).optional(), @@ -72,7 +72,7 @@ export const listCorrectionsQuerySchema = z.object({ export type ListCorrectionsQuery = z.infer; /** - * Body of `PATCH /admin/tech-steps/suggestions/:id` — curate a suggestion + * Body of `PATCH /admin/api/tech-steps/suggestions/:id` — curate a suggestion * before it feeds a `training_data.py` edit. Every field optional; at least * one must be present (enforced service-side). */ @@ -85,7 +85,7 @@ export const updateTrainingSuggestionSchema = z.object({ export type UpdateTrainingSuggestionInput = z.infer; /** - * Body of `POST /admin/tech-steps/retrain` — runs the F1 regression gate + * Body of `POST /admin/api/tech-steps/retrain` — runs the F1 regression gate * then, if it passes, backfills every step and marks the given suggestion * ids. Both id lists optional (an empty run just re-gates + backfills). */ @@ -96,7 +96,7 @@ export const retrainRequestSchema = z.object({ /** Inferred TS type for {@link retrainRequestSchema}. */ export type RetrainRequestInput = z.infer; -/** Query params for `GET /admin/tech-steps/training-data-snippet`. */ +/** Query params for `GET /admin/api/tech-steps/training-data-snippet`. */ export const trainingDataSnippetQuerySchema = z.object({ techStepKey: z.string().min(1), locale: z.string().min(1).default("fr"), @@ -106,7 +106,7 @@ export const trainingDataSnippetQuerySchema = z.object({ export type TrainingDataSnippetQuery = z.infer; /** - * Query params for `GET /admin/catalog/placeholders` — the off-catalog + * Query params for `GET /admin/api/catalog/placeholders` — the off-catalog * ingredient review. `reviewed` is tri-state: omitted / `"false"` hides * groups every row of which has already been triaged (the default working * view), `"true"` shows only fully-triaged groups. @@ -118,7 +118,7 @@ export const listPlaceholdersQuerySchema = z.object({ export type ListPlaceholdersQuery = z.infer; /** - * Body of `PATCH /admin/catalog/placeholders/mark-reviewed` — stamps + * Body of `PATCH /admin/api/catalog/placeholders/mark-reviewed` — stamps * `reviewedAt` on the given placeholder `Ingredient` ids (a maintainer has * seen this catalog gap and, if warranted, added the real entry by hand). * Bounded so one call can't sweep an unbounded set. diff --git a/packages/shared/src/types/admin.ts b/packages/shared/src/types/admin.ts index c6ddbac..9c25095 100644 --- a/packages/shared/src/types/admin.ts +++ b/packages/shared/src/types/admin.ts @@ -1,6 +1,6 @@ /** - * Public shape of an admin operator, as returned by `GET /admin/auth/me` - * and `POST /admin/auth/login` — never includes the password hash. Mirrors + * Public shape of an admin operator, as returned by `GET /admin/api/auth/me` + * and `POST /admin/api/auth/login` — never includes the password hash. Mirrors * apps/api's `Omit`, * declared by hand rather than derived from the Prisma type (same reason as * {@link SafeUserProfile}: apps/admin-web must not depend on @@ -67,7 +67,7 @@ export interface MetricsEventSeries { } /** - * Response of `GET /admin/metrics`. `snapshot` is "right now"; `series` and + * Response of `GET /admin/api/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. @@ -96,7 +96,7 @@ export interface MetricsView { */ export type ServiceStatus = "up" | "degraded" | "down" | "unknown"; -/** One row of `GET /admin/monitoring` — a single probed target. */ +/** One row of `GET /admin/api/monitoring` — a single probed target. */ export interface ServiceHealthView { /** Stable id — `"postgres"`, `"api"`, `"intent-service"`, `"tech-step-llm-worker"`. Resolved to a label client-side via `admin.monitoring.service.`. */ key: string; @@ -111,7 +111,7 @@ export interface ServiceHealthView { lastResult?: { job?: string; ok?: boolean; counts?: Record } | null; } -/** Response of `GET /admin/monitoring` — an actively-probed status board. */ +/** Response of `GET /admin/api/monitoring` — an actively-probed status board. */ export interface MonitoringView { generatedAt: string; services: ServiceHealthView[]; @@ -143,7 +143,7 @@ export interface TrainingSuggestionAdminView { } | null; } -/** Suggestions grouped by their technique key — the shape `GET /admin/tech-steps/suggestions` returns. */ +/** Suggestions grouped by their technique key — the shape `GET /admin/api/tech-steps/suggestions` returns. */ export interface TrainingSuggestionGroupView { techStepKey: string; suggestions: TrainingSuggestionAdminView[]; @@ -168,7 +168,7 @@ export interface CorrectionAdminView { consumedAt: string | null; } -/** Response of `GET /admin/tech-steps/training-data-snippet` — a ready-to-paste block. */ +/** Response of `GET /admin/api/tech-steps/training-data-snippet` — a ready-to-paste block. */ export interface TrainingDataSnippetView { techStepKey: string; locale: string; @@ -182,7 +182,7 @@ export interface TrainingDataSnippetView { } /** - * Result of `POST /admin/tech-steps/retrain`. `gatePassed` is the F1 + * Result of `POST /admin/api/tech-steps/retrain`. `gatePassed` is the F1 * regression gate (`f1 >= minF1`); `backfilled` is present only when the * gate passed (a failed gate refuses to backfill). `marked` echoes how * many suggestion ids were actually flipped. @@ -198,7 +198,7 @@ export interface RetrainResultView { } /** - * One row of `GET /admin/catalog/placeholders` — every placeholder + * One row of `GET /admin/api/catalog/placeholders` — every placeholder * `Ingredient` (see `Ingredient.isPlaceholder` in schema.prisma) sharing * one normalized name, so a maintainer sees "this ingredient is missing * from the catalog, and N recipes are waiting on it" rather than a flat @@ -224,7 +224,7 @@ export interface CatalogPlaceholderGroupView { allReviewed: boolean; } -/** Result of `POST /admin/catalog/placeholders/prune-orphans` — how many placeholder rows with zero remaining recipe references were deleted. */ +/** Result of `POST /admin/api/catalog/placeholders/prune-orphans` — how many placeholder rows with zero remaining recipe references were deleted. */ export interface PruneOrphansResultView { deleted: number; } diff --git a/specs/backend-architecture.md b/specs/backend-architecture.md index bda0b23..ffba337 100644 --- a/specs/backend-architecture.md +++ b/specs/backend-architecture.md @@ -355,6 +355,13 @@ Surface d'exploitation consommée par les routes `/admin/*` de `apps/web` `apps/api` (qui reste seul propriétaire du schéma) mais avec une **authentification totalement distincte** de celle des utilisateurs. +Son API est montée sous `/admin/api`, pas bare `/admin` — les deux surfaces +partagent la même origine/le même process (`serveStaticFrontend`, `app.ts`), +donc un mount bare `/admin` mettait chaque route API en conflit direct avec +la page front de même nom (ex. `GET /admin/monitoring` répondait à la place +du SPA sur un rafraîchissement de `/admin/monitoring` — JSON brut au lieu de +la page). Incident réel, corrigé 2026-09-05. + **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` @@ -365,10 +372,10 @@ hors-bande par `src/scripts/create-admin.ts` (flags ou `ADMIN_INITIAL_*`). `res.locals.adminUser` typé `AdminLocals`. Pas de CORS dédié : l'UI admin est servie par la même origine que le reste de l'app (`CORS_ORIGIN` suffit). -Router agrégateur `modules/admin/admin.routes.ts` monté `/admin` : -`/admin/auth` (`login`/`logout`/`me`), `/admin/metrics` (ci-dessous). +Router agrégateur `modules/admin/admin.routes.ts` monté `/admin/api` : +`/admin/api/auth` (`login`/`logout`/`me`), `/admin/api/metrics` (ci-dessous). -**Métriques** (`admin-metrics.service.ts`, `GET /admin/metrics?days=` 7–365, +**Métriques** (`admin-metrics.service.ts`, `GET /admin/api/metrics?days=` 7–365, défaut 30) — `MetricsView` = `snapshot` (des `count`s : utilisateurs, foyers, foyers actifs, recettes manuelles/importées + ventilation par source, plannings, créneaux, corrections par état, suggestions par statut/source…) + @@ -388,7 +395,7 @@ est un `String` libre (`"user.signup"`, `"recipe.imported"`, `"recipe.created"`, ligne, **sans migration**. `AnalyticsEvent.actorId` n'a **pas** de FK (un évènement est un fait historique qui survit au compte qu'il décrit). -**Monitoring** (`admin-monitoring.service.ts`, `GET /admin/monitoring`, +**Monitoring** (`admin-monitoring.service.ts`, `GET /admin/api/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 @@ -406,7 +413,7 @@ heartbeat en échec ne casse jamais un run. Seuils d'âge : > 8 j ⇒ `degraded` > 21 j ⇒ `down` (cron par défaut hebdomadaire). **Tri des corrections** (`admin-tech-steps.service.ts`, routes -`/admin/tech-steps/*`, `requireAdmin`) — remplace le duo CLI +`/admin/api/tech-steps/*`, `requireAdmin`) — remplace le duo CLI `list-pending-training-suggestions.ts` / `retrain-tech-steps.ts` : - `GET /suggestions` — `TechStepTrainingSuggestion` filtrées @@ -429,7 +436,7 @@ heartbeat en échec ne casse jamais un run. Seuils d'âge : > 8 j ⇒ `degraded` ces deux étapes restent manuelles, l'UI l'affiche en bandeau permanent. **Ingrédients hors-catalogue** (`admin-catalog.service.ts`, routes -`/admin/catalog/*`, `requireAdmin`) — revue des lignes `Ingredient` +`/admin/api/catalog/*`, `requireAdmin`) — revue des lignes `Ingredient` `isPlaceholder` (voir la section *Recettes* et `batch-cooking-modele.md`) : - `GET /placeholders?reviewed=` — tous les placeholders, **regroupés par nom @@ -642,8 +649,8 @@ existant qui fait l'aller-retour par son id réel), **soit** `placeholderName` exclues de `GET /reference/ingredients` et de `loadIngredientCatalog` (`ingredient-matcher.ts`). Retirer une ligne placeholder d'une recette laisse une ligne `Ingredient` orpheline (non-GC — purge manuelle via -`/admin/catalog/placeholders/prune-orphans`). Revue côté admin : -`/admin/catalog/*` (section *admin* ci-dessus). +`/admin/api/catalog/placeholders/prune-orphans`). Revue côté admin : +`/admin/api/catalog/*` (section *admin* ci-dessus). ### Détection des techniques — `tech-step-matcher.ts` diff --git a/specs/batch-cooking-modele.md b/specs/batch-cooking-modele.md index 129f43e..a36d529 100644 --- a/specs/batch-cooking-modele.md +++ b/specs/batch-cooking-modele.md @@ -323,7 +323,7 @@ ci-dessous), seules lignes de cette table jamais issues du seed. | `icon` | `IngredientIcon` — ~20 pictogrammes génériques par *type* de chose (légume, bouteille d'huile, fromage…), pas un emoji par ingrédient (437 rejetés comme peu pro) — voir `apps/web/src/features/recipes/ingredients/ingredient-icons.tsx` | | `category` / `subcategory` | `IngredientCategory` (7 rayons) / `IngredientSubcategory` (racks plus fins) — organisation "rayon de supermarché français" pour permettre le parcours par catégorie dans le picker (400+ ingrédients, la recherche seule ne suffit pas) | | `reproducible` | Vrai si raisonnablement faisable maison (un pain burger, une béchamel) plutôt qu'un achat systématique — juste un flag, pas un lien vers une recette précise (un ancien `alternateRecipeId` jamais câblé a été retiré) | -| `isPlaceholder` / `displayName` / `createdById` / `createdAt` / `reviewedAt` | **Ingrédient hors-catalogue** : quand le catalogue ne couvre pas un ingrédient, l'utilisateur peut saisir un texte libre sur une ligne de recette (`placeholderName` dans le payload `POST/PATCH /recipes`) — l'API crée alors une ligne `Ingredient` `isPlaceholder=true`, `displayName` = le texte, `createdById`/`createdAt` renseignés. `RecipeIngredient` la référence comme n'importe quel ingrédient, mais `GET /reference/ingredients` et `ingredient-matcher.ts` l'excluent (jamais parcourable/matchable). `reviewedAt` est posé quand un admin a traité le manque via `/admin/catalog/*`. La promotion en vraie entrée catalogue (édition de `reference-seed-data.ts` + locales) reste **manuelle**. | +| `isPlaceholder` / `displayName` / `createdById` / `createdAt` / `reviewedAt` | **Ingrédient hors-catalogue** : quand le catalogue ne couvre pas un ingrédient, l'utilisateur peut saisir un texte libre sur une ligne de recette (`placeholderName` dans le payload `POST/PATCH /recipes`) — l'API crée alors une ligne `Ingredient` `isPlaceholder=true`, `displayName` = le texte, `createdById`/`createdAt` renseignés. `RecipeIngredient` la référence comme n'importe quel ingrédient, mais `GET /reference/ingredients` et `ingredient-matcher.ts` l'excluent (jamais parcourable/matchable). `reviewedAt` est posé quand un admin a traité le manque via `/admin/api/catalog/*`. La promotion en vraie entrée catalogue (édition de `reference-seed-data.ts` + locales) reste **manuelle**. | `IngredientDiet` (m2m, régimes compatibles — omet volontairement `Omnivore` et `Sans gluten`, ce dernier dérivable de `IngredientAllergy`) et -- 2.45.2