mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-13 22:32:04 +00:00
Merge the notification dispatch end-to-end journey, the account-scoped reminder sweep and the published notification preferences
This commit is contained in:
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:
|
||||
|
||||
Reference in new issue
Block a user