diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index d5b69b924..e2aaf39bf 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -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: diff --git a/src/__tests__/openapi-route-coverage-guard.test.ts b/src/__tests__/openapi-route-coverage-guard.test.ts index 82480859c..013e83d16 100644 --- a/src/__tests__/openapi-route-coverage-guard.test.ts +++ b/src/__tests__/openapi-route-coverage-guard.test.ts @@ -260,10 +260,6 @@ const UNPUBLISHED: Readonly> = { }, "/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"] }, diff --git a/src/app/api/admin/notifications/reminder-check/__tests__/route.test.ts b/src/app/api/admin/notifications/reminder-check/__tests__/route.test.ts new file mode 100644 index 000000000..25d5a54c1 --- /dev/null +++ b/src/app/api/admin/notifications/reminder-check/__tests__/route.test.ts @@ -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( + "@/lib/api-handler", + ); + return { + ...actual, + apiHandler: Promise>( + 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 { + const call = vi.mocked(prisma.medication.findMany).mock.calls.at(-1); + return (call?.[0] as { where: Record }).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(); + }); +}); diff --git a/src/app/api/admin/notifications/reminder-check/route.ts b/src/app/api/admin/notifications/reminder-check/route.ts index 784b7d4dd..0124e0c89 100644 --- a/src/app/api/admin/notifications/reminder-check/route.ts +++ b/src/app/api/admin/notifications/reminder-check/route.ts @@ -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: diff --git a/src/lib/openapi/routes/admin.ts b/src/lib/openapi/routes/admin.ts index e2e013a03..1e764c178 100644 --- a/src/lib/openapi/routes/admin.ts +++ b/src/lib/openapi/routes/admin.ts @@ -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 = { }, }; +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 = { + "/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"], diff --git a/src/lib/validations/notifications.ts b/src/lib/validations/notifications.ts index f5aeca557..f7d1e0c36 100644 --- a/src/lib/validations/notifications.ts +++ b/src/lib/validations/notifications.ts @@ -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(), +});