feat(notifications): give guardian and discreet deliveries the app root as tap target

Every visible delivery now carries a url. A managed-record delivery to a
guardian and a discreet one open "/", which names no event and no record;
only the silent intake sync push stays without one. The tap-target test walks
every event type across own, guardian and discreet delivery and the channels
that carry the absolute url. Document the forward-auth exemption for the
quick-answer route.
This commit is contained in:
Marc-André Bombeck committed 2026-10-15 23:25:51 +02:00
1 parent fd44a8381b
commit e292db1fbd
6 files changed
+161 -26

No files matched your search

@@ -85,7 +85,9 @@ describe("withDeliveryLinks", () => {
"guardian-1",
);
expect(out.quickActions).toBeUndefined();
expect(out.tap).toBeUndefined();
// The app root, never the managed record's own surface.
expect(out.tap?.path).toBe("/");
expect(out.tap?.url).toBe("https://health.example/");
});
it("keeps the path but drops absolute URLs and answers without a public origin", async () => {
@@ -6,10 +6,24 @@
* `Record<EventType, …>` type already refuses to compile without one; this
* pins that every entry is also a usable path).
*/
import { afterEach, describe, expect, it } from "vitest";
import { afterEach, describe, expect, it, vi } from "vitest";
import { EVENT_TYPES, type EventType } from "@/lib/notifications/types";
vi.mock("@/lib/db", () => ({
prisma: {
user: { findUnique: vi.fn().mockResolvedValue({ locale: "en" }) },
},
}));
import { withDeliveryLinks } from "@/lib/notifications/delivery-links";
import { buildNtfyMessage } from "@/lib/notifications/senders/ntfy";
import { buildWebhookBody } from "@/lib/notifications/senders/webhook";
import {
EVENT_TYPES,
type EventType,
type NotificationPayload,
} from "@/lib/notifications/types";
import {
NEUTRAL_TAP_PATH,
NOTIFICATION_TAP_PATHS,
absoluteAppUrl,
configuredAppOrigin,
@@ -91,14 +105,15 @@ describe("resolveTapPath", () => {
).toBe("/coach");
});
it("gives a managed Guardian delivery no path", () => {
it("gives a managed Guardian delivery the app root, not the event's path", () => {
expect(
resolveTapPath({
eventType: "MEDICATION_REMINDER",
recordUserId: "record-1",
recipientUserId: "guardian-1",
metadata: { url: "/medications/m1" },
}),
).toBeNull();
).toBe(NEUTRAL_TAP_PATH);
});
it("keeps a self delivery's path when both ids are the same", () => {
@@ -111,10 +126,14 @@ describe("resolveTapPath", () => {
).toBe("/medications");
});
it("gives a discreet delivery no path, so nothing names the cycle surface", () => {
it("gives a discreet delivery the app root, so nothing names the cycle surface", () => {
expect(
resolveTapPath({ eventType: "CYCLE_PERIOD_SOON", discreet: true }),
).toBeNull();
resolveTapPath({
eventType: "CYCLE_PERIOD_SOON",
discreet: true,
metadata: { url: "/cycle?day=1" },
}),
).toBe(NEUTRAL_TAP_PATH);
});
it("gives the silent sync push no path even with an override", () => {
@@ -164,3 +183,67 @@ describe("telegramLinkMode", () => {
expect(telegramLinkMode(url)).toBe(mode);
});
});
/**
* Every visible delivery carries a target on every channel: each event type,
* delivered to its own account, to a Guardian of a managed record, and in
* discreet mode. Only the silent sync push carries none. The channels that
* take the absolute URL are checked through their pure builders; APNs and Web
* Push take the path and are pinned in their own sender tests.
*/
describe("tap target on every kind and channel", () => {
const variants: Array<{
name: string;
extra: Pick<
Partial<NotificationPayload>,
"recordUserId" | "recipientUserId" | "discreet"
>;
neutral: boolean;
}> = [
{ name: "own account", extra: {}, neutral: false },
{
name: "managed Guardian",
extra: { recordUserId: "record-1", recipientUserId: "guardian-1" },
neutral: true,
},
{ name: "discreet", extra: { discreet: true }, neutral: true },
];
it.each(variants)("$name", async ({ extra, neutral }) => {
process.env.APP_URL = "https://health.example";
let checked = 0;
for (const eventType of EVENT_TYPES) {
const payload = await withDeliveryLinks(
{ userId: "u1", title: "t", message: "m", eventType, ...extra },
"guardian-1",
);
if (SILENT.has(eventType)) {
expect(payload.tap, `${eventType} is silent`).toBeUndefined();
continue;
}
const expectedPath = neutral
? NEUTRAL_TAP_PATH
: NOTIFICATION_TAP_PATHS[eventType];
expect(payload.tap?.path, eventType).toBe(expectedPath);
const url = `https://health.example${expectedPath}`;
expect(payload.tap?.url, eventType).toBe(url);
expect(
buildNtfyMessage("topic", payload).click,
`ntfy ${eventType}`,
).toBe(url);
expect(
JSON.parse(buildWebhookBody({ format: "generic" }, payload)).url,
`webhook ${eventType}`,
).toBe(url);
expect(
JSON.parse(buildWebhookBody({ format: "gotify" }, payload)).extras[
"client::notification"
].click.url,
`gotify ${eventType}`,
).toBe(url);
checked += 1;
}
expect(checked).toBe(EVENT_TYPES.length - SILENT.size);
});
});
@@ -726,7 +726,7 @@ describe("sendViaApns — dispatcher fan-out", () => {
expect(note.category).toBe("MOOD_REMINDER");
});
it("never gives a managed Guardian a tap path, even when one is set", async () => {
it("gives a managed Guardian the app root, even when a deeper path is set", async () => {
vi.mocked(prisma.device.findMany).mockResolvedValueOnce([
{ id: "d1", apnsToken: "tok-a", apnsEnvironment: "sandbox" },
] as never);
@@ -741,7 +741,24 @@ describe("sendViaApns — dispatcher fan-out", () => {
tap: { path: "/medications", url: null, label: "Open" },
});
expect(sendMock.mock.calls[0][0].payload.url).toBeUndefined();
expect(sendMock.mock.calls[0][0].payload.url).toBe("/");
});
it("carries the resolved root on a discreet delivery", async () => {
vi.mocked(prisma.device.findMany).mockResolvedValueOnce([
{ id: "d1", apnsToken: "tok-a", apnsEnvironment: "sandbox" },
] as never);
sendMock.mockResolvedValueOnce({ sent: [{ device: "tok-a" }], failed: [] });
await sendViaApns("u-1", {
title: "t",
message: "m",
eventType: "CYCLE_PERIOD_SOON",
discreet: true,
tap: { path: "/", url: null, label: "Open" },
});
expect(sendMock.mock.calls[0][0].payload.url).toBe("/");
});
it("forwards reminderId on MEASUREMENT_REMINDER and strips unlisted keys", async () => {
+8 -4
View File
@@ -33,6 +33,7 @@ import { recordPushAttemptForPayload } from "@/lib/notifications/senders/push-at
import { plainPushText } from "@/lib/notifications/strip-emoji";
import { stripHtml } from "@/lib/notifications/strip-html";
import { isManagedGuardianDelivery } from "@/lib/notifications/managed-delivery";
import { NEUTRAL_TAP_PATH } from "@/lib/notifications/tap-target";
import type { NotificationTap } from "@/lib/notifications/types";
export interface ApnsPayload {
@@ -755,10 +756,13 @@ export async function sendViaApns(
),
// The resolved tap target (`tap-target.ts`): a same-origin path
// the app opens on tap. Overrides a producer's `metadata.url`,
// which the resolver already took into account. Never on a
// managed delivery or a discreet one (the resolver returns null).
...(!managedDelivery && payload.tap?.path
? { url: payload.tap.path }
// which the resolver already took into account. A managed
// delivery and a discreet one carry the app root (the resolver
// answers `/`); the managed arm is pinned here as well, so a
// payload that bypassed the resolver still cannot deep-link a
// Guardian into the record they are only notified about.
...(payload.tap?.path
? { url: managedDelivery ? NEUTRAL_TAP_PATH : payload.tap.path }
: {}),
},
threadId: routingEvent,
+18 -9
View File
@@ -70,16 +70,25 @@ export function isSafeAppPath(value: unknown): value is string {
return true;
}
/**
* The app root: where a delivery that may not name its event leads.
*/
export const NEUTRAL_TAP_PATH = "/";
/**
* The path a delivery opens, or `null` when it carries none.
*
* - A Guardian receiving a managed-record notification gets no deep link:
* their session opens on their own record, and a path into the managed
* record's surfaces would land them on the wrong person. The app root is
* where the record switcher lives.
* - Discreet mode (cycle privacy) carries no path either, because the path
* names the surface (`/cycle`) and discreet mode promises that nothing on
* the wire names the event.
* Every visible delivery carries one (v1.43); only a silent background push
* (`null` in {@link NOTIFICATION_TAP_PATHS}) does not. Two deliveries get the
* app root instead of their event's path:
*
* - A Guardian receiving a managed-record notification: their session opens
* on their own record, and a path into the managed record's surfaces would
* land them on the wrong person. The app root is where the record switcher
* lives.
* - Discreet mode (cycle privacy): the event's path names the surface
* (`/cycle`), and discreet mode promises that nothing on the wire names the
* event. The root names nothing.
*/
export function resolveTapPath(
payload: Pick<
@@ -89,8 +98,8 @@ export function resolveTapPath(
): string | null {
const fallback = NOTIFICATION_TAP_PATHS[payload.eventType];
if (fallback === null || fallback === undefined) return null;
if (isManagedGuardianDelivery(payload)) return null;
if (payload.discreet) return null;
if (isManagedGuardianDelivery(payload)) return NEUTRAL_TAP_PATH;
if (payload.discreet) return NEUTRAL_TAP_PATH;
const override = payload.metadata?.url;
return isSafeAppPath(override) ? override : fallback;
}