fix(admin): isole l'API admin sous /admin/api pour éviter la collision avec les pages front #33

Merged
kyuno merged 1 commit from fix/admin-monitoring-spa-route-collision into main 2026-09-05 14:24:59 +02:00
31 changed files with 184 additions and 169 deletions

View file

@ -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

View file

@ -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;

View file

@ -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.

View file

@ -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();
/**

View file

@ -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();
/**

View file

@ -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(

View file

@ -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();

View file

@ -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);
});

View file

@ -47,7 +47,7 @@ async function seedPlaceholderRecipe(name: string): Promise<number> {
}
/**
* `/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);

View file

@ -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);

View file

@ -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",
);

View file

@ -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;

View file

@ -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");

View file

@ -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,

View file

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

View file

@ -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.

View file

@ -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 });
});

View file

@ -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", () => {

View file

@ -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

View file

@ -26,13 +26,20 @@ function query(params: Record<string, string | undefined>): 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<AdminUserView> {
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<void> {
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<AdminUserView> {
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<MetricsView> {
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<MonitoringView> {
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<TrainingSuggestionGroupView[]> {
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<CorrectionAdminView[]> {
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<TrainingSuggestionAdminView> {
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<TrainingDataSnippetView> {
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<RetrainResultView> {
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<CatalogPlaceholderGroupView[]> {
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<PruneOrphansResultView> {
return this._request("/admin/catalog/placeholders/prune-orphans", { method: "POST" });
return this._request("/catalog/placeholders/prune-orphans", { method: "POST" });
}
}

View file

@ -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<void>;
@ -18,7 +18,7 @@ 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`
* `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`.
*/

View file

@ -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.

View file

@ -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();

View file

@ -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/`).
*/

View file

@ -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.
*/

View file

@ -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. */

View file

@ -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,

View file

@ -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<typeof adminLoginSchema>;
/**
* 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<typeof listSuggestionsQuerySchema>;
/** 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<typeof listCorrectionsQuerySchema>;
/**
* 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<typeof updateTrainingSuggestionSchema>;
/**
* 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<typeof retrainRequestSchema>;
/** 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<typeof trainingDataSnippetQuerySchema>;
/**
* 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<typeof listPlaceholdersQuerySchema>;
/**
* 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.

View file

@ -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<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
@ -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>`. */
key: string;
@ -111,7 +111,7 @@ export interface ServiceHealthView {
lastResult?: { job?: string; ok?: boolean; counts?: Record<string, number> } | 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;
}

View file

@ -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`

View file

@ -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