Merge branch 'feat/medication-record-only' into release/v1.39.1

This commit is contained in:
Marc-André Bombeck committed 2026-09-25 09:37:27 +02:00
commit e03cf7c2c8
81 files changed
+2283 -274

No files matched your search

@@ -92,7 +92,7 @@ describe("POST /api/admin/notifications/reminder-check", () => {
expect(res.status).toBe(200);
const json = (await res.json()) as { data: { scoped: boolean } };
expect(json.data.scoped).toBe(false);
expect(whereOfLastFindMany()).toEqual({ active: true });
expect(whereOfLastFindMany()).toEqual({ active: true, trackIntake: true });
});
it("narrows the sweep to the named account", async () => {
@@ -102,6 +102,7 @@ describe("POST /api/admin/notifications/reminder-check", () => {
expect(json.data.scoped).toBe(true);
expect(whereOfLastFindMany()).toEqual({
active: true,
trackIntake: true,
userId: "cku0mock0account0id",
});
});
@@ -8,6 +8,7 @@ import { adminReminderCheckSchema } from "@/lib/validations/notifications";
import { parseScheduleRecurrence } from "@/lib/medication-schedule";
import { dispatchLocalisedNotification } from "@/lib/notifications/dispatch-localised";
import { getUserTodayBounds, getDayOfWeekInTz } from "@/lib/tz/local-day";
import { TRACKED_INTAKE_WHERE } from "@/lib/medications/intake-tracking";
export const dynamic = "force-dynamic";
@@ -99,6 +100,9 @@ export const POST = apiHandler(async (request: NextRequest) => {
const medications = await prisma.medication.findMany({
where: {
active: true,
// v1.39.1 (#1033) — the manual check reminds exactly what the worker
// reminds: never a medication with intake tracking off.
...TRACKED_INTAKE_WHERE,
// Absent selector ⇒ no `userId` key ⇒ the instance-wide sweep.
...(scopedUserId ? { userId: scopedUserId } : {}),
},
+1
View File
@@ -66,6 +66,7 @@ export const GET = apiHandler(async (request: NextRequest) => {
name: true,
dose: true,
active: true,
trackIntake: true,
schedules: {
select: {
windowStart: true,
+7 -1
View File
@@ -33,6 +33,7 @@ import {
SCHEDULE_COMPLIANCE_SELECT,
} from "@/lib/analytics/compliance";
import type { MeasurementType } from "@/generated/prisma/client";
import { TRACKED_INTAKE_WHERE } from "@/lib/medications/intake-tracking";
type Severity = "alert" | "caution" | "info" | "good";
@@ -101,7 +102,12 @@ export const GET = apiHandler(async () => {
prisma.medication.findMany({
// v1.16.11 — as-needed (PRN) medications never surface a compliance
// rate (no expected doses).
where: { userId: user.id, active: true, asNeeded: false },
where: {
userId: user.id,
active: true,
asNeeded: false,
...TRACKED_INTAKE_WHERE,
},
include: {
// Schedules through the shared compliance select so the configured
// per-dose windows reach the engine like every other surface.
+2 -1
View File
@@ -42,6 +42,7 @@ import {
readMoodDayRollups,
} from "@/lib/rollups/mood-rollups";
import { userDayKey } from "@/lib/tz/format";
import { TRACKED_INTAKE_WHERE } from "@/lib/medications/intake-tracking";
export const dynamic = "force-dynamic";
@@ -381,7 +382,7 @@ export async function buildComprehensiveResponse(user: AuthedUser) {
const medicationRows = await prisma.medication.findMany({
// v1.16.11 — as-needed (PRN) medications never surface a compliance
// rate (no expected doses).
where: { userId, active: true, asNeeded: false },
where: { userId, active: true, asNeeded: false, ...TRACKED_INTAKE_WHERE },
// v1.15.20 — schedules through the shared compliance select so the
// configured per-dose windows reach this surface like every other.
include: {
@@ -24,6 +24,10 @@ vi.mock("@/lib/db", () => ({
findMany: vi.fn().mockResolvedValue([]),
updateMany: vi.fn().mockResolvedValue({ count: 0 }),
},
medicationPauseEra: {
create: vi.fn().mockResolvedValue({}),
updateMany: vi.fn().mockResolvedValue({ count: 0 }),
},
apiToken: {
updateMany: vi.fn().mockResolvedValue({ count: 0 }),
},
@@ -776,3 +780,336 @@ describe("PUT /api/medications/[id] — as-needed (v1.16.11, #316)", () => {
expect(prisma.medication.update).not.toHaveBeenCalled();
});
});
describe("PUT /api/medications/[id] — intake tracking switch (#1033)", () => {
const LIVE_ROW = {
id: "s1",
medicationId: "m1",
windowStart: "08:00",
windowEnd: "08:00",
label: null,
dose: null,
daysOfWeek: null,
timesOfDay: ["08:00"],
reminderGraceMinutes: null,
rrule: "FREQ=DAILY",
rollingIntervalDays: null,
scheduleType: "SCHEDULED",
cyclicOnWeeks: null,
cyclicOffWeeks: null,
doseWindows: null,
};
function arrange(trackIntake: boolean) {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.medication.findUnique).mockResolvedValue({
id: "m1",
userId: "user-1",
active: true,
asNeeded: false,
trackIntake,
createdAt: new Date("2026-05-01T08:00:00.000Z"),
} as never);
vi.mocked(prisma.medication.update).mockResolvedValue({
id: "m1",
userId: "user-1",
trackIntake: !trackIntake,
unitsPerDose: 1,
schedules: [{ ...LIVE_ROW, unitsPerDose: null }],
} as never);
vi.mocked(getMedicationCategories).mockResolvedValue({});
vi.mocked(auditLog).mockResolvedValue(undefined);
vi.mocked(dayKeyForScheduledFor).mockReturnValue("2026-06-10");
vi.mocked(recomputeMedicationComplianceForDay).mockResolvedValue(undefined);
vi.mocked(prisma.medicationSchedule.findMany).mockResolvedValue([
LIVE_ROW,
] as never);
vi.mocked(prisma.medicationScheduleRevision.create).mockClear();
vi.mocked(prisma.medicationIntakeEvent.updateMany).mockClear();
vi.mocked(prisma.medication.update).mockClear();
}
it("switching off archives the schedule in force and tombstones open slots", async () => {
arrange(true);
const res = await PUT(putReq({ trackIntake: false }), ROUTE_CTX);
expect(res.status).toBe(200);
// The tracked stretch keeps its expected doses: the archived era
// carries the live schedule and ends now.
const revisions = vi.mocked(prisma.medicationScheduleRevision.create).mock
.calls;
expect(revisions).toHaveLength(1);
const era = revisions[0][0].data as {
payload: Array<{ timesOfDay: string[] }>;
validFrom: Date;
};
expect(era.payload).toHaveLength(1);
expect(era.payload[0].timesOfDay).toEqual(["08:00"]);
expect(era.validFrom.toISOString()).toBe("2026-05-01T08:00:00.000Z");
// Open placeholders of today and later go; actioned rows never do.
const tombstones = vi.mocked(prisma.medicationIntakeEvent.updateMany).mock
.calls;
expect(tombstones).toHaveLength(1);
expect(tombstones[0][0].where).toMatchObject({
medicationId: "m1",
takenAt: null,
skipped: false,
autoMissed: false,
deletedAt: null,
});
// The flag is written field-by-field; the schedule rows are kept.
const update = vi.mocked(prisma.medication.update).mock.calls[0][0];
expect(update.data).toMatchObject({ trackIntake: false });
expect(update.data).not.toHaveProperty("schedules");
expect(prisma.medicationSchedule.deleteMany).not.toHaveBeenCalledWith({
where: { medicationId: "m1" },
});
// The wire serves it like an as-needed medication, schedule on record.
const body = (await res.json()) as {
data: { schedules: unknown[]; recordedSchedules?: unknown[] };
};
expect(body.data.schedules).toEqual([]);
expect(body.data.recordedSchedules).toHaveLength(1);
});
it("switching back on archives the untracked stretch as expecting nothing", async () => {
arrange(false);
const res = await PUT(putReq({ trackIntake: true }), ROUTE_CTX);
expect(res.status).toBe(200);
const revisions = vi.mocked(prisma.medicationScheduleRevision.create).mock
.calls;
expect(revisions).toHaveLength(1);
const era = revisions[0][0].data as { payload: unknown[] };
// No retroactive misses: the stretch that was off expected no dose.
expect(era.payload).toEqual([]);
// Nothing is tombstoned on the way back on.
expect(prisma.medicationIntakeEvent.updateMany).not.toHaveBeenCalled();
const body = (await res.json()) as {
data: { schedules: unknown[]; recordedSchedules?: unknown[] };
};
expect(body.data.schedules).toHaveLength(1);
expect(body.data).not.toHaveProperty("recordedSchedules");
});
it("an unchanged flag archives nothing", async () => {
arrange(true);
const res = await PUT(putReq({ trackIntake: true }), ROUTE_CTX);
expect(res.status).toBe(200);
expect(prisma.medicationScheduleRevision.create).not.toHaveBeenCalled();
expect(prisma.medicationIntakeEvent.updateMany).not.toHaveBeenCalled();
});
it("a schedule edit while untracked archives the stretch as empty", async () => {
arrange(false);
const res = await PUT(
putReq({
// The web editor names the flag on a record-only schedule save.
trackIntake: false,
schedules: [
{
windowStart: "08:00",
windowEnd: "08:00",
timesOfDay: ["08:00"],
rrule: "FREQ=DAILY",
},
],
}),
ROUTE_CTX,
);
expect(res.status).toBe(200);
const era = vi.mocked(prisma.medicationScheduleRevision.create).mock
.calls[0][0].data as { payload: unknown[] };
expect(era.payload).toEqual([]);
});
});
/**
* #1033 — the shipped iPhone app (1.0.3 / 1.0.4) cannot see the schedule of a
* record-only medication (it is served with `schedules: []`). These are the
* exact bodies its edit sheet sends (`EditMedicationSheet.save()` →
* `MedicationPatch`, nil keys omitted): it never names `trackIntake`, always
* sends `active` and `notificationsEnabled`, and sends the schedule shape
* only when a schedule field was touched, rebuilt from the "daily at 08:00"
* default its form shows for a medication without schedule rows.
*/
describe("PUT /api/medications/[id] — shipped client on a record-only medication (#1033)", () => {
const STORED_ROW = {
id: "s1",
medicationId: "m1",
windowStart: "21:00",
windowEnd: "21:00",
label: null,
dose: null,
daysOfWeek: null,
timesOfDay: ["21:00"],
reminderGraceMinutes: null,
rrule: "FREQ=DAILY",
rollingIntervalDays: null,
scheduleType: "SCHEDULED",
cyclicOnWeeks: null,
cyclicOffWeeks: null,
doseWindows: null,
};
// `name`/`dose`/`treatmentClass`/`category`/`active`/`notificationsEnabled`/
// `deliveryForm` ride every save; `dosesPerUnit` is nil for an empty field.
const IOS_RENAME = {
name: "Atorvastatin 20",
dose: "20 mg",
treatmentClass: "GENERIC",
category: "OTHER",
active: true,
notificationsEnabled: true,
deliveryForm: "ORAL",
};
// The same save after the dose time was moved from the shown 08:00 default
// to 09:00: `scheduleWrite(.daily)` + `oneShot` + `startsOn` + `asNeeded`.
const IOS_SCHEDULE_TOUCHED = {
...IOS_RENAME,
schedules: [
{
windowStart: "09:00",
windowEnd: "09:00",
timesOfDay: ["09:00"],
rrule: "FREQ=DAILY",
},
],
oneShot: false,
startsOn: "2026-06-01",
asNeeded: false,
};
// The same save after picking the "as needed" cadence.
const IOS_AS_NEEDED = {
...IOS_RENAME,
schedules: [],
oneShot: false,
asNeeded: true,
};
function arrange(trackIntake: boolean) {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.medication.findUnique).mockResolvedValue({
id: "m1",
userId: "user-1",
active: true,
asNeeded: false,
trackIntake,
createdAt: new Date("2026-05-01T08:00:00.000Z"),
_count: { schedules: 1 },
} as never);
vi.mocked(prisma.medication.update).mockResolvedValue({
id: "m1",
userId: "user-1",
trackIntake,
unitsPerDose: 1,
schedules: [{ ...STORED_ROW, unitsPerDose: null }],
} as never);
vi.mocked(getMedicationCategories).mockResolvedValue({});
vi.mocked(auditLog).mockResolvedValue(undefined);
vi.mocked(dayKeyForScheduledFor).mockReturnValue("2026-06-10");
vi.mocked(recomputeMedicationComplianceForDay).mockResolvedValue(undefined);
vi.mocked(prisma.medicationSchedule.findMany).mockResolvedValue([
STORED_ROW,
] as never);
vi.mocked(prisma.medication.update).mockClear();
vi.mocked(prisma.medicationSchedule.deleteMany).mockClear();
vi.mocked(prisma.medicationScheduleRevision.create).mockClear();
vi.mocked(prisma.medicationIntakeEvent.updateMany).mockClear();
}
function updateData(): Record<string, unknown> {
return vi.mocked(prisma.medication.update).mock.calls[0][0].data as Record<
string,
unknown
>;
}
it("a rename applies and touches no schedule", async () => {
arrange(false);
const res = await PUT(putReq(IOS_RENAME), ROUTE_CTX);
expect(res.status).toBe(200);
expect(updateData()).toMatchObject({
name: "Atorvastatin 20",
notificationsEnabled: true,
});
expect(updateData()).not.toHaveProperty("schedules");
expect(updateData()).not.toHaveProperty("trackIntake");
expect(prisma.medicationSchedule.deleteMany).not.toHaveBeenCalled();
});
it("a schedule-touched save keeps the recorded schedule and applies the rest", async () => {
arrange(false);
const res = await PUT(putReq(IOS_SCHEDULE_TOUCHED), ROUTE_CTX);
expect(res.status).toBe(200);
const data = updateData();
// The 09:00 default the person never chose is not written.
expect(data).not.toHaveProperty("schedules");
expect(data).not.toHaveProperty("asNeeded");
expect(data).not.toHaveProperty("oneShot");
expect(prisma.medicationSchedule.deleteMany).not.toHaveBeenCalled();
expect(prisma.medicationScheduleRevision.create).not.toHaveBeenCalled();
expect(prisma.medicationIntakeEvent.updateMany).not.toHaveBeenCalled();
// Everything else in the save applies.
expect(data).toMatchObject({ name: "Atorvastatin 20" });
expect(data).toHaveProperty("startsOn");
// And it is still a record: the stored schedule comes back on record.
const body = (await res.json()) as {
data: {
schedules: unknown[];
recordedSchedules?: Array<{ timesOfDay: string[] }>;
};
};
expect(body.data.schedules).toEqual([]);
expect(body.data.recordedSchedules?.[0]?.timesOfDay).toEqual(["21:00"]);
});
it("picking 'as needed' neither 422s nor deletes the recorded schedule", async () => {
arrange(false);
const res = await PUT(putReq(IOS_AS_NEEDED), ROUTE_CTX);
expect(res.status).toBe(200);
expect(updateData()).not.toHaveProperty("asNeeded");
expect(updateData()).not.toHaveProperty("schedules");
expect(prisma.medicationSchedule.deleteMany).not.toHaveBeenCalled();
});
it("the same schedule write applies once it names trackIntake", async () => {
arrange(false);
const res = await PUT(
putReq({ ...IOS_SCHEDULE_TOUCHED, trackIntake: false }),
ROUTE_CTX,
);
expect(res.status).toBe(200);
expect(updateData()).toHaveProperty("schedules");
expect(prisma.medicationSchedule.deleteMany).toHaveBeenCalledWith({
where: { medicationId: "m1" },
});
});
it("a tracked medication takes the shipped client's schedule write as before", async () => {
arrange(true);
const res = await PUT(putReq(IOS_SCHEDULE_TOUCHED), ROUTE_CTX);
expect(res.status).toBe(200);
expect(updateData()).toHaveProperty("schedules");
expect(updateData()).toMatchObject({ asNeeded: false, oneShot: false });
expect(prisma.medicationSchedule.deleteMany).toHaveBeenCalledWith({
where: { medicationId: "m1" },
});
});
it.each([
["pause", { active: false }],
["reactivate", { active: true }],
["end course", { endsOn: "2026-06-12" }],
])("the %s write applies unchanged", async (_label, body) => {
arrange(false);
const res = await PUT(putReq(body), ROUTE_CTX);
expect(res.status).toBe(200);
expect(updateData()).toMatchObject(
"active" in body ? { active: body.active } : {},
);
expect(updateData()).not.toHaveProperty("schedules");
});
});
@@ -33,6 +33,7 @@ import {
} from "@/lib/analytics/compliance";
import { assertMedicationOwnership } from "@/lib/medications/route-guards";
import { resolveUserTimezone } from "@/lib/tz/resolver";
import { dueSchedules } from "@/lib/medications/intake-tracking";
type RouteParams = { params: Promise<{ id: string }> };
@@ -131,8 +132,10 @@ export const GET = apiHandler(
scheduleRevisions: med.scheduleRevisions,
};
// v1.39.1 (#1033) — intake tracking off: no chip, no next dose.
const liveSchedules = dueSchedules(med);
const timeline = buildCadenceTimeline(
med.schedules,
liveSchedules,
events,
asOf,
windowDays,
@@ -141,7 +144,7 @@ export const GET = apiHandler(
engineCtx,
);
const chips = complianceChips(
med.schedules,
liveSchedules,
events,
asOf,
windowDays,
@@ -150,7 +153,7 @@ export const GET = apiHandler(
engineCtx,
);
const next = computeNextDose(
med.schedules,
liveSchedules,
asOf,
14,
anchor,
@@ -40,6 +40,7 @@ import {
type WorkerScheduleRow,
} from "@/lib/medications/scheduling/worker-helpers";
import { assertMedicationOwnership } from "@/lib/medications/route-guards";
import { dueSchedules } from "@/lib/medications/intake-tracking";
type RouteParams = { params: Promise<{ id: string }> };
@@ -217,7 +218,10 @@ export const GET = apiHandler(
// A legacy daily schedule carrying only `windowStart` surfaces it as the
// single time-of-day so the minter mints its daily band (mirrors the
// compliance route + the ledger tally).
const canonicalSchedules = medication.schedules.map((s) => {
// v1.39.1 (#1033) — with intake tracking off the live era expects
// nothing: the ledger shows the doses that were recorded and the slots
// of archived eras, never a missed slot minted from the record.
const canonicalSchedules = dueSchedules(medication).map((s) => {
const canonical = buildCanonicalSchedule(s as WorkerScheduleRow);
if (
canonical.timesOfDay.length === 0 &&
+11 -6
View File
@@ -46,6 +46,7 @@ import {
shapeDoseChangeNote,
} from "@/lib/crypto/note-cipher";
import { NextRequest } from "next/server";
import { dueSchedules } from "@/lib/medications/intake-tracking";
type RouteParams = { params: Promise<{ id: string }> };
@@ -120,12 +121,16 @@ export const GET = apiHandler(
prefsRow?.notificationPrefs ?? null,
medication.reorderLeadDays,
);
const schedules: RunwaySchedule[] = medication.schedules.map((sched) => ({
...sched,
// Decimal → number so the slot-aware runway math reads the wire shape.
unitsPerDose:
sched.unitsPerDose === null ? null : Number(sched.unitsPerDose),
}));
// v1.39.1 (#1033) — intake tracking off: no dose consumes stock, so no
// runway and no low-stock flag derive from the stored schedule.
const schedules: RunwaySchedule[] = dueSchedules(medication).map(
(sched) => ({
...sched,
// Decimal → number so the slot-aware runway math reads the wire shape.
unitsPerDose:
sched.unitsPerDose === null ? null : Number(sched.unitsPerDose),
}),
);
/** runway ≤ effective trigger AND the alert is enabled. */
const isLowStock = (dosesRemaining: number): boolean => {
if (runwayFloor === null) return false;
+278 -124
View File
@@ -41,6 +41,11 @@ import { serializeScheduleUnitsPerDose } from "@/lib/medications/schedule-units-
import { hhmmToMinutesOrNull } from "@/lib/medications/scheduling/hhmm";
import { getUserTodayBounds } from "@/lib/tz/local-day";
import { NextRequest } from "next/server";
import {
dueSchedules,
isRecordOnly,
scheduleWireFields,
} from "@/lib/medications/intake-tracking";
type RouteParams = { params: Promise<{ id: string }> };
@@ -106,7 +111,7 @@ export const GET = apiHandler(
// cadences re-anchor on the latest non-skipped intake, so fetch it
// once (no-op for calendar cadences but cheap + keeps the value
// correct for rolling injections).
const lastIntake = medication.schedules.some(
const lastIntake = dueSchedules(medication).some(
(s) => s.rollingIntervalDays !== null,
)
? await prisma.medicationIntakeEvent.findFirst({
@@ -157,6 +162,9 @@ export const GET = apiHandler(
orderBy: { validUntil: "desc" },
select: { validUntil: true },
});
// v1.39.1 (#1033) — intake tracking off: nothing is due from the
// stored rows, and no runway derives from them.
const liveSchedules = dueSchedules(medication);
const display = computeDisplayDue({
medication: {
id: medication.id,
@@ -165,7 +173,7 @@ export const GET = apiHandler(
oneShot: medication.oneShot,
createdAt: medication.createdAt,
},
schedules: medication.schedules,
schedules: liveSchedules,
now,
userTz: user.timezone || "Europe/Berlin",
lastIntakeAt: lastIntake?.takenAt ?? null,
@@ -220,14 +228,16 @@ export const GET = apiHandler(
? null
: estimateUnitsRunwayDays(
stockUnitsRemaining,
schedulesDto,
isRecordOnly(medication) ? [] : schedulesDto,
Number(medication.unitsPerDose),
);
return apiSuccess({
...medication,
unitsPerDose: Number(medication.unitsPerDose),
schedules: schedulesDto,
// v1.39.1 (#1033) — `schedules: []` plus `recordedSchedules` when
// intake tracking is off (see `scheduleWireFields`).
...scheduleWireFields(medication.trackIntake, schedulesDto),
category,
nextDueAt: display ? display.at.toISOString() : null,
nextDueOverdue: display?.overdue ?? false,
@@ -265,6 +275,8 @@ export const PUT = apiHandler(
active: true,
createdAt: true,
asNeeded: true,
// v1.39.1 (#1033) — the tracking transition below needs the before.
trackIntake: true,
// v1.37.0 — the pre-image the audit row carries (C4).
name: true,
dose: true,
@@ -287,6 +299,55 @@ export const PUT = apiHandler(
return returnAllZodIssues(parsed.error, 422);
}
// ── v1.39.1 (#1033) — a record-only schedule is only replaced knowingly ──
//
// A medication with intake tracking off is served with `schedules: []`
// and its stored schedule in `recordedSchedules`, so a client that
// predates the field arms no reminder from it. The price is that such a
// client cannot SEE the stored schedule. The shipped iPhone app (1.0.3 /
// 1.0.4) opens a medication without schedule rows in its editor as
// "daily at 08:00"; a rename or dose edit sends no `schedules` (its
// editor diffs against that same prefill), but any touch of a schedule
// field sends a full replacement built from the 08:00 default plus
// `asNeeded` and `oneShot`, and never `trackIntake`. Applied, that would
// overwrite the schedule the person kept on record with one they never
// chose (or flip it to as-needed and delete the rows).
//
// Rule: on a medication whose intake tracking is off, a write that does
// not name `trackIntake` leaves the schedule shape alone. `schedules`,
// `asNeeded` and `oneShot` are dropped from it; every other field (name,
// dose, dates, `active`, `notificationsEnabled`, inventory, ...) applies
// as sent. A client that knows about the switch names it on every
// schedule save (the web editor and wizard do), and those writes apply.
const keepRecordedSchedule =
existing.trackIntake === false &&
parsed.data.trackIntake === undefined &&
(parsed.data.schedules !== undefined ||
parsed.data.asNeeded !== undefined ||
parsed.data.oneShot !== undefined);
const input = keepRecordedSchedule
? {
...parsed.data,
schedules: undefined,
asNeeded: undefined,
oneShot: undefined,
}
: parsed.data;
if (keepRecordedSchedule) {
annotate({
action: {
name: "medication.update.record_schedule_kept",
entity_type: "medication",
entity_id: id,
},
meta: {
dropped_schedules: parsed.data.schedules !== undefined,
dropped_as_needed: parsed.data.asNeeded !== undefined,
dropped_one_shot: parsed.data.oneShot !== undefined,
},
});
}
const {
name,
dose,
@@ -309,8 +370,15 @@ export const PUT = apiHandler(
endsOn,
oneShot,
asNeeded,
trackIntake,
reminderGraceMinutes: topLevelGraceMinutes,
} = parsed.data;
} = input;
// v1.39.1 (#1033) — intake tracking transition. Off keeps the schedule
// as a record; the live era from here on expects nothing.
const wasTracked = existing.trackIntake !== false;
const willTrack = trackIntake ?? wasTracked;
const trackingChanged = wasTracked !== willTrack;
// ── v1.16.11 (#316) — as-needed invariants ──────────────────────
//
@@ -516,123 +584,16 @@ export const PUT = apiHandler(
// the manager's grant ends.
let replacedCadence: string | null = null;
let replacedScheduleCount: number | null = null;
let tombstonedSlotCount: number | null = null;
let tombstonedFrom: Date | null = null;
let tombstonedTo: Date | null = null;
let tombstonedSlotCount = null as number | null;
// Assigned inside `tombstoneOpenSlots`; the casts keep TypeScript from
// narrowing them to `null` across the closure.
let tombstonedFrom = null as Date | null;
let tombstonedTo = null as Date | null;
// If schedules provided, replace all
if (schedules && normalisedSchedules) {
// v1.16.3 — effective dating. Before the wholesale replace below wipes
// the old rows, archive them as ONE revision covering
// `[previous validUntil | medication.createdAt, now)` — but only when a
// cadence-relevant field actually changes. A no-op edit (the Zeitplan
// tab echoing the same rows back) must not mint a phantom era.
const previousRows = await prisma.medicationSchedule.findMany({
where: { medicationId: id },
});
const previousEntries = previousRows.map((row) =>
toRevisionPayloadEntry({
timesOfDay: row.timesOfDay,
windowStart: row.windowStart,
windowEnd: row.windowEnd,
daysOfWeek: row.daysOfWeek,
rrule: row.rrule,
rollingIntervalDays: row.rollingIntervalDays,
scheduleType: row.scheduleType,
cyclicOnWeeks: row.cyclicOnWeeks,
cyclicOffWeeks: row.cyclicOffWeeks,
doseWindows: row.doseWindows,
label: row.label,
dose: row.dose,
reminderGraceMinutes: row.reminderGraceMinutes,
}),
);
replacedScheduleCount = previousRows.length;
replacedCadence =
previousRows.length > 0
? previousRows
.map((row) =>
[
row.timesOfDay,
row.rrule ?? "",
row.rollingIntervalDays !== null
? `every ${row.rollingIntervalDays}d`
: "",
row.daysOfWeek ?? "",
]
.filter((part) => part !== "")
.join(" "),
)
.join(" | ")
: null;
if (
previousRows.length > 0 &&
schedulesMateriallyDiffer(
previousEntries,
normalisedSchedules.map((n) => n.snapshot),
)
) {
const lastRevision = await prisma.medicationScheduleRevision.findFirst({
// Chain from the ACTIVE boundary: a superseded row is an
// audit record whose `validUntil` may sit past its correction.
where: { medicationId: id, supersededByRevisionId: null },
orderBy: { validUntil: "desc" },
select: { validUntil: true },
});
await prisma.medicationScheduleRevision.create({
data: {
medicationId: id,
validFrom: lastRevision?.validUntil ?? existing.createdAt,
validUntil: new Date(),
payload: previousEntries as unknown as Prisma.InputJsonValue,
},
});
annotate({
action: {
name: "medication.schedule.revision_archived",
entity_type: "medication",
entity_id: id,
},
meta: { schedule_revision_rows: previousEntries.length },
});
} else if (
previousRows.length === 0 &&
existing.asNeeded &&
asNeeded === false &&
normalisedSchedules.length > 0
) {
// v1.16.11 — flipping as-needed OFF. The medication carried ZERO
// schedule rows while as-needed, so the wholesale-replace archive
// above never fires — and without a revision the live era would
// start at the PREVIOUS revision's `validUntil` (or `createdAt`),
// retro-painting the schedule-less as-needed stretch with the NEW
// schedule's expected slots: every PRN day would read as missed.
// Archive an EMPTY era covering the as-needed stretch instead; an
// empty payload expands to zero schedules, so era-aware compliance
// expects nothing there — exactly the as-needed contract.
const lastRevision = await prisma.medicationScheduleRevision.findFirst({
where: { medicationId: id, supersededByRevisionId: null },
orderBy: { validUntil: "desc" },
select: { validUntil: true },
});
await prisma.medicationScheduleRevision.create({
data: {
medicationId: id,
validFrom: lastRevision?.validUntil ?? existing.createdAt,
validUntil: new Date(),
payload: [] as unknown as Prisma.InputJsonValue,
},
});
annotate({
action: {
name: "medication.schedule.revision_archived",
entity_type: "medication",
entity_id: id,
},
meta: { schedule_revision_rows: 0 },
});
}
// Tombstone today's and future open pending rows of this medication.
// Runs on a schedule replace (the old anchors are stale) and when intake
// tracking is switched off (nothing is due any more).
const tombstoneOpenSlots = async (): Promise<void> => {
// A schedule replace invalidates the open slot anchors the projector /
// reminder worker minted for the OLD times: a pending 08:00 row for a
// medication that now doses at 20:00 would linger as a phantom slot,
@@ -703,9 +664,195 @@ export const PUT = apiHandler(
});
}
}
};
// If schedules provided, replace all
if (schedules && normalisedSchedules) {
// v1.16.3 — effective dating. Before the wholesale replace below wipes
// the old rows, archive them as ONE revision covering
// `[previous validUntil | medication.createdAt, now)` — but only when a
// cadence-relevant field actually changes. A no-op edit (the Zeitplan
// tab echoing the same rows back) must not mint a phantom era.
const previousRows = await prisma.medicationSchedule.findMany({
where: { medicationId: id },
});
const previousEntries = previousRows.map((row) =>
toRevisionPayloadEntry({
timesOfDay: row.timesOfDay,
windowStart: row.windowStart,
windowEnd: row.windowEnd,
daysOfWeek: row.daysOfWeek,
rrule: row.rrule,
rollingIntervalDays: row.rollingIntervalDays,
scheduleType: row.scheduleType,
cyclicOnWeeks: row.cyclicOnWeeks,
cyclicOffWeeks: row.cyclicOffWeeks,
doseWindows: row.doseWindows,
label: row.label,
dose: row.dose,
reminderGraceMinutes: row.reminderGraceMinutes,
}),
);
replacedScheduleCount = previousRows.length;
replacedCadence =
previousRows.length > 0
? previousRows
.map((row) =>
[
row.timesOfDay,
row.rrule ?? "",
row.rollingIntervalDays !== null
? `every ${row.rollingIntervalDays}d`
: "",
row.daysOfWeek ?? "",
]
.filter((part) => part !== "")
.join(" "),
)
.join(" | ")
: null;
// v1.39.1 (#1033) — a stretch with intake tracking off expected
// nothing, so it archives with an empty payload whatever the rows
// said; and a tracking transition is itself an era boundary even when
// the rows stay the same.
const archivedEntries = wasTracked ? previousEntries : [];
if (
previousRows.length > 0 &&
(trackingChanged ||
!wasTracked ||
schedulesMateriallyDiffer(
previousEntries,
normalisedSchedules.map((n) => n.snapshot),
))
) {
const lastRevision = await prisma.medicationScheduleRevision.findFirst({
// Chain from the ACTIVE boundary: a superseded row is an
// audit record whose `validUntil` may sit past its correction.
where: { medicationId: id, supersededByRevisionId: null },
orderBy: { validUntil: "desc" },
select: { validUntil: true },
});
await prisma.medicationScheduleRevision.create({
data: {
medicationId: id,
validFrom: lastRevision?.validUntil ?? existing.createdAt,
validUntil: new Date(),
payload: archivedEntries as unknown as Prisma.InputJsonValue,
},
});
annotate({
action: {
name: "medication.schedule.revision_archived",
entity_type: "medication",
entity_id: id,
},
meta: { schedule_revision_rows: archivedEntries.length },
});
} else if (
previousRows.length === 0 &&
existing.asNeeded &&
asNeeded === false &&
normalisedSchedules.length > 0
) {
// v1.16.11 — flipping as-needed OFF. The medication carried ZERO
// schedule rows while as-needed, so the wholesale-replace archive
// above never fires — and without a revision the live era would
// start at the PREVIOUS revision's `validUntil` (or `createdAt`),
// retro-painting the schedule-less as-needed stretch with the NEW
// schedule's expected slots: every PRN day would read as missed.
// Archive an EMPTY era covering the as-needed stretch instead; an
// empty payload expands to zero schedules, so era-aware compliance
// expects nothing there — exactly the as-needed contract.
const lastRevision = await prisma.medicationScheduleRevision.findFirst({
where: { medicationId: id, supersededByRevisionId: null },
orderBy: { validUntil: "desc" },
select: { validUntil: true },
});
await prisma.medicationScheduleRevision.create({
data: {
medicationId: id,
validFrom: lastRevision?.validUntil ?? existing.createdAt,
validUntil: new Date(),
payload: [] as unknown as Prisma.InputJsonValue,
},
});
annotate({
action: {
name: "medication.schedule.revision_archived",
entity_type: "medication",
entity_id: id,
},
meta: { schedule_revision_rows: 0 },
});
}
await tombstoneOpenSlots();
await prisma.medicationSchedule.deleteMany({
where: { medicationId: id },
});
} else if (trackingChanged) {
// v1.39.1 (#1033) — intake tracking switched without a schedule
// replace. Close the current era at this instant: switching OFF
// archives the schedule that was in force (the tracked stretch keeps
// its expected doses); switching back ON archives the untracked
// stretch with an empty payload, so compliance never counts it as
// missed and the live schedule, the projector and the reminder worker
// all resume from now.
const liveRows = await prisma.medicationSchedule.findMany({
where: { medicationId: id },
});
if (liveRows.length > 0) {
const lastRevision = await prisma.medicationScheduleRevision.findFirst({
where: { medicationId: id, supersededByRevisionId: null },
orderBy: { validUntil: "desc" },
select: { validUntil: true },
});
const archivedEntries = wasTracked
? liveRows.map((row) =>
toRevisionPayloadEntry({
timesOfDay: row.timesOfDay,
windowStart: row.windowStart,
windowEnd: row.windowEnd,
daysOfWeek: row.daysOfWeek,
rrule: row.rrule,
rollingIntervalDays: row.rollingIntervalDays,
scheduleType: row.scheduleType,
cyclicOnWeeks: row.cyclicOnWeeks,
cyclicOffWeeks: row.cyclicOffWeeks,
doseWindows: row.doseWindows,
label: row.label,
dose: row.dose,
reminderGraceMinutes: row.reminderGraceMinutes,
}),
)
: [];
await prisma.medicationScheduleRevision.create({
data: {
medicationId: id,
validFrom: lastRevision?.validUntil ?? existing.createdAt,
validUntil: new Date(),
payload: archivedEntries as unknown as Prisma.InputJsonValue,
},
});
annotate({
action: {
name: "medication.schedule.revision_archived",
entity_type: "medication",
entity_id: id,
},
meta: {
schedule_revision_rows: archivedEntries.length,
track_intake: willTrack,
},
});
}
}
// v1.39.1 (#1033) — switching intake tracking off leaves nothing due:
// the open placeholders of today and later go the way a schedule
// replace sends them (a replace in the same request already did it).
if (trackingChanged && !willTrack && !schedules) {
await tombstoneOpenSlots();
}
const baseUpdateData = {
@@ -743,6 +890,8 @@ export const PUT = apiHandler(
// v1.16.11 — as-needed flag, field-by-field (the invariants above
// already guaranteed the medication ends schedule-consistent).
...(asNeeded !== undefined && { asNeeded }),
// v1.39.1 (#1033) — intake tracking, field-by-field.
...(trackIntake !== undefined && { trackIntake }),
...(normalisedSchedules && {
schedules: {
create: normalisedSchedules.map((n) => n.createData),
@@ -849,6 +998,7 @@ export const PUT = apiHandler(
dose: existing.dose,
active: existing.active,
asNeeded: existing.asNeeded,
trackIntake: existing.trackIntake,
endsOn: existing.endsOn,
},
after: {
@@ -856,6 +1006,7 @@ export const PUT = apiHandler(
dose: medication.dose,
active: medication.active,
asNeeded: medication.asNeeded,
trackIntake: medication.trackIntake,
endsOn: medication.endsOn,
},
}),
@@ -886,9 +1037,12 @@ export const PUT = apiHandler(
return apiSuccess({
...medication,
unitsPerDose: Number(medication.unitsPerDose),
schedules: serializeScheduleUnitsPerDose(
medication.schedules,
medication.unitsPerDose,
...scheduleWireFields(
medication.trackIntake,
serializeScheduleUnitsPerDose(
medication.schedules,
medication.unitsPerDose,
),
),
category: normalizedCategory,
});
@@ -250,6 +250,8 @@ describe("GET /api/medications/compliance", () => {
expect(call.where).toEqual({
userId: "user-1",
asNeeded: false,
// v1.39.1 (#1033) — intake tracking off is left out like PRN.
trackIntake: true,
});
});
+2 -1
View File
@@ -11,6 +11,7 @@ import {
} from "@/lib/medications/compliance-payload";
import { cachedSwr, caches, type ServerCache } from "@/lib/cache/server-cache";
import { checkRateLimit } from "@/lib/rate-limit";
import { TRACKED_INTAKE_WHERE } from "@/lib/medications/intake-tracking";
/**
* Batched card-compliance read: one round trip for every medication the
@@ -50,7 +51,7 @@ export const GET = apiHandler(async () => {
// v1.16.11 — as-needed (PRN) medications carry no compliance entry:
// the cards/table render a last-taken presentation for them instead
// of rates, and an empty expected set must not read as 0% or 100%.
where: { userId: user.id, asNeeded: false },
where: { userId: user.id, asNeeded: false, ...TRACKED_INTAKE_WHERE },
include: {
schedules: true,
// v1.16.3 — archived schedule eras for era-aware compliance.
+4
View File
@@ -54,6 +54,7 @@ import { queueMedicationIntakeSync } from "@/lib/notifications/medication-intake
import { dispatchMedicationIntakeWebClear } from "@/lib/notifications/web-push-clear";
import { notifyDelegatedIntake } from "@/lib/notifications/delegated-intake";
import { countOutstandingDosesToday } from "@/lib/medications/outstanding-doses";
import { TRACKED_INTAKE_EVENT_WHERE } from "@/lib/medications/intake-tracking";
// The query + body schemas moved to `@/lib/validations/medication` when this
// endpoint joined the published contract: the OpenAPI registry generates from
@@ -125,6 +126,9 @@ export const GET = apiHandler(async (request: NextRequest) => {
// v1.7.0 sync — exclude tombstoned rows from the today list.
deletedAt: null,
scheduledFor: { gte: todayStart, lt: todayEnd },
// v1.39.1 (#1033) — the today list is what clients offer doses
// from; a medication with intake tracking off is not on it.
...TRACKED_INTAKE_EVENT_WHERE,
},
orderBy: { scheduledFor: "asc" },
include: { medication: { select: { id: true, snoozedUntil: true } } },
+21 -6
View File
@@ -25,6 +25,7 @@ import { readMedicationsListCached } from "@/lib/medications/list-read";
import { serializeScheduleUnitsPerDose } from "@/lib/medications/schedule-units-dto";
import { withIdempotency } from "@/lib/idempotency";
import { NextRequest } from "next/server";
import { scheduleWireFields } from "@/lib/medications/intake-tracking";
// v1.32.25 — blast-radius cap on externally-mirrored medications per user.
// A client that mints a fresh `externalId` for the same source concept on
@@ -72,6 +73,7 @@ async function respondWithExistingMirror(
medication: Record<string, unknown> & {
id: string;
unitsPerDose: unknown;
trackIntake: boolean;
schedules: Array<{ unitsPerDose: Prisma.Decimal | null }>;
},
): Promise<Response> {
@@ -96,9 +98,13 @@ async function respondWithExistingMirror(
...medication,
unitsPerDose: Number(medication.unitsPerDose),
// #219 — Decimal → number for the per-schedule column too.
schedules: serializeScheduleUnitsPerDose(
medication.schedules,
Number(medication.unitsPerDose),
// v1.39.1 (#1033) — record-only medications ship `schedules: []`.
...scheduleWireFields(
medication.trackIntake,
serializeScheduleUnitsPerDose(
medication.schedules,
Number(medication.unitsPerDose),
),
),
category,
});
@@ -165,6 +171,7 @@ async function postMedication(request: NextRequest): Promise<Response> {
endsOn,
oneShot,
asNeeded,
trackIntake,
} = parsed.data;
// v1.28 — idempotent mirror create. A mirrored medication (Apple
@@ -304,6 +311,10 @@ async function postMedication(request: NextRequest): Promise<Response> {
// carries an empty `scheduleInputs`, so the nested create below
// persists zero schedule rows.
...(asNeeded !== undefined && { asNeeded }),
// v1.39.1 (#1033) — intake tracking, field-by-field. Created off,
// the schedule is stored as a record from the start; the column
// default (true) covers every caller that does not send it.
...(trackIntake !== undefined && { trackIntake }),
schedules: {
create: scheduleInputs.map((s) => {
// Invariant 2 — default to FREQ=DAILY when nothing else is set.
@@ -426,9 +437,13 @@ async function postMedication(request: NextRequest): Promise<Response> {
...medication,
unitsPerDose: Number(medication.unitsPerDose),
// #219 — Decimal → number for the per-schedule column too.
schedules: serializeScheduleUnitsPerDose(
medication.schedules,
medication.unitsPerDose,
// v1.39.1 (#1033) — record-only medications ship `schedules: []`.
...scheduleWireFields(
medication.trackIntake,
serializeScheduleUnitsPerDose(
medication.schedules,
medication.unitsPerDose,
),
),
category: normalizedCategory,
},