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

@@ -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: