mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 13:22:14 +00:00
Merge branch 'feat/medication-record-only' into release/v1.39.1
This commit is contained in:
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 } : {}),
|
||||
},
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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 &&
|
||||
|
||||
@@ -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,
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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