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 <noreply@anthropic.com>
This commit is contained in:
parent
e90a6d16e7
commit
afe47e161c
22 changed files with 1183 additions and 13 deletions
95
apps/admin-web/cypress/e2e/dashboard.cy.ts
Normal file
95
apps/admin-web/cypress/e2e/dashboard.cy.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
|
|
@ -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<AdminUserView> {
|
||||
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<MetricsView> {
|
||||
return this._request(`/admin/metrics?days=${days}`);
|
||||
}
|
||||
}
|
||||
|
||||
/** Single shared instance — stateless, same reasoning as apps/web's `apiClient`. */
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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<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 (
|
||||
<div className="admin-page">
|
||||
<h1 className="admin-page__title">{t("admin.dashboard.title")}</h1>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
103
apps/admin-web/src/pages/dashboard/dashboard-page.scss
Normal file
103
apps/admin-web/src/pages/dashboard/dashboard-page.scss
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
51
apps/admin-web/src/pages/dashboard/dashboard.ts
Normal file
51
apps/admin-web/src/pages/dashboard/dashboard.ts
Normal 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 },
|
||||
];
|
||||
}
|
||||
|
|
@ -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")
|
||||
);
|
||||
|
|
@ -103,6 +103,10 @@ model UserProfile {
|
|||
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[]
|
||||
|
|
@ -206,6 +216,8 @@ model PlanningItem {
|
|||
meal String
|
||||
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")
|
||||
}
|
||||
|
|
|
|||
65
apps/api/src/lib/analytics.service.ts
Normal file
65
apps/api/src/lib/analytics.service.ts
Normal 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();
|
||||
22
apps/api/src/modules/admin/admin-metrics.routes.ts
Normal file
22
apps/api/src/modules/admin/admin-metrics.routes.ts
Normal 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=` (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));
|
||||
}),
|
||||
);
|
||||
242
apps/api/src/modules/admin/admin-metrics.service.ts
Normal file
242
apps/api/src/modules/admin/admin-metrics.service.ts
Normal 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`, 7–365).
|
||||
*/
|
||||
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
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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<AuthResult> {
|
|||
},
|
||||
});
|
||||
|
||||
analytics.recordEvent("user.signup", { actorId: profile.id });
|
||||
|
||||
const token = signAuthToken({
|
||||
userProfileId: profile.id,
|
||||
tokenVersion: profile.tokenVersion,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
149
apps/api/test/admin-metrics.test.ts
Normal file
149
apps/api/test/admin-metrics.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -16,3 +16,15 @@ export const adminLoginSchema = z.object({
|
|||
});
|
||||
/** Inferred TS type for {@link adminLoginSchema}'s validated output. */
|
||||
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>;
|
||||
|
|
|
|||
|
|
@ -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[];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -347,6 +347,51 @@ identique (aucune conversion — même posture que `ShoppingListItemView`).
|
|||
|
||||
---
|
||||
|
||||
## `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
|
||||
|
|
|
|||
Loading…
Reference in a new issue