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
@@ -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: <T,>(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<Response>)(
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<Response>)(
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<Response>)(
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<Response>)(
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<Response>)(
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<Response>)(
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<Response>)(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<Response>)(
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<Response>)(
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<Response>)(
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<Response>)(
mkPatch({ medication: { clientManaged: true } }),
);
expect(res.status).toBe(429);
expect(prisma.user.update).not.toHaveBeenCalled();
});
});
@@ -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);
});
+43
View File
@@ -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<ReminderCheckPayload>[]) {
// 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<ReminderCheckPayload>[]) {
// 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,
@@ -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);
});
});
+128
View File
@@ -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<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;
};
}
/**
* 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 ?? {}),
},
};
}