feat(medications,coach,admin,perf): era corrections, a truthful weekly status and one card language everywhere

The dashboard snapshot fires the moment login succeeds instead of
waiting for the page chunk (start ~670 to ~200 ms throttled). Guided
follow-ups are personalised from the user's snapshot and an answer gets
the coach's streamed reaction before the next question. Archived
schedule eras can be corrected through a superseding revision that
keeps the original as an audit trail, era writes serialise per
medication, and duplicate dose times collapse in validation. A rolling
medication whose next dose is tomorrow can no longer read as heavily
overdue — the card status derives strictly from the served next-due —
and the adherence bars keep a fixed geometry in every state. The
server-wide assistant key gains its admin card, the one duplicated
coach preference collapses onto a single owner, twenty-seven admin and
settings card headers move onto the shared pattern with sixty
containers normalised, and the remaining hydration warnings are gone.
This commit is contained in:
Marc-André Bombeck committed 2026-06-11 13:57:15 +02:00
1 parent a4b3d2fcd0
commit 40f7196c20
115 files changed
+3014 -1084

No files matched your search

@@ -0,0 +1,217 @@
/**
* v1.16.6 — admin CRUD contract for the operator-wide AI key
* (`/api/admin/ai-settings`). The endpoint predates its UI; the new
* admin "AI server key" card now drives it, so the contract gets
* pinned: admin-only access, encrypted storage, key removal, the
* HTTPS + hostname allowlist on the base URL, and the masked
* `keyPreview` in every response.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
vi.mock("@/lib/db", () => ({
prisma: {
appSettings: {
findUnique: vi.fn(),
upsert: vi.fn(),
},
},
}));
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/auth/audit", () => ({
auditLog: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/logging/context", () => ({
annotate: vi.fn(),
getEvent: vi.fn(() => null),
}));
vi.mock("@/lib/crypto", () => ({
encrypt: vi.fn((value: string) => `enc(${value})`),
decrypt: vi.fn((value: string) => value.replace(/^enc\(|\)$/g, "")),
}));
vi.mock("@/lib/rate-limit", () => ({
checkRateLimit: vi.fn().mockResolvedValue({ allowed: true }),
}));
import { GET, PUT } from "../route";
import { prisma } from "@/lib/db";
import { requireAdmin, HttpError } from "@/lib/api-handler";
import { encrypt } from "@/lib/crypto";
import { auditLog } from "@/lib/auth/audit";
import { checkRateLimit } from "@/lib/rate-limit";
const ADMIN_CTX = {
session: { id: "s1", expiresAt: new Date(Date.now() + 3_600_000) },
user: {
id: "admin-1",
username: "admin",
role: "ADMIN",
} as never,
};
function jsonReq(body: unknown): NextRequest {
return new NextRequest("http://localhost/api/admin/ai-settings", {
method: "PUT",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
}
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(requireAdmin).mockResolvedValue(ADMIN_CTX);
vi.mocked(checkRateLimit).mockResolvedValue({
allowed: true,
remaining: 9,
resetAt: new Date(Date.now() + 60_000),
} as never);
});
describe("GET /api/admin/ai-settings", () => {
it("propagates the admin gate (non-admin cannot read)", async () => {
vi.mocked(requireAdmin).mockRejectedValue(new HttpError(403, "Forbidden"));
await expect(GET()).rejects.toMatchObject({ statusCode: 403 });
});
it("returns defaults + hasKey=false when nothing is configured", async () => {
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue(null as never);
const res = await GET();
const body = await res.json();
expect(body.data).toEqual({
hasKey: false,
keyPreview: null,
model: "gpt-4o",
baseUrl: "https://api.openai.com/v1",
});
});
it("returns hasKey=true with a masked preview when a key is stored", async () => {
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue({
adminAiKeyEncrypted: "enc(sk-test-12cd)",
adminAiModel: "gpt-4o-mini",
adminAiBaseUrl: "https://api.openai.com/v1",
} as never);
const res = await GET();
const body = await res.json();
expect(body.data.hasKey).toBe(true);
expect(body.data.keyPreview).toBe("...12cd");
expect(body.data.model).toBe("gpt-4o-mini");
// The plaintext key never leaks into the envelope.
expect(JSON.stringify(body)).not.toContain("sk-test-12cd");
});
});
describe("PUT /api/admin/ai-settings", () => {
function upsertEcho() {
vi.mocked(prisma.appSettings.upsert).mockImplementation(
(async (args: { update: Record<string, unknown> }) => ({
adminAiKeyEncrypted: null,
adminAiModel: "gpt-4o",
adminAiBaseUrl: "https://api.openai.com/v1",
...args.update,
})) as never,
);
}
it("propagates the admin gate (non-admin cannot write)", async () => {
vi.mocked(requireAdmin).mockRejectedValue(new HttpError(403, "Forbidden"));
await expect(PUT(jsonReq({ model: "gpt-4o" }))).rejects.toMatchObject({
statusCode: 403,
});
});
it("encrypts and stores a new key, audit-logged", async () => {
upsertEcho();
const res = await PUT(jsonReq({ apiKey: "sk-new-key-89ab" }));
const body = await res.json();
expect(encrypt).toHaveBeenCalledWith("sk-new-key-89ab");
expect(prisma.appSettings.upsert).toHaveBeenCalledWith(
expect.objectContaining({
update: { adminAiKeyEncrypted: "enc(sk-new-key-89ab)" },
}),
);
expect(body.data.hasKey).toBe(true);
expect(body.data.keyPreview).toBe("...89ab");
expect(auditLog).toHaveBeenCalledWith(
"admin.ai-settings.update",
expect.objectContaining({ userId: "admin-1" }),
);
// The audit detail records THAT the key changed, never the key.
const details = vi.mocked(auditLog).mock.calls[0][1] as {
details: Record<string, unknown>;
};
expect(JSON.stringify(details)).not.toContain("sk-new-key-89ab");
});
it("clears the key when apiKey is empty", async () => {
upsertEcho();
const res = await PUT(jsonReq({ apiKey: "" }));
const body = await res.json();
expect(prisma.appSettings.upsert).toHaveBeenCalledWith(
expect.objectContaining({ update: { adminAiKeyEncrypted: null } }),
);
expect(body.data.hasKey).toBe(false);
expect(body.data.keyPreview).toBeNull();
});
it("updates model and baseUrl for an allowlisted https host", async () => {
upsertEcho();
const res = await PUT(
jsonReq({ model: "gpt-4o-mini", baseUrl: "https://openrouter.ai/v1" }),
);
const body = await res.json();
expect(body.data.model).toBe("gpt-4o-mini");
expect(body.data.baseUrl).toBe("https://openrouter.ai/v1");
});
it("rejects a plain-http base URL", async () => {
await expect(
PUT(jsonReq({ baseUrl: "http://api.openai.com/v1" })),
).rejects.toMatchObject({ statusCode: 422 });
});
it("rejects a base URL host outside the allowlist", async () => {
await expect(
PUT(jsonReq({ baseUrl: "https://attacker.example/v1" })),
).rejects.toMatchObject({ statusCode: 422 });
expect(prisma.appSettings.upsert).not.toHaveBeenCalled();
});
it("rejects an empty model", async () => {
await expect(PUT(jsonReq({ model: " " }))).rejects.toMatchObject({
statusCode: 422,
});
});
it("rejects a body with no recognised fields", async () => {
await expect(PUT(jsonReq({ unrelated: true }))).rejects.toMatchObject({
statusCode: 422,
});
});
it("returns 429 when the admin surface is rate-limited", async () => {
vi.mocked(checkRateLimit).mockResolvedValue({ allowed: false } as never);
await expect(PUT(jsonReq({ model: "gpt-4o" }))).rejects.toMatchObject({
statusCode: 429,
});
});
});
+27 -3
View File
@@ -179,7 +179,13 @@ async function handleChatRequest(request: NextRequest): Promise<Response> {
{ status: 422 },
);
}
const { conversationId, message, locale: bodyLocale, scope } = parsed.data;
const {
conversationId,
message,
locale: bodyLocale,
scope,
guidedQuestion,
} = parsed.data;
// Per-user request-rate ceiling layered in front of the daily budget
// gate. The budget catches the cost dimension; this catches the
@@ -206,7 +212,14 @@ async function handleChatRequest(request: NextRequest): Promise<Response> {
});
// ── Refusal short-circuit ────────────────────────────────────
const refusal = detectRefusal({ message, locale });
// v1.16.6 — the guided-flow question rides the prompt too, so it
// runs through the same regex bank as the message. The questions
// are server-derived in the honest case; this guards the dishonest
// one (a crafted client using the field as an unchecked channel).
const refusal = detectRefusal({
message: guidedQuestion ? `${guidedQuestion}\n${message}` : message,
locale,
});
if (refusal.refuse && refusal.message) {
annotate({
action: { name: "insights.coach.refused" },
@@ -358,9 +371,20 @@ async function handleChatRequest(request: NextRequest): Promise<Response> {
const transcript = window
.map((t) => `${t.role.toUpperCase()}: ${t.content}`)
.join("\n\n");
// v1.16.6 — guided clarifying-questions flow: the question the user
// is answering exists only as a client-side bubble, so hand it to
// the model as delimited context. The reaction should read as a
// natural reply to the answer, not as a re-ask.
const guidedBlock = guidedQuestion
? `\nGUIDED QUESTION (user-provided context)
The user's message answers this clarifying question from their self-context questionnaire:
"""${guidedQuestion}"""
React briefly and personally to the answer; do not repeat the question and do not ask it again.
`
: "";
const userPrompt = `SNAPSHOT
${snapshot.snapshotJson || "(no metric data in this user's log yet)"}
${guidedBlock}
CONVERSATION
${transcript}
+6 -2
View File
@@ -143,7 +143,9 @@ export const GET = apiHandler(
// the LIVE schedule rows, so it must not reach past the newest
// revision boundary into a previous era's cadence.
const latestRevision = await prisma.medicationScheduleRevision.findFirst({
where: { medicationId: id },
// Superseded rows are audit records — a correction may have
// shortened the era, so the boundary reads only active rows.
where: { medicationId: id, supersededByRevisionId: null },
orderBy: { validUntil: "desc" },
select: { validUntil: true },
});
@@ -424,7 +426,9 @@ export const PUT = apiHandler(
)
) {
const lastRevision = await prisma.medicationScheduleRevision.findFirst({
where: { medicationId: id },
// Chain from the ACTIVE boundary: a superseded row is an
// audit record whose `validUntil` may sit past its correction.
where: { medicationId: id, supersededByRevisionId: null },
orderBy: { validUntil: "desc" },
select: { validUntil: true },
});
@@ -1,11 +1,22 @@
/**
* v1.16.5 — DELETE /api/medications/[id]/schedule-revisions/[revisionId]
* v1.16.6 — PATCH /api/medications/[id]/schedule-revisions/[revisionId]
*
* Removes a MANUAL schedule era (one the owner appended through the
* sibling POST). Write-path archives (`source: "ARCHIVED"`) are
* immutable history — the wholesale-replace path minted them from rows
* that actually existed, so deleting one would falsify the ledger; the
* route refuses with 409.
* DELETE removes a MANUAL schedule era (one the owner appended through
* the sibling POST, or a correction minted by PATCH). Write-path
* archives (`source: "ARCHIVED"`) are immutable history — the
* wholesale-replace path minted them from rows that actually existed,
* so deleting one would falsify the ledger; the route refuses with 409.
* Deleting a correction restores the archived original it superseded.
*
* PATCH corrects an era. A MANUAL era updates in place. An ARCHIVED era
* stays untouched as the audit record: the correction is minted as a
* new MANUAL revision and the original's `supersededByRevisionId`
* points at it, so every era consumer reads the correction while the
* recorded history remains inspectable. Validation mirrors the sibling
* POST (bounds order, live-plan ceiling, no overlap with other active
* eras); the check-then-write runs under a `FOR UPDATE` lock on the
* medication row so concurrent era writes serialise.
*
* Auth: requireAuth() + medication ownership; a revision belonging to
* another medication (or another user's medication) surfaces as 404,
@@ -17,12 +28,246 @@ import { prisma } from "@/lib/db";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import { apiError, apiSuccess, getClientIp } from "@/lib/api-response";
import {
apiError,
apiSuccess,
getClientIp,
returnAllZodIssues,
safeJson,
} from "@/lib/api-response";
import { assertMedicationOwnership } from "@/lib/medications/route-guards";
import { scheduleRevisionUpdateSchema } from "@/lib/validations/schedule-revision";
import { toRevisionPayloadEntry } from "@/lib/medications/scheduling/schedule-eras";
import { enqueueUserMedicationComplianceBackfill } from "@/lib/rollups/medication-compliance-rollups";
import type { Prisma } from "@/generated/prisma/client";
type RouteParams = { params: Promise<{ id: string; revisionId: string }> };
export const PATCH = apiHandler(
async (request: NextRequest, { params }: RouteParams) => {
const { user } = await requireAuth();
const { id, revisionId } = await params;
const guard = await assertMedicationOwnership(id, user.id);
if (guard) return guard;
const med = await prisma.medication.findUnique({
where: { id },
select: { createdAt: true },
});
if (!med) {
return apiError("Medication not found", 404);
}
const { data: body, error: jsonError } = await safeJson(request, {
maxBytes: 16 * 1024,
});
if (jsonError) return jsonError;
const parsed = scheduleRevisionUpdateSchema.safeParse(body ?? {});
if (!parsed.success) {
return returnAllZodIssues(parsed.error, 422);
}
const validFrom = new Date(parsed.data.validFrom);
const validUntil = new Date(parsed.data.validUntil);
// Sanity floor — mirrors the sibling POST.
if (validFrom.getUTCFullYear() < 1900) {
return apiError("validFrom must be a date after 1900", 422);
}
// Corrected snapshot, shaped exactly like the POST path: daily
// recurrence at the (schema-deduped, sorted) times, window pulled
// to their min/max.
const times = parsed.data.timesOfDay;
const entry = toRevisionPayloadEntry({
timesOfDay: times,
windowStart: times[0],
windowEnd: times[times.length - 1],
daysOfWeek: null,
rrule: "FREQ=DAILY",
rollingIntervalDays: null,
scheduleType: "SCHEDULED",
cyclicOnWeeks: null,
cyclicOffWeeks: null,
doseWindows: null,
label: null,
dose: null,
reminderGraceMinutes: null,
});
// Validate-then-write under the per-medication row lock — the same
// serialisation as the sibling POST, so a concurrent era write can
// never slip an overlapping interval past the check.
const outcome = await prisma.$transaction(async (tx) => {
await tx.$queryRaw`
SELECT id FROM medications
WHERE id = ${id}
FOR UPDATE
`;
const target = await tx.medicationScheduleRevision.findUnique({
where: { id: revisionId },
select: {
id: true,
medicationId: true,
source: true,
supersededByRevisionId: true,
validUntil: true,
},
});
if (!target || target.medicationId !== id) {
return {
ok: false as const,
status: 404 as const,
error: "Schedule revision not found",
};
}
if (target.supersededByRevisionId !== null) {
return {
ok: false as const,
status: 409 as const,
error: "This era has already been corrected",
};
}
const others = await tx.medicationScheduleRevision.findMany({
where: {
medicationId: id,
supersededByRevisionId: null,
id: { not: revisionId },
},
select: { validFrom: true, validUntil: true },
});
// Live-plan ceiling, mirroring the POST rule: the live era began
// at the newest active `validUntil` (never earlier than
// `createdAt`). The edited era's own recorded end counts — an
// ARCHIVED era adjacent to the live plan may keep its boundary,
// but no correction may extend into tracked live history.
const liveBoundary = [target.validUntil, ...others.map((r) => r.validUntil)].reduce(
(latest, v) => (v > latest ? v : latest),
med.createdAt,
);
if (validUntil.getTime() > liveBoundary.getTime()) {
return {
ok: false as const,
status: 422 as const,
error: "A corrected era must end before the current plan begins",
};
}
// No overlap with any OTHER active interval `[validFrom,
// validUntil)` — the corrected bounds may of course cover the
// era's own previous interval.
const overlaps = others.some(
(r) =>
validFrom.getTime() < r.validUntil.getTime() &&
validUntil.getTime() > r.validFrom.getTime(),
);
if (overlaps) {
return {
ok: false as const,
status: 422 as const,
error: "The era overlaps an existing schedule era",
};
}
const revisionSelect = {
id: true,
validFrom: true,
validUntil: true,
source: true,
} as const;
if (target.source === "MANUAL") {
const revision = await tx.medicationScheduleRevision.update({
where: { id: revisionId },
data: {
validFrom,
validUntil,
payload: [entry] as unknown as Prisma.InputJsonValue,
},
select: revisionSelect,
});
return { ok: true as const, revision, mode: "in_place" as const };
}
// ARCHIVED — immutable. Mint the correction as a MANUAL row and
// park the original behind it as the audit record.
const revision = await tx.medicationScheduleRevision.create({
data: {
medicationId: id,
validFrom,
validUntil,
source: "MANUAL",
payload: [entry] as unknown as Prisma.InputJsonValue,
},
select: revisionSelect,
});
await tx.medicationScheduleRevision.update({
where: { id: revisionId },
data: { supersededByRevisionId: revision.id },
});
return { ok: true as const, revision, mode: "supersede" as const };
});
if (!outcome.ok) {
return apiError(outcome.error, outcome.status);
}
await auditLog("medication.schedule_revision.updated", {
userId: user.id,
ipAddress: getClientIp(request),
details: {
medicationId: id,
revisionId,
mode: outcome.mode,
...(outcome.mode === "supersede" && {
correctionRevisionId: outcome.revision.id,
}),
},
});
annotate({
action: {
name:
outcome.mode === "in_place"
? "medication.schedule_revision.manual_updated"
: "medication.schedule_revision.archived_corrected",
entity_type: "medication",
entity_id: id,
},
meta: {
revision_id: outcome.revision.id,
...(outcome.mode === "supersede" && {
superseded_revision_id: revisionId,
}),
},
});
// The corrected era re-segments history; refresh the pre-aggregated
// compliance rollups asynchronously (best-effort).
await enqueueUserMedicationComplianceBackfill(user.id);
return apiSuccess({
id: outcome.revision.id,
validFrom: outcome.revision.validFrom.toISOString(),
validUntil: outcome.revision.validUntil.toISOString(),
source: outcome.revision.source,
entries: [
{
timesOfDay: times,
label: null,
dose: null,
scheduleType: "SCHEDULED",
},
],
});
},
);
export const DELETE = apiHandler(
async (request: NextRequest, { params }: RouteParams) => {
const { user } = await requireAuth();
@@ -46,9 +291,18 @@ export const DELETE = apiHandler(
);
}
await prisma.medicationScheduleRevision.delete({
where: { id: revisionId },
});
// Deleting a correction restores the archived original it had
// superseded — the audit record becomes the era again, atomically
// with the delete.
await prisma.$transaction([
prisma.medicationScheduleRevision.delete({
where: { id: revisionId },
}),
prisma.medicationScheduleRevision.updateMany({
where: { medicationId: id, supersededByRevisionId: revisionId },
data: { supersededByRevisionId: null },
}),
]);
await auditLog("medication.schedule_revision.deleted", {
userId: user.id,
@@ -3,6 +3,7 @@
*
* GET /api/medications/[id]/schedule-revisions
* POST /api/medications/[id]/schedule-revisions
* PATCH /api/medications/[id]/schedule-revisions/[revisionId]
* DELETE /api/medications/[id]/schedule-revisions/[revisionId]
*
* The POST guards are the contract under test: a manual era must end
@@ -10,12 +11,16 @@
* archived era, and is always minted with `source: "MANUAL"`. The
* DELETE refuses write-path archives (409) so the immutable history
* the replace path mints can never be falsified through the API.
* PATCH (v1.16.6) corrects an era: MANUAL updates in place, ARCHIVED
* stays as the audit record behind a superseding MANUAL correction;
* both write paths run inside the per-medication `FOR UPDATE`
* transaction so concurrent era writes serialise.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
vi.mock("@/lib/db", () => ({
prisma: {
vi.mock("@/lib/db", () => {
const prisma: Record<string, unknown> = {
medication: {
findUnique: vi.fn(),
},
@@ -23,10 +28,15 @@ vi.mock("@/lib/db", () => ({
findMany: vi.fn().mockResolvedValue([]),
findUnique: vi.fn(),
create: vi.fn(),
update: vi.fn(),
updateMany: vi.fn(),
delete: vi.fn(),
},
},
}));
$queryRaw: vi.fn(),
};
prisma.$transaction = vi.fn();
return { prisma };
});
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
vi.mock("@/lib/auth/audit", () => ({
@@ -51,7 +61,7 @@ vi.mock("next/headers", () => ({
}));
import { GET, POST } from "../route";
import { DELETE } from "../[revisionId]/route";
import { DELETE, PATCH } from "../[revisionId]/route";
import { prisma } from "@/lib/db";
import { getSession } from "@/lib/auth/session";
import { enqueueUserMedicationComplianceBackfill } from "@/lib/rollups/medication-compliance-rollups";
@@ -91,11 +101,30 @@ function deleteCtx(revisionId: string) {
return { params: Promise.resolve({ id: "m1", revisionId }) };
}
function patchReq(revisionId: string, body: unknown): NextRequest {
return new NextRequest(
`http://localhost/api/medications/m1/schedule-revisions/${revisionId}`,
{
method: "PATCH",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
},
);
}
const MED_CREATED_AT = new Date("2026-01-10T08:00:00Z");
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
// Interactive form runs the callback against the same mock client
// (the routes only touch models the flat mock already carries); the
// array form resolves the batched operations like Promise.all.
vi.mocked(prisma.$transaction).mockImplementation((async (arg: unknown) =>
typeof arg === "function"
? (arg as (tx: typeof prisma) => unknown)(prisma)
: Promise.all(arg as Array<Promise<unknown>>)) as never);
vi.mocked(prisma.$queryRaw).mockResolvedValue([] as never);
// Ownership guard + GET both read `medication.findUnique`; the guard
// selects `{ id, userId }`, the GET selects `{ createdAt }` — one
// superset row satisfies both.
@@ -216,21 +245,53 @@ describe("POST /api/medications/[id]/schedule-revisions", () => {
});
it("422s when the era ends after the live plan began", async () => {
// Realistic archive: the plan was replaced on 2026-03-01, after
// tracking began (createdAt 2026-01-10) — the live plan has run
// since 2026-03-01.
vi.mocked(prisma.medicationScheduleRevision.findMany).mockResolvedValue([
{
validFrom: MED_CREATED_AT,
validUntil: new Date("2026-03-01T00:00:00Z"),
},
] as never);
// Ends 2026-04-01 — after the live plan start — and does NOT
// overlap the archived era; the boundary check must fire.
const res = await POST(
postReq({
...VALID_BODY,
validFrom: "2026-03-05T00:00:00.000Z",
validUntil: "2026-04-01T00:00:00.000Z",
}),
ROUTE_CTX,
);
expect(res.status).toBe(422);
const json = await res.json();
expect(json.error).toMatch(/current plan/i);
});
it("accepts a manual era filling the gap between an older manual era and createdAt", async () => {
// Two-chunk pre-tracking history entered oldest-first: the second
// chunk ends after the first chunk's validUntil but still at or
// before createdAt. The boundary is max(createdAt, newest active
// validUntil) — order of entry must not matter.
vi.mocked(prisma.medicationScheduleRevision.findMany).mockResolvedValue([
{
validFrom: new Date("2025-01-01T00:00:00Z"),
validUntil: new Date("2025-04-01T00:00:00Z"),
},
] as never);
// Ends 2025-06-01 — after the live plan start (2025-04-01) — and
// does NOT overlap the archived era; the boundary check must fire.
vi.mocked(prisma.medicationScheduleRevision.create).mockResolvedValue({
id: "rev-new",
validFrom: new Date("2025-04-01T00:00:00.000Z"),
validUntil: new Date("2025-06-01T00:00:00.000Z"),
source: "MANUAL",
payload: [],
} as never);
const res = await POST(
postReq({ ...VALID_BODY, validFrom: "2025-04-01T00:00:00.000Z" }),
ROUTE_CTX,
);
expect(res.status).toBe(422);
const json = await res.json();
expect(json.error).toMatch(/current plan/i);
expect(res.status).toBe(201);
});
it("422s when no revision exists and the era reaches past createdAt", async () => {
@@ -292,6 +353,34 @@ describe("POST /api/medications/[id]/schedule-revisions", () => {
expect(enqueueUserMedicationComplianceBackfill).toHaveBeenCalledWith(
"user-1",
);
// Check-then-insert ran inside the transaction with the
// per-medication `FOR UPDATE` lock taken first.
expect(prisma.$transaction).toHaveBeenCalled();
expect(prisma.$queryRaw).toHaveBeenCalled();
});
it("collapses duplicate timesOfDay before snapshotting", async () => {
vi.mocked(prisma.medicationScheduleRevision.create).mockResolvedValue({
id: "rev-new",
validFrom: new Date(VALID_BODY.validFrom),
validUntil: new Date(VALID_BODY.validUntil),
source: "MANUAL",
payload: [],
} as never);
const res = await POST(
postReq({ ...VALID_BODY, timesOfDay: ["19:00", "07:00", "19:00"] }),
ROUTE_CTX,
);
expect(res.status).toBe(201);
const createArgs = vi.mocked(prisma.medicationScheduleRevision.create)
.mock.calls[0][0] as unknown as {
data: { payload: Array<{ timesOfDay: string[] }> };
};
// The Zod transform dedupes + sorts — a double-tapped chip must
// never mint a double-counted slot.
expect(createArgs.data.payload[0].timesOfDay).toEqual(["07:00", "19:00"]);
});
});
@@ -322,7 +411,7 @@ describe("DELETE /api/medications/[id]/schedule-revisions/[revisionId]", () => {
expect(prisma.medicationScheduleRevision.delete).not.toHaveBeenCalled();
});
it("deletes a MANUAL era and enqueues the rollup backfill", async () => {
it("deletes a MANUAL era, restores a superseded original, and enqueues the rollup backfill", async () => {
vi.mocked(
prisma.medicationScheduleRevision.findUnique,
).mockResolvedValue({
@@ -333,6 +422,9 @@ describe("DELETE /api/medications/[id]/schedule-revisions/[revisionId]", () => {
vi.mocked(prisma.medicationScheduleRevision.delete).mockResolvedValue(
{} as never,
);
vi.mocked(
prisma.medicationScheduleRevision.updateMany,
).mockResolvedValue({ count: 0 } as never);
const res = await DELETE(deleteReq("rev-m"), deleteCtx("rev-m"));
expect(res.status).toBe(200);
const json = await res.json();
@@ -340,6 +432,164 @@ describe("DELETE /api/medications/[id]/schedule-revisions/[revisionId]", () => {
expect(prisma.medicationScheduleRevision.delete).toHaveBeenCalledWith({
where: { id: "rev-m" },
});
// If the deleted row was a correction, the archived original it
// superseded becomes the era again.
expect(prisma.medicationScheduleRevision.updateMany).toHaveBeenCalledWith({
where: { medicationId: "m1", supersededByRevisionId: "rev-m" },
data: { supersededByRevisionId: null },
});
expect(enqueueUserMedicationComplianceBackfill).toHaveBeenCalledWith(
"user-1",
);
});
});
describe("PATCH /api/medications/[id]/schedule-revisions/[revisionId]", () => {
const PATCH_BODY = {
validFrom: "2025-03-12T00:00:00.000Z",
validUntil: "2025-06-01T00:00:00.000Z",
timesOfDay: ["08:00", "20:00", "08:00"],
};
function mockTarget(overrides: Record<string, unknown> = {}) {
vi.mocked(
prisma.medicationScheduleRevision.findUnique,
).mockResolvedValue({
id: "rev-t",
medicationId: "m1",
source: "MANUAL",
supersededByRevisionId: null,
validUntil: new Date("2025-06-01T00:00:00Z"),
...overrides,
} as never);
}
it("404s when the revision belongs to another medication", async () => {
mockTarget({ medicationId: "other-med" });
const res = await PATCH(
patchReq("rev-t", PATCH_BODY),
deleteCtx("rev-t"),
);
expect(res.status).toBe(404);
expect(prisma.medicationScheduleRevision.update).not.toHaveBeenCalled();
});
it("409s when the era has already been corrected", async () => {
mockTarget({ source: "ARCHIVED", supersededByRevisionId: "rev-fix" });
const res = await PATCH(
patchReq("rev-t", PATCH_BODY),
deleteCtx("rev-t"),
);
expect(res.status).toBe(409);
expect(prisma.medicationScheduleRevision.update).not.toHaveBeenCalled();
expect(prisma.medicationScheduleRevision.create).not.toHaveBeenCalled();
});
it("422s when the corrected era overlaps another active era", async () => {
mockTarget();
vi.mocked(prisma.medicationScheduleRevision.findMany).mockResolvedValue([
{
validFrom: new Date("2025-06-01T00:00:00Z"),
validUntil: new Date("2025-08-01T00:00:00Z"),
},
] as never);
const res = await PATCH(
patchReq("rev-t", { ...PATCH_BODY, validUntil: "2025-07-01T00:00:00.000Z" }),
deleteCtx("rev-t"),
);
expect(res.status).toBe(422);
const json = await res.json();
expect(json.error).toMatch(/overlap/i);
});
it("422s when the correction extends past the live boundary", async () => {
// ARCHIVED era ending 2025-06-01, no other revisions; the live
// ceiling is max(own recorded end, createdAt 2026-01-10) — a
// correction reaching past it would swallow tracked live history.
mockTarget({ source: "ARCHIVED" });
const res = await PATCH(
patchReq("rev-t", {
...PATCH_BODY,
validUntil: "2026-02-01T00:00:00.000Z",
}),
deleteCtx("rev-t"),
);
expect(res.status).toBe(422);
const json = await res.json();
expect(json.error).toMatch(/current plan/i);
});
it("updates a MANUAL era in place with the deduped snapshot", async () => {
mockTarget();
vi.mocked(prisma.medicationScheduleRevision.update).mockResolvedValue({
id: "rev-t",
validFrom: new Date(PATCH_BODY.validFrom),
validUntil: new Date(PATCH_BODY.validUntil),
source: "MANUAL",
} as never);
const res = await PATCH(
patchReq("rev-t", PATCH_BODY),
deleteCtx("rev-t"),
);
expect(res.status).toBe(200);
const json = await res.json();
expect(json.data.id).toBe("rev-t");
expect(json.data.entries[0].timesOfDay).toEqual(["08:00", "20:00"]);
const updateArgs = vi.mocked(prisma.medicationScheduleRevision.update)
.mock.calls[0][0] as unknown as {
where: { id: string };
data: { payload: Array<{ timesOfDay: string[]; rrule: string }> };
};
expect(updateArgs.where.id).toBe("rev-t");
expect(updateArgs.data.payload[0].timesOfDay).toEqual(["08:00", "20:00"]);
expect(updateArgs.data.payload[0].rrule).toBe("FREQ=DAILY");
// In-place — no superseding row is minted.
expect(prisma.medicationScheduleRevision.create).not.toHaveBeenCalled();
expect(enqueueUserMedicationComplianceBackfill).toHaveBeenCalledWith(
"user-1",
);
// Check-then-write ran inside the locked transaction.
expect(prisma.$transaction).toHaveBeenCalled();
expect(prisma.$queryRaw).toHaveBeenCalled();
});
it("corrects an ARCHIVED era via a superseding MANUAL revision", async () => {
mockTarget({ source: "ARCHIVED" });
vi.mocked(prisma.medicationScheduleRevision.create).mockResolvedValue({
id: "rev-fix",
validFrom: new Date(PATCH_BODY.validFrom),
validUntil: new Date(PATCH_BODY.validUntil),
source: "MANUAL",
} as never);
vi.mocked(prisma.medicationScheduleRevision.update).mockResolvedValue(
{} as never,
);
const res = await PATCH(
patchReq("rev-t", PATCH_BODY),
deleteCtx("rev-t"),
);
expect(res.status).toBe(200);
const json = await res.json();
// The response carries the correction, which now takes the
// original's place in every era read.
expect(json.data.id).toBe("rev-fix");
expect(json.data.source).toBe("MANUAL");
const createArgs = vi.mocked(prisma.medicationScheduleRevision.create)
.mock.calls[0][0] as unknown as {
data: { source: string; payload: Array<{ timesOfDay: string[] }> };
};
expect(createArgs.data.source).toBe("MANUAL");
expect(createArgs.data.payload[0].timesOfDay).toEqual(["08:00", "20:00"]);
// The ARCHIVED original is parked behind the correction, not
// touched in its recorded content.
expect(prisma.medicationScheduleRevision.update).toHaveBeenCalledWith({
where: { id: "rev-t" },
data: { supersededByRevisionId: "rev-fix" },
});
expect(enqueueUserMedicationComplianceBackfill).toHaveBeenCalledWith(
"user-1",
);
@@ -86,7 +86,9 @@ export const GET = apiHandler(
}
const revisions = await prisma.medicationScheduleRevision.findMany({
where: { medicationId: id },
// Superseded rows are audit records behind a correction — the
// timeline shows the corrected state, like every era consumer.
where: { medicationId: id, supersededByRevisionId: null },
orderBy: { validFrom: "desc" },
select: {
id: true,
@@ -164,46 +166,10 @@ export const POST = apiHandler(
return apiError("validFrom must be a date after 1900", 422);
}
const existing = await prisma.medicationScheduleRevision.findMany({
where: { medicationId: id },
select: { validFrom: true, validUntil: true },
});
// "Before the current plan": the live era began at the newest
// archived `validUntil`; with no archive the live plan has covered
// everything since the medication was created (the era splitter
// reads `[newest validUntil, ∞)` — or the whole range when no
// revision exists), so a manual era must end at or before
// `createdAt`. Allowing it to reach "now" would let the manual
// snapshot swallow tracked live history and re-score compliance
// against the wrong plan.
const liveStart = existing.reduce(
(latest, r) => (r.validUntil > latest ? r.validUntil : latest),
new Date(0),
);
const liveBoundary = existing.length > 0 ? liveStart : med.createdAt;
if (validUntil.getTime() > liveBoundary.getTime()) {
return apiError(
"A manual era must end before the current plan begins",
422,
);
}
// No overlap with any archived interval `[validFrom, validUntil)`.
const overlaps = existing.some(
(r) =>
validFrom.getTime() < r.validUntil.getTime() &&
validUntil.getTime() > r.validFrom.getTime(),
);
if (overlaps) {
return apiError("The era overlaps an existing schedule era", 422);
}
// Snapshot entry shaped exactly like the write-path archive: daily
// recurrence at the given times, window pulled to their min/max.
const times = [...parsed.data.timesOfDay].sort((a, b) =>
a.localeCompare(b),
);
// The schema already deduped + sorted the times.
const times = parsed.data.timesOfDay;
const entry = toRevisionPayloadEntry({
timesOfDay: times,
windowStart: times[0],
@@ -220,23 +186,77 @@ export const POST = apiHandler(
reminderGraceMinutes: null,
});
const revision = await prisma.medicationScheduleRevision.create({
data: {
medicationId: id,
validFrom,
validUntil,
source: "MANUAL",
payload: [entry] as unknown as Prisma.InputJsonValue,
},
select: {
id: true,
validFrom: true,
validUntil: true,
source: true,
payload: true,
},
// Validate-then-write under a per-medication row lock: two
// concurrent era writes used to both pass the overlap check against
// the same snapshot and both insert. `FOR UPDATE` on the medication
// row serialises every era write for this medication; the reads run
// through the same `tx` so they see the locked state.
const outcome = await prisma.$transaction(async (tx) => {
await tx.$queryRaw`
SELECT id FROM medications
WHERE id = ${id}
FOR UPDATE
`;
const existing = await tx.medicationScheduleRevision.findMany({
where: { medicationId: id, supersededByRevisionId: null },
select: { validFrom: true, validUntil: true },
});
// "Before the current plan": the live era began at the newest
// active `validUntil`, and never earlier than `createdAt` — the
// era splitter reads `[newest validUntil, ∞)`, or the whole range
// when no revision exists. Allowing a manual era to reach past
// that instant would let the manual snapshot swallow tracked live
// history and re-score compliance against the wrong plan.
const liveBoundary = existing.reduce(
(latest, r) => (r.validUntil > latest ? r.validUntil : latest),
med.createdAt,
);
if (validUntil.getTime() > liveBoundary.getTime()) {
return {
ok: false,
error: "A manual era must end before the current plan begins",
} as const;
}
// No overlap with any active interval `[validFrom, validUntil)`.
const overlaps = existing.some(
(r) =>
validFrom.getTime() < r.validUntil.getTime() &&
validUntil.getTime() > r.validFrom.getTime(),
);
if (overlaps) {
return {
ok: false,
error: "The era overlaps an existing schedule era",
} as const;
}
const revision = await tx.medicationScheduleRevision.create({
data: {
medicationId: id,
validFrom,
validUntil,
source: "MANUAL",
payload: [entry] as unknown as Prisma.InputJsonValue,
},
select: {
id: true,
validFrom: true,
validUntil: true,
source: true,
payload: true,
},
});
return { ok: true, revision } as const;
});
if (!outcome.ok) {
return apiError(outcome.error, 422);
}
const revision = outcome.revision;
await auditLog("medication.schedule_revision.created", {
userId: user.id,
ipAddress: getClientIp(request),
+3 -1
View File
@@ -99,7 +99,9 @@ async function buildMedicationsList(
// past this boundary into a previous era's cadence.
prisma.medicationScheduleRevision.groupBy({
by: ["medicationId"],
where: { medication: { userId } },
// Superseded rows are audit records — a correction may have
// shortened the era, so the boundary reads only active rows.
where: { medication: { userId }, supersededByRevisionId: null },
_max: { validUntil: true },
}),
]);