mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
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:
1 parent
3c73ef5c6f
commit
54ad5cdb69
3 files changed
+168
-200
No files matched your search
@@ -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: {
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
Reference in new issue
Block a user