mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-14 14:40:52 +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
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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(),
|
||||
});
|
||||
Reference in new issue
Block a user