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>
199 lines
7.5 KiB
TypeScript
199 lines
7.5 KiB
TypeScript
import { type DateTime, getWeekStart, toDateOnly } from "@batch-cooking/date-tools";
|
|
import { HttpError } from "@batch-cooking/error-tools";
|
|
import {
|
|
type AddPlanningItemInput,
|
|
ErrorCode,
|
|
type PlanningItemView,
|
|
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";
|
|
|
|
/**
|
|
* Finds the household's planning that covers `date` and shapes it into a
|
|
* {@link PlanningView} (recipes resolved to `{id, name}`). `date` is
|
|
* whatever the caller wants "now" to mean — the current `/planning` route
|
|
* passes a `date-tools`-parsed `?date=` query param, letting a caller look
|
|
* up any week's planning, not just the one covering today.
|
|
*
|
|
* Returns `null` for two distinct, both entirely normal states — a
|
|
* `houseId` of `null` (the profile has no household yet — households are no
|
|
* longer created automatically at signup, see `auth.service.ts`) and "no
|
|
* planning row covers this date yet" (e.g. a week nobody has added a recipe
|
|
* to via {@link addPlanningItem}) — neither is an error, so both collapse
|
|
* to the same "nothing to show yet" result rather than throwing.
|
|
*/
|
|
export async function getPlanningForDate(
|
|
houseId: number | null,
|
|
date: DateTime,
|
|
): Promise<PlanningView | null> {
|
|
try {
|
|
if (houseId === null) {
|
|
return null;
|
|
}
|
|
|
|
// `startDate`/`finishDate` are `@db.Date` columns (no time-of-day
|
|
// component) — comparing against a UTC-midnight JS `Date` lines up with
|
|
// how Postgres stores/returns them, regardless of the server's local
|
|
// timezone.
|
|
const dateOnly = toDateOnly(date).toJSDate();
|
|
|
|
const planning = await prisma.planning.findFirst({
|
|
where: {
|
|
houseId,
|
|
startDate: { lte: dateOnly },
|
|
finishDate: { gte: dateOnly },
|
|
},
|
|
// A household should never have two plannings covering the same day,
|
|
// but nothing in the schema enforces that yet — pick the most recently
|
|
// started one rather than letting the query fail if it ever happens.
|
|
orderBy: { startDate: "desc" },
|
|
include: {
|
|
items: {
|
|
include: { recipe: { select: { id: true, name: true } } },
|
|
},
|
|
},
|
|
});
|
|
|
|
if (!planning) {
|
|
return null;
|
|
}
|
|
|
|
return {
|
|
id: planning.id,
|
|
startDate: planning.startDate.toISOString(),
|
|
finishDate: planning.finishDate.toISOString(),
|
|
items: planning.items.map((item) => ({
|
|
id: item.id,
|
|
weekDay: item.weekDay,
|
|
meal: item.meal,
|
|
portions: item.portions,
|
|
recipe: item.recipe,
|
|
})),
|
|
};
|
|
} catch (err) {
|
|
// Rethrown as-is — `wrapAsyncHandler`/the error middleware (which
|
|
// already logs it, see `error-logger.ts`) is what actually handles it,
|
|
// this service layer just isn't allowed a bare `await` per the repo's
|
|
// async/try-catch convention.
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Finds the household's `Planning` covering the week `weekStart` (a Monday)
|
|
* starts, creating it on the fly (spanning the full Monday-to-Sunday week)
|
|
* if none exists yet. Matched by exact `startDate`, not a covering range
|
|
* like {@link getPlanningForDate} — that query is for "what covers today",
|
|
* this one is for "the row `addPlanningItem` should attach to", so it needs
|
|
* to land on the same row a second add to the same week would reuse rather
|
|
* than risk matching some other overlapping planning.
|
|
*
|
|
* Not wrapped in a transaction with the `findFirst` — no unique constraint
|
|
* exists on `(houseId, startDate)` (see {@link getPlanningForDate}'s doc
|
|
* comment on the same gap), so two concurrent first-adds to an empty week
|
|
* could each create their own `Planning` row. Accepted at this project's
|
|
* scale rather than adding a migration + retry-on-conflict loop for it.
|
|
*/
|
|
async function findOrCreatePlanningForWeek(houseId: number, weekStart: DateTime) {
|
|
try {
|
|
const startDate = weekStart.toJSDate();
|
|
const existing = await prisma.planning.findFirst({
|
|
where: { houseId, startDate },
|
|
});
|
|
if (existing) {
|
|
return existing;
|
|
}
|
|
return await prisma.planning.create({
|
|
data: {
|
|
houseId,
|
|
startDate,
|
|
finishDate: weekStart.plus({ days: 6 }).toJSDate(),
|
|
},
|
|
});
|
|
} catch (err) {
|
|
throw err; // see getPlanningForDate()'s catch comment above
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Adds a recipe to one (day, meal) slot of `houseId`'s planning for the
|
|
* week containing `date`, creating that week's `Planning` row first if it
|
|
* doesn't exist yet (see {@link findOrCreatePlanningForWeek}) — this is the
|
|
* only place a `Planning` row gets created at all today, there's no
|
|
* separate "create an empty planning" action. `date` is a caller-parsed
|
|
* `DateTime` (see `planning.routes.ts`, which validates `input.date` the
|
|
* same way `GET /planning` validates its own `?date=`, before calling
|
|
* here) rather than the raw `input.date` string — only its week matters,
|
|
* not the exact day.
|
|
*
|
|
* @throws {HttpError} `404 HOUSE_NOT_FOUND` if `houseId` is `null` (the caller has no household).
|
|
* @throws {HttpError} `404 RECIPE_NOT_FOUND` if `input.recipeId` doesn't match a recipe visible to `viewerId` — see `recipe.service.ts`'s `assertRecipeVisible`.
|
|
*/
|
|
export async function addPlanningItem(
|
|
houseId: number | null,
|
|
viewerId: number,
|
|
viewerHouseId: number | null,
|
|
date: DateTime,
|
|
input: AddPlanningItemInput,
|
|
): Promise<PlanningItemView> {
|
|
try {
|
|
if (houseId === null) {
|
|
throw new HttpError(404, ErrorCode.HOUSE_NOT_FOUND, "Profile has no household");
|
|
}
|
|
await assertRecipeVisible(input.recipeId, viewerId, viewerHouseId);
|
|
|
|
const weekStart = getWeekStart(toDateOnly(date));
|
|
const planning = await findOrCreatePlanningForWeek(houseId, weekStart);
|
|
|
|
const item = await prisma.planningItem.create({
|
|
data: {
|
|
planningId: planning.id,
|
|
weekDay: input.weekDay,
|
|
meal: input.meal,
|
|
recipeId: input.recipeId,
|
|
portions: input.portions,
|
|
},
|
|
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,
|
|
meal: item.meal,
|
|
portions: item.portions,
|
|
recipe: item.recipe,
|
|
};
|
|
} catch (err) {
|
|
throw err; // see getPlanningForDate()'s catch comment above
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Removes one planning item outright. The parent `Planning` row is left in
|
|
* place even if this was its last item — an empty planning is a normal,
|
|
* already-handled state on the read side (`getPlanningForDate`'s grid just
|
|
* shows every slot's "+"), not worth a cleanup pass here.
|
|
*
|
|
* @throws {HttpError} `404 PLANNING_ITEM_NOT_FOUND` if `id` doesn't match any planning item, or does but belongs to a planning outside `houseId` — never `403`, same "don't confirm what exists" reasoning as `RECIPE_NOT_FOUND` elsewhere.
|
|
*/
|
|
export async function removePlanningItem(id: number, houseId: number | null): Promise<void> {
|
|
try {
|
|
const item = await prisma.planningItem.findUnique({
|
|
where: { id },
|
|
include: { planning: true },
|
|
});
|
|
if (!item || houseId === null || item.planning.houseId !== houseId) {
|
|
throw new HttpError(404, ErrorCode.PLANNING_ITEM_NOT_FOUND, `Planning item ${id} not found`);
|
|
}
|
|
await prisma.planningItem.delete({ where: { id } });
|
|
} catch (err) {
|
|
throw err; // see getPlanningForDate()'s catch comment above
|
|
}
|
|
}
|