feat(medications): low-stock notifications with a runway threshold

A daily job projects each tracked medication's remaining supply against
its schedules and notifies through the normal channel cascade when the
runway falls below the user's threshold (default seven days, 1-60 or
off, set beside the reminder thresholds). One notification per
crossing: a stamp on the medication row re-arms on refill or threshold
change, and a failed dispatch leaves it clear for the next day's retry.
The GLP-1 card's fixed four-dose flag stays as the locked iOS read
contract; the engine covers it like any other medication.
This commit is contained in:
Marc-André Bombeck committed 2026-06-12 21:04:09 +02:00
1 parent 159de9b78f
commit cac9a32756
19 files changed
+1741 -59

No files matched your search

+21 -1
View File
@@ -5301,6 +5301,19 @@
"disabledToast": "Proaktive Coach-Hinweise deaktiviert.",
"saveError": "Speichern fehlgeschlagen. Bitte später erneut versuchen."
},
"lowStock": {
"title": "Vorratswarnung",
"description": "Prüft deine erfassten Medikamentenvorräte einmal täglich und benachrichtigt dich, wenn einer weniger Tage abdeckt als diese Schwelle. Eine Benachrichtigung pro Engpass; nach dem Auffüllen meldet sie sich wieder.",
"toggleAria": "Vorratswarnungen umschalten",
"statusOn": "Aktiv",
"statusOff": "Aus",
"daysLabel": "Warnen unter (Tage)",
"daysAria": "Vorratsschwelle in Tagen wählen",
"savedToast": "Vorratsschwelle gespeichert.",
"enabledToast": "Vorratswarnungen aktiviert.",
"disabledToast": "Vorratswarnungen deaktiviert.",
"saveError": "Speichern fehlgeschlagen. Bitte später erneut versuchen."
},
"eventMedicationReminder": "Medikamenten-Erinnerung",
"eventMedicationReminderDesc": "Wird zu jeder geplanten Einnahmezeit gesendet.",
"eventMeasurementAnomaly": "Auffälliger Messwert",
@@ -5320,7 +5333,9 @@
"eventCyclePeriodConfirm": "Hat deine Periode begonnen?",
"eventCyclePeriodConfirmDesc": "Eine sanfte Nachfrage am oder nach dem vorhergesagten Beginn, solange keine Periode erfasst ist.",
"eventCycleFertileSoon": "Fruchtbares Fenster steht bevor",
"eventCycleFertileSoonDesc": "Wird einige Tage vor deinem geschätzten fruchtbaren Fenster gesendet, wenn dein Ziel die Empfängnis ist."
"eventCycleFertileSoonDesc": "Wird einige Tage vor deinem geschätzten fruchtbaren Fenster gesendet, wenn dein Ziel die Empfängnis ist.",
"eventMedicationLowStock": "Medikamentenvorrat niedrig",
"eventMedicationLowStockDesc": "Wenn ein erfasster Vorrat weniger Tage abdeckt als deine Vorratsschwelle"
},
"i18n": {
"maintainershipBanner": {
@@ -5483,6 +5498,11 @@
"buttonSnoozeThreeHours": "🕐 3h",
"buttonSkip": "⏭ Überspringen"
},
"lowStockReminders": {
"title": "Vorrat niedrig: {medName}",
"body": "Dein Vorrat an {medName} reicht noch etwa {days} Tage ({units} Einheiten übrig).",
"bodyToday": "Dein Vorrat an {medName} reicht keinen ganzen Tag mehr ({units} Einheiten übrig)."
},
"moodReminders": {
"dailyTitle": "Stimmung erfassen",
"dailyBody": "Wie geht es dir heute?"
+21 -1
View File
@@ -5301,6 +5301,19 @@
"disabledToast": "Proactive Coach nudges disabled.",
"saveError": "Could not save. Please try again later."
},
"lowStock": {
"title": "Low-stock alert",
"description": "Checks your tracked medication supplies once a day and notifies you when one covers fewer days than this threshold. One notification per shortage; it re-arms after a refill.",
"toggleAria": "Toggle low-stock alerts",
"statusOn": "Active",
"statusOff": "Off",
"daysLabel": "Notify below (days)",
"daysAria": "Select the low-stock threshold in days",
"savedToast": "Low-stock threshold saved.",
"enabledToast": "Low-stock alerts enabled.",
"disabledToast": "Low-stock alerts disabled.",
"saveError": "Could not save. Please try again."
},
"eventMedicationReminder": "Medication reminder",
"eventMedicationReminderDesc": "Sent at each scheduled dose time.",
"eventMeasurementAnomaly": "Unusual measurement",
@@ -5320,7 +5333,9 @@
"eventCyclePeriodConfirm": "Did your period start?",
"eventCyclePeriodConfirmDesc": "A gentle check-in on or after the predicted start while no period is logged.",
"eventCycleFertileSoon": "Fertile window coming up",
"eventCycleFertileSoonDesc": "Sent a few days before your estimated fertile window when your goal is trying to conceive."
"eventCycleFertileSoonDesc": "Sent a few days before your estimated fertile window when your goal is trying to conceive.",
"eventMedicationLowStock": "Medication stock low",
"eventMedicationLowStockDesc": "When a tracked medication supply covers fewer days than your low-stock threshold"
},
"i18n": {
"maintainershipBanner": {
@@ -5483,6 +5498,11 @@
"buttonSnoozeThreeHours": "🕐 3h",
"buttonSkip": "⏭ Skip"
},
"lowStockReminders": {
"title": "Low supply: {medName}",
"body": "{medName}: {units} units left — enough for about {days} more days.",
"bodyToday": "{medName}: {units} units left — less than one day of supply."
},
"moodReminders": {
"dailyTitle": "Log your mood",
"dailyBody": "How are you feeling today?"
+21 -1
View File
@@ -5301,6 +5301,19 @@
"disabledToast": "Avisos proactivos del Coach desactivados.",
"saveError": "No se pudo guardar. Inténtalo de nuevo más tarde."
},
"lowStock": {
"title": "Aviso de existencias bajas",
"description": "Comprueba una vez al día las existencias registradas de tus medicamentos y te avisa cuando alguna cubre menos días que este umbral. Un aviso por escasez; se reactiva tras reponer.",
"toggleAria": "Activar o desactivar los avisos de existencias bajas",
"statusOn": "Activo",
"statusOff": "Desactivado",
"daysLabel": "Avisar por debajo de (días)",
"daysAria": "Seleccionar el umbral de existencias en días",
"savedToast": "Umbral de existencias guardado.",
"enabledToast": "Avisos de existencias bajas activados.",
"disabledToast": "Avisos de existencias bajas desactivados.",
"saveError": "No se pudo guardar. Inténtalo de nuevo."
},
"eventMedicationReminder": "Recordatorio de medicación",
"eventMedicationReminderDesc": "Se envía a cada hora de dosis programada.",
"eventMeasurementAnomaly": "Medición inusual",
@@ -5320,7 +5333,9 @@
"eventCyclePeriodConfirm": "¿Empezó tu período?",
"eventCyclePeriodConfirmDesc": "Un recordatorio suave en o después del inicio previsto mientras no se registre ningún período.",
"eventCycleFertileSoon": "Ventana fértil próxima",
"eventCycleFertileSoonDesc": "Se envía unos días antes de tu ventana fértil estimada cuando tu objetivo es concebir."
"eventCycleFertileSoonDesc": "Se envía unos días antes de tu ventana fértil estimada cuando tu objetivo es concebir.",
"eventMedicationLowStock": "Existencias de medicación bajas",
"eventMedicationLowStockDesc": "Cuando unas existencias registradas cubren menos días que tu umbral de existencias"
},
"i18n": {
"maintainershipBanner": {
@@ -5483,6 +5498,11 @@
"buttonSnoozeThreeHours": "🕐 3h",
"buttonSkip": "⏭ Omitir"
},
"lowStockReminders": {
"title": "Quedan pocas existencias: {medName}",
"body": "{medName}: quedan {units} unidades — para unos {days} días más.",
"bodyToday": "{medName}: quedan {units} unidades — menos de un día de suministro."
},
"moodReminders": {
"dailyTitle": "Registrar el ánimo",
"dailyBody": "¿Cómo te sientes hoy?"
+21 -1
View File
@@ -5301,6 +5301,19 @@
"disabledToast": "Rappels proactifs du Coach désactivés.",
"saveError": "Impossible d'enregistrer. Veuillez réessayer plus tard."
},
"lowStock": {
"title": "Alerte de stock faible",
"description": "Vérifie une fois par jour les stocks suivis de vos médicaments et vous prévient quand l’un d’eux couvre moins de jours que ce seuil. Une notification par pénurie ; elle se réarme après un réapprovisionnement.",
"toggleAria": "Activer ou désactiver les alertes de stock faible",
"statusOn": "Actif",
"statusOff": "Désactivé",
"daysLabel": "Prévenir en dessous de (jours)",
"daysAria": "Choisir le seuil de stock en jours",
"savedToast": "Seuil de stock enregistré.",
"enabledToast": "Alertes de stock faible activées.",
"disabledToast": "Alertes de stock faible désactivées.",
"saveError": "Échec de l’enregistrement. Réessayez."
},
"eventMedicationReminder": "Rappel de médicament",
"eventMedicationReminderDesc": "Envoyé à chaque heure de prise prévue.",
"eventMeasurementAnomaly": "Mesure inhabituelle",
@@ -5320,7 +5333,9 @@
"eventCyclePeriodConfirm": "Vos règles ont-elles commencé ?",
"eventCyclePeriodConfirmDesc": "Un rappel discret le jour prévu ou après, tant qu'aucune règle n'est enregistrée.",
"eventCycleFertileSoon": "Fenêtre fertile à venir",
"eventCycleFertileSoonDesc": "Envoyé quelques jours avant votre fenêtre fertile estimée lorsque votre objectif est de concevoir."
"eventCycleFertileSoonDesc": "Envoyé quelques jours avant votre fenêtre fertile estimée lorsque votre objectif est de concevoir.",
"eventMedicationLowStock": "Stock de médicament faible",
"eventMedicationLowStockDesc": "Quand un stock suivi couvre moins de jours que votre seuil de stock"
},
"i18n": {
"maintainershipBanner": {
@@ -5483,6 +5498,11 @@
"buttonSnoozeThreeHours": "🕐 3h",
"buttonSkip": "⏭ Ignorer"
},
"lowStockReminders": {
"title": "Stock faible : {medName}",
"body": "{medName} : {units} unités restantes — environ {days} jours de réserve.",
"bodyToday": "{medName} : {units} unités restantes — moins d’un jour de réserve."
},
"moodReminders": {
"dailyTitle": "Enregistrer votre humeur",
"dailyBody": "Comment vous sentez-vous aujourd’hui ?"
+21 -1
View File
@@ -5301,6 +5301,19 @@
"disabledToast": "Avvisi proattivi del Coach disattivati.",
"saveError": "Impossibile salvare. Riprova più tardi."
},
"lowStock": {
"title": "Avviso scorte basse",
"description": "Controlla una volta al giorno le scorte tracciate dei tuoi farmaci e ti avvisa quando una copre meno giorni di questa soglia. Un avviso per carenza; si riattiva dopo il rifornimento.",
"toggleAria": "Attiva o disattiva gli avvisi di scorte basse",
"statusOn": "Attivo",
"statusOff": "Disattivato",
"daysLabel": "Avvisa sotto (giorni)",
"daysAria": "Seleziona la soglia delle scorte in giorni",
"savedToast": "Soglia delle scorte salvata.",
"enabledToast": "Avvisi di scorte basse attivati.",
"disabledToast": "Avvisi di scorte basse disattivati.",
"saveError": "Salvataggio non riuscito. Riprova."
},
"eventMedicationReminder": "Promemoria farmaco",
"eventMedicationReminderDesc": "Inviato a ogni orario di dose programmato.",
"eventMeasurementAnomaly": "Misurazione insolita",
@@ -5320,7 +5333,9 @@
"eventCyclePeriodConfirm": "Il ciclo è iniziato?",
"eventCyclePeriodConfirmDesc": "Un promemoria discreto dal giorno previsto in poi finché non registri il ciclo.",
"eventCycleFertileSoon": "Finestra fertile in arrivo",
"eventCycleFertileSoonDesc": "Inviato alcuni giorni prima della finestra fertile stimata quando il tuo obiettivo è il concepimento."
"eventCycleFertileSoonDesc": "Inviato alcuni giorni prima della finestra fertile stimata quando il tuo obiettivo è il concepimento.",
"eventMedicationLowStock": "Scorte di farmaco in esaurimento",
"eventMedicationLowStockDesc": "Quando una scorta tracciata copre meno giorni della tua soglia di scorte"
},
"i18n": {
"maintainershipBanner": {
@@ -5483,6 +5498,11 @@
"buttonSnoozeThreeHours": "🕐 3h",
"buttonSkip": "⏭ Salta"
},
"lowStockReminders": {
"title": "Scorte in esaurimento: {medName}",
"body": "{medName}: {units} unità rimaste — bastano per circa {days} giorni.",
"bodyToday": "{medName}: {units} unità rimaste — meno di un giorno di scorta."
},
"moodReminders": {
"dailyTitle": "Registra il tuo umore",
"dailyBody": "Come ti senti oggi?"
+21 -1
View File
@@ -5301,6 +5301,19 @@
"disabledToast": "Proaktywne podpowiedzi Coacha wyłączone.",
"saveError": "Nie udało się zapisać. Spróbuj ponownie później."
},
"lowStock": {
"title": "Alert niskiego zapasu",
"description": "Raz dziennie sprawdza śledzone zapasy leków i powiadamia, gdy któryś wystarcza na mniej dni niż ten próg. Jedno powiadomienie na niedobór; po uzupełnieniu uzbraja się ponownie.",
"toggleAria": "Przełącz alerty niskiego zapasu",
"statusOn": "Aktywne",
"statusOff": "Wyłączone",
"daysLabel": "Powiadom poniżej (dni)",
"daysAria": "Wybierz próg zapasu w dniach",
"savedToast": "Próg zapasu zapisany.",
"enabledToast": "Alerty niskiego zapasu włączone.",
"disabledToast": "Alerty niskiego zapasu wyłączone.",
"saveError": "Zapis nieudany. Spróbuj ponownie."
},
"eventMedicationReminder": "Przypomnienie o leku",
"eventMedicationReminderDesc": "Wysyłane o każdej zaplanowanej godzinie dawki.",
"eventMeasurementAnomaly": "Nietypowy pomiar",
@@ -5320,7 +5333,9 @@
"eventCyclePeriodConfirm": "Czy okres się zaczął?",
"eventCyclePeriodConfirmDesc": "Delikatne przypomnienie w dniu przewidywanego początku lub później, gdy nie zarejestrowano okresu.",
"eventCycleFertileSoon": "Zbliża się okno płodności",
"eventCycleFertileSoonDesc": "Wysyłane kilka dni przed szacowanym oknem płodności, gdy Twoim celem jest poczęcie."
"eventCycleFertileSoonDesc": "Wysyłane kilka dni przed szacowanym oknem płodności, gdy Twoim celem jest poczęcie.",
"eventMedicationLowStock": "Niski zapas leku",
"eventMedicationLowStockDesc": "Gdy śledzony zapas leku wystarcza na mniej dni niż twój próg zapasu"
},
"i18n": {
"maintainershipBanner": {
@@ -5483,6 +5498,11 @@
"buttonSnoozeThreeHours": "🕐 3h",
"buttonSkip": "⏭ Pomiń"
},
"lowStockReminders": {
"title": "Niski zapas: {medName}",
"body": "{medName}: pozostało {units} jednostek — wystarczy na około {days} dni.",
"bodyToday": "{medName}: pozostało {units} jednostek — mniej niż jeden dzień zapasu."
},
"moodReminders": {
"dailyTitle": "Zapisz nastrój",
"dailyBody": "Jak się dzisiaj czujesz?"
@@ -0,0 +1,11 @@
-- v1.16.11 — low-stock notification stamp (medications).
--
-- The daily low-stock pass notifies once per threshold crossing:
-- `low_stock_notified_at` records the dispatch instant,
-- `low_stock_notified_threshold_days` the user threshold (days of
-- remaining runway) it was written against. The pass clears both when
-- the projected runway rises back to / above the threshold (refill),
-- and treats a differing stamped threshold as re-armed. NULL = armed.
ALTER TABLE "medications" ADD COLUMN "low_stock_notified_at" TIMESTAMP(3);
ALTER TABLE "medications" ADD COLUMN "low_stock_notified_threshold_days" INTEGER;
+9
View File
@@ -1337,6 +1337,15 @@ model Medication {
/// under `http://www.nlm.nih.gov/research/umls/rxnorm` alongside any
/// ATC code, never instead of the free-text `.text` anchor.
rxNormCode String? @map("rxnorm_code")
/// v1.16.11 — low-stock notification stamp. Set when the daily
/// low-stock pass dispatched a notification for the current
/// threshold crossing; cleared (re-armed) when the projected runway
/// rises back to / above the user's threshold (refill). NULL = armed.
lowStockNotifiedAt DateTime? @map("low_stock_notified_at")
/// v1.16.11 — the user threshold (days) the stamp was written
/// against. A changed threshold re-arms the medication even while
/// the runway stays low, so the user hears about the new crossing.
lowStockNotifiedThresholdDays Int? @map("low_stock_notified_threshold_days")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@@ -7,7 +7,7 @@ vi.mock("@/lib/db", () => ({
update: vi.fn(),
},
},
toJson: <T,>(v: T) => v,
toJson: <T>(v: T) => v,
}));
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
@@ -88,10 +88,20 @@ describe("GET /api/auth/me/notification-prefs", () => {
data: { medication: { clientManaged: boolean } };
};
expect(env.data).toEqual({
medication: { clientManaged: false, deliveryDefault: "server" },
medication: {
clientManaged: false,
deliveryDefault: "server",
lowStockRunwayDays: 7,
},
mood: { reminderHour: 22 },
cycle: { clientManaged: false },
coach: { nudgesEnabled: true, nudgeMedication: true, nudgeVitals: true, nudgeRoutine: true, nudgeFrequency: "weekly" },
coach: {
nudgesEnabled: true,
nudgeMedication: true,
nudgeVitals: true,
nudgeRoutine: true,
nudgeFrequency: "weekly",
},
});
});
@@ -109,10 +119,20 @@ describe("GET /api/auth/me/notification-prefs", () => {
data: { medication: { clientManaged: boolean } };
};
expect(env.data).toEqual({
medication: { clientManaged: true, deliveryDefault: "server" },
medication: {
clientManaged: true,
deliveryDefault: "server",
lowStockRunwayDays: 7,
},
mood: { reminderHour: 22 },
cycle: { clientManaged: false },
coach: { nudgesEnabled: true, nudgeMedication: true, nudgeVitals: true, nudgeRoutine: true, nudgeFrequency: "weekly" },
coach: {
nudgesEnabled: true,
nudgeMedication: true,
nudgeVitals: true,
nudgeRoutine: true,
nudgeFrequency: "weekly",
},
});
});
@@ -132,10 +152,20 @@ describe("GET /api/auth/me/notification-prefs", () => {
data: { medication: { clientManaged: boolean } };
};
expect(env.data).toEqual({
medication: { clientManaged: false, deliveryDefault: "server" },
medication: {
clientManaged: false,
deliveryDefault: "server",
lowStockRunwayDays: 7,
},
mood: { reminderHour: 22 },
cycle: { clientManaged: false },
coach: { nudgesEnabled: true, nudgeMedication: true, nudgeVitals: true, nudgeRoutine: true, nudgeFrequency: "weekly" },
coach: {
nudgesEnabled: true,
nudgeMedication: true,
nudgeVitals: true,
nudgeRoutine: true,
nudgeFrequency: "weekly",
},
});
});
});
@@ -166,20 +196,40 @@ describe("PATCH /api/auth/me/notification-prefs", () => {
data: { medication: { clientManaged: boolean } };
};
expect(env.data).toEqual({
medication: { clientManaged: true, deliveryDefault: "server" },
medication: {
clientManaged: true,
deliveryDefault: "server",
lowStockRunwayDays: 7,
},
mood: { reminderHour: 22 },
cycle: { clientManaged: false },
coach: { nudgesEnabled: true, nudgeMedication: true, nudgeVitals: true, nudgeRoutine: true, nudgeFrequency: "weekly" },
coach: {
nudgesEnabled: true,
nudgeMedication: true,
nudgeVitals: true,
nudgeRoutine: true,
nudgeFrequency: "weekly",
},
});
expect(prisma.user.update).toHaveBeenCalledWith({
where: { id: "user-1" },
data: {
notificationPrefs: {
medication: { clientManaged: true, deliveryDefault: "server" },
medication: {
clientManaged: true,
deliveryDefault: "server",
lowStockRunwayDays: 7,
},
mood: { reminderHour: 22 },
cycle: { clientManaged: false },
coach: { nudgesEnabled: true, nudgeMedication: true, nudgeVitals: true, nudgeRoutine: true, nudgeFrequency: "weekly" },
coach: {
nudgesEnabled: true,
nudgeMedication: true,
nudgeVitals: true,
nudgeRoutine: true,
nudgeFrequency: "weekly",
},
},
},
});
@@ -190,16 +240,36 @@ describe("PATCH /api/auth/me/notification-prefs", () => {
userId: "user-1",
details: expect.objectContaining({
previous: {
medication: { clientManaged: false, deliveryDefault: "server" },
medication: {
clientManaged: false,
deliveryDefault: "server",
lowStockRunwayDays: 7,
},
mood: { reminderHour: 22 },
cycle: { clientManaged: false },
coach: { nudgesEnabled: true, nudgeMedication: true, nudgeVitals: true, nudgeRoutine: true, nudgeFrequency: "weekly" },
coach: {
nudgesEnabled: true,
nudgeMedication: true,
nudgeVitals: true,
nudgeRoutine: true,
nudgeFrequency: "weekly",
},
},
next: {
medication: { clientManaged: true, deliveryDefault: "server" },
medication: {
clientManaged: true,
deliveryDefault: "server",
lowStockRunwayDays: 7,
},
mood: { reminderHour: 22 },
cycle: { clientManaged: false },
coach: { nudgesEnabled: true, nudgeMedication: true, nudgeVitals: true, nudgeRoutine: true, nudgeFrequency: "weekly" },
coach: {
nudgesEnabled: true,
nudgeMedication: true,
nudgeVitals: true,
nudgeRoutine: true,
nudgeFrequency: "weekly",
},
},
changed: ["medication"],
}),
@@ -210,14 +280,11 @@ describe("PATCH /api/auth/me/notification-prefs", () => {
it("rejects malformed JSON with 422", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
const req = new Request(
"http://localhost/api/auth/me/notification-prefs",
{
method: "PATCH",
body: "{ not valid json",
headers: { "Content-Type": "application/json" },
},
);
const req = new Request("http://localhost/api/auth/me/notification-prefs", {
method: "PATCH",
body: "{ not valid json",
headers: { "Content-Type": "application/json" },
});
const res = await (PATCH as (r: Request) => Promise<Response>)(req);
expect(res.status).toBe(422);
@@ -258,10 +325,20 @@ describe("PATCH /api/auth/me/notification-prefs", () => {
where: { id: "user-1" },
data: {
notificationPrefs: {
medication: { clientManaged: true, deliveryDefault: "server" },
medication: {
clientManaged: true,
deliveryDefault: "server",
lowStockRunwayDays: 7,
},
mood: { reminderHour: 22 },
cycle: { clientManaged: false },
coach: { nudgesEnabled: true, nudgeMedication: true, nudgeVitals: true, nudgeRoutine: true, nudgeFrequency: "weekly" },
coach: {
nudgesEnabled: true,
nudgeMedication: true,
nudgeVitals: true,
nudgeRoutine: true,
nudgeFrequency: "weekly",
},
},
},
});
@@ -276,28 +353,46 @@ describe("PATCH /api/auth/me/notification-prefs", () => {
} as never);
vi.mocked(prisma.user.update).mockResolvedValue({} as never);
const res = await (PATCH as (r: Request) => Promise<Response>)(
mkPatch({}),
);
const res = await (PATCH as (r: Request) => Promise<Response>)(mkPatch({}));
expect(res.status).toBe(200);
const env = (await res.json()) as {
data: { medication: { clientManaged: boolean } };
};
expect(env.data).toEqual({
medication: { clientManaged: true, deliveryDefault: "server" },
medication: {
clientManaged: true,
deliveryDefault: "server",
lowStockRunwayDays: 7,
},
mood: { reminderHour: 22 },
cycle: { clientManaged: false },
coach: { nudgesEnabled: true, nudgeMedication: true, nudgeVitals: true, nudgeRoutine: true, nudgeFrequency: "weekly" },
coach: {
nudgesEnabled: true,
nudgeMedication: true,
nudgeVitals: true,
nudgeRoutine: true,
nudgeFrequency: "weekly",
},
});
expect(prisma.user.update).toHaveBeenCalledWith({
where: { id: "user-1" },
data: {
notificationPrefs: {
medication: { clientManaged: true, deliveryDefault: "server" },
medication: {
clientManaged: true,
deliveryDefault: "server",
lowStockRunwayDays: 7,
},
mood: { reminderHour: 22 },
cycle: { clientManaged: false },
coach: { nudgesEnabled: true, nudgeMedication: true, nudgeVitals: true, nudgeRoutine: true, nudgeFrequency: "weekly" },
coach: {
nudgesEnabled: true,
nudgeMedication: true,
nudgeVitals: true,
nudgeRoutine: true,
nudgeFrequency: "weekly",
},
},
},
});
@@ -323,10 +418,20 @@ describe("PATCH /api/auth/me/notification-prefs", () => {
where: { id: "user-1" },
data: {
notificationPrefs: {
medication: { clientManaged: false, deliveryDefault: "server" },
medication: {
clientManaged: false,
deliveryDefault: "server",
lowStockRunwayDays: 7,
},
mood: { reminderHour: 9 },
cycle: { clientManaged: false },
coach: { nudgesEnabled: true, nudgeMedication: true, nudgeVitals: true, nudgeRoutine: true, nudgeFrequency: "weekly" },
coach: {
nudgesEnabled: true,
nudgeMedication: true,
nudgeVitals: true,
nudgeRoutine: true,
nudgeFrequency: "weekly",
},
},
},
});
@@ -342,6 +447,65 @@ describe("PATCH /api/auth/me/notification-prefs", () => {
expect(prisma.user.update).not.toHaveBeenCalled();
});
it("v1.16.11 — round-trips a custom lowStockRunwayDays", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
notificationPrefs: null,
} as never);
vi.mocked(prisma.user.update).mockResolvedValue({} as never);
const res = await (PATCH as (r: Request) => Promise<Response>)(
mkPatch({ medication: { lowStockRunwayDays: 14 } }),
);
expect(res.status).toBe(200);
const env = (await res.json()) as {
data: { medication: { lowStockRunwayDays: number | null } };
};
expect(env.data.medication.lowStockRunwayDays).toBe(14);
expect(prisma.user.update).toHaveBeenCalledWith({
where: { id: "user-1" },
data: {
notificationPrefs: expect.objectContaining({
medication: {
clientManaged: false,
deliveryDefault: "server",
lowStockRunwayDays: 14,
},
}),
},
});
});
it("v1.16.11 — persists the explicit null (alert off)", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
notificationPrefs: { medication: { lowStockRunwayDays: 14 } },
} as never);
vi.mocked(prisma.user.update).mockResolvedValue({} as never);
const res = await (PATCH as (r: Request) => Promise<Response>)(
mkPatch({ medication: { lowStockRunwayDays: null } }),
);
expect(res.status).toBe(200);
const env = (await res.json()) as {
data: { medication: { lowStockRunwayDays: number | null } };
};
expect(env.data.medication.lowStockRunwayDays).toBe(null);
});
it("v1.16.11 — rejects a lowStockRunwayDays outside 1..60 with 422", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
for (const value of [0, 61]) {
const res = await (PATCH as (r: Request) => Promise<Response>)(
mkPatch({ medication: { lowStockRunwayDays: value } }),
);
expect(res.status).toBe(422);
}
expect(prisma.user.update).not.toHaveBeenCalled();
});
it("returns 429 when the per-user rate-limit fires", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(checkRateLimit).mockResolvedValueOnce({
@@ -0,0 +1,116 @@
/**
* v1.16.11 — reminder-thresholds GET: the operator-level late / missed
* minutes ride along unchanged; the new `lowStockRunwayDays` field is
* PER-USER (from `notificationPrefs.medication`), so the endpoint
* reflects whatever the notification-prefs PATCH persisted — the read
* half of the settings round-trip.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("@/lib/db", () => ({
prisma: {
user: {
findUnique: vi.fn(),
},
appSettings: {
findUnique: vi.fn(),
},
},
}));
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("next/headers", () => ({
headers: vi.fn(async () => ({ get: () => null })),
cookies: vi.fn(async () => ({
get: () => undefined,
set: () => {},
delete: () => {},
})),
}));
import { GET } from "../route";
import { prisma } from "@/lib/db";
import { getSession } from "@/lib/auth/session";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
user: { id: "user-1", username: "testuser", role: "USER" as const },
};
function mkGet(): Request {
return new Request("http://localhost/api/settings/reminder-thresholds");
}
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue({
reminderLateMinutes: 120,
reminderMissedMinutes: 240,
} as never);
});
describe("GET /api/settings/reminder-thresholds", () => {
it("rejects an unauthenticated request with 401", async () => {
vi.mocked(getSession).mockResolvedValue(null);
const res = await (GET as (r: Request) => Promise<Response>)(mkGet());
expect(res.status).toBe(401);
});
it("returns the default low-stock threshold (7) for a fresh user", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
notificationPrefs: null,
} as never);
const res = await (GET as (r: Request) => Promise<Response>)(mkGet());
expect(res.status).toBe(200);
const env = (await res.json()) as {
data: {
lateMinutes: number;
missedMinutes: number;
lowStockRunwayDays: number | null;
};
};
expect(env.data).toEqual({
lateMinutes: 120,
missedMinutes: 240,
lowStockRunwayDays: 7,
});
});
it("reflects the per-user threshold persisted by the prefs PATCH", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
notificationPrefs: { medication: { lowStockRunwayDays: 21 } },
} as never);
const res = await (GET as (r: Request) => Promise<Response>)(mkGet());
expect(res.status).toBe(200);
const env = (await res.json()) as {
data: { lowStockRunwayDays: number | null };
};
expect(env.data.lowStockRunwayDays).toBe(21);
});
it("reflects the OFF state as null", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
notificationPrefs: { medication: { lowStockRunwayDays: null } },
} as never);
const res = await (GET as (r: Request) => Promise<Response>)(mkGet());
expect(res.status).toBe(200);
const env = (await res.json()) as {
data: { lowStockRunwayDays: number | null };
};
expect(env.data.lowStockRunwayDays).toBe(null);
});
});
@@ -2,15 +2,31 @@ import { apiSuccess } from "@/lib/api-response";
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";
export const dynamic = "force-dynamic";
export const GET = apiHandler(async () => {
await requireAuth();
const { user } = await requireAuth();
const thresholds = await getReminderThresholds();
// v1.16.11 — the low-stock runway threshold rides along so every
// threshold consumer reads one endpoint. Unlike `lateMinutes` /
// `missedMinutes` (operator-level singleton) this one is PER-USER:
// it lives in `notificationPrefs.medication.lowStockRunwayDays`
// (1–60 days, `null` = alert off, default 7) and is written through
// the established `PATCH /api/auth/me/notification-prefs` path.
const row = await prisma.user.findUnique({
where: { id: user.id },
select: { notificationPrefs: true },
});
const lowStockRunwayDays = resolveLowStockRunwayDays(
row?.notificationPrefs ?? null,
);
annotate({ action: { name: "settings.reminder-thresholds.get" } });
return apiSuccess(thresholds);
return apiSuccess({ ...thresholds, lowStockRunwayDays });
});
+212
View File
@@ -0,0 +1,212 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { PackageOpen } from "lucide-react";
import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { apiFetchRaw, apiGet } from "@/lib/api/api-fetch";
/**
* v1.16.11 — medication low-stock alert threshold (Settings →
* Notifications). The threshold is REMAINING RUNWAY: notify when a
* tracked medication's supply covers fewer than N days (1–60, default
* 7); the switch off persists `null` (alert off). Reads the same
* per-user prefs blob the mood / coach cards use and writes through
* `PATCH /api/auth/me/notification-prefs`; the value also surfaces on
* `GET /api/settings/reminder-thresholds` for API consumers, so the
* save invalidates both keys.
*/
interface NotificationPrefsShape {
medication: { lowStockRunwayDays: number | null };
}
const DEFAULT_DAYS = 7;
const MIN_DAYS = 1;
const MAX_DAYS = 60;
export function LowStockCard({
isAuthenticated,
}: {
isAuthenticated: boolean;
}) {
const { t } = useTranslations();
const queryClient = useQueryClient();
const [optimistic, setOptimistic] = useState<number | null | undefined>(
undefined,
);
const [draft, setDraft] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
const [msg, setMsg] = useState<string | null>(null);
const [msgType, setMsgType] = useState<"success" | "error" | null>(null);
// Auto-clear the inline status line 3 s after the mutation settles —
// the MoodReminderCard pattern, including the unmount / re-toggle
// timer hygiene.
const clearTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
return () => {
if (clearTimerRef.current !== null) {
clearTimeout(clearTimerRef.current);
clearTimerRef.current = null;
}
};
}, []);
function scheduleClear() {
if (clearTimerRef.current !== null) {
clearTimeout(clearTimerRef.current);
}
clearTimerRef.current = setTimeout(() => {
clearTimerRef.current = null;
setMsg(null);
setMsgType(null);
}, 3000);
}
const { data: prefs } = useQuery({
queryKey: queryKeys.authNotificationPrefs(),
queryFn: async () => {
return apiGet<NotificationPrefsShape>("/api/auth/me/notification-prefs");
},
enabled: isAuthenticated,
});
const persisted =
prefs?.medication?.lowStockRunwayDays === undefined
? DEFAULT_DAYS
: prefs.medication.lowStockRunwayDays;
const current = optimistic === undefined ? persisted : optimistic;
const enabled = current !== null;
const days = current ?? DEFAULT_DAYS;
async function persist(next: number | null, successMsg: string) {
setOptimistic(next);
setSaving(true);
setMsg(null);
setMsgType(null);
if (clearTimerRef.current !== null) {
clearTimeout(clearTimerRef.current);
clearTimerRef.current = null;
}
const res = await apiFetchRaw("/api/auth/me/notification-prefs", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ medication: { lowStockRunwayDays: next } }),
});
if (res.ok) {
setMsg(successMsg);
setMsgType("success");
await Promise.all([
queryClient.invalidateQueries({
queryKey: queryKeys.authNotificationPrefs(),
}),
queryClient.invalidateQueries({
queryKey: queryKeys.settingsReminderThresholds(),
}),
]);
setOptimistic(undefined);
} else {
setOptimistic(undefined);
setMsg(t("notifications.lowStock.saveError"));
setMsgType("error");
}
scheduleClear();
setSaving(false);
}
async function handleToggle(next: boolean) {
await persist(
next ? days : null,
next
? t("notifications.lowStock.enabledToast")
: t("notifications.lowStock.disabledToast"),
);
}
function commitDraft() {
if (draft === null) return;
const parsed = Number(draft);
setDraft(null);
if (!Number.isInteger(parsed)) return;
const clamped = Math.min(MAX_DAYS, Math.max(MIN_DAYS, parsed));
if (clamped === days) return;
void persist(clamped, t("notifications.lowStock.savedToast"));
}
return (
<section
aria-labelledby="settings-low-stock-title"
className="bg-card border-border rounded-xl border p-4 sm:p-6"
>
<SettingsCardHeader
icon={PackageOpen}
title={t("notifications.lowStock.title")}
titleId="settings-low-stock-title"
description={t("notifications.lowStock.description")}
status={
<label className="flex min-h-11 items-center gap-3">
<Switch
checked={enabled}
onCheckedChange={handleToggle}
disabled={!isAuthenticated || saving}
aria-label={t("notifications.lowStock.toggleAria")}
/>
<span className="text-muted-foreground text-xs">
{enabled
? t("notifications.lowStock.statusOn")
: t("notifications.lowStock.statusOff")}
</span>
</label>
}
/>
{enabled && (
<div className="mt-4 flex min-h-11 items-center gap-3 pl-7">
<label htmlFor="low-stock-days" className="text-sm font-medium">
{t("notifications.lowStock.daysLabel")}
</label>
<Input
id="low-stock-days"
type="number"
inputMode="numeric"
min={MIN_DAYS}
max={MAX_DAYS}
className="w-24"
value={draft ?? String(days)}
disabled={!isAuthenticated || saving}
onChange={(e) => setDraft(e.target.value)}
onBlur={commitDraft}
onKeyDown={(e) => {
if (e.key === "Enter") commitDraft();
}}
aria-label={t("notifications.lowStock.daysAria")}
autoComplete="off"
data-lpignore="true"
data-1p-ignore="true"
/>
</div>
)}
{msg && (
<p
role="status"
aria-live="polite"
className={
msgType === "error"
? "text-destructive mt-3 pl-7 text-sm"
: "text-muted-foreground mt-3 pl-7 text-sm"
}
>
{msg}
</p>
)}
</section>
);
}
@@ -8,6 +8,7 @@ import { useAuth } from "@/hooks/use-auth";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { CoachNudgeCard } from "@/components/settings/coach-nudge-card";
import { LowStockCard } from "@/components/settings/low-stock-card";
import { MoodReminderCard } from "@/components/settings/mood-reminder-card";
import { NotificationStatusCard } from "@/components/settings/notification-status-card";
import { NtfyCard } from "@/components/settings/ntfy-card";
@@ -107,6 +108,10 @@ export function NotificationsSection() {
<div id="mood-reminder" className="scroll-mt-28">
<MoodReminderCard isAuthenticated={isAuthenticated} />
</div>
{/* v1.16.11 — medication low-stock runway threshold. */}
<div id="low-stock" className="scroll-mt-28">
<LowStockCard isAuthenticated={isAuthenticated} />
</div>
{/* v1.15.20 — proactive Coach nudge opt-out. */}
<div id="coach-nudge" className="scroll-mt-28">
<CoachNudgeCard isAuthenticated={isAuthenticated} />
@@ -0,0 +1,428 @@
/**
* v1.16.11 — medication low-stock engine: runway-threshold boundary,
* once-per-crossing dedupe, re-arm on refill / threshold change, the
* OFF state, and the schedule-less (no runway derivable) case. Pure
* helpers are pinned without a DB; the tick runs against a prisma
* mock. Fixtures use relative PAST instants only.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { PrismaClient } from "@/generated/prisma/client";
import {
buildLowStockPayload,
decideLowStockAction,
evaluateMedicationRunway,
runMedicationLowStockTick,
} from "../medication-low-stock";
const MS_PER_DAY = 24 * 60 * 60 * 1000;
const now = new Date(Date.now() - 60_000);
const daysAgo = (n: number) => new Date(now.getTime() - n * MS_PER_DAY);
/** One dose per day — `timesOfDay` carries a single entry. */
const dailySchedule = {
windowStart: "08:00",
daysOfWeek: null,
timesOfDay: ["08:00"],
rrule: null,
rollingIntervalDays: null,
};
/** Weekly GLP-1 cadence — the special case rides the general math. */
const weeklySchedule = {
windowStart: "08:00",
daysOfWeek: "6",
timesOfDay: ["08:00"],
rrule: null,
rollingIntervalDays: null,
};
function item(unitsRemaining: number, state = "ACTIVE" as const) {
return { state, unitsTotal: 30, unitsRemaining };
}
describe("evaluateMedicationRunway", () => {
it("derives the runway from available units ÷ daily consumption", () => {
const out = evaluateMedicationRunway([item(6)], 1, [dailySchedule]);
expect(out).toEqual({
runwayDays: 6,
dosesRemaining: 6,
unitsRemaining: 6,
});
});
it("treats a weekly GLP-1 cadence through the same math (4 doses ≈ 28 days)", () => {
const out = evaluateMedicationRunway([item(4)], 1, [weeklySchedule]);
expect(out.runwayDays).toBe(28);
});
it("honours unitsPerDose and pools only available containers", () => {
// 2 units per dose, 4 usable + 10 expired units → 2 doses.
const out = evaluateMedicationRunway(
[item(4), { state: "EXPIRED", unitsTotal: 30, unitsRemaining: 10 }],
2,
[dailySchedule],
);
expect(out.dosesRemaining).toBe(2);
expect(out.runwayDays).toBe(2);
});
it("returns runway 0 for an exhausted supply with a consuming schedule", () => {
const out = evaluateMedicationRunway([item(0)], 1, [dailySchedule]);
expect(out.runwayDays).toBe(0);
});
it("derives NO runway for a schedule-less medication", () => {
expect(evaluateMedicationRunway([item(2)], 1, []).runwayDays).toBe(null);
});
});
describe("decideLowStockAction — boundary / dedupe / re-arm", () => {
const armed = { notifiedAt: null, notifiedThresholdDays: null };
it("notifies just below the threshold", () => {
expect(
decideLowStockAction({ runwayDays: 6, thresholdDays: 7, ...armed }),
).toBe("notify");
});
it("stays silent AT the threshold (strictly-below contract)", () => {
expect(
decideLowStockAction({ runwayDays: 7, thresholdDays: 7, ...armed }),
).toBe("skip_above_threshold");
});
it("stays silent above the threshold", () => {
expect(
decideLowStockAction({ runwayDays: 8, thresholdDays: 7, ...armed }),
).toBe("skip_above_threshold");
});
it("does not repeat while still low (stamped crossing)", () => {
expect(
decideLowStockAction({
runwayDays: 5,
thresholdDays: 7,
notifiedAt: daysAgo(1),
notifiedThresholdDays: 7,
}),
).toBe("skip_already_notified");
});
it("re-arms when the runway rises back above the threshold (refill)", () => {
expect(
decideLowStockAction({
runwayDays: 30,
thresholdDays: 7,
notifiedAt: daysAgo(2),
notifiedThresholdDays: 7,
}),
).toBe("rearm");
});
it("a changed threshold re-arms even while the runway stays low", () => {
expect(
decideLowStockAction({
runwayDays: 10,
thresholdDays: 14,
notifiedAt: daysAgo(3),
notifiedThresholdDays: 7,
}),
).toBe("notify");
});
it("never notifies when no runway is derivable", () => {
expect(
decideLowStockAction({ runwayDays: null, thresholdDays: 7, ...armed }),
).toBe("skip_no_runway");
});
});
describe("buildLowStockPayload", () => {
it("names the medication and the remaining days / units (en)", () => {
const out = buildLowStockPayload("en", "Metformin", 5, 10);
expect(out.title).toContain("Metformin");
expect(out.body).toContain("Metformin");
expect(out.body).toContain("5");
expect(out.body).toContain("10");
});
it("uses the du-Form German copy", () => {
const out = buildLowStockPayload("de", "Metformin", 5, 10);
expect(out.body).toContain("Dein Vorrat an Metformin");
expect(out.body).toContain("5");
});
it("switches to the depleted line at runway 0", () => {
const out = buildLowStockPayload("en", "Metformin", 0, 1);
expect(out.body).toContain("less than one day");
});
it("falls back to the app default locale for unknown locales", () => {
const out = buildLowStockPayload("xx", "Metformin", 5, 10);
expect(out.title).toBe("Low supply: Metformin");
});
});
describe("runMedicationLowStockTick", () => {
function medRow(overrides: Partial<Record<string, unknown>> = {}) {
return {
id: "med-1",
name: "Metformin",
unitsPerDose: 1,
lowStockNotifiedAt: null,
lowStockNotifiedThresholdDays: null,
inventoryItems: [item(5)],
schedules: [dailySchedule],
...overrides,
};
}
function prismaMock(overrides: {
users?: unknown[];
medications?: unknown[];
}) {
return {
user: {
findMany: vi.fn(async () => overrides.users ?? []),
},
medication: {
findMany: vi.fn(async () => overrides.medications ?? []),
update: vi.fn(async () => ({})),
},
};
}
const userRow = (notificationPrefs: unknown = null) => ({
id: "user-1",
locale: "en",
notificationPrefs,
});
const okDispatch = () =>
vi.fn<
(payload: unknown) => Promise<{
dispatched: boolean;
channelsAttempted: number;
channelsSucceeded: number;
}>
>(async () => ({
dispatched: true,
channelsAttempted: 1,
channelsSucceeded: 1,
}));
beforeEach(() => {
vi.clearAllMocks();
});
it("notifies a below-threshold medication and stamps the crossing", async () => {
const dispatch = okDispatch();
const prisma = prismaMock({
users: [userRow()],
medications: [medRow()],
});
const summary = await runMedicationLowStockTick(
prisma as unknown as PrismaClient,
now,
{ dispatch },
);
expect(summary.notified).toBe(1);
expect(dispatch).toHaveBeenCalledWith(
expect.objectContaining({
eventType: "MEDICATION_LOW_STOCK",
userId: "user-1",
metadata: expect.objectContaining({
medicationId: "med-1",
runwayDays: 5,
thresholdDays: 7,
unitsRemaining: 5,
url: "/medications/med-1",
}),
}),
);
expect(prisma.medication.update).toHaveBeenCalledWith({
where: { id: "med-1" },
data: {
lowStockNotifiedAt: now,
lowStockNotifiedThresholdDays: 7,
},
});
});
it("does not repeat while still low (stamp present for this threshold)", async () => {
const dispatch = okDispatch();
const prisma = prismaMock({
users: [userRow()],
medications: [
medRow({
lowStockNotifiedAt: daysAgo(1),
lowStockNotifiedThresholdDays: 7,
}),
],
});
const summary = await runMedicationLowStockTick(
prisma as unknown as PrismaClient,
now,
{ dispatch },
);
expect(summary.skippedAlreadyNotified).toBe(1);
expect(summary.notified).toBe(0);
expect(dispatch).not.toHaveBeenCalled();
expect(prisma.medication.update).not.toHaveBeenCalled();
});
it("re-arms after a refill, then notifies again on the next crossing", async () => {
const dispatch = okDispatch();
const stamped = {
lowStockNotifiedAt: daysAgo(2),
lowStockNotifiedThresholdDays: 7,
};
// Tick 1 — refilled (30 units, runway 30 ≥ 7): the stamp clears.
const prismaRefilled = prismaMock({
users: [userRow()],
medications: [medRow({ ...stamped, inventoryItems: [item(30)] })],
});
const refillSummary = await runMedicationLowStockTick(
prismaRefilled as unknown as PrismaClient,
now,
{ dispatch },
);
expect(refillSummary.rearmed).toBe(1);
expect(dispatch).not.toHaveBeenCalled();
expect(prismaRefilled.medication.update).toHaveBeenCalledWith({
where: { id: "med-1" },
data: { lowStockNotifiedAt: null, lowStockNotifiedThresholdDays: null },
});
// Tick 2 — consumed back below the threshold with a cleared stamp:
// the next crossing notifies again.
const prismaLow = prismaMock({
users: [userRow()],
medications: [medRow({ inventoryItems: [item(3)] })],
});
const lowSummary = await runMedicationLowStockTick(
prismaLow as unknown as PrismaClient,
now,
{ dispatch },
);
expect(lowSummary.notified).toBe(1);
expect(dispatch).toHaveBeenCalledTimes(1);
});
it("a changed threshold re-arms a still-low stamped medication", async () => {
const dispatch = okDispatch();
const prisma = prismaMock({
users: [userRow({ medication: { lowStockRunwayDays: 14 } })],
medications: [
medRow({
inventoryItems: [item(10)],
lowStockNotifiedAt: daysAgo(3),
lowStockNotifiedThresholdDays: 7,
}),
],
});
const summary = await runMedicationLowStockTick(
prisma as unknown as PrismaClient,
now,
{ dispatch },
);
expect(summary.notified).toBe(1);
expect(dispatch).toHaveBeenCalledWith(
expect.objectContaining({
metadata: expect.objectContaining({ thresholdDays: 14 }),
}),
);
});
it("threshold OFF (null) skips the user without reading medications", async () => {
const dispatch = okDispatch();
const prisma = prismaMock({
users: [userRow({ medication: { lowStockRunwayDays: null } })],
medications: [medRow()],
});
const summary = await runMedicationLowStockTick(
prisma as unknown as PrismaClient,
now,
{ dispatch },
);
expect(summary.skippedThresholdOff).toBe(1);
expect(prisma.medication.findMany).not.toHaveBeenCalled();
expect(dispatch).not.toHaveBeenCalled();
});
it("a schedule-less medication never notifies (no runway derivable)", async () => {
const dispatch = okDispatch();
const prisma = prismaMock({
users: [userRow()],
medications: [medRow({ schedules: [], inventoryItems: [item(1)] })],
});
const summary = await runMedicationLowStockTick(
prisma as unknown as PrismaClient,
now,
{ dispatch },
);
expect(summary.skippedNoRunway).toBe(1);
expect(summary.notified).toBe(0);
expect(dispatch).not.toHaveBeenCalled();
expect(prisma.medication.update).not.toHaveBeenCalled();
});
it("an exhausted supply (runway 0) notifies with the depleted copy", async () => {
const dispatch = okDispatch();
const prisma = prismaMock({
users: [userRow()],
medications: [medRow({ inventoryItems: [item(0)] })],
});
const summary = await runMedicationLowStockTick(
prisma as unknown as PrismaClient,
now,
{ dispatch },
);
expect(summary.notified).toBe(1);
const payload = dispatch.mock.calls[0]?.[0] as unknown as {
message: string;
};
expect(payload.message).toContain("less than one day");
});
it("leaves the stamp clear when no channel succeeded (retry tomorrow)", async () => {
const dispatch = vi.fn<
(payload: unknown) => Promise<{
dispatched: boolean;
channelsAttempted: number;
channelsSucceeded: number;
}>
>(async () => ({
dispatched: false,
channelsAttempted: 0,
channelsSucceeded: 0,
}));
const prisma = prismaMock({
users: [userRow()],
medications: [medRow()],
});
const summary = await runMedicationLowStockTick(
prisma as unknown as PrismaClient,
now,
{ dispatch },
);
expect(summary.skippedNoChannel).toBe(1);
expect(summary.notified).toBe(0);
expect(prisma.medication.update).not.toHaveBeenCalled();
});
});
+402
View File
@@ -0,0 +1,402 @@
/**
* v1.16.11 — medication low-stock notifications (one engine, every
* tracked medication).
*
* Generalises the GLP-1-only low-stock surface (the fixed 4-dose
* `lowStock` flag the GLP-1 details endpoint computes for its card)
* into one daily pass over EVERY medication with tracked inventory.
* The GLP-1 case needs nothing special here: its weekly schedule flows
* through the same runway math as any other cadence — the card flag
* stays as the locked iOS read-model, the notification is this engine.
*
* Trigger: a medication qualifies when it has tracked inventory (at
* least one container ever registered — the list payload's
* `stockUnitsRemaining` non-null predicate) AND its projected runway
* in days falls strictly below the user's threshold. Runway reuses the
* shared math verbatim:
* - available units: `summariseSupply` (ACTIVE / IN_USE with units
* left; EXPIRED visible but never available),
* - daily consumption: `estimateDailyDoseCount` over the schedules
* (the detail Übersicht's `estimateRunwayDays` source),
* so the push can never disagree with the detail page's "lasts about
* N more days" line. A medication whose schedules derive no daily
* consumption (none, or zero times) has NO runway and never notifies.
*
* Threshold: per-user `notificationPrefs.medication.lowStockRunwayDays`
* (1–60 days, default 7, `null` = off) — surfaced on
* `GET /api/settings/reminder-thresholds`, written through
* `PATCH /api/auth/me/notification-prefs`.
*
* Anti-spam: notify ONCE per threshold crossing. The stamp lives on
* the medication row (`lowStockNotifiedAt` +
* `lowStockNotifiedThresholdDays`); the pass clears it (re-arms) when
* the runway rises back to / above the threshold (refill), and a
* stamped threshold that differs from the current one counts as
* re-armed so a threshold change is heard. A dispatch that reaches no
* channel leaves the stamp clear, so tomorrow's tick retries.
*
* clientManaged is deliberately NOT consulted: that suppression
* contract covers reminders a client can schedule locally
* (MEDICATION_REMINDER dose slots, cycle predictions). Inventory state
* is server-side only — no client can derive it — so this event
* follows the COACH_NUDGE posture (server-only, prefs-gated).
*
* Inactive / paused medications are skipped: nothing consumes their
* stock, so their runway is not falling.
*/
import type { PrismaClient } from "@/generated/prisma/client";
import {
estimateDailyDoseCount,
estimateRunwayDays,
type RunwaySchedule,
} from "@/components/medications/detail/supply-runway";
import {
summariseSupply,
type SupplyItem,
} from "@/lib/medications/inventory/summary";
import { dispatchNotification } from "@/lib/notifications/dispatcher";
import { resolveLowStockRunwayDays } from "@/lib/validations/notification-prefs";
import { getServerTranslator } from "@/lib/i18n/server-translator";
import type { Locale } from "@/lib/i18n/config";
import { defaultLocale, locales } from "@/lib/i18n/config";
import { annotate, getEvent } from "@/lib/logging/context";
/** Pg-boss queue + cron — imported by the reminder worker's bootstrap. */
export const MEDICATION_LOW_STOCK_QUEUE = "medication-low-stock";
/**
* 09:00 Europe/Berlin — a supply alert is an errand prompt ("order a
* refill"), not a night signal; it fires once daily at a time the user
* can act on it.
*/
export const MEDICATION_LOW_STOCK_CRON = "0 9 * * *";
export interface MedicationLowStockSummary {
usersScanned: number;
skippedThresholdOff: number;
medicationsEvaluated: number;
notified: number;
rearmed: number;
skippedAlreadyNotified: number;
skippedAboveThreshold: number;
skippedNoRunway: number;
skippedNoChannel: number;
failed: number;
}
export interface LowStockEvaluation {
/** Whole days the available supply covers; null = no runway derivable. */
runwayDays: number | null;
dosesRemaining: number;
unitsRemaining: number;
}
/**
* Shared runway evaluation: `summariseSupply` availability semantics ÷
* `estimateDailyDoseCount` consumption. Exported for the unit tests.
* Zero available doses with a consuming schedule is runway 0 (below
* every threshold); no consuming schedule is `null` (never notifies).
*/
export function evaluateMedicationRunway(
items: readonly SupplyItem[],
unitsPerDose: number,
schedules: RunwaySchedule[],
): LowStockEvaluation {
const supply = summariseSupply(items, unitsPerDose);
const perDay = estimateDailyDoseCount(schedules);
if (perDay <= 0) {
return {
runwayDays: null,
dosesRemaining: supply.dosesRemaining,
unitsRemaining: supply.unitsRemaining,
};
}
const runwayDays =
supply.dosesRemaining > 0
? (estimateRunwayDays(supply.dosesRemaining, schedules) ?? 0)
: 0;
return {
runwayDays,
dosesRemaining: supply.dosesRemaining,
unitsRemaining: supply.unitsRemaining,
};
}
export type LowStockDecision =
| "notify"
| "rearm"
| "skip_above_threshold"
| "skip_already_notified"
| "skip_no_runway";
/**
* Pure crossing logic — exported for the unit tests so the boundary /
* dedupe / re-arm semantics stay pinned without a DB.
*
* runway < threshold → notify, unless the stamp already records THIS
* threshold's crossing (`skip_already_notified`).
* A stamp written against a DIFFERENT threshold
* counts as re-armed and notifies again.
* runway ≥ threshold → re-arm (clear the stamp) when one is set;
* otherwise nothing to do.
* runway null → no runway derivable (schedule-less) — never
* notifies, stamp untouched.
*/
export function decideLowStockAction(input: {
runwayDays: number | null;
thresholdDays: number;
notifiedAt: Date | null;
notifiedThresholdDays: number | null;
}): LowStockDecision {
if (input.runwayDays === null) return "skip_no_runway";
if (input.runwayDays >= input.thresholdDays) {
return input.notifiedAt !== null ? "rearm" : "skip_above_threshold";
}
if (
input.notifiedAt !== null &&
input.notifiedThresholdDays === input.thresholdDays
) {
return "skip_already_notified";
}
return "notify";
}
function resolveLocale(locale: string | null | undefined): Locale {
return locales.includes(locale as Locale)
? (locale as Locale)
: defaultLocale;
}
/**
* Localised push copy: factual, names the medication and the remaining
* days / units. Runway 0 gets its own line — "about 0 more days" reads
* broken in every locale.
*/
export function buildLowStockPayload(
locale: string | null | undefined,
medName: string,
runwayDays: number,
unitsRemaining: number,
): { title: string; body: string } {
const t = getServerTranslator(resolveLocale(locale)).t;
return {
title: t("lowStockReminders.title", { medName }),
body:
runwayDays >= 1
? t("lowStockReminders.body", {
medName,
days: runwayDays,
units: unitsRemaining,
})
: t("lowStockReminders.bodyToday", {
medName,
units: unitsRemaining,
}),
};
}
/** Pinned wide-event meta shape for `.notified` / `.skipped` entries. */
interface LowStockEventMeta {
user_id: string;
medication_id: string;
runway_days: number | null;
threshold_days: number;
doses_remaining: number;
units_remaining: number;
reason?: "already_notified" | "no_runway" | "no_channel";
}
/**
* Run one medication-low-stock cron tick. Per-user work is wrapped in
* its own try/catch so a single bad row cannot abort the rest of the
* pass (the coach-nudge posture).
*/
export async function runMedicationLowStockTick(
prisma: PrismaClient,
now: Date,
options: {
dispatch?: typeof dispatchNotification;
} = {},
): Promise<MedicationLowStockSummary> {
const dispatchImpl = options.dispatch ?? dispatchNotification;
const summary: MedicationLowStockSummary = {
usersScanned: 0,
skippedThresholdOff: 0,
medicationsEvaluated: 0,
notified: 0,
rearmed: 0,
skippedAlreadyNotified: 0,
skippedAboveThreshold: 0,
skippedNoRunway: 0,
skippedNoChannel: 0,
failed: 0,
};
const notifiedMeta: LowStockEventMeta[] = [];
const skippedMeta: LowStockEventMeta[] = [];
// Only users who own at least one ACTIVE, unpaused medication with
// tracked inventory (any container ever registered) are candidates.
const users = await prisma.user.findMany({
where: {
medications: {
some: { active: true, pausedAt: null, inventoryItems: { some: {} } },
},
},
select: { id: true, locale: true, notificationPrefs: true },
});
for (const user of users) {
summary.usersScanned += 1;
try {
// Gate — per-user threshold. `null` = the alert is off; the
// stamps stay as-is and the threshold-change rule re-arms them
// if the user later re-enables with a different value.
const thresholdDays = resolveLowStockRunwayDays(user.notificationPrefs);
if (thresholdDays === null) {
summary.skippedThresholdOff += 1;
continue;
}
const medications = await prisma.medication.findMany({
where: {
userId: user.id,
active: true,
pausedAt: null,
inventoryItems: { some: {} },
},
select: {
id: true,
name: true,
unitsPerDose: true,
lowStockNotifiedAt: true,
lowStockNotifiedThresholdDays: true,
inventoryItems: {
select: { state: true, unitsTotal: true, unitsRemaining: true },
},
schedules: {
select: {
windowStart: true,
daysOfWeek: true,
timesOfDay: true,
rrule: true,
rollingIntervalDays: true,
},
},
},
});
for (const med of medications) {
summary.medicationsEvaluated += 1;
const evaluation = evaluateMedicationRunway(
med.inventoryItems,
med.unitsPerDose,
med.schedules,
);
const decision = decideLowStockAction({
runwayDays: evaluation.runwayDays,
thresholdDays,
notifiedAt: med.lowStockNotifiedAt,
notifiedThresholdDays: med.lowStockNotifiedThresholdDays,
});
const meta: LowStockEventMeta = {
user_id: user.id,
medication_id: med.id,
runway_days: evaluation.runwayDays,
threshold_days: thresholdDays,
doses_remaining: evaluation.dosesRemaining,
units_remaining: evaluation.unitsRemaining,
};
switch (decision) {
case "skip_no_runway":
summary.skippedNoRunway += 1;
skippedMeta.push({ ...meta, reason: "no_runway" });
break;
case "skip_above_threshold":
summary.skippedAboveThreshold += 1;
break;
case "skip_already_notified":
summary.skippedAlreadyNotified += 1;
skippedMeta.push({ ...meta, reason: "already_notified" });
break;
case "rearm":
await prisma.medication.update({
where: { id: med.id },
data: {
lowStockNotifiedAt: null,
lowStockNotifiedThresholdDays: null,
},
});
summary.rearmed += 1;
break;
case "notify": {
const { title, body } = buildLowStockPayload(
user.locale,
med.name,
evaluation.runwayDays ?? 0,
evaluation.unitsRemaining,
);
const outcome = await dispatchImpl({
eventType: "MEDICATION_LOW_STOCK",
userId: user.id,
title,
message: body,
metadata: {
scheduledAt: now.toISOString(),
medicationId: med.id,
runwayDays: evaluation.runwayDays,
thresholdDays,
unitsRemaining: evaluation.unitsRemaining,
dosesRemaining: evaluation.dosesRemaining,
// Deep link — the web-push sender threads this into
// the service-worker payload's click target.
url: `/medications/${med.id}`,
},
});
if (!outcome.dispatched) {
// No channel succeeded — leave the stamp clear so the
// next daily tick retries the crossing.
summary.skippedNoChannel += 1;
skippedMeta.push({ ...meta, reason: "no_channel" });
break;
}
await prisma.medication.update({
where: { id: med.id },
data: {
lowStockNotifiedAt: now,
lowStockNotifiedThresholdDays: thresholdDays,
},
});
summary.notified += 1;
notifiedMeta.push(meta);
break;
}
}
}
} catch (err: unknown) {
summary.failed += 1;
const message = err instanceof Error ? err.message : String(err);
getEvent()?.addWarning(
`medication-low-stock per-user pass failed for ${user.id}: ${message}`,
);
}
}
// Wide events — pinned meta shape (see `LowStockEventMeta`), one
// entry per medication, emitted once per tick.
if (notifiedMeta.length > 0) {
annotate({
meta: { "medication.inventory.low_stock.notified": notifiedMeta },
});
}
if (skippedMeta.length > 0) {
annotate({
meta: { "medication.inventory.low_stock.skipped": skippedMeta },
});
}
return summary;
}
+50
View File
@@ -55,6 +55,11 @@ import {
COACH_NUDGE_CRON,
runCoachNudgeTick,
} from "@/lib/jobs/coach-nudge";
import {
MEDICATION_LOW_STOCK_QUEUE,
MEDICATION_LOW_STOCK_CRON,
runMedicationLowStockTick,
} from "@/lib/jobs/medication-low-stock";
import {
STRESS_SCORE_QUEUE,
STRESS_SCORE_CRON,
@@ -732,6 +737,10 @@ export async function startReminderWorker() {
// contract; without this entry the daily 05:15 schedule silently
// no-ops and no nudge ever fires.
COACH_NUDGE_QUEUE,
// v1.16.11 — daily medication low-stock pass. Same pg-boss v12
// createQueue contract; without this entry the 09:00 schedule
// silently no-ops and no low-stock alert ever fires.
MEDICATION_LOW_STOCK_QUEUE,
];
for (const q of allQueues) {
@@ -895,6 +904,11 @@ export async function startReminderWorker() {
// the 04:45–04:55 score crons so the recovery-score trigger reads
// settled rows. Deterministic triggers only — no AI call on this path.
[COACH_NUDGE_QUEUE, COACH_NUDGE_CRON],
// v1.16.11 — medication low-stock pass at 09:00 Europe/Berlin: a
// supply alert is an errand prompt, so it fires at a time the user
// can act on it. Once daily; the per-medication stamp keeps it at
// one push per threshold crossing.
[MEDICATION_LOW_STOCK_QUEUE, MEDICATION_LOW_STOCK_CRON],
];
for (const [name, cron, sendOptions] of schedules) {
@@ -1207,6 +1221,42 @@ export async function startReminderWorker() {
}
});
});
// v1.16.11 — medication low-stock pass. Single-flight; the
// per-medication stamp makes a re-fire idempotent (already-notified
// crossings skip), so an overlapping tick would only waste reads.
await boss.work(
MEDICATION_LOW_STOCK_QUEUE,
{ localConcurrency: 1 },
async () => {
await withBackgroundEvent("job.medication_low_stock", async (evt) => {
try {
const summary = await runMedicationLowStockTick(
getWorkerPrisma(),
new Date(),
);
evt.setBackground({
task_name: "job.medication_low_stock",
result: {
users_scanned: summary.usersScanned,
skipped_threshold_off: summary.skippedThresholdOff,
medications_evaluated: summary.medicationsEvaluated,
notified: summary.notified,
rearmed: summary.rearmed,
skipped_already_notified: summary.skippedAlreadyNotified,
skipped_above_threshold: summary.skippedAboveThreshold,
skipped_no_runway: summary.skippedNoRunway,
skipped_no_channel: summary.skippedNoChannel,
failed: summary.failed,
},
});
} catch (err) {
evt.setError(err);
recordError();
throw err;
}
});
},
);
// v1.10.0 — computed scores (WX-E). Nightly Stress-score (HRV-derived
// proxy) compute + store. Single-flight so two ticks never double-walk
// the cohort. The runner iterates every eligible user and upserts one
+14
View File
@@ -48,6 +48,14 @@ export const EVENT_TYPES = [
// per rolling week via the `push_attempts` ledger; per-user opt-out
// lives in `notificationPrefs.coach.nudgesEnabled`.
"COACH_NUDGE",
// v1.16.11 — medication low-stock alert. Fired by the daily
// medication-low-stock cron when a medication with tracked inventory
// projects fewer remaining-supply days than the user's per-user
// runway threshold (default 7 days, OFF possible). One notification
// per threshold crossing; the stamp on the medication row re-arms on
// refill or threshold change. ON at the channel layer — the real
// gate is the per-user threshold in `notificationPrefs.medication`.
"MEDICATION_LOW_STOCK",
] as const;
export type EventType = (typeof EVENT_TYPES)[number];
@@ -88,6 +96,12 @@ export const EVENT_DEFAULT_ENABLED: Record<EventType, boolean> = {
// server never surfaces fertile-window language unless the user has both
// chosen the conception goal and opted the reminder in.
CYCLE_FERTILE_SOON: false,
// v1.16.11 — ON at the channel layer; the per-user runway threshold
// (`notificationPrefs.medication.lowStockRunwayDays`, OFF-able) is the
// real gate, and the once-per-crossing stamp keeps the volume at one
// push per medication per crossing. An explicit per-channel
// `NotificationPreference` row still wins.
MEDICATION_LOW_STOCK: true,
};
export const CHANNEL_TYPE_LABELS: Record<ChannelType, string> = {
@@ -1,6 +1,7 @@
import { describe, expect, it } from "vitest";
import {
DEFAULT_LOW_STOCK_RUNWAY_DAYS,
DEFAULT_MOOD_REMINDER_HOUR,
DEFAULT_NOTIFICATION_PREFS,
isCycleReminderClientManaged,
@@ -9,6 +10,7 @@ import {
parseNotificationPrefs,
resolveCoachNudgePrefs,
resolveDeviceDelivery,
resolveLowStockRunwayDays,
resolveMoodReminderHour,
resolveNotificationPrefs,
} from "../notification-prefs";
@@ -42,9 +44,9 @@ describe("notificationPrefsSchema", () => {
});
it("accepts a valid mood.reminderHour (0..23)", () => {
expect(notificationPrefsSchema.safeParse({ mood: { reminderHour: 0 } }).success).toBe(
true,
);
expect(
notificationPrefsSchema.safeParse({ mood: { reminderHour: 0 } }).success,
).toBe(true);
expect(
notificationPrefsSchema.safeParse({ mood: { reminderHour: 23 } }).success,
).toBe(true);
@@ -61,7 +63,8 @@ describe("notificationPrefsSchema", () => {
it("rejects a non-integer mood.reminderHour", () => {
expect(
notificationPrefsSchema.safeParse({ mood: { reminderHour: 9.5 } }).success,
notificationPrefsSchema.safeParse({ mood: { reminderHour: 9.5 } })
.success,
).toBe(false);
});
});
@@ -89,7 +92,11 @@ describe("parseNotificationPrefs", () => {
expect(
parseNotificationPrefs({ medication: { clientManaged: true } }),
).toEqual({
medication: { clientManaged: true, deliveryDefault: "server" },
medication: {
clientManaged: true,
deliveryDefault: "server",
lowStockRunwayDays: 7,
},
mood: { reminderHour: DEFAULT_MOOD_REMINDER_HOUR },
cycle: { clientManaged: false },
coach: {
@@ -104,7 +111,11 @@ describe("parseNotificationPrefs", () => {
it("fills missing keys from the defaults", () => {
expect(parseNotificationPrefs({ medication: {} })).toEqual({
medication: { clientManaged: false, deliveryDefault: "server" },
medication: {
clientManaged: false,
deliveryDefault: "server",
lowStockRunwayDays: 7,
},
mood: { reminderHour: DEFAULT_MOOD_REMINDER_HOUR },
cycle: { clientManaged: false },
coach: {
@@ -121,7 +132,11 @@ describe("parseNotificationPrefs", () => {
expect(
parseNotificationPrefs({ medication: { deliveryDefault: "client" } }),
).toEqual({
medication: { clientManaged: true, deliveryDefault: "client" },
medication: {
clientManaged: true,
deliveryDefault: "client",
lowStockRunwayDays: 7,
},
mood: { reminderHour: DEFAULT_MOOD_REMINDER_HOUR },
cycle: { clientManaged: false },
coach: {
@@ -154,7 +169,11 @@ describe("resolveNotificationPrefs (deep-merge)", () => {
{ medication: { clientManaged: true } },
);
expect(out).toEqual({
medication: { clientManaged: true, deliveryDefault: "server" },
medication: {
clientManaged: true,
deliveryDefault: "server",
lowStockRunwayDays: 7,
},
mood: { reminderHour: DEFAULT_MOOD_REMINDER_HOUR },
cycle: { clientManaged: false },
coach: {
@@ -175,7 +194,11 @@ describe("resolveNotificationPrefs (deep-merge)", () => {
{ medication: {} },
);
expect(out).toEqual({
medication: { clientManaged: true, deliveryDefault: "server" },
medication: {
clientManaged: true,
deliveryDefault: "server",
lowStockRunwayDays: 7,
},
mood: { reminderHour: DEFAULT_MOOD_REMINDER_HOUR },
cycle: { clientManaged: false },
coach: {
@@ -193,7 +216,11 @@ describe("resolveNotificationPrefs (deep-merge)", () => {
medication: { clientManaged: true },
});
expect(out).toEqual({
medication: { clientManaged: true, deliveryDefault: "server" },
medication: {
clientManaged: true,
deliveryDefault: "server",
lowStockRunwayDays: 7,
},
mood: { reminderHour: DEFAULT_MOOD_REMINDER_HOUR },
cycle: { clientManaged: false },
coach: {
@@ -211,7 +238,11 @@ describe("resolveNotificationPrefs (deep-merge)", () => {
medication: { deliveryDefault: "client" },
});
expect(out).toEqual({
medication: { clientManaged: true, deliveryDefault: "client" },
medication: {
clientManaged: true,
deliveryDefault: "client",
lowStockRunwayDays: 7,
},
mood: { reminderHour: DEFAULT_MOOD_REMINDER_HOUR },
cycle: { clientManaged: false },
coach: {
@@ -230,7 +261,11 @@ describe("resolveNotificationPrefs (deep-merge)", () => {
{ mood: { reminderHour: 8 } },
);
expect(out).toEqual({
medication: { clientManaged: true, deliveryDefault: "server" },
medication: {
clientManaged: true,
deliveryDefault: "server",
lowStockRunwayDays: 7,
},
mood: { reminderHour: 8 },
cycle: { clientManaged: false },
coach: {
@@ -360,7 +395,10 @@ describe("resolveDeviceDelivery — per-device override", () => {
it("a null override inherits the user-level roaming default", () => {
expect(
resolveDeviceDelivery({ medication: { deliveryDefault: "client" } }, null),
resolveDeviceDelivery(
{ medication: { deliveryDefault: "client" } },
null,
),
).toBe("client");
});
@@ -439,3 +477,70 @@ describe("resolveCoachNudgePrefs — v1.16.5 nudge cron gate", () => {
).toBe(7);
});
});
describe("lowStockRunwayDays — v1.16.11 low-stock threshold", () => {
it("accepts the 1..60 range and the explicit null (off)", () => {
for (const value of [1, 7, 60, null]) {
expect(
notificationPrefsSchema.safeParse({
medication: { lowStockRunwayDays: value },
}).success,
).toBe(true);
}
});
it("rejects out-of-range and non-integer values", () => {
for (const value of [0, 61, 7.5, "7"]) {
expect(
notificationPrefsSchema.safeParse({
medication: { lowStockRunwayDays: value },
}).success,
).toBe(false);
}
});
it("resolves the default (7) for a null / drifted row", () => {
expect(resolveLowStockRunwayDays(null)).toBe(DEFAULT_LOW_STOCK_RUNWAY_DAYS);
expect(resolveLowStockRunwayDays(undefined)).toBe(
DEFAULT_LOW_STOCK_RUNWAY_DAYS,
);
expect(resolveLowStockRunwayDays({ unknown: "shape" })).toBe(
DEFAULT_LOW_STOCK_RUNWAY_DAYS,
);
});
it("returns the persisted custom threshold", () => {
expect(
resolveLowStockRunwayDays({ medication: { lowStockRunwayDays: 14 } }),
).toBe(14);
});
it("returns null when the user switched the alert off", () => {
expect(
resolveLowStockRunwayDays({ medication: { lowStockRunwayDays: null } }),
).toBe(null);
});
it("survives a deep-merge round-trip (PATCH semantics)", () => {
// PATCH { medication: { lowStockRunwayDays: 21 } } over a row that
// already opted into clientManaged must preserve the sibling.
const merged = resolveNotificationPrefs(
{ medication: { clientManaged: true } },
{ medication: { lowStockRunwayDays: 21 } },
);
expect(merged.medication).toEqual({
clientManaged: true,
deliveryDefault: "server",
lowStockRunwayDays: 21,
});
// ...and the next PATCH (threshold off) keeps the sibling again.
const off = resolveNotificationPrefs(merged, {
medication: { lowStockRunwayDays: null },
});
expect(off.medication).toEqual({
clientManaged: true,
deliveryDefault: "server",
lowStockRunwayDays: null,
});
});
});
+34 -4
View File
@@ -40,6 +40,14 @@ const medicationPrefsSchema = z
* backward compatibility so the cron keeps reading one boolean.
*/
deliveryDefault: z.enum(["server", "client"]),
/**
* v1.16.11 — low-stock alert threshold as REMAINING RUNWAY: notify
* when a tracked medication's projected supply covers fewer than
* this many days. 1–60; `null` switches the alert off entirely.
* Default 7 (see `DEFAULT_NOTIFICATION_PREFS`). Read once daily by
* the medication-low-stock cron.
*/
lowStockRunwayDays: z.number().int().min(1).max(60).nullable(),
})
.partial();
@@ -130,6 +138,11 @@ export interface NotificationPrefs {
clientManaged: boolean;
/** v1.7.0 — roaming user-level delivery default. */
deliveryDefault: "server" | "client";
/**
* v1.16.11 — low-stock runway threshold in days (1–60), or `null`
* when the alert is off. Default 7.
*/
lowStockRunwayDays: number | null;
};
mood: {
/** v1.7.0 — local-time hour (0–23) for the daily mood reminder. */
@@ -166,6 +179,12 @@ export interface NotificationPrefs {
*/
export const DEFAULT_MOOD_REMINDER_HOUR = 22;
/**
* v1.16.11 — default low-stock runway threshold: notify when the
* remaining supply covers fewer than 7 days.
*/
export const DEFAULT_LOW_STOCK_RUNWAY_DAYS = 7;
/**
* Safe defaults. Every category is off so the server reminders flow
* unchanged for any user the iOS app has not explicitly opted in.
@@ -176,6 +195,7 @@ export const DEFAULT_NOTIFICATION_PREFS: NotificationPrefs = {
medication: {
clientManaged: false,
deliveryDefault: "server",
lowStockRunwayDays: DEFAULT_LOW_STOCK_RUNWAY_DAYS,
},
mood: {
reminderHour: DEFAULT_MOOD_REMINDER_HOUR,
@@ -247,7 +267,9 @@ export function resolveNotificationPrefs(
* The mapping never flips `clientManaged` back to false on its own —
* an explicit `clientManaged` in the input still wins.
*/
function applyDeliveryDefaultMapping(prefs: NotificationPrefs): NotificationPrefs {
function applyDeliveryDefaultMapping(
prefs: NotificationPrefs,
): NotificationPrefs {
const clientManaged =
prefs.medication.deliveryDefault === "client"
? true
@@ -316,6 +338,16 @@ export function resolveMoodReminderHour(raw: unknown): number {
return parseNotificationPrefs(raw).mood.reminderHour;
}
/**
* Cron-side helper. Resolve the user's low-stock runway threshold in
* days from the persisted prefs blob: 1–60, or `null` when the user
* switched the alert off. A null / drifted row resolves to the default
* (7 days) so the alert works out of the box.
*/
export function resolveLowStockRunwayDays(raw: unknown): number | null {
return parseNotificationPrefs(raw).medication.lowStockRunwayDays;
}
/**
* v1.16.5 — fully-resolved Coach-nudge view for the cron: the master
* switch, the three per-group toggles, and the frequency cap mapped to
@@ -355,9 +387,7 @@ function cloneDefaults(): NotificationPrefs {
};
}
function mergeOverDefaults(
input: NotificationPrefsInput,
): NotificationPrefs {
function mergeOverDefaults(input: NotificationPrefsInput): NotificationPrefs {
return applyDeliveryDefaultMapping({
medication: {
...DEFAULT_NOTIFICATION_PREFS.medication,