mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 22:02:16 +00:00
feat(medications): keep a medication as a record without tracking intake
A per-medication trackIntake flag (migration 0351, default on for every existing and new row). With it off the dose, dates and schedule stay stored, but nothing derives a due dose from them: the reminder worker, the manual reminder check, the today projector, the auto-miss pass, the low-stock pass, the doses card, the today list, the outstanding-dose badge, compliance and its heatmap, the doctor report, the Coach, the BP-status gate, insights, targets, achievements, the efficacy view and the MCP schedule tool all skip it. The medication wire serves such a medication with schedules: [] and the stored rows in an additive recordedSchedules field, so a client that arms local reminders from schedules stays silent without an update. Switching tracking off or back on archives a schedule revision at that instant, the untracked stretch with an empty payload, so it never counts as missed and the projector does not put a dose from before the switch back on the card. A structural guard freezes the set of schedule readers: each one filters on the flag, derives from dueSchedules, shapes the wire with scheduleWireFields, or is allowlisted with the reason it derives nothing due. The flag travels through backup, restore and the medication CSV export.
This commit is contained in:
1 parent
700de6d228
commit
b4c2706ec9
57 files changed
+1490
-182
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 } : {}),
|
||||
},
|
||||
|
||||
@@ -66,6 +66,7 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
name: true,
|
||||
dose: true,
|
||||
active: true,
|
||||
trackIntake: true,
|
||||
schedules: {
|
||||
select: {
|
||||
windowStart: true,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -776,3 +776,146 @@ 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({
|
||||
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([]);
|
||||
});
|
||||
});
|
||||
@@ -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 &&
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
@@ -309,9 +321,16 @@ export const PUT = apiHandler(
|
||||
endsOn,
|
||||
oneShot,
|
||||
asNeeded,
|
||||
trackIntake,
|
||||
reminderGraceMinutes: topLevelGraceMinutes,
|
||||
} = parsed.data;
|
||||
|
||||
// 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 ──────────────────────
|
||||
//
|
||||
// An as-needed medication ends schedule-less, always. The Zod layer
|
||||
@@ -516,123 +535,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 +615,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 +841,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 +949,7 @@ export const PUT = apiHandler(
|
||||
dose: existing.dose,
|
||||
active: existing.active,
|
||||
asNeeded: existing.asNeeded,
|
||||
trackIntake: existing.trackIntake,
|
||||
endsOn: existing.endsOn,
|
||||
},
|
||||
after: {
|
||||
@@ -856,6 +957,7 @@ export const PUT = apiHandler(
|
||||
dose: medication.dose,
|
||||
active: medication.active,
|
||||
asNeeded: medication.asNeeded,
|
||||
trackIntake: medication.trackIntake,
|
||||
endsOn: medication.endsOn,
|
||||
},
|
||||
}),
|
||||
@@ -886,9 +988,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,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 } } },
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
Reference in new issue
Block a user