feat(api): let the admin reminder sweep name one account

`POST /api/admin/notifications/reminder-check` walked every active
medication on the instance and dispatched a reminder for each overdue
slot it found. That is the operator button's intended behaviour and it
stays the default, but it left any other caller — a browser journey, a
smoke check — writing into every account's delivery ledger at once with
no way to say otherwise.

The body now accepts an optional `userId` that narrows the sweep. It can
only narrow: the route is `requireAdmin()`, cookie-only by construction,
so a Bearer caller never reaches it however wide its scope, and the
selector feeds the Prisma `where` rather than the authorisation. A
bodyless POST — what the admin panel sends — is unchanged. An unknown key
is a 422 instead of a silently instance-wide run.

Published in the contract alongside the notification diagnostic, so the
scope is readable rather than folded into the console-only exemption.
This commit is contained in:
Marc-André Bombeck committed 2026-09-10 06:00:27 +02:00
1 parent 8be51d9beb
commit f3321b8031
6 files changed
+447 -9

No files matched your search

+170
View File
@@ -9712,6 +9712,50 @@ paths:
$ref: "#/components/schemas/ErrorEnvelope"
"422": *a4
"429": *a5
/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.
Scope 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:
type: object
properties:
userId:
type: string
minLength: 1
maxLength: 64
pattern: ^[A-Za-z0-9_-]+$
additionalProperties: false
responses:
"200":
description: The sweep's findings and how many reminders it sent.
content:
application/json:
schema:
$ref: "#/components/schemas/AdminReminderCheckResponse"
"401": *a1
"403":
description: Caller is not an admin.
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorEnvelope"
"422": *a4
"429": *a5
/api/admin/notifications/diagnostic:
get:
tags:
@@ -48690,6 +48734,132 @@ components:
- data
- error
additionalProperties: false
AdminReminderCheckResponse:
type: object
properties:
data:
$ref: "#/components/schemas/AdminReminderCheckResult"
error:
type: "null"
meta:
type: object
properties:
requestId:
type: string
additionalProperties: false
required:
- data
- error
additionalProperties: false
AdminReminderCheckResult:
type: object
properties:
timestamp:
type: string
format: date-time
pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$
missedThresholdMinutes:
type: integer
minimum: -9007199254740991
maximum: 9007199254740991
scoped:
type: boolean
description: True when the request named a `userId` and the sweep therefore walked one account only.
medications:
type: array
items:
$ref: "#/components/schemas/AdminReminderMedication"
notificationsSent:
type: integer
minimum: -9007199254740991
maximum: 9007199254740991
message:
type: string
required:
- timestamp
- missedThresholdMinutes
- scoped
- medications
- notificationsSent
- message
additionalProperties: false
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.
AdminReminderMedication:
type: object
properties:
name:
type: string
dose:
type: string
user:
type: string
timezone:
type: string
localTime:
type: string
dayOfWeek:
type: string
notificationsEnabled:
type: boolean
schedules:
type: array
items:
$ref: "#/components/schemas/AdminReminderScheduleStatus"
eventsToday:
type: integer
minimum: -9007199254740991
maximum: 9007199254740991
required:
- name
- dose
- user
- timezone
- localTime
- dayOfWeek
- notificationsEnabled
- schedules
- eventsToday
additionalProperties: false
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.
AdminReminderScheduleStatus:
type: object
properties:
window:
type: string
days:
type: string
status:
type: string
enum:
- open
- late
- threshold
- missed
- skipped
label:
type: string
minutesPastEnd:
anyOf:
- type: integer
minimum: -9007199254740991
maximum: 9007199254740991
- type: "null"
notificationSent:
type: boolean
required:
- window
- days
- status
- label
- minutesPastEnd
- notificationSent
additionalProperties: false
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.
AdminDiagnosticResponse:
type: object
properties:
@@ -260,10 +260,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:
+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(),
});