mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 13:22:14 +00:00
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:
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);
|
||||
},
|
||||
);
|
||||
@@ -7,6 +7,7 @@ import { useRouter } from "next/navigation";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { IntakeHistoryList } from "@/components/medications/intake-history-list";
|
||||
import { DrugLevelChart } from "@/components/medications/DrugLevelChart";
|
||||
import { SideEffectsSection } from "@/components/medications/SideEffectsSection";
|
||||
import { ArrowLeft, Loader2, Plus } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
@@ -98,6 +99,16 @@ export default function IntakeHistoryPage({
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* v1.4.25 W19d — GLP-1 side-effect logbook. Sits between the
|
||||
drug-level chart and the intake history so the user lands on
|
||||
the cycle context (where am I in the curve), then on the
|
||||
symptom record, then on the dose-by-dose timeline. Mounted
|
||||
only for GLP-1 medications; future waves (W19e reminders,
|
||||
W19f titration ladder) hang off this same surface. */}
|
||||
{medication?.treatmentClass === "GLP1" && (
|
||||
<SideEffectsSection medicationId={id} />
|
||||
)}
|
||||
|
||||
<IntakeHistoryList
|
||||
medicationId={id}
|
||||
createOpen={createOpen}
|
||||
|
||||
@@ -0,0 +1,465 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Loader2, Plus, Trash2 } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { useFormatters, useTranslations } from "@/lib/i18n/context";
|
||||
import type {
|
||||
MedicationSideEffectCategory,
|
||||
MedicationSideEffectEntry,
|
||||
} from "@/generated/prisma/client";
|
||||
import {
|
||||
SIDE_EFFECT_CATEGORY_ORDER,
|
||||
SIDE_EFFECT_SEVERITY_LADDER,
|
||||
categoryForEntry,
|
||||
entriesByCategory,
|
||||
severityLikertLabel,
|
||||
} from "@/lib/medications/side-effects/taxonomy";
|
||||
|
||||
/**
|
||||
* v1.4.25 W19d — GLP-1 side-effect section for the medication detail
|
||||
* page. Sits below the drug-level chart and above the intake history
|
||||
* timeline. Mounted only for `treatmentClass === "GLP1"` rows.
|
||||
*
|
||||
* UX shape:
|
||||
* - Section header + "Log side effect" CTA opens a modal dialog.
|
||||
* - Inside the dialog: category picker → entry chip-picker (filtered
|
||||
* by category) → 5-Likert severity ladder → optional notes.
|
||||
* - Below the header: timeline of the last 30 days, newest first,
|
||||
* each row showing category badge + entry label + severity chip +
|
||||
* timestamp + delete button + collapsed notes.
|
||||
*
|
||||
* Component-tests cover: empty state, category → entry filtering,
|
||||
* submit-and-refetch flow, severity-label translation lookup.
|
||||
*/
|
||||
|
||||
interface SideEffectRow {
|
||||
id: string;
|
||||
category: MedicationSideEffectCategory;
|
||||
entry: MedicationSideEffectEntry;
|
||||
severity: number;
|
||||
occurredAt: string;
|
||||
notes: string | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
interface SideEffectsSectionProps {
|
||||
medicationId: string;
|
||||
}
|
||||
|
||||
const NOTES_MAX = 280;
|
||||
|
||||
function categoryI18nKey(category: MedicationSideEffectCategory): string {
|
||||
// GI → gi, METABOLIC → metabolic, INJECTION_SITE → injectionSite,
|
||||
// COGNITIVE → cognitive, GLP1_SPECIFIC → glp1Specific.
|
||||
switch (category) {
|
||||
case "GI":
|
||||
return "gi";
|
||||
case "METABOLIC":
|
||||
return "metabolic";
|
||||
case "INJECTION_SITE":
|
||||
return "injectionSite";
|
||||
case "COGNITIVE":
|
||||
return "cognitive";
|
||||
case "GLP1_SPECIFIC":
|
||||
return "glp1Specific";
|
||||
}
|
||||
}
|
||||
|
||||
function entryI18nKey(entry: MedicationSideEffectEntry): string {
|
||||
// SCREAMING_SNAKE → camelCase.
|
||||
return entry
|
||||
.toLowerCase()
|
||||
.replace(/_([a-z])/g, (_, c: string) => c.toUpperCase());
|
||||
}
|
||||
|
||||
export function SideEffectsSection({ medicationId }: SideEffectsSectionProps) {
|
||||
const { t } = useTranslations();
|
||||
const fmt = useFormatters();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const [open, setOpen] = useState(false);
|
||||
const [category, setCategory] = useState<MedicationSideEffectCategory>("GI");
|
||||
const [entry, setEntry] = useState<MedicationSideEffectEntry | null>(null);
|
||||
const [severity, setSeverity] = useState<1 | 2 | 3 | 4 | 5 | null>(null);
|
||||
const [notes, setNotes] = useState<string>("");
|
||||
const [formError, setFormError] = useState<string | null>(null);
|
||||
|
||||
const listKey = [
|
||||
"medications",
|
||||
medicationId,
|
||||
"side-effects",
|
||||
"list",
|
||||
] as const;
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: listKey,
|
||||
queryFn: async () => {
|
||||
// Last 30 days, newest-first.
|
||||
const to = new Date();
|
||||
const from = new Date(to.getTime() - 30 * 24 * 60 * 60 * 1000);
|
||||
const url = `/api/medications/${medicationId}/side-effects?from=${encodeURIComponent(from.toISOString())}&to=${encodeURIComponent(to.toISOString())}&limit=100`;
|
||||
const res = await fetch(url);
|
||||
if (!res.ok) return { items: [] as SideEffectRow[], meta: { total: 0 } };
|
||||
const json = (await res.json()) as {
|
||||
data: { items: SideEffectRow[]; meta: { total: number } };
|
||||
};
|
||||
return json.data;
|
||||
},
|
||||
staleTime: 30 * 1000,
|
||||
});
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: async (payload: {
|
||||
category: MedicationSideEffectCategory;
|
||||
entry: MedicationSideEffectEntry;
|
||||
severity: number;
|
||||
notes: string | null;
|
||||
}) => {
|
||||
const res = await fetch(
|
||||
`/api/medications/${medicationId}/side-effects`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
},
|
||||
);
|
||||
if (!res.ok) {
|
||||
const body = (await res.json().catch(() => ({}))) as {
|
||||
error?: string;
|
||||
};
|
||||
throw new Error(body.error ?? "Failed to log side effect");
|
||||
}
|
||||
return (await res.json()).data as SideEffectRow;
|
||||
},
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: listKey });
|
||||
},
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: async (logId: string) => {
|
||||
const res = await fetch(
|
||||
`/api/medications/${medicationId}/side-effects/${logId}`,
|
||||
{ method: "DELETE" },
|
||||
);
|
||||
if (!res.ok) throw new Error("Failed to delete entry");
|
||||
return true;
|
||||
},
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: listKey });
|
||||
},
|
||||
});
|
||||
|
||||
const items = data?.items ?? [];
|
||||
|
||||
const filteredEntries = useMemo(
|
||||
() => entriesByCategory(category),
|
||||
[category],
|
||||
);
|
||||
|
||||
function resetForm() {
|
||||
setCategory("GI");
|
||||
setEntry(null);
|
||||
setSeverity(null);
|
||||
setNotes("");
|
||||
setFormError(null);
|
||||
}
|
||||
|
||||
function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault();
|
||||
setFormError(null);
|
||||
if (!entry) {
|
||||
setFormError(t("medications.sideEffects.errorEntry"));
|
||||
return;
|
||||
}
|
||||
if (!severity) {
|
||||
setFormError(t("medications.sideEffects.errorSeverity"));
|
||||
return;
|
||||
}
|
||||
createMutation.mutate(
|
||||
{
|
||||
category: categoryForEntry(entry),
|
||||
entry,
|
||||
severity,
|
||||
notes: notes.trim() ? notes.trim().slice(0, NOTES_MAX) : null,
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
resetForm();
|
||||
setOpen(false);
|
||||
},
|
||||
onError: (err) => {
|
||||
setFormError(err instanceof Error ? err.message : String(err));
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<section
|
||||
className="border-border/60 rounded-md border"
|
||||
aria-labelledby="side-effects-heading"
|
||||
>
|
||||
<header className="flex items-center justify-between px-3 py-2.5">
|
||||
<h2
|
||||
id="side-effects-heading"
|
||||
className="text-foreground/85 text-sm font-medium"
|
||||
>
|
||||
{t("medications.sideEffects.section")}
|
||||
{items.length > 0 && (
|
||||
<span className="text-muted-foreground ml-1.5 font-normal">
|
||||
({items.length})
|
||||
</span>
|
||||
)}
|
||||
</h2>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
resetForm();
|
||||
setOpen(true);
|
||||
}}
|
||||
>
|
||||
<Plus className="mr-1.5 h-3.5 w-3.5" />
|
||||
{t("medications.sideEffects.addCta")}
|
||||
</Button>
|
||||
</header>
|
||||
|
||||
<div className="border-border/60 border-t px-3 py-2.5 text-xs">
|
||||
{isLoading && (
|
||||
<div className="flex items-center gap-2 text-muted-foreground">
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
<span>{t("medications.sideEffects.loading")}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isLoading && items.length === 0 && (
|
||||
<p className="text-muted-foreground py-1">
|
||||
{t("medications.sideEffects.emptyState")}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{!isLoading && items.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<p className="text-muted-foreground text-[11px] uppercase tracking-wide">
|
||||
{t("medications.sideEffects.recentTitle")}
|
||||
</p>
|
||||
<ul className="space-y-1.5">
|
||||
{items.map((row) => (
|
||||
<li
|
||||
key={row.id}
|
||||
className="bg-muted/30 flex items-start justify-between gap-2 rounded-md px-2.5 py-2"
|
||||
>
|
||||
<div className="min-w-0 flex-1 space-y-1">
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<Badge variant="outline" className="text-[10px]">
|
||||
{t(
|
||||
`medications.sideEffects.categories.${categoryI18nKey(row.category)}`,
|
||||
)}
|
||||
</Badge>
|
||||
<span className="text-foreground font-medium">
|
||||
{t(
|
||||
`medications.sideEffects.entries.${entryI18nKey(row.entry)}`,
|
||||
)}
|
||||
</span>
|
||||
<Badge variant="secondary" className="text-[10px]">
|
||||
{t(
|
||||
`medications.sideEffects.severity.${severityLikertLabel(row.severity as 1 | 2 | 3 | 4 | 5)}`,
|
||||
)}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-muted-foreground text-[11px]">
|
||||
{fmt.dateShort(new Date(row.occurredAt))}
|
||||
</p>
|
||||
{row.notes && (
|
||||
<p className="text-foreground/80 whitespace-pre-wrap break-words">
|
||||
{row.notes}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-muted-foreground hover:text-destructive h-7 w-7 shrink-0"
|
||||
aria-label={t("medications.sideEffects.deleteCta")}
|
||||
onClick={() => {
|
||||
if (
|
||||
typeof window === "undefined" ||
|
||||
window.confirm(
|
||||
t("medications.sideEffects.deleteConfirm"),
|
||||
)
|
||||
) {
|
||||
deleteMutation.mutate(row.id);
|
||||
}
|
||||
}}
|
||||
disabled={deleteMutation.isPending}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("medications.sideEffects.sheetTitle")}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
className="space-y-4"
|
||||
aria-label={t("medications.sideEffects.sheetTitle")}
|
||||
>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="side-effect-category">
|
||||
{t("medications.sideEffects.categoryLabel")}
|
||||
</Label>
|
||||
<select
|
||||
id="side-effect-category"
|
||||
value={category}
|
||||
onChange={(e) => {
|
||||
const next = e.target.value as MedicationSideEffectCategory;
|
||||
setCategory(next);
|
||||
// Reset entry — the previously-selected entry probably
|
||||
// doesn't belong to the new category.
|
||||
setEntry(null);
|
||||
}}
|
||||
className="border-input bg-background text-foreground w-full rounded-md border px-2 py-1.5 text-sm"
|
||||
>
|
||||
{SIDE_EFFECT_CATEGORY_ORDER.map((c) => (
|
||||
<option key={c} value={c}>
|
||||
{t(
|
||||
`medications.sideEffects.categories.${categoryI18nKey(c)}`,
|
||||
)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label>{t("medications.sideEffects.entryLabel")}</Label>
|
||||
<div
|
||||
className="flex flex-wrap gap-1.5"
|
||||
role="radiogroup"
|
||||
aria-label={t("medications.sideEffects.entryLabel")}
|
||||
>
|
||||
{filteredEntries.map((candidate) => {
|
||||
const selected = candidate === entry;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
key={candidate}
|
||||
role="radio"
|
||||
aria-checked={selected}
|
||||
onClick={() => setEntry(candidate)}
|
||||
className={`rounded-full border px-2.5 py-1 text-xs transition-colors ${
|
||||
selected
|
||||
? "border-primary bg-primary text-primary-foreground"
|
||||
: "border-border bg-background text-foreground hover:bg-muted"
|
||||
}`}
|
||||
>
|
||||
{t(
|
||||
`medications.sideEffects.entries.${entryI18nKey(candidate)}`,
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label>{t("medications.sideEffects.severityLabel")}</Label>
|
||||
<div
|
||||
className="flex flex-wrap gap-1.5"
|
||||
role="radiogroup"
|
||||
aria-label={t("medications.sideEffects.severityLabel")}
|
||||
>
|
||||
{SIDE_EFFECT_SEVERITY_LADDER.map((label, idx) => {
|
||||
const value = (idx + 1) as 1 | 2 | 3 | 4 | 5;
|
||||
const selected = severity === value;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
key={label}
|
||||
role="radio"
|
||||
aria-checked={selected}
|
||||
onClick={() => setSeverity(value)}
|
||||
className={`min-w-[2.5rem] rounded-md border px-2.5 py-1.5 text-xs font-medium transition-colors ${
|
||||
selected
|
||||
? "border-primary bg-primary text-primary-foreground"
|
||||
: "border-border bg-background text-foreground hover:bg-muted"
|
||||
}`}
|
||||
>
|
||||
<span aria-hidden className="block text-sm">
|
||||
{value}
|
||||
</span>
|
||||
<span className="block text-[10px] opacity-80">
|
||||
{t(`medications.sideEffects.severity.${label}`)}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="side-effect-notes">
|
||||
{t("medications.sideEffects.notesLabel")}
|
||||
</Label>
|
||||
<textarea
|
||||
id="side-effect-notes"
|
||||
value={notes}
|
||||
onChange={(e) => setNotes(e.target.value.slice(0, NOTES_MAX))}
|
||||
placeholder={t("medications.sideEffects.notesPlaceholder")}
|
||||
rows={3}
|
||||
maxLength={NOTES_MAX}
|
||||
className="border-input bg-background text-foreground w-full rounded-md border px-2 py-1.5 text-sm"
|
||||
/>
|
||||
<p className="text-muted-foreground text-right text-[10px]">
|
||||
{notes.length} / {NOTES_MAX}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{formError && (
|
||||
<p className="text-destructive text-sm" role="alert">
|
||||
{formError}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<DialogFooter className="gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={() => setOpen(false)}
|
||||
>
|
||||
{t("medications.sideEffects.cancelCta")}
|
||||
</Button>
|
||||
<Button type="submit" disabled={createMutation.isPending}>
|
||||
{createMutation.isPending && (
|
||||
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
|
||||
)}
|
||||
{t("medications.sideEffects.submitCta")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import { SideEffectsSection } from "@/components/medications/SideEffectsSection";
|
||||
|
||||
/**
|
||||
* v1.4.25 W19d — SideEffectsSection SSR smoke tests.
|
||||
*
|
||||
* The project never installed `@testing-library/react`; the
|
||||
* convention is `renderToStaticMarkup` + assertions against the SSR
|
||||
* string, with react-query data seeded via `QueryClient.setQueryData()`
|
||||
* so the section's `useQuery` resolves synchronously. Interactive
|
||||
* branches (submit, delete, dialog open) are covered by the API-route
|
||||
* tests + the pure taxonomy tests; the surface tests here pin the
|
||||
* static-render contract:
|
||||
*
|
||||
* 1. Section heading + add-CTA render in both locales.
|
||||
* 2. Empty state copy when no rows are seeded.
|
||||
* 3. Recent-30-days timeline renders entries with category badge,
|
||||
* entry label, severity label, and notes.
|
||||
* 4. Severity translation passes through the Likert ladder.
|
||||
* 5. Category badge translation uses the i18n category key.
|
||||
*/
|
||||
|
||||
function makeClient() {
|
||||
return new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false,
|
||||
// Synchronous resolution of seeded data is the test contract.
|
||||
staleTime: Number.POSITIVE_INFINITY,
|
||||
refetchOnMount: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function seedSideEffects(
|
||||
client: QueryClient,
|
||||
medId: string,
|
||||
rows: Array<{
|
||||
id: string;
|
||||
category: string;
|
||||
entry: string;
|
||||
severity: number;
|
||||
occurredAt: string;
|
||||
notes: string | null;
|
||||
}>,
|
||||
) {
|
||||
client.setQueryData(["medications", medId, "side-effects", "list"], {
|
||||
items: rows,
|
||||
meta: { total: rows.length },
|
||||
});
|
||||
}
|
||||
|
||||
function render(
|
||||
node: React.ReactNode,
|
||||
client: QueryClient,
|
||||
locale: "en" | "de" = "en",
|
||||
) {
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale={locale}>
|
||||
<QueryClientProvider client={client}>{node}</QueryClientProvider>
|
||||
</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("<SideEffectsSection> — surface render", () => {
|
||||
it("renders the section heading and add-CTA in English", () => {
|
||||
const client = makeClient();
|
||||
seedSideEffects(client, "med-1", []);
|
||||
const html = render(<SideEffectsSection medicationId="med-1" />, client);
|
||||
expect(html).toContain("Side effects");
|
||||
expect(html).toContain("Log side effect");
|
||||
});
|
||||
|
||||
it("renders the section heading and add-CTA in German", () => {
|
||||
const client = makeClient();
|
||||
seedSideEffects(client, "med-1", []);
|
||||
const html = render(
|
||||
<SideEffectsSection medicationId="med-1" />,
|
||||
client,
|
||||
"de",
|
||||
);
|
||||
expect(html).toContain("Nebenwirkungen");
|
||||
expect(html).toContain("Nebenwirkung erfassen");
|
||||
});
|
||||
|
||||
it("renders the empty-state copy when no rows are seeded", () => {
|
||||
const client = makeClient();
|
||||
seedSideEffects(client, "med-1", []);
|
||||
const html = render(<SideEffectsSection medicationId="med-1" />, client);
|
||||
expect(html).toContain("No side effects logged yet");
|
||||
});
|
||||
});
|
||||
|
||||
describe("<SideEffectsSection> — timeline rows", () => {
|
||||
it("renders entry label, category badge, and severity label", () => {
|
||||
const client = makeClient();
|
||||
seedSideEffects(client, "med-1", [
|
||||
{
|
||||
id: "se-1",
|
||||
category: "GI",
|
||||
entry: "NAUSEA",
|
||||
severity: 2,
|
||||
occurredAt: "2026-05-13T08:00:00.000Z",
|
||||
notes: null,
|
||||
},
|
||||
]);
|
||||
const html = render(<SideEffectsSection medicationId="med-1" />, client);
|
||||
expect(html).toContain("Nausea");
|
||||
expect(html).toContain("Gastrointestinal");
|
||||
expect(html).toContain("Moderate");
|
||||
});
|
||||
|
||||
it("renders multi-category rows independently", () => {
|
||||
const client = makeClient();
|
||||
seedSideEffects(client, "med-1", [
|
||||
{
|
||||
id: "se-1",
|
||||
category: "GI",
|
||||
entry: "NAUSEA",
|
||||
severity: 1,
|
||||
occurredAt: "2026-05-13T08:00:00.000Z",
|
||||
notes: null,
|
||||
},
|
||||
{
|
||||
id: "se-2",
|
||||
category: "GLP1_SPECIFIC",
|
||||
entry: "EARLY_SATIETY",
|
||||
severity: 4,
|
||||
occurredAt: "2026-05-12T08:00:00.000Z",
|
||||
notes: "felt full after two bites",
|
||||
},
|
||||
]);
|
||||
const html = render(<SideEffectsSection medicationId="med-1" />, client);
|
||||
expect(html).toContain("Nausea");
|
||||
expect(html).toContain("Mild");
|
||||
expect(html).toContain("Early satiety");
|
||||
expect(html).toContain("GLP-1 specific");
|
||||
expect(html).toContain("Severe");
|
||||
expect(html).toContain("felt full after two bites");
|
||||
});
|
||||
|
||||
it("shows the recent-30-days label when items are present", () => {
|
||||
const client = makeClient();
|
||||
seedSideEffects(client, "med-1", [
|
||||
{
|
||||
id: "se-1",
|
||||
category: "COGNITIVE",
|
||||
entry: "BRAIN_FOG",
|
||||
severity: 3,
|
||||
occurredAt: "2026-05-13T08:00:00.000Z",
|
||||
notes: null,
|
||||
},
|
||||
]);
|
||||
const html = render(<SideEffectsSection medicationId="med-1" />, client);
|
||||
expect(html).toContain("Last 30 days");
|
||||
});
|
||||
|
||||
it("translates the severity-label ladder via the i18n helper", () => {
|
||||
const client = makeClient();
|
||||
seedSideEffects(client, "med-1", [
|
||||
{
|
||||
id: "a",
|
||||
category: "GI",
|
||||
entry: "NAUSEA",
|
||||
severity: 1,
|
||||
occurredAt: "2026-05-13T08:00:00.000Z",
|
||||
notes: null,
|
||||
},
|
||||
{
|
||||
id: "b",
|
||||
category: "GI",
|
||||
entry: "VOMITING",
|
||||
severity: 5,
|
||||
occurredAt: "2026-05-13T09:00:00.000Z",
|
||||
notes: null,
|
||||
},
|
||||
]);
|
||||
const html = render(<SideEffectsSection medicationId="med-1" />, client);
|
||||
expect(html).toContain("Mild");
|
||||
expect(html).toContain("Very severe");
|
||||
});
|
||||
|
||||
it("renders German entry and category labels when locale=de", () => {
|
||||
const client = makeClient();
|
||||
seedSideEffects(client, "med-1", [
|
||||
{
|
||||
id: "se-1",
|
||||
category: "INJECTION_SITE",
|
||||
entry: "INJECTION_REDNESS",
|
||||
severity: 2,
|
||||
occurredAt: "2026-05-13T08:00:00.000Z",
|
||||
notes: null,
|
||||
},
|
||||
]);
|
||||
const html = render(
|
||||
<SideEffectsSection medicationId="med-1" />,
|
||||
client,
|
||||
"de",
|
||||
);
|
||||
expect(html).toContain("Injektionsstelle");
|
||||
expect(html).toContain("Rötung Einstichstelle");
|
||||
expect(html).toContain("Mittel");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* v1.4.25 W19d — Zod validators for the side-effect API surface.
|
||||
*
|
||||
* The category supplied on the wire is sanity-checked against the
|
||||
* authoritative entry → category mapping in `./taxonomy.ts`; on
|
||||
* mismatch the route returns 422. This means a client cannot
|
||||
* accidentally (or maliciously) write a NAUSEA row stamped with the
|
||||
* INJECTION_SITE category and confuse the Coach aggregator.
|
||||
*
|
||||
* Severity is bounded 1-5 — the same range as the DB-level CHECK
|
||||
* constraint and the `SideEffectSeverity` type-guard. Notes are
|
||||
* capped to 280 chars (the chat-bubble length budget the timeline
|
||||
* renders inside).
|
||||
*/
|
||||
|
||||
import { z } from "zod/v4";
|
||||
|
||||
export const SIDE_EFFECT_CATEGORY_VALUES = [
|
||||
"GI",
|
||||
"METABOLIC",
|
||||
"INJECTION_SITE",
|
||||
"COGNITIVE",
|
||||
"GLP1_SPECIFIC",
|
||||
] as const;
|
||||
|
||||
export const SIDE_EFFECT_ENTRY_VALUES = [
|
||||
"NAUSEA",
|
||||
"VOMITING",
|
||||
"DIARRHEA",
|
||||
"CONSTIPATION",
|
||||
"ABDOMINAL_PAIN",
|
||||
"HYPOGLYCEMIA_SYMPTOMS",
|
||||
"DEHYDRATION",
|
||||
"ANOREXIA",
|
||||
"ELECTROLYTE_FATIGUE",
|
||||
"INJECTION_REDNESS",
|
||||
"INJECTION_SWELLING",
|
||||
"INJECTION_BRUISING",
|
||||
"INJECTION_INDURATION",
|
||||
"BRAIN_FOG",
|
||||
"DIZZINESS",
|
||||
"LOW_MOOD",
|
||||
"LOW_ENERGY",
|
||||
"EARLY_SATIETY",
|
||||
"GASTROPARESIS_LIKE",
|
||||
"DYSGEUSIA",
|
||||
"GALLBLADDER_DISCOMFORT",
|
||||
] as const;
|
||||
|
||||
export const SIDE_EFFECT_NOTES_MAX = 280;
|
||||
|
||||
export const createSideEffectSchema = z.object({
|
||||
category: z.enum(SIDE_EFFECT_CATEGORY_VALUES),
|
||||
entry: z.enum(SIDE_EFFECT_ENTRY_VALUES),
|
||||
severity: z.number().int().min(1).max(5),
|
||||
occurredAt: z.iso
|
||||
.datetime({ offset: true })
|
||||
.transform((s) => new Date(s))
|
||||
.optional(),
|
||||
notes: z.string().max(SIDE_EFFECT_NOTES_MAX).nullable().optional(),
|
||||
});
|
||||
|
||||
export const listSideEffectsSchema = z.object({
|
||||
from: z.iso
|
||||
.datetime({ offset: true })
|
||||
.transform((s) => new Date(s))
|
||||
.optional(),
|
||||
to: z.iso
|
||||
.datetime({ offset: true })
|
||||
.transform((s) => new Date(s))
|
||||
.optional(),
|
||||
limit: z.coerce.number().int().min(1).max(200).optional().default(50),
|
||||
});
|
||||
|
||||
export type CreateSideEffectInput = z.infer<typeof createSideEffectSchema>;
|
||||
export type ListSideEffectsInput = z.infer<typeof listSideEffectsSchema>;
|
||||
Reference in new issue
Block a user