mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
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:
1 parent
159de9b78f
commit
cac9a32756
19 files changed
+1741
-59
No files matched your search
+21
-1
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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;
|
||||
@@ -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 });
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user