Merge pull request #706 from MBombeck/work/dw-c

Tell the owner when somebody else marks a dose
This commit is contained in:
Marc-André Bombeck authored and GitHub committed 2026-08-03 11:22:17 +02:00
commit 9e3cd7c430
14 files changed
+859 -13

No files matched your search

+6 -13
View File
@@ -15,6 +15,10 @@ import {
EVENT_DEFAULT_ENABLED,
type EventType,
} from "@/lib/notifications/types";
import {
eventDescriptionTranslationKey,
eventTranslationKey,
} from "@/lib/notifications/event-labels";
import { queryKeys } from "@/lib/query-keys";
import { apiGet, apiPut } from "@/lib/api/api-fetch";
@@ -38,17 +42,6 @@ interface PreferencesData {
eventTypes: string[];
}
/** Maps SCREAMING_SNAKE event type to translation key, e.g. MEDICATION_REMINDER → eventMedicationReminder */
function eventTranslationKey(eventType: string): string {
return (
"notifications.event" +
eventType
.toLowerCase()
.replace(/_([a-z])/g, (_, c: string) => c.toUpperCase())
.replace(/^./, (c) => c.toUpperCase())
);
}
export default function NotificationsPage() {
const { isAuthenticated, isLoading: authLoading } = useAuth();
const { t } = useTranslations();
@@ -265,7 +258,7 @@ export default function NotificationsPage() {
<tbody>
{eventTypes.map((eventType, idx) => {
const nameKey = eventTranslationKey(eventType);
const descKey = nameKey + "Desc";
const descKey = eventDescriptionTranslationKey(eventType);
return (
<tr
key={eventType}
@@ -311,7 +304,7 @@ export default function NotificationsPage() {
<div className="space-y-4 md:hidden">
{eventTypes.map((eventType) => {
const nameKey = eventTranslationKey(eventType);
const descKey = nameKey + "Desc";
const descKey = eventDescriptionTranslationKey(eventType);
return (
<div
key={eventType}
@@ -0,0 +1,198 @@
/**
* v1.36.x — "somebody else marked your dose" helper.
*
* What is pinned here, and why each one is a property rather than an
* observation:
*
* 1. The notification is addressed to the OWNER and names the acting person
* and the medication. The delegate is not a recipient anywhere in this
* file, because there is no delegate-directed push to assert.
* 2. The self refusal lives in the helper, so a future call site that forgets
* to compare ids still cannot notify somebody about their own dose.
* 3. A snooze is not a marking and produces nothing.
* 4. The medication is read scoped to the owner — the name that reaches a
* lock screen comes from the recipient's own rows.
* 5. Nothing here throws: a failed notification never fails the write that
* produced it.
*
* Mutation check (run before this file was committed): deleting the
* `actorId === ownerId` guard turns "fires nothing when the owner marks their
* own dose" red with `expected "spy" to not be called at all, but actually
* been called 1 times`. Deleting the `messageKey === null` return turns the
* snooze case red the same way.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
const dispatchLocalisedMock = vi.fn();
const addWarningMock = vi.fn();
vi.mock("@/lib/notifications/dispatch-localised", () => ({
dispatchLocalisedNotification: (...args: unknown[]) =>
dispatchLocalisedMock(...args),
}));
vi.mock("@/lib/logging/context", () => ({
getEvent: () => ({ addWarning: addWarningMock, addMeta: vi.fn() }),
}));
const userFindUnique = vi.fn();
const medicationFindFirst = vi.fn();
vi.mock("@/lib/db", () => ({
prisma: {
user: { findUnique: (...a: unknown[]) => userFindUnique(...a) },
medication: { findFirst: (...a: unknown[]) => medicationFindFirst(...a) },
},
}));
import {
delegatedIntakeMessageKey,
notifyDelegatedIntake,
} from "@/lib/notifications/delegated-intake";
const OWNER = "owner-account";
const DELEGATE = "delegate-account";
const MEDICATION = "med-1";
beforeEach(() => {
vi.clearAllMocks();
userFindUnique.mockResolvedValue({
username: "wren",
displayName: "Wren",
});
medicationFindFirst.mockResolvedValue({ name: "Ramipril" });
dispatchLocalisedMock.mockResolvedValue(undefined);
});
describe("delegatedIntakeMessageKey", () => {
it("maps a marking to its own body and a snooze to nothing", () => {
expect(delegatedIntakeMessageKey("taken")).toBe(
"notifications.sharedRecordIntake.bodyTaken",
);
expect(delegatedIntakeMessageKey("skipped")).toBe(
"notifications.sharedRecordIntake.bodySkipped",
);
expect(delegatedIntakeMessageKey("snoozed")).toBeNull();
});
});
describe("notifyDelegatedIntake", () => {
it("notifies the owner, naming the acting person and the medication", async () => {
await notifyDelegatedIntake({
ownerId: OWNER,
actorId: DELEGATE,
medicationId: MEDICATION,
status: "taken",
});
expect(dispatchLocalisedMock).toHaveBeenCalledTimes(1);
expect(dispatchLocalisedMock).toHaveBeenCalledWith({
userId: OWNER,
eventType: "SHARED_RECORD_INTAKE",
titleKey: "notifications.sharedRecordIntake.title",
messageKey: "notifications.sharedRecordIntake.bodyTaken",
params: { name: "Wren", medication: "Ramipril" },
metadata: { medicationId: MEDICATION },
});
});
it("carries the skipped body for a skipped dose", async () => {
await notifyDelegatedIntake({
ownerId: OWNER,
actorId: DELEGATE,
medicationId: MEDICATION,
status: "skipped",
});
expect(dispatchLocalisedMock.mock.calls[0][0]).toMatchObject({
messageKey: "notifications.sharedRecordIntake.bodySkipped",
});
});
it("falls back to the username when the acting account set no display name", async () => {
userFindUnique.mockResolvedValue({ username: "wren", displayName: null });
await notifyDelegatedIntake({
ownerId: OWNER,
actorId: DELEGATE,
medicationId: MEDICATION,
status: "taken",
});
expect(dispatchLocalisedMock.mock.calls[0][0].params).toMatchObject({
name: "wren",
});
});
it("fires nothing when the owner marks their own dose", async () => {
await notifyDelegatedIntake({
ownerId: OWNER,
actorId: OWNER,
medicationId: MEDICATION,
status: "taken",
});
expect(dispatchLocalisedMock).not.toHaveBeenCalled();
// Not even a lookup: the refusal is the first thing the helper does.
expect(userFindUnique).not.toHaveBeenCalled();
expect(medicationFindFirst).not.toHaveBeenCalled();
});
it("fires nothing for a snooze", async () => {
await notifyDelegatedIntake({
ownerId: OWNER,
actorId: DELEGATE,
medicationId: MEDICATION,
status: "snoozed",
});
expect(dispatchLocalisedMock).not.toHaveBeenCalled();
});
it("reads the medication scoped to the owner", async () => {
await notifyDelegatedIntake({
ownerId: OWNER,
actorId: DELEGATE,
medicationId: MEDICATION,
status: "taken",
});
expect(medicationFindFirst).toHaveBeenCalledWith({
where: { id: MEDICATION, userId: OWNER },
select: { name: true },
});
});
it("stays silent, with a warning, when the medication is not the owner's", async () => {
medicationFindFirst.mockResolvedValue(null);
await notifyDelegatedIntake({
ownerId: OWNER,
actorId: DELEGATE,
medicationId: MEDICATION,
status: "taken",
});
expect(dispatchLocalisedMock).not.toHaveBeenCalled();
expect(addWarningMock).toHaveBeenCalledWith(
expect.stringContaining("medication=missing"),
);
});
it("swallows a dispatch failure so the write that produced it survives", async () => {
dispatchLocalisedMock.mockRejectedValue(new Error("telegram is down"));
await expect(
notifyDelegatedIntake({
ownerId: OWNER,
actorId: DELEGATE,
medicationId: MEDICATION,
status: "taken",
}),
).resolves.toBeUndefined();
expect(addWarningMock).toHaveBeenCalledWith(
expect.stringContaining("telegram is down"),
);
});
});
@@ -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)", () => {
@@ -0,0 +1,114 @@
/**
* Every event type the /notifications matrix renders has words to render.
*
* The matrix builds its rows from `EVENT_TYPES` and derives each row's label
* and description key from the event name, so a new event type silently gets a
* row whether or not anybody wrote the two strings for it. That is not a
* theory: `SECURITY_ALERT` shipped in v1.23 and rendered the raw key
* `notifications.eventSecurityAlert` in the matrix until v1.36.x, because
* nothing anywhere asserted the pairing — the forward i18n coverage test only
* sees literal `t("…")` call sites, and this key is assembled from a runtime
* value.
*
* So this test walks the event list the page walks, computes the keys the page
* computes, and asserts both resolve in every locale bundle.
*
* Mutation check: remove `notifications.eventSharedRecordIntakeDesc` from
* `messages/pl.json` and this file reports
* `pl is missing notifications.eventSharedRecordIntakeDesc`.
*/
import { describe, expect, it } from "vitest";
import { readdirSync, readFileSync } from "node:fs";
import { join } from "node:path";
import { EVENT_TYPES } from "@/lib/notifications/types";
import {
eventDescriptionTranslationKey,
eventTranslationKey,
} from "@/lib/notifications/event-labels";
const MESSAGES_DIR = join(__dirname, "../../../../messages");
function bundle(locale: string): Record<string, unknown> {
return JSON.parse(
readFileSync(join(MESSAGES_DIR, `${locale}.json`), "utf8"),
) as Record<string, unknown>;
}
function resolve(root: Record<string, unknown>, key: string): unknown {
return key
.split(".")
.reduce<unknown>(
(node, part) =>
node && typeof node === "object"
? (node as Record<string, unknown>)[part]
: undefined,
root,
);
}
const LOCALES = readdirSync(MESSAGES_DIR)
.filter((f) => f.endsWith(".json"))
.map((f) => f.replace(/\.json$/, ""));
describe("notification event labels", () => {
it("derives the key the matrix renders under", () => {
expect(eventTranslationKey("MEDICATION_REMINDER")).toBe(
"notifications.eventMedicationReminder",
);
expect(eventDescriptionTranslationKey("SHARED_RECORD_INTAKE")).toBe(
"notifications.eventSharedRecordIntakeDesc",
);
});
it("has a label and a description for every event type, in every locale", () => {
expect(LOCALES.length).toBeGreaterThanOrEqual(6);
expect(EVENT_TYPES.length).toBeGreaterThan(10);
const missing: string[] = [];
for (const locale of LOCALES) {
const root = bundle(locale);
for (const eventType of EVENT_TYPES) {
for (const key of [
eventTranslationKey(eventType),
eventDescriptionTranslationKey(eventType),
]) {
const value = resolve(root, key);
if (typeof value !== "string" || value.trim().length === 0) {
missing.push(`${locale} is missing ${key}`);
}
}
}
}
expect(missing).toEqual([]);
});
});
describe("the delegated-dose push copy", () => {
it("interpolates the same names in every locale", () => {
const keys = [
"notifications.sharedRecordIntake.title",
"notifications.sharedRecordIntake.bodyTaken",
"notifications.sharedRecordIntake.bodySkipped",
];
const placeholders: Record<string, string[]> = {
"notifications.sharedRecordIntake.title": ["{name}"],
"notifications.sharedRecordIntake.bodyTaken": ["{name}", "{medication}"],
"notifications.sharedRecordIntake.bodySkipped": [
"{name}",
"{medication}",
],
};
for (const locale of LOCALES) {
const root = bundle(locale);
for (const key of keys) {
const value = resolve(root, key);
expect(typeof value, `${locale} ${key}`).toBe("string");
for (const token of placeholders[key]) {
expect(value as string, `${locale} ${key}`).toContain(token);
}
}
}
});
});
+136
View File
@@ -0,0 +1,136 @@
/**
* v1.36.x — "somebody else marked your dose".
*
* The one notification a delegated write produces. Everything else a delegate
* adds to a shared record (a reading, a lab result, an allergy) is bookkeeping
* and lives in the owner's activity view; a dose is a statement about the
* owner's own body, and the owner is the person best placed to decide whether
* they want to hear about it. So this rides the ordinary alert cascade with an
* ordinary per-channel preference row, and nothing about it is special-cased
* inside the dispatcher.
*
* Two properties are structural rather than observed:
*
* 1. **It refuses to fire on self.** The owner never gets a push about their
* own dose. The refusal lives HERE, not at the call sites, so a future
* route that calls this helper cannot forget it.
* 2. **It addresses the owner and only the owner.** The delegate gets their
* feedback from the response they are already awaiting, rendered in their
* own session. There is no delegate-directed push and this file is not the
* place to grow one: the dispatcher takes a `userId`, and under record
* substitution that id is the owner by design.
*
* The medication is named in the push body, the same verbosity class as
* MEDICATION_REMINDER, which has named medications on the lock screen since
* v1.4.40. No dose figures ride along.
*/
import { prisma } from "@/lib/db";
import { getEvent } from "@/lib/logging/context";
import { accountLabel } from "@/lib/sharing/account-access-view";
import { dispatchLocalisedNotification } from "@/lib/notifications/dispatch-localised";
/**
* The dose states the intake routes write. `"snoozed"` is deliberately in the
* union: both intake routes can produce it, and the helper answering "that one
* is not a marking" in one place beats every call site remembering to.
*/
export type DelegatedIntakeStatus = "taken" | "skipped" | "snoozed";
/**
* Which body the push carries for a dose state, or `null` when the state is
* not a marking at all.
*
* Pure, exported, and tested on its own: a snooze moves a dose, it does not
* resolve it, and the web-push dose-clear draws the same line (`intake/route
* .ts` clears the pending reminder for taken / skipped only). An owner woken
* by "your carer postponed a dose by ten minutes" would be the noise this
* whole event type is trying not to be.
*/
export function delegatedIntakeMessageKey(
status: DelegatedIntakeStatus,
): string | null {
switch (status) {
case "taken":
return "notifications.sharedRecordIntake.bodyTaken";
case "skipped":
return "notifications.sharedRecordIntake.bodySkipped";
case "snoozed":
return null;
}
}
export interface NotifyDelegatedIntakeInput {
/** The account the dose belongs to — the recipient of the notification. */
ownerId: string;
/** The account that actually marked it. Equal to `ownerId` on a self-write. */
actorId: string;
/** The medication whose dose was marked. Resolved for its name, owner-scoped. */
medicationId: string;
/** What the dose was marked as. */
status: DelegatedIntakeStatus;
}
/**
* Tell the owner that somebody else marked one of their doses.
*
* Never throws: a notification that fails is not a reason to fail the write
* that produced it. Await it — the call sites reach this only on a delegated
* write, so the added latency lands on the caregiver's request and never on
* the owner's own hot path, and awaiting is what makes "one row in
* `push_attempts`" a thing a test can assert rather than a thing it can race.
*/
export async function notifyDelegatedIntake(
input: NotifyDelegatedIntakeInput,
): Promise<void> {
const { ownerId, actorId, medicationId, status } = input;
// The self refusal. First, cheap, and unconditional.
if (actorId === ownerId) return;
const messageKey = delegatedIntakeMessageKey(status);
if (messageKey === null) return;
try {
const [actor, medication] = await Promise.all([
prisma.user.findUnique({
where: { id: actorId },
select: { username: true, displayName: true },
}),
// Scoped to the owner: the name in this body goes onto the owner's lock
// screen, so it is read from the owner's own row set or not at all.
prisma.medication.findFirst({
where: { id: medicationId, userId: ownerId },
select: { name: true },
}),
]);
// Both halves of the sentence or none of it. An acting account that no
// longer resolves is nobody we can name, and a medication that does not
// resolve under the OWNER is not one whose name belongs on their lock
// screen. Neither is reachable from the intake routes, which have both
// rows in hand before they call; a warning to ops beats a push that says
// "somebody marked something".
if (!actor || !medication) {
getEvent()?.addWarning(
`notifyDelegatedIntake — nothing to name (actor=${actor ? "ok" : "missing"} medication=${medication ? "ok" : "missing"}), no notification sent`,
);
return;
}
await dispatchLocalisedNotification({
userId: ownerId,
eventType: "SHARED_RECORD_INTAKE",
titleKey: "notifications.sharedRecordIntake.title",
messageKey,
params: {
name: accountLabel(actor),
medication: medication.name,
},
metadata: { medicationId },
});
} catch (err) {
getEvent()?.addWarning(
`notifyDelegatedIntake failed: ${err instanceof Error ? err.message : String(err)}`,
);
}
}
+28
View File
@@ -0,0 +1,28 @@
/**
* The i18n key an event type is rendered under.
*
* The /notifications matrix builds its rows from the event list the server
* publishes, so the label for a new event type is never written at a call site
* — it is derived here. Extracted from the page so the derivation can be
* pinned by a test without rendering anything, and so the guard that asserts
* every event type actually HAS its two strings (label + description, six
* locales) computes the key exactly the way the page does rather than a way
* that agrees with it today.
*
* `MEDICATION_REMINDER` → `notifications.eventMedicationReminder`; the
* description key is that plus `Desc`.
*/
export function eventTranslationKey(eventType: string): string {
return (
"notifications.event" +
eventType
.toLowerCase()
.replace(/_([a-z])/g, (_, c: string) => c.toUpperCase())
.replace(/^./, (c) => c.toUpperCase())
);
}
/** The description key that sits under the label in the matrix row. */
export function eventDescriptionTranslationKey(eventType: string): string {
return `${eventTranslationKey(eventType)}Desc`;
}
+18
View File
@@ -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<EventType, boolean> = {
// 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<ChannelType, string> = {