From 427e25d1aeae2ea3d1200d3c0d73de9005f80b4e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 3 Aug 2026 09:51:42 +0200 Subject: [PATCH] Add a notification for a dose somebody else marked The owner of a shared record gets an alert when a delegate marks one of their doses. It is the one exception to the rule that bookkeeping stays in the activity view: whether the tablets were taken is a fact about the owner's own body, and they should not have to go looking for it. Default ON, against the opt-in precedent of PERSONAL_RECORD, because the event only exists once the owner has invited somebody and given them write access. The per-channel row in the matrix is the valve for anyone who finds it noisy, and both positions of that preference are covered. --- .../__tests__/dispatcher.test.ts | 52 +++++++++++++++++++ src/lib/notifications/types.ts | 18 +++++++ 2 files changed, 70 insertions(+) diff --git a/src/lib/notifications/__tests__/dispatcher.test.ts b/src/lib/notifications/__tests__/dispatcher.test.ts index f350ec7b5..b8b543938 100644 --- a/src/lib/notifications/__tests__/dispatcher.test.ts +++ b/src/lib/notifications/__tests__/dispatcher.test.ts @@ -381,6 +381,58 @@ describe("dispatchNotification — per-event default policy (v1.4.25 W16c)", () expect(sendViaNtfyMock).toHaveBeenCalledTimes(1); }); + + // v1.36.x — the delegated-dose event goes the other way from + // PERSONAL_RECORD: ON without a row, because the owner invited the person + // whose action produces it. Both positions of the preference are asserted, + // so "default ON" cannot be true only because the dispatcher ignores the + // event entirely. + it("SHARED_RECORD_INTAKE defaults ON when no preference row exists", async () => { + const channel = makeChannel({ + type: "NTFY", + config: JSON.stringify({ + serverUrl: "https://ntfy.example", + topic: "t", + }), + preferences: [], + }); + vi.mocked(prisma.notificationChannel.findMany).mockResolvedValueOnce([ + channel, + ] as never); + sendViaNtfyMock.mockResolvedValueOnce({ ok: true }); + + await dispatchNotification({ + eventType: "SHARED_RECORD_INTAKE", + userId: "u-1", + title: "Wren marked a dose", + message: "Ramipril: marked as taken by Wren.", + }); + + expect(sendViaNtfyMock).toHaveBeenCalledTimes(1); + }); + + it("SHARED_RECORD_INTAKE is suppressed by an explicit per-channel opt-out", async () => { + const channel = makeChannel({ + type: "NTFY", + config: JSON.stringify({ + serverUrl: "https://ntfy.example", + topic: "t", + }), + preferences: [{ eventType: "SHARED_RECORD_INTAKE", enabled: false }], + }); + vi.mocked(prisma.notificationChannel.findMany).mockResolvedValueOnce([ + channel, + ] as never); + + await dispatchNotification({ + eventType: "SHARED_RECORD_INTAKE", + userId: "u-1", + title: "Wren marked a dose", + message: "Ramipril: marked as taken by Wren.", + }); + + expect(sendViaNtfyMock).not.toHaveBeenCalled(); + }); }); describe("dispatchNotification — MOOD_REMINDER single source of truth (v1.7.0)", () => { diff --git a/src/lib/notifications/types.ts b/src/lib/notifications/types.ts index 102871b2e..25b494515 100644 --- a/src/lib/notifications/types.ts +++ b/src/lib/notifications/types.ts @@ -112,6 +112,16 @@ export const EVENT_TYPES = [ // morning window, so a concerning reading always stays on // `MEASUREMENT_ANOMALY` and this never bypasses Focus. "DAILY_BRIEFING", + // v1.36.x — somebody with a write grant on this record marked one of the + // owner's doses. The only notification a delegated write produces: every + // other shared-record write is bookkeeping and stays in the owner's activity + // view, while "somebody said I took my tablets" is a fact about the owner's + // own body that they should not have to go looking for. Addressed to the + // OWNER — the delegate learns what they did from the response in their own + // session, not from a push (there is no channel that could carry it: the + // dispatcher addresses a userId, and under record substitution that userId + // is the owner by design). Default ON, see EVENT_DEFAULT_ENABLED. + "SHARED_RECORD_INTAKE", ] as const; export type EventType = (typeof EVENT_TYPES)[number]; @@ -179,6 +189,14 @@ export const EVENT_DEFAULT_ENABLED: Record = { // channel that has no explicit enabled row, so a fresh account never // receives a morning push it did not ask for. DAILY_BRIEFING: false, + // v1.36.x — ON by default, deliberately against the PERSONAL_RECORD opt-in + // precedent. The event cannot exist unless the owner invited a second person + // and gave them write access, so there is no backfill class of volume to + // protect against; and an owner who has to discover a setting before they + // learn that somebody else marks their doses was never told at all. The row + // sits in the /notifications matrix from day one, so an owner who finds it + // noisy silences it per channel with one switch. + SHARED_RECORD_INTAKE: true, }; export const CHANNEL_TYPE_LABELS: Record = {