feat(illness): add the condition-journal MVP behind a born-gated module

Introduce a retrospective illness/condition journal: episodes with a
lifecycle (acute / chronic-ongoing / recurring / flare) and a daily
symptom timeline forked structurally from the cycle log-day surface. The
journal is a record, not a medical device — no prediction or diagnosis UI.

- Register `illness` as a born-gated (opt-in / default-off) module. The
  gate resolver flips the disabled-allowlist default to off for born-gated
  keys, so the surface stays dark until the account opts in from the
  Modules hub. `isIllnessEnabled` + `requireIllnessEnabled` delegate to the
  module foundation (one source of truth) and re-stamp an illness errorCode.
- Episode CRUD under /api/illness: list / create / get / edit / resolve /
  delete, plus a per-episode-day day-log upsert on (episodeId, date). Every
  route wraps apiHandler, Zod-validates with returnAllZodIssues, narrows
  userId from auth, builds data field-by-field, and gates the module.
  Free-text notes encrypt at rest through the shared Bytes codec.
- Log-day sheet (cycle fork): ~8 Jackson/WURSS symptom chips 0-3 + a
  functional-impact group + fever + an encrypted note, one row per day.
- Episode history page + a born-gated nav entry, neutral palette, status
  via a neutral Badge, retrospective-only copy. i18n across all six locales.
- Cover the new routes in the module-route-gate inventory; unit tests for
  the day-log upsert, the gate enforcement, and the born-gated resolver.

The /api/auth/me module map now projects the `illness` flag (openapi.yaml
regenerated).
This commit is contained in:
Marc-André Bombeck committed 2026-06-16 21:09:35 +02:00
1 parent bee7295b19
commit b079431a8d
34 files changed
+2799 -6

No files matched your search

+6
View File
@@ -6834,6 +6834,8 @@ components:
type: boolean
labs:
type: boolean
illness:
type: boolean
achievements:
type: boolean
insights:
@@ -12404,6 +12406,9 @@ components:
labs:
type: boolean
description: Whether the "labs" module is enabled for this account.
illness:
type: boolean
description: Whether the "illness" module is enabled for this account.
achievements:
type: boolean
description: Whether the "achievements" module is enabled for this account.
@@ -12424,6 +12429,7 @@ components:
- workouts
- recovery
- labs
- illness
- achievements
- coach
- insights
+80 -1
View File
@@ -266,7 +266,8 @@
"cycle": "Zyklus",
"labs": "Laborwerte",
"recovery": "Erholung",
"vorsorge": "Vorsorge"
"vorsorge": "Vorsorge",
"illness": "Krankheit"
},
"auth": {
"login": "Anmelden",
@@ -6606,6 +6607,84 @@
"medications": {
"label": "Medikamente",
"description": "Pläne, Einnahmen und Compliance."
},
"illness": {
"label": "Krankheitstagebuch",
"description": "Krankheits- und Beschwerde-Episoden mit täglichem Symptomverlauf festhalten."
}
},
"illness": {
"title": "Krankheitstagebuch",
"subtitle": "Ein rückblickendes Protokoll deiner Krankheits- und Beschwerde-Episoden.",
"disclaimer": "Ein persönliches Tagebuch — kein Medizinprodukt. Es stellt keine Diagnose und keine Prognose.",
"newEpisode": "Neue Episode",
"logDay": "Tag erfassen",
"markRecovered": "Als genesen markieren",
"onsetOn": "Begonnen am {date}",
"recoveredOn": "genesen {date}",
"status": {
"active": "Aktiv",
"ongoing": "Andauernd",
"recovered": "Genesen"
},
"empty": {
"title": "Noch keine Episoden",
"body": "Beginne eine Episode, um einen täglichen Symptomverlauf zu führen und nachzusehen, wie sie verlief."
},
"type": {
"INFECTION": "Infektion",
"ALLERGY": "Allergie",
"INJURY": "Verletzung",
"MENTAL_HEALTH": "Psychische Gesundheit",
"AUTOIMMUNE": "Autoimmun",
"CHRONIC": "Chronisch",
"OTHER": "Sonstiges"
},
"lifecycle": {
"ACUTE": "Akut",
"CHRONIC_ONGOING": "Chronisch / andauernd",
"RECURRING": "Wiederkehrend",
"FLARE": "Schub"
},
"new": {
"title": "Neue Episode",
"description": "Erfasse eine Krankheit oder Beschwerde. Tägliche Symptome kannst du danach festhalten.",
"label": "Bezeichnung",
"labelPlaceholder": "z. B. Erkältung",
"type": "Art",
"lifecycle": "Verlauf",
"onset": "Begonnen am",
"note": "Notiz",
"notePlaceholder": "Was erwähnenswert ist (verschlüsselt).",
"saveError": "Die Episode konnte nicht gespeichert werden. Bitte erneut versuchen."
},
"sheet": {
"title": "{date} erfassen",
"description": "Halte die heutigen Symptome, deine Belastbarkeit und ggf. Fieber fest.",
"symptoms": "Symptome",
"functionalImpact": "Belastbarkeit",
"fever": "Temperatur",
"note": "Notiz",
"noteSet": "Gesetzt",
"notePlaceholder": "Wie sich der Tag anfühlte (verschlüsselt).",
"severityFor": "Stärke für {symptom}",
"saveError": "Der Tag konnte nicht gespeichert werden. Bitte erneut versuchen."
},
"impact": {
"0": "Voll belastbar",
"1": "Leicht eingeschränkt",
"2": "Überwiegend ruhend",
"3": "Bettlägerig"
},
"symptom": {
"runnyNose": "Laufende Nase",
"stuffyNose": "Verstopfte Nase",
"sneezing": "Niesen",
"soreThroat": "Halsschmerzen",
"cough": "Husten",
"headache": "Kopfschmerzen",
"bodyAches": "Gliederschmerzen",
"fatigue": "Erschöpfung"
}
}
}
+80 -1
View File
@@ -266,7 +266,8 @@
"cycle": "Cycle",
"labs": "Labs",
"recovery": "Recovery",
"vorsorge": "Preventive Care"
"vorsorge": "Preventive Care",
"illness": "Illness"
},
"auth": {
"login": "Sign in",
@@ -6606,6 +6607,84 @@
"medications": {
"label": "Medications",
"description": "Schedules, intakes, and compliance."
},
"illness": {
"label": "Illness journal",
"description": "Track illness and condition episodes with a daily symptom timeline."
}
},
"illness": {
"title": "Illness journal",
"subtitle": "A retrospective log of your illness and condition episodes.",
"disclaimer": "A personal journal — not a medical device. It does not diagnose or predict.",
"newEpisode": "New episode",
"logDay": "Log a day",
"markRecovered": "Mark recovered",
"onsetOn": "Started {date}",
"recoveredOn": "recovered {date}",
"status": {
"active": "Active",
"ongoing": "Ongoing",
"recovered": "Recovered"
},
"empty": {
"title": "No episodes yet",
"body": "Start an episode to keep a daily symptom timeline and look back on how it unfolded."
},
"type": {
"INFECTION": "Infection",
"ALLERGY": "Allergy",
"INJURY": "Injury",
"MENTAL_HEALTH": "Mental health",
"AUTOIMMUNE": "Autoimmune",
"CHRONIC": "Chronic",
"OTHER": "Other"
},
"lifecycle": {
"ACUTE": "Acute",
"CHRONIC_ONGOING": "Chronic / ongoing",
"RECURRING": "Recurring",
"FLARE": "Flare"
},
"new": {
"title": "New episode",
"description": "Record an illness or condition. You can log daily symptoms afterwards.",
"label": "Name",
"labelPlaceholder": "e.g. Common cold",
"type": "Type",
"lifecycle": "Course",
"onset": "Started on",
"note": "Note",
"notePlaceholder": "Anything worth remembering (encrypted).",
"saveError": "Could not save the episode. Please try again."
},
"sheet": {
"title": "Log {date}",
"description": "Record today's symptoms, how you function, and any fever.",
"symptoms": "Symptoms",
"functionalImpact": "How you function",
"fever": "Temperature",
"note": "Note",
"noteSet": "Set",
"notePlaceholder": "How the day felt (encrypted).",
"severityFor": "Severity for {symptom}",
"saveError": "Could not save the day. Please try again."
},
"impact": {
"0": "Fully functional",
"1": "Slightly limited",
"2": "Mostly resting",
"3": "Bedbound"
},
"symptom": {
"runnyNose": "Runny nose",
"stuffyNose": "Stuffy nose",
"sneezing": "Sneezing",
"soreThroat": "Sore throat",
"cough": "Cough",
"headache": "Headache",
"bodyAches": "Body aches",
"fatigue": "Fatigue"
}
}
}
+80 -1
View File
@@ -266,7 +266,8 @@
"cycle": "Ciclo",
"labs": "Análisis",
"recovery": "Recuperación",
"vorsorge": "Prevención"
"vorsorge": "Prevención",
"illness": "Enfermedad"
},
"auth": {
"login": "Iniciar sesión",
@@ -6606,6 +6607,84 @@
"medications": {
"label": "Medicamentos",
"description": "Pautas, tomas y cumplimiento."
},
"illness": {
"label": "Diario de enfermedad",
"description": "Registra episodios de enfermedad y dolencias con un historial diario de síntomas."
}
},
"illness": {
"title": "Diario de enfermedad",
"subtitle": "Un registro retrospectivo de tus episodios de enfermedad y dolencias.",
"disclaimer": "Un diario personal, no un dispositivo médico. No diagnostica ni predice.",
"newEpisode": "Nuevo episodio",
"logDay": "Registrar un día",
"markRecovered": "Marcar como recuperado",
"onsetOn": "Comenzó el {date}",
"recoveredOn": "recuperado {date}",
"status": {
"active": "Activo",
"ongoing": "En curso",
"recovered": "Recuperado"
},
"empty": {
"title": "Aún no hay episodios",
"body": "Inicia un episodio para llevar un historial diario de síntomas y revisar cómo evolucionó."
},
"type": {
"INFECTION": "Infección",
"ALLERGY": "Alergia",
"INJURY": "Lesión",
"MENTAL_HEALTH": "Salud mental",
"AUTOIMMUNE": "Autoinmune",
"CHRONIC": "Crónica",
"OTHER": "Otro"
},
"lifecycle": {
"ACUTE": "Aguda",
"CHRONIC_ONGOING": "Crónica / en curso",
"RECURRING": "Recurrente",
"FLARE": "Brote"
},
"new": {
"title": "Nuevo episodio",
"description": "Registra una enfermedad o dolencia. Luego podrás anotar síntomas diarios.",
"label": "Nombre",
"labelPlaceholder": "p. ej. Resfriado común",
"type": "Tipo",
"lifecycle": "Curso",
"onset": "Comenzó el",
"note": "Nota",
"notePlaceholder": "Algo que valga la pena recordar (cifrado).",
"saveError": "No se pudo guardar el episodio. Inténtalo de nuevo."
},
"sheet": {
"title": "Registrar {date}",
"description": "Anota los síntomas de hoy, cómo funcionas y la fiebre, si la hay.",
"symptoms": "Síntomas",
"functionalImpact": "Cómo funcionas",
"fever": "Temperatura",
"note": "Nota",
"noteSet": "Definida",
"notePlaceholder": "Cómo te sentiste hoy (cifrado).",
"severityFor": "Gravedad de {symptom}",
"saveError": "No se pudo guardar el día. Inténtalo de nuevo."
},
"impact": {
"0": "Plenamente funcional",
"1": "Algo limitado",
"2": "Mayormente en reposo",
"3": "En cama"
},
"symptom": {
"runnyNose": "Goteo nasal",
"stuffyNose": "Congestión nasal",
"sneezing": "Estornudos",
"soreThroat": "Dolor de garganta",
"cough": "Tos",
"headache": "Dolor de cabeza",
"bodyAches": "Dolores musculares",
"fatigue": "Fatiga"
}
}
}
+80 -1
View File
@@ -266,7 +266,8 @@
"cycle": "Cycle",
"labs": "Analyses",
"recovery": "Récupération",
"vorsorge": "Prévention"
"vorsorge": "Prévention",
"illness": "Maladie"
},
"auth": {
"login": "Se connecter",
@@ -6606,6 +6607,84 @@
"medications": {
"label": "Médicaments",
"description": "Schémas, prises et observance."
},
"illness": {
"label": "Journal de maladie",
"description": "Suivez les épisodes de maladie et d'affections avec un historique quotidien des symptômes."
}
},
"illness": {
"title": "Journal de maladie",
"subtitle": "Un suivi rétrospectif de vos épisodes de maladie et d'affections.",
"disclaimer": "Un journal personnel, pas un dispositif médical. Il ne diagnostique ni ne prédit.",
"newEpisode": "Nouvel épisode",
"logDay": "Enregistrer un jour",
"markRecovered": "Marquer comme rétabli",
"onsetOn": "Débuté le {date}",
"recoveredOn": "rétabli {date}",
"status": {
"active": "En cours",
"ongoing": "Persistant",
"recovered": "Rétabli"
},
"empty": {
"title": "Aucun épisode pour l'instant",
"body": "Démarrez un épisode pour tenir un historique quotidien des symptômes et revoir son évolution."
},
"type": {
"INFECTION": "Infection",
"ALLERGY": "Allergie",
"INJURY": "Blessure",
"MENTAL_HEALTH": "Santé mentale",
"AUTOIMMUNE": "Auto-immune",
"CHRONIC": "Chronique",
"OTHER": "Autre"
},
"lifecycle": {
"ACUTE": "Aiguë",
"CHRONIC_ONGOING": "Chronique / persistante",
"RECURRING": "Récurrente",
"FLARE": "Poussée"
},
"new": {
"title": "Nouvel épisode",
"description": "Enregistrez une maladie ou une affection. Vous pourrez ensuite noter les symptômes au quotidien.",
"label": "Nom",
"labelPlaceholder": "p. ex. Rhume",
"type": "Type",
"lifecycle": "Évolution",
"onset": "Débuté le",
"note": "Note",
"notePlaceholder": "Ce qui mérite d'être noté (chiffré).",
"saveError": "Impossible d'enregistrer l'épisode. Veuillez réessayer."
},
"sheet": {
"title": "Enregistrer {date}",
"description": "Notez les symptômes du jour, votre forme et une éventuelle fièvre.",
"symptoms": "Symptômes",
"functionalImpact": "Votre forme",
"fever": "Température",
"note": "Note",
"noteSet": "Définie",
"notePlaceholder": "Comment s'est passée la journée (chiffré).",
"severityFor": "Intensité de {symptom}",
"saveError": "Impossible d'enregistrer la journée. Veuillez réessayer."
},
"impact": {
"0": "Pleinement fonctionnel",
"1": "Légèrement limité",
"2": "Surtout au repos",
"3": "Alité"
},
"symptom": {
"runnyNose": "Nez qui coule",
"stuffyNose": "Nez bouché",
"sneezing": "Éternuements",
"soreThroat": "Mal de gorge",
"cough": "Toux",
"headache": "Mal de tête",
"bodyAches": "Courbatures",
"fatigue": "Fatigue"
}
}
}
+80 -1
View File
@@ -266,7 +266,8 @@
"cycle": "Ciclo",
"labs": "Esami",
"recovery": "Recupero",
"vorsorge": "Prevenzione"
"vorsorge": "Prevenzione",
"illness": "Malattia"
},
"auth": {
"login": "Accedi",
@@ -6606,6 +6607,84 @@
"medications": {
"label": "Farmaci",
"description": "Schemi, assunzioni e aderenza."
},
"illness": {
"label": "Diario delle malattie",
"description": "Registra episodi di malattia e disturbi con una cronologia giornaliera dei sintomi."
}
},
"illness": {
"title": "Diario delle malattie",
"subtitle": "Un registro retrospettivo dei tuoi episodi di malattia e disturbi.",
"disclaimer": "Un diario personale, non un dispositivo medico. Non diagnostica né prevede.",
"newEpisode": "Nuovo episodio",
"logDay": "Registra un giorno",
"markRecovered": "Segna come guarito",
"onsetOn": "Iniziato il {date}",
"recoveredOn": "guarito {date}",
"status": {
"active": "Attivo",
"ongoing": "In corso",
"recovered": "Guarito"
},
"empty": {
"title": "Ancora nessun episodio",
"body": "Avvia un episodio per tenere una cronologia giornaliera dei sintomi e rivedere come si è evoluto."
},
"type": {
"INFECTION": "Infezione",
"ALLERGY": "Allergia",
"INJURY": "Infortunio",
"MENTAL_HEALTH": "Salute mentale",
"AUTOIMMUNE": "Autoimmune",
"CHRONIC": "Cronica",
"OTHER": "Altro"
},
"lifecycle": {
"ACUTE": "Acuta",
"CHRONIC_ONGOING": "Cronica / in corso",
"RECURRING": "Ricorrente",
"FLARE": "Riacutizzazione"
},
"new": {
"title": "Nuovo episodio",
"description": "Registra una malattia o un disturbo. Potrai annotare i sintomi giornalieri in seguito.",
"label": "Nome",
"labelPlaceholder": "es. Raffreddore",
"type": "Tipo",
"lifecycle": "Decorso",
"onset": "Iniziato il",
"note": "Nota",
"notePlaceholder": "Qualcosa da ricordare (cifrato).",
"saveError": "Impossibile salvare l'episodio. Riprova."
},
"sheet": {
"title": "Registra {date}",
"description": "Annota i sintomi di oggi, come funzioni e l'eventuale febbre.",
"symptoms": "Sintomi",
"functionalImpact": "Come funzioni",
"fever": "Temperatura",
"note": "Nota",
"noteSet": "Impostata",
"notePlaceholder": "Com'è andata la giornata (cifrato).",
"severityFor": "Intensità di {symptom}",
"saveError": "Impossibile salvare la giornata. Riprova."
},
"impact": {
"0": "Pienamente funzionale",
"1": "Leggermente limitato",
"2": "Per lo più a riposo",
"3": "A letto"
},
"symptom": {
"runnyNose": "Naso che cola",
"stuffyNose": "Naso chiuso",
"sneezing": "Starnuti",
"soreThroat": "Mal di gola",
"cough": "Tosse",
"headache": "Mal di testa",
"bodyAches": "Dolori muscolari",
"fatigue": "Affaticamento"
}
}
}
+80 -1
View File
@@ -266,7 +266,8 @@
"cycle": "Cykl",
"labs": "Wyniki badań",
"recovery": "Regeneracja",
"vorsorge": "Profilaktyka"
"vorsorge": "Profilaktyka",
"illness": "Choroba"
},
"auth": {
"login": "Zaloguj się",
@@ -6606,6 +6607,84 @@
"medications": {
"label": "Leki",
"description": "Schematy, przyjęcia i zgodność."
},
"illness": {
"label": "Dziennik chorób",
"description": "Zapisuj epizody chorób i dolegliwości z dziennym przebiegiem objawów."
}
},
"illness": {
"title": "Dziennik chorób",
"subtitle": "Retrospektywny zapis Twoich epizodów chorób i dolegliwości.",
"disclaimer": "Osobisty dziennik — nie wyrób medyczny. Nie diagnozuje ani nie przewiduje.",
"newEpisode": "Nowy epizod",
"logDay": "Zapisz dzień",
"markRecovered": "Oznacz jako wyleczone",
"onsetOn": "Początek {date}",
"recoveredOn": "wyleczono {date}",
"status": {
"active": "Aktywny",
"ongoing": "Trwający",
"recovered": "Wyleczony"
},
"empty": {
"title": "Brak epizodów",
"body": "Rozpocznij epizod, aby prowadzić dzienny przebieg objawów i prześledzić, jak się rozwijał."
},
"type": {
"INFECTION": "Infekcja",
"ALLERGY": "Alergia",
"INJURY": "Uraz",
"MENTAL_HEALTH": "Zdrowie psychiczne",
"AUTOIMMUNE": "Autoimmunologiczna",
"CHRONIC": "Przewlekła",
"OTHER": "Inne"
},
"lifecycle": {
"ACUTE": "Ostra",
"CHRONIC_ONGOING": "Przewlekła / trwająca",
"RECURRING": "Nawracająca",
"FLARE": "Zaostrzenie"
},
"new": {
"title": "Nowy epizod",
"description": "Zapisz chorobę lub dolegliwość. Codzienne objawy możesz notować później.",
"label": "Nazwa",
"labelPlaceholder": "np. Przeziębienie",
"type": "Typ",
"lifecycle": "Przebieg",
"onset": "Początek",
"note": "Notatka",
"notePlaceholder": "Cokolwiek wartego zapamiętania (zaszyfrowane).",
"saveError": "Nie udało się zapisać epizodu. Spróbuj ponownie."
},
"sheet": {
"title": "Zapisz {date}",
"description": "Zanotuj dzisiejsze objawy, swoją sprawność i ewentualną gorączkę.",
"symptoms": "Objawy",
"functionalImpact": "Sprawność",
"fever": "Temperatura",
"note": "Notatka",
"noteSet": "Ustawiona",
"notePlaceholder": "Jak minął dzień (zaszyfrowane).",
"severityFor": "Nasilenie: {symptom}",
"saveError": "Nie udało się zapisać dnia. Spróbuj ponownie."
},
"impact": {
"0": "Pełna sprawność",
"1": "Lekko ograniczona",
"2": "Głównie odpoczynek",
"3": "W łóżku"
},
"symptom": {
"runnyNose": "Katar",
"stuffyNose": "Zatkany nos",
"sneezing": "Kichanie",
"soreThroat": "Ból gardła",
"cough": "Kaszel",
"headache": "Ból głowy",
"bodyAches": "Bóle ciała",
"fatigue": "Zmęczenie"
}
}
}
@@ -75,6 +75,14 @@ const MODULE_ROUTE_TREES: ReadonlyArray<string> = [
"src/app/api/workouts",
"src/app/api/labs",
"src/app/api/cycle",
// v1.18.1 (W-B) — the illness/condition journal. Every `/api/illness/*`
// route gates on the born-gated `illness` module via the thin
// `requireIllnessEnabled(...)` wrapper (which re-stamps the
// illness-specific errorCode over `requireModuleEnabled("illness")`),
// recognised below as a delegated gate. Walking the tree means a NEW
// ungated illness route fails this test BY NAME rather than leaking the
// surface over a Bearer token when the account never opted in.
"src/app/api/illness",
"src/app/api/gamification",
// v1.18.0 B3 — the legacy `/api/doctor-report` tree (JSON + server-PDF +
// availability probe) was orphaned dead code (no production caller) and
@@ -168,6 +176,12 @@ const EXEMPT_ROUTES: ReadonlyArray<string> = [
const MODULE_GATE_NEEDLE = "requireModuleEnabled(";
const CYCLE_GATE_NEEDLE = "requireCycleEnabled(";
const COACH_GATE_NEEDLE = 'requireAssistantSurface("coach")';
// v1.18.1 — the illness journal's thin gate wrapper. `requireIllnessEnabled`
// delegates to `requireModuleEnabled(userId, "illness")` and re-stamps the
// illness-specific errorCode, exactly mirroring how `cycle` delegates to
// `requireCycleEnabled`. Recognised as a delegated gate so illness routes
// are not flagged as ungated.
const ILLNESS_GATE_NEEDLE = "requireIllnessEnabled(";
// Builder aggregators that resolve `resolveModuleMap` once and exclude
// disabled-module sections/resources at the build boundary.
const BUILDER_GATE_NEEDLES: ReadonlyArray<string> = [
@@ -251,6 +265,7 @@ describe("module API route gate inventory", () => {
"workouts",
"recovery",
"labs",
"illness",
"achievements",
"cycle",
"coach",
@@ -276,6 +291,7 @@ describe("module API route gate inventory", () => {
if (fileHasCall(text, MODULE_GATE_NEEDLE)) continue;
if (fileHasCall(text, CYCLE_GATE_NEEDLE)) continue;
if (fileHasCall(text, COACH_GATE_NEEDLE)) continue;
if (fileHasCall(text, ILLNESS_GATE_NEEDLE)) continue;
if (BUILDER_GATE_NEEDLES.some((n) => fileHasCall(text, n))) continue;
if (exempt.has(path)) continue;
@@ -346,6 +362,7 @@ describe("module API route gate inventory", () => {
fileHasCall(text, MODULE_GATE_NEEDLE) ||
fileHasCall(text, CYCLE_GATE_NEEDLE) ||
fileHasCall(text, COACH_GATE_NEEDLE) ||
fileHasCall(text, ILLNESS_GATE_NEEDLE) ||
BUILDER_GATE_NEEDLES.some((n) => fileHasCall(text, n))
) {
stillGated.push(path);
@@ -0,0 +1,140 @@
/**
* `GET /api/illness/episodes/{id}/day-logs?date=YYYY-MM-DD` — the one
* day-log for the episode + day, or `null` when nothing is logged
* (lets the log-day sheet pre-fill).
* `POST /api/illness/episodes/{id}/day-logs` — upsert the day's symptom /
* functional-impact / fever timeline row. Upserts on
* `(episodeId, date)`: 201 on insert, 200 on update.
*
* Born-gated + owner-scoped (the episode must be owned + live). `note` is
* encrypted at rest; `userId` is narrowed from auth, never a body field.
*/
import { NextRequest } from "next/server";
import { prisma } from "@/lib/db";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import {
apiSuccess,
apiError,
getClientIp,
returnAllZodIssues,
safeJson,
} from "@/lib/api-response";
import { requireIllnessEnabled } from "@/lib/illness/gate";
import {
illnessDayLogInputSchema,
illnessDayLogQuerySchema,
} from "@/lib/validations/illness";
import { upsertIllnessDayLog } from "@/lib/illness/day-log-write";
import { toIllnessDayLogDTO, dayLogSymptomInclude } from "@/lib/illness/dto";
import { DEFAULT_TIMEZONE } from "@/lib/mood/date-key";
type RouteParams = { params: Promise<{ id: string }> };
/** Resolve the episode, 404ing unless it is owned + live. */
async function loadOwnedEpisode(
episodeId: string,
userId: string,
): Promise<{ ok: true } | { ok: false; response: Response }> {
const episode = await prisma.illnessEpisode.findUnique({
where: { id: episodeId },
select: { userId: true, deletedAt: true },
});
if (!episode || episode.userId !== userId || episode.deletedAt !== null) {
return { ok: false, response: apiError("Episode not found", 404) };
}
return { ok: true };
}
export const GET = apiHandler(
async (request: NextRequest, { params }: RouteParams) => {
const { user } = await requireAuth();
const gate = await requireIllnessEnabled(user.id);
if (!gate.enabled) return gate.response;
const { id } = await params;
const owned = await loadOwnedEpisode(id, user.id);
if (!owned.ok) return owned.response;
const parsed = illnessDayLogQuerySchema.safeParse({
date: new URL(request.url).searchParams.get("date"),
});
if (!parsed.success) {
return returnAllZodIssues(parsed.error, 422, {
errorCode: "illness.day-log.invalid",
});
}
const row = await prisma.illnessDayLog.findFirst({
where: { episodeId: id, date: parsed.data.date, deletedAt: null },
include: dayLogSymptomInclude,
});
annotate({
action: {
name: "illness.day-log.read",
entity_type: "illness_day_log",
},
meta: { found: row !== null },
});
return apiSuccess(row ? toIllnessDayLogDTO(row) : null);
},
);
export const POST = apiHandler(
async (request: NextRequest, { params }: RouteParams) => {
const { user } = await requireAuth();
const gate = await requireIllnessEnabled(user.id);
if (!gate.enabled) return gate.response;
const { id } = await params;
const owned = await loadOwnedEpisode(id, user.id);
if (!owned.ok) return owned.response;
const { data: rawBody, error: jsonError } = await safeJson(request, {
maxBytes: 16 * 1024,
});
if (jsonError) return jsonError;
const parsed = illnessDayLogInputSchema.safeParse(rawBody);
if (!parsed.success) {
annotate({
action: { name: "illness.day-log.validation-failed" },
meta: { issue_count: parsed.error.issues.length },
});
return returnAllZodIssues(parsed.error, 422, {
errorCode: "illness.day-log.invalid",
});
}
const tz = user.timezone ?? DEFAULT_TIMEZONE;
const result = await upsertIllnessDayLog(user.id, id, parsed.data, tz);
const row = await prisma.illnessDayLog.findUniqueOrThrow({
where: { id: result.id },
include: dayLogSymptomInclude,
});
await auditLog("illness.day-log.upsert", {
userId: user.id,
ipAddress: getClientIp(request),
details: { dayLogId: result.id, episodeId: id, existed: result.existed },
});
annotate({
action: {
name: "illness.day-log.upsert",
entity_type: "illness_day_log",
entity_id: result.id,
},
meta: { existed: result.existed },
});
return apiSuccess(toIllnessDayLogDTO(row), result.existed ? 200 : 201);
},
);
@@ -0,0 +1,87 @@
/**
* `PATCH /api/illness/episodes/{id}/resolve` — mark an episode recovered.
*
* Sets `resolvedAt` (defaults to "now" when the body omits it). A
* dedicated verb so the history surface's one-tap "mark recovered" never
* has to send the full edit body. Born-gated + owner-scoped. A
* CHRONIC_ONGOING episode has no "recovered" date by design — resolving
* one is refused.
*/
import { NextRequest } from "next/server";
import { prisma } from "@/lib/db";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import {
apiSuccess,
apiError,
getClientIp,
returnAllZodIssues,
safeJson,
} from "@/lib/api-response";
import { requireIllnessEnabled } from "@/lib/illness/gate";
import { illnessEpisodeResolveSchema } from "@/lib/validations/illness";
import { toIllnessEpisodeDTO } from "@/lib/illness/dto";
type RouteParams = { params: Promise<{ id: string }> };
export const PATCH = apiHandler(
async (request: NextRequest, { params }: RouteParams) => {
const { user } = await requireAuth();
const gate = await requireIllnessEnabled(user.id);
if (!gate.enabled) return gate.response;
const { id } = await params;
const existing = await prisma.illnessEpisode.findUnique({
where: { id },
select: { id: true, userId: true, deletedAt: true, lifecycle: true },
});
if (!existing || existing.userId !== user.id || existing.deletedAt !== null) {
return apiError("Episode not found", 404);
}
if (existing.lifecycle === "CHRONIC_ONGOING") {
return apiError("A chronic-ongoing condition has no recovery date", 422, {
errorCode: "illness.episode.chronic-no-resolve",
});
}
const { data: rawBody, error: jsonError } = await safeJson(request, {
maxBytes: 4 * 1024,
});
if (jsonError) return jsonError;
const parsed = illnessEpisodeResolveSchema.safeParse(rawBody ?? {});
if (!parsed.success) {
return returnAllZodIssues(parsed.error, 422, {
errorCode: "illness.episode.invalid",
});
}
const resolvedAt = parsed.data.resolvedAt
? new Date(parsed.data.resolvedAt)
: new Date();
const updated = await prisma.illnessEpisode.update({
where: { id },
data: { resolvedAt },
});
await auditLog("illness.episode.resolve", {
userId: user.id,
ipAddress: getClientIp(request),
details: { episodeId: id },
});
annotate({
action: {
name: "illness.episode.resolve",
entity_type: "illness_episode",
entity_id: id,
},
});
return apiSuccess(toIllnessEpisodeDTO(updated));
},
);
+178
View File
@@ -0,0 +1,178 @@
/**
* `GET /api/illness/episodes/{id}` — one owned, live episode.
* `PATCH /api/illness/episodes/{id}` — edit it (partial; field-by-field).
* `DELETE /api/illness/episodes/{id}` — soft-delete it (idempotent, 204).
*
* Born-gated + owner-scoped. `userId` is narrowed from auth and fed to the
* Prisma `where`; the body never carries it. `note` is encrypted at rest.
*/
import { NextRequest } from "next/server";
import { prisma } from "@/lib/db";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import {
apiSuccess,
apiError,
getClientIp,
returnAllZodIssues,
safeJson,
} from "@/lib/api-response";
import { requireIllnessEnabled } from "@/lib/illness/gate";
import { encryptToBytes } from "@/lib/ai/coach/bytes-codec";
import { illnessEpisodeUpdateSchema } from "@/lib/validations/illness";
import { toIllnessEpisodeDTO } from "@/lib/illness/dto";
import type { Prisma } from "@/generated/prisma/client";
type RouteParams = { params: Promise<{ id: string }> };
export const GET = apiHandler(
async (request: NextRequest, { params }: RouteParams) => {
const { user } = await requireAuth();
const gate = await requireIllnessEnabled(user.id);
if (!gate.enabled) return gate.response;
const { id } = await params;
const row = await prisma.illnessEpisode.findUnique({ where: { id } });
if (!row || row.userId !== user.id || row.deletedAt !== null) {
return apiError("Episode not found", 404);
}
annotate({
action: {
name: "illness.episode.read",
entity_type: "illness_episode",
entity_id: id,
},
});
return apiSuccess(toIllnessEpisodeDTO(row));
},
);
export const PATCH = apiHandler(
async (request: NextRequest, { params }: RouteParams) => {
const { user } = await requireAuth();
const gate = await requireIllnessEnabled(user.id);
if (!gate.enabled) return gate.response;
const { id } = await params;
const existing = await prisma.illnessEpisode.findUnique({
where: { id },
select: { id: true, userId: true, deletedAt: true },
});
if (!existing || existing.userId !== user.id || existing.deletedAt !== null) {
return apiError("Episode not found", 404);
}
const { data: rawBody, error: jsonError } = await safeJson(request, {
maxBytes: 16 * 1024,
});
if (jsonError) return jsonError;
const parsed = illnessEpisodeUpdateSchema.safeParse(rawBody);
if (!parsed.success) {
return returnAllZodIssues(parsed.error, 422, {
errorCode: "illness.episode.invalid",
});
}
const entry = parsed.data;
// A re-parent must point at an owned, live episode.
if (entry.parentConditionId) {
const parent = await prisma.illnessEpisode.findUnique({
where: { id: entry.parentConditionId },
select: { userId: true, deletedAt: true },
});
if (
entry.parentConditionId === id ||
!parent ||
parent.userId !== user.id ||
parent.deletedAt !== null
) {
return apiError("Parent condition not found", 404, {
errorCode: "illness.episode.parent-not-found",
});
}
}
// Field-by-field — only fields actually present in the body are written.
const data: Prisma.IllnessEpisodeUpdateInput = {};
if (entry.label !== undefined) data.label = entry.label;
if (entry.type !== undefined) data.type = entry.type;
if (entry.lifecycle !== undefined) data.lifecycle = entry.lifecycle;
if (entry.onsetAt !== undefined) data.onsetAt = new Date(entry.onsetAt);
if (entry.resolvedAt !== undefined) {
data.resolvedAt = entry.resolvedAt ? new Date(entry.resolvedAt) : null;
}
if (entry.parentConditionId !== undefined) {
data.parent = entry.parentConditionId
? { connect: { id: entry.parentConditionId } }
: { disconnect: true };
}
if (entry.note !== undefined) {
data.noteEncrypted = entry.note ? encryptToBytes(entry.note) : null;
}
const updated = await prisma.illnessEpisode.update({ where: { id }, data });
await auditLog("illness.episode.update", {
userId: user.id,
ipAddress: getClientIp(request),
details: { episodeId: id },
});
annotate({
action: {
name: "illness.episode.update",
entity_type: "illness_episode",
entity_id: id,
},
});
return apiSuccess(toIllnessEpisodeDTO(updated));
},
);
export const DELETE = apiHandler(
async (request: NextRequest, { params }: RouteParams) => {
const { user } = await requireAuth();
const gate = await requireIllnessEnabled(user.id);
if (!gate.enabled) return gate.response;
const { id } = await params;
const existing = await prisma.illnessEpisode.findUnique({
where: { id },
select: { id: true, userId: true, deletedAt: true },
});
if (!existing || existing.userId !== user.id) {
return apiError("Episode not found", 404);
}
if (existing.deletedAt === null) {
await prisma.illnessEpisode.update({
where: { id },
data: { deletedAt: new Date() },
});
await auditLog("illness.episode.delete", {
userId: user.id,
ipAddress: getClientIp(request),
details: { episodeId: id },
});
}
annotate({
action: {
name: "illness.episode.delete",
entity_type: "illness_episode",
entity_id: id,
},
});
return new Response(null, { status: 204 });
},
);
+138
View File
@@ -0,0 +1,138 @@
/**
* `GET /api/illness/episodes` — newest-first list of the account's
* illness/condition episodes.
* `POST /api/illness/episodes` — create one episode.
*
* The illness journal is a CONDITION journal, retrospective-only — NEVER a
* predictor/diagnoser. Born-gated: a non-opted-in account (or an
* operator-disabled instance) 403s with `errorCode:"illness.disabled"`
* even with a valid Bearer token. `userId` is narrowed from auth and fed
* to the Prisma `where`; it is never a body field. The `data` object is
* built field-by-field (no mass assignment); `note` is encrypted at rest.
*/
import { NextRequest } from "next/server";
import { prisma } from "@/lib/db";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import {
apiSuccess,
apiError,
getClientIp,
returnAllZodIssues,
safeJson,
} from "@/lib/api-response";
import { requireIllnessEnabled } from "@/lib/illness/gate";
import { encryptToBytes } from "@/lib/ai/coach/bytes-codec";
import {
illnessEpisodeCreateSchema,
illnessEpisodeListQuerySchema,
} from "@/lib/validations/illness";
import { toIllnessEpisodeDTO } from "@/lib/illness/dto";
export const GET = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
const gate = await requireIllnessEnabled(user.id);
if (!gate.enabled) return gate.response;
const params = new URL(request.url).searchParams;
const parsed = illnessEpisodeListQuerySchema.safeParse({
limit: params.get("limit") ?? undefined,
includeResolved: params.get("includeResolved") ?? undefined,
});
if (!parsed.success) {
return returnAllZodIssues(parsed.error, 422, {
errorCode: "illness.episode.invalid",
});
}
const limit = parsed.data.limit ?? 50;
const rows = await prisma.illnessEpisode.findMany({
where: {
userId: user.id,
deletedAt: null,
...(parsed.data.includeResolved === "false" ? { resolvedAt: null } : {}),
},
orderBy: { onsetAt: "desc" },
take: limit,
});
annotate({
action: { name: "illness.episode.list", entity_type: "illness_episode" },
meta: { count: rows.length },
});
return apiSuccess(rows.map(toIllnessEpisodeDTO));
});
export const POST = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
const gate = await requireIllnessEnabled(user.id);
if (!gate.enabled) return gate.response;
const { data: rawBody, error: jsonError } = await safeJson(request, {
maxBytes: 16 * 1024,
});
if (jsonError) return jsonError;
const parsed = illnessEpisodeCreateSchema.safeParse(rawBody);
if (!parsed.success) {
annotate({
action: { name: "illness.episode.validation-failed" },
meta: { issue_count: parsed.error.issues.length },
});
return returnAllZodIssues(parsed.error, 422, {
errorCode: "illness.episode.invalid",
});
}
const entry = parsed.data;
// A FLARE/RECURRING bout may hang off a parent condition — verify the
// parent is owned + live before threading.
if (entry.parentConditionId) {
const parent = await prisma.illnessEpisode.findUnique({
where: { id: entry.parentConditionId },
select: { userId: true, deletedAt: true },
});
if (!parent || parent.userId !== user.id || parent.deletedAt !== null) {
return apiError("Parent condition not found", 404, {
errorCode: "illness.episode.parent-not-found",
});
}
}
// Field-by-field — never spread the parsed object whole.
const created = await prisma.illnessEpisode.create({
data: {
userId: user.id,
label: entry.label,
type: entry.type,
lifecycle: entry.lifecycle,
onsetAt: entry.onsetAt ? new Date(entry.onsetAt) : new Date(),
resolvedAt: entry.resolvedAt ? new Date(entry.resolvedAt) : null,
parentConditionId: entry.parentConditionId ?? null,
noteEncrypted: entry.note ? encryptToBytes(entry.note) : null,
},
});
await auditLog("illness.episode.create", {
userId: user.id,
ipAddress: getClientIp(request),
details: { episodeId: created.id, type: created.type },
});
annotate({
action: {
name: "illness.episode.create",
entity_type: "illness_episode",
entity_id: created.id,
},
meta: { type: created.type, lifecycle: created.lifecycle },
});
return apiSuccess(toIllnessEpisodeDTO(created), 201);
});
+46
View File
@@ -0,0 +1,46 @@
"use client";
import { useEffect } from "react";
import { useRouter } from "next/navigation";
import { Loader2 } from "lucide-react";
import { useAuth } from "@/hooks/use-auth";
import { IllnessView } from "@/components/illness/illness-view";
/**
* v1.18.1 — the illness / condition-journal entry.
*
* Born-gated on the resolved `modules.illness` flag from `GET /api/auth/me`
* (opt-in / default-off; the per-user opt-in AND the operator server-wide
* kill-switch). An unauthenticated visitor is bounced to login; an
* authenticated account without the module opted in is bounced home (the
* nav entry is already hidden for them, so this only catches a direct URL
* hit). Every `/api/illness/*` route also enforces the gate server-side,
* so this is a UX redirect, not the security boundary.
*/
export default function IllnessPage() {
const { user, isLoading, isAuthenticated } = useAuth();
const router = useRouter();
// Born-gated: enabled ONLY when the resolved map says `true`.
const enabled = user?.modules?.illness === true;
useEffect(() => {
if (isLoading) return;
if (!isAuthenticated) {
router.push("/auth/login");
} else if (!enabled) {
router.push("/");
}
}, [isLoading, isAuthenticated, enabled, router]);
if (isLoading || !isAuthenticated || !enabled) {
return (
<div className="flex h-64 items-center justify-center">
<Loader2 className="text-primary h-8 w-8 animate-spin motion-reduce:animate-none" />
</div>
);
}
return <IllnessView />;
}
+184
View File
@@ -0,0 +1,184 @@
"use client";
/**
* v1.18.1 — the illness / condition-journal surface.
*
* A calm, retrospective journal: the episode history (newest first) with a
* neutral status Badge (active / recovered — never an alarming colour, the
* med-card rule generalised), a "log a day" entry into the day-log sheet,
* and a "mark recovered" one-tap. Retrospective-only copy: this is a
* journal, not a medical device, and it does not diagnose. No prediction
* UI by design.
*/
import { useState } from "react";
import { Plus, Stethoscope } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import { useTranslations } from "@/lib/i18n/context";
import { useFormatters } from "@/lib/i18n/context";
import { LogDaySheet } from "./log-day-sheet";
import { NewEpisodeSheet } from "./new-episode-sheet";
import {
useDeleteEpisode,
useIllnessEpisodes,
useResolveEpisode,
} from "./use-illness";
import type { IllnessEpisodeDTO } from "./types";
/** Today as a YYYY-MM-DD string in the viewer's local calendar. */
function todayLocal(): string {
const now = new Date();
const y = now.getFullYear();
const m = String(now.getMonth() + 1).padStart(2, "0");
const d = String(now.getDate()).padStart(2, "0");
return `${y}-${m}-${d}`;
}
function EpisodeRow({
episode,
onLogDay,
onResolve,
onDelete,
resolving,
}: {
episode: IllnessEpisodeDTO;
onLogDay: (id: string) => void;
onResolve: (id: string) => void;
onDelete: (id: string) => void;
resolving: boolean;
}) {
const { t } = useTranslations();
const fmt = useFormatters();
const active = episode.resolvedAt === null;
const isChronic = episode.lifecycle === "CHRONIC_ONGOING";
return (
<Card className="flex flex-col gap-3 p-4 sm:flex-row sm:items-center sm:justify-between">
<div className="min-w-0">
<div className="flex items-center gap-2">
<span className="truncate font-medium">{episode.label}</span>
<Badge variant="secondary">{t(`illness.type.${episode.type}`)}</Badge>
<Badge variant="outline">
{active
? isChronic
? t("illness.status.ongoing")
: t("illness.status.active")
: t("illness.status.recovered")}
</Badge>
</div>
<p className="text-muted-foreground mt-1 text-xs">
{t("illness.onsetOn", { date: fmt.dateShort(new Date(episode.onsetAt)) })}
{episode.resolvedAt
? ` · ${t("illness.recoveredOn", {
date: fmt.dateShort(new Date(episode.resolvedAt)),
})}`
: ""}
</p>
</div>
<div className="flex shrink-0 flex-wrap gap-2">
<Button variant="outline" size="sm" onClick={() => onLogDay(episode.id)}>
{t("illness.logDay")}
</Button>
{active && !isChronic ? (
<Button
variant="ghost"
size="sm"
disabled={resolving}
onClick={() => onResolve(episode.id)}
>
{t("illness.markRecovered")}
</Button>
) : null}
<Button
variant="ghost"
size="sm"
className="text-muted-foreground"
onClick={() => onDelete(episode.id)}
>
{t("common.delete")}
</Button>
</div>
</Card>
);
}
export function IllnessView() {
const { t } = useTranslations();
const { data: episodes, isLoading } = useIllnessEpisodes(true);
const resolve = useResolveEpisode();
const del = useDeleteEpisode();
const today = todayLocal();
const [newOpen, setNewOpen] = useState(false);
const [logEpisodeId, setLogEpisodeId] = useState<string | null>(null);
return (
<div className="space-y-6">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<h1 className="text-2xl font-bold tracking-tight">
{t("illness.title")}
</h1>
<p className="text-muted-foreground text-xs sm:text-sm">
{t("illness.subtitle")}
</p>
</div>
<Button
onClick={() => setNewOpen(true)}
className="min-h-11 shrink-0 sm:min-h-9"
>
<Plus className="h-4 w-4" />
{t("illness.newEpisode")}
</Button>
</div>
<p className="text-muted-foreground text-xs">{t("illness.disclaimer")}</p>
{isLoading ? (
<p className="text-muted-foreground text-sm">{t("common.loading")}</p>
) : episodes && episodes.length > 0 ? (
<div className="space-y-3">
{episodes.map((episode) => (
<EpisodeRow
key={episode.id}
episode={episode}
resolving={resolve.isPending}
onLogDay={(id) => setLogEpisodeId(id)}
onResolve={(id) => resolve.mutate(id)}
onDelete={(id) => del.mutate(id)}
/>
))}
</div>
) : (
<Card className="flex flex-col items-center gap-3 p-10 text-center">
<Stethoscope className="text-muted-foreground h-8 w-8" />
<div>
<p className="font-medium">{t("illness.empty.title")}</p>
<p className="text-muted-foreground text-sm">
{t("illness.empty.body")}
</p>
</div>
<Button variant="outline" onClick={() => setNewOpen(true)}>
<Plus className="h-4 w-4" />
{t("illness.newEpisode")}
</Button>
</Card>
)}
<NewEpisodeSheet open={newOpen} onOpenChange={setNewOpen} today={today} />
{logEpisodeId ? (
<LogDaySheet
open={logEpisodeId !== null}
onOpenChange={(open) => {
if (!open) setLogEpisodeId(null);
}}
episodeId={logEpisodeId}
date={today}
/>
) : null}
</div>
);
}
+342
View File
@@ -0,0 +1,342 @@
"use client";
/**
* v1.18.1 — illness day-log sheet. The daily symptom timeline for one
* episode-day: ~8 Jackson/WURSS symptom chips with a 0–3 severity, a
* coarse functional-impact chip group (0 = fully functional … 3 =
* bedbound), an optional fever reading, and an encrypted free-text note.
*
* Structurally a fork of the cycle `log-day-sheet`: the shared
* `ResponsiveSheet` + `SheetSection` primitives, a `Chip`/`SymptomChip`
* vocabulary, the envelope-aware pre-fill, and the `["illness"]`-prefix
* invalidation on write. Neutral palette throughout — no alarming colour;
* status reads via discreet text, never a red tint (the med-card rule
* generalised).
*/
import { useMemo, useState } from "react";
import { Activity, NotebookPen, Thermometer } from "lucide-react";
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
import {
SheetSection,
SheetSectionCount,
} from "@/components/ui/sheet-section";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { cn } from "@/lib/utils";
import { useTranslations } from "@/lib/i18n/context";
import { ILLNESS_SYMPTOM_CATALOG } from "./symptom-catalog";
import { useIllnessDayLog, useUpsertDayLog } from "./use-illness";
import type { IllnessDayLogInput } from "./types";
interface LogDaySheetProps {
open: boolean;
onOpenChange: (open: boolean) => void;
episodeId: string;
date: string;
}
/** Mini toggle chip — the shared selection vocabulary. */
function Chip({
active,
onClick,
children,
}: {
active: boolean;
onClick: () => void;
children: React.ReactNode;
}) {
return (
<button
type="button"
aria-pressed={active}
onClick={onClick}
className={cn(
"rounded-full border px-3 py-1.5 text-sm transition-colors",
active
? "border-primary bg-primary/10 text-foreground"
: "border-border text-muted-foreground hover:bg-muted",
)}
>
{children}
</button>
);
}
/** A symptom chip with an inline 0–3 severity selector once active. */
function SymptomChip({
label,
icon: Icon,
active,
severity,
onToggle,
onSeverity,
}: {
label: string;
icon: React.ComponentType<{ className?: string }>;
active: boolean;
severity: number | null;
onToggle: () => void;
onSeverity: (value: number) => void;
}) {
const { t } = useTranslations();
return (
<div className="flex flex-col gap-1">
<button
type="button"
aria-pressed={active}
onClick={onToggle}
className={cn(
"flex items-center gap-2 rounded-full border px-3 py-1.5 text-sm transition-colors",
active
? "border-primary bg-primary/10 text-foreground"
: "border-border text-muted-foreground hover:bg-muted",
)}
>
<Icon className="h-4 w-4" />
{label}
</button>
{active ? (
<div
className="flex gap-1 pl-2"
role="group"
aria-label={t("illness.sheet.severityFor", { symptom: label })}
>
{[1, 2, 3].map((value) => (
<button
key={value}
type="button"
aria-pressed={severity === value}
onClick={() => onSeverity(value)}
className={cn(
"h-6 w-6 rounded-md border text-xs",
severity === value
? "border-primary bg-primary/10 text-foreground"
: "border-border text-muted-foreground hover:bg-muted",
)}
>
{value}
</button>
))}
</div>
) : null}
</div>
);
}
export function LogDaySheet({
open,
onOpenChange,
episodeId,
date,
}: LogDaySheetProps) {
const { t } = useTranslations();
const dayLog = useIllnessDayLog(open ? episodeId : null, date);
const upsert = useUpsertDayLog(episodeId);
const [symptoms, setSymptoms] = useState<Map<string, number | null>>(
new Map(),
);
const [functionalImpact, setFunctionalImpact] = useState<number | null>(null);
const [fever, setFever] = useState("");
const [note, setNote] = useState("");
// Hydrate the form from the fetched DTO once per (open, date, fetched-row)
// tuple — React's recommended "adjust state during render keyed on a prop"
// alternative to a setState-in-effect (the cycle log-day-sheet precedent).
// `dto === null` (nothing logged) resets to blank.
const dto = open ? (dayLog.data ?? null) : undefined;
const formKey = open
? `${episodeId}:${date}:${dayLog.isSuccess ? "loaded" : "loading"}`
: null;
const [lastFormKey, setLastFormKey] = useState<string | null>(null);
if (open && formKey !== lastFormKey && !dayLog.isLoading) {
setLastFormKey(formKey);
setSymptoms(
new Map((dto?.symptoms ?? []).map((s) => [s.key, s.severity ?? null])),
);
setFunctionalImpact(dto?.functionalImpact ?? null);
setFever(dto?.feverC != null ? String(dto.feverC) : "");
setNote(dto?.note ?? "");
} else if (!open && lastFormKey !== null) {
setLastFormKey(null);
}
const symptomCount = symptoms.size;
function toggleSymptom(key: string) {
setSymptoms((prev) => {
const next = new Map(prev);
if (next.has(key)) next.delete(key);
else next.set(key, null);
return next;
});
}
function setSeverity(key: string, value: number) {
setSymptoms((prev) => {
const next = new Map(prev);
next.set(key, prev.get(key) === value ? null : value);
return next;
});
}
const feverValue = useMemo(() => {
if (fever.trim() === "") return null;
const parsed = Number.parseFloat(fever.replace(",", "."));
return Number.isFinite(parsed) ? parsed : null;
}, [fever]);
async function handleSave() {
const input: IllnessDayLogInput = {
date,
functionalImpact,
feverC: feverValue,
symptoms: Array.from(symptoms.entries()).map(([key, severity]) => ({
key,
severity: severity ?? undefined,
})),
note: note.trim() === "" ? null : note,
};
try {
await upsert.mutateAsync(input);
onOpenChange(false);
} catch {
// Keep the sheet open; the error strip below reports it.
}
}
const impactLabels = [
t("illness.impact.0"),
t("illness.impact.1"),
t("illness.impact.2"),
t("illness.impact.3"),
];
return (
<ResponsiveSheet
open={open}
onOpenChange={onOpenChange}
title={t("illness.sheet.title", { date })}
description={t("illness.sheet.description")}
footer={
<>
<Button variant="ghost" onClick={() => onOpenChange(false)}>
{t("common.cancel")}
</Button>
<Button onClick={handleSave} disabled={upsert.isPending}>
{t("common.save")}
</Button>
</>
}
>
<div className="space-y-4">
<SheetSection
title={t("illness.sheet.symptoms")}
icon={<Activity className="h-4 w-4" />}
summary={<SheetSectionCount count={symptomCount} />}
defaultOpen
>
<div className="flex flex-wrap gap-2 pt-2">
{ILLNESS_SYMPTOM_CATALOG.map((s) => (
<SymptomChip
key={s.key}
label={t(s.labelKey)}
icon={s.icon}
active={symptoms.has(s.key)}
severity={symptoms.get(s.key) ?? null}
onToggle={() => toggleSymptom(s.key)}
onSeverity={(value) => setSeverity(s.key, value)}
/>
))}
</div>
</SheetSection>
<SheetSection
title={t("illness.sheet.functionalImpact")}
icon={<Activity className="h-4 w-4" />}
summary={
functionalImpact != null ? (
<span className="text-muted-foreground text-xs">
{impactLabels[functionalImpact]}
</span>
) : null
}
>
<div className="flex flex-wrap gap-2 pt-2">
{[0, 1, 2, 3].map((value) => (
<Chip
key={value}
active={functionalImpact === value}
onClick={() =>
setFunctionalImpact(
functionalImpact === value ? null : value,
)
}
>
{impactLabels[value]}
</Chip>
))}
</div>
</SheetSection>
<SheetSection
title={t("illness.sheet.fever")}
icon={<Thermometer className="h-4 w-4" />}
summary={
feverValue != null ? (
<span className="text-muted-foreground text-xs">
{feverValue.toFixed(1)} °C
</span>
) : null
}
>
<div className="pt-2">
<Label htmlFor="illness-fever" className="sr-only">
{t("illness.sheet.fever")}
</Label>
<Input
id="illness-fever"
inputMode="decimal"
placeholder="37.0"
value={fever}
onChange={(e) => setFever(e.target.value)}
className="max-w-32"
/>
</div>
</SheetSection>
<SheetSection
title={t("illness.sheet.note")}
icon={<NotebookPen className="h-4 w-4" />}
summary={
note.trim() !== "" ? (
<span className="text-muted-foreground text-xs">
{t("illness.sheet.noteSet")}
</span>
) : null
}
>
<div className="pt-2">
<Textarea
value={note}
maxLength={2000}
onChange={(e) => setNote(e.target.value)}
placeholder={t("illness.sheet.notePlaceholder")}
rows={4}
/>
</div>
</SheetSection>
{upsert.isError ? (
<p className="text-destructive text-sm" role="alert">
{t("illness.sheet.saveError")}
</p>
) : null}
</div>
</ResponsiveSheet>
);
}
@@ -0,0 +1,196 @@
"use client";
/**
* v1.18.1 — the "new episode" capture sheet. A calm, minimal form: a
* label, the broad condition type, the lifecycle, an onset date, and an
* optional encrypted note. Retrospective-only — no prediction UI, no
* diagnosis prompt. Neutral palette throughout.
*/
import { useState } from "react";
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { useTranslations } from "@/lib/i18n/context";
import { useCreateEpisode } from "./use-illness";
import type { IllnessLifecycle, IllnessType } from "./types";
const TYPES: IllnessType[] = [
"INFECTION",
"ALLERGY",
"INJURY",
"MENTAL_HEALTH",
"AUTOIMMUNE",
"CHRONIC",
"OTHER",
];
const LIFECYCLES: IllnessLifecycle[] = [
"ACUTE",
"CHRONIC_ONGOING",
"RECURRING",
];
interface NewEpisodeSheetProps {
open: boolean;
onOpenChange: (open: boolean) => void;
today: string;
}
export function NewEpisodeSheet({
open,
onOpenChange,
today,
}: NewEpisodeSheetProps) {
const { t } = useTranslations();
const create = useCreateEpisode();
const [label, setLabel] = useState("");
const [type, setType] = useState<IllnessType>("INFECTION");
const [lifecycle, setLifecycle] = useState<IllnessLifecycle>("ACUTE");
const [onset, setOnset] = useState(today);
const [note, setNote] = useState("");
// Reset the form each time the sheet opens — adjusted during render keyed
// on the open transition (React's recommended alternative to a
// setState-in-effect).
const [wasOpen, setWasOpen] = useState(false);
if (open && !wasOpen) {
setWasOpen(true);
setLabel("");
setType("INFECTION");
setLifecycle("ACUTE");
setOnset(today);
setNote("");
} else if (!open && wasOpen) {
setWasOpen(false);
}
async function handleSave() {
if (label.trim() === "") return;
try {
await create.mutateAsync({
label: label.trim(),
type,
lifecycle,
// Anchor the date string to local noon so the UTC instant lands on
// the intended calendar day regardless of the viewer's offset.
onsetAt: new Date(`${onset}T12:00:00`).toISOString(),
note: note.trim() === "" ? null : note,
});
onOpenChange(false);
} catch {
// Keep the sheet open; the error strip reports it.
}
}
return (
<ResponsiveSheet
open={open}
onOpenChange={onOpenChange}
title={t("illness.new.title")}
description={t("illness.new.description")}
footer={
<>
<Button variant="ghost" onClick={() => onOpenChange(false)}>
{t("common.cancel")}
</Button>
<Button
onClick={handleSave}
disabled={create.isPending || label.trim() === ""}
>
{t("common.save")}
</Button>
</>
}
>
<div className="space-y-4">
<div className="space-y-1.5">
<Label htmlFor="illness-label">{t("illness.new.label")}</Label>
<Input
id="illness-label"
value={label}
maxLength={120}
onChange={(e) => setLabel(e.target.value)}
placeholder={t("illness.new.labelPlaceholder")}
/>
</div>
<div className="space-y-1.5">
<Label>{t("illness.new.type")}</Label>
<Select value={type} onValueChange={(v) => setType(v as IllnessType)}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{TYPES.map((value) => (
<SelectItem key={value} value={value}>
{t(`illness.type.${value}`)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label>{t("illness.new.lifecycle")}</Label>
<Select
value={lifecycle}
onValueChange={(v) => setLifecycle(v as IllnessLifecycle)}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{LIFECYCLES.map((value) => (
<SelectItem key={value} value={value}>
{t(`illness.lifecycle.${value}`)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label htmlFor="illness-onset">{t("illness.new.onset")}</Label>
<Input
id="illness-onset"
type="date"
value={onset}
max={today}
onChange={(e) => setOnset(e.target.value)}
className="max-w-44"
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="illness-note">{t("illness.new.note")}</Label>
<Textarea
id="illness-note"
value={note}
maxLength={2000}
onChange={(e) => setNote(e.target.value)}
placeholder={t("illness.new.notePlaceholder")}
rows={3}
/>
</div>
{create.isError ? (
<p className="text-destructive text-sm" role="alert">
{t("illness.new.saveError")}
</p>
) : null}
</div>
</ResponsiveSheet>
);
}
+48
View File
@@ -0,0 +1,48 @@
/**
* v1.18.1 — the seeded illness-symptom catalog, mirrored client-side.
*
* The keys are fixed at the migration seed (0171). The `key` is the
* snake_case DB key the day-log write resolves against; the `labelKey` is
* the i18n leaf under `illness.symptom.*`; the `icon` is the Lucide name
* from the seed. Keep this in lockstep with the seed in
* `prisma/migrations/0171_v1181_illness_episode/migration.sql` and the
* `illness.symptom.*` i18n leaves.
*
* The set is Jackson / WURSS-derived (~8 cardinal upper-respiratory + viral
* symptoms, 0–3 severity). Fever lives on the day row (`feverC`), not as a
* symptom chip; functional impact is a separate slider.
*/
import {
BatteryLow,
Brain,
Droplets,
Flame,
Megaphone,
PersonStanding,
Waves,
Wind,
type LucideIcon,
} from "lucide-react";
export interface IllnessSymptom {
/** snake_case DB key — the value sent in `symptoms[].key`. */
key: string;
/** i18n leaf under `illness.symptom.*`. */
labelKey: string;
icon: LucideIcon;
}
export const ILLNESS_SYMPTOM_CATALOG: IllnessSymptom[] = [
{ key: "runny_nose", labelKey: "illness.symptom.runnyNose", icon: Droplets },
{ key: "stuffy_nose", labelKey: "illness.symptom.stuffyNose", icon: Wind },
{ key: "sneezing", labelKey: "illness.symptom.sneezing", icon: Waves },
{ key: "sore_throat", labelKey: "illness.symptom.soreThroat", icon: Flame },
{ key: "cough", labelKey: "illness.symptom.cough", icon: Megaphone },
{ key: "headache", labelKey: "illness.symptom.headache", icon: Brain },
{
key: "body_aches",
labelKey: "illness.symptom.bodyAches",
icon: PersonStanding,
},
{ key: "fatigue", labelKey: "illness.symptom.fatigue", icon: BatteryLow },
];
+64
View File
@@ -0,0 +1,64 @@
/**
* Client-side illness DTO mirrors (v1.18.1) — the wire shapes the
* `/api/illness/*` routes return. Kept in lockstep with
* `src/lib/illness/dto.ts` (the server-authoritative source).
*/
export type IllnessType =
| "INFECTION"
| "ALLERGY"
| "INJURY"
| "MENTAL_HEALTH"
| "AUTOIMMUNE"
| "CHRONIC"
| "OTHER";
export type IllnessLifecycle =
| "ACUTE"
| "CHRONIC_ONGOING"
| "RECURRING"
| "FLARE";
export interface IllnessSymptomSelection {
key: string;
severity?: number | null;
}
export interface IllnessEpisodeDTO {
id: string;
label: string;
type: IllnessType;
lifecycle: IllnessLifecycle;
onsetAt: string;
resolvedAt: string | null;
parentConditionId: string | null;
note: string | null;
createdAt: string;
updatedAt: string;
}
export interface IllnessDayLogDTO {
id: string;
episodeId: string;
date: string;
functionalImpact: number | null;
feverC: number | null;
symptoms: IllnessSymptomSelection[];
note: string | null;
updatedAt: string;
}
export interface IllnessEpisodeCreateInput {
label: string;
type: IllnessType;
lifecycle?: IllnessLifecycle;
onsetAt?: string;
note?: string | null;
}
export interface IllnessDayLogInput {
date: string;
functionalImpact?: number | null;
feverC?: number | null;
symptoms?: IllnessSymptomSelection[];
note?: string | null;
}
+92
View File
@@ -0,0 +1,92 @@
"use client";
/**
* v1.18.1 — illness / condition-journal read + write hooks.
*
* Reads unwrap the envelope `data` (via `apiGet`/`apiPost`) per the project
* rule; every key is factory-routed through `queryKeys.illness*`. Writes
* invalidate the whole `["illness"]` prefix so the episode history list and
* an open day-log sheet repaint in lockstep after a log.
*/
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { apiDelete, apiGet, apiPatch, apiPost } from "@/lib/api/api-fetch";
import { queryKeys } from "@/lib/query-keys";
import type {
IllnessDayLogDTO,
IllnessDayLogInput,
IllnessEpisodeCreateInput,
IllnessEpisodeDTO,
} from "./types";
/** Newest-first episode list. */
export function useIllnessEpisodes(includeResolved = true) {
return useQuery({
queryKey: queryKeys.illnessEpisodes(includeResolved),
queryFn: () =>
apiGet<IllnessEpisodeDTO[]>(
`/api/illness/episodes?includeResolved=${includeResolved}`,
),
});
}
/** One episode's day-log for a given date (null when nothing is logged). */
export function useIllnessDayLog(episodeId: string | null, date: string) {
return useQuery({
queryKey: queryKeys.illnessDayLog(episodeId ?? "none", date),
enabled: episodeId !== null,
queryFn: () =>
apiGet<IllnessDayLogDTO | null>(
`/api/illness/episodes/${episodeId}/day-logs?date=${date}`,
),
});
}
function useInvalidateIllness() {
const qc = useQueryClient();
return () => qc.invalidateQueries({ queryKey: queryKeys.illness() });
}
/** Create an episode. */
export function useCreateEpisode() {
const invalidate = useInvalidateIllness();
return useMutation({
mutationFn: (input: IllnessEpisodeCreateInput) =>
apiPost<IllnessEpisodeDTO>("/api/illness/episodes", input),
onSuccess: invalidate,
});
}
/** Mark an episode recovered. */
export function useResolveEpisode() {
const invalidate = useInvalidateIllness();
return useMutation({
mutationFn: (id: string) =>
apiPatch<IllnessEpisodeDTO>(`/api/illness/episodes/${id}/resolve`, {}),
onSuccess: invalidate,
});
}
/** Soft-delete an episode. */
export function useDeleteEpisode() {
const invalidate = useInvalidateIllness();
return useMutation({
mutationFn: (id: string) =>
apiDelete<void>(`/api/illness/episodes/${id}`),
onSuccess: invalidate,
});
}
/** Upsert one day-log on an episode. */
export function useUpsertDayLog(episodeId: string) {
const invalidate = useInvalidateIllness();
return useMutation({
mutationFn: (input: IllnessDayLogInput) =>
apiPost<IllnessDayLogDTO>(
`/api/illness/episodes/${episodeId}/day-logs`,
input,
),
onSuccess: invalidate,
});
}
+12
View File
@@ -10,6 +10,7 @@ import {
Pill,
Settings,
Stethoscope,
Thermometer,
Trophy,
Waves,
type LucideIcon,
@@ -97,6 +98,17 @@ export const NAV_DESTINATIONS: ReadonlyArray<NavDestination> = [
tourId: "nav-labs",
requiresModule: "labs",
},
// v1.18.1 — the illness/condition journal sits in the clinical spine
// next to Labs. Born-gated: `requiresModule: "illness"` reads the
// opt-in `illness` key from the resolved module map, so the entry is
// absent until the account turns the module on from the Modules hub.
{
href: "/illness",
tKey: "nav.illness",
icon: Thermometer,
tourId: "nav-illness",
requiresModule: "illness",
},
// v1.17.1 — Vorsorge (preventive-care) gets a top-level nav home in the
// clinical spine, peer to Labs and Recovery. It is a first-class tracking
// surface ("wann muss ich was wo machen"), not pure configuration, so it
@@ -40,6 +40,7 @@ import {
Smile,
Sparkles,
TestTube,
Thermometer,
type LucideIcon,
} from "lucide-react";
@@ -66,6 +67,7 @@ const MODULE_ICONS: Record<ModuleKey, LucideIcon> = {
workouts: Dumbbell,
recovery: Activity,
labs: TestTube,
illness: Thermometer,
achievements: Award,
coach: MessageCircleHeart,
insights: Sparkles,
@@ -77,6 +77,7 @@ function moduleMap(
workouts: true,
recovery: true,
labs: true,
illness: true,
achievements: true,
coach: true,
insights: true,
@@ -0,0 +1,158 @@
/**
* v1.18.1 — illness day-log upsert helper.
*
* Asserts the insert-vs-update split, the partial-merge contract (an
* omitted field never nulls a stored value; an explicit null clears), and
* the symptom-link reconciliation (resolve catalog keys, drop unknowns,
* carry the 0–3 severity).
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
const db = vi.hoisted(() => ({
illnessSymptom: { findMany: vi.fn() },
illnessSymptomLink: { deleteMany: vi.fn(), createMany: vi.fn() },
illnessDayLog: { findUnique: vi.fn(), upsert: vi.fn() },
}));
vi.mock("@/lib/db", () => ({ prisma: db }));
vi.mock("@/lib/ai/coach/bytes-codec", () => ({
encryptToBytes: (s: string) => new Uint8Array(Buffer.from(`enc:${s}`)),
decryptFromBytes: (b: Uint8Array) =>
Buffer.from(b).toString("utf8").replace(/^enc:/, ""),
}));
import {
resolveIllnessSymptomIds,
replaceIllnessSymptomLinks,
upsertIllnessDayLog,
} from "@/lib/illness/day-log-write";
import type { IllnessDayLogInput } from "@/lib/validations/illness";
beforeEach(() => {
vi.clearAllMocks();
db.illnessDayLog.upsert.mockResolvedValue({ id: "dl-1" });
});
describe("resolveIllnessSymptomIds", () => {
it("queries the active catalog and drops unknown keys", async () => {
db.illnessSymptom.findMany.mockResolvedValue([
{ id: "is_cough", key: "cough" },
]);
const out = await resolveIllnessSymptomIds(["cough", "not_a_symptom"]);
expect(out).toEqual([{ key: "cough", id: "is_cough" }]);
const where = db.illnessSymptom.findMany.mock.calls[0][0].where;
expect(where.isActive).toBe(true);
expect(where.key.in).toEqual(["cough", "not_a_symptom"]);
});
it("short-circuits on an empty key set", async () => {
const out = await resolveIllnessSymptomIds([]);
expect(out).toEqual([]);
expect(db.illnessSymptom.findMany).not.toHaveBeenCalled();
});
});
describe("replaceIllnessSymptomLinks", () => {
it("clears then writes the links with their severity", async () => {
db.illnessSymptom.findMany.mockResolvedValue([
{ id: "is_cough", key: "cough" },
{ id: "is_fatigue", key: "fatigue" },
]);
await replaceIllnessSymptomLinks("dl-1", [
{ key: "cough", severity: 3 },
{ key: "fatigue" },
]);
expect(db.illnessSymptomLink.deleteMany).toHaveBeenCalledWith({
where: { dayLogId: "dl-1" },
});
expect(db.illnessSymptomLink.createMany).toHaveBeenCalledWith({
data: [
{ dayLogId: "dl-1", symptomId: "is_cough", severity: 3 },
{ dayLogId: "dl-1", symptomId: "is_fatigue", severity: null },
],
skipDuplicates: true,
});
});
it("does not call createMany when nothing resolves", async () => {
db.illnessSymptom.findMany.mockResolvedValue([]);
await replaceIllnessSymptomLinks("dl-1", [{ key: "unknown" }]);
expect(db.illnessSymptomLink.deleteMany).toHaveBeenCalled();
expect(db.illnessSymptomLink.createMany).not.toHaveBeenCalled();
});
});
describe("upsertIllnessDayLog", () => {
const base: IllnessDayLogInput = {
date: "2026-06-16",
functionalImpact: 2,
feverC: 38.4,
};
it("reports inserted (existed=false) on a fresh row", async () => {
db.illnessDayLog.findUnique.mockResolvedValue(null);
const result = await upsertIllnessDayLog("u1", "ep1", base, "Europe/Berlin");
expect(result.existed).toBe(false);
const args = db.illnessDayLog.upsert.mock.calls[0][0];
expect(args.where).toEqual({
episodeId_date: { episodeId: "ep1", date: "2026-06-16" },
});
expect(args.create.functionalImpact).toBe(2);
expect(args.create.feverC).toBe(38.4);
});
it("reports updated (existed=true) on a re-post", async () => {
db.illnessDayLog.findUnique.mockResolvedValue({
id: "dl-1",
functionalImpact: 1,
feverC: null,
noteEncrypted: null,
});
const result = await upsertIllnessDayLog("u1", "ep1", base, null);
expect(result.existed).toBe(true);
});
it("partial-merges an omitted field (keeps the stored value)", async () => {
db.illnessDayLog.findUnique.mockResolvedValue({
id: "dl-1",
functionalImpact: 3,
feverC: 39,
noteEncrypted: null,
});
// Only fever supplied — functionalImpact must keep the stored 3.
await upsertIllnessDayLog("u1", "ep1", { date: "2026-06-16", feverC: 37 }, null);
const args = db.illnessDayLog.upsert.mock.calls[0][0];
expect(args.update.functionalImpact).toBe(3);
expect(args.update.feverC).toBe(37);
});
it("clears a field on an explicit null", async () => {
db.illnessDayLog.findUnique.mockResolvedValue({
id: "dl-1",
functionalImpact: 3,
feverC: 39,
noteEncrypted: null,
});
await upsertIllnessDayLog(
"u1",
"ep1",
{ date: "2026-06-16", feverC: null },
null,
);
const args = db.illnessDayLog.upsert.mock.calls[0][0];
expect(args.update.feverC).toBeNull();
});
it("encrypts a supplied note and skips link replace when symptoms omitted", async () => {
db.illnessDayLog.findUnique.mockResolvedValue(null);
await upsertIllnessDayLog(
"u1",
"ep1",
{ date: "2026-06-16", note: "rough night" },
null,
);
const args = db.illnessDayLog.upsert.mock.calls[0][0];
expect(args.create.noteEncrypted).toBeInstanceOf(Uint8Array);
expect(db.illnessSymptomLink.deleteMany).not.toHaveBeenCalled();
});
});
+55
View File
@@ -0,0 +1,55 @@
/**
* v1.18.1 — illness route gate.
*
* `requireIllnessEnabled` delegates to the module gate but re-stamps the
* illness-specific errorCode. Asserts the enabled pass, the disabled 403
* envelope shape, and that `isIllnessEnabled` mirrors `isModuleEnabled`.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
const moduleGate = vi.hoisted(() => ({
isModuleEnabled: vi.fn(),
requireModuleEnabled: vi.fn(),
}));
vi.mock("@/lib/modules/gate", () => moduleGate);
import {
ILLNESS_DISABLED_ERROR_CODE,
isIllnessEnabled,
requireIllnessEnabled,
} from "@/lib/illness/gate";
beforeEach(() => {
vi.clearAllMocks();
});
describe("isIllnessEnabled", () => {
it("delegates to isModuleEnabled('illness')", async () => {
moduleGate.isModuleEnabled.mockResolvedValue(true);
await expect(isIllnessEnabled("u1")).resolves.toBe(true);
expect(moduleGate.isModuleEnabled).toHaveBeenCalledWith("u1", "illness");
});
});
describe("requireIllnessEnabled", () => {
it("passes when the module gate passes", async () => {
moduleGate.requireModuleEnabled.mockResolvedValue({ enabled: true });
const gate = await requireIllnessEnabled("u1");
expect(gate.enabled).toBe(true);
});
it("returns a 403 with the illness errorCode when disabled", async () => {
moduleGate.requireModuleEnabled.mockResolvedValue({
enabled: false,
response: new Response(null, { status: 403 }),
});
const gate = await requireIllnessEnabled("u1");
expect(gate.enabled).toBe(false);
if (gate.enabled) throw new Error("expected disabled");
expect(gate.response.status).toBe(403);
const body = await gate.response.json();
expect(body.meta.errorCode).toBe(ILLNESS_DISABLED_ERROR_CODE);
expect(body.meta.module).toBe("illness");
});
});
+155
View File
@@ -0,0 +1,155 @@
/**
* Illness day-log write helper (v1.18.1) — the upsert behind
* `POST .../episodes/{id}/day-logs`.
*
* UPSERT key: the canonical `(episodeId, date)`. `note` → `noteEncrypted`
* (AES-256-GCM via the shared Bytes codec). Symptom links carry a 0–3
* Jackson/WURSS severity (NULL = a plain presence link).
*
* Partial merge: a re-post that omits a field never nulls a previously
* stored value — the UPDATE branch only overwrites the fields actually
* present in the input. An explicit `null` clears.
*
* Symptom-key resolution is scoped to the global seeded catalog (illness
* symptoms have no per-user custom rows in the MVP); unknown keys are
* dropped silently.
*/
import { prisma } from "@/lib/db";
import {
decryptFromBytes,
encryptToBytes,
} from "@/lib/ai/coach/bytes-codec";
import type { IllnessDayLogInput } from "@/lib/validations/illness";
export interface IllnessDayLogWriteResult {
id: string;
existed: boolean;
}
/** A symptom selection carrying its catalog key + optional 0–3 severity. */
export interface IllnessSymptomSelection {
key: string;
severity?: number | null;
}
/**
* Resolve catalog symptom keys → `{ key, id }`, dropping unknown / inactive
* keys silently.
*/
export async function resolveIllnessSymptomIds(
keys: readonly string[],
): Promise<{ key: string; id: string }[]> {
if (keys.length === 0) return [];
const unique = Array.from(new Set(keys));
const rows = await prisma.illnessSymptom.findMany({
where: { key: { in: unique }, isActive: true },
select: { id: true, key: true },
});
return rows.map((r) => ({ key: r.key, id: r.id }));
}
/**
* Replace the symptom-link set for a day-log. Each selection's optional
* 0–3 severity is persisted on the link (NULL = a plain presence link).
*/
export async function replaceIllnessSymptomLinks(
dayLogId: string,
selections: readonly IllnessSymptomSelection[],
): Promise<void> {
const resolved = await resolveIllnessSymptomIds(
selections.map((s) => s.key),
);
const severityByKey = new Map(
selections.map((s) => [
s.key,
typeof s.severity === "number" ? s.severity : null,
]),
);
await prisma.illnessSymptomLink.deleteMany({ where: { dayLogId } });
if (resolved.length > 0) {
await prisma.illnessSymptomLink.createMany({
data: resolved.map(({ key, id }) => ({
dayLogId,
symptomId: id,
severity: severityByKey.get(key) ?? null,
})),
skipDuplicates: true,
});
}
}
/** Decrypt a Bytes note fail-soft (null on missing / undecryptable). */
function decryptNoteSoft(noteEncrypted: Uint8Array | null): string | null {
if (!noteEncrypted || noteEncrypted.byteLength === 0) return null;
try {
return decryptFromBytes(noteEncrypted);
} catch {
return null;
}
}
/**
* Upsert one illness day-log on the `(episodeId, date)` key. The caller is
* responsible for verifying the episode is owned + live before calling.
* `tz` anchors the date string.
*/
export async function upsertIllnessDayLog(
userId: string,
episodeId: string,
entry: IllnessDayLogInput,
tz: string | null,
): Promise<IllnessDayLogWriteResult> {
const where = { episodeId_date: { episodeId, date: entry.date } };
const existing = await prisma.illnessDayLog.findUnique({
where,
select: {
id: true,
functionalImpact: true,
feverC: true,
noteEncrypted: true,
},
});
// Partial merge against the stored row.
const functionalImpact =
entry.functionalImpact !== undefined
? (entry.functionalImpact ?? null)
: (existing?.functionalImpact ?? null);
const feverC =
entry.feverC !== undefined
? (entry.feverC ?? null)
: (existing?.feverC ?? null);
const incomingNote =
entry.note !== undefined
? (entry.note ?? null)
: decryptNoteSoft(existing?.noteEncrypted ?? null);
const noteEncrypted = incomingNote ? encryptToBytes(incomingNote) : null;
const row = await prisma.illnessDayLog.upsert({
where,
create: {
userId,
episodeId,
date: entry.date,
tz,
functionalImpact,
feverC,
noteEncrypted,
},
update: {
functionalImpact,
feverC,
noteEncrypted,
deletedAt: null,
},
select: { id: true },
});
if (entry.symptoms !== undefined) {
await replaceIllnessSymptomLinks(row.id, entry.symptoms);
}
return { id: row.id, existed: existing !== null };
}
+99
View File
@@ -0,0 +1,99 @@
/**
* Illness row → wire-DTO mappers (v1.18.1).
*
* The canonical `IllnessEpisodeDTO` and `IllnessDayLogDTO` shapes the iOS
* client mirrors (server-authoritative — iOS renders, never recomputes).
* Free-text notes live in `noteEncrypted` (Bytes) and are decrypted
* fail-soft on read (a key-rotation gap on one row reads `null`, never
* 500s the whole page). Symptom links flatten to `[{ key, severity }]`
* using the catalog key; `severity` carries the persisted per-link 0–3
* Jackson/WURSS intensity (NULL = a plain presence link).
*/
import { decryptFromBytes } from "@/lib/ai/coach/bytes-codec";
import type {
IllnessEpisode,
IllnessDayLog,
IllnessSymptom,
IllnessSymptomLink,
} from "@/generated/prisma/client";
export interface IllnessSymptomDTO {
key: string;
severity: number | null;
}
export interface IllnessEpisodeDTO {
id: string;
label: string;
type: string;
lifecycle: string;
onsetAt: string;
resolvedAt: string | null;
parentConditionId: string | null;
note: string | null;
createdAt: string;
updatedAt: string;
}
export interface IllnessDayLogDTO {
id: string;
episodeId: string;
date: string;
functionalImpact: number | null;
feverC: number | null;
symptoms: IllnessSymptomDTO[];
note: string | null;
updatedAt: string;
}
/** Decrypt a Bytes note fail-soft (null on missing / undecryptable). */
function decryptNote(noteEncrypted: Uint8Array | null): string | null {
if (!noteEncrypted || noteEncrypted.byteLength === 0) return null;
try {
return decryptFromBytes(noteEncrypted);
} catch {
return null;
}
}
export function toIllnessEpisodeDTO(row: IllnessEpisode): IllnessEpisodeDTO {
return {
id: row.id,
label: row.label,
type: row.type,
lifecycle: row.lifecycle,
onsetAt: row.onsetAt.toISOString(),
resolvedAt: row.resolvedAt ? row.resolvedAt.toISOString() : null,
parentConditionId: row.parentConditionId,
note: decryptNote(row.noteEncrypted),
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
};
}
type DayLogWithLinks = IllnessDayLog & {
symptomLinks?: (IllnessSymptomLink & {
symptom: Pick<IllnessSymptom, "key">;
})[];
};
/** Prisma `include` that flattens the symptom links for the DTO mapper. */
export const dayLogSymptomInclude = {
symptomLinks: { include: { symptom: { select: { key: true } } } },
} as const;
export function toIllnessDayLogDTO(row: DayLogWithLinks): IllnessDayLogDTO {
return {
id: row.id,
episodeId: row.episodeId,
date: row.date,
functionalImpact: row.functionalImpact,
feverC: row.feverC,
symptoms: (row.symptomLinks ?? []).map((l) => ({
key: l.symptom.key,
severity: l.severity ?? null,
})),
note: decryptNote(row.noteEncrypted),
updatedAt: row.updatedAt.toISOString(),
};
}
+54
View File
@@ -0,0 +1,54 @@
/**
* Illness / condition-journal feature gate (v1.18.1).
*
* The illness journal is BORN-GATED (opt-in / default-off) and resolves
* its enabled-state through the module foundation — there is no per-domain
* profile row and no second source of truth. `isIllnessEnabled` and the
* `requireIllnessEnabled` 403 guard both delegate to the module gate's
* `isModuleEnabled(userId, "illness")`, which (for a born-gated key) reads
* an explicit `modulePreferencesJson.illness === true` AND the operator
* server-wide availability. This mirrors `src/lib/cycle/gate.ts` so every
* `/api/illness/*` route gates identically.
*/
import { apiError } from "@/lib/api-response";
import { isModuleEnabled, requireModuleEnabled } from "@/lib/modules/gate";
/** Wire `errorCode` the iOS retry classifier branches on. */
export const ILLNESS_DISABLED_ERROR_CODE = "illness.disabled";
/**
* Fully-resolved illness availability for an account: the born-gated opt-in
* AND the operator server-wide kill-switch, via the module foundation.
*/
export function isIllnessEnabled(userId: string): Promise<boolean> {
return isModuleEnabled(userId, "illness");
}
/**
* Route-guard outcome: a clear pass or a ready 403 envelope to return
* verbatim — mirrors `CycleGateResult`.
*/
export type IllnessGateResult =
| { enabled: true }
| { enabled: false; response: Response };
/**
* Enforce the gate for an `/api/illness/*` route. Returns a 403
* `illness.disabled` envelope when the account has not opted in (or the
* operator turned the module off server-wide) — even with a valid Bearer
* token. Delegates to `requireModuleEnabled` but re-stamps the
* illness-specific `errorCode` so the iOS classifier branches cleanly.
*/
export async function requireIllnessEnabled(
userId: string,
): Promise<IllnessGateResult> {
const gate = await requireModuleEnabled(userId, "illness");
if (gate.enabled) return { enabled: true };
return {
enabled: false,
response: apiError("Illness journal is not enabled", 403, {
errorCode: ILLNESS_DISABLED_ERROR_CODE,
module: "illness",
}),
};
}
+48
View File
@@ -78,6 +78,54 @@ describe("resolveModuleEnabled — disabled allowlist (default-on)", () => {
});
});
describe("resolveModuleEnabled — born-gated module (illness, default-off)", () => {
it("is OFF when no preference is recorded (opt-in, not default-on)", () => {
expect(resolveModuleEnabled("illness", inputs(), true, ALL_AVAILABLE)).toBe(
false,
);
});
it("is ON only on an explicit true", () => {
expect(
resolveModuleEnabled(
"illness",
inputs({ modulePreferences: { illness: true } }),
true,
ALL_AVAILABLE,
),
).toBe(true);
});
it("stays OFF on an explicit false", () => {
expect(
resolveModuleEnabled(
"illness",
inputs({ modulePreferences: { illness: false } }),
true,
ALL_AVAILABLE,
),
).toBe(false);
});
it("operator-off short-circuits even when the user opted in", () => {
expect(
resolveModuleEnabled(
"illness",
inputs({ modulePreferences: { illness: true } }),
true,
operator({ illness: false }),
),
).toBe(false);
});
it("does not affect sibling default-on modules", () => {
const i = inputs({ modulePreferences: {} });
expect(resolveModuleEnabled("illness", i, true, ALL_AVAILABLE)).toBe(false);
expect(resolveModuleEnabled("mood", i, true, ALL_AVAILABLE)).toBe(true);
expect(resolveModuleEnabled("labs", i, true, ALL_AVAILABLE)).toBe(true);
});
});
describe("resolveModuleEnabled — cycle delegation", () => {
it("ignores the module blob and reads the cycle gate (gender-derived)", () => {
// Even a crafted `{ cycle: false }` blob cannot override the real
+8
View File
@@ -55,6 +55,7 @@ import {
} from "./operator-availability";
import {
MODULE_KEYS,
isBornGatedModule,
moduleDelegatesTo,
type ModuleKey,
} from "./registry";
@@ -146,6 +147,13 @@ export function resolveModuleEnabled(
return assistantCoach && !inputs.disableCoach;
}
if (isBornGatedModule(key)) {
// BORN-GATED (opt-in / default-off): inverse of the disabled allowlist.
// The module stays OFF until the account explicitly enables it — only a
// literal `true` turns it on, so the surface never appears unbidden.
return inputs.modulePreferences[key] === true;
}
// Disabled allowlist: only an explicit `false` turns the module off.
return inputs.modulePreferences[key] !== false;
}
+30
View File
@@ -72,6 +72,17 @@ export interface ModuleDefinition {
* link; `labelKey` names the destination ("Account", "Coach settings").
*/
managedAt?: { href: string; labelKey: string };
/**
* v1.18.1 — BORN-GATED (opt-in / default-off). A born-gated module is
* the inverse of the standard disabled-allowlist: it stays OFF until the
* account explicitly enables it (`modulePreferencesJson[key] === true`).
* Used for new optional verticals (the illness/condition journal) that
* ship dark and only appear once the user opts in from the Modules hub.
* The gate's `false`-disables contract still holds — an explicit `false`
* (or simply the absent default) keeps it off — but here ONLY an explicit
* `true` turns it on, so the surface never appears unbidden.
*/
bornGated?: boolean;
}
/**
@@ -88,6 +99,7 @@ export const MODULE_KEYS = [
"workouts",
"recovery",
"labs",
"illness",
"achievements",
"coach",
"insights",
@@ -169,6 +181,15 @@ export const MODULE_REGISTRY: Readonly<Record<ModuleKey, ModuleDefinition>> =
descriptionKey: "modules.labs.description",
category: "tracking",
},
illness: {
key: "illness",
labelKey: "modules.illness.label",
descriptionKey: "modules.illness.description",
category: "tracking",
// Ships dark — only appears once the account opts in from the
// Modules hub. Default-off, no nag, retrospective-only.
bornGated: true,
},
sleep: {
key: "sleep",
labelKey: "modules.sleep.label",
@@ -230,3 +251,12 @@ export function isModuleKey(key: string): key is ModuleKey {
export function moduleDelegatesTo(key: ModuleKey): ModuleDelegation | undefined {
return MODULE_REGISTRY[key].delegatesTo;
}
/**
* v1.18.1 — true for a born-gated (opt-in / default-off) module. The gate
* flips its default from on to off for these keys: they require an explicit
* `modulePreferencesJson[key] === true` to enable.
*/
export function isBornGatedModule(key: ModuleKey): boolean {
return MODULE_REGISTRY[key].bornGated === true;
}
+21
View File
@@ -0,0 +1,21 @@
/**
* Query keys — illness / condition journal (v1.18.1): the episode list,
* a single episode, and a single episode-day day-log.
* Part of the centralized factory; aggregated in `./index.ts`.
*/
export const illnessKeys = {
/**
* v1.18.1 — illness-journal surfaces. `illness()` is the root prefix
* every illness write invalidates through (`illnessDependentKeys` in
* `./index.ts`). The episode list, a single episode read, and a per-day
* day-log each get their own slot; any episode / day-log write evicts
* the whole `["illness"]` prefix so the history list and the open sheet
* repaint in lockstep.
*/
illness: () => ["illness"] as const,
illnessEpisodes: (includeResolved: boolean) =>
["illness", "episodes", includeResolved] as const,
illnessEpisode: (id: string) => ["illness", "episode", id] as const,
illnessDayLog: (episodeId: string, date: string) =>
["illness", "day-log", episodeId, date] as const,
};
+2
View File
@@ -19,6 +19,7 @@ import { authKeys } from "./auth";
import { coachKeys } from "./coach";
import { cycleKeys } from "./cycle";
import { dashboardKeys } from "./dashboard";
import { illnessKeys } from "./illness";
import { insightsKeys } from "./insights";
import { integrationKeys } from "./integrations";
import { labKeys } from "./labs";
@@ -44,6 +45,7 @@ export const queryKeys = {
...cycleKeys,
...measurementReminderKeys,
...labKeys,
...illnessKeys,
};
/**
+135
View File
@@ -0,0 +1,135 @@
/**
* Illness / condition-journal request/response validation (v1.18.1).
*
* Source of truth for the `/api/illness/*` wire contract. The Zod schemas
* here are reused by the OpenAPI registry so the spec stays single-source.
* `userId` is NEVER a body field — it is narrowed from the session/Bearer
* in every route and fed to the Prisma `where`.
*
* The journal is a CONDITION journal (any illness/condition kept for
* oneself), retrospective-only — NEVER a predictor/diagnoser. Day-log
* dates are `YYYY-MM-DD` strings (the CycleDayLog / MoodEntry tz-anchored
* convention); instants are ISO-8601 with offset. Free-text notes are
* encrypted at rest (`noteEncrypted` Bytes column); every other field is
* queryable plaintext.
*/
import { z } from "zod/v4";
import { isPlausibleEntryInstant } from "@/lib/validations/entry-instant";
/**
* Plausible-instant bound shared with the measurement / mood / cycle
* paths: no future instant beyond the 5-min skew, no instant before 1900.
* Stays a string on the wire (iOS reads it back verbatim).
*/
const boundedInstant = z.iso
.datetime({ offset: true })
.refine((s) => isPlausibleEntryInstant(new Date(s)), {
message: "must be a plausible instant (not future, not pre-1900)",
});
const dateString = z
.string()
.regex(/^\d{4}-\d{2}-\d{2}$/, "Expected a YYYY-MM-DD date");
/* ── enums (mirror the Prisma enums) ─────────────────────────────── */
export const illnessTypeEnum = z.enum([
"INFECTION",
"ALLERGY",
"INJURY",
"MENTAL_HEALTH",
"AUTOIMMUNE",
"CHRONIC",
"OTHER",
]);
export const illnessLifecycleEnum = z.enum([
"ACUTE",
"CHRONIC_ONGOING",
"RECURRING",
"FLARE",
]);
/* ── episode CRUD ─────────────────────────────────────────────────── */
/**
* Create an episode. `label` is the user-facing name; `note` is encrypted
* at rest. `parentConditionId` threads a FLARE/RECURRING bout under a
* parent condition. `onsetAt` defaults to "now" server-side when omitted.
*/
export const illnessEpisodeCreateSchema = z.object({
label: z.string().min(1).max(120),
type: illnessTypeEnum,
lifecycle: illnessLifecycleEnum.optional().default("ACUTE"),
onsetAt: boundedInstant.optional(),
resolvedAt: boundedInstant.nullable().optional(),
parentConditionId: z.string().min(1).max(40).nullable().optional(),
note: z.string().max(2000).nullable().optional(),
});
export type IllnessEpisodeCreate = z.infer<typeof illnessEpisodeCreateSchema>;
/**
* Edit an episode — every field optional; an omitted field is left
* untouched. A `null` `resolvedAt` re-opens a resolved episode.
*/
export const illnessEpisodeUpdateSchema = z
.object({
label: z.string().min(1).max(120).optional(),
type: illnessTypeEnum.optional(),
lifecycle: illnessLifecycleEnum.optional(),
onsetAt: boundedInstant.optional(),
resolvedAt: boundedInstant.nullable().optional(),
parentConditionId: z.string().min(1).max(40).nullable().optional(),
note: z.string().max(2000).nullable().optional(),
})
.strict();
export type IllnessEpisodeUpdate = z.infer<typeof illnessEpisodeUpdateSchema>;
/** Resolve (mark recovered) — a dedicated PATCH `/resolve` endpoint. */
export const illnessEpisodeResolveSchema = z.object({
resolvedAt: boundedInstant.optional(),
});
export type IllnessEpisodeResolve = z.infer<
typeof illnessEpisodeResolveSchema
>;
/** History/list query — newest-first, bounded. */
export const illnessEpisodeListQuerySchema = z.object({
limit: z.coerce.number().int().min(1).max(100).optional(),
includeResolved: z
.union([z.literal("true"), z.literal("false")])
.optional(),
});
/* ── day-log capture (one row per day of an episode) ─────────────── */
/**
* The canonical day-log input for an episode. Upserts on the
* `(episodeId, date)` key. `note` is encrypted at rest (`noteEncrypted`);
* `functionalImpact` (0–3) and `feverC` are queryable plaintext. Symptoms
* carry a 0–3 Jackson/WURSS severity on the link.
*/
export const illnessDayLogInputSchema = z.object({
date: dateString,
functionalImpact: z.number().int().min(0).max(3).nullable().optional(),
feverC: z.number().min(30).max(45).nullable().optional(),
symptoms: z
.array(
z.object({
key: z.string().min(1).max(80),
severity: z.number().int().min(0).max(3).optional(),
}),
)
.max(40)
.optional(),
note: z.string().max(2000).nullable().optional(),
loggedAt: boundedInstant.optional(),
});
export type IllnessDayLogInput = z.infer<typeof illnessDayLogInputSchema>;
/** The single-day read query: `GET .../day-logs?date=YYYY-MM-DD`. */
export const illnessDayLogQuerySchema = z.object({ date: dateString });
@@ -311,6 +311,7 @@ describe("doctor-report — module enable/disable gating", () => {
workouts: true,
recovery: true,
labs: true,
illness: true,
achievements: true,
coach: true,
insights: true,