Merge the notification dispatch end-to-end journey, the account-scoped reminder sweep and the published notification preferences

This commit is contained in:
Marc-André Bombeck committed 2026-09-10 06:37:56 +02:00
commit 8b1ca7ee5a
15 files changed
+1742 -14

No files matched your search

@@ -1,5 +1,6 @@
/**
* v1.32.36 — pair guard over the account payload (`GET /api/auth/me`).
* v1.32.36 — pair guard over the account payload (`GET /api/auth/me`), in both
* directions.
*
* The recurring failure this exists to prevent: a two-ended feature whose ends
* ship in different releases. The column lands with the schema change, the
@@ -33,12 +34,39 @@
* be allowlisted with that reason written down, and the reason is an
* assumption about another repository until an audit crosses over.
*
* ## The other direction: a reader with no field
*
* Everything above asks that a published field has a reader. It cannot see the
* inverse — a client that reads a field the payload never published — and that
* blind spot cost a shipped feature: the medication surface decided between
* the server-side reminder switch and the client-managed chip on
* `notificationPrefs.medication.clientManaged`, read off this payload, which
* did not carry it. The flag was `undefined` for every account, so the chip
* never rendered and a person whose phone owned the reminders was shown a
* switch that decided nothing. Every guard was green.
*
* The last case closes it, and the shape it takes is deliberate. A typed guard
* — one `AccountPayload` interface, `tsc` refusing an absent property — was the
* first idea and does not work here: the consumers declare their own local
* response interfaces (`interface UserPrefsResponse { … }`), so `tsc` checks
* each file against its own private claim about the payload and agrees with
* every one of them. So the check reads those local claims instead, out of the
* file that makes them, and holds them against what the route publishes. It
* asserts a non-zero match count, because a textual guard that silently
* matches nothing is the failure mode this repository already has on record.
*
* Its limit, written down: it sees the response type a `/api/auth/me` read
* names, so a consumer that types the read as `unknown` and indexes into it, or
* imports its response type from another module, is invisible to it.
*
* Mutation check: append a field to the response literal in
* `src/app/api/auth/me/route.ts` and this goes red; delete the practice-name
* read in `health-record-export-panel.tsx` and it goes red too.
* read in `health-record-export-panel.tsx` and it goes red too; delete
* `notificationPrefs` from the response literal and the last case goes red
* naming the medication notification section.
*/
import { readFileSync, readdirSync, statSync } from "node:fs";
import { join } from "node:path";
import { join, relative } from "node:path";
import { describe, it, expect } from "vitest";
@@ -189,6 +217,31 @@ function readsField(text: string, field: string): boolean {
return re.test(text);
}
/**
* Top-level member names of a locally declared `interface Name { … }`, or null
* when the file does not declare one under that name (the type is imported, or
* is a shared transport type this file only references).
*
* Members are read at one indent level, the same way the route's response
* literal is read above — the tree is prettier-formatted, so a nested object's
* keys sit deeper and are not mistaken for top-level fields.
*/
function declaredMembers(text: string, name: string): string[] | null {
const open = new RegExp(`\\binterface\\s+${name}\\s*\\{`).exec(text);
if (!open) return null;
const start = open.index + open[0].length;
const end = text.indexOf("\n}", start);
if (end === -1) return null;
const body = text.slice(start, end);
return [
...new Set(
[...body.matchAll(/^ {2}([a-zA-Z_$][\w$]*)\??\s*[?:]/gm)].map(
(m) => m[1],
),
),
];
}
describe("account payload consumer guard", () => {
const source = readFileSync(ME_ROUTE, "utf8");
const fields = payloadFields(source);
@@ -294,4 +347,40 @@ describe("account payload consumer guard", () => {
).toMatch(new RegExp(`active\\.${field}(?![\\w$])`));
}
});
it("every field a client types onto the account payload is published", () => {
const published = new Set(fields);
const offenders: string[] = [];
let claimsChecked = 0;
for (const file of consumerFiles) {
const text = stripComments(readFileSync(file, "utf8"));
// The type argument of a read of THIS endpoint. `/api/auth/me/...`
// siblings are different payloads, so the literal is anchored closed.
for (const call of text.matchAll(
/<\s*([A-Za-z_$][\w$]*)\s*>\s*\(\s*"\/api\/auth\/me"\s*\)/g,
)) {
const declared = declaredMembers(text, call[1]);
if (declared === null) continue; // an imported / shared type
claimsChecked += 1;
for (const member of declared) {
if (!published.has(member)) {
offenders.push(
`${relative(process.cwd(), file)} reads \`${member}\``,
);
}
}
}
}
expect(
claimsChecked,
"no client names a response type for GET /api/auth/me, so this case matched nothing rather than proving anything",
).toBeGreaterThan(0);
expect(
offenders,
"these clients type a field onto the account payload that the route does not publish, so it is `undefined` for every account and whatever it decides never happens",
).toEqual([]);
});
});
@@ -256,10 +256,6 @@ const UNPUBLISHED: Readonly<Record<string, Exemption>> = {
},
"/api/admin/provider-health": { kind: "adminConsole", methods: ["GET"] },
"/api/admin/notifications/health": { kind: "adminConsole", methods: ["GET"] },
"/api/admin/notifications/reminder-check": {
kind: "adminConsole",
methods: ["POST"],
},
"/api/admin/notifications/test": { kind: "adminConsole", methods: ["POST"] },
"/api/admin/rollups/recompute": { kind: "adminConsole", methods: ["POST"] },
"/api/admin/settings": { kind: "adminConsole", methods: ["GET", "PUT"] },
@@ -0,0 +1,120 @@
/**
* The admin reminder sweep's SCOPE, which is the half of this route that is
* easy to state backwards.
*
* The route dispatches. Every overdue slot it finds enters the channel cascade
* and reaches whoever owns the medication, so an unscoped run is a writer into
* every account on the instance at once — not a reader that somebody else might
* disturb. These cases pin both arms: no body means the long-standing
* instance-wide sweep, and a named account means a `where` that cannot reach
* anyone else.
*
* Mutation check: drop the `userId` spread from the route's `where` and the
* second case fails on the filter it no longer carries.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
vi.mock("@/lib/api-handler", async () => {
const actual =
await vi.importActual<typeof import("@/lib/api-handler")>(
"@/lib/api-handler",
);
return {
...actual,
apiHandler: <T extends (...args: unknown[]) => Promise<Response>>(
h: T,
): T => h,
requireAdmin: vi.fn(),
};
});
vi.mock("@/lib/logging/context", () => ({
annotate: vi.fn(),
getEvent: vi.fn(() => null),
}));
vi.mock("@/lib/db", () => ({
prisma: {
appSettings: { findUnique: vi.fn() },
medication: { findMany: vi.fn() },
medicationIntakeEvent: { count: vi.fn() },
},
}));
vi.mock("@/lib/notifications/dispatch-localised", () => ({
dispatchLocalisedNotification: vi.fn(async () => undefined),
}));
import { POST } from "../route";
import { requireAdmin } from "@/lib/api-handler";
import { prisma } from "@/lib/db";
const ADMIN_CTX = {
authMethod: "cookie" as const,
session: { id: "s1", expiresAt: new Date(Date.now() + 3_600_000) },
user: { id: "admin-1", username: "admin", role: "ADMIN" } as never,
};
const URL_ = "http://localhost/api/admin/notifications/reminder-check";
/** The admin console's own call: no body, no content-type. */
function bare(): NextRequest {
return new NextRequest(URL_, { method: "POST" });
}
function withBody(body: unknown): NextRequest {
return new NextRequest(URL_, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
}
function whereOfLastFindMany(): Record<string, unknown> {
const call = vi.mocked(prisma.medication.findMany).mock.calls.at(-1);
return (call?.[0] as { where: Record<string, unknown> }).where;
}
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(requireAdmin).mockResolvedValue(ADMIN_CTX);
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue({
reminderMissedMinutes: 240,
} as never);
vi.mocked(prisma.medication.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.medicationIntakeEvent.count).mockResolvedValue(0 as never);
});
describe("POST /api/admin/notifications/reminder-check", () => {
it("sweeps the whole instance when no account is named", async () => {
const res = await POST(bare());
expect(res.status).toBe(200);
const json = (await res.json()) as { data: { scoped: boolean } };
expect(json.data.scoped).toBe(false);
expect(whereOfLastFindMany()).toEqual({ active: true });
});
it("narrows the sweep to the named account", async () => {
const res = await POST(withBody({ userId: "cku0mock0account0id" }));
expect(res.status).toBe(200);
const json = (await res.json()) as { data: { scoped: boolean } };
expect(json.data.scoped).toBe(true);
expect(whereOfLastFindMany()).toEqual({
active: true,
userId: "cku0mock0account0id",
});
});
it("refuses an unknown body key rather than sweeping everything", async () => {
const res = await POST(withBody({ userid: "cku0mock0account0id" }));
expect(res.status).toBe(422);
expect(prisma.medication.findMany).not.toHaveBeenCalled();
});
it("refuses an account selector that is not an id", async () => {
const res = await POST(withBody({ userId: "../../etc/passwd" }));
expect(res.status).toBe(422);
expect(prisma.medication.findMany).not.toHaveBeenCalled();
});
});
@@ -1,7 +1,10 @@
import type { NextRequest } from "next/server";
import { prisma } from "@/lib/db";
import { apiHandler, requireAdmin } from "@/lib/api-handler";
import { annotate, getEvent } from "@/lib/logging/context";
import { apiSuccess } from "@/lib/api-response";
import { apiSuccess, returnAllZodIssues, safeJson } from "@/lib/api-response";
import { adminReminderCheckSchema } from "@/lib/validations/notifications";
import { parseScheduleRecurrence } from "@/lib/medication-schedule";
import { dispatchLocalisedNotification } from "@/lib/notifications/dispatch-localised";
import { getUserTodayBounds, getDayOfWeekInTz } from "@/lib/tz/local-day";
@@ -40,13 +43,50 @@ interface MedicationResult {
}
/**
* POST: Execute the reminder check — analyzes all medications AND sends
* POST: Execute the reminder check — analyses medications AND sends
* notifications for overdue schedules (late + missed). Returns detailed
* results for display in the admin panel.
*
* ## Scope
*
* The sweep is instance-wide by default: no body, every active medication on
* the instance, one dispatch per overdue slot. That is the operator button's
* long-standing behaviour and it stays.
*
* An optional `userId` in the body narrows it to ONE account. The direction
* that matters is easy to state backwards, so it is written here: the hazard a
* caller of this route creates is not that somebody else writes into its
* window, it is that THIS call dispatches into everybody else's — a run reaches
* every account that has an overdue dose and a configured channel. A caller
* that only means to sweep one account names it and stops being a second
* writer for every other one.
*
* Naming an account cannot widen anything. `requireAdmin()` is cookie-only by
* construction, so a Bearer token — wildcard scope included — never reaches
* this handler at all, and the field only ever removes rows from the `where`.
*/
export const POST = apiHandler(async () => {
export const POST = apiHandler(async (request: NextRequest) => {
await requireAdmin();
annotate({ action: { name: "admin.notifications.reminder-check" } });
// A bodyless POST is the admin console's own call (`apiPost(path)` sends no
// body and no content-type), so the parse only runs when a JSON body is
// actually presented. An empty object is the same instance-wide sweep.
let input: unknown = {};
if (
(request.headers.get("content-type") ?? "").includes("application/json")
) {
const { data, error } = await safeJson(request, { maxBytes: 4 * 1024 });
if (error) return error;
input = data ?? {};
}
const parsed = adminReminderCheckSchema.safeParse(input);
if (!parsed.success) return returnAllZodIssues(parsed.error);
const scopedUserId = parsed.data.userId ?? null;
annotate({
action: { name: "admin.notifications.reminder-check" },
meta: { scope: scopedUserId ? "account" : "instance" },
});
const now = new Date();
@@ -57,7 +97,11 @@ export const POST = apiHandler(async () => {
const missedMinutes = appSettings?.reminderMissedMinutes ?? 240;
const medications = await prisma.medication.findMany({
where: { active: true },
where: {
active: true,
// Absent selector ⇒ no `userId` key ⇒ the instance-wide sweep.
...(scopedUserId ? { userId: scopedUserId } : {}),
},
include: {
schedules: true,
user: { select: { id: true, username: true, timezone: true } },
@@ -211,6 +255,7 @@ export const POST = apiHandler(async () => {
return apiSuccess({
timestamp: now.toISOString(),
missedThresholdMinutes: missedMinutes,
scoped: scopedUserId !== null,
medications: results,
notificationsSent,
message:
+11
View File
@@ -55,6 +55,7 @@ import {
getOperatorModuleAvailability,
} from "@/lib/modules/gate";
import { parseTourProgress } from "@/lib/onboarding/tour-progress";
import { parseNotificationPrefs } from "@/lib/validations/notification-prefs";
import { resolveAccountAccess } from "@/lib/sharing/account-access";
import {
buildModuleDisclosure,
@@ -224,6 +225,16 @@ export const GET = apiHandler(async () => {
// the account has never saved one, which is what makes the panel show the
// named template on the first run rather than a silent server default.
reportSelection: user.reportSelectionJson ?? null,
// The account's resolved notification preferences — the same resolver
// `GET /api/auth/me/notification-prefs` answers with, so the two surfaces
// cannot disagree. Published here because the medication detail's
// notification section decides between the server-side reminder switch and
// the client-managed chip on `medication.clientManaged`, and it reads that
// decision off THIS payload: the dedicated endpoint is not on the app-boot
// path. An account that has never opted in resolves to the documented
// defaults rather than to `undefined`, so the switch is what renders.
// No extra query — the session already loads the row this column is on.
notificationPrefs: parseNotificationPrefs(user.notificationPrefs ?? null),
// v1.4.47 W3 — per-user Coach opt-out. Default `false` if the
// column is absent (partial-deploy rollback safety, see migration
// 0078 commentary). Every Coach mount point on the client checks
+11
View File
@@ -451,6 +451,17 @@ const accountPayload = z
accountAccess: accountAccessBlock,
moduleAccess: moduleAccessMap,
recordSession: recordSessionState.nullable(),
// Enumerated because a client DECIDES on it rather than displays it: the
// medication surface hides the server-side reminder switch when
// `medication.clientManaged` is true, because the phone owns those
// reminders and a switch that no longer decides anything is worse than no
// switch. Always fully resolved — an account that never opted in reads
// `false`, not absent. `PATCH /api/auth/me/notification-prefs` writes it;
// the categories this object does not name are the rest of the open
// preference envelope.
notificationPrefs: z.looseObject({
medication: z.looseObject({ clientManaged: z.boolean() }),
}),
})
.meta({
id: "AccountPayload",
+85
View File
@@ -8,6 +8,7 @@
import { z } from "zod/v4";
import type { ZodOpenApiObject } from "zod-openapi";
import { inviteCreateSchema } from "@/lib/validations/invite";
import { adminReminderCheckSchema } from "@/lib/validations/notifications";
import { dataEnvelope, errorEnvelope, stdResponses } from "./shared";
// v1.4.48 H-APNs-1 — admin diagnostic endpoint for the notification
@@ -190,7 +191,91 @@ export const adminInvitePaths: NonNullable<ZodOpenApiObject["paths"]> = {
},
};
const adminReminderSchedule = z
.object({
window: z.string(),
days: z.string(),
status: z.enum(["open", "late", "threshold", "missed", "skipped"]),
label: z.string(),
minutesPastEnd: z.number().int().nullable(),
notificationSent: z.boolean(),
})
.meta({
id: "AdminReminderScheduleStatus",
description:
"One dose window as the sweep classified it against the OWNING account's wall clock, and whether this run dispatched for it. `notificationSent` is false for a window that is still open, for a day the schedule does not cover, and for a slot that already has an intake event today.",
});
const adminReminderMedication = z
.object({
name: z.string(),
dose: z.string(),
user: z.string(),
timezone: z.string(),
localTime: z.string(),
dayOfWeek: z.string(),
notificationsEnabled: z.boolean(),
schedules: z.array(adminReminderSchedule),
eventsToday: z.number().int(),
})
.meta({
id: "AdminReminderMedication",
description:
"One active medication the sweep walked, with the account it belongs to and that account's local clock — the sweep classifies every window against the owner's timezone, never the operator's.",
});
const adminReminderCheckResult = z
.object({
timestamp: z.iso.datetime({ offset: true }),
missedThresholdMinutes: z.number().int(),
scoped: z
.boolean()
.describe(
"True when the request named a `userId` and the sweep therefore walked one account only.",
),
medications: z.array(adminReminderMedication),
notificationsSent: z.number().int(),
message: z.string(),
})
.meta({
id: "AdminReminderCheckResult",
description:
"What the sweep found and what it sent. `notificationsSent` counts DISPATCHES, not deliveries — each one enters the channel cascade and the per-channel verdict lands in the `push_attempts` ledger the notification diagnostic surfaces.",
});
export const adminDiagnosticPaths: NonNullable<ZodOpenApiObject["paths"]> = {
"/api/admin/notifications/reminder-check": {
post: {
tags: ["Admin"],
summary: "Run the medication-reminder sweep",
description:
"The admin panel's manual reminder sweep: it classifies every active medication's dose windows against the OWNING account's wall clock and dispatches a MEDICATION_REMINDER for each overdue slot that has no intake event today. Cookie auth only — `requireAdmin()` refuses every Bearer caller, wildcard scope included.\n\nScope is the thing to get right. With no body the sweep is INSTANCE-WIDE and dispatches into every account that has an overdue dose and a configured channel; that is the operator button's behaviour and it is deliberate. A caller that means to sweep one account sends `userId` and stops being a second writer for every other one. The selector can only narrow: it feeds the Prisma `where`, never the authorisation.",
requestBody: {
required: false,
content: {
"application/json": { schema: adminReminderCheckSchema },
},
},
responses: {
"200": {
description: "The sweep's findings and how many reminders it sent.",
content: {
"application/json": {
schema: dataEnvelope(
adminReminderCheckResult,
"AdminReminderCheckResponse",
),
},
},
},
"403": {
description: "Caller is not an admin.",
content: { "application/json": { schema: errorEnvelope } },
},
...stdResponses,
},
},
},
"/api/admin/notifications/diagnostic": {
get: {
tags: ["Admin"],
+22
View File
@@ -347,3 +347,25 @@ export const webPushSubscriptionSchema = z.object({
auth: z.string().min(1),
}),
});
/**
* Body of `POST /api/admin/notifications/reminder-check` — the admin panel's
* manual reminder sweep.
*
* `userId` narrows the sweep to one account. Omitted (and a bodyless POST is
* the admin console's own call) the sweep stays instance-wide, which is what
* the operator button has always done. The field is an ACCOUNT SELECTOR, not
* an identity: the route is `requireAdmin()`, cookie-only by construction, so
* naming an account here can never widen who is allowed to run the sweep — it
* only narrows whose overdue doses it dispatches for.
*
* Strict, so a typo'd key is a 422 rather than a silently instance-wide run.
*/
export const adminReminderCheckSchema = z.strictObject({
userId: z
.string()
.min(1)
.max(64)
.regex(/^[A-Za-z0-9_-]+$/, "Ungültige Account-ID")
.optional(),
});