From b08081e48dc4339bb51871abf29bc57d6be5b876 Mon Sep 17 00:00:00 2001 From: Nicolas Date: Fri, 28 Aug 2026 12:46:54 +0200 Subject: [PATCH] feat(admin): metriques d'utilisation (derive DB + AnalyticsEvent) PR 3 du chantier admin. Tableau de bord metriques : snapshot de compteurs + series temporelles journalieres. Schema (migration admin_metrics) : - AnalyticsEvent (type String libre, actorType/actorId sans FK, context Json, index [type, created_at]) + WorkerHeartbeat (cable en PR 4). - colonnes createdAt @default(now()) sur UserProfile / Recipe / Planning / PlanningItem (lecture admin uniquement ; lignes existantes = timestamp de la migration). Instrumentation (lib/analytics.service.ts, fire-and-forget) : - analytics.recordEvent(type, {actorId?, context?}) : retourne void, insert detache, echec loggue+avale, jamais de latence sur la requete. - points d'appel : user.signup, recipe.created, recipe.imported, planning.item_added, tech_step.correction_submitted, shopping_list.viewed. API : GET /admin/metrics?days= (7-365, defaut 30, requireAdmin) -> admin-metrics.service.ts. bucketByDay pur (zero-remplissage, teste sans base). MetricsView dans packages/shared. Front : DashboardPage (tuiles KPI + un graphe recharts par serie + listes recettes-par-source / evenements), logique pure dans dashboard.ts, i18n admin.dashboard.*. AdminApiClient.getMetrics. reset-db.ts truncate analytics_events + worker_heartbeats. Tests : Mocha admin-metrics.test.ts (bucketByDay pur x2 verts ; snapshot, series zero-remplies, event user.signup fire-and-forget) ; Cypress dashboard.cy.ts (2 verts). specs/backend-architecture.md : section admin. Co-Authored-By: Claude Sonnet 5 --- apps/admin-web/cypress/e2e/dashboard.cy.ts | 95 +++++++ apps/admin-web/src/api/client.ts | 6 + .../admin-web/src/locales/fr/translation.json | 31 ++- .../src/pages/dashboard/DashboardPage.tsx | 164 +++++++++++- .../src/pages/dashboard/dashboard-page.scss | 103 ++++++++ .../src/pages/dashboard/dashboard.ts | 51 ++++ .../migration.sql | 32 +++ apps/api/prisma/schema.prisma | 75 +++++- apps/api/src/lib/analytics.service.ts | 65 +++++ .../src/modules/admin/admin-metrics.routes.ts | 22 ++ .../modules/admin/admin-metrics.service.ts | 242 ++++++++++++++++++ apps/api/src/modules/admin/admin.routes.ts | 2 + apps/api/src/modules/auth/auth.service.ts | 3 + .../src/modules/planning/planning.service.ts | 6 + .../recipe-tech-step-correction.service.ts | 11 + apps/api/src/modules/recipe/recipe.service.ts | 7 + .../shopping-list/shopping-list.routes.ts | 2 + apps/api/test-support/reset-db.ts | 2 +- apps/api/test/admin-metrics.test.ts | 149 +++++++++++ packages/shared/src/schemas/admin.ts | 12 + packages/shared/src/types/admin.ts | 71 +++++ specs/backend-architecture.md | 45 ++++ 22 files changed, 1183 insertions(+), 13 deletions(-) create mode 100644 apps/admin-web/cypress/e2e/dashboard.cy.ts create mode 100644 apps/admin-web/src/pages/dashboard/dashboard-page.scss create mode 100644 apps/admin-web/src/pages/dashboard/dashboard.ts create mode 100644 apps/api/prisma/migrations/20260828130000_admin_metrics/migration.sql create mode 100644 apps/api/src/lib/analytics.service.ts create mode 100644 apps/api/src/modules/admin/admin-metrics.routes.ts create mode 100644 apps/api/src/modules/admin/admin-metrics.service.ts create mode 100644 apps/api/test/admin-metrics.test.ts diff --git a/apps/admin-web/cypress/e2e/dashboard.cy.ts b/apps/admin-web/cypress/e2e/dashboard.cy.ts new file mode 100644 index 0000000..e731c23 --- /dev/null +++ b/apps/admin-web/cypress/e2e/dashboard.cy.ts @@ -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"); + }); +}); diff --git a/apps/admin-web/src/api/client.ts b/apps/admin-web/src/api/client.ts index 132e39d..ed7fb21 100644 --- a/apps/admin-web/src/api/client.ts +++ b/apps/admin-web/src/api/client.ts @@ -3,6 +3,7 @@ import { type AdminUserView, type ApiErrorResponse, ErrorCode, + type MetricsView, } from "@batch-cooking/shared"; /** @@ -90,6 +91,11 @@ export class AdminApiClient { public me(): Promise { return this._request("/admin/auth/me"); } + + /** Usage metrics for the dashboard — snapshot totals + `days` (7–365) of daily time series. */ + public getMetrics(days: number): Promise { + return this._request(`/admin/metrics?days=${days}`); + } } /** Single shared instance — stateless, same reasoning as apps/web's `apiClient`. */ diff --git a/apps/admin-web/src/locales/fr/translation.json b/apps/admin-web/src/locales/fr/translation.json index 198b1d2..297aebf 100644 --- a/apps/admin-web/src/locales/fr/translation.json +++ b/apps/admin-web/src/locales/fr/translation.json @@ -9,7 +9,9 @@ }, "admin": { "common": { - "comingSoon": "Section à venir." + "comingSoon": "Section à venir.", + "loading": "Chargement…", + "loadError": "Impossible de charger les données, réessayez plus tard." }, "login": { "title": "Administration", @@ -28,7 +30,32 @@ }, "dashboard": { "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": { "title": "Monitoring", diff --git a/apps/admin-web/src/pages/dashboard/DashboardPage.tsx b/apps/admin-web/src/pages/dashboard/DashboardPage.tsx index 5eab1a5..56f66e8 100644 --- a/apps/admin-web/src/pages/dashboard/DashboardPage.tsx +++ b/apps/admin-web/src/pages/dashboard/DashboardPage.tsx @@ -1,17 +1,175 @@ +import type { MetricsTimeBucket, MetricsView } from "@batch-cooking/shared"; +import { useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; +import { + CartesianGrid, + Line, + LineChart, + ResponsiveContainer, + Tooltip, + XAxis, + YAxis, +} from "recharts"; +import { adminApiClient } from "../../api/client"; import "../admin-page.scss"; +import "./dashboard-page.scss"; +import { formatCount, kpiTiles, seriesTotal, shortDay } from "./dashboard"; + +/** Load state for `GET /admin/metrics` — same discriminated-union shape as apps/web's page states. */ +type DashboardState = + | { status: "loading" } + | { status: "loaded"; metrics: MetricsView } + | { status: "error" }; + +const RANGE_DAYS = 30; /** - * Usage-metrics dashboard — KPI tiles + trend charts fed by - * `GET /admin/metrics`. Placeholder until PR 3 (metrics) fills it in. + * Usage-metrics dashboard. KPI tiles from the snapshot, then a small line + * chart per instrumented time series over the last {@link RANGE_DAYS} days. + * Fed by `GET /admin/metrics`; read-only, refetched only on mount. */ export function DashboardPage() { const { t } = useTranslation(); + const [state, setState] = useState({ 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 (

{t("admin.dashboard.title")}

{t("admin.dashboard.lead")}

-

{t("admin.common.comingSoon")}

+ + {state.status === "loading" && ( +

{t("admin.common.loading")}

+ )} + {state.status === "error" && ( +

{t("admin.common.loadError")}

+ )} + {state.status === "loaded" && } +
+ ); +} + +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 ( + <> +
+ {kpiTiles(snapshot).map((tile) => ( +
+ {formatCount(tile.value)} + {t(`admin.dashboard.kpi.${tile.labelKey}`)} +
+ ))} +
+ +
+ {charts.map(({ key, buckets }) => ( +
+
+

{t(`admin.dashboard.series.${key}`)}

+ + {t("admin.dashboard.windowTotal", { n: seriesTotal(buckets) })} + +
+ +
+ ))} +
+ +
+

{t("admin.dashboard.recipesBySource")}

+ {snapshot.recipesBySource.length === 0 ? ( +

{t("admin.dashboard.noImports")}

+ ) : ( +
    + {snapshot.recipesBySource.map((row) => ( +
  • + {row.label} + {formatCount(row.count)} +
  • + ))} +
+ )} +
+ + {metrics.events.length > 0 && ( +
+

{t("admin.dashboard.events")}

+
    + {metrics.events.map((event) => ( +
  • + {event.type} + {formatCount(seriesTotal(event.buckets))} +
  • + ))} +
+
+ )} + + ); +} + +/** 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 ( +
+ + + + + + + + +
); } diff --git a/apps/admin-web/src/pages/dashboard/dashboard-page.scss b/apps/admin-web/src/pages/dashboard/dashboard-page.scss new file mode 100644 index 0000000..e31c564 --- /dev/null +++ b/apps/admin-web/src/pages/dashboard/dashboard-page.scss @@ -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); + } + } + } +} diff --git a/apps/admin-web/src/pages/dashboard/dashboard.ts b/apps/admin-web/src/pages/dashboard/dashboard.ts new file mode 100644 index 0000000..f2a8e8f --- /dev/null +++ b/apps/admin-web/src/pages/dashboard/dashboard.ts @@ -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 }, + ]; +} diff --git a/apps/api/prisma/migrations/20260828130000_admin_metrics/migration.sql b/apps/api/prisma/migrations/20260828130000_admin_metrics/migration.sql new file mode 100644 index 0000000..473e744 --- /dev/null +++ b/apps/api/prisma/migrations/20260828130000_admin_metrics/migration.sql @@ -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") +); diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index d8e08da..d9f373c 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -100,9 +100,13 @@ model UserProfile { passwordHash String @map("password_hash") /// Bumped to invalidate previously-issued JWTs (e.g. on password change). /// Not in the original spec doc — required for stateless JWT auth. - tokenVersion Int @default(0) @map("token_version") - houseId Int? @map("house_id") - dietId Int? @map("diet_id") + tokenVersion Int @default(0) @map("token_version") + houseId Int? @map("house_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) diet Diet? @relation(fields: [dietId], references: [id], onDelete: SetNull) @@ -192,6 +196,12 @@ model Planning { startDate DateTime @map("start_date") @db.Date finishDate DateTime @map("finish_date") @db.Date 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) items PlanningItem[] @@ -200,12 +210,14 @@ model Planning { } model PlanningItem { - id Int @id @default(autoincrement()) - planningId Int @map("planning_id") - weekDay String @map("week_day") + id Int @id @default(autoincrement()) + planningId Int @map("planning_id") + weekDay String @map("week_day") meal String - recipeId Int @map("recipe_id") + recipeId Int @map("recipe_id") 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) recipe Recipe @relation(fields: [recipeId], references: [id]) @@ -319,6 +331,10 @@ model Recipe { /// the author had no household yet. authorHouseId Int? @map("author_house_id") 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]) authorHouse House? @relation(fields: [authorHouseId], references: [id], onDelete: SetNull) @@ -930,3 +946,48 @@ model AdminUser { @@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") +} diff --git a/apps/api/src/lib/analytics.service.ts b/apps/api/src/lib/analytics.service.ts new file mode 100644 index 0000000..66270e1 --- /dev/null +++ b/apps/api/src/lib/analytics.service.ts @@ -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 { + 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(); diff --git a/apps/api/src/modules/admin/admin-metrics.routes.ts b/apps/api/src/modules/admin/admin-metrics.routes.ts new file mode 100644 index 0000000..3bed532 --- /dev/null +++ b/apps/api/src/modules/admin/admin-metrics.routes.ts @@ -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=` (7–365, 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)); + }), +); diff --git a/apps/api/src/modules/admin/admin-metrics.service.ts b/apps/api/src/modules/admin/admin-metrics.service.ts new file mode 100644 index 0000000..fb838cc --- /dev/null +++ b/apps/api/src/modules/admin/admin-metrics.service.ts @@ -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(); + 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 { + 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`, 7–365). + */ +export async function getMetrics(days: number): Promise { + 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(); + 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 + } +} diff --git a/apps/api/src/modules/admin/admin.routes.ts b/apps/api/src/modules/admin/admin.routes.ts index ce51e05..75ffe8e 100644 --- a/apps/api/src/modules/admin/admin.routes.ts +++ b/apps/api/src/modules/admin/admin.routes.ts @@ -1,5 +1,6 @@ import { Router } from "express"; 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` @@ -11,3 +12,4 @@ import { adminAuthRouter } from "./admin-auth.routes.js"; export const adminRouter = Router(); adminRouter.use("/auth", adminAuthRouter); +adminRouter.use("/metrics", adminMetricsRouter); diff --git a/apps/api/src/modules/auth/auth.service.ts b/apps/api/src/modules/auth/auth.service.ts index 85d0c76..58dd0fb 100644 --- a/apps/api/src/modules/auth/auth.service.ts +++ b/apps/api/src/modules/auth/auth.service.ts @@ -8,6 +8,7 @@ import { import argon2 from "argon2"; import { env } from "../../config/env.js"; import { prisma } from "../../db/prisma.js"; +import { analytics } from "../../lib/analytics.service.js"; import { signAuthToken } from "../../lib/jwt.js"; import { toSafeProfile } from "../../lib/safe-profile.js"; import { leaveCurrentHouse } from "../house/house.service.js"; @@ -58,6 +59,8 @@ export async function signup(input: SignupInput): Promise { }, }); + analytics.recordEvent("user.signup", { actorId: profile.id }); + const token = signAuthToken({ userProfileId: profile.id, tokenVersion: profile.tokenVersion, diff --git a/apps/api/src/modules/planning/planning.service.ts b/apps/api/src/modules/planning/planning.service.ts index 23146e1..2fcaaf9 100644 --- a/apps/api/src/modules/planning/planning.service.ts +++ b/apps/api/src/modules/planning/planning.service.ts @@ -7,6 +7,7 @@ import { type PlanningView, } from "@batch-cooking/shared"; import { prisma } from "../../db/prisma.js"; +import { analytics } from "../../lib/analytics.service.js"; import { assertRecipeVisible } from "../recipe/recipe.service.js"; /** @@ -157,6 +158,11 @@ export async function addPlanningItem( include: { recipe: { select: { id: true, name: true } } }, }); + analytics.recordEvent("planning.item_added", { + actorId: viewerId, + context: { recipeId: input.recipeId, portions: input.portions }, + }); + return { id: item.id, weekDay: item.weekDay, diff --git a/apps/api/src/modules/recipe/recipe-tech-step-correction.service.ts b/apps/api/src/modules/recipe/recipe-tech-step-correction.service.ts index a43a8fb..b106f2c 100644 --- a/apps/api/src/modules/recipe/recipe-tech-step-correction.service.ts +++ b/apps/api/src/modules/recipe/recipe-tech-step-correction.service.ts @@ -7,6 +7,7 @@ import { } from "@batch-cooking/shared"; import type { Prisma } from "@prisma/client"; import { prisma } from "../../db/prisma.js"; +import { analytics } from "../../lib/analytics.service.js"; import { assertRecipeVisible, toStepTechStepViews } from "./recipe.service.js"; /** @@ -472,6 +473,16 @@ export async function submitTechStepCorrection( 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) }; } catch (err) { throw err; // see loadVisibleStepOrThrow's catch comment diff --git a/apps/api/src/modules/recipe/recipe.service.ts b/apps/api/src/modules/recipe/recipe.service.ts index 9b7e6c6..42f02ad 100644 --- a/apps/api/src/modules/recipe/recipe.service.ts +++ b/apps/api/src/modules/recipe/recipe.service.ts @@ -14,6 +14,7 @@ import { } from "@batch-cooking/shared"; import type { Prisma } from "@prisma/client"; import { prisma } from "../../db/prisma.js"; +import { analytics } from "../../lib/analytics.service.js"; import { type TechStepMatch, techStepClassifier, @@ -616,6 +617,12 @@ async function createRecipeInternal( }, include: recipeInclude(authorId), }); + + analytics.recordEvent(source === null ? "recipe.created" : "recipe.imported", { + actorId: authorId, + context: { recipeId: created.id, sourceId: source?.sourceId ?? null }, + }); + return toRecipeView(created); } catch (err) { throw err; // see suitableForHouseholdWhere()'s catch comment above diff --git a/apps/api/src/modules/shopping-list/shopping-list.routes.ts b/apps/api/src/modules/shopping-list/shopping-list.routes.ts index dae9ab3..6880d43 100644 --- a/apps/api/src/modules/shopping-list/shopping-list.routes.ts +++ b/apps/api/src/modules/shopping-list/shopping-list.routes.ts @@ -3,6 +3,7 @@ import { HttpError } from "@batch-cooking/error-tools"; import { wrapAsyncHandler } from "@batch-cooking/express-tools"; import { ErrorCode, getShoppingListSchema } from "@batch-cooking/shared"; import { Router } from "express"; +import { analytics } from "../../lib/analytics.service.js"; import { type AuthLocals, requireAuth } from "../../middlewares/require-auth.js"; import { getShoppingListForDate } from "./shopping-list.service.js"; @@ -31,6 +32,7 @@ shoppingListRouter.get( } const shoppingList = await getShoppingListForDate(res.locals.userProfile.houseId, date); + analytics.recordEvent("shopping_list.viewed", { actorId: res.locals.userProfile.id }); res.status(200).json(shoppingList); }), ); diff --git a/apps/api/test-support/reset-db.ts b/apps/api/test-support/reset-db.ts index 6d98bd6..c543730 100644 --- a/apps/api/test-support/reset-db.ts +++ b/apps/api/test-support/reset-db.ts @@ -48,7 +48,7 @@ export async function resetDatabase() { "recipe_ingredient", "step_tech_step", "step", "tech_step", "recipe", "ingredients", "sources", "unit", "user_profiles", "diet", "house", - "admin_users" + "admin_users", "analytics_events", "worker_heartbeats" RESTART IDENTITY CASCADE; `); await seedReferenceData(prisma); diff --git a/apps/api/test/admin-metrics.test.ts b/apps/api/test/admin-metrics.test.ts new file mode 100644 index 0000000..c7ba930 --- /dev/null +++ b/apps/api/test/admin-metrics.test.ts @@ -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, timeoutMs = 2000): Promise { + 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); + }); + }); + }); +}); diff --git a/packages/shared/src/schemas/admin.ts b/packages/shared/src/schemas/admin.ts index f02cf08..2b7fb28 100644 --- a/packages/shared/src/schemas/admin.ts +++ b/packages/shared/src/schemas/admin.ts @@ -16,3 +16,15 @@ export const adminLoginSchema = z.object({ }); /** Inferred TS type for {@link adminLoginSchema}'s validated output. */ export type AdminLoginInput = z.infer; + +/** + * 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; diff --git a/packages/shared/src/types/admin.ts b/packages/shared/src/types/admin.ts index 499e5eb..364139e 100644 --- a/packages/shared/src/types/admin.ts +++ b/packages/shared/src/types/admin.ts @@ -14,3 +14,74 @@ export interface AdminUserView { createdAt: string; lastLoginAt: string | null; } + +/** One `{ key, count }` breakdown row — e.g. recipes per source, corrections per status. */ +export interface MetricsBreakdownRow { + key: string; + /** Human-readable label when the key isn't self-explanatory (a source's display name); otherwise equal to `key`. */ + label: string; + count: number; +} + +/** + * Point-in-time totals for the admin dashboard's KPI tiles — every field is + * a `COUNT` against the current database, not a time series. See + * `apps/api`'s `admin-metrics.service.ts`. + */ +export interface MetricsSnapshotView { + admins: number; + users: number; + households: number; + /** Households with at least one `Planning` row. */ + activeHouseholds: number; + recipes: number; + recipesManual: number; + recipesImported: number; + /** Imported recipes grouped by their `Source` (`key` = source key, `label` = display name). */ + recipesBySource: MetricsBreakdownRow[]; + plannings: number; + planningItems: number; + steps: number; + detectedTechniques: number; + favorites: number; + corrections: number; + /** Corrections not yet turned into a `TechStepTrainingSuggestion` (`consumedAt IS NULL`). */ + correctionsUnconsumed: number; + /** Corrections that assert "no technique here" (`correctedTechStepId IS NULL`) — never become suggestions. */ + correctionsRemoval: number; + trainingSuggestions: number; + trainingSuggestionsByStatus: MetricsBreakdownRow[]; + trainingSuggestionsBySourceType: MetricsBreakdownRow[]; +} + +/** One day of a metrics time series. `date` is `YYYY-MM-DD` (UTC day). Days with no activity are present with `count: 0`. */ +export interface MetricsTimeBucket { + date: string; + count: number; +} + +/** One instrumented `AnalyticsEvent` type, bucketed by day over the requested window. */ +export interface MetricsEventSeries { + type: string; + buckets: MetricsTimeBucket[]; +} + +/** + * Response of `GET /admin/metrics`. `snapshot` is "right now"; `series` and + * `events` cover the last `rangeDays` days as daily buckets (zero-filled). + * `series` is derived from the `createdAt` columns added by the + * `admin_metrics` migration; `events` from the `AnalyticsEvent` table. + */ +export interface MetricsView { + generatedAt: string; + rangeDays: number; + snapshot: MetricsSnapshotView; + series: { + signups: MetricsTimeBucket[]; + recipesCreated: MetricsTimeBucket[]; + planningItemsAdded: MetricsTimeBucket[]; + correctionsSubmitted: MetricsTimeBucket[]; + trainingSuggestions: MetricsTimeBucket[]; + }; + events: MetricsEventSeries[]; +} diff --git a/specs/backend-architecture.md b/specs/backend-architecture.md index 6c5902c..c19462a 100644 --- a/specs/backend-architecture.md +++ b/specs/backend-architecture.md @@ -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=` 7–365, +défaut 30) — `MetricsView` = `snapshot` (des `count`s : utilisateurs, foyers, +foyers actifs, recettes manuelles/importées + ventilation par source, +plannings, créneaux, corrections par état, suggestions par statut/source…) + +`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) Router `/reference` (`reference.routes.ts`/`.service.ts`) — **toutes les -- 2.45.2