Merge pull request 'fix(admin): isole l'API admin sous /admin/api pour éviter la collision avec les pages front' (#33) from fix/admin-monitoring-spa-route-collision into main
Reviewed-on: #33
This commit is contained in:
commit
71c409cf32
31 changed files with 184 additions and 169 deletions
|
|
@ -45,10 +45,18 @@ export function createServer(): ExpressServer {
|
||||||
});
|
});
|
||||||
|
|
||||||
server.mountRouter("/auth", authRouter);
|
server.mountRouter("/auth", authRouter);
|
||||||
// Admin application surface (`apps/web`'s `/admin/*` routes) — its own
|
// Admin application surface — its own auth (`requireAdmin`, distinct
|
||||||
// auth (`requireAdmin`, distinct cookie/secret), never the end-user
|
// cookie/secret), never the end-user session. Mounted at `/admin/api`,
|
||||||
// session.
|
// *not* bare `/admin`: apps/web's own admin pages live at `/admin/*` too
|
||||||
server.mountRouter("/admin", adminRouter);
|
// (`/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);
|
server.mountRouter("/house", houseRouter);
|
||||||
// Not user-facing — `services/tech-step-llm-worker` only, guarded by
|
// Not user-facing — `services/tech-step-llm-worker` only, guarded by
|
||||||
// `requireInternalWorker` on every route within (see that router's own
|
// `requireInternalWorker` on every route within (see that router's own
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ import { env } from "../../config/env.js";
|
||||||
import { type AdminLocals, requireAdmin } from "../../middlewares/require-admin.js";
|
import { type AdminLocals, requireAdmin } from "../../middlewares/require-admin.js";
|
||||||
import { adminLogin } from "./admin-auth.service.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();
|
export const adminAuthRouter = Router();
|
||||||
|
|
||||||
const SEVEN_DAYS_MS = 7 * 24 * 60 * 60 * 1000;
|
const SEVEN_DAYS_MS = 7 * 24 * 60 * 60 * 1000;
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ import {
|
||||||
} from "./admin-catalog.service.js";
|
} 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
|
* behind {@link requireAdmin}. Surfaces the off-catalog ingredient
|
||||||
* "placeholders" users typed when the seeded catalog fell short, so a
|
* "placeholders" users typed when the seeded catalog fell short, so a
|
||||||
* maintainer can see what's missing and mark gaps as handled.
|
* maintainer can see what's missing and mark gaps as handled.
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ import { Router } from "express";
|
||||||
import { requireAdmin } from "../../middlewares/require-admin.js";
|
import { requireAdmin } from "../../middlewares/require-admin.js";
|
||||||
import { getMetrics } from "./admin-metrics.service.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();
|
export const adminMetricsRouter = Router();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ import { Router } from "express";
|
||||||
import { requireAdmin } from "../../middlewares/require-admin.js";
|
import { requireAdmin } from "../../middlewares/require-admin.js";
|
||||||
import { getMonitoring } from "./admin-monitoring.service.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();
|
export const adminMonitoringRouter = Router();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,7 @@ import {
|
||||||
updateSuggestion,
|
updateSuggestion,
|
||||||
} from "./admin-tech-steps.service.js";
|
} 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();
|
export const adminTechStepsRouter = Router();
|
||||||
|
|
||||||
adminTechStepsRouter.get(
|
adminTechStepsRouter.get(
|
||||||
|
|
|
||||||
|
|
@ -6,11 +6,13 @@ import { adminMonitoringRouter } from "./admin-monitoring.routes.js";
|
||||||
import { adminTechStepsRouter } from "./admin-tech-steps.routes.js";
|
import { adminTechStepsRouter } from "./admin-tech-steps.routes.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Aggregator for the admin application's API surface, mounted at `/admin`
|
* Aggregator for the admin application's API surface, mounted at
|
||||||
* in `app.ts`. Every sub-router here backs `apps/web`'s `/admin/*` routes
|
* `/admin/api` in `app.ts` (not bare `/admin` — see that mount point's own
|
||||||
* only — `/admin/auth` is public (login), everything added later
|
* comment for why: it would collide with apps/web's `/admin/*` page
|
||||||
* (`/admin/metrics`, `/admin/monitoring`, `/admin/tech-steps/*`,
|
* routes). Every sub-router here backs one of those pages —
|
||||||
* `/admin/catalog/*`) sits behind `requireAdmin`
|
* `/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`).
|
* (`middlewares/require-admin.ts`).
|
||||||
*/
|
*/
|
||||||
export const adminRouter = Router();
|
export const adminRouter = Router();
|
||||||
|
|
|
||||||
|
|
@ -44,16 +44,16 @@ describe("Admin auth", () => {
|
||||||
await prisma.$disconnect();
|
await prisma.$disconnect();
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("POST /admin/auth/login", () => {
|
describe("POST /admin/api/auth/login", () => {
|
||||||
it("rejects a missing body with 400 VALIDATION_ERROR", async () => {
|
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.status).to.equal(400);
|
||||||
expect(res.body.code).to.equal(ErrorCode.VALIDATION_ERROR);
|
expect(res.body.code).to.equal(ErrorCode.VALIDATION_ERROR);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("rejects an unknown email with 401 INVALID_CREDENTIALS", async () => {
|
it("rejects an unknown email with 401 INVALID_CREDENTIALS", async () => {
|
||||||
const res = await request(app)
|
const res = await request(app)
|
||||||
.post("/admin/auth/login")
|
.post("/admin/api/auth/login")
|
||||||
.send({ email: "nobody@example.com", password: "whatever" });
|
.send({ email: "nobody@example.com", password: "whatever" });
|
||||||
expect(res.status).to.equal(401);
|
expect(res.status).to.equal(401);
|
||||||
expect(res.body.code).to.equal(ErrorCode.INVALID_CREDENTIALS);
|
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 () => {
|
it("rejects a wrong password with 401 INVALID_CREDENTIALS", async () => {
|
||||||
const { email } = await seedAdmin();
|
const { email } = await seedAdmin();
|
||||||
const res = await request(app)
|
const res = await request(app)
|
||||||
.post("/admin/auth/login")
|
.post("/admin/api/auth/login")
|
||||||
.send({ email, password: "not-the-password" });
|
.send({ email, password: "not-the-password" });
|
||||||
expect(res.status).to.equal(401);
|
expect(res.status).to.equal(401);
|
||||||
expect(res.body.code).to.equal(ErrorCode.INVALID_CREDENTIALS);
|
expect(res.body.code).to.equal(ErrorCode.INVALID_CREDENTIALS);
|
||||||
|
|
@ -76,7 +76,7 @@ describe("Admin auth", () => {
|
||||||
}
|
}
|
||||||
const { email, password } = await seedAdmin();
|
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.status).to.equal(200);
|
||||||
expect(res.body.email).to.equal(email);
|
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 () => {
|
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.status).to.equal(401);
|
||||||
expect(res.body.code).to.equal(ErrorCode.NOT_AUTHENTICATED);
|
expect(res.body.code).to.equal(ErrorCode.NOT_AUTHENTICATED);
|
||||||
});
|
});
|
||||||
|
|
@ -109,9 +109,9 @@ describe("Admin auth", () => {
|
||||||
}
|
}
|
||||||
const { email, password } = await seedAdmin();
|
const { email, password } = await seedAdmin();
|
||||||
const agent = request.agent(app);
|
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.status).to.equal(200);
|
||||||
expect(res.body.email).to.equal(email);
|
expect(res.body.email).to.equal(email);
|
||||||
});
|
});
|
||||||
|
|
@ -124,12 +124,12 @@ describe("Admin auth", () => {
|
||||||
}
|
}
|
||||||
const { email, password } = await seedAdmin();
|
const { email, password } = await seedAdmin();
|
||||||
const agent = request.agent(app);
|
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);
|
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);
|
expect(meRes.status).to.equal(401);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -141,7 +141,7 @@ describe("Admin auth", () => {
|
||||||
const agent = request.agent(app);
|
const agent = request.agent(app);
|
||||||
await agent.post("/auth/signup").send(buildSignupPayload());
|
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.status).to.equal(401);
|
||||||
expect(res.body.code).to.equal(ErrorCode.NOT_AUTHENTICATED);
|
expect(res.body.code).to.equal(ErrorCode.NOT_AUTHENTICATED);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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
|
* behind `requireAdmin`; the list groups placeholder rows by normalized
|
||||||
* name, `mark-reviewed` stamps `reviewedAt`, `prune-orphans` deletes rows
|
* name, `mark-reviewed` stamps `reviewedAt`, `prune-orphans` deletes rows
|
||||||
* no recipe references any more.
|
* no recipe references any more.
|
||||||
|
|
@ -66,18 +66,18 @@ describe("Admin catalog — off-catalog ingredients", () => {
|
||||||
async function adminAgent() {
|
async function adminAgent() {
|
||||||
const { email, password } = await seedAdmin();
|
const { email, password } = await seedAdmin();
|
||||||
const agent = request.agent(app);
|
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;
|
return agent;
|
||||||
}
|
}
|
||||||
|
|
||||||
it("rejects every route without an admin session", async () => {
|
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);
|
expect(get.status).to.equal(401);
|
||||||
const patch = await request(app)
|
const patch = await request(app)
|
||||||
.patch("/admin/catalog/placeholders/mark-reviewed")
|
.patch("/admin/api/catalog/placeholders/mark-reviewed")
|
||||||
.send({ ingredientIds: [1] });
|
.send({ ingredientIds: [1] });
|
||||||
expect(patch.status).to.equal(401);
|
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);
|
expect(post.status).to.equal(401);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -87,7 +87,7 @@ describe("Admin catalog — off-catalog ingredients", () => {
|
||||||
await seedPlaceholderRecipe("Sumac");
|
await seedPlaceholderRecipe("Sumac");
|
||||||
|
|
||||||
const agent = await adminAgent();
|
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.status).to.equal(200);
|
||||||
expect(res.body).to.have.length(2);
|
expect(res.body).to.have.length(2);
|
||||||
|
|
||||||
|
|
@ -106,7 +106,7 @@ describe("Admin catalog — off-catalog ingredients", () => {
|
||||||
const agent = await adminAgent();
|
const agent = await adminAgent();
|
||||||
|
|
||||||
const patched = await agent
|
const patched = await agent
|
||||||
.patch("/admin/catalog/placeholders/mark-reviewed")
|
.patch("/admin/api/catalog/placeholders/mark-reviewed")
|
||||||
.send({ ingredientIds: [id] });
|
.send({ ingredientIds: [id] });
|
||||||
expect(patched.status).to.equal(200);
|
expect(patched.status).to.equal(200);
|
||||||
expect(patched.body.reviewed).to.equal(1);
|
expect(patched.body.reviewed).to.equal(1);
|
||||||
|
|
@ -114,9 +114,9 @@ describe("Admin catalog — off-catalog ingredients", () => {
|
||||||
(await prisma.ingredient.findUniqueOrThrow({ where: { id } })).reviewedAt,
|
(await prisma.ingredient.findUniqueOrThrow({ where: { id } })).reviewedAt,
|
||||||
).to.be.an.instanceOf(Date);
|
).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);
|
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).to.have.length(1);
|
||||||
expect(reviewed.body[0].allReviewed).to.equal(true);
|
expect(reviewed.body[0].allReviewed).to.equal(true);
|
||||||
});
|
});
|
||||||
|
|
@ -133,7 +133,7 @@ describe("Admin catalog — off-catalog ingredients", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
const agent = await adminAgent();
|
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.status).to.equal(200);
|
||||||
expect(res.body.deleted).to.equal(1);
|
expect(res.body.deleted).to.equal(1);
|
||||||
expect(await prisma.ingredient.count({ where: { isPlaceholder: true } })).to.equal(1);
|
expect(await prisma.ingredient.count({ where: { isPlaceholder: true } })).to.equal(1);
|
||||||
|
|
|
||||||
|
|
@ -78,7 +78,7 @@ describe("Admin metrics", () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("GET /admin/metrics", () => {
|
describe("GET /admin/api/metrics", () => {
|
||||||
const app = createApp();
|
const app = createApp();
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
|
|
@ -90,7 +90,7 @@ describe("Admin metrics", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it("rejects a request with no admin session with 401", async () => {
|
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);
|
expect(res.status).to.equal(401);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -109,9 +109,9 @@ describe("Admin metrics", () => {
|
||||||
await userB.post("/auth/signup").send(buildSignupPayload());
|
await userB.post("/auth/signup").send(buildSignupPayload());
|
||||||
|
|
||||||
const adminAgent = request.agent(app);
|
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.status).to.equal(200);
|
||||||
expect(res.body.rangeDays).to.equal(14);
|
expect(res.body.rangeDays).to.equal(14);
|
||||||
expect(res.body.snapshot.users).to.equal(2);
|
expect(res.body.snapshot.users).to.equal(2);
|
||||||
|
|
|
||||||
|
|
@ -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 () => {
|
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);
|
expect(res.status).to.equal(401);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -114,9 +114,9 @@ describe("Admin monitoring", () => {
|
||||||
}
|
}
|
||||||
const { email, password } = await seedAdmin();
|
const { email, password } = await seedAdmin();
|
||||||
const agent = request.agent(app);
|
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);
|
expect(res.status).to.equal(200);
|
||||||
|
|
||||||
const keys = res.body.services.map((s: { key: string }) => s.key);
|
const keys = res.body.services.map((s: { key: string }) => s.key);
|
||||||
|
|
@ -146,9 +146,9 @@ describe("Admin monitoring", () => {
|
||||||
|
|
||||||
const { email, password } = await seedAdmin();
|
const { email, password } = await seedAdmin();
|
||||||
const agent = request.agent(app);
|
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(
|
const worker = res.body.services.find(
|
||||||
(s: { key: string }) => s.key === "tech-step-llm-worker",
|
(s: { key: string }) => s.key === "tech-step-llm-worker",
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -92,20 +92,20 @@ describe("Admin tech-steps triage", () => {
|
||||||
async function adminAgent() {
|
async function adminAgent() {
|
||||||
const { email, password } = await seedAdmin();
|
const { email, password } = await seedAdmin();
|
||||||
const agent = request.agent(app);
|
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;
|
return agent;
|
||||||
}
|
}
|
||||||
|
|
||||||
it("rejects every route without an admin session", async () => {
|
it("rejects every route without an admin session", async () => {
|
||||||
for (const path of [
|
for (const path of [
|
||||||
"/admin/tech-steps/suggestions",
|
"/admin/api/tech-steps/suggestions",
|
||||||
"/admin/tech-steps/corrections",
|
"/admin/api/tech-steps/corrections",
|
||||||
"/admin/tech-steps/training-data-snippet?techStepKey=simmer",
|
"/admin/api/tech-steps/training-data-snippet?techStepKey=simmer",
|
||||||
]) {
|
]) {
|
||||||
const res = await request(app).get(path);
|
const res = await request(app).get(path);
|
||||||
expect(res.status, path).to.equal(401);
|
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);
|
expect(post.status).to.equal(401);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -129,7 +129,7 @@ describe("Admin tech-steps triage", () => {
|
||||||
|
|
||||||
const agent = await adminAgent();
|
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.status).to.equal(200);
|
||||||
expect(all.body.map((g: { techStepKey: string }) => g.techStepKey)).to.have.members([
|
expect(all.body.map((g: { techStepKey: string }) => g.techStepKey)).to.have.members([
|
||||||
"chop",
|
"chop",
|
||||||
|
|
@ -139,7 +139,7 @@ describe("Admin tech-steps triage", () => {
|
||||||
expect(simmerGroup.suggestions[0].sourceCorrection.clauseText).to.equal("Faire mijoter");
|
expect(simmerGroup.suggestions[0].sourceCorrection.clauseText).to.equal("Faire mijoter");
|
||||||
|
|
||||||
const pendingOnly = await agent
|
const pendingOnly = await agent
|
||||||
.get("/admin/tech-steps/suggestions")
|
.get("/admin/api/tech-steps/suggestions")
|
||||||
.query({ status: "pending" });
|
.query({ status: "pending" });
|
||||||
expect(pendingOnly.body).to.have.length(1);
|
expect(pendingOnly.body).to.have.length(1);
|
||||||
expect(pendingOnly.body[0].techStepKey).to.equal("simmer");
|
expect(pendingOnly.body[0].techStepKey).to.equal("simmer");
|
||||||
|
|
@ -159,7 +159,7 @@ describe("Admin tech-steps triage", () => {
|
||||||
withSuggestion: { status: "pending", synonyms: ["x"] },
|
withSuggestion: { status: "pending", synonyms: ["x"] },
|
||||||
});
|
});
|
||||||
const agent = await adminAgent();
|
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.status).to.equal(400);
|
||||||
expect(res.body.code).to.equal(ErrorCode.VALIDATION_ERROR);
|
expect(res.body.code).to.equal(ErrorCode.VALIDATION_ERROR);
|
||||||
});
|
});
|
||||||
|
|
@ -172,7 +172,7 @@ describe("Admin tech-steps triage", () => {
|
||||||
}
|
}
|
||||||
const agent = await adminAgent();
|
const agent = await adminAgent();
|
||||||
const res = await agent
|
const res = await agent
|
||||||
.patch("/admin/tech-steps/suggestions/999999")
|
.patch("/admin/api/tech-steps/suggestions/999999")
|
||||||
.send({ status: "applied" });
|
.send({ status: "applied" });
|
||||||
expect(res.status).to.equal(404);
|
expect(res.status).to.equal(404);
|
||||||
});
|
});
|
||||||
|
|
@ -191,7 +191,7 @@ describe("Admin tech-steps triage", () => {
|
||||||
const agent = await adminAgent();
|
const agent = await adminAgent();
|
||||||
|
|
||||||
const patched = await agent
|
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"] });
|
.send({ status: "applied", suggestedSynonyms: ["frémir", "mijoter doucement"] });
|
||||||
expect(patched.status).to.equal(200);
|
expect(patched.status).to.equal(200);
|
||||||
expect(patched.body.status).to.equal("applied");
|
expect(patched.body.status).to.equal("applied");
|
||||||
|
|
@ -215,12 +215,12 @@ describe("Admin tech-steps triage", () => {
|
||||||
await seedCorrectionAndSuggestion({ clause: "Faire mijoter", correctedKey: "simmer" });
|
await seedCorrectionAndSuggestion({ clause: "Faire mijoter", correctedKey: "simmer" });
|
||||||
|
|
||||||
const agent = await adminAgent();
|
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.status).to.equal(200);
|
||||||
expect(res.body).to.have.length(2);
|
expect(res.body).to.have.length(2);
|
||||||
|
|
||||||
const removals = await agent
|
const removals = await agent
|
||||||
.get("/admin/tech-steps/corrections")
|
.get("/admin/api/tech-steps/corrections")
|
||||||
.query({ hasCorrectedTechStep: "false" });
|
.query({ hasCorrectedTechStep: "false" });
|
||||||
expect(removals.body).to.have.length(1);
|
expect(removals.body).to.have.length(1);
|
||||||
expect(removals.body[0].clauseText).to.equal("Rien ici");
|
expect(removals.body[0].clauseText).to.equal("Rien ici");
|
||||||
|
|
@ -242,7 +242,7 @@ describe("Admin tech-steps triage", () => {
|
||||||
});
|
});
|
||||||
const agent = await adminAgent();
|
const agent = await adminAgent();
|
||||||
const res = await agent
|
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" });
|
.query({ techStepKey: "simmer", locale: "fr", status: "applied" });
|
||||||
expect(res.status).to.equal(200);
|
expect(res.status).to.equal(200);
|
||||||
expect(res.body.suggestionCount).to.equal(1);
|
expect(res.body.suggestionCount).to.equal(1);
|
||||||
|
|
@ -261,7 +261,7 @@ describe("Admin tech-steps triage", () => {
|
||||||
}
|
}
|
||||||
this.timeout(60000);
|
this.timeout(60000);
|
||||||
const agent = await adminAgent();
|
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.status).to.equal(200);
|
||||||
expect(res.body).to.have.keys([
|
expect(res.body).to.have.keys([
|
||||||
"f1",
|
"f1",
|
||||||
|
|
@ -292,14 +292,14 @@ describe("Admin tech-steps triage", () => {
|
||||||
// (100ms later) checks it. Swallow its result/rejection — this test
|
// (100ms later) checks it. Swallow its result/rejection — this test
|
||||||
// only asserts on the second request.
|
// only asserts on the second request.
|
||||||
const first = agent
|
const first = agent
|
||||||
.post("/admin/tech-steps/retrain")
|
.post("/admin/api/tech-steps/retrain")
|
||||||
.send({})
|
.send({})
|
||||||
.then(
|
.then(
|
||||||
(res) => res,
|
(res) => res,
|
||||||
(err) => err,
|
(err) => err,
|
||||||
);
|
);
|
||||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
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.status).to.equal(409);
|
||||||
expect(second.body.code).to.equal(ErrorCode.RETRAIN_ALREADY_RUNNING);
|
expect(second.body.code).to.equal(ErrorCode.RETRAIN_ALREADY_RUNNING);
|
||||||
await first;
|
await first;
|
||||||
|
|
|
||||||
|
|
@ -37,11 +37,11 @@ function pendingGroups() {
|
||||||
describe("Admin catalog — off-catalog ingredients", () => {
|
describe("Admin catalog — off-catalog ingredients", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
cy.viewport(1400, 900);
|
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", () => {
|
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,
|
statusCode: 200,
|
||||||
body: pendingGroups(),
|
body: pendingGroups(),
|
||||||
}).as("getPlaceholders");
|
}).as("getPlaceholders");
|
||||||
|
|
@ -57,11 +57,11 @@ describe("Admin catalog — off-catalog ingredients", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it("marks a group reviewed and reloads the list", () => {
|
it("marks a group reviewed and reloads the list", () => {
|
||||||
cy.intercept("GET", "**/admin/catalog/placeholders*", {
|
cy.intercept("GET", "**/admin/api/catalog/placeholders*", {
|
||||||
statusCode: 200,
|
statusCode: 200,
|
||||||
body: pendingGroups(),
|
body: pendingGroups(),
|
||||||
}).as("getPlaceholders");
|
}).as("getPlaceholders");
|
||||||
cy.intercept("PATCH", "**/admin/catalog/placeholders/mark-reviewed", {
|
cy.intercept("PATCH", "**/admin/api/catalog/placeholders/mark-reviewed", {
|
||||||
statusCode: 200,
|
statusCode: 200,
|
||||||
body: { reviewed: 1 },
|
body: { reviewed: 1 },
|
||||||
}).as("markReviewed");
|
}).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`
|
// 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
|
// query, and minimatch's `?` is itself a wildcard — a glob can't tell
|
||||||
// them apart reliably.
|
// them apart reliably.
|
||||||
cy.intercept("GET", /\/admin\/catalog\/placeholders$/, {
|
cy.intercept("GET", /\/admin\/api\/catalog\/placeholders$/, {
|
||||||
statusCode: 200,
|
statusCode: 200,
|
||||||
body: pendingGroups(),
|
body: pendingGroups(),
|
||||||
});
|
});
|
||||||
cy.intercept("GET", /\/admin\/catalog\/placeholders\?reviewed=true$/, {
|
cy.intercept("GET", /\/admin\/api\/catalog\/placeholders\?reviewed=true$/, {
|
||||||
statusCode: 200,
|
statusCode: 200,
|
||||||
body: [],
|
body: [],
|
||||||
}).as("getReviewed");
|
}).as("getReviewed");
|
||||||
|
|
|
||||||
|
|
@ -70,19 +70,19 @@ function corrections() {
|
||||||
describe("Admin corrections triage", () => {
|
describe("Admin corrections triage", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
cy.viewport(1400, 1000);
|
cy.viewport(1400, 1000);
|
||||||
cy.intercept("GET", "**/admin/auth/me", { statusCode: 200, body: adminBody });
|
cy.intercept("GET", "**/admin/api/auth/me", { statusCode: 200, body: adminBody });
|
||||||
cy.intercept("GET", "**/admin/tech-steps/suggestions*", {
|
cy.intercept("GET", "**/admin/api/tech-steps/suggestions*", {
|
||||||
statusCode: 200,
|
statusCode: 200,
|
||||||
body: suggestionGroups(),
|
body: suggestionGroups(),
|
||||||
}).as("getSuggestions");
|
}).as("getSuggestions");
|
||||||
cy.intercept("GET", "**/admin/tech-steps/corrections*", {
|
cy.intercept("GET", "**/admin/api/tech-steps/corrections*", {
|
||||||
statusCode: 200,
|
statusCode: 200,
|
||||||
body: corrections(),
|
body: corrections(),
|
||||||
}).as("getCorrections");
|
}).as("getCorrections");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("shows the caveat, groups suggestions by technique, and applies one", () => {
|
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,
|
statusCode: 200,
|
||||||
body: { ...suggestionGroups()[0].suggestions[0], status: "applied" },
|
body: { ...suggestionGroups()[0].suggestions[0], status: "applied" },
|
||||||
}).as("patch");
|
}).as("patch");
|
||||||
|
|
@ -102,7 +102,7 @@ describe("Admin corrections triage", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it("generates a training_data.py snippet", () => {
|
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,
|
statusCode: 200,
|
||||||
body: {
|
body: {
|
||||||
techStepKey: "simmer",
|
techStepKey: "simmer",
|
||||||
|
|
@ -124,7 +124,7 @@ describe("Admin corrections triage", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it("runs the F1 gate and shows the result", () => {
|
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,
|
statusCode: 200,
|
||||||
body: {
|
body: {
|
||||||
f1: 0.83,
|
f1: 0.83,
|
||||||
|
|
|
||||||
|
|
@ -58,11 +58,11 @@ function metricsFixture() {
|
||||||
describe("Admin dashboard", () => {
|
describe("Admin dashboard", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
cy.viewport(1400, 900);
|
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", () => {
|
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",
|
"getMetrics",
|
||||||
);
|
);
|
||||||
cy.visit("/admin");
|
cy.visit("/admin");
|
||||||
|
|
@ -85,7 +85,7 @@ describe("Admin dashboard", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it("shows an error state when the metrics request fails", () => {
|
it("shows an error state when the metrics request fails", () => {
|
||||||
cy.intercept("GET", "**/admin/metrics*", {
|
cy.intercept("GET", "**/admin/api/metrics*", {
|
||||||
statusCode: 500,
|
statusCode: 500,
|
||||||
body: { code: 5000, message: "x" },
|
body: { code: 5000, message: "x" },
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@ const adminBody = {
|
||||||
|
|
||||||
describe("Admin layout", () => {
|
describe("Admin layout", () => {
|
||||||
it("redirects to /login when there is no admin session", () => {
|
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,
|
statusCode: 401,
|
||||||
body: { code: 4011, message: "no" },
|
body: { code: 4011, message: "no" },
|
||||||
});
|
});
|
||||||
|
|
@ -21,7 +21,7 @@ describe("Admin layout", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it("shows the sidebar and navigates between the sections", () => {
|
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.visit("/admin");
|
||||||
|
|
||||||
cy.contains("h1", "Tableau de bord").should("be.visible");
|
cy.contains("h1", "Tableau de bord").should("be.visible");
|
||||||
|
|
@ -36,7 +36,7 @@ describe("Admin layout", () => {
|
||||||
cy.url().should("include", "/admin/corrections");
|
cy.url().should("include", "/admin/corrections");
|
||||||
cy.contains("h1", "Corrections").should("be.visible");
|
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.contains("nav a", "Catalogue").click();
|
||||||
cy.url().should("include", "/admin/catalogue");
|
cy.url().should("include", "/admin/catalogue");
|
||||||
cy.contains("h1", "Ingrédients hors-catalogue").should("be.visible");
|
cy.contains("h1", "Ingrédients hors-catalogue").should("be.visible");
|
||||||
|
|
@ -46,8 +46,8 @@ describe("Admin layout", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it("logs out back to /login", () => {
|
it("logs out back to /login", () => {
|
||||||
cy.intercept("GET", "**/admin/auth/me", { statusCode: 200, body: adminBody });
|
cy.intercept("GET", "**/admin/api/auth/me", { statusCode: 200, body: adminBody });
|
||||||
cy.intercept("POST", "**/admin/auth/logout", { statusCode: 204 });
|
cy.intercept("POST", "**/admin/api/auth/logout", { statusCode: 204 });
|
||||||
cy.visit("/admin");
|
cy.visit("/admin");
|
||||||
|
|
||||||
// Wait until the guarded layout has actually mounted before acting.
|
// Wait until the guarded layout has actually mounted before acting.
|
||||||
|
|
|
||||||
|
|
@ -16,11 +16,14 @@ const adminBody = {
|
||||||
};
|
};
|
||||||
|
|
||||||
Given("the admin session check returns unauthenticated", () => {
|
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", () => {
|
Given("admin login fails with invalid credentials", () => {
|
||||||
cy.intercept("POST", "**/admin/auth/login", {
|
cy.intercept("POST", "**/admin/api/auth/login", {
|
||||||
statusCode: 401,
|
statusCode: 401,
|
||||||
body: { code: 4010, message: "Invalid email or password" },
|
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) => {
|
Given("admin login succeeds as {string}", (name: string) => {
|
||||||
const body = { ...adminBody, name, email: `${name.toLowerCase()}@example.com` };
|
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
|
// After navigate("/admin"), RequireAdmin re-checks the session — from now
|
||||||
// on it must report authenticated (last matching intercept wins).
|
// 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 });
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -49,33 +49,21 @@ function monitoringFixture() {
|
||||||
describe("Admin monitoring", () => {
|
describe("Admin monitoring", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
cy.viewport(1400, 900);
|
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`
|
// Plain URL/method matcher is enough here: the API's `/admin/api/monitoring`
|
||||||
// are identical — a plain `**/admin/monitoring` glob also matches the
|
// can no longer collide with the page route `/admin/monitoring` (see
|
||||||
// `cy.visit()` document request (and whether that's same-origin or not
|
// `mountRouter("/admin/api", ...)`'s comment in the API's `app.ts` for the
|
||||||
// depends on `VITE_API_URL`, which isn't set in CI).
|
// incident that made that distinction necessary in the first place). This
|
||||||
//
|
// used to need a `resourceType`-based workaround for that collision, which
|
||||||
// This used to be disambiguated with a declarative `resourceType: "fetch"`
|
// also surfaced an unrelated Cypress bug: on this machine's Chrome build, a
|
||||||
// route matcher, but that stopped working (Cypress 13.17.0 on this
|
// *declarative* `resourceType` route matcher silently never caught a
|
||||||
// machine's Chrome 152): the *declarative* `resourceType` matcher no
|
// request whose own `req.resourceType` genuinely was that type — worth
|
||||||
// longer catches this request at all — confirmed by logging every request
|
// remembering if a `resourceType` matcher elsewhere in this suite ever
|
||||||
// Cypress hands to a `{resourceType: "fetch"}` intercept, which only ever
|
// starts failing the same way.
|
||||||
// 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.
|
|
||||||
function interceptMonitoringApi(response: { statusCode: number; body: unknown }) {
|
function interceptMonitoringApi(response: { statusCode: number; body: unknown }) {
|
||||||
return cy.intercept("GET", "**/admin/monitoring", (req) => {
|
return cy.intercept("GET", "**/admin/api/monitoring", response);
|
||||||
if (req.resourceType === "document") {
|
|
||||||
req.continue();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
req.reply(response);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
it("renders one card per service with its status and details", () => {
|
it("renders one card per service with its status and details", () => {
|
||||||
|
|
|
||||||
|
|
@ -54,7 +54,7 @@ import { ShoppingListPage } from "./pages/shopping-list/ShoppingListPage";
|
||||||
*
|
*
|
||||||
* `/admin/*` is the internal admin surface (metrics, monitoring, correction
|
* `/admin/*` is the internal admin surface (metrics, monitoring, correction
|
||||||
* triage, off-catalog ingredient review). It's its own top-level group,
|
* 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`,
|
* 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
|
* `ADMIN_JWT_SECRET`) is entirely separate from the end-user one, so this is
|
||||||
* never nested under `RequireAuth`. `/admin/login` is the only
|
* never nested under `RequireAuth`. `/admin/login` is the only
|
||||||
|
|
|
||||||
|
|
@ -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
|
* Base URL of the admin API — the same `VITE_API_URL` the user-facing
|
||||||
* {@link apiClient} reads (see `api/client.ts`). Defaults to `""` (same
|
* {@link apiClient} reads (see `api/client.ts`), plus the `/admin/api`
|
||||||
* origin) — correct behind a shared reverse proxy; native dev overrides it
|
* prefix every route below is relative to. That prefix (not just bare
|
||||||
* to `http://localhost:3000` in `apps/web/.env`. The `/admin/*` surface is
|
* `/admin`) exists because this API is served from the same origin/process
|
||||||
* served by the same API process as the rest of the app.
|
* 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
|
* 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
|
* apps/web's `ApiClient` (a class for cohesion/extensibility, one shared
|
||||||
* stateless instance). Every request sends credentials so the
|
* stateless instance). Every request sends credentials so the
|
||||||
* `admin_session` httpOnly cookie round-trips.
|
* `admin_session` httpOnly cookie round-trips.
|
||||||
|
|
@ -98,27 +105,27 @@ export class AdminApiClient {
|
||||||
|
|
||||||
/** Verifies admin credentials and starts an admin session. */
|
/** Verifies admin credentials and starts an admin session. */
|
||||||
public login(input: AdminLoginInput): Promise<AdminUserView> {
|
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. */
|
/** Ends the current admin session. */
|
||||||
public logout(): Promise<void> {
|
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. */
|
/** Fetches the currently authenticated admin — rejects with `NOT_AUTHENTICATED` if there's no session. */
|
||||||
public me(): Promise<AdminUserView> {
|
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. */
|
/** Usage metrics for the dashboard — snapshot totals + `days` (7–365) of daily time series. */
|
||||||
public getMetrics(days: number): Promise<MetricsView> {
|
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. */
|
/** Live health of Postgres, the API, the intent-service and the LLM worker — polled by the monitoring board. */
|
||||||
public getMonitoring(): Promise<MonitoringView> {
|
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. */
|
/** Training suggestions, grouped by technique, filtered by the given (all-optional) criteria. */
|
||||||
|
|
@ -128,7 +135,7 @@ export class AdminApiClient {
|
||||||
techStepKey?: string;
|
techStepKey?: string;
|
||||||
locale?: string;
|
locale?: string;
|
||||||
}): Promise<TrainingSuggestionGroupView[]> {
|
}): 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. */
|
/** Raw user corrections, including the "no technique here" removals. */
|
||||||
|
|
@ -136,7 +143,7 @@ export class AdminApiClient {
|
||||||
consumed?: string;
|
consumed?: string;
|
||||||
hasCorrectedTechStep?: string;
|
hasCorrectedTechStep?: string;
|
||||||
}): Promise<CorrectionAdminView[]> {
|
}): 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. */
|
/** Edits a suggestion's proposed synonyms/utterances and/or its status. */
|
||||||
|
|
@ -144,7 +151,7 @@ export class AdminApiClient {
|
||||||
id: number,
|
id: number,
|
||||||
body: UpdateTrainingSuggestionInput,
|
body: UpdateTrainingSuggestionInput,
|
||||||
): Promise<TrainingSuggestionAdminView> {
|
): Promise<TrainingSuggestionAdminView> {
|
||||||
return this._request(`/admin/tech-steps/suggestions/${id}`, {
|
return this._request(`/tech-steps/suggestions/${id}`, {
|
||||||
method: "PATCH",
|
method: "PATCH",
|
||||||
body: JSON.stringify(body),
|
body: JSON.stringify(body),
|
||||||
});
|
});
|
||||||
|
|
@ -156,12 +163,12 @@ export class AdminApiClient {
|
||||||
locale?: string;
|
locale?: string;
|
||||||
status?: string;
|
status?: string;
|
||||||
}): Promise<TrainingDataSnippetView> {
|
}): 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. */
|
/** Runs the F1 gate + backfill (+ marks suggestion ids). Rejects with `RETRAIN_ALREADY_RUNNING` if one is in flight. */
|
||||||
public retrain(body: RetrainRequestInput): Promise<RetrainResultView> {
|
public retrain(body: RetrainRequestInput): Promise<RetrainResultView> {
|
||||||
return this._request("/admin/tech-steps/retrain", {
|
return this._request("/tech-steps/retrain", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify(body),
|
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. */
|
/** 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[]> {
|
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`). */
|
/** Marks the given placeholder ingredient ids as triaged (`reviewedAt`). */
|
||||||
public markPlaceholdersReviewed(
|
public markPlaceholdersReviewed(
|
||||||
body: MarkPlaceholdersReviewedInput,
|
body: MarkPlaceholdersReviewedInput,
|
||||||
): Promise<{ reviewed: number }> {
|
): Promise<{ reviewed: number }> {
|
||||||
return this._request("/admin/catalog/placeholders/mark-reviewed", {
|
return this._request("/catalog/placeholders/mark-reviewed", {
|
||||||
method: "PATCH",
|
method: "PATCH",
|
||||||
body: JSON.stringify(body),
|
body: JSON.stringify(body),
|
||||||
});
|
});
|
||||||
|
|
@ -184,7 +191,7 @@ export class AdminApiClient {
|
||||||
|
|
||||||
/** Deletes placeholder rows no recipe references any more. */
|
/** Deletes placeholder rows no recipe references any more. */
|
||||||
public pruneOrphanPlaceholders(): Promise<PruneOrphansResultView> {
|
public pruneOrphanPlaceholders(): Promise<PruneOrphansResultView> {
|
||||||
return this._request("/admin/catalog/placeholders/prune-orphans", { method: "POST" });
|
return this._request("/catalog/placeholders/prune-orphans", { method: "POST" });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ import { adminApiClient } from "../../api/admin-client";
|
||||||
interface AdminAuthContextValue {
|
interface AdminAuthContextValue {
|
||||||
/** Currently authenticated admin, or `null` if no active session. */
|
/** Currently authenticated admin, or `null` if no active session. */
|
||||||
admin: AdminUserView | null;
|
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;
|
isLoading: boolean;
|
||||||
/** Verifies credentials and updates `admin` on success. Throws `ApiError` on failure. */
|
/** Verifies credentials and updates `admin` on success. Throws `ApiError` on failure. */
|
||||||
login: (input: AdminLoginInput) => Promise<void>;
|
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
|
* 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
|
* httpOnly cookie (if any) — same "reload keeps you logged in" behaviour as
|
||||||
* apps/web's `AuthProvider`.
|
* apps/web's `AuthProvider`.
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ import { useAdminAuth } from "./AdminAuthContext";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Route guard for every admin page. Renders nothing while the initial
|
* 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
|
* once resolved, renders `children` or redirects to `/admin/login`. Mirror
|
||||||
* of `RequireAuth` (`features/auth/`), but keyed to the separate admin
|
* of `RequireAuth` (`features/auth/`), but keyed to the separate admin
|
||||||
* session (`admin_session` cookie), not the user one.
|
* session (`admin_session` cookie), not the user one.
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,7 @@ import "../admin-page.scss";
|
||||||
import "./dashboard-page.scss";
|
import "./dashboard-page.scss";
|
||||||
import { formatCount, kpiTiles, seriesTotal, shortDay } from "./dashboard";
|
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 =
|
type DashboardState =
|
||||||
| { status: "loading" }
|
| { status: "loading" }
|
||||||
| { status: "loaded"; metrics: MetricsView }
|
| { status: "loaded"; metrics: MetricsView }
|
||||||
|
|
@ -26,7 +26,7 @@ const RANGE_DAYS = 30;
|
||||||
/**
|
/**
|
||||||
* Usage-metrics dashboard. KPI tiles from the snapshot, then a small line
|
* Usage-metrics dashboard. KPI tiles from the snapshot, then a small line
|
||||||
* chart per instrumented time series over the last {@link RANGE_DAYS} days.
|
* 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() {
|
export function DashboardPage() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@ import { errorMessageService } from "../../../services/error-message.service";
|
||||||
/**
|
/**
|
||||||
* The admin login screen — the only unauthenticated route under `/admin`.
|
* The admin login screen — the only unauthenticated route under `/admin`.
|
||||||
* Client-side validation via the shared `adminLoginSchema` (same rules the
|
* 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
|
* translated to a localized label via {@link ErrorMessageService}. Same
|
||||||
* structure as the user-facing `LoginPage` (`pages/auth/`).
|
* structure as the user-facing `LoginPage` (`pages/auth/`).
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@ type MonitoringState =
|
||||||
| { status: "error" };
|
| { 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
|
* re-polls every {@link POLL_INTERVAL_MS} ms. One card per probed target
|
||||||
* (Postgres, API, intent-service, LLM worker), coloured by status.
|
* (Postgres, API, intent-service, LLM worker), coloured by status.
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ export function statusModifier(status: ServiceStatus): string {
|
||||||
return status;
|
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;
|
export const POLL_INTERVAL_MS = 15_000;
|
||||||
|
|
||||||
/** `"2026-08-28T09:00:00.000Z"` → `"09:00:00"` (local time) for the "last checked" line. */
|
/** `"2026-08-28T09:00:00.000Z"` → `"09:00:00"` (local time) for the "last checked" line. */
|
||||||
|
|
|
||||||
|
|
@ -40,7 +40,7 @@ export enum ErrorCode {
|
||||||
RECIPE_IN_USE = 4021,
|
RECIPE_IN_USE = 4021,
|
||||||
/** `POST /sources/:sourceKey/import/:externalId` attempted on an item already imported (a `Recipe` already exists for that `sourceId`/`externalId` pair). */
|
/** `POST /sources/:sourceKey/import/:externalId` attempted on an item already imported (a `Recipe` already exists for that `sourceId`/`externalId` pair). */
|
||||||
RECIPE_ALREADY_IMPORTED = 4022,
|
RECIPE_ALREADY_IMPORTED = 4022,
|
||||||
/** `POST /admin/tech-steps/retrain` attempted while a previous retrain (F1 gate + backfill) is still running — it holds a process-wide lock so two can't overlap. */
|
/** `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,
|
RETRAIN_ALREADY_RUNNING = 4023,
|
||||||
/** A household action reserved to its admin (delete the household, remove a member) attempted by a non-admin member. */
|
/** A household action reserved to its admin (delete the household, remove a member) attempted by a non-admin member. */
|
||||||
NOT_HOUSE_ADMIN = 4030,
|
NOT_HOUSE_ADMIN = 4030,
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ import { z } from "zod";
|
||||||
// as-is in the admin login form.
|
// 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
|
* (not a re-export of `loginSchema`): the admin surface is a separate
|
||||||
* contract from the end-user one even though the shape currently matches.
|
* 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>;
|
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
|
* 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
|
* query string; clamped to a sane window so a huge value can't make the
|
||||||
* dashboard scan the whole history.
|
* dashboard scan the whole history.
|
||||||
|
|
@ -53,7 +53,7 @@ export const TRAINING_SUGGESTION_STATUSES = ["pending", "applied", "rejected"] a
|
||||||
/** One of {@link TRAINING_SUGGESTION_STATUSES}. */
|
/** One of {@link TRAINING_SUGGESTION_STATUSES}. */
|
||||||
export type TrainingSuggestionStatus = (typeof TRAINING_SUGGESTION_STATUSES)[number];
|
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({
|
export const listSuggestionsQuerySchema = z.object({
|
||||||
status: z.enum(TRAINING_SUGGESTION_STATUSES).optional(),
|
status: z.enum(TRAINING_SUGGESTION_STATUSES).optional(),
|
||||||
sourceType: z.enum(["correction", "llm_audit"]).optional(),
|
sourceType: z.enum(["correction", "llm_audit"]).optional(),
|
||||||
|
|
@ -63,7 +63,7 @@ export const listSuggestionsQuerySchema = z.object({
|
||||||
/** Inferred TS type for {@link listSuggestionsQuerySchema}. */
|
/** Inferred TS type for {@link listSuggestionsQuerySchema}. */
|
||||||
export type ListSuggestionsQuery = z.infer<typeof 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({
|
export const listCorrectionsQuerySchema = z.object({
|
||||||
consumed: z.enum(["true", "false"]).optional(),
|
consumed: z.enum(["true", "false"]).optional(),
|
||||||
hasCorrectedTechStep: 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>;
|
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
|
* before it feeds a `training_data.py` edit. Every field optional; at least
|
||||||
* one must be present (enforced service-side).
|
* one must be present (enforced service-side).
|
||||||
*/
|
*/
|
||||||
|
|
@ -85,7 +85,7 @@ export const updateTrainingSuggestionSchema = z.object({
|
||||||
export type UpdateTrainingSuggestionInput = z.infer<typeof updateTrainingSuggestionSchema>;
|
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
|
* then, if it passes, backfills every step and marks the given suggestion
|
||||||
* ids. Both id lists optional (an empty run just re-gates + backfills).
|
* 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}. */
|
/** Inferred TS type for {@link retrainRequestSchema}. */
|
||||||
export type RetrainRequestInput = z.infer<typeof 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({
|
export const trainingDataSnippetQuerySchema = z.object({
|
||||||
techStepKey: z.string().min(1),
|
techStepKey: z.string().min(1),
|
||||||
locale: z.string().min(1).default("fr"),
|
locale: z.string().min(1).default("fr"),
|
||||||
|
|
@ -106,7 +106,7 @@ export const trainingDataSnippetQuerySchema = z.object({
|
||||||
export type TrainingDataSnippetQuery = z.infer<typeof trainingDataSnippetQuerySchema>;
|
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
|
* ingredient review. `reviewed` is tri-state: omitted / `"false"` hides
|
||||||
* groups every row of which has already been triaged (the default working
|
* groups every row of which has already been triaged (the default working
|
||||||
* view), `"true"` shows only fully-triaged groups.
|
* view), `"true"` shows only fully-triaged groups.
|
||||||
|
|
@ -118,7 +118,7 @@ export const listPlaceholdersQuerySchema = z.object({
|
||||||
export type ListPlaceholdersQuery = z.infer<typeof listPlaceholdersQuerySchema>;
|
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
|
* `reviewedAt` on the given placeholder `Ingredient` ids (a maintainer has
|
||||||
* seen this catalog gap and, if warranted, added the real entry by hand).
|
* seen this catalog gap and, if warranted, added the real entry by hand).
|
||||||
* Bounded so one call can't sweep an unbounded set.
|
* Bounded so one call can't sweep an unbounded set.
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
/**
|
/**
|
||||||
* Public shape of an admin operator, as returned by `GET /admin/auth/me`
|
* Public shape of an admin operator, as returned by `GET /admin/api/auth/me`
|
||||||
* and `POST /admin/auth/login` — never includes the password hash. Mirrors
|
* and `POST /admin/api/auth/login` — never includes the password hash. Mirrors
|
||||||
* apps/api's `Omit<PrismaAdminUser, "passwordHash" | "tokenVersion">`,
|
* apps/api's `Omit<PrismaAdminUser, "passwordHash" | "tokenVersion">`,
|
||||||
* declared by hand rather than derived from the Prisma type (same reason as
|
* declared by hand rather than derived from the Prisma type (same reason as
|
||||||
* {@link SafeUserProfile}: apps/admin-web must not depend on
|
* {@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).
|
* `events` cover the last `rangeDays` days as daily buckets (zero-filled).
|
||||||
* `series` is derived from the `createdAt` columns added by the
|
* `series` is derived from the `createdAt` columns added by the
|
||||||
* `admin_metrics` migration; `events` from the `AnalyticsEvent` table.
|
* `admin_metrics` migration; `events` from the `AnalyticsEvent` table.
|
||||||
|
|
@ -96,7 +96,7 @@ export interface MetricsView {
|
||||||
*/
|
*/
|
||||||
export type ServiceStatus = "up" | "degraded" | "down" | "unknown";
|
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 {
|
export interface ServiceHealthView {
|
||||||
/** Stable id — `"postgres"`, `"api"`, `"intent-service"`, `"tech-step-llm-worker"`. Resolved to a label client-side via `admin.monitoring.service.<key>`. */
|
/** Stable id — `"postgres"`, `"api"`, `"intent-service"`, `"tech-step-llm-worker"`. Resolved to a label client-side via `admin.monitoring.service.<key>`. */
|
||||||
key: string;
|
key: string;
|
||||||
|
|
@ -111,7 +111,7 @@ export interface ServiceHealthView {
|
||||||
lastResult?: { job?: string; ok?: boolean; counts?: Record<string, number> } | null;
|
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 {
|
export interface MonitoringView {
|
||||||
generatedAt: string;
|
generatedAt: string;
|
||||||
services: ServiceHealthView[];
|
services: ServiceHealthView[];
|
||||||
|
|
@ -143,7 +143,7 @@ export interface TrainingSuggestionAdminView {
|
||||||
} | null;
|
} | 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 {
|
export interface TrainingSuggestionGroupView {
|
||||||
techStepKey: string;
|
techStepKey: string;
|
||||||
suggestions: TrainingSuggestionAdminView[];
|
suggestions: TrainingSuggestionAdminView[];
|
||||||
|
|
@ -168,7 +168,7 @@ export interface CorrectionAdminView {
|
||||||
consumedAt: string | null;
|
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 {
|
export interface TrainingDataSnippetView {
|
||||||
techStepKey: string;
|
techStepKey: string;
|
||||||
locale: 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
|
* regression gate (`f1 >= minF1`); `backfilled` is present only when the
|
||||||
* gate passed (a failed gate refuses to backfill). `marked` echoes how
|
* gate passed (a failed gate refuses to backfill). `marked` echoes how
|
||||||
* many suggestion ids were actually flipped.
|
* 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
|
* `Ingredient` (see `Ingredient.isPlaceholder` in schema.prisma) sharing
|
||||||
* one normalized name, so a maintainer sees "this ingredient is missing
|
* 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
|
* from the catalog, and N recipes are waiting on it" rather than a flat
|
||||||
|
|
@ -224,7 +224,7 @@ export interface CatalogPlaceholderGroupView {
|
||||||
allReviewed: boolean;
|
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 {
|
export interface PruneOrphansResultView {
|
||||||
deleted: number;
|
deleted: number;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
`apps/api` (qui reste seul propriétaire du schéma) mais avec une
|
||||||
**authentification totalement distincte** de celle des utilisateurs.
|
**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
|
**Auth** (`middlewares/require-admin.ts`, `lib/admin-jwt.ts`) — table
|
||||||
`AdminUser` isolée (aucune relation vers `UserProfile`), cookie
|
`AdminUser` isolée (aucune relation vers `UserProfile`), cookie
|
||||||
`ADMIN_COOKIE_NAME` (`admin_session`, ≠ `session`), secret `ADMIN_JWT_SECRET`
|
`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
|
`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).
|
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` :
|
Router agrégateur `modules/admin/admin.routes.ts` monté `/admin/api` :
|
||||||
`/admin/auth` (`login`/`logout`/`me`), `/admin/metrics` (ci-dessous).
|
`/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,
|
défaut 30) — `MetricsView` = `snapshot` (des `count`s : utilisateurs, foyers,
|
||||||
foyers actifs, recettes manuelles/importées + ventilation par source,
|
foyers actifs, recettes manuelles/importées + ventilation par source,
|
||||||
plannings, créneaux, corrections par état, suggestions par statut/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
|
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).
|
é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 :
|
`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`
|
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
|
(`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).
|
> 21 j ⇒ `down` (cron par défaut hebdomadaire).
|
||||||
|
|
||||||
**Tri des corrections** (`admin-tech-steps.service.ts`, routes
|
**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` :
|
`list-pending-training-suggestions.ts` / `retrain-tech-steps.ts` :
|
||||||
|
|
||||||
- `GET /suggestions` — `TechStepTrainingSuggestion` filtrées
|
- `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.
|
ces deux étapes restent manuelles, l'UI l'affiche en bandeau permanent.
|
||||||
|
|
||||||
**Ingrédients hors-catalogue** (`admin-catalog.service.ts`, routes
|
**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`) :
|
`isPlaceholder` (voir la section *Recettes* et `batch-cooking-modele.md`) :
|
||||||
|
|
||||||
- `GET /placeholders?reviewed=` — tous les placeholders, **regroupés par nom
|
- `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`
|
exclues de `GET /reference/ingredients` et de `loadIngredientCatalog`
|
||||||
(`ingredient-matcher.ts`). Retirer une ligne placeholder d'une recette
|
(`ingredient-matcher.ts`). Retirer une ligne placeholder d'une recette
|
||||||
laisse une ligne `Ingredient` orpheline (non-GC — purge manuelle via
|
laisse une ligne `Ingredient` orpheline (non-GC — purge manuelle via
|
||||||
`/admin/catalog/placeholders/prune-orphans`). Revue côté admin :
|
`/admin/api/catalog/placeholders/prune-orphans`). Revue côté admin :
|
||||||
`/admin/catalog/*` (section *admin* ci-dessus).
|
`/admin/api/catalog/*` (section *admin* ci-dessus).
|
||||||
|
|
||||||
### Détection des techniques — `tech-step-matcher.ts`
|
### Détection des techniques — `tech-step-matcher.ts`
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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` |
|
| `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) |
|
| `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é) |
|
| `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`
|
`IngredientDiet` (m2m, régimes compatibles — omet volontairement `Omnivore`
|
||||||
et `Sans gluten`, ce dernier dérivable de `IngredientAllergy`) et
|
et `Sans gluten`, ce dernier dérivable de `IngredientAllergy`) et
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue