batchCooking/apps/api/src/modules/planning/planning.service.ts
Nicolas 3a416ea955 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>
2026-08-28 23:36:31 +02:00

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
}
}