feat(medications): make low-stock alert reorder-lead-time aware

The low-stock alert fired when the projected runway dropped below the
user threshold (default 7 days). For a sparse cadence — a weekly
injection's 7-day floor is barely one dose-interval — the warning landed
with about one dose left, too late to reorder before the supply ran out.

Add an optional per-medication reorder lead time plus a user-level
default (0–60 days, default 10) and widen the alert trigger to
max(lowStockRunwayDays, leadDays + cadenceIntervalDays), so the warning
lands before the last dose for any cadence. Because the trigger is a
max() over the user floor it never shrinks anyone's current threshold; a
daily med with no lead keeps its prior boundary.

The push body and the card notice surface concrete dates — supply runs
out around <date>, reorder by <date> — with a last-dose informational
state and the days/units fallback when supply data is thin. Retire the
stale fixed 4-dose count threshold on the GLP-1 details endpoint and
route its card lowStock flag through the same runway evaluation so the
card and the daily cron agree. Surface the lead default in the low-stock
settings card with progressive disclosure; a single medication can
override it.

Additive schema: nullable Medication.reorderLeadDays (migration 0161, no
backfill) and notificationPrefs.medication.reorderLeadDays. OpenAPI
regenerated.
This commit is contained in:
Marc-André Bombeck committed 2026-06-14 20:39:06 +02:00
1 parent 52bf985734
commit 08aa655be4
29 files changed
+1060 -97

No files matched your search

@@ -92,6 +92,7 @@ describe("GET /api/auth/me/notification-prefs", () => {
clientManaged: false,
deliveryDefault: "server",
lowStockRunwayDays: 7,
reorderLeadDays: 10,
},
mood: { reminderHour: 22 },
cycle: { clientManaged: false },
@@ -123,6 +124,7 @@ describe("GET /api/auth/me/notification-prefs", () => {
clientManaged: true,
deliveryDefault: "server",
lowStockRunwayDays: 7,
reorderLeadDays: 10,
},
mood: { reminderHour: 22 },
cycle: { clientManaged: false },
@@ -156,6 +158,7 @@ describe("GET /api/auth/me/notification-prefs", () => {
clientManaged: false,
deliveryDefault: "server",
lowStockRunwayDays: 7,
reorderLeadDays: 10,
},
mood: { reminderHour: 22 },
cycle: { clientManaged: false },
@@ -200,6 +203,7 @@ describe("PATCH /api/auth/me/notification-prefs", () => {
clientManaged: true,
deliveryDefault: "server",
lowStockRunwayDays: 7,
reorderLeadDays: 10,
},
mood: { reminderHour: 22 },
cycle: { clientManaged: false },
@@ -220,6 +224,7 @@ describe("PATCH /api/auth/me/notification-prefs", () => {
clientManaged: true,
deliveryDefault: "server",
lowStockRunwayDays: 7,
reorderLeadDays: 10,
},
mood: { reminderHour: 22 },
cycle: { clientManaged: false },
@@ -244,6 +249,7 @@ describe("PATCH /api/auth/me/notification-prefs", () => {
clientManaged: false,
deliveryDefault: "server",
lowStockRunwayDays: 7,
reorderLeadDays: 10,
},
mood: { reminderHour: 22 },
cycle: { clientManaged: false },
@@ -260,6 +266,7 @@ describe("PATCH /api/auth/me/notification-prefs", () => {
clientManaged: true,
deliveryDefault: "server",
lowStockRunwayDays: 7,
reorderLeadDays: 10,
},
mood: { reminderHour: 22 },
cycle: { clientManaged: false },
@@ -329,6 +336,7 @@ describe("PATCH /api/auth/me/notification-prefs", () => {
clientManaged: true,
deliveryDefault: "server",
lowStockRunwayDays: 7,
reorderLeadDays: 10,
},
mood: { reminderHour: 22 },
cycle: { clientManaged: false },
@@ -363,6 +371,7 @@ describe("PATCH /api/auth/me/notification-prefs", () => {
clientManaged: true,
deliveryDefault: "server",
lowStockRunwayDays: 7,
reorderLeadDays: 10,
},
mood: { reminderHour: 22 },
cycle: { clientManaged: false },
@@ -383,6 +392,7 @@ describe("PATCH /api/auth/me/notification-prefs", () => {
clientManaged: true,
deliveryDefault: "server",
lowStockRunwayDays: 7,
reorderLeadDays: 10,
},
mood: { reminderHour: 22 },
cycle: { clientManaged: false },
@@ -422,6 +432,7 @@ describe("PATCH /api/auth/me/notification-prefs", () => {
clientManaged: false,
deliveryDefault: "server",
lowStockRunwayDays: 7,
reorderLeadDays: 10,
},
mood: { reminderHour: 9 },
cycle: { clientManaged: false },
@@ -471,6 +482,7 @@ describe("PATCH /api/auth/me/notification-prefs", () => {
clientManaged: false,
deliveryDefault: "server",
lowStockRunwayDays: 14,
reorderLeadDays: 10,
},
}),
},
@@ -14,6 +14,10 @@ import { NextRequest } from "next/server";
vi.mock("@/lib/db", () => ({
prisma: {
medication: { findUnique: vi.fn() },
// v1.17.0 — the GET reads the caller's notificationPrefs to resolve
// the reorder-lead-aware low-stock trigger. Default null = the
// documented defaults (alert on, 7-day floor, 10-day lead).
user: { findUnique: vi.fn().mockResolvedValue({ notificationPrefs: null }) },
medicationDoseChange: { create: vi.fn() },
medicationInventoryEvent: { create: vi.fn() },
},
@@ -443,7 +447,19 @@ describe("GET /api/medications/[id]/glp1 — v1.16.10 item-backed inventory", ()
userId: "user-1",
doseChanges: [],
intakeEvents: [],
schedules: [],
// Weekly cadence — the canonical GLP-1 case. 3 doses ≈ 21 days of
// runway, comfortably above the reorder-lead-aware trigger
// (max(7, 10 + 7) = 17), so the flag is FALSE.
schedules: [
{
windowStart: "08:00",
daysOfWeek: "6",
timesOfDay: ["08:00"],
rrule: null,
rollingIntervalDays: null,
},
],
reorderLeadDays: null,
dosesPerUnit: 4,
unitsPerDose: 2,
inventoryItems: [
@@ -476,14 +492,90 @@ describe("GET /api/medications/[id]/glp1 — v1.16.10 item-backed inventory", ()
};
};
// 2 usable containers; 7 pooled units / 2 units per dose = 3 doses.
// v1.17.0 — lowStock now rides the runway model: 3 weekly doses ≈ 21
// days > trigger 17 ⇒ false.
expect(body.data.inventory).toEqual({
pensRemaining: 2,
dosesRemaining: 3,
weeksOfSupply: 3,
lowStock: true,
lowStock: false,
});
});
it("v1.17.0 — lowStock flag matches the reorder-lead-aware runway decision (fires before the last weekly dose)", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.medication.findUnique).mockResolvedValue({
id: "med-1",
userId: "user-1",
doseChanges: [],
intakeEvents: [],
// Weekly cadence, ONE dose left ≈ 7 days of runway. With the default
// 10-day reorder lead the trigger is max(7, 10 + 7) = 17, so runway
// 7 ≤ 17 ⇒ the card flag lights with reorder headroom — exactly the
// cron's decision (no longer "at the last dose").
schedules: [
{
windowStart: "08:00",
daysOfWeek: "6",
timesOfDay: ["08:00"],
rrule: null,
rollingIntervalDays: null,
},
],
reorderLeadDays: null,
dosesPerUnit: 1,
unitsPerDose: 1,
inventoryItems: [{ state: "ACTIVE", unitsTotal: 4, unitsRemaining: 1 }],
inventoryEvents: [],
} as never);
const res = await GET(getReq(), {
params: Promise.resolve({ id: "med-1" }),
});
expect(res.status).toBe(200);
const body = (await res.json()) as {
data: { inventory: { dosesRemaining: number; lowStock: boolean } };
};
expect(body.data.inventory.dosesRemaining).toBe(1);
expect(body.data.inventory.lowStock).toBe(true);
});
it("v1.17.0 — lowStock is false when the alert is switched off (null threshold)", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.user.findUnique).mockResolvedValueOnce({
notificationPrefs: { medication: { lowStockRunwayDays: null } },
} as never);
vi.mocked(prisma.medication.findUnique).mockResolvedValue({
id: "med-1",
userId: "user-1",
doseChanges: [],
intakeEvents: [],
schedules: [
{
windowStart: "08:00",
daysOfWeek: "6",
timesOfDay: ["08:00"],
rrule: null,
rollingIntervalDays: null,
},
],
reorderLeadDays: null,
dosesPerUnit: 1,
unitsPerDose: 1,
inventoryItems: [{ state: "ACTIVE", unitsTotal: 4, unitsRemaining: 1 }],
inventoryEvents: [],
} as never);
const res = await GET(getReq(), {
params: Promise.resolve({ id: "med-1" }),
});
expect(res.status).toBe(200);
const body = (await res.json()) as {
data: { inventory: { lowStock: boolean } };
};
expect(body.data.inventory.lowStock).toBe(false);
});
it("returns a null inventory block when no items and no ledger rows exist", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.medication.findUnique).mockResolvedValue({
+53 -3
View File
@@ -28,11 +28,20 @@ import {
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
import { glp1PostBodySchema } from "@/lib/validations/medication";
import { assertMedicationOwnership } from "@/lib/medications/route-guards";
import {
estimateDailyDoseCount,
estimateRunwayDays,
lowStockTriggerDays,
type RunwaySchedule,
} from "@/components/medications/detail/supply-runway";
import {
resolveLowStockRunwayDays,
resolveReorderLeadDays,
} from "@/lib/validations/notification-prefs";
import { NextRequest } from "next/server";
type RouteParams = { params: Promise<{ id: string }> };
const LOW_STOCK_DOSE_THRESHOLD = 4;
const POST_RATE_LIMIT = 30;
const POST_WINDOW_MS = 60_000;
@@ -76,6 +85,47 @@ export const GET = apiHandler(
return apiError("Medication not found", 404);
}
// v1.17.0 — the card `lowStock` flag rides the SAME reorder-lead-aware
// runway evaluation as the daily low-stock cron, so the GLP-1 card and
// the notification can never disagree. The legacy fixed 4-dose count
// threshold (`LOW_STOCK_DOSE_THRESHOLD`) is retired: a weekly cadence's
// "4 doses" was ~28 days — far too eager — while a daily med at 4 doses
// was ~4 days — far too late. Runway days ≤ the effective trigger
// (`max(lowStockRunwayDays, leadDays + cadenceIntervalDays)`) is the one
// truth. `lowStockRunwayDays === null` (alert off) ⇒ flag never lights.
const prefsRow = await prisma.user.findUnique({
where: { id: user.id },
select: { notificationPrefs: true },
});
const runwayFloor = resolveLowStockRunwayDays(
prefsRow?.notificationPrefs ?? null,
);
const leadDays = resolveReorderLeadDays(
prefsRow?.notificationPrefs ?? null,
medication.reorderLeadDays,
);
const schedules: RunwaySchedule[] = medication.schedules;
/** runway ≤ effective trigger AND the alert is enabled. */
const isLowStock = (dosesRemaining: number): boolean => {
if (runwayFloor === null) return false;
// No consuming schedule ⇒ no runway ⇒ never low (matches the cron's
// `evaluateMedicationRunway` null branch).
if (estimateDailyDoseCount(schedules) <= 0) return false;
const triggerDays = lowStockTriggerDays({
lowStockRunwayDays: runwayFloor,
leadDays,
schedules,
});
// Exhausted supply with a consuming schedule is runway 0 (below
// every trigger); otherwise the cadence-aware days of supply. Same
// semantics the cron's `evaluateMedicationRunway` applies.
const runwayDays =
dosesRemaining > 0
? (estimateRunwayDays(dosesRemaining, schedules) ?? 0)
: 0;
return runwayDays <= triggerDays;
};
// Inventory math over the per-item entities. The response shape is
// locked for the iOS client: `pensRemaining` = count of usable
// containers (ACTIVE / IN_USE with units left), `dosesRemaining` =
@@ -103,7 +153,7 @@ export const GET = apiHandler(
unitsRemaining / (Number(medication.unitsPerDose) || 1),
);
const weeksOfSupply = dosesRemaining;
const lowStock = dosesRemaining < LOW_STOCK_DOSE_THRESHOLD;
const lowStock = isLowStock(dosesRemaining);
inventory = {
pensRemaining,
dosesRemaining,
@@ -127,7 +177,7 @@ export const GET = apiHandler(
const pensRemaining = Math.max(0, pens);
const dosesRemaining = pensRemaining * medication.dosesPerUnit;
const weeksOfSupply = dosesRemaining;
const lowStock = dosesRemaining < LOW_STOCK_DOSE_THRESHOLD;
const lowStock = isLowStock(dosesRemaining);
inventory = {
pensRemaining,
dosesRemaining,
+4
View File
@@ -232,6 +232,7 @@ export const PUT = apiHandler(
treatmentClass,
dosesPerUnit,
unitsPerDose,
reorderLeadDays,
deliveryForm,
trackInjectionSites,
allowedInjectionSites,
@@ -598,6 +599,9 @@ export const PUT = apiHandler(
// v1.16.10 — units consumed per dose. Field-by-field; the stamp on
// already-taken intake events freezes their recorded consumption.
...(unitsPerDose !== undefined && { unitsPerDose }),
// v1.17.0 — reorder lead override. Field-by-field; `null` is a
// valid explicit value (clears the override → user-level default).
...(reorderLeadDays !== undefined && { reorderLeadDays }),
...(deliveryForm !== undefined && { deliveryForm }),
// v1.8.5 — injection-site tracking opt-in + per-medication allowed
// sites. Field-by-field; `false` / `[]` are valid explicit values
+4
View File
@@ -300,6 +300,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
treatmentClass,
dosesPerUnit,
unitsPerDose,
reorderLeadDays,
deliveryForm,
trackInjectionSites,
allowedInjectionSites,
@@ -379,6 +380,9 @@ export const POST = apiHandler(async (request: NextRequest) => {
...(dosesPerUnit !== undefined && { dosesPerUnit }),
// v1.16.10 — units consumed per dose; Prisma defaults to 1 when omitted.
...(unitsPerDose !== undefined && { unitsPerDose }),
// v1.17.0 — optional reorder lead override; null/omitted = inherit
// the user-level default in the low-stock alert.
...(reorderLeadDays !== undefined && { reorderLeadDays }),
// v1.6.0 — route of administration; Prisma defaults to ORAL when omitted.
...(deliveryForm !== undefined && { deliveryForm }),
// v1.8.5 — injection-site tracking opt-in + per-medication allowed
@@ -77,12 +77,15 @@ describe("GET /api/settings/reminder-thresholds", () => {
lateMinutes: number;
missedMinutes: number;
lowStockRunwayDays: number | null;
reorderLeadDays: number;
};
};
expect(env.data).toEqual({
lateMinutes: 120,
missedMinutes: 240,
lowStockRunwayDays: 7,
// v1.17.0 — the per-user reorder lead default rides along.
reorderLeadDays: 10,
});
});
@@ -3,7 +3,7 @@ import { getReminderThresholds } from "@/lib/app-settings";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { prisma } from "@/lib/db";
import { resolveLowStockRunwayDays } from "@/lib/validations/notification-prefs";
import { parseNotificationPrefs } from "@/lib/validations/notification-prefs";
export const dynamic = "force-dynamic";
@@ -22,11 +22,15 @@ export const GET = apiHandler(async () => {
where: { id: user.id },
select: { notificationPrefs: true },
});
const lowStockRunwayDays = resolveLowStockRunwayDays(
row?.notificationPrefs ?? null,
);
const prefs = parseNotificationPrefs(row?.notificationPrefs ?? null);
const lowStockRunwayDays = prefs.medication.lowStockRunwayDays;
// v1.17.0 — the user-level reorder lead default rides along so the
// medication cards can derive the same reorder-lead-aware trigger the
// daily cron uses (per-medication overrides come from the list payload's
// own `reorderLeadDays`).
const reorderLeadDays = prefs.medication.reorderLeadDays;
annotate({ action: { name: "settings.reminder-thresholds.get" } });
return apiSuccess({ ...thresholds, lowStockRunwayDays });
return apiSuccess({ ...thresholds, lowStockRunwayDays, reorderLeadDays });
});