feat(admin): métriques d'utilisation (dérivé DB + AnalyticsEvent) #13

Closed
kyuno wants to merge 1 commit from feat/admin-metrics into feat/admin-web-scaffold
22 changed files with 1183 additions and 13 deletions
Showing only changes of commit b08081e48d - Show all commits

View file

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

View file

@ -3,6 +3,7 @@ import {
type AdminUserView, type AdminUserView,
type ApiErrorResponse, type ApiErrorResponse,
ErrorCode, ErrorCode,
type MetricsView,
} from "@batch-cooking/shared"; } from "@batch-cooking/shared";
/** /**
@ -90,6 +91,11 @@ export class AdminApiClient {
public me(): Promise<AdminUserView> { public me(): Promise<AdminUserView> {
return this._request("/admin/auth/me"); return this._request("/admin/auth/me");
} }
/** Usage metrics for the dashboard — snapshot totals + `days` (7365) of daily time series. */
public getMetrics(days: number): Promise<MetricsView> {
return this._request(`/admin/metrics?days=${days}`);
}
} }
/** Single shared instance — stateless, same reasoning as apps/web's `apiClient`. */ /** Single shared instance — stateless, same reasoning as apps/web's `apiClient`. */

View file

@ -9,7 +9,9 @@
}, },
"admin": { "admin": {
"common": { "common": {
"comingSoon": "Section à venir." "comingSoon": "Section à venir.",
"loading": "Chargement…",
"loadError": "Impossible de charger les données, réessayez plus tard."
}, },
"login": { "login": {
"title": "Administration", "title": "Administration",
@ -28,7 +30,32 @@
}, },
"dashboard": { "dashboard": {
"title": "Tableau de bord", "title": "Tableau de bord",
"lead": "Métriques d'utilisation de l'application." "lead": "Métriques d'utilisation de l'application.",
"windowTotal": "{{n}} sur 30 j",
"recipesBySource": "Recettes importées par source",
"noImports": "Aucune recette importée.",
"events": "Évènements enregistrés (30 j)",
"kpi": {
"users": "Utilisateurs",
"households": "Foyers",
"activeHouseholds": "Foyers actifs",
"recipes": "Recettes",
"recipesImported": "Recettes importées",
"plannings": "Plannings",
"planningItems": "Créneaux planifiés",
"favorites": "Favoris",
"corrections": "Corrections",
"correctionsUnconsumed": "Corrections à traiter",
"trainingSuggestions": "Suggestions d'entraînement",
"admins": "Administrateurs"
},
"series": {
"signups": "Inscriptions",
"recipesCreated": "Recettes créées",
"planningItemsAdded": "Ajouts au planning",
"correctionsSubmitted": "Corrections soumises",
"trainingSuggestions": "Suggestions générées"
}
}, },
"monitoring": { "monitoring": {
"title": "Monitoring", "title": "Monitoring",

View file

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

View file

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

View file

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

View file

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

View file

@ -103,6 +103,10 @@ model UserProfile {
tokenVersion Int @default(0) @map("token_version") tokenVersion Int @default(0) @map("token_version")
houseId Int? @map("house_id") houseId Int? @map("house_id")
dietId Int? @map("diet_id") dietId Int? @map("diet_id")
/// See `Planning.createdAt` — same admin-metrics-only timestamp, added by
/// the `admin_metrics` migration for the dashboard's signup curve. No
/// application code reads it (auth doesn't need it).
createdAt DateTime @default(now()) @map("created_at")
house House? @relation("HouseMember", fields: [houseId], references: [id], onDelete: SetNull) house House? @relation("HouseMember", fields: [houseId], references: [id], onDelete: SetNull)
diet Diet? @relation(fields: [dietId], references: [id], onDelete: SetNull) diet Diet? @relation(fields: [dietId], references: [id], onDelete: SetNull)
@ -192,6 +196,12 @@ model Planning {
startDate DateTime @map("start_date") @db.Date startDate DateTime @map("start_date") @db.Date
finishDate DateTime @map("finish_date") @db.Date finishDate DateTime @map("finish_date") @db.Date
houseId Int @map("house_id") houseId Int @map("house_id")
/// When this planning row was first created. Added by the `admin_metrics`
/// migration purely for the admin dashboard's activity curves — no
/// application code reads it. Rows that predate the migration all get the
/// migration's own timestamp (same acceptable one-off skew as the
/// `ingredient_unit_catalog` migration), which is fine for a trend chart.
createdAt DateTime @default(now()) @map("created_at")
house House @relation(fields: [houseId], references: [id], onDelete: Cascade) house House @relation(fields: [houseId], references: [id], onDelete: Cascade)
items PlanningItem[] items PlanningItem[]
@ -206,6 +216,8 @@ model PlanningItem {
meal String meal String
recipeId Int @map("recipe_id") recipeId Int @map("recipe_id")
portions Int portions Int
/// See `Planning.createdAt` — same admin-metrics-only timestamp.
createdAt DateTime @default(now()) @map("created_at")
planning Planning @relation(fields: [planningId], references: [id], onDelete: Cascade) planning Planning @relation(fields: [planningId], references: [id], onDelete: Cascade)
recipe Recipe @relation(fields: [recipeId], references: [id]) recipe Recipe @relation(fields: [recipeId], references: [id])
@ -319,6 +331,10 @@ model Recipe {
/// the author had no household yet. /// the author had no household yet.
authorHouseId Int? @map("author_house_id") authorHouseId Int? @map("author_house_id")
visibility RecipeVisibility @default(PERSONAL) visibility RecipeVisibility @default(PERSONAL)
/// See `Planning.createdAt` — same admin-metrics-only timestamp, added by
/// the `admin_metrics` migration for the dashboard's "recipes created"
/// curve. No application code reads it.
createdAt DateTime @default(now()) @map("created_at")
author UserProfile @relation(fields: [authorId], references: [id]) author UserProfile @relation(fields: [authorId], references: [id])
authorHouse House? @relation(fields: [authorHouseId], references: [id], onDelete: SetNull) authorHouse House? @relation(fields: [authorHouseId], references: [id], onDelete: SetNull)
@ -930,3 +946,48 @@ model AdminUser {
@@map("admin_users") @@map("admin_users")
} }
/// One recorded product event, for the admin dashboard's usage metrics.
/// Written fire-and-forget by `lib/analytics.service.ts`'s `recordEvent`
/// from a handful of key service methods (signup, recipe import/create,
/// planning add, cooking-session open, tech-step correction, shopping-list
/// view) — never on the request's critical path, so a failed insert is
/// logged and swallowed, never surfaced to the user.
///
/// `type` is a free `String` (`"user.signup"`, `"recipe.imported"`…), not
/// an enum: adding a new event to instrument is a one-line call site
/// change with **no migration**. `actorId` is a `UserProfile.id` when
/// `actorType == "user"` but carries **no FK** — an event is an immutable
/// historical fact that must outlive the account it describes (a deleted
/// user's signup still counts on the curve). `context` is a small free
/// JSON blob (`{ sourceKey, recipeId, … }`) for slicing later; nothing
/// queries into it today.
model AnalyticsEvent {
id Int @id @default(autoincrement())
type String
actorType String @map("actor_type")
actorId Int? @map("actor_id")
context Json?
createdAt DateTime @default(now()) @map("created_at")
@@index([type, createdAt])
@@map("analytics_events")
}
/// Liveness/last-run record for a background worker that has no inbound
/// HTTP surface of its own — one row per worker (`workerKey`, today only
/// `"tech-step-llm-worker"`). The worker POSTs `/internal/tech-steps/heartbeat`
/// (`requireInternalWorker`) on boot, on every scheduler tick, and after
/// each job; the admin monitoring board reads this to show the worker as
/// up / stale / down and to surface its last job result. Upserted, never
/// accumulated — only the latest state matters.
model WorkerHeartbeat {
workerKey String @id @map("worker_key")
lastSeenAt DateTime @map("last_seen_at")
/// Set only by a `"job"` heartbeat — the last time the worker actually ran a job (vs. just a tick proving it's alive).
lastRunAt DateTime? @map("last_run_at")
/// Small JSON summary of that last job (`{ job, ok, counts }`).
lastResult Json? @map("last_result")
@@map("worker_heartbeats")
}

View file

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

View file

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

View file

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

View file

@ -1,5 +1,6 @@
import { Router } from "express"; import { Router } from "express";
import { adminAuthRouter } from "./admin-auth.routes.js"; import { adminAuthRouter } from "./admin-auth.routes.js";
import { adminMetricsRouter } from "./admin-metrics.routes.js";
/** /**
* Aggregator for the admin application's API surface, mounted at `/admin` * Aggregator for the admin application's API surface, mounted at `/admin`
@ -11,3 +12,4 @@ import { adminAuthRouter } from "./admin-auth.routes.js";
export const adminRouter = Router(); export const adminRouter = Router();
adminRouter.use("/auth", adminAuthRouter); adminRouter.use("/auth", adminAuthRouter);
adminRouter.use("/metrics", adminMetricsRouter);

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -16,3 +16,15 @@ export const adminLoginSchema = z.object({
}); });
/** Inferred TS type for {@link adminLoginSchema}'s validated output. */ /** Inferred TS type for {@link adminLoginSchema}'s validated output. */
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
* time-series go (and how many daily buckets they carry). Coerced from the
* query string; clamped to a sane window so a huge value can't make the
* dashboard scan the whole history.
*/
export const getMetricsSchema = z.object({
days: z.coerce.number().int().min(7).max(365).default(30),
});
/** Inferred TS type for {@link getMetricsSchema}'s validated output. */
export type GetMetricsInput = z.infer<typeof getMetricsSchema>;

View file

@ -14,3 +14,74 @@ export interface AdminUserView {
createdAt: string; createdAt: string;
lastLoginAt: string | null; lastLoginAt: string | null;
} }
/** One `{ key, count }` breakdown row — e.g. recipes per source, corrections per status. */
export interface MetricsBreakdownRow {
key: string;
/** Human-readable label when the key isn't self-explanatory (a source's display name); otherwise equal to `key`. */
label: string;
count: number;
}
/**
* Point-in-time totals for the admin dashboard's KPI tiles every field is
* a `COUNT` against the current database, not a time series. See
* `apps/api`'s `admin-metrics.service.ts`.
*/
export interface MetricsSnapshotView {
admins: number;
users: number;
households: number;
/** Households with at least one `Planning` row. */
activeHouseholds: number;
recipes: number;
recipesManual: number;
recipesImported: number;
/** Imported recipes grouped by their `Source` (`key` = source key, `label` = display name). */
recipesBySource: MetricsBreakdownRow[];
plannings: number;
planningItems: number;
steps: number;
detectedTechniques: number;
favorites: number;
corrections: number;
/** Corrections not yet turned into a `TechStepTrainingSuggestion` (`consumedAt IS NULL`). */
correctionsUnconsumed: number;
/** Corrections that assert "no technique here" (`correctedTechStepId IS NULL`) — never become suggestions. */
correctionsRemoval: number;
trainingSuggestions: number;
trainingSuggestionsByStatus: MetricsBreakdownRow[];
trainingSuggestionsBySourceType: MetricsBreakdownRow[];
}
/** One day of a metrics time series. `date` is `YYYY-MM-DD` (UTC day). Days with no activity are present with `count: 0`. */
export interface MetricsTimeBucket {
date: string;
count: number;
}
/** One instrumented `AnalyticsEvent` type, bucketed by day over the requested window. */
export interface MetricsEventSeries {
type: string;
buckets: MetricsTimeBucket[];
}
/**
* Response of `GET /admin/metrics`. `snapshot` is "right now"; `series` and
* `events` cover the last `rangeDays` days as daily buckets (zero-filled).
* `series` is derived from the `createdAt` columns added by the
* `admin_metrics` migration; `events` from the `AnalyticsEvent` table.
*/
export interface MetricsView {
generatedAt: string;
rangeDays: number;
snapshot: MetricsSnapshotView;
series: {
signups: MetricsTimeBucket[];
recipesCreated: MetricsTimeBucket[];
planningItemsAdded: MetricsTimeBucket[];
correctionsSubmitted: MetricsTimeBucket[];
trainingSuggestions: MetricsTimeBucket[];
};
events: MetricsEventSeries[];
}

View file

@ -296,6 +296,51 @@ telles quelles par typage structurel.
--- ---
## `admin` — application d'administration (`/admin/*`)
Surface d'exploitation servie à `apps/admin-web` (frontend Vite **séparé**,
URL/déploiement propres). Vit dans `apps/api` (qui reste seul propriétaire du
schéma) mais avec une **authentification totalement distincte** de celle des
utilisateurs.
**Auth** (`middlewares/require-admin.ts`, `lib/admin-jwt.ts`) — table
`AdminUser` isolée (aucune relation vers `UserProfile`), cookie
`ADMIN_COOKIE_NAME` (`admin_session`, ≠ `session`), secret `ADMIN_JWT_SECRET`
(≠ `JWT_SECRET`). `requireAdmin` re-check `tokenVersion` en base comme
`requireAuth`, **échoue fermé** si `ADMIN_JWT_SECRET` est absent (posture
`requireInternalWorker`). Aucun signup exposé — le 1ᵉʳ admin est créé
hors-bande par `src/scripts/create-admin.ts` (flags ou `ADMIN_INITIAL_*`).
`res.locals.adminUser` typé `AdminLocals`. CORS : `setupCore` accepte
`string[]`, `app.ts` autorise `CORS_ORIGIN` + `ADMIN_CORS_ORIGIN`.
Router agrégateur `modules/admin/admin.routes.ts` monté `/admin` :
`/admin/auth` (`login`/`logout`/`me`), `/admin/metrics` (ci-dessous).
**Métriques** (`admin-metrics.service.ts`, `GET /admin/metrics?days=` 7365,
défaut 30) — `MetricsView` = `snapshot` (des `count`s : utilisateurs, foyers,
foyers actifs, recettes manuelles/importées + ventilation par source,
plannings, créneaux, corrections par état, suggestions par statut/source…) +
`series` (buckets journaliers zéro-remplis) + `events` (rollup
`AnalyticsEvent` par type/jour). Les séries sont dérivées de colonnes
`createdAt` ajoutées par la migration `admin_metrics` à `UserProfile` /
`Recipe` / `Planning` / `PlanningItem` (aucun code applicatif ne les lit ;
lignes préexistantes = timestamp de la migration). `bucketByDay` est
pur/testable sans base.
**Instrumentation** (`lib/analytics.service.ts`) — `analytics.recordEvent(type, { actorId?, context? })`
**fire-and-forget** : retourne `void`, insère détaché, un échec est loggué
`warn` et avalé (jamais de latence ni d'échec sur la requête appelante). `type`
est un `String` libre (`"user.signup"`, `"recipe.imported"`, `"recipe.created"`,
`"planning.item_added"`, `"tech_step.correction_submitted"`,
`"shopping_list.viewed"` aujourd'hui) — ajouter un évènement = un appel d'une
ligne, **sans migration**. `AnalyticsEvent.actorId` n'a **pas** de FK (un
évènement est un fait historique qui survit au compte qu'il décrit).
*(La table `WorkerHeartbeat` est créée par la même migration mais n'est
câblée que par le monitoring — section à venir.)*
---
## `reference` — catalogues publics (pas de session requise) ## `reference` — catalogues publics (pas de session requise)
Router `/reference` (`reference.routes.ts`/`.service.ts`) — **toutes les Router `/reference` (`reference.routes.ts`/`.service.ts`) — **toutes les