diff --git a/messages/de.json b/messages/de.json index 02535493f..68ad9b5a6 100644 --- a/messages/de.json +++ b/messages/de.json @@ -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", diff --git a/messages/en.json b/messages/en.json index d8ce2dee6..45334c1f7 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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", diff --git a/messages/es.json b/messages/es.json index 527cda99e..96bcd3124 100644 --- a/messages/es.json +++ b/messages/es.json @@ -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", diff --git a/messages/fr.json b/messages/fr.json index 3b1bffc72..5d7d7a763 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -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", diff --git a/messages/it.json b/messages/it.json index 91b8750ce..3748a774e 100644 --- a/messages/it.json +++ b/messages/it.json @@ -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", diff --git a/messages/pl.json b/messages/pl.json index de4295d3c..953c32c1f 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -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", diff --git a/src/app/api/medications/[id]/side-effects/[logId]/route.ts b/src/app/api/medications/[id]/side-effects/[logId]/route.ts new file mode 100644 index 000000000..e6fa10576 --- /dev/null +++ b/src/app/api/medications/[id]/side-effects/[logId]/route.ts @@ -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 }); + }, +); diff --git a/src/app/api/medications/[id]/side-effects/__tests__/route.test.ts b/src/app/api/medications/[id]/side-effects/__tests__/route.test.ts new file mode 100644 index 000000000..a2bea1fd3 --- /dev/null +++ b/src/app/api/medications/[id]/side-effects/__tests__/route.test.ts @@ -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" }, + }); + }); +}); diff --git a/src/app/api/medications/[id]/side-effects/route.ts b/src/app/api/medications/[id]/side-effects/route.ts new file mode 100644 index 000000000..c19cfc060 --- /dev/null +++ b/src/app/api/medications/[id]/side-effects/route.ts @@ -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); + }, +); diff --git a/src/app/medications/[id]/history/page.tsx b/src/app/medications/[id]/history/page.tsx index 80de69003..d38021cd9 100644 --- a/src/app/medications/[id]/history/page.tsx +++ b/src/app/medications/[id]/history/page.tsx @@ -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" && ( + + )} + 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("GI"); + const [entry, setEntry] = useState(null); + const [severity, setSeverity] = useState<1 | 2 | 3 | 4 | 5 | null>(null); + const [notes, setNotes] = useState(""); + const [formError, setFormError] = useState(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) { + 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 ( +
+
+

+ {t("medications.sideEffects.section")} + {items.length > 0 && ( + + ({items.length}) + + )} +

+ +
+ +
+ {isLoading && ( +
+ + {t("medications.sideEffects.loading")} +
+ )} + + {!isLoading && items.length === 0 && ( +

+ {t("medications.sideEffects.emptyState")} +

+ )} + + {!isLoading && items.length > 0 && ( +
+

+ {t("medications.sideEffects.recentTitle")} +

+
    + {items.map((row) => ( +
  • +
    +
    + + {t( + `medications.sideEffects.categories.${categoryI18nKey(row.category)}`, + )} + + + {t( + `medications.sideEffects.entries.${entryI18nKey(row.entry)}`, + )} + + + {t( + `medications.sideEffects.severity.${severityLikertLabel(row.severity as 1 | 2 | 3 | 4 | 5)}`, + )} + +
    +

    + {fmt.dateShort(new Date(row.occurredAt))} +

    + {row.notes && ( +

    + {row.notes} +

    + )} +
    + +
  • + ))} +
+
+ )} +
+ + + + + {t("medications.sideEffects.sheetTitle")} + + +
+
+ + +
+ +
+ +
+ {filteredEntries.map((candidate) => { + const selected = candidate === entry; + return ( + + ); + })} +
+
+ +
+ +
+ {SIDE_EFFECT_SEVERITY_LADDER.map((label, idx) => { + const value = (idx + 1) as 1 | 2 | 3 | 4 | 5; + const selected = severity === value; + return ( + + ); + })} +
+
+ +
+ +