mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 22:02:16 +00:00
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:
1 parent
550f314a54
commit
3943dcfb72
43 files changed
+1273
-70
No files matched your search
@@ -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: {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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.
|
||||
|
||||
@@ -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).
|
||||
|
||||
Reference in new issue
Block a user