diff --git a/src/app/api/dashboard/summary/route.ts b/src/app/api/dashboard/summary/route.ts index 816e122e4..27895ba19 100644 --- a/src/app/api/dashboard/summary/route.ts +++ b/src/app/api/dashboard/summary/route.ts @@ -48,8 +48,7 @@ import { getServerTranslator } from "@/lib/i18n/server-translator"; import { defaultLocale, type Locale } from "@/lib/i18n/config"; import { userDayKey, DEFAULT_TIMEZONE } from "@/lib/tz/resolver"; import { cached, caches, type ServerCache } from "@/lib/cache/server-cache"; -import { expandTodayIntakes } from "@/lib/medication-schedule"; -import { recomputeMedicationComplianceForEvent } from "@/lib/rollups/medication-compliance-rollups"; +import { projectTodayIntakesAndRecompute } from "@/lib/medications/scheduling/project-today-intakes"; const SPARK_DAYS = 7; const STREAK_WINDOW_DAYS = 365; @@ -271,26 +270,15 @@ function buildContext( } /** - * v1.4.39 W-SERVER-FIX-2 — project today's active schedules through - * the canonical `expandTodayIntakes` helper, idempotently backfill any - * missing `MedicationIntakeEvent` rows, and re-read the today-window - * so the caller sees both pre-existing rows + the freshly minted ones. + * v1.4.39 W-SERVER-FIX-2 — project today's active schedules, idempotently + * backfill any missing `MedicationIntakeEvent` rows via the shared + * helper, then re-read the today-window so the caller sees both + * pre-existing rows + the freshly minted ones. * - * Mirrors the post-fix behaviour of `/api/medications/intake?scope= - * today` so the iOS Dashboard tile (fed by this route) and the iOS + * Mirrors the behaviour of `/api/medications/intake?scope=today` (same + * helper) so the iOS Dashboard tile (fed by this route) and the iOS * Erfassen sheet (fed by the intake route) converge on the same row - * set the moment a daily med becomes active. Without this projection - * the dashboard route returned `[]` for daily meds (`daysOfWeek = null`) - * even after the intake route's projection landed, leaving the iOS - * Dashboard tile in its "Heute nichts geplant" empty state all - * morning. - * - * `skipDuplicates: true` guards against a concurrent intake-route hit - * minting the same `(userId, medicationId, scheduledFor, REMINDER)` - * row in the gap between the existence probe and the `createMany`. - * The schema-level `@@unique` is the structural backstop; this flag - * is the defense-in-depth that keeps the route returning 2xx instead - * of bubbling a constraint violation during the race window. + * set the moment a daily med becomes active. */ async function projectAndReadTodaysIntakes( userId: string, @@ -298,89 +286,12 @@ async function projectAndReadTodaysIntakes( todayStart: Date, todayEnd: Date, ): Promise> { - const activeMedications = await prisma.medication.findMany({ - where: { userId, active: true }, - select: { - id: true, - schedules: { - select: { - id: true, - medicationId: true, - windowStart: true, - windowEnd: true, - daysOfWeek: true, - }, - }, - }, - }); - const projected = expandTodayIntakes( - activeMedications.flatMap((m) => m.schedules), - new Date(), + await projectTodayIntakesAndRecompute({ + userId, userTz, - ); - - if (projected.length > 0) { - const existing = await prisma.medicationIntakeEvent.findMany({ - where: { - userId, - scheduledFor: { gte: todayStart, lt: todayEnd }, - }, - select: { medicationId: true, scheduledFor: true }, - }); - const existingKey = new Set( - existing.map((e) => `${e.medicationId}|${e.scheduledFor.toISOString()}`), - ); - const missing = projected.filter( - (p) => - !existingKey.has(`${p.medicationId}|${p.scheduledFor.toISOString()}`), - ); - if (missing.length > 0) { - await prisma.medicationIntakeEvent.createMany({ - data: missing.map((m) => ({ - userId, - medicationId: m.medicationId, - scheduledFor: m.scheduledFor, - takenAt: null, - skipped: false, - // `REMINDER` mirrors the intake-route projection — the same - // `IntakeSource` literal the reminder worker uses for - // RED-phase rows. Doctor-report + analytics filters stay - // byte-stable across both code paths. - source: "REMINDER", - })), - skipDuplicates: true, - }); - - // v1.4.40 — close the compliance-rollup hook gap from v1.4.39.4. - // Mirrors the intake-route call site: after a bulk projection - // backfill, fire one rollup recompute per distinct - // `(medication_id, dayKey)` so the rollup row reflects the new - // `scheduled` count before the next read. Best-effort: errors - // stay caught inside the helper so a populator hiccup doesn't - // surface as a 5xx on the dashboard summary. - const seenDayKeys = new Set(); - const recomputeJobs: Array> = []; - for (const m of missing) { - const key = `${m.medicationId}|${m.scheduledFor.toISOString().slice(0, 10)}`; - if (seenDayKeys.has(key)) continue; - seenDayKeys.add(key); - recomputeJobs.push( - recomputeMedicationComplianceForEvent({ - userId, - medicationId: m.medicationId, - scheduledFor: m.scheduledFor, - tz: userTz, - }), - ); - } - // Defence in depth (specialist H1): the helper swallows internally - // today, but any future refactor that lets a throw escape would - // turn the dashboard summary into a 5xx. `allSettled` keeps the - // best-effort contract explicit at the call site so the request - // stays 200-OK regardless of helper behaviour. - await Promise.allSettled(recomputeJobs); - } - } + todayStart, + todayEnd, + }); return prisma.medicationIntakeEvent.findMany({ where: { diff --git a/src/app/api/medications/intake/route.ts b/src/app/api/medications/intake/route.ts index 5a8d475e3..957b499df 100644 --- a/src/app/api/medications/intake/route.ts +++ b/src/app/api/medications/intake/route.ts @@ -33,7 +33,7 @@ import { enqueueUserMedicationComplianceBackfill, type ComplianceBucket as RollupComplianceBucket, } from "@/lib/rollups/medication-compliance-rollups"; -import { expandTodayIntakes } from "@/lib/medication-schedule"; +import { projectTodayIntakesAndRecompute } from "@/lib/medications/scheduling/project-today-intakes"; const querySchema = z.object({ scope: z.enum(["today", "compliance"]), @@ -93,103 +93,15 @@ export const GET = apiHandler(async (request: NextRequest) => { // for daily meds (`schedule.daysOfWeek = null` in the DB) until // the reminder worker entered the RED phase at the end of the // dose window — leaving the iOS Dashboard tile + the "Erfassen" - // sheet empty for the whole morning. The projection re-uses the - // canonical `parseScheduleRecurrence` (null/empty = every day) - // so the daysOfWeek semantics stay aligned across every reader. - const activeMedications = await prisma.medication.findMany({ - where: { userId: user.id, active: true }, - select: { - id: true, - schedules: { - select: { - id: true, - medicationId: true, - windowStart: true, - windowEnd: true, - daysOfWeek: true, - }, - }, - }, - }); - const projected = expandTodayIntakes( - activeMedications.flatMap((m) => m.schedules), - new Date(), + // sheet empty for the whole morning. Shared helper mirrors the + // dashboard-summary call site so the two routes converge on the + // same row set. + const { projected, backfilled } = await projectTodayIntakesAndRecompute({ + userId: user.id, userTz, - ); - - const existing = await prisma.medicationIntakeEvent.findMany({ - where: { - userId: user.id, - scheduledFor: { gte: todayStart, lt: todayEnd }, - }, - select: { medicationId: true, scheduledFor: true }, + todayStart, + todayEnd, }); - const existingKey = new Set( - existing.map((e) => `${e.medicationId}|${e.scheduledFor.toISOString()}`), - ); - const missing = projected.filter( - (p) => - !existingKey.has(`${p.medicationId}|${p.scheduledFor.toISOString()}`), - ); - if (missing.length > 0) { - await prisma.medicationIntakeEvent.createMany({ - data: missing.map((m) => ({ - userId: user.id, - medicationId: m.medicationId, - scheduledFor: m.scheduledFor, - takenAt: null, - skipped: false, - // `REMINDER` is the same source the reminder worker uses to - // mint RED-phase rows — semantically "the server projected - // this slot before the user logged anything". The IntakeSource - // enum has no separate `SCHEDULER` literal; reusing REMINDER - // keeps the doctor-report + analytics filters byte-stable. - source: "REMINDER", - })), - // v1.4.39 W-SERVER-FIX-2 — paired with the schema-level - // @@unique([userId, medicationId, scheduledFor, source]). A - // concurrent dashboard-summary hit (the second route that now - // runs the same projection) can race a duplicate row in - // between this route's existence probe and createMany; the - // flag tells Postgres to swallow the rejected rows so the - // request still returns 2xx. The unique constraint is the - // structural backstop, this is the defense-in-depth. - skipDuplicates: true, - }); - - // v1.4.40 — close the compliance-rollup hook gap from v1.4.39.4. - // The bulk projection mints fresh `(medicationId, scheduledFor)` - // rows in PENDING state; without this hook the rollup for the - // affected `(user, medication, day)` tuples stays at its previous - // (pre-projection) `scheduled` count, which inflates the apparent - // compliance % until the user actually logs against the new - // row. Coalesce by `(medicationId, dayKey)` so we fire one - // recompute per distinct day-tuple instead of one per row. - // Best-effort: errors stay caught inside the helper so a - // populator hiccup doesn't surface as a 5xx on the intake - // request. Mirror call site in `dashboard/summary/route.ts`. - const seenDayKeys = new Set(); - const recomputeJobs: Array> = []; - for (const m of missing) { - const key = `${m.medicationId}|${m.scheduledFor.toISOString().slice(0, 10)}`; - if (seenDayKeys.has(key)) continue; - seenDayKeys.add(key); - recomputeJobs.push( - recomputeMedicationComplianceForEvent({ - userId: user.id, - medicationId: m.medicationId, - scheduledFor: m.scheduledFor, - tz: userTz, - }), - ); - } - // Defence in depth (specialist H1): the helper swallows internally - // today, but any future refactor that lets a throw escape would - // turn the intake POST into a 5xx. `allSettled` keeps the - // best-effort contract explicit at the call site so the request - // stays 200-OK regardless of helper behaviour. - await Promise.allSettled(recomputeJobs); - } const events = await prisma.medicationIntakeEvent.findMany({ where: { @@ -204,8 +116,8 @@ export const GET = apiHandler(async (request: NextRequest) => { action: { name: "medications.intake.today" }, meta: { count: events.length, - projected: projected.length, - backfilled: missing.length, + projected, + backfilled, }, }); diff --git a/src/lib/medications/scheduling/project-today-intakes.ts b/src/lib/medications/scheduling/project-today-intakes.ts new file mode 100644 index 000000000..ee0dc045b --- /dev/null +++ b/src/lib/medications/scheduling/project-today-intakes.ts @@ -0,0 +1,145 @@ +/** + * Shared today-window intake projector for the two routes that both + * need to backfill `MedicationIntakeEvent` rows for daily schedules + * before reading them: + * + * - `/api/medications/intake?scope=today` (web + iOS Erfassen sheet) + * - `/api/dashboard/summary` (iOS Dashboard tile) + * + * Pre-v1.4.41 both routes carried their own copy of the projection + + * idempotent `createMany` + per-`(med, day)` compliance recompute. The + * two implementations grew apart twice; this helper folds them into + * one tested location so future changes (new IntakeSource literal, + * new schedule cadence, audit metadata) only have to land once. + * + * The helper is intentionally side-effect-only: + * 1. Projects the active schedules through `expandTodayIntakes`. + * 2. Reads existing rows in the today-window. + * 3. Inserts the missing rows (`skipDuplicates: true` for the + * `(userId, medicationId, scheduledFor, source)` unique index). + * 4. Fires one compliance-rollup recompute per distinct + * `(medicationId, dayKey)` so the rollup matches the new + * `scheduled` count before the next read. + * + * The caller owns its own typed re-read of `MedicationIntakeEvent` + * afterwards — the two routes return different projections of those + * rows, so the helper stays read-shape-neutral. + * + * Returns the counts so the caller can surface them on `annotate(...)`. + */ +import { prisma } from "@/lib/db"; +import { expandTodayIntakes } from "@/lib/medication-schedule"; +import { recomputeMedicationComplianceForEvent } from "@/lib/rollups/medication-compliance-rollups"; + +export interface ProjectTodayIntakesResult { + projected: number; + backfilled: number; +} + +export async function projectTodayIntakesAndRecompute(input: { + userId: string; + userTz: string; + todayStart: Date; + todayEnd: Date; +}): Promise { + const { userId, userTz, todayStart, todayEnd } = input; + + const activeMedications = await prisma.medication.findMany({ + where: { userId, active: true }, + select: { + id: true, + schedules: { + select: { + id: true, + medicationId: true, + windowStart: true, + windowEnd: true, + daysOfWeek: true, + }, + }, + }, + }); + + const projected = expandTodayIntakes( + activeMedications.flatMap((m) => m.schedules), + new Date(), + userTz, + ); + + if (projected.length === 0) { + return { projected: 0, backfilled: 0 }; + } + + const existing = await prisma.medicationIntakeEvent.findMany({ + where: { + userId, + scheduledFor: { gte: todayStart, lt: todayEnd }, + }, + select: { medicationId: true, scheduledFor: true }, + }); + const existingKey = new Set( + existing.map((e) => `${e.medicationId}|${e.scheduledFor.toISOString()}`), + ); + const missing = projected.filter( + (p) => !existingKey.has(`${p.medicationId}|${p.scheduledFor.toISOString()}`), + ); + + if (missing.length === 0) { + return { projected: projected.length, backfilled: 0 }; + } + + await prisma.medicationIntakeEvent.createMany({ + data: missing.map((m) => ({ + userId, + medicationId: m.medicationId, + scheduledFor: m.scheduledFor, + takenAt: null, + skipped: false, + // `REMINDER` is the same source the reminder worker uses to mint + // RED-phase rows — semantically "the server projected this slot + // before the user logged anything". The `IntakeSource` enum has + // no separate `SCHEDULER` literal; reusing REMINDER keeps the + // doctor-report + analytics filters byte-stable. + source: "REMINDER", + })), + // Paired with the schema-level @@unique([userId, medicationId, + // scheduledFor, source]). A concurrent dashboard-summary + intake + // route hit can race a duplicate row in between the existence + // probe and createMany; the flag tells Postgres to swallow the + // rejected rows so the request still returns 2xx. The unique + // constraint is the structural backstop; this is the + // defense-in-depth. + skipDuplicates: true, + }); + + // Close the v1.4.39.4 compliance-rollup hook gap: a bulk projection + // mints fresh `(medicationId, scheduledFor)` rows in PENDING state; + // without this hook the rollup row stays at its previous + // `scheduled` count, which inflates compliance % until the user + // actually logs against the new row. Coalesce by `(medicationId, + // dayKey)` so we fire one recompute per distinct day-tuple instead + // of one per row. + // + // `Promise.allSettled` keeps the best-effort contract explicit at + // the call site: the helper swallows internally today, but a + // future refactor that lets a throw escape would otherwise turn + // the parent POST into a 5xx. + const seenDayKeys = new Set(); + const recomputeJobs: Array> = []; + for (const m of missing) { + const key = `${m.medicationId}|${m.scheduledFor.toISOString().slice(0, 10)}`; + if (seenDayKeys.has(key)) continue; + seenDayKeys.add(key); + recomputeJobs.push( + recomputeMedicationComplianceForEvent({ + userId, + medicationId: m.medicationId, + scheduledFor: m.scheduledFor, + tz: userTz, + }), + ); + } + await Promise.allSettled(recomputeJobs); + + return { projected: projected.length, backfilled: missing.length }; +}