feat(notifications): MEASUREMENT_REMINDER event type and client-managed gate

Register the MEASUREMENT_REMINDER event type (default on at the channel
layer; the real gate is the per-reminder enabled flag) and add the
notificationPrefs.measurementReminder.clientManaged sub-object plus its
cron-side resolver, mirroring the medication and cycle client-managed
precedent. The event rides the generic APNs category path and stays
non-time-sensitive, so it delivers as a plain banner with no client
change.
This commit is contained in:
Marc-André Bombeck committed 2026-06-14 23:33:30 +02:00
1 parent 383963d3b4
commit 8058d81590
4 files changed
+152

No files matched your search

@@ -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,
},
},
},
});
+15
View File
@@ -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<EventType, boolean> = {
// 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<ChannelType, string> = {
@@ -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);
+47
View File
@@ -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 ?? {}),
},
});
}