feat(medications): as-needed medications log without a schedule

A medication can declare itself as-needed: no schedules, never due,
never reminded, excluded from every compliance rate — but intakes log
ad-hoc, consume inventory exactly like scheduled doses, and the
history stays. The wizard offers it as a cadence choice and round-
trips it on edit; card, table and detail show a calm marker where the
next dose normally sits, the take-all dialog never collects one, and
the supply runway stays unknowable by construction. (#316)
This commit is contained in:
Marc-André Bombeck committed 2026-06-12 22:56:03 +02:00
1 parent 550f314a54
commit 3943dcfb72
43 files changed
+1273 -70

No files matched your search

+3 -1
View File
@@ -277,7 +277,9 @@ export async function buildComprehensiveResponse(user: AuthedUser) {
// ── Medication compliance ────────────────────────────────
// Bounded reads — left untouched per the directive.
const medications = await prisma.medication.findMany({
where: { userId, active: true },
// v1.16.11 — as-needed (PRN) medications never surface a compliance
// rate (no expected doses).
where: { userId, active: true, asNeeded: false },
// v1.15.20 — schedules through the shared compliance select so the
// configured per-dose windows reach this surface like every other.
include: {
+3 -1
View File
@@ -305,7 +305,9 @@ async function buildTargetsResponse(user: AuthedUser) {
// inside this slot while riding the outer fan-out.
limit(async () => {
const activeMedications = await prisma.medication.findMany({
where: { userId, active: true },
// v1.16.11 — as-needed (PRN) medications never surface a
// compliance rate (no expected doses).
where: { userId, active: true, asNeeded: false },
// v1.15.20 — schedules through the shared compliance select so the
// configured per-dose windows reach this surface like every other.
include: {
@@ -593,3 +593,117 @@ describe("PUT /api/medications/[id] — primary-schedule grace bridge", () => {
expect(prisma.medicationSchedule.update).not.toHaveBeenCalled();
});
});
describe("PUT /api/medications/[id] — as-needed (v1.16.11, #316)", () => {
/** Existing-row shape the as-needed invariants read. */
function mockExisting(overrides: {
asNeeded?: boolean;
scheduleCount?: number;
}) {
vi.mocked(prisma.medication.findUnique).mockResolvedValue({
id: "m1",
userId: "user-1",
active: true,
createdAt: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000),
asNeeded: overrides.asNeeded ?? false,
_count: { schedules: overrides.scheduleCount ?? 1 },
} as never);
}
it("422s when asNeeded:true carries a non-empty schedules array (Zod)", async () => {
mockExisting({ scheduleCount: 1 });
const res = await PUT(
putReq({
asNeeded: true,
schedules: [{ windowStart: "08:00", windowEnd: "09:00" }],
}),
ROUTE_CTX,
);
expect(res.status).toBe(422);
expect(prisma.medication.update).not.toHaveBeenCalled();
});
it("422s when asNeeded:true would keep the existing schedules (no replace list)", async () => {
mockExisting({ scheduleCount: 2 });
const res = await PUT(putReq({ asNeeded: true }), ROUTE_CTX);
expect(res.status).toBe(422);
const body = (await res.json()) as { error: string };
expect(body.error).toContain("schedules: []");
expect(prisma.medication.update).not.toHaveBeenCalled();
});
it("flips to as-needed with schedules:[] — old rows deleted, none recreated", async () => {
mockExisting({ scheduleCount: 1 });
// Re-stub deps the outer beforeEach's resetAllMocks cleared.
vi.mocked(getMedicationCategories).mockResolvedValue({} as never);
vi.mocked(prisma.medication.update).mockResolvedValue({
id: "m1",
userId: "user-1",
asNeeded: true,
schedules: [],
} as never);
const res = await PUT(
putReq({ asNeeded: true, schedules: [] }),
ROUTE_CTX,
);
expect(res.status).toBe(200);
// Wholesale replace ran: delete-all, recreate none.
expect(prisma.medicationSchedule.deleteMany).toHaveBeenCalledWith({
where: { medicationId: "m1" },
});
const updateData = vi.mocked(prisma.medication.update).mock
.calls[0][0] as { data: Record<string, unknown> };
expect(updateData.data.asNeeded).toBe(true);
expect(updateData.data.schedules).toEqual({ create: [] });
});
it("PUT on an already-as-needed medication never re-acquires schedules silently", async () => {
mockExisting({ asNeeded: true, scheduleCount: 0 });
// Body without asNeeded but WITH schedules: the effective flag stays
// true (existing row), so the entries are refused.
const res = await PUT(
putReq({ schedules: [{ windowStart: "08:00", windowEnd: "09:00" }] }),
ROUTE_CTX,
);
expect(res.status).toBe(422);
expect(prisma.medication.update).not.toHaveBeenCalled();
});
it("422s a flip back to scheduled without at least one schedule", async () => {
mockExisting({ asNeeded: true, scheduleCount: 0 });
const res = await PUT(putReq({ asNeeded: false }), ROUTE_CTX);
expect(res.status).toBe(422);
const body = (await res.json()) as { error: string };
expect(body.error).toContain("at least one schedule");
expect(prisma.medication.update).not.toHaveBeenCalled();
});
it("flips back to scheduled when the body carries the new schedule list", async () => {
mockExisting({ asNeeded: true, scheduleCount: 0 });
vi.mocked(getMedicationCategories).mockResolvedValue({} as never);
vi.mocked(prisma.medication.update).mockResolvedValue({
id: "m1",
userId: "user-1",
asNeeded: false,
schedules: [{ id: "s1" }],
} as never);
const res = await PUT(
putReq({
asNeeded: false,
schedules: [{ windowStart: "08:00", windowEnd: "09:00" }],
}),
ROUTE_CTX,
);
expect(res.status).toBe(200);
const updateData = vi.mocked(prisma.medication.update).mock
.calls[0][0] as { data: Record<string, unknown> };
expect(updateData.data.asNeeded).toBe(false);
});
it("422s an empty schedules array WITHOUT asNeeded:true (Zod)", async () => {
mockExisting({ scheduleCount: 1 });
const res = await PUT(putReq({ schedules: [] }), ROUTE_CTX);
expect(res.status).toBe(422);
expect(prisma.medication.update).not.toHaveBeenCalled();
});
});
@@ -561,3 +561,123 @@ describe("POST /api/medications/[id]/intake — v1.8.2 reconcile (M2 inventory)"
);
});
});
// ────────────────────────────────────────────────────────────────────
// v1.16.11 (#316) — as-needed (PRN, schedule-less) intakes
// ────────────────────────────────────────────────────────────────────
describe("POST /api/medications/[id]/intake — as-needed (schedule-less) medication", () => {
function postReq(body: unknown): NextRequest {
return new NextRequest("http://localhost/api/medications/med-1/intake", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
}
it("records an ad-hoc row (scheduledFor = takenAt) and consumes inventory", async () => {
// One hour in the past — relative instant, no slot semantics.
const takenAt = new Date(Date.now() - 60 * 60 * 1000);
const createdEvent = {
id: "evt-prn-1",
userId: "user-1",
medicationId: "med-1",
scheduledFor: takenAt,
takenAt,
skipped: false,
};
vi.mocked(prisma.$transaction).mockResolvedValue([createdEvent] as never);
// Slot-snap resolver loads the med: an as-needed medication carries
// ZERO schedules, so band attribution yields no slot → standalone
// ad-hoc insert (the documented PRN path).
vi.mocked(prisma.medication.findFirst).mockResolvedValueOnce({
id: "med-1",
startsOn: null,
endsOn: null,
oneShot: false,
createdAt: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000),
schedules: [],
} as never);
// Dedup probe — no recent duplicate.
vi.mocked(prisma.medicationIntakeEvent.findFirst).mockResolvedValueOnce(
null as never,
);
// One-shot reconcile probe — not a one-shot.
vi.mocked(prisma.medication.findUnique).mockResolvedValue({
id: "med-1",
userId: "user-1",
oneShot: false,
active: true,
} as never);
vi.mocked(prisma.medication.updateMany).mockResolvedValue({
count: 0,
} as never);
const res = await POST(
postReq({ takenAt: takenAt.toISOString(), skipped: false }),
ROUTE_PARAMS,
);
expect(res.status).toBe(201);
// Standalone insert anchored on the intake instant — the ad-hoc
// contract (`scheduledFor = takenAt`), no slot attribution.
const txArg = vi.mocked(prisma.$transaction).mock.calls[0][0];
expect(Array.isArray(txArg)).toBe(true);
// Inventory consumption fires exactly as for a scheduled medication —
// the seam is medication-level (eventId + intakeAt), no schedule input.
const { consumeForIntake } = await import(
"@/lib/medications/inventory/consumption"
);
expect(consumeForIntake).toHaveBeenCalledTimes(1);
expect(consumeForIntake).toHaveBeenCalledWith(
expect.objectContaining({
userId: "user-1",
medicationId: "med-1",
eventId: "evt-prn-1",
intakeAt: takenAt,
}),
);
});
it("a skipped ad-hoc write never consumes", async () => {
const skipEvent = {
id: "evt-prn-2",
userId: "user-1",
medicationId: "med-1",
scheduledFor: new Date(),
takenAt: null,
skipped: true,
};
vi.mocked(prisma.$transaction).mockResolvedValue([skipEvent] as never);
vi.mocked(prisma.medication.findFirst).mockResolvedValue({
id: "med-1",
startsOn: null,
endsOn: null,
oneShot: false,
createdAt: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000),
schedules: [],
} as never);
vi.mocked(prisma.medicationIntakeEvent.findFirst).mockResolvedValue(
null as never,
);
vi.mocked(prisma.medication.findUnique).mockResolvedValue({
id: "med-1",
userId: "user-1",
oneShot: false,
active: true,
} as never);
vi.mocked(prisma.medication.updateMany).mockResolvedValue({
count: 0,
} as never);
const res = await POST(postReq({ skipped: true }), ROUTE_PARAMS);
expect(res.status).toBe(201);
const { consumeForIntake, restoreForIntake } = await import(
"@/lib/medications/inventory/consumption"
);
expect(consumeForIntake).not.toHaveBeenCalled();
expect(restoreForIntake).toHaveBeenCalledTimes(1);
});
});
+46 -1
View File
@@ -199,7 +199,15 @@ export const PUT = apiHandler(
if (guard) return guard;
const existing = await prisma.medication.findUnique({
where: { id },
select: { active: true, createdAt: true },
// v1.16.11 — `asNeeded` + the schedule count feed the as-needed
// invariants below: a flip to as-needed must end schedule-less,
// a flip back to scheduled must end with at least one schedule.
select: {
active: true,
createdAt: true,
asNeeded: true,
_count: { select: { schedules: true } },
},
});
if (!existing) {
return apiError("Medication not found", 404);
@@ -236,9 +244,43 @@ export const PUT = apiHandler(
startsOn,
endsOn,
oneShot,
asNeeded,
reminderGraceMinutes: topLevelGraceMinutes,
} = parsed.data;
// ── v1.16.11 (#316) — as-needed invariants ──────────────────────
//
// An as-needed medication ends schedule-less, always. The Zod layer
// already rejects a populated `schedules` array alongside
// `asNeeded: true`; the route covers the cross-row cases the schema
// cannot see:
// - flipping an existing scheduled medication to as-needed
// requires `schedules: []` in the same request (keeping the old
// rows alongside the flag is a 422, per the feature contract);
// - flipping as-needed OFF requires the medication to end with at
// least one schedule (the wizard always sends the full array).
const effectiveAsNeeded = asNeeded ?? existing.asNeeded;
if (effectiveAsNeeded) {
const endsScheduleless = schedules
? schedules.length === 0
: existing._count.schedules === 0;
if (!endsScheduleless) {
return apiError(
"An as-needed medication cannot carry schedules (send schedules: [] to clear them)",
422,
);
}
} else if (
asNeeded === false &&
existing.asNeeded &&
(!schedules || schedules.length === 0)
) {
return apiError(
"A scheduled medication requires at least one schedule",
422,
);
}
// ── v1.5.5 — primary-schedule grace bridge ──────────────────────
//
// The detail-page settings section saves the reminder-window in
@@ -540,6 +582,9 @@ export const PUT = apiHandler(
...(startsOn !== undefined && { startsOn }),
...(normalisedEndsOn !== undefined && { endsOn: normalisedEndsOn }),
...(oneShot !== undefined && { oneShot }),
// v1.16.11 — as-needed flag, field-by-field (the invariants above
// already guaranteed the medication ends schedule-consistent).
...(asNeeded !== undefined && { asNeeded }),
...(normalisedSchedules && {
schedules: {
create: normalisedSchedules.map((n) => n.createData),
@@ -417,3 +417,85 @@ describe("GET /api/medications — todayEventCount counts only actioned rows (v1
expect(where.OR).toEqual([{ takenAt: { not: null } }, { skipped: true }]);
});
});
describe("POST /api/medications — as-needed (v1.16.11, #316)", () => {
function lastCreateData(): Record<string, unknown> & {
schedules: { create: Record<string, unknown>[] };
} {
const calls = vi.mocked(prisma.medication.create).mock.calls;
expect(calls.length).toBeGreaterThan(0);
return (calls[calls.length - 1][0] as { data: never }).data;
}
it("422s when asNeeded:true carries a schedules array", async () => {
const res = await POST(
postReq({
name: "Ibuprofen",
dose: "400 mg",
asNeeded: true,
schedules: [{ windowStart: "08:00", windowEnd: "09:00" }],
}),
);
expect(res.status).toBe(422);
const body = (await res.json()) as {
details: { issues: Array<{ path: string; message: string }> };
};
expect(
body.details.issues.some((i) =>
i.message.includes("as-needed medication cannot carry schedules"),
),
).toBe(true);
expect(prisma.medication.create).not.toHaveBeenCalled();
});
it("422s when asNeeded:true is combined with oneShot:true", async () => {
const res = await POST(
postReq({
name: "Ibuprofen",
dose: "400 mg",
asNeeded: true,
oneShot: true,
startsOn: "2026-06-01",
}),
);
expect(res.status).toBe(422);
const body = (await res.json()) as {
details: { issues: Array<{ message: string }> };
};
expect(
body.details.issues.some((i) =>
i.message.includes("mutually exclusive"),
),
).toBe(true);
});
it("creates an as-needed medication with ZERO schedules", async () => {
const res = await POST(
postReq({
name: "Ibuprofen",
dose: "400 mg",
category: "PAIN_RELIEF",
unitsPerDose: 2,
asNeeded: true,
}),
);
expect(res.status).toBe(201);
const data = lastCreateData();
expect(data.asNeeded).toBe(true);
expect(data.oneShot).toBeUndefined();
expect(data.schedules.create).toEqual([]);
// Dose fields stay first-class — they feed inventory consumption.
expect(data.unitsPerDose).toBe(2);
});
it("still 422s a scheduled create without any schedule (legacy contract)", async () => {
const res = await POST(postReq({ name: "Ramipril", dose: "5 mg" }));
expect(res.status).toBe(422);
const body = (await res.json()) as {
details: { issues: Array<{ path: string; message: string }> };
};
expect(
body.details.issues.some((i) => i.path.includes("schedules")),
).toBe(true);
});
});
+4 -1
View File
@@ -46,7 +46,10 @@ export const GET = apiHandler(async () => {
// Same ordering as the medications list so the page's card order and
// this payload walk the same sequence.
const medications = await prisma.medication.findMany({
where: { userId: user.id },
// 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 },
include: {
schedules: true,
// v1.16.3 — archived schedule eras for era-aware compliance.
+18 -4
View File
@@ -309,6 +309,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
startsOn,
endsOn,
oneShot,
asNeeded,
} = parsed.data;
// ── v1.5 route invariants for the new scheduling primitives ───────
@@ -335,15 +336,24 @@ export const POST = apiHandler(async (request: NextRequest) => {
// always sees a populated `timesOfDay` while legacy iOS clients
// (v0.6.x) keep encoding the `windowStart` / `windowEnd` /
// `daysOfWeek` shape. Both shapes coexist through v1.5.x.
// 4. **As-needed (v1.16.11, #316)** — when `asNeeded === true` the
// medication carries ZERO schedules (the Zod refine already
// rejects a populated array, mutual exclusion with `oneShot`
// included). Every "never due / never reminded / never scored"
// surface follows structurally from the empty schedule list.
const scheduleInputs = schedules ?? [];
if (oneShot === true) {
if (schedules.length > 1) {
if (scheduleInputs.length > 1) {
return apiError(
"A one-shot medication can have at most one schedule",
422,
);
}
const s = schedules[0];
if (s.rrule !== undefined || s.rollingIntervalDays !== undefined) {
const s = scheduleInputs[0];
if (
s &&
(s.rrule !== undefined || s.rollingIntervalDays !== undefined)
) {
return apiError(
"A one-shot medication cannot have a recurrence (rrule or rollingIntervalDays)",
422,
@@ -387,8 +397,12 @@ export const POST = apiHandler(async (request: NextRequest) => {
...(startsOn !== undefined && { startsOn }),
...(normalisedEndsOn !== undefined && { endsOn: normalisedEndsOn }),
...(oneShot !== undefined && { oneShot }),
// v1.16.11 — as-needed flag, field-by-field. An asNeeded create
// carries an empty `scheduleInputs`, so the nested create below
// persists zero schedule rows.
...(asNeeded !== undefined && { asNeeded }),
schedules: {
create: schedules.map((s) => {
create: scheduleInputs.map((s) => {
// Invariant 2 — default to FREQ=DAILY when nothing else is set.
// v1.7.0 — PRN schedules carry no cadence, so never default
// them to FREQ=DAILY (they would otherwise project + remind).