mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 04:32:42 +00:00
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:
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;
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 ?? {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user