From cac9a327564eb463396771986aecd11660e8299c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 12 Jun 2026 21:04:09 +0200 Subject: [PATCH] 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. --- messages/de.json | 22 +- messages/en.json | 22 +- messages/es.json | 22 +- messages/fr.json | 22 +- messages/it.json | 22 +- messages/pl.json | 22 +- .../migration.sql | 11 + prisma/schema.prisma | 9 + .../__tests__/route.test.ts | 232 ++++++++-- .../__tests__/route.test.ts | 116 +++++ .../api/settings/reminder-thresholds/route.ts | 20 +- src/components/settings/low-stock-card.tsx | 212 +++++++++ .../settings/notifications-section.tsx | 5 + .../__tests__/medication-low-stock.test.ts | 428 ++++++++++++++++++ src/lib/jobs/medication-low-stock.ts | 402 ++++++++++++++++ src/lib/jobs/reminder-worker.ts | 50 ++ src/lib/notifications/types.ts | 14 + .../__tests__/notification-prefs.test.ts | 131 +++++- src/lib/validations/notification-prefs.ts | 38 +- 19 files changed, 1741 insertions(+), 59 deletions(-) create mode 100644 prisma/migrations/0151_v11611_medication_low_stock_stamp/migration.sql create mode 100644 src/app/api/settings/reminder-thresholds/__tests__/route.test.ts create mode 100644 src/components/settings/low-stock-card.tsx create mode 100644 src/lib/jobs/__tests__/medication-low-stock.test.ts create mode 100644 src/lib/jobs/medication-low-stock.ts diff --git a/messages/de.json b/messages/de.json index 1856da5b6..260718ff5 100644 --- a/messages/de.json +++ b/messages/de.json @@ -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?" diff --git a/messages/en.json b/messages/en.json index d6ce05052..dbea482f7 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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?" diff --git a/messages/es.json b/messages/es.json index d80ac5cbb..0e74cb0dd 100644 --- a/messages/es.json +++ b/messages/es.json @@ -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?" diff --git a/messages/fr.json b/messages/fr.json index 3bcc9b3e8..31be826ff 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -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 ?" diff --git a/messages/it.json b/messages/it.json index be32183ab..ac19d1ffe 100644 --- a/messages/it.json +++ b/messages/it.json @@ -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?" diff --git a/messages/pl.json b/messages/pl.json index a5cec9ca4..ea8c170ef 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -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?" diff --git a/prisma/migrations/0151_v11611_medication_low_stock_stamp/migration.sql b/prisma/migrations/0151_v11611_medication_low_stock_stamp/migration.sql new file mode 100644 index 000000000..f193ce9ed --- /dev/null +++ b/prisma/migrations/0151_v11611_medication_low_stock_stamp/migration.sql @@ -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; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 79c5acbfd..d32c7affd 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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") diff --git a/src/app/api/auth/me/notification-prefs/__tests__/route.test.ts b/src/app/api/auth/me/notification-prefs/__tests__/route.test.ts index 3137ecb03..afae11aea 100644 --- a/src/app/api/auth/me/notification-prefs/__tests__/route.test.ts +++ b/src/app/api/auth/me/notification-prefs/__tests__/route.test.ts @@ -7,7 +7,7 @@ vi.mock("@/lib/db", () => ({ update: vi.fn(), }, }, - toJson: (v: T) => v, + toJson: (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)(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)( - mkPatch({}), - ); + const res = await (PATCH as (r: Request) => Promise)(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)( + 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)( + 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)( + 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({ diff --git a/src/app/api/settings/reminder-thresholds/__tests__/route.test.ts b/src/app/api/settings/reminder-thresholds/__tests__/route.test.ts new file mode 100644 index 000000000..a9fb93cab --- /dev/null +++ b/src/app/api/settings/reminder-thresholds/__tests__/route.test.ts @@ -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)(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)(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)(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)(mkGet()); + expect(res.status).toBe(200); + const env = (await res.json()) as { + data: { lowStockRunwayDays: number | null }; + }; + expect(env.data.lowStockRunwayDays).toBe(null); + }); +}); diff --git a/src/app/api/settings/reminder-thresholds/route.ts b/src/app/api/settings/reminder-thresholds/route.ts index 0f2bb76da..8432fc3db 100644 --- a/src/app/api/settings/reminder-thresholds/route.ts +++ b/src/app/api/settings/reminder-thresholds/route.ts @@ -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 }); }); diff --git a/src/components/settings/low-stock-card.tsx b/src/components/settings/low-stock-card.tsx new file mode 100644 index 000000000..9f996530a --- /dev/null +++ b/src/components/settings/low-stock-card.tsx @@ -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( + undefined, + ); + const [draft, setDraft] = useState(null); + const [saving, setSaving] = useState(false); + const [msg, setMsg] = useState(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 | 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("/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 ( +
+ + + + {enabled + ? t("notifications.lowStock.statusOn") + : t("notifications.lowStock.statusOff")} + + + } + /> + {enabled && ( +
+ + 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" + /> +
+ )} + {msg && ( +

+ {msg} +

+ )} +
+ ); +} diff --git a/src/components/settings/notifications-section.tsx b/src/components/settings/notifications-section.tsx index 024e913eb..e7172f7c5 100644 --- a/src/components/settings/notifications-section.tsx +++ b/src/components/settings/notifications-section.tsx @@ -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() {
+ {/* v1.16.11 — medication low-stock runway threshold. */} +
+ +
{/* v1.15.20 — proactive Coach nudge opt-out. */}
diff --git a/src/lib/jobs/__tests__/medication-low-stock.test.ts b/src/lib/jobs/__tests__/medication-low-stock.test.ts new file mode 100644 index 000000000..ec33425ca --- /dev/null +++ b/src/lib/jobs/__tests__/medication-low-stock.test.ts @@ -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> = {}) { + 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(); + }); +}); diff --git a/src/lib/jobs/medication-low-stock.ts b/src/lib/jobs/medication-low-stock.ts new file mode 100644 index 000000000..b9ff7e72d --- /dev/null +++ b/src/lib/jobs/medication-low-stock.ts @@ -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 { + 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; +} diff --git a/src/lib/jobs/reminder-worker.ts b/src/lib/jobs/reminder-worker.ts index 0a9b34ed1..cac07ce26 100644 --- a/src/lib/jobs/reminder-worker.ts +++ b/src/lib/jobs/reminder-worker.ts @@ -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 diff --git a/src/lib/notifications/types.ts b/src/lib/notifications/types.ts index 67f24e5bd..129bfe8a0 100644 --- a/src/lib/notifications/types.ts +++ b/src/lib/notifications/types.ts @@ -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 = { // 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 = { diff --git a/src/lib/validations/__tests__/notification-prefs.test.ts b/src/lib/validations/__tests__/notification-prefs.test.ts index efaf6f12a..874cc6b26 100644 --- a/src/lib/validations/__tests__/notification-prefs.test.ts +++ b/src/lib/validations/__tests__/notification-prefs.test.ts @@ -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, + }); + }); +}); diff --git a/src/lib/validations/notification-prefs.ts b/src/lib/validations/notification-prefs.ts index 17bb1dc84..4df4b52ba 100644 --- a/src/lib/validations/notification-prefs.ts +++ b/src/lib/validations/notification-prefs.ts @@ -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,