Files
HealthLog/src/lib/validations/notification-prefs.ts
T
Marc-André Bombeck 8058d81590 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.
2026-06-14 23:33:30 +02:00

499 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* v1.4.49 M-DOUBLE-REMINDER — per-user notification preferences blob.
*
* Persisted on `User.notificationPrefs` as Postgres jsonb. Null = the
* documented defaults (every category off). The flag is opt-in: the
* iOS app flips it to `true` only after confirming local Spezi
* reminders are wired, so existing users without a working local
* notification path see zero regression.
*
* Shape (additive — future categories slot in next to `medication`):
* {
* "medication": { "clientManaged": boolean },
* "mood": { "reminderHour": 0..23 },
* "cycle": { "clientManaged": boolean }
* }
*
* The schema is intentionally additive — every new category grows the
* schema with a new optional sub-object plus an entry in
* `DEFAULT_NOTIFICATION_PREFS`. Forward-compat: an unknown / drifted
* shape falls back to defaults rather than throwing.
*/
import { z } from "zod/v4";
/**
* Medication category schema. Currently a single boolean
* (`clientManaged`); the sub-object exists so future per-category
* knobs (e.g. quiet-hours overrides) can be added without breaking
* the persisted layout.
*/
const medicationPrefsSchema = z
.object({
clientManaged: z.boolean(),
/**
* v1.7.0 — roaming user-level delivery default. "server" = the
* server fires APNs (the legacy behaviour); "client" = the iOS app
* manages reminders locally. Kept as a SEPARATE field from
* `clientManaged` (the established cron gate the reminder worker
* reads verbatim at `reminder-worker.ts`); the resolver maps
* `deliveryDefault === "client"` onto `clientManaged: true` for
* 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(),
/**
* v1.17.0 — reorder lead time (days) the low-stock alert assumes
* between placing a refill order and the new supply arriving. The
* engine fires the alert when the projected runway drops to or below
* `max(lowStockRunwayDays, reorderLeadDays + one dose-interval)`, so
* the warning lands BEFORE the last dose for any cadence (a weekly
* injection no longer alerts only when ~1 dose is left). 0–60;
* default 10. A per-medication `Medication.reorderLeadDays` overrides
* this user default when set.
*/
reorderLeadDays: z.number().int().min(0).max(60),
})
.partial();
/**
* v1.7.0 — mood category schema. `reminderHour` is the local-time hour
* (0–23) at which the daily mood reminder fires. Sub-object form keeps
* the layout open for future mood knobs (quiet days, snooze, …).
* Default is 22:00 — see `DEFAULT_NOTIFICATION_PREFS` — so an unset
* value is identical to the legacy hardcoded behaviour.
*/
const moodPrefsSchema = z
.object({
reminderHour: z.number().int().min(0).max(23),
})
.partial();
/**
* v1.15 — cycle category schema. `clientManaged` mirrors the medication
* gate: when the iOS app owns local cycle reminders (period-soon /
* period-start-confirm), it flips this to `true` and the server-side
* cycle cron suppresses its APNs send for that user, exactly as the
* medication path does. Sub-object form keeps the layout open for future
* cycle-notification knobs.
*/
const cyclePrefsSchema = z
.object({
clientManaged: z.boolean(),
})
.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
* opt-out surface — it only fires for users who kept the Coach enabled
* AND have a working provider), flipped off from Settings →
* Notifications. Sub-object form keeps the layout open for future
* coach-notification knobs (quiet days, trigger selection, …).
*/
const coachPrefsSchema = z
.object({
nudgesEnabled: z.boolean(),
/**
* v1.16.5 — per-trigger-group opt-outs underneath the master
* switch. Groups, not individual triggers, so the UI stays three
* comprehensible toggles as the trigger list grows:
* medication → compliance; vitals → bp / score / weight / sleep;
* routine → measurement-gap / self-context check-up.
*/
nudgeMedication: z.boolean(),
nudgeVitals: z.boolean(),
nudgeRoutine: z.boolean(),
/** v1.16.5 — frequency cap: one nudge per 7 or per 14 days. */
nudgeFrequency: z.enum(["weekly", "biweekly"]),
})
.partial();
/**
* v1.7.0 — per-device delivery override schema for the device PATCH.
* NULL clears the override (the device inherits the user-level roaming
* default).
*/
export const deviceDeliverySchema = z.enum(["server", "client"]).nullable();
/**
* Top-level prefs schema. Every category is optional so a PATCH that
* only touches `medication` doesn't have to re-state future siblings.
* The route layer deep-merges the partial input over the persisted
* row before persisting so the column shape stays stable.
*/
export const notificationPrefsSchema = z
.object({
medication: medicationPrefsSchema,
mood: moodPrefsSchema,
cycle: cyclePrefsSchema,
coach: coachPrefsSchema,
measurementReminder: measurementReminderPrefsSchema,
})
.partial();
export type NotificationPrefsInput = z.infer<typeof notificationPrefsSchema>;
/**
* Fully-resolved prefs. Every key required so consumers (cron
* suppression, GET response) don't need to thread "is this present?"
* checks through their paths.
*/
export interface NotificationPrefs {
medication: {
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;
/**
* v1.17.0 — reorder lead time (days) the low-stock alert assumes.
* 0–60; default 10. Per-medication `reorderLeadDays` overrides it.
*/
reorderLeadDays: number;
};
mood: {
/** v1.7.0 — local-time hour (0–23) for the daily mood reminder. */
reminderHour: number;
};
cycle: {
/**
* v1.15 — when true the iOS app owns local cycle reminders and the
* server-side cycle cron suppresses its APNs send (the medication
* `clientManaged` precedent).
*/
clientManaged: boolean;
};
coach: {
/**
* v1.15.20 — proactive Coach nudge opt-out. Default `true`; the
* 05:15 nudge cron skips the user when `false`.
*/
nudgesEnabled: boolean;
/** v1.16.5 — medication-group triggers (compliance). Default on. */
nudgeMedication: boolean;
/** v1.16.5 — vitals-group triggers (bp / score / weight / sleep). */
nudgeVitals: boolean;
/** v1.16.5 — routine-group triggers (measurement gap / self-context). */
nudgeRoutine: boolean;
/** 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;
};
}
/**
* v1.7.0 — default mood-reminder hour. 22:00 reproduces the legacy
* hardcoded window, so any user with an unset value sees zero change.
*/
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;
/**
* v1.17.0 — default reorder lead time: assume 10 days between ordering a
* refill and the new supply arriving. The alert engine widens the
* trigger by this lead plus one dose-interval so a sparse cadence is
* warned before its last dose.
*/
export const DEFAULT_REORDER_LEAD_DAYS = 10;
/**
* Safe defaults. Every category is off so the server reminders flow
* unchanged for any user the iOS app has not explicitly opted in.
* The maintainer's directive: zero regression for clients without working
* local reminders.
*/
export const DEFAULT_NOTIFICATION_PREFS: NotificationPrefs = {
medication: {
clientManaged: false,
deliveryDefault: "server",
lowStockRunwayDays: DEFAULT_LOW_STOCK_RUNWAY_DAYS,
reorderLeadDays: DEFAULT_REORDER_LEAD_DAYS,
},
mood: {
reminderHour: DEFAULT_MOOD_REMINDER_HOUR,
},
cycle: {
clientManaged: false,
},
coach: {
nudgesEnabled: true,
nudgeMedication: true,
nudgeVitals: true,
nudgeRoutine: true,
nudgeFrequency: "weekly",
},
measurementReminder: {
clientManaged: false,
},
};
/**
* Parse the persisted `notificationPrefs` JSON blob into a typed
* `NotificationPrefs`, falling back to the defaults when the row is
* null OR the persisted shape has drifted (a forward-compat field
* rename, an admin-side hand-edit, etc.). Missing keys are filled
* from the defaults so callers always get a fully-resolved object.
*/
export function parseNotificationPrefs(raw: unknown): NotificationPrefs {
if (raw == null) return cloneDefaults();
const parsed = notificationPrefsSchema.safeParse(raw);
if (!parsed.success) return cloneDefaults();
return mergeOverDefaults(parsed.data);
}
/**
* Deep-merge a partial input (typically from a PATCH body) over the
* current persisted row, layering each supplied category's keys over
* the existing category sub-object so future siblings are not
* overwritten when the client only PATCHes `medication`.
*/
export function resolveNotificationPrefs(
current: unknown,
incoming: NotificationPrefsInput,
): NotificationPrefs {
const base = parseNotificationPrefs(current);
return applyDeliveryDefaultMapping({
medication: {
...base.medication,
...(incoming.medication ?? {}),
},
mood: {
...base.mood,
...(incoming.mood ?? {}),
},
cycle: {
...base.cycle,
...(incoming.cycle ?? {}),
},
coach: {
...base.coach,
...(incoming.coach ?? {}),
},
measurementReminder: {
...base.measurementReminder,
...(incoming.measurementReminder ?? {}),
},
});
}
/**
* v1.7.0 — map the human-meaningful `deliveryDefault` onto the
* established `clientManaged` cron gate. When the user sets
* `deliveryDefault: "client"` ("Alle Geräte" → local), the reminder
* worker must suppress server APNs, which it does by reading
* `clientManaged`. Keeping two fields lets the iOS UI surface the
* "server / client" choice while the cron keeps reading one boolean.
* The mapping never flips `clientManaged` back to false on its own —
* an explicit `clientManaged` in the input still wins.
*/
function applyDeliveryDefaultMapping(
prefs: NotificationPrefs,
): NotificationPrefs {
const clientManaged =
prefs.medication.deliveryDefault === "client"
? true
: prefs.medication.clientManaged;
return {
medication: {
...prefs.medication,
clientManaged,
},
mood: { ...prefs.mood },
cycle: { ...prefs.cycle },
coach: { ...prefs.coach },
measurementReminder: { ...prefs.measurementReminder },
};
}
/**
* Cron-side helper. Returns `true` when the user has opted in to
* client-managed medication reminders and the server-side push
* should be suppressed. Tolerates a null / missing prefs row. v1.7.0 —
* also true when the roaming `deliveryDefault` is "client".
*/
export function isMedicationReminderClientManaged(raw: unknown): boolean {
const prefs = parseNotificationPrefs(raw);
return (
prefs.medication.clientManaged === true ||
prefs.medication.deliveryDefault === "client"
);
}
/**
* Cron-side helper. Returns `true` when the user has opted in to
* client-managed CYCLE reminders (iOS owns the local period-soon /
* period-start-confirm nudges) and the server-side push should be
* suppressed. Tolerates a null / missing prefs row. Mirrors
* `isMedicationReminderClientManaged`.
*/
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;
* otherwise "server" (the safe default). This is an iOS-display concern
* in v1.7.0 — which device shows the local banner — not a server
* fan-out concern, so the reminder cron stays user-level (APNs already
* fans out to all the user's devices and iOS dedupes locally).
*/
export function resolveDeviceDelivery(
raw: unknown,
deviceOverride: string | null | undefined,
): "server" | "client" {
if (deviceOverride === "server" || deviceOverride === "client") {
return deviceOverride;
}
return parseNotificationPrefs(raw).medication.deliveryDefault;
}
/**
* Cron-side helper. Resolve the user's local-time mood-reminder hour
* (0–23) from the persisted prefs blob, falling back to the default
* (22:00) for a null / drifted row. Pulled out so the mood-reminder
* cron reads one number instead of threading the whole prefs object.
*/
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.17.0 — resolve the EFFECTIVE reorder lead time (days) for one
* medication: the per-medication override when set, otherwise the
* user-level default from the prefs blob (a null / drifted row resolves
* to the documented 10-day default). The low-stock alert adds this lead
* plus one dose-interval on top of the runway threshold.
*/
export function resolveReorderLeadDays(
raw: unknown,
medicationOverride: number | null | undefined,
): number {
if (typeof medicationOverride === "number" && medicationOverride >= 0) {
return medicationOverride;
}
return parseNotificationPrefs(raw).medication.reorderLeadDays;
}
/**
* 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
* days. One call so the nudge tick reads a single shape instead of
* threading the whole prefs object.
*/
export interface CoachNudgePrefs {
enabled: boolean;
groups: {
medication: boolean;
vitals: boolean;
routine: boolean;
};
/** 7 for "weekly", 14 for "biweekly". */
minIntervalDays: number;
}
export function resolveCoachNudgePrefs(raw: unknown): CoachNudgePrefs {
const coach = parseNotificationPrefs(raw).coach;
return {
enabled: coach.nudgesEnabled,
groups: {
medication: coach.nudgeMedication,
vitals: coach.nudgeVitals,
routine: coach.nudgeRoutine,
},
minIntervalDays: coach.nudgeFrequency === "biweekly" ? 14 : 7,
};
}
function cloneDefaults(): NotificationPrefs {
return {
medication: { ...DEFAULT_NOTIFICATION_PREFS.medication },
mood: { ...DEFAULT_NOTIFICATION_PREFS.mood },
cycle: { ...DEFAULT_NOTIFICATION_PREFS.cycle },
coach: { ...DEFAULT_NOTIFICATION_PREFS.coach },
measurementReminder: {
...DEFAULT_NOTIFICATION_PREFS.measurementReminder,
},
};
}
function mergeOverDefaults(input: NotificationPrefsInput): NotificationPrefs {
return applyDeliveryDefaultMapping({
medication: {
...DEFAULT_NOTIFICATION_PREFS.medication,
...(input.medication ?? {}),
},
mood: {
...DEFAULT_NOTIFICATION_PREFS.mood,
...(input.mood ?? {}),
},
cycle: {
...DEFAULT_NOTIFICATION_PREFS.cycle,
...(input.cycle ?? {}),
},
coach: {
...DEFAULT_NOTIFICATION_PREFS.coach,
...(input.coach ?? {}),
},
measurementReminder: {
...DEFAULT_NOTIFICATION_PREFS.measurementReminder,
...(input.measurementReminder ?? {}),
},
});
}