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

+56
View File
@@ -611,6 +611,62 @@
"usedUp": "Verbraucht"
}
},
"sideEffects": {
"section": "Nebenwirkungen",
"addCta": "Nebenwirkung erfassen",
"sheetTitle": "Nebenwirkung erfassen",
"categoryLabel": "Kategorie",
"entryLabel": "Symptom",
"severityLabel": "Schweregrad",
"severity": {
"mild": "Leicht",
"moderate": "Mittel",
"significant": "Deutlich",
"severe": "Stark",
"verySevere": "Sehr stark"
},
"notesLabel": "Notiz (optional)",
"notesPlaceholder": "Was du dir merken willst, max. 280 Zeichen",
"submitCta": "Speichern",
"cancelCta": "Abbrechen",
"emptyState": "Noch keine Nebenwirkungen erfasst. Tracking hilft dir und deiner Ärztin, Muster über die Zeit zu erkennen.",
"recentTitle": "Letzte 30 Tage",
"loading": "Wird geladen…",
"deleteCta": "Löschen",
"deleteConfirm": "Eintrag löschen?",
"errorSeverity": "Schweregrad muss zwischen 1 und 5 liegen",
"errorEntry": "Bitte ein Symptom auswählen",
"categories": {
"gi": "Magen-Darm",
"metabolic": "Stoffwechsel",
"injectionSite": "Injektionsstelle",
"cognitive": "Kognition",
"glp1Specific": "GLP-1 spezifisch"
},
"entries": {
"nausea": "Übelkeit",
"vomiting": "Erbrechen",
"diarrhea": "Durchfall",
"constipation": "Verstopfung",
"abdominalPain": "Bauchschmerzen",
"hypoglycemiaSymptoms": "Unterzucker-Symptome",
"dehydration": "Dehydratation",
"anorexia": "Appetitverlust",
"electrolyteFatigue": "Elektrolyt-Müdigkeit",
"injectionRedness": "Rötung Einstichstelle",
"injectionSwelling": "Schwellung Einstichstelle",
"injectionBruising": "Bluterguss Einstichstelle",
"injectionInduration": "Verhärtung Einstichstelle",
"brainFog": "Konzentrationsnebel",
"dizziness": "Schwindel",
"lowMood": "Gedrückte Stimmung",
"lowEnergy": "Energielosigkeit",
"earlySatiety": "Frühes Sättigungsgefühl",
"gastroparesisLike": "Verlangsamte Magenentleerung",
"dysgeusia": "Geschmacksveränderung",
"gallbladderDiscomfort": "Gallenblasen-Beschwerden"
}
},
"injectionSiteBodyOutlineAriaLabel": "Körperumriss",
"injectionSitePickerAriaLabel": "Injektionsstelle auswählen",
"siteAbdomenLeft": "Bauch, unten links",
+56
View File
@@ -611,6 +611,62 @@
"usedUp": "Used up"
}
},
"sideEffects": {
"section": "Side effects",
"addCta": "Log side effect",
"sheetTitle": "Log a side effect",
"categoryLabel": "Category",
"entryLabel": "Symptom",
"severityLabel": "Severity",
"severity": {
"mild": "Mild",
"moderate": "Moderate",
"significant": "Significant",
"severe": "Severe",
"verySevere": "Very severe"
},
"notesLabel": "Notes (optional)",
"notesPlaceholder": "Anything worth remembering, max 280 characters",
"submitCta": "Save",
"cancelCta": "Cancel",
"emptyState": "No side effects logged yet. Tracking can help your doctor and surface patterns over time.",
"recentTitle": "Last 30 days",
"loading": "Loading…",
"deleteCta": "Delete",
"deleteConfirm": "Delete this entry?",
"errorSeverity": "Severity must be between 1 and 5",
"errorEntry": "Pick a symptom",
"categories": {
"gi": "Gastrointestinal",
"metabolic": "Metabolic",
"injectionSite": "Injection site",
"cognitive": "Cognitive",
"glp1Specific": "GLP-1 specific"
},
"entries": {
"nausea": "Nausea",
"vomiting": "Vomiting",
"diarrhea": "Diarrhoea",
"constipation": "Constipation",
"abdominalPain": "Abdominal pain",
"hypoglycemiaSymptoms": "Low-glucose symptoms",
"dehydration": "Dehydration",
"anorexia": "Decreased appetite",
"electrolyteFatigue": "Electrolyte-imbalance fatigue",
"injectionRedness": "Injection site redness",
"injectionSwelling": "Injection site swelling",
"injectionBruising": "Injection site bruising",
"injectionInduration": "Injection site induration",
"brainFog": "Brain fog",
"dizziness": "Dizziness",
"lowMood": "Low mood",
"lowEnergy": "Low energy",
"earlySatiety": "Early satiety",
"gastroparesisLike": "Slow stomach emptying",
"dysgeusia": "Taste change",
"gallbladderDiscomfort": "Gallbladder discomfort"
}
},
"injectionSiteBodyOutlineAriaLabel": "Body outline",
"injectionSitePickerAriaLabel": "Injection site picker",
"siteAbdomenLeft": "Abdomen, lower left",
+56
View File
@@ -611,6 +611,62 @@
"usedUp": "Agotada"
}
},
"sideEffects": {
"section": "Efectos secundarios",
"addCta": "Registrar efecto",
"sheetTitle": "Registrar un efecto secundario",
"categoryLabel": "Categoría",
"entryLabel": "Síntoma",
"severityLabel": "Gravedad",
"severity": {
"mild": "Leve",
"moderate": "Moderado",
"significant": "Notable",
"severe": "Grave",
"verySevere": "Muy grave"
},
"notesLabel": "Notas (opcional)",
"notesPlaceholder": "Lo que merezca la pena anotar, máx. 280 caracteres",
"submitCta": "Guardar",
"cancelCta": "Cancelar",
"emptyState": "Aún no hay efectos secundarios registrados. Hacer seguimiento puede ayudar a tu médico y revelar patrones a lo largo del tiempo.",
"recentTitle": "Últimos 30 días",
"loading": "Cargando…",
"deleteCta": "Eliminar",
"deleteConfirm": "¿Eliminar esta entrada?",
"errorSeverity": "La gravedad debe estar entre 1 y 5",
"errorEntry": "Elige un síntoma",
"categories": {
"gi": "Gastrointestinal",
"metabolic": "Metabólico",
"injectionSite": "Zona de inyección",
"cognitive": "Cognitivo",
"glp1Specific": "Específico de GLP-1"
},
"entries": {
"nausea": "Náusea",
"vomiting": "Vómito",
"diarrhea": "Diarrea",
"constipation": "Estreñimiento",
"abdominalPain": "Dolor abdominal",
"hypoglycemiaSymptoms": "Síntomas de hipoglucemia",
"dehydration": "Deshidratación",
"anorexia": "Pérdida de apetito",
"electrolyteFatigue": "Fatiga por desequilibrio electrolítico",
"injectionRedness": "Enrojecimiento en la zona de inyección",
"injectionSwelling": "Hinchazón en la zona de inyección",
"injectionBruising": "Hematoma en la zona de inyección",
"injectionInduration": "Induración en la zona de inyección",
"brainFog": "Niebla mental",
"dizziness": "Mareo",
"lowMood": "Estado de ánimo bajo",
"lowEnergy": "Falta de energía",
"earlySatiety": "Saciedad precoz",
"gastroparesisLike": "Vaciado gástrico lento",
"dysgeusia": "Alteración del gusto",
"gallbladderDiscomfort": "Molestia en la vesícula biliar"
}
},
"injectionSiteBodyOutlineAriaLabel": "Silueta del cuerpo",
"injectionSitePickerAriaLabel": "Selector de sitio de inyección",
"siteAbdomenLeft": "Abdomen, lower left",
+56
View File
@@ -611,6 +611,62 @@
"usedUp": "Épuisé"
}
},
"sideEffects": {
"section": "Effets secondaires",
"addCta": "Consigner un effet",
"sheetTitle": "Consigner un effet secondaire",
"categoryLabel": "Catégorie",
"entryLabel": "Symptôme",
"severityLabel": "Sévérité",
"severity": {
"mild": "Léger",
"moderate": "Modéré",
"significant": "Notable",
"severe": "Sévère",
"verySevere": "Très sévère"
},
"notesLabel": "Notes (facultatif)",
"notesPlaceholder": "Ce qui vaut la peine d'être noté, 280 caractères max.",
"submitCta": "Enregistrer",
"cancelCta": "Annuler",
"emptyState": "Aucun effet secondaire consigné. Le suivi peut aider votre médecin et révéler des tendances dans le temps.",
"recentTitle": "30 derniers jours",
"loading": "Chargement…",
"deleteCta": "Supprimer",
"deleteConfirm": "Supprimer cette entrée ?",
"errorSeverity": "La sévérité doit être entre 1 et 5",
"errorEntry": "Choisissez un symptôme",
"categories": {
"gi": "Gastro-intestinal",
"metabolic": "Métabolique",
"injectionSite": "Site d'injection",
"cognitive": "Cognitif",
"glp1Specific": "Spécifique GLP-1"
},
"entries": {
"nausea": "Nausée",
"vomiting": "Vomissement",
"diarrhea": "Diarrhée",
"constipation": "Constipation",
"abdominalPain": "Douleur abdominale",
"hypoglycemiaSymptoms": "Symptômes d'hypoglycémie",
"dehydration": "Déshydratation",
"anorexia": "Perte d'appétit",
"electrolyteFatigue": "Fatigue électrolytique",
"injectionRedness": "Rougeur au site d'injection",
"injectionSwelling": "Gonflement au site d'injection",
"injectionBruising": "Ecchymose au site d'injection",
"injectionInduration": "Induration au site d'injection",
"brainFog": "Brouillard mental",
"dizziness": "Vertiges",
"lowMood": "Humeur basse",
"lowEnergy": "Faible énergie",
"earlySatiety": "Satiété précoce",
"gastroparesisLike": "Vidange gastrique lente",
"dysgeusia": "Altération du goût",
"gallbladderDiscomfort": "Inconfort de la vésicule biliaire"
}
},
"injectionSiteBodyOutlineAriaLabel": "Silhouette du corps",
"injectionSitePickerAriaLabel": "Sélecteur de site d'injection",
"siteAbdomenLeft": "Abdomen, lower left",
+56
View File
@@ -611,6 +611,62 @@
"usedUp": "Esaurita"
}
},
"sideEffects": {
"section": "Effetti collaterali",
"addCta": "Registra effetto",
"sheetTitle": "Registra un effetto collaterale",
"categoryLabel": "Categoria",
"entryLabel": "Sintomo",
"severityLabel": "Gravità",
"severity": {
"mild": "Lieve",
"moderate": "Moderato",
"significant": "Marcato",
"severe": "Grave",
"verySevere": "Molto grave"
},
"notesLabel": "Note (facoltativo)",
"notesPlaceholder": "Qualcosa che vale la pena ricordare, max 280 caratteri",
"submitCta": "Salva",
"cancelCta": "Annulla",
"emptyState": "Nessun effetto collaterale registrato. Tracciarli può aiutare il medico e far emergere schemi nel tempo.",
"recentTitle": "Ultimi 30 giorni",
"loading": "Caricamento…",
"deleteCta": "Elimina",
"deleteConfirm": "Eliminare questa voce?",
"errorSeverity": "La gravità deve essere tra 1 e 5",
"errorEntry": "Scegli un sintomo",
"categories": {
"gi": "Gastrointestinale",
"metabolic": "Metabolico",
"injectionSite": "Sito di iniezione",
"cognitive": "Cognitivo",
"glp1Specific": "Specifico GLP-1"
},
"entries": {
"nausea": "Nausea",
"vomiting": "Vomito",
"diarrhea": "Diarrea",
"constipation": "Stitichezza",
"abdominalPain": "Dolore addominale",
"hypoglycemiaSymptoms": "Sintomi da ipoglicemia",
"dehydration": "Disidratazione",
"anorexia": "Perdita di appetito",
"electrolyteFatigue": "Stanchezza da squilibrio elettrolitico",
"injectionRedness": "Arrossamento al sito di iniezione",
"injectionSwelling": "Gonfiore al sito di iniezione",
"injectionBruising": "Livido al sito di iniezione",
"injectionInduration": "Indurimento al sito di iniezione",
"brainFog": "Annebbiamento mentale",
"dizziness": "Vertigini",
"lowMood": "Umore basso",
"lowEnergy": "Poca energia",
"earlySatiety": "Sazietà precoce",
"gastroparesisLike": "Svuotamento gastrico lento",
"dysgeusia": "Alterazione del gusto",
"gallbladderDiscomfort": "Fastidio alla cistifellea"
}
},
"injectionSiteBodyOutlineAriaLabel": "Sagoma del corpo",
"injectionSitePickerAriaLabel": "Selettore del sito di iniezione",
"siteAbdomenLeft": "Abdomen, lower left",
+56
View File
@@ -611,6 +611,62 @@
"usedUp": "Zużyty"
}
},
"sideEffects": {
"section": "Skutki uboczne",
"addCta": "Dodaj skutek uboczny",
"sheetTitle": "Zarejestruj skutek uboczny",
"categoryLabel": "Kategoria",
"entryLabel": "Objaw",
"severityLabel": "Nasilenie",
"severity": {
"mild": "Łagodne",
"moderate": "Umiarkowane",
"significant": "Wyraźne",
"severe": "Silne",
"verySevere": "Bardzo silne"
},
"notesLabel": "Notatka (opcjonalnie)",
"notesPlaceholder": "Cokolwiek warto zapamiętać, maks. 280 znaków",
"submitCta": "Zapisz",
"cancelCta": "Anuluj",
"emptyState": "Nie zarejestrowano jeszcze skutków ubocznych. Śledzenie może pomóc lekarzowi i ujawnić wzorce w czasie.",
"recentTitle": "Ostatnie 30 dni",
"loading": "Ładowanie…",
"deleteCta": "Usuń",
"deleteConfirm": "Usunąć ten wpis?",
"errorSeverity": "Nasilenie musi mieścić się w zakresie 1–5",
"errorEntry": "Wybierz objaw",
"categories": {
"gi": "Żołądkowo-jelitowe",
"metabolic": "Metaboliczne",
"injectionSite": "Miejsce wstrzyknięcia",
"cognitive": "Poznawcze",
"glp1Specific": "Specyficzne dla GLP-1"
},
"entries": {
"nausea": "Nudności",
"vomiting": "Wymioty",
"diarrhea": "Biegunka",
"constipation": "Zaparcia",
"abdominalPain": "Ból brzucha",
"hypoglycemiaSymptoms": "Objawy hipoglikemii",
"dehydration": "Odwodnienie",
"anorexia": "Utrata apetytu",
"electrolyteFatigue": "Zmęczenie elektrolitowe",
"injectionRedness": "Zaczerwienienie miejsca wstrzyknięcia",
"injectionSwelling": "Obrzęk miejsca wstrzyknięcia",
"injectionBruising": "Siniak w miejscu wstrzyknięcia",
"injectionInduration": "Stwardnienie miejsca wstrzyknięcia",
"brainFog": "Mgła umysłowa",
"dizziness": "Zawroty głowy",
"lowMood": "Obniżony nastrój",
"lowEnergy": "Brak energii",
"earlySatiety": "Wczesne uczucie sytości",
"gastroparesisLike": "Spowolnione opróżnianie żołądka",
"dysgeusia": "Zmiana smaku",
"gallbladderDiscomfort": "Dolegliwości pęcherzyka żółciowego"
}
},
"injectionSiteBodyOutlineAriaLabel": "Sylwetka ciała",
"injectionSitePickerAriaLabel": "Wybór miejsca iniekcji",
"siteAbdomenLeft": "Abdomen, lower left",
@@ -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);
},
);
+11
View File
@@ -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>;