mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +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
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
);
|
||||
@@ -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