feat(medications): side-effect logging on GLP-1 detail pages

Adds the /api/medications/[id]/side-effects collection + per-row
endpoints (GET / POST + DELETE), the SideEffectsSection picker UI,
and i18n across the six locales. Mounted on the medication detail
page between the drug-level chart and the intake history; only the
GLP-1 treatmentClass renders the section.

The picker is category → entry chip → 5-Likert severity with an
optional 280-char note. The server derives the category from the
entry server-side — a client-supplied mismatch returns 422, so a
NAUSEA row tagged as INJECTION_SITE never lands in the DB.

POST is rate-limited 30/min/user (matching the inventory route);
DELETE is allowed at any time because the rows are user-owned and
non-clinical, with the audit trail preserving entry + severity +
occurredAt so a forensic reconstruction stays possible.

DE + EN copy hand-curated; FR / ES / IT / PL drafted from EN with
EMA-EPAR-§4.8 terminology. 39 new tests cover the route contract
(auth, ownership, validation, category mismatch, rate-limit) and
the section's static-render shape (empty state, category badge,
severity ladder, entry label, locale switch).
This commit is contained in:
Marc-André Bombeck committed 2026-05-14 21:01:58 +02:00
1 parent 4e2b7be472
commit 7dfc212634
13 files changed
+1693

No files matched your search

@@ -0,0 +1,72 @@
/**
* v1.4.25 W19d — per-row side-effect operations.
*
* DELETE /api/medications/[id]/side-effects/[logId]
* - hard delete; the row is user-owned, non-clinical, and the
* audit log captures enough state to reconstruct it if needed.
*
* Marc-decision (from the W19d brief): allow delete at any time
* rather than a 24-hour retraction window. Side-effect rows are not
* a clinical record; the user owns them and a stale mis-entry should
* never be undeletable. The audit trail preserves "what was deleted
* when" for any forensic reconstruction.
*/
import { NextRequest } from "next/server";
import { prisma } from "@/lib/db";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { auditLog } from "@/lib/auth/audit";
import { annotate } from "@/lib/logging/context";
import { apiError, apiSuccess, getClientIp } from "@/lib/api-response";
type RouteParams = { params: Promise<{ id: string; logId: string }> };
async function loadOwnedRow(
medicationId: string,
logId: string,
userId: string,
) {
const row = await prisma.medicationSideEffect.findUnique({
where: { id: logId },
});
if (!row || row.userId !== userId || row.medicationId !== medicationId) {
return null;
}
return row;
}
export const DELETE = apiHandler(
async (request: NextRequest, { params }: RouteParams) => {
const { user } = await requireAuth();
const { id, logId } = await params;
const existing = await loadOwnedRow(id, logId, user.id);
if (!existing) return apiError("Side-effect log not found", 404);
await prisma.medicationSideEffect.delete({ where: { id: logId } });
await auditLog("medication.sideEffect.delete", {
userId: user.id,
ipAddress: getClientIp(request),
details: {
medicationId: id,
sideEffectId: logId,
entry: existing.entry,
severity: existing.severity,
occurredAt: existing.occurredAt.toISOString(),
},
});
annotate({
action: {
name: "medication.sideEffect.delete",
entity_type: "medication_side_effect",
entity_id: logId,
},
meta: { medication_id: id, entry: existing.entry },
});
return apiSuccess({ id: logId, deleted: true });
},
);
@@ -0,0 +1,338 @@
/**
* v1.4.25 W19d — side-effect API route tests.
*
* Mirrors the inventory route test fixture pattern: every external
* dependency is mocked at module boundaries (Prisma, session, audit,
* rate-limit, logging transport), and each describe block exercises
* one HTTP verb's contract.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
vi.mock("@/lib/db", () => ({
prisma: {
medication: { findUnique: vi.fn() },
medicationSideEffect: {
findMany: vi.fn(),
findUnique: vi.fn(),
create: vi.fn(),
delete: vi.fn(),
},
},
}));
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
vi.mock("@/lib/auth/audit", () => ({
auditLog: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/rate-limit", () => ({
checkRateLimit: vi.fn().mockResolvedValue({
allowed: true,
remaining: 29,
resetAt: Date.now() + 60_000,
}),
rateLimitHeaders: vi.fn(() => ({})),
}));
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("next/headers", () => ({
headers: vi.fn(async () => ({ get: () => null })),
cookies: vi.fn(async () => ({
get: () => undefined,
set: () => {},
delete: () => {},
})),
}));
import { GET, POST } from "../route";
import { DELETE } from "../[logId]/route";
import { prisma } from "@/lib/db";
import { getSession } from "@/lib/auth/session";
import { checkRateLimit } from "@/lib/rate-limit";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
user: { id: "user-1", username: "marc", role: "USER" as const },
};
const MED_OK = { id: "med-1", userId: "user-1" };
function jsonReq(url: string, body: unknown, method = "POST"): NextRequest {
return new NextRequest(url, {
method,
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
}
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(checkRateLimit).mockResolvedValue({
allowed: true,
remaining: 29,
resetAt: Date.now() + 60_000,
});
});
describe("GET /api/medications/[id]/side-effects", () => {
it("returns 401 when unauthenticated", async () => {
vi.mocked(getSession).mockResolvedValue(null);
const res = await GET(
new NextRequest("http://localhost/api/medications/med-1/side-effects"),
{ params: Promise.resolve({ id: "med-1" }) },
);
expect(res.status).toBe(401);
});
it("returns 404 when the medication is not owned by the caller", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.medication.findUnique).mockResolvedValue({
id: "med-1",
userId: "OTHER",
} as never);
const res = await GET(
new NextRequest("http://localhost/api/medications/med-1/side-effects"),
{ params: Promise.resolve({ id: "med-1" }) },
);
expect(res.status).toBe(404);
});
it("returns the logs for the owned medication", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.medication.findUnique).mockResolvedValue(MED_OK as never);
vi.mocked(prisma.medicationSideEffect.findMany).mockResolvedValue([
{ id: "se-1", entry: "NAUSEA", severity: 2 },
{ id: "se-2", entry: "DIARRHEA", severity: 3 },
] as never);
const res = await GET(
new NextRequest("http://localhost/api/medications/med-1/side-effects"),
{ params: Promise.resolve({ id: "med-1" }) },
);
expect(res.status).toBe(200);
const body = (await res.json()) as {
data: { items: unknown[]; meta: { total: number } };
};
expect(body.data.items).toHaveLength(2);
expect(body.data.meta.total).toBe(2);
});
it("applies from / to / limit query params", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.medication.findUnique).mockResolvedValue(MED_OK as never);
vi.mocked(prisma.medicationSideEffect.findMany).mockResolvedValue(
[] as never,
);
const from = "2026-05-01T00:00:00Z";
const to = "2026-05-14T00:00:00Z";
await GET(
new NextRequest(
`http://localhost/api/medications/med-1/side-effects?from=${from}&to=${to}&limit=10`,
),
{ params: Promise.resolve({ id: "med-1" }) },
);
const call = vi.mocked(prisma.medicationSideEffect.findMany).mock.calls[0][0];
expect(call).toMatchObject({
where: {
userId: "user-1",
medicationId: "med-1",
occurredAt: { gte: new Date(from), lt: new Date(to) },
},
take: 10,
});
});
});
describe("POST /api/medications/[id]/side-effects", () => {
it("rejects unauthenticated", async () => {
vi.mocked(getSession).mockResolvedValue(null);
const res = await POST(
jsonReq("http://localhost/api/medications/med-1/side-effects", {
category: "GI",
entry: "NAUSEA",
severity: 2,
}),
{ params: Promise.resolve({ id: "med-1" }) },
);
expect(res.status).toBe(401);
});
it("returns 422 when severity is out of range", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.medication.findUnique).mockResolvedValue(MED_OK as never);
const res = await POST(
jsonReq("http://localhost/api/medications/med-1/side-effects", {
category: "GI",
entry: "NAUSEA",
severity: 7,
}),
{ params: Promise.resolve({ id: "med-1" }) },
);
expect(res.status).toBe(422);
});
it("returns 422 when entry / category mismatch", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.medication.findUnique).mockResolvedValue(MED_OK as never);
const res = await POST(
jsonReq("http://localhost/api/medications/med-1/side-effects", {
category: "INJECTION_SITE",
entry: "NAUSEA",
severity: 2,
}),
{ params: Promise.resolve({ id: "med-1" }) },
);
expect(res.status).toBe(422);
});
it("creates a row with the server-derived category", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.medication.findUnique).mockResolvedValue(MED_OK as never);
vi.mocked(prisma.medicationSideEffect.create).mockResolvedValue({
id: "se-new",
entry: "NAUSEA",
category: "GI",
severity: 2,
} as never);
const occurredAt = "2026-05-14T08:00:00Z";
const res = await POST(
jsonReq("http://localhost/api/medications/med-1/side-effects", {
category: "GI",
entry: "NAUSEA",
severity: 2,
occurredAt,
notes: "after breakfast",
}),
{ params: Promise.resolve({ id: "med-1" }) },
);
expect(res.status).toBe(201);
expect(prisma.medicationSideEffect.create).toHaveBeenCalledWith({
data: expect.objectContaining({
userId: "user-1",
medicationId: "med-1",
category: "GI",
entry: "NAUSEA",
severity: 2,
occurredAt: new Date(occurredAt),
notes: "after breakfast",
}),
});
});
it("defaults occurredAt to now when omitted", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.medication.findUnique).mockResolvedValue(MED_OK as never);
vi.mocked(prisma.medicationSideEffect.create).mockResolvedValue({
id: "se-new",
} as never);
const before = Date.now();
await POST(
jsonReq("http://localhost/api/medications/med-1/side-effects", {
category: "GLP1_SPECIFIC",
entry: "EARLY_SATIETY",
severity: 3,
}),
{ params: Promise.resolve({ id: "med-1" }) },
);
const after = Date.now();
const call = vi.mocked(prisma.medicationSideEffect.create).mock.calls[0][0];
const created = call.data.occurredAt as Date;
expect(created.getTime()).toBeGreaterThanOrEqual(before);
expect(created.getTime()).toBeLessThanOrEqual(after);
});
it("returns 429 when the rate limit is exceeded", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.medication.findUnique).mockResolvedValue(MED_OK as never);
vi.mocked(checkRateLimit).mockResolvedValueOnce({
allowed: false,
remaining: 0,
resetAt: Date.now() + 60_000,
});
const res = await POST(
jsonReq("http://localhost/api/medications/med-1/side-effects", {
category: "GI",
entry: "NAUSEA",
severity: 2,
}),
{ params: Promise.resolve({ id: "med-1" }) },
);
expect(res.status).toBe(429);
});
});
describe("DELETE /api/medications/[id]/side-effects/[logId]", () => {
it("returns 401 unauthenticated", async () => {
vi.mocked(getSession).mockResolvedValue(null);
const res = await DELETE(
new NextRequest(
"http://localhost/api/medications/med-1/side-effects/se-1",
{ method: "DELETE" },
),
{ params: Promise.resolve({ id: "med-1", logId: "se-1" }) },
);
expect(res.status).toBe(401);
});
it("returns 404 when the row belongs to another user", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.medicationSideEffect.findUnique).mockResolvedValue({
id: "se-1",
userId: "OTHER",
medicationId: "med-1",
entry: "NAUSEA",
severity: 2,
occurredAt: new Date(),
} as never);
const res = await DELETE(
new NextRequest(
"http://localhost/api/medications/med-1/side-effects/se-1",
{ method: "DELETE" },
),
{ params: Promise.resolve({ id: "med-1", logId: "se-1" }) },
);
expect(res.status).toBe(404);
});
it("hard-deletes the row and audit-logs entry + severity", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.medicationSideEffect.findUnique).mockResolvedValue({
id: "se-1",
userId: "user-1",
medicationId: "med-1",
entry: "NAUSEA",
severity: 3,
occurredAt: new Date("2026-05-14T08:00:00Z"),
} as never);
vi.mocked(prisma.medicationSideEffect.delete).mockResolvedValue({
id: "se-1",
} as never);
const res = await DELETE(
new NextRequest(
"http://localhost/api/medications/med-1/side-effects/se-1",
{ method: "DELETE" },
),
{ params: Promise.resolve({ id: "med-1", logId: "se-1" }) },
);
expect(res.status).toBe(200);
expect(prisma.medicationSideEffect.delete).toHaveBeenCalledWith({
where: { id: "se-1" },
});
});
});
@@ -0,0 +1,182 @@
/**
* v1.4.25 W19d — GLP-1 side-effect log CRUD (collection).
*
* GET /api/medications/[id]/side-effects?from=ISO&to=ISO&limit=50
* - returns the user's logs for this medication, newest first,
* optionally bounded by an [from, to) window.
*
* POST /api/medications/[id]/side-effects
* - creates a new entry. Body: { category, entry, severity,
* occurredAt?, notes? }. Category is verified against the
* authoritative entry → category mapping; mismatch → 422.
*
* Auth: cookie-session via requireAuth(); the medication is verified
* to belong to the caller before any read/write.
* Rate-limit: 30/min/user on the POST path.
* Audit-log every mutation with the affected row id.
*/
import { NextRequest } from "next/server";
import { prisma } from "@/lib/db";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { auditLog } from "@/lib/auth/audit";
import { annotate } from "@/lib/logging/context";
import {
apiError,
apiSuccess,
getClientIp,
safeJson,
} from "@/lib/api-response";
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
import {
createSideEffectSchema,
listSideEffectsSchema,
} from "@/lib/medications/side-effects/validators";
import { categoryForEntry } from "@/lib/medications/side-effects/taxonomy";
type RouteParams = { params: Promise<{ id: string }> };
const POST_RATE_LIMIT = 30;
const POST_WINDOW_MS = 60_000;
async function assertMedicationOwnership(
medicationId: string,
userId: string,
) {
const med = await prisma.medication.findUnique({
where: { id: medicationId },
select: { id: true, userId: true },
});
if (!med || med.userId !== userId) {
return apiError("Medication not found", 404);
}
return null;
}
export const GET = apiHandler(
async (request: NextRequest, { params }: RouteParams) => {
const { user } = await requireAuth();
const { id } = await params;
const guard = await assertMedicationOwnership(id, user.id);
if (guard) return guard;
const url = new URL(request.url);
const parsed = listSideEffectsSchema.safeParse({
from: url.searchParams.get("from") ?? undefined,
to: url.searchParams.get("to") ?? undefined,
limit: url.searchParams.get("limit") ?? undefined,
});
if (!parsed.success) {
return apiError(parsed.error.issues[0].message, 422);
}
const { from, to, limit } = parsed.data;
const items = await prisma.medicationSideEffect.findMany({
where: {
userId: user.id,
medicationId: id,
...(from || to
? {
occurredAt: {
...(from ? { gte: from } : {}),
...(to ? { lt: to } : {}),
},
}
: {}),
},
orderBy: { occurredAt: "desc" },
take: limit,
});
annotate({
action: { name: "medication.sideEffect.list" },
meta: { medication_id: id, total: items.length },
});
return apiSuccess({ items, meta: { total: items.length } });
},
);
export const POST = apiHandler(
async (request: NextRequest, { params }: RouteParams) => {
const { user } = await requireAuth();
const { id } = await params;
const guard = await assertMedicationOwnership(id, user.id);
if (guard) return guard;
// Per-user POST rate-limit — 30/min comfortably absorbs a session
// of bulk back-fill (e.g. "log yesterday's symptoms") while cutting
// off automated abuse.
const rl = await checkRateLimit(
`medication-side-effect:post:${user.id}`,
POST_RATE_LIMIT,
POST_WINDOW_MS,
);
if (!rl.allowed) {
const response = apiError("Too many requests", 429);
for (const [k, v] of Object.entries(rateLimitHeaders(rl))) {
response.headers.set(k, v);
}
return response;
}
const { data: body, error: jsonError } = await safeJson(request);
if (jsonError) return jsonError;
const parsed = createSideEffectSchema.safeParse(body);
if (!parsed.success) {
return apiError(parsed.error.issues[0].message, 422);
}
const { category, entry, severity, occurredAt, notes } = parsed.data;
// Authoritative category derivation. The client-supplied category
// is sanity-checked, never trusted — a NAUSEA row tagged as
// INJECTION_SITE would poison the Coach aggregator.
const expectedCategory = categoryForEntry(entry);
if (expectedCategory !== category) {
return apiError(
`Entry ${entry} belongs to category ${expectedCategory}, not ${category}`,
422,
);
}
const created = await prisma.medicationSideEffect.create({
data: {
userId: user.id,
medicationId: id,
category: expectedCategory,
entry,
severity,
occurredAt: occurredAt ?? new Date(),
notes: notes ?? null,
},
});
await auditLog("medication.sideEffect.create", {
userId: user.id,
ipAddress: getClientIp(request),
details: {
medicationId: id,
sideEffectId: created.id,
entry,
severity,
},
});
annotate({
action: {
name: "medication.sideEffect.create",
entity_type: "medication_side_effect",
entity_id: created.id,
},
meta: { medication_id: id, entry, severity },
});
return apiSuccess(created, 201);
},
);