mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
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:
1 parent
bee7295b19
commit
b079431a8d
34 files changed
+2799
-6
No files matched your search
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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));
|
||||
},
|
||||
);
|
||||
@@ -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 });
|
||||
},
|
||||
);
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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 />;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 },
|
||||
];
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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(),
|
||||
};
|
||||
}
|
||||
@@ -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",
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user