diff --git a/prisma/migrations/0079_v1449_user_notification_prefs/migration.sql b/prisma/migrations/0079_v1449_user_notification_prefs/migration.sql new file mode 100644 index 000000000..26961682e --- /dev/null +++ b/prisma/migrations/0079_v1449_user_notification_prefs/migration.sql @@ -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; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index c40f16d52..52ca861bc 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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 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 new file mode 100644 index 000000000..c16c9d6b4 --- /dev/null +++ b/src/app/api/auth/me/notification-prefs/__tests__/route.test.ts @@ -0,0 +1,270 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("@/lib/db", () => ({ + prisma: { + user: { + findUnique: vi.fn(), + update: vi.fn(), + }, + }, + toJson: (v: T) => v, +})); + +vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() })); + +vi.mock("@/lib/auth/audit", () => ({ + auditLog: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() })); + +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock("@/lib/rate-limit", () => ({ + checkRateLimit: vi.fn().mockResolvedValue({ + allowed: true, + remaining: 59, + resetAt: Date.now() + 60_000, + }), + rateLimitHeaders: () => ({}), +})); + +vi.mock("next/headers", () => ({ + headers: vi.fn(async () => ({ get: () => null })), + cookies: vi.fn(async () => ({ + get: () => undefined, + set: () => {}, + delete: () => {}, + })), +})); + +import { GET, PATCH } from "../route"; +import { prisma } from "@/lib/db"; +import { getSession } from "@/lib/auth/session"; +import { auditLog } from "@/lib/auth/audit"; +import { checkRateLimit } from "@/lib/rate-limit"; + +const SESSION_OK = { + session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) }, + user: { id: "user-1", username: "marc", role: "USER" as const }, +}; + +function mkPatch(body: unknown): Request { + return new Request("http://localhost/api/auth/me/notification-prefs", { + method: "PATCH", + body: JSON.stringify(body), + headers: { "Content-Type": "application/json" }, + }); +} + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("GET /api/auth/me/notification-prefs", () => { + it("rejects an unauthenticated request with 401", async () => { + vi.mocked(getSession).mockResolvedValue(null); + + const res = await (GET as (r: Request) => Promise)( + new Request("http://localhost/api/auth/me/notification-prefs"), + ); + expect(res.status).toBe(401); + expect(prisma.user.findUnique).not.toHaveBeenCalled(); + }); + + it("returns the documented defaults for a fresh user (null row)", 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)( + new Request("http://localhost/api/auth/me/notification-prefs"), + ); + expect(res.status).toBe(200); + const env = (await res.json()) as { + data: { medication: { clientManaged: boolean } }; + }; + expect(env.data).toEqual({ medication: { clientManaged: false } }); + }); + + it("returns the resolved prefs when the row holds a value", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + notificationPrefs: { medication: { clientManaged: true } }, + } as never); + + const res = await (GET as (r: Request) => Promise)( + new Request("http://localhost/api/auth/me/notification-prefs"), + ); + expect(res.status).toBe(200); + const env = (await res.json()) as { + data: { medication: { clientManaged: boolean } }; + }; + expect(env.data).toEqual({ medication: { clientManaged: true } }); + }); + + it("returns defaults when the persisted shape has drifted", async () => { + // Forward-compat: an admin hand-edit / future-rename should not + // crash the GET. + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + notificationPrefs: { unknownCategory: { foo: "bar" } }, + } as never); + + const res = await (GET as (r: Request) => Promise)( + new Request("http://localhost/api/auth/me/notification-prefs"), + ); + expect(res.status).toBe(200); + const env = (await res.json()) as { + data: { medication: { clientManaged: boolean } }; + }; + expect(env.data).toEqual({ medication: { clientManaged: false } }); + }); +}); + +describe("PATCH /api/auth/me/notification-prefs", () => { + it("rejects an unauthenticated request with 401", async () => { + vi.mocked(getSession).mockResolvedValue(null); + + const res = await (PATCH as (r: Request) => Promise)( + mkPatch({ medication: { clientManaged: true } }), + ); + expect(res.status).toBe(401); + expect(prisma.user.update).not.toHaveBeenCalled(); + }); + + it("flips medication.clientManaged on and writes the audit row", 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)( + mkPatch({ medication: { clientManaged: true } }), + ); + expect(res.status).toBe(200); + const env = (await res.json()) as { + data: { medication: { clientManaged: boolean } }; + }; + expect(env.data).toEqual({ medication: { clientManaged: true } }); + + expect(prisma.user.update).toHaveBeenCalledWith({ + where: { id: "user-1" }, + data: { + notificationPrefs: { medication: { clientManaged: true } }, + }, + }); + + expect(auditLog).toHaveBeenCalledWith( + "user.notification-prefs.update", + expect.objectContaining({ + userId: "user-1", + details: expect.objectContaining({ + previous: { medication: { clientManaged: false } }, + next: { medication: { clientManaged: true } }, + changed: ["medication"], + }), + }), + ); + }); + + 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 res = await (PATCH as (r: Request) => Promise)(req); + expect(res.status).toBe(422); + expect(prisma.user.update).not.toHaveBeenCalled(); + }); + + it("rejects a non-boolean clientManaged with 422", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + + const res = await (PATCH as (r: Request) => Promise)( + mkPatch({ medication: { clientManaged: "yes" } }), + ); + expect(res.status).toBe(422); + expect(prisma.user.update).not.toHaveBeenCalled(); + }); + + it("deep-merges over existing siblings without overwriting them", async () => { + // Forward-compat: a row that already carries an unknown sibling + // category (e.g. a future "mood" key persisted by a newer client) + // must survive a PATCH that only touches `medication`. The route + // parses with the current zod schema first, then deep-merges the + // input over the parsed (defaulted) base. Today, "unknown sibling" + // resolves back to defaults; the test pins that the medication + // PATCH still lands and that future schema growth (adding new + // siblings to the zod shape) will preserve them. + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + notificationPrefs: { medication: { clientManaged: false } }, + } as never); + vi.mocked(prisma.user.update).mockResolvedValue({} as never); + + const res = await (PATCH as (r: Request) => Promise)( + mkPatch({ medication: { clientManaged: true } }), + ); + expect(res.status).toBe(200); + + expect(prisma.user.update).toHaveBeenCalledWith({ + where: { id: "user-1" }, + data: { + notificationPrefs: { medication: { clientManaged: true } }, + }, + }); + }); + + it("returns the merged shape unchanged on an empty PATCH body", async () => { + // Idempotent — an empty body keeps the row as-is and still writes + // the audit trail (mirrors the disable-coach posture). + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + notificationPrefs: { medication: { clientManaged: true } }, + } as never); + vi.mocked(prisma.user.update).mockResolvedValue({} as never); + + const res = await (PATCH as (r: Request) => Promise)( + mkPatch({}), + ); + expect(res.status).toBe(200); + const env = (await res.json()) as { + data: { medication: { clientManaged: boolean } }; + }; + expect(env.data).toEqual({ medication: { clientManaged: true } }); + + expect(prisma.user.update).toHaveBeenCalledWith({ + where: { id: "user-1" }, + data: { + notificationPrefs: { medication: { clientManaged: true } }, + }, + }); + }); + + it("returns 429 when the per-user rate-limit fires", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(checkRateLimit).mockResolvedValueOnce({ + allowed: false, + remaining: 0, + resetAt: Date.now() + 30_000, + }); + + const res = await (PATCH as (r: Request) => Promise)( + mkPatch({ medication: { clientManaged: true } }), + ); + expect(res.status).toBe(429); + expect(prisma.user.update).not.toHaveBeenCalled(); + }); +}); diff --git a/src/app/api/auth/me/notification-prefs/route.ts b/src/app/api/auth/me/notification-prefs/route.ts new file mode 100644 index 000000000..f22c1b1f6 --- /dev/null +++ b/src/app/api/auth/me/notification-prefs/route.ts @@ -0,0 +1,133 @@ +/** + * v1.4.49 M-DOUBLE-REMINDER — per-user notification preferences endpoint. + * + * GET /api/auth/me/notification-prefs — current resolved prefs. + * PATCH /api/auth/me/notification-prefs — body `{ medication?: { clientManaged?: boolean } }`. + * Deep-merges the supplied + * shape over the persisted + * row so future categories + * slot in next to + * `medication` without + * overwriting siblings. + * + * Auth is shared via `requireAuth()` so cookie sessions + Bearer + * tokens both work (the iOS app uses Bearer). Default shape lives in + * `parseNotificationPrefs` — a null DB row resolves to + * `{ medication: { clientManaged: false } }`, preserving the legacy + * server-side reminder behaviour for any user the iOS client has not + * yet opted in. + * + * Idempotent. The endpoint always returns the fully-resolved next + * state so the client can hard-set the optimistic update without an + * extra round-trip. Rate-limit is intentionally generous (60/min) — + * matches the disable-coach route which is the closest analogue. + */ +import { apiHandler, requireAuth, HttpError } from "@/lib/api-handler"; +import { + apiError, + apiSuccess, + getClientIp, + returnAllZodIssues, +} from "@/lib/api-response"; +import { annotate } from "@/lib/logging/context"; +import { auditLog } from "@/lib/auth/audit"; +import { prisma, toJson } from "@/lib/db"; +import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit"; +import { + notificationPrefsSchema, + parseNotificationPrefs, + resolveNotificationPrefs, +} from "@/lib/validations/notification-prefs"; + +export const dynamic = "force-dynamic"; + +const PATCH_RATE_LIMIT = 60; +const PATCH_WINDOW_MS = 60_000; + +export const GET = apiHandler(async () => { + const { user } = await requireAuth(); + annotate({ action: { name: "auth.me.notification-prefs.get" } }); + + const row = await prisma.user.findUnique({ + where: { id: user.id }, + select: { notificationPrefs: true }, + }); + return apiSuccess(parseNotificationPrefs(row?.notificationPrefs ?? null)); +}); + +export const PATCH = apiHandler(async (req: Request) => { + const { user } = await requireAuth(); + + const rl = await checkRateLimit( + `notification-prefs:patch:${user.id}`, + PATCH_RATE_LIMIT, + PATCH_WINDOW_MS, + ); + if (!rl.allowed) { + const response = apiError("Too many requests", 429); + for (const [k, v] of Object.entries(rateLimitHeaders(rl))) { + response.headers.set(k, v); + } + return response; + } + + let body: unknown; + try { + body = await req.json(); + } catch { + throw new HttpError(422, "notification-prefs.body.invalid_json"); + } + + const parsed = notificationPrefsSchema.safeParse(body ?? {}); + if (!parsed.success) { + annotate({ + action: { name: "auth.me.notification-prefs.patch.invalid_shape" }, + meta: { issues: parsed.error.issues.length }, + }); + return returnAllZodIssues(parsed.error, 422); + } + + // Deep-merge the partial input over the current row so a PATCH that + // only touches `medication` does not overwrite future sibling + // categories (mood, personal_records, ...). The full resolved shape + // is persisted so the column layout stays stable across schema + // additions. + const current = await prisma.user.findUnique({ + where: { id: user.id }, + select: { notificationPrefs: true }, + }); + const previous = parseNotificationPrefs(current?.notificationPrefs ?? null); + const merged = resolveNotificationPrefs( + current?.notificationPrefs ?? null, + parsed.data, + ); + + await prisma.user.update({ + where: { id: user.id }, + data: { notificationPrefs: toJson(merged) }, + }); + + // Capture which categories the PATCH actually touched so the audit + // trail records the user's intent, not just the full resolved row. + const changedCategories = Object.keys(parsed.data); + + await auditLog("user.notification-prefs.update", { + userId: user.id, + ipAddress: getClientIp(req), + details: { + previous, + next: merged, + changed: changedCategories, + }, + }); + + annotate({ + action: { name: "user.notification_prefs.update" }, + meta: { + changed: changedCategories, + medication_client_managed: merged.medication.clientManaged, + }, + }); + + return apiSuccess(merged); +}); diff --git a/src/lib/jobs/reminder-worker.ts b/src/lib/jobs/reminder-worker.ts index e61b6d35d..9d8f9a16e 100644 --- a/src/lib/jobs/reminder-worker.ts +++ b/src/lib/jobs/reminder-worker.ts @@ -110,6 +110,7 @@ import { getPhaseKeyboard, } from "@/lib/jobs/reminder-phases"; import { runMoodReminderTick } from "@/lib/jobs/mood-reminder"; +import { isMedicationReminderClientManaged } from "@/lib/validations/notification-prefs"; import { withBackgroundEvent } from "@/lib/logging/background"; import { assertSubsystemEnabled } from "@/lib/process-type"; import { runOffhostBackup } from "@/lib/jobs/offhost-backup"; @@ -459,6 +460,12 @@ async function handleReminderCheck(jobs: Job[]) { // Used to localise the reminder title / message / keyboard // labels per user. Null falls back to the app default. locale: true, + // v1.4.49 M-DOUBLE-REMINDER — read the per-user prefs + // blob so the dispatch step can skip APNs sends for users + // whose iOS client has opted in to local SpeziScheduler + // reminders. Null = legacy default (clientManaged: false), + // i.e. the server reminder fires as before. + notificationPrefs: true, }, }, }, @@ -623,6 +630,42 @@ async function handleReminderCheck(jobs: Job[]) { // Send notification if enabled if (med.notificationsEnabled) { + // v1.4.49 M-DOUBLE-REMINDER — opt-in client-managed + // suppression. When the iOS app has confirmed local + // SpeziScheduler banners cover the dose, the server-side + // APNs push is redundant. Skip the dispatch and emit a + // wide-event annotation so the operator can audit the + // skip path. ONLY suppresses MEDICATION_REMINDER — other + // notification kinds (MOOD_REMINDER, PERSONAL_RECORD, + // SYSTEM_ALERT, anomaly alerts) flow unchanged. + if ( + isMedicationReminderClientManaged(med.user.notificationPrefs) + ) { + const [winH, winM] = schedule.windowStart.split(":").map(Number); + const doseAtIso = localHmAsUtc( + now, + med.user.timezone, + winH, + winM, + ).toISOString(); + evt.addMeta( + "medication_reminder_suppressed_client_managed", + `${med.name}:${schedule.windowStart}-${schedule.windowEnd}`, + ); + evt.addMeta( + "medication_reminder_suppressed_meta", + { + user_id: med.user.id, + medication_id: med.id, + schedule_id: schedule.id, + phase: currentPhase, + dose_at: doseAtIso, + }, + ); + schedulesProcessed++; + continue; + } + const { title, message } = getPhaseMessage( currentPhase, med.name, diff --git a/src/lib/validations/__tests__/notification-prefs.test.ts b/src/lib/validations/__tests__/notification-prefs.test.ts new file mode 100644 index 000000000..2b45a6c46 --- /dev/null +++ b/src/lib/validations/__tests__/notification-prefs.test.ts @@ -0,0 +1,146 @@ +import { describe, expect, it } from "vitest"; + +import { + DEFAULT_NOTIFICATION_PREFS, + isMedicationReminderClientManaged, + notificationPrefsSchema, + parseNotificationPrefs, + resolveNotificationPrefs, +} from "../notification-prefs"; + +/** + * v1.4.49 M-DOUBLE-REMINDER — per-user notification preferences blob. + * + * The cron at `src/lib/jobs/reminder-worker.ts` reads + * `user.notificationPrefs` and calls + * `isMedicationReminderClientManaged` to decide whether to skip the + * MEDICATION_REMINDER APNs dispatch. These tests pin the helper's + * contract so the cron skip is deterministic across schema drift. + */ +describe("notificationPrefsSchema", () => { + it("accepts an empty object (every key is optional)", () => { + const out = notificationPrefsSchema.parse({}); + expect(out).toEqual({}); + }); + + it("accepts the medication.clientManaged shape", () => { + const input = { medication: { clientManaged: true } }; + const out = notificationPrefsSchema.parse(input); + expect(out).toEqual(input); + }); + + it("rejects a non-boolean clientManaged", () => { + const res = notificationPrefsSchema.safeParse({ + medication: { clientManaged: "yes" }, + }); + expect(res.success).toBe(false); + }); +}); + +describe("parseNotificationPrefs", () => { + it("returns documented defaults for null", () => { + expect(parseNotificationPrefs(null)).toEqual(DEFAULT_NOTIFICATION_PREFS); + }); + + it("returns documented defaults for undefined", () => { + expect(parseNotificationPrefs(undefined)).toEqual( + DEFAULT_NOTIFICATION_PREFS, + ); + }); + + it("returns defaults when the persisted shape has drifted", () => { + expect(parseNotificationPrefs({ unknown: "shape" })).toEqual( + DEFAULT_NOTIFICATION_PREFS, + ); + }); + + it("returns the persisted shape when valid", () => { + expect( + parseNotificationPrefs({ medication: { clientManaged: true } }), + ).toEqual({ medication: { clientManaged: true } }); + }); + + it("fills missing keys from the defaults", () => { + expect(parseNotificationPrefs({ medication: {} })).toEqual({ + medication: { clientManaged: false }, + }); + }); +}); + +describe("resolveNotificationPrefs (deep-merge)", () => { + it("layers the input over the persisted row", () => { + const out = resolveNotificationPrefs( + { medication: { clientManaged: false } }, + { medication: { clientManaged: true } }, + ); + expect(out).toEqual({ medication: { clientManaged: true } }); + }); + + it("preserves the persisted medication keys when the input only touches new sub-keys", () => { + // Future-proof: the route hands the merged shape to Prisma, so + // sibling keys inside `medication` must survive a partial PATCH. + const out = resolveNotificationPrefs( + { medication: { clientManaged: true } }, + { medication: {} }, + ); + expect(out).toEqual({ medication: { clientManaged: true } }); + }); + + it("falls back to defaults when the persisted row is null", () => { + const out = resolveNotificationPrefs(null, { + medication: { clientManaged: true }, + }); + expect(out).toEqual({ medication: { clientManaged: true } }); + }); + + it("returns the defaults when neither side carries a value", () => { + const out = resolveNotificationPrefs(null, {}); + expect(out).toEqual(DEFAULT_NOTIFICATION_PREFS); + }); +}); + +describe("isMedicationReminderClientManaged — cron-skip gate", () => { + it("returns false for a null row (legacy server-managed default)", () => { + expect(isMedicationReminderClientManaged(null)).toBe(false); + }); + + it("returns false for undefined (Prisma findUnique miss)", () => { + expect(isMedicationReminderClientManaged(undefined)).toBe(false); + }); + + it("returns false when the user has not opted in", () => { + expect( + isMedicationReminderClientManaged({ + medication: { clientManaged: false }, + }), + ).toBe(false); + }); + + it("returns true when the user has opted in via the iOS app", () => { + expect( + isMedicationReminderClientManaged({ + medication: { clientManaged: true }, + }), + ).toBe(true); + }); + + it("returns false for a drifted persisted shape (forward-compat)", () => { + expect( + isMedicationReminderClientManaged({ notACategory: { foo: "bar" } }), + ).toBe(false); + }); + + it("returns false when only sibling categories are present", () => { + // Future-proof: another (future) category set to true must NOT + // gate MEDICATION_REMINDER — only `medication.clientManaged` + // affects the medication cron. + expect( + isMedicationReminderClientManaged({ + // Cast through unknown — the production runtime sees this as + // a JSONB value that bypasses the zod schema, but the helper + // must still resolve to the safe default. + otherCategory: { enabled: true }, + } as unknown), + ).toBe(false); + }); +}); diff --git a/src/lib/validations/notification-prefs.ts b/src/lib/validations/notification-prefs.ts new file mode 100644 index 000000000..c10c3ed50 --- /dev/null +++ b/src/lib/validations/notification-prefs.ts @@ -0,0 +1,128 @@ +/** + * 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 } + * } + * + * 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(), + }) + .partial(); + +/** + * 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, + }) + .partial(); + +export type NotificationPrefsInput = z.infer; + +/** + * 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; + }; +} + +/** + * Safe defaults. Every category is off so the server reminders flow + * unchanged for any user the iOS app has not explicitly opted in. + * Marc's directive: zero regression for clients without working + * local reminders. + */ +export const DEFAULT_NOTIFICATION_PREFS: NotificationPrefs = { + medication: { + 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 { + medication: { + ...base.medication, + ...(incoming.medication ?? {}), + }, + }; +} + +/** + * 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. + */ +export function isMedicationReminderClientManaged(raw: unknown): boolean { + return parseNotificationPrefs(raw).medication.clientManaged === true; +} + +function cloneDefaults(): NotificationPrefs { + return { + medication: { ...DEFAULT_NOTIFICATION_PREFS.medication }, + }; +} + +function mergeOverDefaults( + input: NotificationPrefsInput, +): NotificationPrefs { + return { + medication: { + ...DEFAULT_NOTIFICATION_PREFS.medication, + ...(input.medication ?? {}), + }, + }; +}