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 ae16929e3..46dbd045d 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 @@ -103,6 +103,9 @@ describe("GET /api/auth/me/notification-prefs", () => { nudgeRoutine: true, nudgeFrequency: "weekly", }, + measurementReminder: { + clientManaged: false, + }, }); }); @@ -135,6 +138,9 @@ describe("GET /api/auth/me/notification-prefs", () => { nudgeRoutine: true, nudgeFrequency: "weekly", }, + measurementReminder: { + clientManaged: false, + }, }); }); @@ -169,6 +175,9 @@ describe("GET /api/auth/me/notification-prefs", () => { nudgeRoutine: true, nudgeFrequency: "weekly", }, + measurementReminder: { + clientManaged: false, + }, }); }); }); @@ -214,6 +223,9 @@ describe("PATCH /api/auth/me/notification-prefs", () => { nudgeRoutine: true, nudgeFrequency: "weekly", }, + measurementReminder: { + clientManaged: false, + }, }); expect(prisma.user.update).toHaveBeenCalledWith({ @@ -235,6 +247,9 @@ describe("PATCH /api/auth/me/notification-prefs", () => { nudgeRoutine: true, nudgeFrequency: "weekly", }, + measurementReminder: { + clientManaged: false, + }, }, }, }); @@ -260,6 +275,9 @@ describe("PATCH /api/auth/me/notification-prefs", () => { nudgeRoutine: true, nudgeFrequency: "weekly", }, + measurementReminder: { + clientManaged: false, + }, }, next: { medication: { @@ -277,6 +295,9 @@ describe("PATCH /api/auth/me/notification-prefs", () => { nudgeRoutine: true, nudgeFrequency: "weekly", }, + measurementReminder: { + clientManaged: false, + }, }, changed: ["medication"], }), @@ -347,6 +368,9 @@ describe("PATCH /api/auth/me/notification-prefs", () => { nudgeRoutine: true, nudgeFrequency: "weekly", }, + measurementReminder: { + clientManaged: false, + }, }, }, }); @@ -382,6 +406,9 @@ describe("PATCH /api/auth/me/notification-prefs", () => { nudgeRoutine: true, nudgeFrequency: "weekly", }, + measurementReminder: { + clientManaged: false, + }, }); expect(prisma.user.update).toHaveBeenCalledWith({ @@ -403,6 +430,9 @@ describe("PATCH /api/auth/me/notification-prefs", () => { nudgeRoutine: true, nudgeFrequency: "weekly", }, + measurementReminder: { + clientManaged: false, + }, }, }, }); @@ -443,6 +473,9 @@ describe("PATCH /api/auth/me/notification-prefs", () => { nudgeRoutine: true, nudgeFrequency: "weekly", }, + measurementReminder: { + clientManaged: false, + }, }, }, }); diff --git a/src/lib/notifications/types.ts b/src/lib/notifications/types.ts index 129bfe8a0..ca4eb6759 100644 --- a/src/lib/notifications/types.ts +++ b/src/lib/notifications/types.ts @@ -56,6 +56,16 @@ export const EVENT_TYPES = [ // refill or threshold change. ON at the channel layer — the real // gate is the per-user threshold in `notificationPrefs.medication`. "MEDICATION_LOW_STOCK", + // v1.17.1 — Vorsorge (measurement / preventive-care) reminder. Fired by + // the every-15-min measurement-reminder cron when a user-created + // reminder ("measure BP every 7 days", "annual blood panel") is due + // inside the user's local notify-hour window. ON by default at the + // channel layer (see EVENT_DEFAULT_ENABLED): these are reminders the + // user explicitly created, so silence-by-default would be surprising. + // The real gate is the per-reminder `enabled` flag plus the per-user + // `notificationPrefs.measurementReminder.clientManaged` suppression. + // NOT time-sensitive — a Vorsorge nudge is not a Focus-bypass case. + "MEASUREMENT_REMINDER", ] as const; export type EventType = (typeof EVENT_TYPES)[number]; @@ -102,6 +112,11 @@ export const EVENT_DEFAULT_ENABLED: Record = { // push per medication per crossing. An explicit per-channel // `NotificationPreference` row still wins. MEDICATION_LOW_STOCK: true, + // v1.17.1 — ON at the channel layer; the real gates are the per-reminder + // `enabled` flag and the per-user `measurementReminder.clientManaged` + // suppression the cron reads. An explicit per-channel + // `NotificationPreference` row still wins. + MEASUREMENT_REMINDER: 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 eef97b83d..813f2c8ac 100644 --- a/src/lib/validations/__tests__/notification-prefs.test.ts +++ b/src/lib/validations/__tests__/notification-prefs.test.ts @@ -6,6 +6,7 @@ import { DEFAULT_NOTIFICATION_PREFS, DEFAULT_REORDER_LEAD_DAYS, isCycleReminderClientManaged, + isMeasurementReminderClientManaged, isMedicationReminderClientManaged, notificationPrefsSchema, parseNotificationPrefs, @@ -109,6 +110,9 @@ describe("parseNotificationPrefs", () => { nudgeRoutine: true, nudgeFrequency: "weekly", }, + measurementReminder: { + clientManaged: false, + }, }); }); @@ -129,6 +133,9 @@ describe("parseNotificationPrefs", () => { nudgeRoutine: true, nudgeFrequency: "weekly", }, + measurementReminder: { + clientManaged: false, + }, }); }); @@ -151,6 +158,9 @@ describe("parseNotificationPrefs", () => { nudgeRoutine: true, nudgeFrequency: "weekly", }, + measurementReminder: { + clientManaged: false, + }, }); }); @@ -189,6 +199,9 @@ describe("resolveNotificationPrefs (deep-merge)", () => { nudgeRoutine: true, nudgeFrequency: "weekly", }, + measurementReminder: { + clientManaged: false, + }, }); }); @@ -215,6 +228,9 @@ describe("resolveNotificationPrefs (deep-merge)", () => { nudgeRoutine: true, nudgeFrequency: "weekly", }, + measurementReminder: { + clientManaged: false, + }, }); }); @@ -238,6 +254,9 @@ describe("resolveNotificationPrefs (deep-merge)", () => { nudgeRoutine: true, nudgeFrequency: "weekly", }, + measurementReminder: { + clientManaged: false, + }, }); }); @@ -261,6 +280,9 @@ describe("resolveNotificationPrefs (deep-merge)", () => { nudgeRoutine: true, nudgeFrequency: "weekly", }, + measurementReminder: { + clientManaged: false, + }, }); }); @@ -285,6 +307,9 @@ describe("resolveNotificationPrefs (deep-merge)", () => { nudgeRoutine: true, nudgeFrequency: "weekly", }, + measurementReminder: { + clientManaged: false, + }, }); }); @@ -356,6 +381,38 @@ describe("isCycleReminderClientManaged — cron-skip gate", () => { }); }); +describe("isMeasurementReminderClientManaged — cron-skip gate", () => { + it("returns false for a null / undefined row (server-managed default)", () => { + expect(isMeasurementReminderClientManaged(null)).toBe(false); + expect(isMeasurementReminderClientManaged(undefined)).toBe(false); + }); + + it("returns false when the user has not opted in", () => { + expect( + isMeasurementReminderClientManaged({ + measurementReminder: { clientManaged: false }, + }), + ).toBe(false); + }); + + it("returns true when the iOS app owns local Vorsorge reminders", () => { + expect( + isMeasurementReminderClientManaged({ + measurementReminder: { clientManaged: true }, + }), + ).toBe(true); + }); + + it("is independent of the cycle / medication client-managed flags", () => { + expect( + isMeasurementReminderClientManaged({ + cycle: { clientManaged: true }, + medication: { deliveryDefault: "client" }, + }), + ).toBe(false); + }); +}); + describe("resolveMoodReminderHour — cron hour gate", () => { it("returns the default hour for a null row", () => { expect(resolveMoodReminderHour(null)).toBe(DEFAULT_MOOD_REMINDER_HOUR); diff --git a/src/lib/validations/notification-prefs.ts b/src/lib/validations/notification-prefs.ts index 92e00d7f1..0f405c708 100644 --- a/src/lib/validations/notification-prefs.ts +++ b/src/lib/validations/notification-prefs.ts @@ -89,6 +89,19 @@ const cyclePrefsSchema = z }) .partial(); +/** + * v1.17.1 — measurement / Vorsorge reminder category schema. + * `clientManaged` mirrors the medication + cycle gate: when the iOS app + * owns local Vorsorge reminders it flips this to `true` and the + * server-side measurement-reminder cron suppresses its APNs send for that + * user. Sub-object form keeps the layout open for future Vorsorge knobs. + */ +const measurementReminderPrefsSchema = z + .object({ + clientManaged: z.boolean(), + }) + .partial(); + /** * v1.15.20 — coach category schema. `nudgesEnabled` gates the proactive * Coach nudge cron (05:15 Europe/Berlin): default ON (the nudge is an @@ -134,6 +147,7 @@ export const notificationPrefsSchema = z mood: moodPrefsSchema, cycle: cyclePrefsSchema, coach: coachPrefsSchema, + measurementReminder: measurementReminderPrefsSchema, }) .partial(); @@ -187,6 +201,14 @@ export interface NotificationPrefs { /** v1.16.5 — cap interval: "weekly" (7 d) or "biweekly" (14 d). */ nudgeFrequency: "weekly" | "biweekly"; }; + measurementReminder: { + /** + * v1.17.1 — when true the iOS app owns local Vorsorge reminders and + * the server-side measurement-reminder cron suppresses its APNs send + * (the medication / cycle `clientManaged` precedent). + */ + clientManaged: boolean; + }; } /** @@ -235,6 +257,9 @@ export const DEFAULT_NOTIFICATION_PREFS: NotificationPrefs = { nudgeRoutine: true, nudgeFrequency: "weekly", }, + measurementReminder: { + clientManaged: false, + }, }; /** @@ -279,6 +304,10 @@ export function resolveNotificationPrefs( ...base.coach, ...(incoming.coach ?? {}), }, + measurementReminder: { + ...base.measurementReminder, + ...(incoming.measurementReminder ?? {}), + }, }); } @@ -307,6 +336,7 @@ function applyDeliveryDefaultMapping( mood: { ...prefs.mood }, cycle: { ...prefs.cycle }, coach: { ...prefs.coach }, + measurementReminder: { ...prefs.measurementReminder }, }; } @@ -335,6 +365,16 @@ export function isCycleReminderClientManaged(raw: unknown): boolean { return parseNotificationPrefs(raw).cycle.clientManaged === true; } +/** + * v1.17.1 — cron-side helper. Returns `true` when the user has opted in + * to client-managed Vorsorge (measurement) reminders so the server-side + * push should be suppressed. Tolerates a null / missing prefs row. + * Mirrors `isMedicationReminderClientManaged` / `isCycleReminderClientManaged`. + */ +export function isMeasurementReminderClientManaged(raw: unknown): boolean { + return parseNotificationPrefs(raw).measurementReminder.clientManaged === true; +} + /** * v1.7.0 — resolve the effective delivery channel for a device. The * per-device override wins; otherwise the user-level roaming default; @@ -426,6 +466,9 @@ function cloneDefaults(): NotificationPrefs { mood: { ...DEFAULT_NOTIFICATION_PREFS.mood }, cycle: { ...DEFAULT_NOTIFICATION_PREFS.cycle }, coach: { ...DEFAULT_NOTIFICATION_PREFS.coach }, + measurementReminder: { + ...DEFAULT_NOTIFICATION_PREFS.measurementReminder, + }, }; } @@ -447,5 +490,9 @@ function mergeOverDefaults(input: NotificationPrefsInput): NotificationPrefs { ...DEFAULT_NOTIFICATION_PREFS.coach, ...(input.coach ?? {}), }, + measurementReminder: { + ...DEFAULT_NOTIFICATION_PREFS.measurementReminder, + ...(input.measurementReminder ?? {}), + }, }); }