refactor(medications): extract shared today-intake projection helper

Both `/api/medications/intake?scope=today` and `/api/dashboard/summary`
carried their own copy of the v1.4.39 projection + idempotent
`createMany` + per-`(med, day)` compliance recompute. The duplicate
implementations had already drifted twice; fold them into one tested
location at `src/lib/medications/scheduling/project-today-intakes.ts`
so future changes (new IntakeSource literal, new schedule cadence,
audit metadata) only land once.

The helper stays read-shape-neutral — the two routes return different
projections of the resulting events, so the caller still owns its
typed re-read. Returns `{ projected, backfilled }` for the existing
`annotate` telemetry.

Net: ~200 lines collapsed into 145 (helper) + two short call sites.
This commit is contained in:
Marc-André Bombeck committed 2026-05-21 17:52:01 +02:00
1 parent 3c73ef5c6f
commit 54ad5cdb69
3 files changed
+168 -200

No files matched your search

+13 -102
View File
@@ -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<Array<{ id: string; takenAt: Date | null; skipped: boolean }>> {
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<string>();
const recomputeJobs: Array<Promise<void>> = [];
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: {
+10 -98
View File
@@ -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<string>();
const recomputeJobs: Array<Promise<void>> = [];
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,
},
});
@@ -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<ProjectTodayIntakesResult> {
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<string>();
const recomputeJobs: Array<Promise<void>> = [];
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 };
}