feat(notifications): opt-in clientManaged flag to suppress server MEDICATION_REMINDER

Closes GH #206. Server-side APNs reminder for a dose now skips
when the user's notification_prefs.medication.clientManaged is
true — set by the iOS client once it confirms local SpeziScheduler
banners are wired. Default false so existing users see no change
until the iOS client opts them in. PATCH + GET endpoints land at
/api/auth/me/notification-prefs with deep-merge semantics so
future categories can slot in next to `medication` without
overwriting siblings. Cron emits a wide-event annotation per
suppressed dose so the operator can audit the skip path.
This commit is contained in:
Marc-André Bombeck committed 2026-05-23 11:09:09 +02:00
1 parent 8956bd22af
commit d49fd2bc4c
7 files changed
+785

No files matched your search

@@ -0,0 +1,41 @@
-- v1.4.49 M-DOUBLE-REMINDER — per-user notification preferences blob.
--
-- iOS v0.6.0.7 wires local SpeziScheduler banners for medication doses.
-- Once the client has confirmed (a) APNs permission = `.authorized`,
-- (b) Spezi reminders are actively scheduled, and (c) the binary is
-- >= v0.6.0.7, it flips `notification_prefs.medication.clientManaged`
-- to true via `PATCH /api/auth/me/notification-prefs`. The server-side
-- MEDICATION_REMINDER cron then suppresses the redundant APNs push for
-- that user so the dose only buzzes once.
--
-- Shape (additive — future categories slot in next to `medication`):
-- {
-- "medication": { "clientManaged": boolean }
-- }
--
-- Default `NULL` (the column itself), which the application layer
-- resolves to `{ medication: { clientManaged: false } }` so every
-- existing user keeps the legacy server-side push until the iOS app
-- explicitly opts them in. This preserves UX for clients that don't
-- have working local reminders yet (e.g. the
-- `healthlog-iOS#10` notification-permission-stuck-`notDetermined`
-- scenario).
--
-- Idempotent guard (`IF NOT EXISTS`) matches the 0067 / 0070 / 0071 /
-- 0075 / 0078 pattern so reruns are safe on prod where the migration
-- has already been applied.
--
-- Reversibility: down migration is
-- ALTER TABLE "users" DROP COLUMN IF EXISTS "notification_prefs";
-- A roll-back loses opt-in state for every user that had flipped
-- `clientManaged` (they fall back to server-side reminders) but the
-- application reads tolerate a missing column / null row and default
-- to `clientManaged: false`, so a partial deploy where the schema is
-- rolled back ahead of the app code keeps working.
-- No additional index — reads at cron time are per-user
-- (`WHERE id = $1`), matching the convention set by
-- `source_priority_json` and `doctor_report_prefs_json` on the same
-- table (no GIN index on either).
ALTER TABLE "users"
ADD COLUMN IF NOT EXISTS "notification_prefs" JSONB;
+24
View File
@@ -223,6 +223,30 @@ model User {
// payload when the toggle is off so the data never leaves the DB row.
doctorReportPrefsJson Json? @map("doctor_report_prefs_json")
// v1.4.49 M-DOUBLE-REMINDER — per-user notification preferences blob.
//
// Opt-in suppression for redundant server-side pushes. Shape:
// { "medication": { "clientManaged": boolean } }
//
// When `medication.clientManaged === true`, the reminder cron skips
// the APNs `MEDICATION_REMINDER` for this user — iOS v0.6.0.7+
// schedules local SpeziScheduler banners that cover the same dose.
// The iOS app flips the flag to true on first launch after it has
// confirmed (a) APNs permission = `.authorized`, (b) local Spezi
// reminders are actively scheduled, and (c) the binary is
// >= v0.6.0.7. Default `null` (resolved as `clientManaged: false`)
// so existing installs see zero regression — server reminders flow
// unchanged until the iOS client explicitly opts the user in.
//
// Only `MEDICATION_REMINDER` is gated by this flag; MOOD_REMINDER,
// PERSONAL_RECORD, SYSTEM_ALERT, and anomaly pushes flow unchanged.
//
// Persisted via `PATCH /api/auth/me/notification-prefs` with
// deep-merge semantics so future categories (e.g. mood,
// personal_records) slot in next to `medication` without
// overwriting siblings.
notificationPrefs Json? @map("notification_prefs")
// v1.4.47 W3 — per-user Coach opt-out (privacy-leaning users).
//
// When `true`, every Coach mount point on the web client renders