mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 21:32:18 +00:00
Merge branch 'feat/v125-records' into release/v1.25.0
# Conflicts: # docs/api/openapi.yaml # messages/de.json # messages/en.json # messages/es.json # messages/fr.json # messages/it.json # messages/pl.json # scripts/rotate-encryption-key.ts # src/lib/crypto/encrypted-columns.ts # src/lib/openapi/routes/index.ts # src/lib/query-keys/index.ts
This commit is contained in:
commit
ce2bddcc51
41 files changed
+3517
-11
No files matched your search
@@ -4272,6 +4272,11 @@
|
||||
"mcp": {
|
||||
"title": "MCP-Connector",
|
||||
"subtitle": "Verbinde einen externen Assistenten mit deinen eigenen Gesundheitsdaten."
|
||||
},
|
||||
"anamnesis": {
|
||||
"title": "Anamnese",
|
||||
"subtitle": "Verwalte deine Allergien und deine Familienanamnese.",
|
||||
"description": "Strukturierte Datensätze zu Allergien und Familienanamnese."
|
||||
}
|
||||
},
|
||||
"profile": "Profil",
|
||||
@@ -7553,5 +7558,113 @@
|
||||
"name": "Crisis Text Line"
|
||||
}
|
||||
}
|
||||
},
|
||||
"records": {
|
||||
"disclaimer": "Das sind deine eigenen Angaben, keine ärztliche Diagnose. HealthLog stellt keine Diagnose, behandelt nicht und deutet nichts.",
|
||||
"allergies": {
|
||||
"cardTitle": "Allergien & Unverträglichkeiten",
|
||||
"cardDescription": "Stoffe, auf die du reagierst, mit Reaktion und Schweregrad.",
|
||||
"description": "Eine Allergie oder Unverträglichkeit erfassen.",
|
||||
"add": "Allergie hinzufügen",
|
||||
"addFirst": "Erste Allergie hinzufügen",
|
||||
"addTitle": "Allergie hinzufügen",
|
||||
"editTitle": "Allergie bearbeiten",
|
||||
"formDescription": "Der Stoff wird im Klartext gespeichert; Reaktion und Notiz werden verschlüsselt.",
|
||||
"emptyTitle": "Keine Allergien erfasst",
|
||||
"emptyDescription": "Erfasse Stoffe, auf die du reagierst, und die Reaktionen.",
|
||||
"loadError": "Deine Allergien konnten nicht geladen werden.",
|
||||
"deletedToast": "Allergie gelöscht",
|
||||
"deleteError": "Die Allergie konnte nicht gelöscht werden.",
|
||||
"deleteConfirmTitle": "Diese Allergie löschen?",
|
||||
"deleteConfirmDescription": "Damit wird der Eintrag entfernt. Das lässt sich nicht rückgängig machen.",
|
||||
"category": {
|
||||
"FOOD": "Nahrungsmittel",
|
||||
"MEDICATION": "Medikament",
|
||||
"ENVIRONMENT": "Umwelt",
|
||||
"BIOLOGIC": "Biologikum",
|
||||
"OTHER": "Sonstiges"
|
||||
},
|
||||
"type": {
|
||||
"ALLERGY": "Allergie",
|
||||
"INTOLERANCE": "Unverträglichkeit"
|
||||
},
|
||||
"severity": {
|
||||
"NONE": "Nicht bewertet",
|
||||
"MILD": "Leicht",
|
||||
"MODERATE": "Mittel",
|
||||
"SEVERE": "Schwer"
|
||||
},
|
||||
"status": {
|
||||
"ACTIVE": "Aktiv",
|
||||
"INACTIVE": "Inaktiv",
|
||||
"RESOLVED": "Abgeklungen"
|
||||
},
|
||||
"form": {
|
||||
"substance": "Stoff",
|
||||
"substancePlaceholder": "z. B. Penicillin, Erdnüsse",
|
||||
"category": "Kategorie",
|
||||
"type": "Art",
|
||||
"severity": "Schweregrad",
|
||||
"status": "Status",
|
||||
"onset": "Erstmals bemerkt",
|
||||
"reaction": "Reaktion",
|
||||
"reactionPlaceholder": "z. B. Nesselsucht, Schwellung, Anaphylaxie",
|
||||
"note": "Notiz",
|
||||
"notePlaceholder": "Was du dir sonst merken möchtest",
|
||||
"requiredError": "Bitte den Stoff angeben.",
|
||||
"savedToast": "Allergie gespeichert",
|
||||
"updatedToast": "Allergie aktualisiert",
|
||||
"saveError": "Die Allergie konnte nicht gespeichert werden."
|
||||
}
|
||||
},
|
||||
"family": {
|
||||
"cardTitle": "Familienanamnese",
|
||||
"cardDescription": "Erkrankungen bei Verwandten, ein Eintrag je Erkrankung.",
|
||||
"description": "Eine Erkrankung bei einem Verwandten erfassen.",
|
||||
"add": "Eintrag hinzufügen",
|
||||
"addFirst": "Ersten Eintrag hinzufügen",
|
||||
"addTitle": "Familienanamnese hinzufügen",
|
||||
"editTitle": "Familienanamnese bearbeiten",
|
||||
"formDescription": "Verwandtschaft und Erkrankung werden im Klartext gespeichert; die Notiz wird verschlüsselt.",
|
||||
"emptyTitle": "Keine Familienanamnese erfasst",
|
||||
"emptyDescription": "Erfasse Erkrankungen, die in deiner Familie vorkommen.",
|
||||
"loadError": "Deine Familienanamnese konnte nicht geladen werden.",
|
||||
"deletedToast": "Eintrag gelöscht",
|
||||
"deleteError": "Der Eintrag konnte nicht gelöscht werden.",
|
||||
"deleteConfirmTitle": "Diesen Eintrag löschen?",
|
||||
"deleteConfirmDescription": "Damit wird der Eintrag entfernt. Das lässt sich nicht rückgängig machen.",
|
||||
"ageAtOnsetShort": "Alter {age}",
|
||||
"relationship": {
|
||||
"MOTHER": "Mutter",
|
||||
"FATHER": "Vater",
|
||||
"SISTER": "Schwester",
|
||||
"BROTHER": "Bruder",
|
||||
"DAUGHTER": "Tochter",
|
||||
"SON": "Sohn",
|
||||
"GRANDMOTHER_MATERNAL": "Großmutter (mütterlich)",
|
||||
"GRANDFATHER_MATERNAL": "Großvater (mütterlich)",
|
||||
"GRANDMOTHER_PATERNAL": "Großmutter (väterlich)",
|
||||
"GRANDFATHER_PATERNAL": "Großvater (väterlich)",
|
||||
"AUNT": "Tante",
|
||||
"UNCLE": "Onkel",
|
||||
"COUSIN": "Cousin/Cousine",
|
||||
"HALF_SIBLING": "Halbgeschwister",
|
||||
"OTHER": "Andere/r Verwandte/r"
|
||||
},
|
||||
"form": {
|
||||
"relationship": "Verwandte/r",
|
||||
"condition": "Erkrankung",
|
||||
"conditionPlaceholder": "z. B. Typ-2-Diabetes, Brustkrebs",
|
||||
"ageAtOnset": "Alter bei Beginn",
|
||||
"ageAtOnsetPlaceholder": "z. B. 55",
|
||||
"note": "Notiz",
|
||||
"notePlaceholder": "Was du dir sonst merken möchtest",
|
||||
"requiredError": "Bitte die Erkrankung angeben.",
|
||||
"ageError": "Bitte ein Alter zwischen 0 und 120 angeben.",
|
||||
"savedToast": "Eintrag gespeichert",
|
||||
"updatedToast": "Eintrag aktualisiert",
|
||||
"saveError": "Der Eintrag konnte nicht gespeichert werden."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4272,6 +4272,11 @@
|
||||
"mcp": {
|
||||
"title": "MCP Connector",
|
||||
"subtitle": "Connect an external assistant to your own health data."
|
||||
},
|
||||
"anamnesis": {
|
||||
"title": "Medical history",
|
||||
"subtitle": "Manage your allergies and family history.",
|
||||
"description": "Structured allergies and family history records."
|
||||
}
|
||||
},
|
||||
"profile": "Profile",
|
||||
@@ -7553,5 +7558,113 @@
|
||||
"name": "Crisis Text Line"
|
||||
}
|
||||
}
|
||||
},
|
||||
"records": {
|
||||
"disclaimer": "These are your own records, not a medical diagnosis. HealthLog does not diagnose, treat, or interpret them.",
|
||||
"allergies": {
|
||||
"cardTitle": "Allergies & intolerances",
|
||||
"cardDescription": "Substances you react to, with the reaction and how severe it is.",
|
||||
"description": "Record an allergy or intolerance.",
|
||||
"add": "Add allergy",
|
||||
"addFirst": "Add your first allergy",
|
||||
"addTitle": "Add allergy",
|
||||
"editTitle": "Edit allergy",
|
||||
"formDescription": "The substance is stored as plain text; the reaction and note are encrypted.",
|
||||
"emptyTitle": "No allergies recorded",
|
||||
"emptyDescription": "Add substances you react to and their reactions.",
|
||||
"loadError": "Couldn't load your allergies.",
|
||||
"deletedToast": "Allergy deleted",
|
||||
"deleteError": "Couldn't delete the allergy.",
|
||||
"deleteConfirmTitle": "Delete this allergy?",
|
||||
"deleteConfirmDescription": "This removes the record. It can't be undone.",
|
||||
"category": {
|
||||
"FOOD": "Food",
|
||||
"MEDICATION": "Medication",
|
||||
"ENVIRONMENT": "Environment",
|
||||
"BIOLOGIC": "Biologic",
|
||||
"OTHER": "Other"
|
||||
},
|
||||
"type": {
|
||||
"ALLERGY": "Allergy",
|
||||
"INTOLERANCE": "Intolerance"
|
||||
},
|
||||
"severity": {
|
||||
"NONE": "Not assessed",
|
||||
"MILD": "Mild",
|
||||
"MODERATE": "Moderate",
|
||||
"SEVERE": "Severe"
|
||||
},
|
||||
"status": {
|
||||
"ACTIVE": "Active",
|
||||
"INACTIVE": "Inactive",
|
||||
"RESOLVED": "Resolved"
|
||||
},
|
||||
"form": {
|
||||
"substance": "Substance",
|
||||
"substancePlaceholder": "e.g. Penicillin, Peanuts",
|
||||
"category": "Category",
|
||||
"type": "Type",
|
||||
"severity": "Severity",
|
||||
"status": "Status",
|
||||
"onset": "First noticed",
|
||||
"reaction": "Reaction",
|
||||
"reactionPlaceholder": "e.g. hives, swelling, anaphylaxis",
|
||||
"note": "Note",
|
||||
"notePlaceholder": "Anything else worth remembering",
|
||||
"requiredError": "Enter the substance.",
|
||||
"savedToast": "Allergy saved",
|
||||
"updatedToast": "Allergy updated",
|
||||
"saveError": "Couldn't save the allergy."
|
||||
}
|
||||
},
|
||||
"family": {
|
||||
"cardTitle": "Family history",
|
||||
"cardDescription": "Conditions in your relatives, one entry per condition.",
|
||||
"description": "Record a condition in a relative.",
|
||||
"add": "Add entry",
|
||||
"addFirst": "Add your first entry",
|
||||
"addTitle": "Add family history",
|
||||
"editTitle": "Edit family history",
|
||||
"formDescription": "The relationship and condition are stored as plain text; the note is encrypted.",
|
||||
"emptyTitle": "No family history recorded",
|
||||
"emptyDescription": "Add conditions that run in your family.",
|
||||
"loadError": "Couldn't load your family history.",
|
||||
"deletedToast": "Entry deleted",
|
||||
"deleteError": "Couldn't delete the entry.",
|
||||
"deleteConfirmTitle": "Delete this entry?",
|
||||
"deleteConfirmDescription": "This removes the record. It can't be undone.",
|
||||
"ageAtOnsetShort": "Age {age}",
|
||||
"relationship": {
|
||||
"MOTHER": "Mother",
|
||||
"FATHER": "Father",
|
||||
"SISTER": "Sister",
|
||||
"BROTHER": "Brother",
|
||||
"DAUGHTER": "Daughter",
|
||||
"SON": "Son",
|
||||
"GRANDMOTHER_MATERNAL": "Grandmother (maternal)",
|
||||
"GRANDFATHER_MATERNAL": "Grandfather (maternal)",
|
||||
"GRANDMOTHER_PATERNAL": "Grandmother (paternal)",
|
||||
"GRANDFATHER_PATERNAL": "Grandfather (paternal)",
|
||||
"AUNT": "Aunt",
|
||||
"UNCLE": "Uncle",
|
||||
"COUSIN": "Cousin",
|
||||
"HALF_SIBLING": "Half-sibling",
|
||||
"OTHER": "Other relative"
|
||||
},
|
||||
"form": {
|
||||
"relationship": "Relative",
|
||||
"condition": "Condition",
|
||||
"conditionPlaceholder": "e.g. Type 2 diabetes, Breast cancer",
|
||||
"ageAtOnset": "Age at onset",
|
||||
"ageAtOnsetPlaceholder": "e.g. 55",
|
||||
"note": "Note",
|
||||
"notePlaceholder": "Anything else worth remembering",
|
||||
"requiredError": "Enter the condition.",
|
||||
"ageError": "Enter an age between 0 and 120.",
|
||||
"savedToast": "Entry saved",
|
||||
"updatedToast": "Entry updated",
|
||||
"saveError": "Couldn't save the entry."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4272,6 +4272,11 @@
|
||||
"mcp": {
|
||||
"title": "Conector MCP",
|
||||
"subtitle": "Conecta un asistente externo a tus propios datos de salud."
|
||||
},
|
||||
"anamnesis": {
|
||||
"title": "Historial médico",
|
||||
"subtitle": "Gestiona tus alergias y tus antecedentes familiares.",
|
||||
"description": "Registros estructurados de alergias y antecedentes familiares."
|
||||
}
|
||||
},
|
||||
"profile": "Perfil",
|
||||
@@ -7553,5 +7558,113 @@
|
||||
"name": "Crisis Text Line"
|
||||
}
|
||||
}
|
||||
},
|
||||
"records": {
|
||||
"disclaimer": "Son tus propios registros, no un diagnóstico médico. HealthLog no diagnostica, no trata ni los interpreta.",
|
||||
"allergies": {
|
||||
"cardTitle": "Alergias e intolerancias",
|
||||
"cardDescription": "Sustancias a las que reaccionas, con la reacción y su gravedad.",
|
||||
"description": "Registrar una alergia o intolerancia.",
|
||||
"add": "Añadir alergia",
|
||||
"addFirst": "Añade tu primera alergia",
|
||||
"addTitle": "Añadir alergia",
|
||||
"editTitle": "Editar alergia",
|
||||
"formDescription": "La sustancia se guarda como texto; la reacción y la nota se cifran.",
|
||||
"emptyTitle": "No hay alergias registradas",
|
||||
"emptyDescription": "Añade sustancias a las que reaccionas y sus reacciones.",
|
||||
"loadError": "No se han podido cargar tus alergias.",
|
||||
"deletedToast": "Alergia eliminada",
|
||||
"deleteError": "No se ha podido eliminar la alergia.",
|
||||
"deleteConfirmTitle": "¿Eliminar esta alergia?",
|
||||
"deleteConfirmDescription": "Esto elimina el registro. No se puede deshacer.",
|
||||
"category": {
|
||||
"FOOD": "Alimento",
|
||||
"MEDICATION": "Medicamento",
|
||||
"ENVIRONMENT": "Ambiental",
|
||||
"BIOLOGIC": "Biológico",
|
||||
"OTHER": "Otro"
|
||||
},
|
||||
"type": {
|
||||
"ALLERGY": "Alergia",
|
||||
"INTOLERANCE": "Intolerancia"
|
||||
},
|
||||
"severity": {
|
||||
"NONE": "Sin evaluar",
|
||||
"MILD": "Leve",
|
||||
"MODERATE": "Moderada",
|
||||
"SEVERE": "Grave"
|
||||
},
|
||||
"status": {
|
||||
"ACTIVE": "Activa",
|
||||
"INACTIVE": "Inactiva",
|
||||
"RESOLVED": "Resuelta"
|
||||
},
|
||||
"form": {
|
||||
"substance": "Sustancia",
|
||||
"substancePlaceholder": "p. ej. Penicilina, Cacahuetes",
|
||||
"category": "Categoría",
|
||||
"type": "Tipo",
|
||||
"severity": "Gravedad",
|
||||
"status": "Estado",
|
||||
"onset": "Primera vez observada",
|
||||
"reaction": "Reacción",
|
||||
"reactionPlaceholder": "p. ej. urticaria, hinchazón, anafilaxia",
|
||||
"note": "Nota",
|
||||
"notePlaceholder": "Cualquier otra cosa que merezca recordar",
|
||||
"requiredError": "Introduce la sustancia.",
|
||||
"savedToast": "Alergia guardada",
|
||||
"updatedToast": "Alergia actualizada",
|
||||
"saveError": "No se ha podido guardar la alergia."
|
||||
}
|
||||
},
|
||||
"family": {
|
||||
"cardTitle": "Antecedentes familiares",
|
||||
"cardDescription": "Enfermedades de tus familiares, una entrada por enfermedad.",
|
||||
"description": "Registrar una enfermedad de un familiar.",
|
||||
"add": "Añadir entrada",
|
||||
"addFirst": "Añade tu primera entrada",
|
||||
"addTitle": "Añadir antecedente familiar",
|
||||
"editTitle": "Editar antecedente familiar",
|
||||
"formDescription": "El parentesco y la enfermedad se guardan como texto; la nota se cifra.",
|
||||
"emptyTitle": "No hay antecedentes familiares",
|
||||
"emptyDescription": "Añade enfermedades que aparecen en tu familia.",
|
||||
"loadError": "No se han podido cargar tus antecedentes familiares.",
|
||||
"deletedToast": "Entrada eliminada",
|
||||
"deleteError": "No se ha podido eliminar la entrada.",
|
||||
"deleteConfirmTitle": "¿Eliminar esta entrada?",
|
||||
"deleteConfirmDescription": "Esto elimina el registro. No se puede deshacer.",
|
||||
"ageAtOnsetShort": "Edad {age}",
|
||||
"relationship": {
|
||||
"MOTHER": "Madre",
|
||||
"FATHER": "Padre",
|
||||
"SISTER": "Hermana",
|
||||
"BROTHER": "Hermano",
|
||||
"DAUGHTER": "Hija",
|
||||
"SON": "Hijo",
|
||||
"GRANDMOTHER_MATERNAL": "Abuela (materna)",
|
||||
"GRANDFATHER_MATERNAL": "Abuelo (materno)",
|
||||
"GRANDMOTHER_PATERNAL": "Abuela (paterna)",
|
||||
"GRANDFATHER_PATERNAL": "Abuelo (paterno)",
|
||||
"AUNT": "Tía",
|
||||
"UNCLE": "Tío",
|
||||
"COUSIN": "Primo/a",
|
||||
"HALF_SIBLING": "Medio hermano/a",
|
||||
"OTHER": "Otro familiar"
|
||||
},
|
||||
"form": {
|
||||
"relationship": "Familiar",
|
||||
"condition": "Enfermedad",
|
||||
"conditionPlaceholder": "p. ej. Diabetes tipo 2, Cáncer de mama",
|
||||
"ageAtOnset": "Edad de inicio",
|
||||
"ageAtOnsetPlaceholder": "p. ej. 55",
|
||||
"note": "Nota",
|
||||
"notePlaceholder": "Cualquier otra cosa que merezca recordar",
|
||||
"requiredError": "Introduce la enfermedad.",
|
||||
"ageError": "Introduce una edad entre 0 y 120.",
|
||||
"savedToast": "Entrada guardada",
|
||||
"updatedToast": "Entrada actualizada",
|
||||
"saveError": "No se ha podido guardar la entrada."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4272,6 +4272,11 @@
|
||||
"mcp": {
|
||||
"title": "Connecteur MCP",
|
||||
"subtitle": "Connectez un assistant externe à vos propres données de santé."
|
||||
},
|
||||
"anamnesis": {
|
||||
"title": "Antécédents médicaux",
|
||||
"subtitle": "Gérez vos allergies et vos antécédents familiaux.",
|
||||
"description": "Dossiers structurés des allergies et des antécédents familiaux."
|
||||
}
|
||||
},
|
||||
"profile": "Profil",
|
||||
@@ -7553,5 +7558,113 @@
|
||||
"name": "Crisis Text Line"
|
||||
}
|
||||
}
|
||||
},
|
||||
"records": {
|
||||
"disclaimer": "Ce sont vos propres informations, pas un diagnostic médical. HealthLog ne diagnostique pas, ne traite pas et ne les interprète pas.",
|
||||
"allergies": {
|
||||
"cardTitle": "Allergies & intolérances",
|
||||
"cardDescription": "Substances auxquelles vous réagissez, avec la réaction et sa gravité.",
|
||||
"description": "Enregistrer une allergie ou une intolérance.",
|
||||
"add": "Ajouter une allergie",
|
||||
"addFirst": "Ajoutez votre première allergie",
|
||||
"addTitle": "Ajouter une allergie",
|
||||
"editTitle": "Modifier l'allergie",
|
||||
"formDescription": "La substance est stockée en clair ; la réaction et la note sont chiffrées.",
|
||||
"emptyTitle": "Aucune allergie enregistrée",
|
||||
"emptyDescription": "Ajoutez les substances auxquelles vous réagissez et leurs réactions.",
|
||||
"loadError": "Impossible de charger vos allergies.",
|
||||
"deletedToast": "Allergie supprimée",
|
||||
"deleteError": "Impossible de supprimer l'allergie.",
|
||||
"deleteConfirmTitle": "Supprimer cette allergie ?",
|
||||
"deleteConfirmDescription": "Cela supprime l'enregistrement. C'est irréversible.",
|
||||
"category": {
|
||||
"FOOD": "Aliment",
|
||||
"MEDICATION": "Médicament",
|
||||
"ENVIRONMENT": "Environnement",
|
||||
"BIOLOGIC": "Biologique",
|
||||
"OTHER": "Autre"
|
||||
},
|
||||
"type": {
|
||||
"ALLERGY": "Allergie",
|
||||
"INTOLERANCE": "Intolérance"
|
||||
},
|
||||
"severity": {
|
||||
"NONE": "Non évaluée",
|
||||
"MILD": "Légère",
|
||||
"MODERATE": "Modérée",
|
||||
"SEVERE": "Sévère"
|
||||
},
|
||||
"status": {
|
||||
"ACTIVE": "Active",
|
||||
"INACTIVE": "Inactive",
|
||||
"RESOLVED": "Résolue"
|
||||
},
|
||||
"form": {
|
||||
"substance": "Substance",
|
||||
"substancePlaceholder": "p. ex. Pénicilline, Arachides",
|
||||
"category": "Catégorie",
|
||||
"type": "Type",
|
||||
"severity": "Gravité",
|
||||
"status": "Statut",
|
||||
"onset": "Première fois remarquée",
|
||||
"reaction": "Réaction",
|
||||
"reactionPlaceholder": "p. ex. urticaire, gonflement, anaphylaxie",
|
||||
"note": "Note",
|
||||
"notePlaceholder": "Tout autre élément à retenir",
|
||||
"requiredError": "Saisissez la substance.",
|
||||
"savedToast": "Allergie enregistrée",
|
||||
"updatedToast": "Allergie mise à jour",
|
||||
"saveError": "Impossible d'enregistrer l'allergie."
|
||||
}
|
||||
},
|
||||
"family": {
|
||||
"cardTitle": "Antécédents familiaux",
|
||||
"cardDescription": "Maladies de vos proches, une entrée par maladie.",
|
||||
"description": "Enregistrer une maladie d'un proche.",
|
||||
"add": "Ajouter une entrée",
|
||||
"addFirst": "Ajoutez votre première entrée",
|
||||
"addTitle": "Ajouter un antécédent familial",
|
||||
"editTitle": "Modifier l'antécédent familial",
|
||||
"formDescription": "Le lien de parenté et la maladie sont stockés en clair ; la note est chiffrée.",
|
||||
"emptyTitle": "Aucun antécédent familial",
|
||||
"emptyDescription": "Ajoutez les maladies présentes dans votre famille.",
|
||||
"loadError": "Impossible de charger vos antécédents familiaux.",
|
||||
"deletedToast": "Entrée supprimée",
|
||||
"deleteError": "Impossible de supprimer l'entrée.",
|
||||
"deleteConfirmTitle": "Supprimer cette entrée ?",
|
||||
"deleteConfirmDescription": "Cela supprime l'enregistrement. C'est irréversible.",
|
||||
"ageAtOnsetShort": "Âge {age}",
|
||||
"relationship": {
|
||||
"MOTHER": "Mère",
|
||||
"FATHER": "Père",
|
||||
"SISTER": "Sœur",
|
||||
"BROTHER": "Frère",
|
||||
"DAUGHTER": "Fille",
|
||||
"SON": "Fils",
|
||||
"GRANDMOTHER_MATERNAL": "Grand-mère (maternelle)",
|
||||
"GRANDFATHER_MATERNAL": "Grand-père (maternel)",
|
||||
"GRANDMOTHER_PATERNAL": "Grand-mère (paternelle)",
|
||||
"GRANDFATHER_PATERNAL": "Grand-père (paternel)",
|
||||
"AUNT": "Tante",
|
||||
"UNCLE": "Oncle",
|
||||
"COUSIN": "Cousin/cousine",
|
||||
"HALF_SIBLING": "Demi-frère/demi-sœur",
|
||||
"OTHER": "Autre proche"
|
||||
},
|
||||
"form": {
|
||||
"relationship": "Proche",
|
||||
"condition": "Maladie",
|
||||
"conditionPlaceholder": "p. ex. Diabète de type 2, Cancer du sein",
|
||||
"ageAtOnset": "Âge d'apparition",
|
||||
"ageAtOnsetPlaceholder": "p. ex. 55",
|
||||
"note": "Note",
|
||||
"notePlaceholder": "Tout autre élément à retenir",
|
||||
"requiredError": "Saisissez la maladie.",
|
||||
"ageError": "Saisissez un âge entre 0 et 120.",
|
||||
"savedToast": "Entrée enregistrée",
|
||||
"updatedToast": "Entrée mise à jour",
|
||||
"saveError": "Impossible d'enregistrer l'entrée."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4272,6 +4272,11 @@
|
||||
"mcp": {
|
||||
"title": "Connettore MCP",
|
||||
"subtitle": "Collega un assistente esterno ai tuoi dati sulla salute."
|
||||
},
|
||||
"anamnesis": {
|
||||
"title": "Anamnesi",
|
||||
"subtitle": "Gestisci le tue allergie e la tua anamnesi familiare.",
|
||||
"description": "Record strutturati di allergie e anamnesi familiare."
|
||||
}
|
||||
},
|
||||
"profile": "Profilo",
|
||||
@@ -7553,5 +7558,113 @@
|
||||
"name": "Crisis Text Line"
|
||||
}
|
||||
}
|
||||
},
|
||||
"records": {
|
||||
"disclaimer": "Sono i tuoi dati, non una diagnosi medica. HealthLog non diagnostica, non cura e non li interpreta.",
|
||||
"allergies": {
|
||||
"cardTitle": "Allergie e intolleranze",
|
||||
"cardDescription": "Sostanze a cui reagisci, con la reazione e la sua gravità.",
|
||||
"description": "Registra un'allergia o un'intolleranza.",
|
||||
"add": "Aggiungi allergia",
|
||||
"addFirst": "Aggiungi la tua prima allergia",
|
||||
"addTitle": "Aggiungi allergia",
|
||||
"editTitle": "Modifica allergia",
|
||||
"formDescription": "La sostanza è salvata in chiaro; la reazione e la nota sono cifrate.",
|
||||
"emptyTitle": "Nessuna allergia registrata",
|
||||
"emptyDescription": "Aggiungi le sostanze a cui reagisci e le reazioni.",
|
||||
"loadError": "Impossibile caricare le tue allergie.",
|
||||
"deletedToast": "Allergia eliminata",
|
||||
"deleteError": "Impossibile eliminare l'allergia.",
|
||||
"deleteConfirmTitle": "Eliminare questa allergia?",
|
||||
"deleteConfirmDescription": "Questo rimuove il record. Non può essere annullato.",
|
||||
"category": {
|
||||
"FOOD": "Alimento",
|
||||
"MEDICATION": "Farmaco",
|
||||
"ENVIRONMENT": "Ambiente",
|
||||
"BIOLOGIC": "Biologico",
|
||||
"OTHER": "Altro"
|
||||
},
|
||||
"type": {
|
||||
"ALLERGY": "Allergia",
|
||||
"INTOLERANCE": "Intolleranza"
|
||||
},
|
||||
"severity": {
|
||||
"NONE": "Non valutata",
|
||||
"MILD": "Lieve",
|
||||
"MODERATE": "Moderata",
|
||||
"SEVERE": "Grave"
|
||||
},
|
||||
"status": {
|
||||
"ACTIVE": "Attiva",
|
||||
"INACTIVE": "Inattiva",
|
||||
"RESOLVED": "Risolta"
|
||||
},
|
||||
"form": {
|
||||
"substance": "Sostanza",
|
||||
"substancePlaceholder": "es. Penicillina, Arachidi",
|
||||
"category": "Categoria",
|
||||
"type": "Tipo",
|
||||
"severity": "Gravità",
|
||||
"status": "Stato",
|
||||
"onset": "Notata per la prima volta",
|
||||
"reaction": "Reazione",
|
||||
"reactionPlaceholder": "es. orticaria, gonfiore, anafilassi",
|
||||
"note": "Nota",
|
||||
"notePlaceholder": "Qualsiasi altra cosa da ricordare",
|
||||
"requiredError": "Inserisci la sostanza.",
|
||||
"savedToast": "Allergia salvata",
|
||||
"updatedToast": "Allergia aggiornata",
|
||||
"saveError": "Impossibile salvare l'allergia."
|
||||
}
|
||||
},
|
||||
"family": {
|
||||
"cardTitle": "Anamnesi familiare",
|
||||
"cardDescription": "Patologie dei tuoi parenti, una voce per patologia.",
|
||||
"description": "Registra una patologia di un parente.",
|
||||
"add": "Aggiungi voce",
|
||||
"addFirst": "Aggiungi la tua prima voce",
|
||||
"addTitle": "Aggiungi anamnesi familiare",
|
||||
"editTitle": "Modifica anamnesi familiare",
|
||||
"formDescription": "La parentela e la patologia sono salvate in chiaro; la nota è cifrata.",
|
||||
"emptyTitle": "Nessuna anamnesi familiare",
|
||||
"emptyDescription": "Aggiungi le patologie presenti nella tua famiglia.",
|
||||
"loadError": "Impossibile caricare la tua anamnesi familiare.",
|
||||
"deletedToast": "Voce eliminata",
|
||||
"deleteError": "Impossibile eliminare la voce.",
|
||||
"deleteConfirmTitle": "Eliminare questa voce?",
|
||||
"deleteConfirmDescription": "Questo rimuove il record. Non può essere annullato.",
|
||||
"ageAtOnsetShort": "Età {age}",
|
||||
"relationship": {
|
||||
"MOTHER": "Madre",
|
||||
"FATHER": "Padre",
|
||||
"SISTER": "Sorella",
|
||||
"BROTHER": "Fratello",
|
||||
"DAUGHTER": "Figlia",
|
||||
"SON": "Figlio",
|
||||
"GRANDMOTHER_MATERNAL": "Nonna (materna)",
|
||||
"GRANDFATHER_MATERNAL": "Nonno (materno)",
|
||||
"GRANDMOTHER_PATERNAL": "Nonna (paterna)",
|
||||
"GRANDFATHER_PATERNAL": "Nonno (paterno)",
|
||||
"AUNT": "Zia",
|
||||
"UNCLE": "Zio",
|
||||
"COUSIN": "Cugino/a",
|
||||
"HALF_SIBLING": "Fratellastro/sorellastra",
|
||||
"OTHER": "Altro parente"
|
||||
},
|
||||
"form": {
|
||||
"relationship": "Parente",
|
||||
"condition": "Patologia",
|
||||
"conditionPlaceholder": "es. Diabete di tipo 2, Cancro al seno",
|
||||
"ageAtOnset": "Età di insorgenza",
|
||||
"ageAtOnsetPlaceholder": "es. 55",
|
||||
"note": "Nota",
|
||||
"notePlaceholder": "Qualsiasi altra cosa da ricordare",
|
||||
"requiredError": "Inserisci la patologia.",
|
||||
"ageError": "Inserisci un'età tra 0 e 120.",
|
||||
"savedToast": "Voce salvata",
|
||||
"updatedToast": "Voce aggiornata",
|
||||
"saveError": "Impossibile salvare la voce."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4272,6 +4272,11 @@
|
||||
"mcp": {
|
||||
"title": "Łącznik MCP",
|
||||
"subtitle": "Połącz zewnętrznego asystenta z własnymi danymi zdrowotnymi."
|
||||
},
|
||||
"anamnesis": {
|
||||
"title": "Wywiad medyczny",
|
||||
"subtitle": "Zarządzaj swoimi alergiami i wywiadem rodzinnym.",
|
||||
"description": "Ustrukturyzowane rekordy alergii i wywiadu rodzinnego."
|
||||
}
|
||||
},
|
||||
"profile": "Profil",
|
||||
@@ -7553,5 +7558,113 @@
|
||||
"name": "Crisis Text Line"
|
||||
}
|
||||
}
|
||||
},
|
||||
"records": {
|
||||
"disclaimer": "To Twoje własne wpisy, a nie diagnoza lekarska. HealthLog nie diagnozuje, nie leczy i ich nie interpretuje.",
|
||||
"allergies": {
|
||||
"cardTitle": "Alergie i nietolerancje",
|
||||
"cardDescription": "Substancje, na które reagujesz, wraz z reakcją i jej nasileniem.",
|
||||
"description": "Zapisz alergię lub nietolerancję.",
|
||||
"add": "Dodaj alergię",
|
||||
"addFirst": "Dodaj swoją pierwszą alergię",
|
||||
"addTitle": "Dodaj alergię",
|
||||
"editTitle": "Edytuj alergię",
|
||||
"formDescription": "Substancja jest przechowywana jako tekst; reakcja i notatka są szyfrowane.",
|
||||
"emptyTitle": "Brak zapisanych alergii",
|
||||
"emptyDescription": "Dodaj substancje, na które reagujesz, i ich reakcje.",
|
||||
"loadError": "Nie udało się wczytać Twoich alergii.",
|
||||
"deletedToast": "Alergia usunięta",
|
||||
"deleteError": "Nie udało się usunąć alergii.",
|
||||
"deleteConfirmTitle": "Usunąć tę alergię?",
|
||||
"deleteConfirmDescription": "To usuwa wpis. Nie można tego cofnąć.",
|
||||
"category": {
|
||||
"FOOD": "Pokarm",
|
||||
"MEDICATION": "Lek",
|
||||
"ENVIRONMENT": "Środowisko",
|
||||
"BIOLOGIC": "Biologiczny",
|
||||
"OTHER": "Inne"
|
||||
},
|
||||
"type": {
|
||||
"ALLERGY": "Alergia",
|
||||
"INTOLERANCE": "Nietolerancja"
|
||||
},
|
||||
"severity": {
|
||||
"NONE": "Nieoceniona",
|
||||
"MILD": "Łagodna",
|
||||
"MODERATE": "Umiarkowana",
|
||||
"SEVERE": "Ciężka"
|
||||
},
|
||||
"status": {
|
||||
"ACTIVE": "Aktywna",
|
||||
"INACTIVE": "Nieaktywna",
|
||||
"RESOLVED": "Ustąpiła"
|
||||
},
|
||||
"form": {
|
||||
"substance": "Substancja",
|
||||
"substancePlaceholder": "np. Penicylina, Orzeszki ziemne",
|
||||
"category": "Kategoria",
|
||||
"type": "Typ",
|
||||
"severity": "Nasilenie",
|
||||
"status": "Status",
|
||||
"onset": "Pierwszy raz zauważono",
|
||||
"reaction": "Reakcja",
|
||||
"reactionPlaceholder": "np. pokrzywka, obrzęk, anafilaksja",
|
||||
"note": "Notatka",
|
||||
"notePlaceholder": "Cokolwiek innego, co warto zapamiętać",
|
||||
"requiredError": "Podaj substancję.",
|
||||
"savedToast": "Alergia zapisana",
|
||||
"updatedToast": "Alergia zaktualizowana",
|
||||
"saveError": "Nie udało się zapisać alergii."
|
||||
}
|
||||
},
|
||||
"family": {
|
||||
"cardTitle": "Wywiad rodzinny",
|
||||
"cardDescription": "Choroby u Twoich krewnych, jeden wpis na chorobę.",
|
||||
"description": "Zapisz chorobę u krewnego.",
|
||||
"add": "Dodaj wpis",
|
||||
"addFirst": "Dodaj swój pierwszy wpis",
|
||||
"addTitle": "Dodaj wywiad rodzinny",
|
||||
"editTitle": "Edytuj wywiad rodzinny",
|
||||
"formDescription": "Pokrewieństwo i choroba są przechowywane jako tekst; notatka jest szyfrowana.",
|
||||
"emptyTitle": "Brak wywiadu rodzinnego",
|
||||
"emptyDescription": "Dodaj choroby występujące w Twojej rodzinie.",
|
||||
"loadError": "Nie udało się wczytać Twojego wywiadu rodzinnego.",
|
||||
"deletedToast": "Wpis usunięty",
|
||||
"deleteError": "Nie udało się usunąć wpisu.",
|
||||
"deleteConfirmTitle": "Usunąć ten wpis?",
|
||||
"deleteConfirmDescription": "To usuwa wpis. Nie można tego cofnąć.",
|
||||
"ageAtOnsetShort": "Wiek {age}",
|
||||
"relationship": {
|
||||
"MOTHER": "Matka",
|
||||
"FATHER": "Ojciec",
|
||||
"SISTER": "Siostra",
|
||||
"BROTHER": "Brat",
|
||||
"DAUGHTER": "Córka",
|
||||
"SON": "Syn",
|
||||
"GRANDMOTHER_MATERNAL": "Babcia (ze strony matki)",
|
||||
"GRANDFATHER_MATERNAL": "Dziadek (ze strony matki)",
|
||||
"GRANDMOTHER_PATERNAL": "Babcia (ze strony ojca)",
|
||||
"GRANDFATHER_PATERNAL": "Dziadek (ze strony ojca)",
|
||||
"AUNT": "Ciotka",
|
||||
"UNCLE": "Wujek",
|
||||
"COUSIN": "Kuzyn/kuzynka",
|
||||
"HALF_SIBLING": "Przyrodnie rodzeństwo",
|
||||
"OTHER": "Inny krewny"
|
||||
},
|
||||
"form": {
|
||||
"relationship": "Krewny",
|
||||
"condition": "Choroba",
|
||||
"conditionPlaceholder": "np. Cukrzyca typu 2, Rak piersi",
|
||||
"ageAtOnset": "Wiek zachorowania",
|
||||
"ageAtOnsetPlaceholder": "np. 55",
|
||||
"note": "Notatka",
|
||||
"notePlaceholder": "Cokolwiek innego, co warto zapamiętać",
|
||||
"requiredError": "Podaj chorobę.",
|
||||
"ageError": "Podaj wiek od 0 do 120.",
|
||||
"savedToast": "Wpis zapisany",
|
||||
"updatedToast": "Wpis zaktualizowany",
|
||||
"saveError": "Nie udało się zapisać wpisu."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
-- v1.25 (W-RECORDS) — structured allergies / intolerances.
|
||||
--
|
||||
-- A self-recorded AllergyIntolerance-style record: substance + category +
|
||||
-- type + severity + status + optional onset, with the free-text reaction
|
||||
-- description and notes encrypted at rest (BYTEA, the AES-256-GCM
|
||||
-- `*_encrypted` convention shared with the illness / mood / lab note
|
||||
-- columns). The `substance` label stays queryable plaintext (the
|
||||
-- medication.name / illness.label precedent) — it rides the FHIR
|
||||
-- AllergyIntolerance `code.text` anchor, never a machine-guessed code.
|
||||
--
|
||||
-- Patient-reported reference data, not a time-series signal and not a
|
||||
-- clinical diagnosis. Soft-deleted (`deleted_at`). Owner-scoped via the FK.
|
||||
--
|
||||
-- Additive; no existing row touched. Idempotent guards (`IF NOT EXISTS` /
|
||||
-- `DO $$`) so reruns are safe on prod.
|
||||
--
|
||||
-- Reversibility (down):
|
||||
-- DROP TABLE IF EXISTS "allergies";
|
||||
-- DROP TYPE IF EXISTS "allergy_status";
|
||||
-- DROP TYPE IF EXISTS "allergy_severity";
|
||||
-- DROP TYPE IF EXISTS "allergy_type";
|
||||
-- DROP TYPE IF EXISTS "allergy_category";
|
||||
-- A roll-back drops the allergy record wholesale — no other domain depends
|
||||
-- on it.
|
||||
|
||||
-- CreateEnum
|
||||
DO $$ BEGIN
|
||||
CREATE TYPE "allergy_category" AS ENUM ('FOOD', 'MEDICATION', 'ENVIRONMENT', 'BIOLOGIC', 'OTHER');
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN null;
|
||||
END $$;
|
||||
|
||||
-- CreateEnum
|
||||
DO $$ BEGIN
|
||||
CREATE TYPE "allergy_type" AS ENUM ('ALLERGY', 'INTOLERANCE');
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN null;
|
||||
END $$;
|
||||
|
||||
-- CreateEnum
|
||||
DO $$ BEGIN
|
||||
CREATE TYPE "allergy_severity" AS ENUM ('MILD', 'MODERATE', 'SEVERE');
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN null;
|
||||
END $$;
|
||||
|
||||
-- CreateEnum
|
||||
DO $$ BEGIN
|
||||
CREATE TYPE "allergy_status" AS ENUM ('ACTIVE', 'INACTIVE', 'RESOLVED');
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN null;
|
||||
END $$;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE IF NOT EXISTS "allergies" (
|
||||
"id" TEXT NOT NULL,
|
||||
"user_id" TEXT NOT NULL,
|
||||
"substance" TEXT NOT NULL,
|
||||
"category" "allergy_category" NOT NULL DEFAULT 'OTHER',
|
||||
"type" "allergy_type" NOT NULL DEFAULT 'ALLERGY',
|
||||
"severity" "allergy_severity",
|
||||
"status" "allergy_status" NOT NULL DEFAULT 'ACTIVE',
|
||||
"onset_at" TIMESTAMP(3),
|
||||
"reaction_encrypted" BYTEA,
|
||||
"notes_encrypted" BYTEA,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||
"deleted_at" TIMESTAMP(3),
|
||||
|
||||
CONSTRAINT "allergies_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX IF NOT EXISTS "allergies_user_id_deleted_at_idx" ON "allergies"("user_id", "deleted_at");
|
||||
|
||||
-- AddForeignKey
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "allergies" ADD CONSTRAINT "allergies_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN null;
|
||||
END $$;
|
||||
@@ -0,0 +1,58 @@
|
||||
-- v1.25 (W-RECORDS) — structured family history.
|
||||
--
|
||||
-- One condition recorded for one relative (a FHIR FamilyMemberHistory with a
|
||||
-- single condition): relationship + condition label + optional age-at-onset,
|
||||
-- with the optional free-text note encrypted at rest (BYTEA, the AES-256-GCM
|
||||
-- `*_encrypted` convention). The `condition` label stays queryable plaintext
|
||||
-- (the medication.name / illness.label precedent) — it rides the FHIR
|
||||
-- FamilyMemberHistory `condition.code.text` anchor, never a machine-guessed
|
||||
-- code.
|
||||
--
|
||||
-- Patient-reported reference data, not a time-series signal and not a clinical
|
||||
-- diagnosis. Soft-deleted (`deleted_at`). Owner-scoped via the FK.
|
||||
--
|
||||
-- Additive; no existing row touched. Idempotent guards (`IF NOT EXISTS` /
|
||||
-- `DO $$`) so reruns are safe on prod.
|
||||
--
|
||||
-- Reversibility (down):
|
||||
-- DROP TABLE IF EXISTS "family_history_entries";
|
||||
-- DROP TYPE IF EXISTS "family_relationship";
|
||||
-- A roll-back drops the family-history record wholesale — no other domain
|
||||
-- depends on it.
|
||||
|
||||
-- CreateEnum
|
||||
DO $$ BEGIN
|
||||
CREATE TYPE "family_relationship" AS ENUM (
|
||||
'MOTHER', 'FATHER', 'SISTER', 'BROTHER', 'DAUGHTER', 'SON',
|
||||
'GRANDMOTHER_MATERNAL', 'GRANDFATHER_MATERNAL',
|
||||
'GRANDMOTHER_PATERNAL', 'GRANDFATHER_PATERNAL',
|
||||
'AUNT', 'UNCLE', 'COUSIN', 'HALF_SIBLING', 'OTHER'
|
||||
);
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN null;
|
||||
END $$;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE IF NOT EXISTS "family_history_entries" (
|
||||
"id" TEXT NOT NULL,
|
||||
"user_id" TEXT NOT NULL,
|
||||
"relationship" "family_relationship" NOT NULL,
|
||||
"condition" TEXT NOT NULL,
|
||||
"age_at_onset" INTEGER,
|
||||
"notes_encrypted" BYTEA,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||
"deleted_at" TIMESTAMP(3),
|
||||
|
||||
CONSTRAINT "family_history_entries_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX IF NOT EXISTS "family_history_entries_user_id_deleted_at_idx" ON "family_history_entries"("user_id", "deleted_at");
|
||||
|
||||
-- AddForeignKey
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "family_history_entries" ADD CONSTRAINT "family_history_entries_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN null;
|
||||
END $$;
|
||||
@@ -721,6 +721,12 @@ model User {
|
||||
// per-day timeline mirroring the CycleDayLog shape.
|
||||
illnessEpisodes IllnessEpisode[]
|
||||
illnessDayLogs IllnessDayLog[]
|
||||
// v1.25 (W-RECORDS) — structured health-record entries. Allergies map to
|
||||
// FHIR AllergyIntolerance; family-history entries map to FHIR
|
||||
// FamilyMemberHistory. Reference records (not time-series signals); the
|
||||
// sensitive free-text fields are encrypted at rest.
|
||||
allergies Allergy[]
|
||||
familyHistory FamilyHistoryEntry[]
|
||||
// v1.19.0 — full Withings ECG waveform recordings (encrypted at rest).
|
||||
// The paired AFib verdict still lands as an IRREGULAR_RHYTHM_NOTIFICATION
|
||||
// Measurement EVENT row (v1.18.11); this holds the raw signal samples.
|
||||
@@ -5191,6 +5197,142 @@ model IllnessSymptomLink {
|
||||
@@map("illness_symptom_links")
|
||||
}
|
||||
|
||||
// ─── Structured health records (v1.25 W-RECORDS) ──────────────────
|
||||
//
|
||||
// Allergies + family history kept as STRUCTURED records (not time-series
|
||||
// signals — they are NOT in the signal registry). Reference data the user
|
||||
// curates once and edits rarely, surfaced in the Settings "Anamnese"
|
||||
// (medical-history) section and exported to FHIR (AllergyIntolerance /
|
||||
// FamilyMemberHistory). Patient-reported throughout — never a clinical
|
||||
// diagnosis the app asserts. Sensitive free-text fields are AES-256-GCM
|
||||
// encrypted at rest (`*Encrypted` Bytes columns, the shared note codec);
|
||||
// the structured enum fields stay queryable plaintext. Soft-deleted.
|
||||
|
||||
/// Broad allergen category for an Allergy (maps to FHIR
|
||||
/// AllergyIntolerance.category — food / medication / environment / biologic).
|
||||
enum AllergyCategory {
|
||||
FOOD
|
||||
MEDICATION
|
||||
ENVIRONMENT
|
||||
BIOLOGIC
|
||||
OTHER
|
||||
|
||||
@@map("allergy_category")
|
||||
}
|
||||
|
||||
/// Distinguishes an immune-mediated allergy from a non-immune intolerance
|
||||
/// (FHIR AllergyIntolerance.type allergy | intolerance).
|
||||
enum AllergyType {
|
||||
ALLERGY
|
||||
INTOLERANCE
|
||||
|
||||
@@map("allergy_type")
|
||||
}
|
||||
|
||||
/// Worst-reaction severity (FHIR AllergyIntolerance.reaction.severity /
|
||||
/// criticality). NULL = not assessed.
|
||||
enum AllergySeverity {
|
||||
MILD
|
||||
MODERATE
|
||||
SEVERE
|
||||
|
||||
@@map("allergy_severity")
|
||||
}
|
||||
|
||||
/// Clinical status of the record (FHIR AllergyIntolerance.clinicalStatus).
|
||||
enum AllergyStatus {
|
||||
ACTIVE
|
||||
INACTIVE
|
||||
RESOLVED
|
||||
|
||||
@@map("allergy_status")
|
||||
}
|
||||
|
||||
/// One self-recorded allergy/intolerance. `substance` is the queryable
|
||||
/// human label (the AllergyIntolerance `code.text` anchor — never a
|
||||
/// machine-guessed SNOMED/RxNorm code). The free-text reaction description
|
||||
/// and notes are encrypted at rest. Patient-reported; the export carries a
|
||||
/// "not a clinical diagnosis" guard rail.
|
||||
model Allergy {
|
||||
id String @id @default(cuid())
|
||||
userId String @map("user_id")
|
||||
/// User-facing substance/allergen name, e.g. "Penicillin", "Peanuts".
|
||||
/// Queryable plaintext label (the medication.name / illness.label
|
||||
/// precedent); rides FHIR `code.text`.
|
||||
substance String
|
||||
category AllergyCategory @default(OTHER)
|
||||
type AllergyType @default(ALLERGY)
|
||||
/// Worst observed reaction severity. NULL = not assessed.
|
||||
severity AllergySeverity?
|
||||
status AllergyStatus @default(ACTIVE)
|
||||
/// When the allergy was first noticed (UTC instant). NULL = unknown.
|
||||
onsetAt DateTime? @map("onset_at")
|
||||
/// AES-256-GCM ciphertext of the free-text reaction/manifestation
|
||||
/// description (e.g. "anaphylaxis", "hives"). Bytes via the shared codec.
|
||||
reactionEncrypted Bytes? @map("reaction_encrypted")
|
||||
/// AES-256-GCM ciphertext of an optional free-text note. Bytes column.
|
||||
notesEncrypted Bytes? @map("notes_encrypted")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
/// Soft-delete tombstone. NULL = live; non-NULL = deleted at the instant.
|
||||
deletedAt DateTime? @map("deleted_at")
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([userId, deletedAt])
|
||||
@@map("allergies")
|
||||
}
|
||||
|
||||
/// Relationship of a family member to the user (maps to FHIR
|
||||
/// FamilyMemberHistory.relationship). Lateralised grandparents let the
|
||||
/// maternal/paternal line be recorded where the user knows it.
|
||||
enum FamilyRelationship {
|
||||
MOTHER
|
||||
FATHER
|
||||
SISTER
|
||||
BROTHER
|
||||
DAUGHTER
|
||||
SON
|
||||
GRANDMOTHER_MATERNAL
|
||||
GRANDFATHER_MATERNAL
|
||||
GRANDMOTHER_PATERNAL
|
||||
GRANDFATHER_PATERNAL
|
||||
AUNT
|
||||
UNCLE
|
||||
COUSIN
|
||||
HALF_SIBLING
|
||||
OTHER
|
||||
|
||||
@@map("family_relationship")
|
||||
}
|
||||
|
||||
/// One condition recorded for one relative (a FHIR FamilyMemberHistory with
|
||||
/// a single condition). `condition` is the queryable human label (the
|
||||
/// FamilyMemberHistory.condition `code.text` anchor — never a machine-guessed
|
||||
/// code); `ageAtOnset` is the relative's age when the condition began. The
|
||||
/// optional free-text note is encrypted at rest. Patient-reported.
|
||||
model FamilyHistoryEntry {
|
||||
id String @id @default(cuid())
|
||||
userId String @map("user_id")
|
||||
relationship FamilyRelationship
|
||||
/// User-facing condition name, e.g. "Type 2 diabetes", "Breast cancer".
|
||||
/// Queryable plaintext label; rides FHIR `condition.code.text`.
|
||||
condition String
|
||||
/// The relative's age (years) when the condition began. NULL = unknown.
|
||||
ageAtOnset Int? @map("age_at_onset")
|
||||
/// AES-256-GCM ciphertext of an optional free-text note. Bytes column.
|
||||
notesEncrypted Bytes? @map("notes_encrypted")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
/// Soft-delete tombstone. NULL = live; non-NULL = deleted at the instant.
|
||||
deletedAt DateTime? @map("deleted_at")
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([userId, deletedAt])
|
||||
@@map("family_history_entries")
|
||||
}
|
||||
|
||||
/// One cached forecast per user, regenerated debounced /
|
||||
/// stale-while-revalidate (the InsightNarrative + v1.8.7 assessment
|
||||
/// cache pattern), NOT per-read. Derived — not soft-deleted.
|
||||
|
||||
@@ -458,6 +458,22 @@ async function main() {
|
||||
prisma.mentalHealthAssessment,
|
||||
),
|
||||
);
|
||||
// ───── v1.25 structured health records (Bytes columns) ─────
|
||||
// Allergy free-text reaction + note; family-history note. Always encrypted
|
||||
// on write (no legacy plaintext column), so every non-null row rotates.
|
||||
results.push(
|
||||
await rotateBytesColumn("Allergy", "reactionEncrypted", prisma.allergy),
|
||||
);
|
||||
results.push(
|
||||
await rotateBytesColumn("Allergy", "notesEncrypted", prisma.allergy),
|
||||
);
|
||||
results.push(
|
||||
await rotateBytesColumn(
|
||||
"FamilyHistoryEntry",
|
||||
"notesEncrypted",
|
||||
prisma.familyHistoryEntry,
|
||||
),
|
||||
);
|
||||
|
||||
console.log("\n=== Rotation summary ===");
|
||||
let totalRotated = 0;
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
/**
|
||||
* `GET /api/allergies/{id}` — one owned, live allergy record.
|
||||
* `PATCH /api/allergies/{id}` — edit it (partial; field-by-field).
|
||||
* `DELETE /api/allergies/{id}` — soft-delete it (idempotent; `{ deleted: true }`).
|
||||
*
|
||||
* Owner-scoped. `userId` is narrowed from auth and fed to the Prisma `where`;
|
||||
* the body never carries it. The free-text `reaction` + `note` are 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 { encryptToBytes } from "@/lib/ai/coach/bytes-codec";
|
||||
import { allergyUpdateSchema } from "@/lib/validations/allergy";
|
||||
import { toAllergyDTO } from "@/lib/records/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 { id } = await params;
|
||||
const row = await prisma.allergy.findUnique({ where: { id } });
|
||||
if (!row || row.userId !== user.id || row.deletedAt !== null) {
|
||||
return apiError("Allergy not found", 404);
|
||||
}
|
||||
|
||||
annotate({
|
||||
action: { name: "allergy.read", entity_type: "allergy", entity_id: id },
|
||||
});
|
||||
|
||||
return apiSuccess(toAllergyDTO(row));
|
||||
},
|
||||
);
|
||||
|
||||
export const PATCH = apiHandler(
|
||||
async (request: NextRequest, { params }: RouteParams) => {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
const { id } = await params;
|
||||
const existing = await prisma.allergy.findUnique({
|
||||
where: { id },
|
||||
select: { id: true, userId: true, deletedAt: true },
|
||||
});
|
||||
if (
|
||||
!existing ||
|
||||
existing.userId !== user.id ||
|
||||
existing.deletedAt !== null
|
||||
) {
|
||||
return apiError("Allergy not found", 404);
|
||||
}
|
||||
|
||||
const { data: rawBody, error: jsonError } = await safeJson(request, {
|
||||
maxBytes: 16 * 1024,
|
||||
});
|
||||
if (jsonError) return jsonError;
|
||||
|
||||
const parsed = allergyUpdateSchema.safeParse(rawBody);
|
||||
if (!parsed.success) {
|
||||
return returnAllZodIssues(parsed.error, 422, {
|
||||
errorCode: "allergy.invalid",
|
||||
});
|
||||
}
|
||||
const entry = parsed.data;
|
||||
|
||||
// Field-by-field — only fields actually present in the body are written.
|
||||
const data: Prisma.AllergyUpdateInput = {};
|
||||
if (entry.substance !== undefined) data.substance = entry.substance;
|
||||
if (entry.category !== undefined) data.category = entry.category;
|
||||
if (entry.type !== undefined) data.type = entry.type;
|
||||
if (entry.severity !== undefined) data.severity = entry.severity;
|
||||
if (entry.status !== undefined) data.status = entry.status;
|
||||
if (entry.onsetAt !== undefined) {
|
||||
data.onsetAt = entry.onsetAt ? new Date(entry.onsetAt) : null;
|
||||
}
|
||||
if (entry.reaction !== undefined) {
|
||||
data.reactionEncrypted = entry.reaction
|
||||
? encryptToBytes(entry.reaction)
|
||||
: null;
|
||||
}
|
||||
if (entry.note !== undefined) {
|
||||
data.notesEncrypted = entry.note ? encryptToBytes(entry.note) : null;
|
||||
}
|
||||
|
||||
const updated = await prisma.allergy.update({ where: { id }, data });
|
||||
|
||||
await auditLog("allergy.update", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(request),
|
||||
details: { allergyId: id },
|
||||
});
|
||||
|
||||
annotate({
|
||||
action: { name: "allergy.update", entity_type: "allergy", entity_id: id },
|
||||
});
|
||||
|
||||
return apiSuccess(toAllergyDTO(updated));
|
||||
},
|
||||
);
|
||||
|
||||
export const DELETE = apiHandler(
|
||||
async (request: NextRequest, { params }: RouteParams) => {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
const { id } = await params;
|
||||
const existing = await prisma.allergy.findUnique({
|
||||
where: { id },
|
||||
select: { id: true, userId: true, deletedAt: true },
|
||||
});
|
||||
if (!existing || existing.userId !== user.id) {
|
||||
return apiError("Allergy not found", 404);
|
||||
}
|
||||
|
||||
if (existing.deletedAt === null) {
|
||||
await prisma.allergy.update({
|
||||
where: { id },
|
||||
data: { deletedAt: new Date() },
|
||||
});
|
||||
await auditLog("allergy.delete", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(request),
|
||||
details: { allergyId: id },
|
||||
});
|
||||
}
|
||||
|
||||
annotate({
|
||||
action: { name: "allergy.delete", entity_type: "allergy", entity_id: id },
|
||||
});
|
||||
|
||||
return apiSuccess({ deleted: true });
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,121 @@
|
||||
/**
|
||||
* `GET /api/allergies` — newest-first list of the account's allergies.
|
||||
* `POST /api/allergies` — create one allergy/intolerance record.
|
||||
*
|
||||
* A structured AllergyIntolerance-style RECORD, patient-reported — never a
|
||||
* clinical diagnosis the app asserts. `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); the free-text `reaction` + `note` are
|
||||
* 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,
|
||||
getClientIp,
|
||||
returnAllZodIssues,
|
||||
safeJson,
|
||||
} from "@/lib/api-response";
|
||||
import { withIdempotency } from "@/lib/idempotency";
|
||||
import { encryptToBytes } from "@/lib/ai/coach/bytes-codec";
|
||||
import {
|
||||
allergyCreateSchema,
|
||||
allergyListQuerySchema,
|
||||
} from "@/lib/validations/allergy";
|
||||
import { toAllergyDTO } from "@/lib/records/dto";
|
||||
|
||||
export const GET = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
const params = new URL(request.url).searchParams;
|
||||
const parsed = allergyListQuerySchema.safeParse({
|
||||
limit: params.get("limit") ?? undefined,
|
||||
includeInactive: params.get("includeInactive") ?? undefined,
|
||||
});
|
||||
if (!parsed.success) {
|
||||
return returnAllZodIssues(parsed.error, 422, {
|
||||
errorCode: "allergy.invalid",
|
||||
});
|
||||
}
|
||||
|
||||
const limit = parsed.data.limit ?? 100;
|
||||
const rows = await prisma.allergy.findMany({
|
||||
where: {
|
||||
userId: user.id,
|
||||
deletedAt: null,
|
||||
...(parsed.data.includeInactive === "false" ? { status: "ACTIVE" } : {}),
|
||||
},
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: limit,
|
||||
});
|
||||
|
||||
annotate({
|
||||
action: { name: "allergy.list", entity_type: "allergy" },
|
||||
meta: { count: rows.length },
|
||||
});
|
||||
|
||||
return apiSuccess(rows.map(toAllergyDTO));
|
||||
});
|
||||
|
||||
// `withIdempotency` lets an iOS retry / double-tap re-send the same
|
||||
// `Idempotency-Key` without minting a duplicate record (the labs /
|
||||
// illness create precedent).
|
||||
export const POST = apiHandler(withIdempotency<[NextRequest]>(postAllergy));
|
||||
|
||||
async function postAllergy(request: NextRequest): Promise<Response> {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
const { data: rawBody, error: jsonError } = await safeJson(request, {
|
||||
maxBytes: 16 * 1024,
|
||||
});
|
||||
if (jsonError) return jsonError;
|
||||
|
||||
const parsed = allergyCreateSchema.safeParse(rawBody);
|
||||
if (!parsed.success) {
|
||||
annotate({
|
||||
action: { name: "allergy.validation-failed" },
|
||||
meta: { issue_count: parsed.error.issues.length },
|
||||
});
|
||||
return returnAllZodIssues(parsed.error, 422, {
|
||||
errorCode: "allergy.invalid",
|
||||
});
|
||||
}
|
||||
|
||||
const entry = parsed.data;
|
||||
|
||||
// Field-by-field — never spread the parsed object whole.
|
||||
const created = await prisma.allergy.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
substance: entry.substance,
|
||||
category: entry.category,
|
||||
type: entry.type,
|
||||
severity: entry.severity ?? null,
|
||||
status: entry.status,
|
||||
onsetAt: entry.onsetAt ? new Date(entry.onsetAt) : null,
|
||||
reactionEncrypted: entry.reaction ? encryptToBytes(entry.reaction) : null,
|
||||
notesEncrypted: entry.note ? encryptToBytes(entry.note) : null,
|
||||
},
|
||||
});
|
||||
|
||||
await auditLog("allergy.create", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(request),
|
||||
details: { allergyId: created.id, category: created.category },
|
||||
});
|
||||
|
||||
annotate({
|
||||
action: {
|
||||
name: "allergy.create",
|
||||
entity_type: "allergy",
|
||||
entity_id: created.id,
|
||||
},
|
||||
meta: { category: created.category, type: created.type },
|
||||
});
|
||||
|
||||
return apiSuccess(toAllergyDTO(created), 201);
|
||||
}
|
||||
@@ -19,6 +19,8 @@ vi.mock("@/lib/db", () => ({
|
||||
moodEntry: { findMany: vi.fn() },
|
||||
labResult: { findMany: vi.fn() },
|
||||
illnessEpisode: { findMany: vi.fn() },
|
||||
allergy: { findMany: vi.fn() },
|
||||
familyHistoryEntry: { findMany: vi.fn() },
|
||||
user: { findUnique: vi.fn(), update: vi.fn() },
|
||||
},
|
||||
}));
|
||||
@@ -100,6 +102,8 @@ beforeEach(() => {
|
||||
vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([] as never);
|
||||
vi.mocked(prisma.labResult.findMany).mockResolvedValue([] as never);
|
||||
vi.mocked(prisma.illnessEpisode.findMany).mockResolvedValue([] as never);
|
||||
vi.mocked(prisma.allergy.findMany).mockResolvedValue([] as never);
|
||||
vi.mocked(prisma.familyHistoryEntry.findMany).mockResolvedValue([] as never);
|
||||
// First findUnique = aggregator profile select; second = route KVNR select.
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
username: "sample",
|
||||
|
||||
@@ -34,6 +34,7 @@ import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { requireModuleEnabled } from "@/lib/modules/gate";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { decrypt } from "@/lib/crypto";
|
||||
import { toAllergyDTO, toFamilyHistoryEntryDTO } from "@/lib/records/dto";
|
||||
import {
|
||||
collectDoctorReportData,
|
||||
normaliseDateRange,
|
||||
@@ -112,14 +113,32 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
selection.germanAtc ??
|
||||
(GERMAN_ATC_DEFAULT_LOCALES as readonly string[]).includes(locale);
|
||||
|
||||
const [data, userTz, userRow] = await Promise.all([
|
||||
collectDoctorReportData(user.id, range, { practiceName, sections }),
|
||||
resolveUserTimezone(user.id),
|
||||
prisma.user.findUnique({
|
||||
where: { id: user.id },
|
||||
select: { insuranceNumberEncrypted: true, insightsCachedText: true },
|
||||
}),
|
||||
]);
|
||||
const [data, userTz, userRow, allergyRows, familyHistoryRows] =
|
||||
await Promise.all([
|
||||
collectDoctorReportData(user.id, range, { practiceName, sections }),
|
||||
resolveUserTimezone(user.id),
|
||||
prisma.user.findUnique({
|
||||
where: { id: user.id },
|
||||
select: { insuranceNumberEncrypted: true, insightsCachedText: true },
|
||||
}),
|
||||
// v1.25 (W-RECORDS) — structured records are always-available reference
|
||||
// data (not time-windowed), so they ride alongside the report rather
|
||||
// than through `collectDoctorReportData`. Owner-scoped, live rows only.
|
||||
prisma.allergy.findMany({
|
||||
where: { userId: user.id, deletedAt: null },
|
||||
orderBy: { createdAt: "desc" },
|
||||
}),
|
||||
prisma.familyHistoryEntry.findMany({
|
||||
where: { userId: user.id, deletedAt: null },
|
||||
orderBy: { createdAt: "desc" },
|
||||
}),
|
||||
]);
|
||||
|
||||
// FHIR records folded into every bundle format (fhir + package).
|
||||
const fhirRecords = {
|
||||
allergies: allergyRows.map(toAllergyDTO),
|
||||
familyHistory: familyHistoryRows.map(toFamilyHistoryEntryDTO),
|
||||
};
|
||||
|
||||
// Decrypt the KVNR fail-soft: a key-rotation gap on one row should
|
||||
// never 500 the export — the cover/identifier just omits the value.
|
||||
@@ -163,6 +182,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
{ insuranceNumber },
|
||||
undefined,
|
||||
{ germanAtc },
|
||||
fhirRecords,
|
||||
);
|
||||
const json = JSON.stringify(bundle);
|
||||
annotate({
|
||||
@@ -205,9 +225,13 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
}
|
||||
|
||||
// format === "package": one zip holding the PDF + FHIR Bundle + a README.
|
||||
const bundle = buildFhirDocumentBundle(data, { insuranceNumber }, undefined, {
|
||||
germanAtc,
|
||||
});
|
||||
const bundle = buildFhirDocumentBundle(
|
||||
data,
|
||||
{ insuranceNumber },
|
||||
undefined,
|
||||
{ germanAtc },
|
||||
fhirRecords,
|
||||
);
|
||||
const readme = t("doctorReport.packageReadme");
|
||||
const zipped = zipSync(
|
||||
{
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
/**
|
||||
* `GET /api/family-history/{id}` — one owned, live entry.
|
||||
* `PATCH /api/family-history/{id}` — edit it (partial; field-by-field).
|
||||
* `DELETE /api/family-history/{id}` — soft-delete it (idempotent;
|
||||
* returns `{ deleted: true }`).
|
||||
*
|
||||
* Owner-scoped. `userId` is narrowed from auth and fed to the Prisma `where`;
|
||||
* the body never carries it. The free-text `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 { encryptToBytes } from "@/lib/ai/coach/bytes-codec";
|
||||
import { familyHistoryUpdateSchema } from "@/lib/validations/family-history";
|
||||
import { toFamilyHistoryEntryDTO } from "@/lib/records/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 { id } = await params;
|
||||
const row = await prisma.familyHistoryEntry.findUnique({ where: { id } });
|
||||
if (!row || row.userId !== user.id || row.deletedAt !== null) {
|
||||
return apiError("Family history entry not found", 404);
|
||||
}
|
||||
|
||||
annotate({
|
||||
action: {
|
||||
name: "family-history.read",
|
||||
entity_type: "family_history",
|
||||
entity_id: id,
|
||||
},
|
||||
});
|
||||
|
||||
return apiSuccess(toFamilyHistoryEntryDTO(row));
|
||||
},
|
||||
);
|
||||
|
||||
export const PATCH = apiHandler(
|
||||
async (request: NextRequest, { params }: RouteParams) => {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
const { id } = await params;
|
||||
const existing = await prisma.familyHistoryEntry.findUnique({
|
||||
where: { id },
|
||||
select: { id: true, userId: true, deletedAt: true },
|
||||
});
|
||||
if (
|
||||
!existing ||
|
||||
existing.userId !== user.id ||
|
||||
existing.deletedAt !== null
|
||||
) {
|
||||
return apiError("Family history entry not found", 404);
|
||||
}
|
||||
|
||||
const { data: rawBody, error: jsonError } = await safeJson(request, {
|
||||
maxBytes: 16 * 1024,
|
||||
});
|
||||
if (jsonError) return jsonError;
|
||||
|
||||
const parsed = familyHistoryUpdateSchema.safeParse(rawBody);
|
||||
if (!parsed.success) {
|
||||
return returnAllZodIssues(parsed.error, 422, {
|
||||
errorCode: "familyHistory.invalid",
|
||||
});
|
||||
}
|
||||
const entry = parsed.data;
|
||||
|
||||
// Field-by-field — only fields actually present in the body are written.
|
||||
const data: Prisma.FamilyHistoryEntryUpdateInput = {};
|
||||
if (entry.relationship !== undefined) {
|
||||
data.relationship = entry.relationship;
|
||||
}
|
||||
if (entry.condition !== undefined) data.condition = entry.condition;
|
||||
if (entry.ageAtOnset !== undefined) data.ageAtOnset = entry.ageAtOnset;
|
||||
if (entry.note !== undefined) {
|
||||
data.notesEncrypted = entry.note ? encryptToBytes(entry.note) : null;
|
||||
}
|
||||
|
||||
const updated = await prisma.familyHistoryEntry.update({
|
||||
where: { id },
|
||||
data,
|
||||
});
|
||||
|
||||
await auditLog("family-history.update", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(request),
|
||||
details: { entryId: id },
|
||||
});
|
||||
|
||||
annotate({
|
||||
action: {
|
||||
name: "family-history.update",
|
||||
entity_type: "family_history",
|
||||
entity_id: id,
|
||||
},
|
||||
});
|
||||
|
||||
return apiSuccess(toFamilyHistoryEntryDTO(updated));
|
||||
},
|
||||
);
|
||||
|
||||
export const DELETE = apiHandler(
|
||||
async (request: NextRequest, { params }: RouteParams) => {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
const { id } = await params;
|
||||
const existing = await prisma.familyHistoryEntry.findUnique({
|
||||
where: { id },
|
||||
select: { id: true, userId: true, deletedAt: true },
|
||||
});
|
||||
if (!existing || existing.userId !== user.id) {
|
||||
return apiError("Family history entry not found", 404);
|
||||
}
|
||||
|
||||
if (existing.deletedAt === null) {
|
||||
await prisma.familyHistoryEntry.update({
|
||||
where: { id },
|
||||
data: { deletedAt: new Date() },
|
||||
});
|
||||
await auditLog("family-history.delete", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(request),
|
||||
details: { entryId: id },
|
||||
});
|
||||
}
|
||||
|
||||
annotate({
|
||||
action: {
|
||||
name: "family-history.delete",
|
||||
entity_type: "family_history",
|
||||
entity_id: id,
|
||||
},
|
||||
});
|
||||
|
||||
return apiSuccess({ deleted: true });
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,112 @@
|
||||
/**
|
||||
* `GET /api/family-history` — newest-first list of the account's family-history
|
||||
* entries.
|
||||
* `POST /api/family-history` — create one condition-by-relative record.
|
||||
*
|
||||
* A structured FamilyMemberHistory-style RECORD, patient-reported — never a
|
||||
* clinical diagnosis the app asserts. `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); the free-text `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,
|
||||
getClientIp,
|
||||
returnAllZodIssues,
|
||||
safeJson,
|
||||
} from "@/lib/api-response";
|
||||
import { withIdempotency } from "@/lib/idempotency";
|
||||
import { encryptToBytes } from "@/lib/ai/coach/bytes-codec";
|
||||
import {
|
||||
familyHistoryCreateSchema,
|
||||
familyHistoryListQuerySchema,
|
||||
} from "@/lib/validations/family-history";
|
||||
import { toFamilyHistoryEntryDTO } from "@/lib/records/dto";
|
||||
|
||||
export const GET = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
const params = new URL(request.url).searchParams;
|
||||
const parsed = familyHistoryListQuerySchema.safeParse({
|
||||
limit: params.get("limit") ?? undefined,
|
||||
});
|
||||
if (!parsed.success) {
|
||||
return returnAllZodIssues(parsed.error, 422, {
|
||||
errorCode: "familyHistory.invalid",
|
||||
});
|
||||
}
|
||||
|
||||
const limit = parsed.data.limit ?? 100;
|
||||
const rows = await prisma.familyHistoryEntry.findMany({
|
||||
where: { userId: user.id, deletedAt: null },
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: limit,
|
||||
});
|
||||
|
||||
annotate({
|
||||
action: { name: "family-history.list", entity_type: "family_history" },
|
||||
meta: { count: rows.length },
|
||||
});
|
||||
|
||||
return apiSuccess(rows.map(toFamilyHistoryEntryDTO));
|
||||
});
|
||||
|
||||
export const POST = apiHandler(
|
||||
withIdempotency<[NextRequest]>(postFamilyHistory),
|
||||
);
|
||||
|
||||
async function postFamilyHistory(request: NextRequest): Promise<Response> {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
const { data: rawBody, error: jsonError } = await safeJson(request, {
|
||||
maxBytes: 16 * 1024,
|
||||
});
|
||||
if (jsonError) return jsonError;
|
||||
|
||||
const parsed = familyHistoryCreateSchema.safeParse(rawBody);
|
||||
if (!parsed.success) {
|
||||
annotate({
|
||||
action: { name: "family-history.validation-failed" },
|
||||
meta: { issue_count: parsed.error.issues.length },
|
||||
});
|
||||
return returnAllZodIssues(parsed.error, 422, {
|
||||
errorCode: "familyHistory.invalid",
|
||||
});
|
||||
}
|
||||
|
||||
const entry = parsed.data;
|
||||
|
||||
// Field-by-field — never spread the parsed object whole.
|
||||
const created = await prisma.familyHistoryEntry.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
relationship: entry.relationship,
|
||||
condition: entry.condition,
|
||||
ageAtOnset: entry.ageAtOnset ?? null,
|
||||
notesEncrypted: entry.note ? encryptToBytes(entry.note) : null,
|
||||
},
|
||||
});
|
||||
|
||||
await auditLog("family-history.create", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(request),
|
||||
details: { entryId: created.id, relationship: created.relationship },
|
||||
});
|
||||
|
||||
annotate({
|
||||
action: {
|
||||
name: "family-history.create",
|
||||
entity_type: "family_history",
|
||||
entity_id: created.id,
|
||||
},
|
||||
meta: { relationship: created.relationship },
|
||||
});
|
||||
|
||||
return apiSuccess(toFamilyHistoryEntryDTO(created), 201);
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { notFound } from "next/navigation";
|
||||
import type { JSX } from "react";
|
||||
|
||||
import { AccountSection } from "@/components/settings/account-section";
|
||||
import { AnamnesisSection } from "@/components/settings/anamnesis-section";
|
||||
import { SecuritySection } from "@/components/settings/security-section";
|
||||
import { AboutSection } from "@/components/settings/about-section";
|
||||
import { AdvancedSection } from "@/components/settings/advanced-section";
|
||||
@@ -74,6 +75,7 @@ const SECTION_COMPONENTS: Record<
|
||||
mood: MoodSection,
|
||||
labs: LabsSection,
|
||||
illness: IllnessSection,
|
||||
anamnesis: AnamnesisSection,
|
||||
vorsorge: VorsorgeSection,
|
||||
thresholds: ThresholdsSection,
|
||||
api: ApiSection,
|
||||
|
||||
@@ -0,0 +1,282 @@
|
||||
"use client";
|
||||
|
||||
import { useId, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { ApiError, apiPost, apiPut } from "@/lib/api/api-fetch";
|
||||
import {
|
||||
allergyCategoryEnum,
|
||||
allergySeverityEnum,
|
||||
allergyStatusEnum,
|
||||
allergyTypeEnum,
|
||||
} from "@/lib/validations/allergy";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
|
||||
import type { AllergyDTO } from "@/lib/records/dto";
|
||||
|
||||
const TEXT_MAX_LENGTH = 2000;
|
||||
/** Select sentinel for the optional "not assessed" severity. */
|
||||
const SEVERITY_NONE = "NONE";
|
||||
|
||||
interface AllergyFormProps {
|
||||
/** When set the form edits this record; otherwise it creates a new one. */
|
||||
existing?: AllergyDTO;
|
||||
onSuccess?: (saved: AllergyDTO) => void;
|
||||
onCancel?: () => void;
|
||||
/** The sheet footer slot the Cancel / Save row portals into when present. */
|
||||
footerSlot?: HTMLElement | null;
|
||||
}
|
||||
|
||||
/** ISO date input (YYYY-MM-DD) → start-of-day UTC instant, or undefined. */
|
||||
function toInstant(value: string): string | undefined {
|
||||
if (!value) return undefined;
|
||||
const d = new Date(`${value}T00:00:00.000Z`);
|
||||
return Number.isNaN(d.getTime()) ? undefined : d.toISOString();
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.25 (W-RECORDS) — create or edit a structured allergy/intolerance record.
|
||||
*
|
||||
* `substance` is the queryable label; the free-text reaction + note are
|
||||
* encrypted server-side. Patient-reported reference data, not a clinical
|
||||
* diagnosis.
|
||||
*/
|
||||
export function AllergyForm({
|
||||
existing,
|
||||
onSuccess,
|
||||
onCancel,
|
||||
footerSlot,
|
||||
}: AllergyFormProps) {
|
||||
const { t } = useTranslations();
|
||||
const formId = useId();
|
||||
|
||||
const [substance, setSubstance] = useState(existing?.substance ?? "");
|
||||
const [category, setCategory] = useState(existing?.category ?? "OTHER");
|
||||
const [type, setType] = useState(existing?.type ?? "ALLERGY");
|
||||
const [severity, setSeverity] = useState(existing?.severity ?? SEVERITY_NONE);
|
||||
const [status, setStatus] = useState(existing?.status ?? "ACTIVE");
|
||||
const [onsetDate, setOnsetDate] = useState(
|
||||
existing?.onsetAt ? existing.onsetAt.slice(0, 10) : "",
|
||||
);
|
||||
const [reaction, setReaction] = useState(existing?.reaction ?? "");
|
||||
const [note, setNote] = useState(existing?.note ?? "");
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
|
||||
if (!substance.trim()) {
|
||||
setError(t("records.allergies.form.requiredError"));
|
||||
return;
|
||||
}
|
||||
|
||||
const body = {
|
||||
substance: substance.trim(),
|
||||
category,
|
||||
type,
|
||||
severity: severity === SEVERITY_NONE ? null : severity,
|
||||
status,
|
||||
onsetAt: onsetDate ? (toInstant(onsetDate) ?? null) : null,
|
||||
reaction: reaction.trim() ? reaction.trim() : null,
|
||||
note: note.trim() ? note.trim() : null,
|
||||
};
|
||||
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const saved = existing
|
||||
? await apiPut<AllergyDTO>(`/api/allergies/${existing.id}`, body)
|
||||
: await apiPost<AllergyDTO>("/api/allergies", body);
|
||||
toast.success(
|
||||
existing
|
||||
? t("records.allergies.form.updatedToast")
|
||||
: t("records.allergies.form.savedToast"),
|
||||
);
|
||||
onSuccess?.(saved);
|
||||
} catch (err) {
|
||||
const message =
|
||||
err instanceof ApiError
|
||||
? err.message
|
||||
: t("records.allergies.form.saveError");
|
||||
setError(message);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
const footerNode = (
|
||||
<>
|
||||
{onCancel ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={onCancel}
|
||||
disabled={submitting}
|
||||
>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
) : null}
|
||||
<Button type="submit" form={formId} disabled={submitting}>
|
||||
{submitting ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
|
||||
) : null}
|
||||
{t("common.save")}
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<form id={formId} onSubmit={handleSubmit} className="space-y-5">
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-1.5 sm:col-span-2">
|
||||
<Label htmlFor="allergy-substance">
|
||||
{t("records.allergies.form.substance")}
|
||||
</Label>
|
||||
<Input
|
||||
id="allergy-substance"
|
||||
value={substance}
|
||||
onChange={(e) => setSubstance(e.target.value)}
|
||||
placeholder={t("records.allergies.form.substancePlaceholder")}
|
||||
maxLength={160}
|
||||
autoFocus
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="allergy-category">
|
||||
{t("records.allergies.form.category")}
|
||||
</Label>
|
||||
<Select value={category} onValueChange={setCategory}>
|
||||
<SelectTrigger id="allergy-category" className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{allergyCategoryEnum.options.map((opt) => (
|
||||
<SelectItem key={opt} value={opt}>
|
||||
{t(`records.allergies.category.${opt}`)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="allergy-type">
|
||||
{t("records.allergies.form.type")}
|
||||
</Label>
|
||||
<Select value={type} onValueChange={setType}>
|
||||
<SelectTrigger id="allergy-type" className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{allergyTypeEnum.options.map((opt) => (
|
||||
<SelectItem key={opt} value={opt}>
|
||||
{t(`records.allergies.type.${opt}`)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="allergy-severity">
|
||||
{t("records.allergies.form.severity")}
|
||||
</Label>
|
||||
<Select value={severity} onValueChange={setSeverity}>
|
||||
<SelectTrigger id="allergy-severity" className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={SEVERITY_NONE}>
|
||||
{t("records.allergies.severity.NONE")}
|
||||
</SelectItem>
|
||||
{allergySeverityEnum.options.map((opt) => (
|
||||
<SelectItem key={opt} value={opt}>
|
||||
{t(`records.allergies.severity.${opt}`)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="allergy-status">
|
||||
{t("records.allergies.form.status")}
|
||||
</Label>
|
||||
<Select value={status} onValueChange={setStatus}>
|
||||
<SelectTrigger id="allergy-status" className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{allergyStatusEnum.options.map((opt) => (
|
||||
<SelectItem key={opt} value={opt}>
|
||||
{t(`records.allergies.status.${opt}`)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1.5 sm:col-span-2">
|
||||
<Label htmlFor="allergy-onset">
|
||||
{t("records.allergies.form.onset")}
|
||||
</Label>
|
||||
<Input
|
||||
id="allergy-onset"
|
||||
type="date"
|
||||
value={onsetDate}
|
||||
onChange={(e) => setOnsetDate(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5 sm:col-span-2">
|
||||
<Label htmlFor="allergy-reaction">
|
||||
{t("records.allergies.form.reaction")}
|
||||
</Label>
|
||||
<Textarea
|
||||
id="allergy-reaction"
|
||||
value={reaction}
|
||||
onChange={(e) => setReaction(e.target.value)}
|
||||
placeholder={t("records.allergies.form.reactionPlaceholder")}
|
||||
maxLength={TEXT_MAX_LENGTH}
|
||||
rows={2}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5 sm:col-span-2">
|
||||
<Label htmlFor="allergy-note">
|
||||
{t("records.allergies.form.note")}
|
||||
</Label>
|
||||
<Textarea
|
||||
id="allergy-note"
|
||||
value={note}
|
||||
onChange={(e) => setNote(e.target.value)}
|
||||
placeholder={t("records.allergies.form.notePlaceholder")}
|
||||
maxLength={TEXT_MAX_LENGTH}
|
||||
rows={2}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error ? (
|
||||
<p className="text-destructive text-sm" role="alert">
|
||||
{error}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{footerSlot ? (
|
||||
createPortal(footerNode, footerSlot)
|
||||
) : (
|
||||
<div className="flex justify-end gap-2">{footerNode}</div>
|
||||
)}
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Pencil, Plus, ShieldAlert } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { DeleteButton } from "@/components/data-list";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { EmptyState } from "@/components/ui/empty-state";
|
||||
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { apiDelete, apiGet } from "@/lib/api/api-fetch";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
|
||||
import { AllergyForm } from "./allergy-form";
|
||||
import type { AllergyDTO } from "@/lib/records/dto";
|
||||
|
||||
/**
|
||||
* v1.25 (W-RECORDS) — the structured allergy/intolerance manager. Renders in
|
||||
* the Settings → Anamnese section: a list of records with add / edit / delete.
|
||||
* Patient-reported reference data, not a clinical diagnosis.
|
||||
*/
|
||||
export function AllergyManager() {
|
||||
const { t } = useTranslations();
|
||||
const queryClient = useQueryClient();
|
||||
const [formOpen, setFormOpen] = useState(false);
|
||||
const [editing, setEditing] = useState<AllergyDTO | null>(null);
|
||||
const [formFooterEl, setFormFooterEl] = useState<HTMLDivElement | null>(null);
|
||||
|
||||
const { data, isLoading, isError } = useQuery({
|
||||
queryKey: queryKeys.allergyList(true),
|
||||
queryFn: () => apiGet<AllergyDTO[]>("/api/allergies"),
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (id: string) => apiDelete(`/api/allergies/${id}`),
|
||||
onSuccess: () => {
|
||||
toast.success(t("records.allergies.deletedToast"));
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.allergies() });
|
||||
},
|
||||
onError: () => toast.error(t("records.allergies.deleteError")),
|
||||
});
|
||||
|
||||
function openNew() {
|
||||
setEditing(null);
|
||||
setFormOpen(true);
|
||||
}
|
||||
|
||||
function openEdit(row: AllergyDTO) {
|
||||
setEditing(row);
|
||||
setFormOpen(true);
|
||||
}
|
||||
|
||||
function afterSave() {
|
||||
setFormOpen(false);
|
||||
setEditing(null);
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.allergies() });
|
||||
}
|
||||
|
||||
const rows = data ?? [];
|
||||
|
||||
function renderRow(row: AllergyDTO) {
|
||||
const detail = [
|
||||
t(`records.allergies.category.${row.category}`),
|
||||
row.severity ? t(`records.allergies.severity.${row.severity}`) : null,
|
||||
row.reaction,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" · ");
|
||||
return (
|
||||
<li
|
||||
key={row.id}
|
||||
className="border-border bg-background/30 flex min-h-12 items-center gap-2 rounded-md border px-3 py-2"
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-baseline gap-x-2">
|
||||
<span className="truncate text-sm font-medium">
|
||||
{row.substance}
|
||||
</span>
|
||||
{row.type === "INTOLERANCE" ? (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{t("records.allergies.type.INTOLERANCE")}
|
||||
</Badge>
|
||||
) : null}
|
||||
{row.status !== "ACTIVE" ? (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{t(`records.allergies.status.${row.status}`)}
|
||||
</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
{detail ? (
|
||||
<p className="text-muted-foreground truncate text-xs">{detail}</p>
|
||||
) : null}
|
||||
</div>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="size-11 sm:size-9"
|
||||
onClick={() => openEdit(row)}
|
||||
aria-label={t("common.edit")}
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
<DeleteButton
|
||||
onConfirm={() => deleteMutation.mutate(row.id)}
|
||||
title={t("records.allergies.deleteConfirmTitle")}
|
||||
description={t("records.allergies.deleteConfirmDescription")}
|
||||
confirmLabel={t("common.delete")}
|
||||
className="size-11 sm:size-9"
|
||||
iconClassName="h-4 w-4"
|
||||
/>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("records.allergies.description")}
|
||||
</p>
|
||||
<Button size="sm" onClick={openNew} className="shrink-0">
|
||||
<Plus className="h-4 w-4" />
|
||||
{t("records.allergies.add")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="space-y-2">
|
||||
{Array.from({ length: 2 }, (_, i) => (
|
||||
<Skeleton key={i} className="h-12 w-full" />
|
||||
))}
|
||||
</div>
|
||||
) : isError ? (
|
||||
<p className="text-destructive py-6 text-center text-sm">
|
||||
{t("records.allergies.loadError")}
|
||||
</p>
|
||||
) : rows.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={<ShieldAlert className="size-6" />}
|
||||
title={t("records.allergies.emptyTitle")}
|
||||
description={t("records.allergies.emptyDescription")}
|
||||
action={
|
||||
<Button onClick={openNew}>{t("records.allergies.addFirst")}</Button>
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<ul className="space-y-2">{rows.map(renderRow)}</ul>
|
||||
)}
|
||||
|
||||
<ResponsiveSheet
|
||||
open={formOpen}
|
||||
onOpenChange={setFormOpen}
|
||||
title={
|
||||
editing
|
||||
? t("records.allergies.editTitle")
|
||||
: t("records.allergies.addTitle")
|
||||
}
|
||||
description={t("records.allergies.formDescription")}
|
||||
footer={
|
||||
<div
|
||||
ref={setFormFooterEl}
|
||||
className="flex w-full justify-end gap-2"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<AllergyForm
|
||||
existing={editing ?? undefined}
|
||||
footerSlot={formFooterEl}
|
||||
onSuccess={afterSave}
|
||||
onCancel={() => setFormOpen(false)}
|
||||
/>
|
||||
</ResponsiveSheet>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
"use client";
|
||||
|
||||
import { useId, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { ApiError, apiPost, apiPut } from "@/lib/api/api-fetch";
|
||||
import { familyRelationshipEnum } from "@/lib/validations/family-history";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
|
||||
import type { FamilyHistoryEntryDTO } from "@/lib/records/dto";
|
||||
|
||||
const TEXT_MAX_LENGTH = 2000;
|
||||
|
||||
interface FamilyHistoryFormProps {
|
||||
/** When set the form edits this entry; otherwise it creates a new one. */
|
||||
existing?: FamilyHistoryEntryDTO;
|
||||
onSuccess?: (saved: FamilyHistoryEntryDTO) => void;
|
||||
onCancel?: () => void;
|
||||
/** The sheet footer slot the Cancel / Save row portals into when present. */
|
||||
footerSlot?: HTMLElement | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.25 (W-RECORDS) — create or edit a structured family-history entry (one
|
||||
* condition for one relative). `condition` is the queryable label; the
|
||||
* free-text note is encrypted server-side. Patient-reported.
|
||||
*/
|
||||
export function FamilyHistoryForm({
|
||||
existing,
|
||||
onSuccess,
|
||||
onCancel,
|
||||
footerSlot,
|
||||
}: FamilyHistoryFormProps) {
|
||||
const { t } = useTranslations();
|
||||
const formId = useId();
|
||||
|
||||
const [relationship, setRelationship] = useState(
|
||||
existing?.relationship ?? "MOTHER",
|
||||
);
|
||||
const [condition, setCondition] = useState(existing?.condition ?? "");
|
||||
const [ageAtOnset, setAgeAtOnset] = useState(
|
||||
existing?.ageAtOnset != null ? String(existing.ageAtOnset) : "",
|
||||
);
|
||||
const [note, setNote] = useState(existing?.note ?? "");
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
|
||||
if (!condition.trim()) {
|
||||
setError(t("records.family.form.requiredError"));
|
||||
return;
|
||||
}
|
||||
|
||||
let age: number | null = null;
|
||||
if (ageAtOnset.trim() !== "") {
|
||||
const parsed = Number(ageAtOnset.trim());
|
||||
if (!Number.isInteger(parsed) || parsed < 0 || parsed > 120) {
|
||||
setError(t("records.family.form.ageError"));
|
||||
return;
|
||||
}
|
||||
age = parsed;
|
||||
}
|
||||
|
||||
const body = {
|
||||
relationship,
|
||||
condition: condition.trim(),
|
||||
ageAtOnset: age,
|
||||
note: note.trim() ? note.trim() : null,
|
||||
};
|
||||
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const saved = existing
|
||||
? await apiPut<FamilyHistoryEntryDTO>(
|
||||
`/api/family-history/${existing.id}`,
|
||||
body,
|
||||
)
|
||||
: await apiPost<FamilyHistoryEntryDTO>("/api/family-history", body);
|
||||
toast.success(
|
||||
existing
|
||||
? t("records.family.form.updatedToast")
|
||||
: t("records.family.form.savedToast"),
|
||||
);
|
||||
onSuccess?.(saved);
|
||||
} catch (err) {
|
||||
const message =
|
||||
err instanceof ApiError
|
||||
? err.message
|
||||
: t("records.family.form.saveError");
|
||||
setError(message);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
const footerNode = (
|
||||
<>
|
||||
{onCancel ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={onCancel}
|
||||
disabled={submitting}
|
||||
>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
) : null}
|
||||
<Button type="submit" form={formId} disabled={submitting}>
|
||||
{submitting ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
|
||||
) : null}
|
||||
{t("common.save")}
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<form id={formId} onSubmit={handleSubmit} className="space-y-5">
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="family-relationship">
|
||||
{t("records.family.form.relationship")}
|
||||
</Label>
|
||||
<Select value={relationship} onValueChange={setRelationship}>
|
||||
<SelectTrigger id="family-relationship" className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{familyRelationshipEnum.options.map((opt) => (
|
||||
<SelectItem key={opt} value={opt}>
|
||||
{t(`records.family.relationship.${opt}`)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="family-age">
|
||||
{t("records.family.form.ageAtOnset")}
|
||||
</Label>
|
||||
<Input
|
||||
id="family-age"
|
||||
inputMode="numeric"
|
||||
value={ageAtOnset}
|
||||
onChange={(e) => setAgeAtOnset(e.target.value)}
|
||||
placeholder={t("records.family.form.ageAtOnsetPlaceholder")}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5 sm:col-span-2">
|
||||
<Label htmlFor="family-condition">
|
||||
{t("records.family.form.condition")}
|
||||
</Label>
|
||||
<Input
|
||||
id="family-condition"
|
||||
value={condition}
|
||||
onChange={(e) => setCondition(e.target.value)}
|
||||
placeholder={t("records.family.form.conditionPlaceholder")}
|
||||
maxLength={160}
|
||||
autoFocus
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5 sm:col-span-2">
|
||||
<Label htmlFor="family-note">{t("records.family.form.note")}</Label>
|
||||
<Textarea
|
||||
id="family-note"
|
||||
value={note}
|
||||
onChange={(e) => setNote(e.target.value)}
|
||||
placeholder={t("records.family.form.notePlaceholder")}
|
||||
maxLength={TEXT_MAX_LENGTH}
|
||||
rows={2}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error ? (
|
||||
<p className="text-destructive text-sm" role="alert">
|
||||
{error}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{footerSlot ? (
|
||||
createPortal(footerNode, footerSlot)
|
||||
) : (
|
||||
<div className="flex justify-end gap-2">{footerNode}</div>
|
||||
)}
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Pencil, Plus, Users } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { DeleteButton } from "@/components/data-list";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { EmptyState } from "@/components/ui/empty-state";
|
||||
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { apiDelete, apiGet } from "@/lib/api/api-fetch";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
|
||||
import { FamilyHistoryForm } from "./family-history-form";
|
||||
import type { FamilyHistoryEntryDTO } from "@/lib/records/dto";
|
||||
|
||||
/**
|
||||
* v1.25 (W-RECORDS) — the structured family-history manager. Renders in the
|
||||
* Settings → Anamnese section: a list of condition-by-relative records with
|
||||
* add / edit / delete. Patient-reported reference data, not a clinical
|
||||
* diagnosis.
|
||||
*/
|
||||
export function FamilyHistoryManager() {
|
||||
const { t } = useTranslations();
|
||||
const queryClient = useQueryClient();
|
||||
const [formOpen, setFormOpen] = useState(false);
|
||||
const [editing, setEditing] = useState<FamilyHistoryEntryDTO | null>(null);
|
||||
const [formFooterEl, setFormFooterEl] = useState<HTMLDivElement | null>(null);
|
||||
|
||||
const { data, isLoading, isError } = useQuery({
|
||||
queryKey: queryKeys.familyHistoryList(),
|
||||
queryFn: () => apiGet<FamilyHistoryEntryDTO[]>("/api/family-history"),
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (id: string) => apiDelete(`/api/family-history/${id}`),
|
||||
onSuccess: () => {
|
||||
toast.success(t("records.family.deletedToast"));
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.familyHistory() });
|
||||
},
|
||||
onError: () => toast.error(t("records.family.deleteError")),
|
||||
});
|
||||
|
||||
function openNew() {
|
||||
setEditing(null);
|
||||
setFormOpen(true);
|
||||
}
|
||||
|
||||
function openEdit(row: FamilyHistoryEntryDTO) {
|
||||
setEditing(row);
|
||||
setFormOpen(true);
|
||||
}
|
||||
|
||||
function afterSave() {
|
||||
setFormOpen(false);
|
||||
setEditing(null);
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.familyHistory() });
|
||||
}
|
||||
|
||||
const rows = data ?? [];
|
||||
|
||||
function renderRow(row: FamilyHistoryEntryDTO) {
|
||||
const detail = [
|
||||
t(`records.family.relationship.${row.relationship}`),
|
||||
row.ageAtOnset != null
|
||||
? t("records.family.ageAtOnsetShort", { age: row.ageAtOnset })
|
||||
: null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" · ");
|
||||
return (
|
||||
<li
|
||||
key={row.id}
|
||||
className="border-border bg-background/30 flex min-h-12 items-center gap-2 rounded-md border px-3 py-2"
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<span className="truncate text-sm font-medium">{row.condition}</span>
|
||||
<p className="text-muted-foreground truncate text-xs">{detail}</p>
|
||||
</div>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="size-11 sm:size-9"
|
||||
onClick={() => openEdit(row)}
|
||||
aria-label={t("common.edit")}
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
<DeleteButton
|
||||
onConfirm={() => deleteMutation.mutate(row.id)}
|
||||
title={t("records.family.deleteConfirmTitle")}
|
||||
description={t("records.family.deleteConfirmDescription")}
|
||||
confirmLabel={t("common.delete")}
|
||||
className="size-11 sm:size-9"
|
||||
iconClassName="h-4 w-4"
|
||||
/>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("records.family.description")}
|
||||
</p>
|
||||
<Button size="sm" onClick={openNew} className="shrink-0">
|
||||
<Plus className="h-4 w-4" />
|
||||
{t("records.family.add")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="space-y-2">
|
||||
{Array.from({ length: 2 }, (_, i) => (
|
||||
<Skeleton key={i} className="h-12 w-full" />
|
||||
))}
|
||||
</div>
|
||||
) : isError ? (
|
||||
<p className="text-destructive py-6 text-center text-sm">
|
||||
{t("records.family.loadError")}
|
||||
</p>
|
||||
) : rows.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={<Users className="size-6" />}
|
||||
title={t("records.family.emptyTitle")}
|
||||
description={t("records.family.emptyDescription")}
|
||||
action={
|
||||
<Button onClick={openNew}>{t("records.family.addFirst")}</Button>
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<ul className="space-y-2">{rows.map(renderRow)}</ul>
|
||||
)}
|
||||
|
||||
<ResponsiveSheet
|
||||
open={formOpen}
|
||||
onOpenChange={setFormOpen}
|
||||
title={
|
||||
editing ? t("records.family.editTitle") : t("records.family.addTitle")
|
||||
}
|
||||
description={t("records.family.formDescription")}
|
||||
footer={
|
||||
<div
|
||||
ref={setFormFooterEl}
|
||||
className="flex w-full justify-end gap-2"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<FamilyHistoryForm
|
||||
existing={editing ?? undefined}
|
||||
footerSlot={formFooterEl}
|
||||
onSuccess={afterSave}
|
||||
onCancel={() => setFormOpen(false)}
|
||||
/>
|
||||
</ResponsiveSheet>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -112,6 +112,8 @@ describe("SETTINGS_SECTION_SLUGS", () => {
|
||||
"mood",
|
||||
"labs",
|
||||
"illness",
|
||||
// v1.25 (W-RECORDS) — Anamnese (allergies + family history), always shown.
|
||||
"anamnesis",
|
||||
"vorsorge",
|
||||
"thresholds",
|
||||
"ai",
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* v1.25 (W-RECORDS) — Settings → Anamnese (medical history).
|
||||
*
|
||||
* The home for the structured health records: allergies / intolerances and
|
||||
* family history. Each is a self-contained CRUD manager rendered in its own
|
||||
* card. These are patient-reported reference records — not a time-series
|
||||
* signal and not a clinical diagnosis — surfaced alongside the existing
|
||||
* tracking-domain settings sections (Labs / Illness / Vorsorge).
|
||||
*/
|
||||
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
|
||||
import { AllergyManager } from "@/components/records/allergy-manager";
|
||||
import { FamilyHistoryManager } from "@/components/records/family-history-manager";
|
||||
|
||||
export function AnamnesisSection() {
|
||||
const { t } = useTranslations();
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t("records.allergies.cardTitle")}</CardTitle>
|
||||
<CardDescription>
|
||||
{t("records.allergies.cardDescription")}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<AllergyManager />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t("records.family.cardTitle")}</CardTitle>
|
||||
<CardDescription>
|
||||
{t("records.family.cardDescription")}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<FamilyHistoryManager />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<p className="text-muted-foreground text-xs">{t("records.disclaimer")}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
Bot,
|
||||
CalendarCheck,
|
||||
Download,
|
||||
ClipboardList,
|
||||
FileHeart,
|
||||
FlaskConical,
|
||||
Info,
|
||||
@@ -59,6 +60,7 @@ const SLUG_ICON: Record<SettingsSectionSlug, LucideIcon> = {
|
||||
mood: Smile,
|
||||
labs: FlaskConical,
|
||||
illness: Thermometer,
|
||||
anamnesis: ClipboardList,
|
||||
vorsorge: CalendarCheck,
|
||||
thresholds: SlidersHorizontal,
|
||||
ai: Sparkles,
|
||||
|
||||
@@ -69,6 +69,10 @@ export const SETTINGS_SECTION_SLUGS = [
|
||||
"mood",
|
||||
"labs",
|
||||
"illness",
|
||||
// v1.25 (W-RECORDS) — "Anamnese" (medical history): the structured-record
|
||||
// home for allergies + family history. Always shown (not module-gated, like
|
||||
// `vorsorge`); the records are foundational health-profile data.
|
||||
"anamnesis",
|
||||
"vorsorge",
|
||||
"thresholds",
|
||||
"ai",
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
Blocks,
|
||||
Bot,
|
||||
CalendarCheck,
|
||||
ClipboardList,
|
||||
Download,
|
||||
FileHeart,
|
||||
FlaskConical,
|
||||
@@ -191,6 +192,15 @@ export const SETTINGS_SECTIONS: readonly SettingsSection[] = [
|
||||
icon: Thermometer,
|
||||
moduleGate: "illness",
|
||||
},
|
||||
// v1.25 (W-RECORDS) — Anamnese (medical history): the structured-record home
|
||||
// for allergies + family history. NOT module-gated (like Vorsorge) — these
|
||||
// are foundational health-profile records, always available. Sits between
|
||||
// the illness journal and the preventive-care reminders.
|
||||
{
|
||||
slug: "anamnesis",
|
||||
titleKey: "settings.sections.anamnesis.title",
|
||||
icon: ClipboardList,
|
||||
},
|
||||
// v1.18.7 — Vorsorge: the preventive-care reminders customise surface
|
||||
// (view, order, individual reminders). NOT module-gated — preventive-care
|
||||
// reminders are not a toggleable module — so the entry is always shown.
|
||||
|
||||
@@ -166,6 +166,13 @@ export const ENCRYPTED_COLUMNS: readonly EncryptedColumn[] = [
|
||||
field: "responsesEncrypted",
|
||||
kind: "bytes",
|
||||
},
|
||||
// ───── v1.25 structured health records (Bytes columns) ─────
|
||||
// Allergy free-text reaction + note, and the family-history note. The
|
||||
// structured enum/label columns stay queryable plaintext; only the
|
||||
// sensitive free-text fields are encrypted.
|
||||
{ model: "Allergy", field: "reactionEncrypted", kind: "bytes" },
|
||||
{ model: "Allergy", field: "notesEncrypted", kind: "bytes" },
|
||||
{ model: "FamilyHistoryEntry", field: "notesEncrypted", kind: "bytes" },
|
||||
] as const;
|
||||
|
||||
/** Stable `Model.field` key for a registry entry. */
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
|
||||
import {
|
||||
allergyIntoleranceResources,
|
||||
familyMemberHistoryResources,
|
||||
} from "@/lib/fhir/records";
|
||||
import type { AllergyDTO, FamilyHistoryEntryDTO } from "@/lib/records/dto";
|
||||
|
||||
function allergy(overrides: Partial<AllergyDTO> = {}): AllergyDTO {
|
||||
return {
|
||||
id: "a1",
|
||||
substance: "Penicillin",
|
||||
category: "MEDICATION",
|
||||
type: "ALLERGY",
|
||||
severity: "SEVERE",
|
||||
status: "ACTIVE",
|
||||
onsetAt: "2020-01-02T00:00:00.000Z",
|
||||
reaction: "Anaphylaxis",
|
||||
note: null,
|
||||
createdAt: "2020-01-02T00:00:00.000Z",
|
||||
updatedAt: "2020-01-02T00:00:00.000Z",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function family(
|
||||
overrides: Partial<FamilyHistoryEntryDTO> = {},
|
||||
): FamilyHistoryEntryDTO {
|
||||
return {
|
||||
id: "f1",
|
||||
relationship: "MOTHER",
|
||||
condition: "Type 2 diabetes",
|
||||
ageAtOnset: 55,
|
||||
note: null,
|
||||
createdAt: "2020-01-02T00:00:00.000Z",
|
||||
updatedAt: "2020-01-02T00:00:00.000Z",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("allergyIntoleranceResources", () => {
|
||||
it("maps a record to a patient-reported AllergyIntolerance with the label on code.text", () => {
|
||||
const [r] = allergyIntoleranceResources([allergy()]);
|
||||
expect(r.resourceType).toBe("AllergyIntolerance");
|
||||
expect(r.id).toBe("allergy-1");
|
||||
expect(r.code).toEqual({ text: "Penicillin" });
|
||||
expect(r.type).toBe("allergy");
|
||||
expect(r.category).toEqual(["medication"]);
|
||||
expect(r.criticality).toBe("high");
|
||||
expect(r.clinicalStatus?.coding?.[0].code).toBe("active");
|
||||
// Patient-reported, never clinician-confirmed.
|
||||
expect(r.verificationStatus?.coding?.[0].code).toBe("unconfirmed");
|
||||
expect(r.patient.reference).toBe("Patient/patient-1");
|
||||
expect(r.onsetDateTime).toBe("2020-01-02T00:00:00.000Z");
|
||||
expect(r.reaction?.[0].manifestation[0]).toEqual({ text: "Anaphylaxis" });
|
||||
expect(r.reaction?.[0].severity).toBe("severe");
|
||||
// First note is the non-diagnostic guard rail.
|
||||
expect(r.note?.[0].text).toMatch(/not a clinical diagnosis/i);
|
||||
});
|
||||
|
||||
it("omits category for OTHER and reaction when no free-text reaction", () => {
|
||||
const [r] = allergyIntoleranceResources([
|
||||
allergy({ category: "OTHER", reaction: null, severity: null }),
|
||||
]);
|
||||
expect(r.category).toBeUndefined();
|
||||
expect(r.reaction).toBeUndefined();
|
||||
expect(r.criticality).toBeUndefined();
|
||||
});
|
||||
|
||||
it("never machine-guesses a substance code", () => {
|
||||
const [r] = allergyIntoleranceResources([allergy()]);
|
||||
expect(r.code.coding).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("familyMemberHistoryResources", () => {
|
||||
it("maps a record to a completed FamilyMemberHistory with the label on condition.code.text", () => {
|
||||
const [r] = familyMemberHistoryResources([family()]);
|
||||
expect(r.resourceType).toBe("FamilyMemberHistory");
|
||||
expect(r.id).toBe("famhist-1");
|
||||
expect(r.status).toBe("completed");
|
||||
expect(r.relationship.coding?.[0].code).toBe("MTH");
|
||||
expect(r.condition?.[0].code).toEqual({ text: "Type 2 diabetes" });
|
||||
expect(r.condition?.[0].onsetAge).toEqual({
|
||||
value: 55,
|
||||
unit: "a",
|
||||
system: "http://unitsofmeasure.org",
|
||||
code: "a",
|
||||
});
|
||||
expect(r.note?.[0].text).toMatch(/not a clinical diagnosis/i);
|
||||
});
|
||||
|
||||
it("falls back to the generic family-member RoleCode for OTHER and omits onsetAge when unknown", () => {
|
||||
const [r] = familyMemberHistoryResources([
|
||||
family({ relationship: "OTHER", ageAtOnset: null }),
|
||||
]);
|
||||
expect(r.relationship.coding?.[0].code).toBe("FAMMEMB");
|
||||
expect(r.condition?.[0].onsetAge).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -24,6 +24,11 @@
|
||||
* user-supplied HTML (no markdown library, no `dangerouslySetInnerHTML`).
|
||||
*/
|
||||
import type { DoctorReportData } from "@/lib/doctor-report-data";
|
||||
import type { AllergyDTO, FamilyHistoryEntryDTO } from "@/lib/records/dto";
|
||||
import {
|
||||
allergyIntoleranceResources,
|
||||
familyMemberHistoryResources,
|
||||
} from "@/lib/fhir/records";
|
||||
import { LOINC_SYSTEM } from "@/lib/fhir/loinc-map";
|
||||
import {
|
||||
type FhirBuildOptions,
|
||||
@@ -66,6 +71,17 @@ function escapeXml(value: string): string {
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.25 (W-RECORDS) — the structured records folded into the document bundle.
|
||||
* Passed separately from `DoctorReportData` (they are always-available
|
||||
* reference records, not part of the time-windowed report aggregation).
|
||||
* Empty arrays emit nothing.
|
||||
*/
|
||||
export interface FhirRecordInputs {
|
||||
allergies?: AllergyDTO[];
|
||||
familyHistory?: FamilyHistoryEntryDTO[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a FHIR R4 document Bundle from the aggregated report data.
|
||||
*
|
||||
@@ -76,6 +92,7 @@ export function buildFhirDocumentBundle(
|
||||
identity: FhirPatientIdentity,
|
||||
now: Date = new Date(),
|
||||
options: FhirBuildOptions = {},
|
||||
records: FhirRecordInputs = {},
|
||||
): FhirBundle {
|
||||
const patientRef: FhirReference = {
|
||||
reference: `Patient/${PATIENT_RESOURCE_ID}`,
|
||||
@@ -86,6 +103,8 @@ export function buildFhirDocumentBundle(
|
||||
const medicationRefs: FhirReference[] = [];
|
||||
const administrationRefs: FhirReference[] = [];
|
||||
const conditionRefs: FhirReference[] = [];
|
||||
const allergyRefs: FhirReference[] = [];
|
||||
const familyHistoryRefs: FhirReference[] = [];
|
||||
|
||||
// --- Patient -----------------------------------------------------------
|
||||
const patient = patientResource(data, identity);
|
||||
@@ -140,6 +159,20 @@ export function buildFhirDocumentBundle(
|
||||
entries.push({ fullUrl: `urn:uuid:${encounter.id}`, resource: encounter });
|
||||
}
|
||||
|
||||
// --- AllergyIntolerance per recorded allergy (v1.25) -------------------
|
||||
// Always-available structured records (not time-windowed). Patient-reported
|
||||
// (verificationStatus unconfirmed); the substance rides `code.text`.
|
||||
for (const allergy of allergyIntoleranceResources(records.allergies ?? [])) {
|
||||
entries.push({ fullUrl: `urn:uuid:${allergy.id}`, resource: allergy });
|
||||
allergyRefs.push({ reference: `AllergyIntolerance/${allergy.id}` });
|
||||
}
|
||||
|
||||
// --- FamilyMemberHistory per recorded entry (v1.25) -------------------
|
||||
for (const fmh of familyMemberHistoryResources(records.familyHistory ?? [])) {
|
||||
entries.push({ fullUrl: `urn:uuid:${fmh.id}`, resource: fmh });
|
||||
familyHistoryRefs.push({ reference: `FamilyMemberHistory/${fmh.id}` });
|
||||
}
|
||||
|
||||
// --- Composition (leading "cover" resource) ----------------------------
|
||||
// v1.9.0 — when the aggregator capped the administration set, disclose
|
||||
// it in the narrative so the export is honest: it carries the
|
||||
@@ -226,6 +259,25 @@ export function buildFhirDocumentBundle(
|
||||
},
|
||||
]
|
||||
: []),
|
||||
// v1.25 — structured allergy records (AllergyIntolerance). Present only
|
||||
// when the account recorded at least one allergy.
|
||||
...(allergyRefs.length > 0
|
||||
? [
|
||||
{
|
||||
title: "Allergies",
|
||||
entry: allergyRefs,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
// v1.25 — structured family-history records (FamilyMemberHistory).
|
||||
...(familyHistoryRefs.length > 0
|
||||
? [
|
||||
{
|
||||
title: "Family history",
|
||||
entry: familyHistoryRefs,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
],
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,214 @@
|
||||
/**
|
||||
* v1.25 (W-RECORDS) — FHIR R4 emitters for the structured health records.
|
||||
*
|
||||
* Pure functions over the server-authoritative DTOs (`@/lib/records/dto`):
|
||||
* - Allergy → `AllergyIntolerance`
|
||||
* - FamilyHistoryEntry → `FamilyMemberHistory`
|
||||
*
|
||||
* Vendor-blind + non-diagnostic, matching the illness `Condition` stance in
|
||||
* `./resources`: the user's own label rides `code.text` (NEVER a
|
||||
* machine-guessed SNOMED/RxNorm code); only the FHIR-defined value sets
|
||||
* (category, clinical/verification status, family-member RoleCode) carry
|
||||
* codings. Every resource is marked patient-reported (allergy
|
||||
* `verificationStatus: unconfirmed`; an explicit "not a clinical diagnosis"
|
||||
* note) so a clinician's viewer never reads it as an adjudicated finding.
|
||||
*
|
||||
* No FHIR SDK — narrow hand-rolled interfaces only (`./types`), matching the
|
||||
* project's "hand-rolled over the documented wire" convention. All text is
|
||||
* plain text; never user-supplied HTML.
|
||||
*/
|
||||
import type { AllergyDTO, FamilyHistoryEntryDTO } from "@/lib/records/dto";
|
||||
import { PATIENT_RESOURCE_ID } from "@/lib/fhir/resources";
|
||||
import type {
|
||||
FhirAllergyIntolerance,
|
||||
FhirFamilyMemberHistory,
|
||||
FhirReference,
|
||||
} from "@/lib/fhir/types";
|
||||
|
||||
const patientRef: FhirReference = {
|
||||
reference: `Patient/${PATIENT_RESOURCE_ID}`,
|
||||
};
|
||||
|
||||
const ALLERGY_CLINICAL_SYSTEM =
|
||||
"http://terminology.hl7.org/CodeSystem/allergyintolerance-clinical";
|
||||
const ALLERGY_VERIFICATION_SYSTEM =
|
||||
"http://terminology.hl7.org/CodeSystem/allergyintolerance-verification";
|
||||
|
||||
/** FHIR-fixed `AllergyIntolerance.clinicalStatus` per record status. */
|
||||
const ALLERGY_CLINICAL: Record<string, { code: string; display: string }> = {
|
||||
ACTIVE: { code: "active", display: "Active" },
|
||||
INACTIVE: { code: "inactive", display: "Inactive" },
|
||||
RESOLVED: { code: "resolved", display: "Resolved" },
|
||||
};
|
||||
|
||||
/** Record category → the fixed `AllergyIntolerance.category` value set. */
|
||||
const ALLERGY_CATEGORY: Record<
|
||||
string,
|
||||
"food" | "medication" | "environment" | "biologic" | undefined
|
||||
> = {
|
||||
FOOD: "food",
|
||||
MEDICATION: "medication",
|
||||
ENVIRONMENT: "environment",
|
||||
BIOLOGIC: "biologic",
|
||||
OTHER: undefined,
|
||||
};
|
||||
|
||||
/** Record severity → the fixed `reaction.severity` value set. */
|
||||
const ALLERGY_SEVERITY: Record<string, "mild" | "moderate" | "severe"> = {
|
||||
MILD: "mild",
|
||||
MODERATE: "moderate",
|
||||
SEVERE: "severe",
|
||||
};
|
||||
|
||||
const ALLERGY_TYPE: Record<string, "allergy" | "intolerance"> = {
|
||||
ALLERGY: "allergy",
|
||||
INTOLERANCE: "intolerance",
|
||||
};
|
||||
|
||||
/**
|
||||
* Emit one `AllergyIntolerance` per record. Ids run `allergy-1..N`. The
|
||||
* substance rides `code.text`; the broad category + clinical/verification
|
||||
* status carry their FHIR-fixed codings. `verificationStatus` is always
|
||||
* `unconfirmed` (patient-reported). The decrypted reaction free-text rides
|
||||
* `reaction[].manifestation.text` with the graded severity; an explicit note
|
||||
* marks the record as self-recorded.
|
||||
*/
|
||||
export function allergyIntoleranceResources(
|
||||
allergies: AllergyDTO[],
|
||||
): FhirAllergyIntolerance[] {
|
||||
const out: FhirAllergyIntolerance[] = [];
|
||||
let seq = 0;
|
||||
for (const a of allergies) {
|
||||
seq += 1;
|
||||
const clinical = ALLERGY_CLINICAL[a.status] ?? ALLERGY_CLINICAL.ACTIVE;
|
||||
const category = ALLERGY_CATEGORY[a.category];
|
||||
const severity = a.severity ? ALLERGY_SEVERITY[a.severity] : undefined;
|
||||
const resource: FhirAllergyIntolerance = {
|
||||
resourceType: "AllergyIntolerance",
|
||||
id: `allergy-${seq}`,
|
||||
clinicalStatus: {
|
||||
coding: [
|
||||
{
|
||||
system: ALLERGY_CLINICAL_SYSTEM,
|
||||
code: clinical.code,
|
||||
display: clinical.display,
|
||||
},
|
||||
],
|
||||
},
|
||||
verificationStatus: {
|
||||
coding: [
|
||||
{
|
||||
system: ALLERGY_VERIFICATION_SYSTEM,
|
||||
// Patient-reported record → "unconfirmed", never clinician-confirmed.
|
||||
code: "unconfirmed",
|
||||
display: "Unconfirmed",
|
||||
},
|
||||
],
|
||||
},
|
||||
type: ALLERGY_TYPE[a.type] ?? "allergy",
|
||||
...(category ? { category: [category] } : {}),
|
||||
...(severity
|
||||
? { criticality: severity === "severe" ? "high" : "low" }
|
||||
: {}),
|
||||
// The user's own substance label — never a machine-guessed code.
|
||||
code: { text: a.substance },
|
||||
patient: patientRef,
|
||||
...(a.onsetAt ? { onsetDateTime: a.onsetAt } : {}),
|
||||
note: [
|
||||
{
|
||||
text: "Self-recorded allergy/intolerance; patient-reported, not a clinical diagnosis.",
|
||||
},
|
||||
...(a.note ? [{ text: a.note }] : []),
|
||||
],
|
||||
};
|
||||
if (a.reaction) {
|
||||
resource.reaction = [
|
||||
{
|
||||
manifestation: [{ text: a.reaction }],
|
||||
...(severity ? { severity } : {}),
|
||||
},
|
||||
];
|
||||
}
|
||||
out.push(resource);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const ROLE_CODE_SYSTEM = "http://terminology.hl7.org/CodeSystem/v3-RoleCode";
|
||||
|
||||
/**
|
||||
* Record relationship → HL7 v3 `RoleCode` family-member concept (the fixed
|
||||
* code system FHIR `FamilyMemberHistory.relationship` examples use). `OTHER`
|
||||
* falls back to the generic `FAMMEMB` (family member) concept — an honest
|
||||
* fallback, never a fabricated specific relation. `display` carries a stable
|
||||
* English label; the localized label lives on the client surface.
|
||||
*/
|
||||
const RELATIONSHIP_ROLE: Record<string, { code: string; display: string }> = {
|
||||
MOTHER: { code: "MTH", display: "Mother" },
|
||||
FATHER: { code: "FTH", display: "Father" },
|
||||
SISTER: { code: "SIS", display: "Sister" },
|
||||
BROTHER: { code: "BRO", display: "Brother" },
|
||||
DAUGHTER: { code: "DAUC", display: "Daughter" },
|
||||
SON: { code: "SONC", display: "Son" },
|
||||
GRANDMOTHER_MATERNAL: { code: "MGRMTH", display: "Maternal grandmother" },
|
||||
GRANDFATHER_MATERNAL: { code: "MGRFTH", display: "Maternal grandfather" },
|
||||
GRANDMOTHER_PATERNAL: { code: "PGRMTH", display: "Paternal grandmother" },
|
||||
GRANDFATHER_PATERNAL: { code: "PGRFTH", display: "Paternal grandfather" },
|
||||
AUNT: { code: "AUNT", display: "Aunt" },
|
||||
UNCLE: { code: "UNCLE", display: "Uncle" },
|
||||
COUSIN: { code: "COUSN", display: "Cousin" },
|
||||
HALF_SIBLING: { code: "HSIB", display: "Half-sibling" },
|
||||
OTHER: { code: "FAMMEMB", display: "Family member" },
|
||||
};
|
||||
|
||||
/**
|
||||
* Emit one `FamilyMemberHistory` per entry (one condition each). Ids run
|
||||
* `famhist-1..N`. The relationship carries the v3 RoleCode coding + a readable
|
||||
* `.text`; the condition rides `condition[].code.text` (no machine-guessed
|
||||
* code) with an optional `onsetAge` in years (UCUM `a`). `status: "completed"`.
|
||||
*/
|
||||
export function familyMemberHistoryResources(
|
||||
entries: FamilyHistoryEntryDTO[],
|
||||
): FhirFamilyMemberHistory[] {
|
||||
const out: FhirFamilyMemberHistory[] = [];
|
||||
let seq = 0;
|
||||
for (const e of entries) {
|
||||
seq += 1;
|
||||
const role = RELATIONSHIP_ROLE[e.relationship] ?? RELATIONSHIP_ROLE.OTHER;
|
||||
out.push({
|
||||
resourceType: "FamilyMemberHistory",
|
||||
id: `famhist-${seq}`,
|
||||
status: "completed",
|
||||
patient: patientRef,
|
||||
relationship: {
|
||||
coding: [
|
||||
{ system: ROLE_CODE_SYSTEM, code: role.code, display: role.display },
|
||||
],
|
||||
text: role.display,
|
||||
},
|
||||
condition: [
|
||||
{
|
||||
// The user's own condition label — never a machine-guessed code.
|
||||
code: { text: e.condition },
|
||||
...(e.ageAtOnset !== null
|
||||
? {
|
||||
onsetAge: {
|
||||
value: e.ageAtOnset,
|
||||
unit: "a",
|
||||
system: "http://unitsofmeasure.org",
|
||||
code: "a",
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
],
|
||||
note: [
|
||||
{
|
||||
text: "Self-recorded family history; patient-reported, not a clinical diagnosis.",
|
||||
},
|
||||
...(e.note ? [{ text: e.note }] : []),
|
||||
],
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -245,6 +245,50 @@ export interface FhirEncounter {
|
||||
reasonReference?: FhirReference[];
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.25 (W-RECORDS) — R4 `AllergyIntolerance`. A self-recorded allergy /
|
||||
* intolerance. `clinicalStatus`/`verificationStatus` carry "what the user
|
||||
* asserts" (verification is always `unconfirmed` — patient-reported, not a
|
||||
* clinician-confirmed diagnosis). The substance rides `code.text` (no
|
||||
* machine-guessed SNOMED/RxNorm code); the broad category rides `category[]`.
|
||||
*/
|
||||
export interface FhirAllergyIntolerance {
|
||||
resourceType: "AllergyIntolerance";
|
||||
id: string;
|
||||
clinicalStatus?: FhirCodeableConcept;
|
||||
verificationStatus?: FhirCodeableConcept;
|
||||
type?: "allergy" | "intolerance";
|
||||
category?: ("food" | "medication" | "environment" | "biologic")[];
|
||||
criticality?: "low" | "high" | "unable-to-assess";
|
||||
code: FhirCodeableConcept;
|
||||
patient: FhirReference;
|
||||
onsetDateTime?: string;
|
||||
reaction?: {
|
||||
manifestation: FhirCodeableConcept[];
|
||||
severity?: "mild" | "moderate" | "severe";
|
||||
}[];
|
||||
note?: FhirAnnotation[];
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.25 (W-RECORDS) — R4 `FamilyMemberHistory`. One relative's condition as
|
||||
* recorded by the user. `relationship` carries the SNOMED family-member
|
||||
* concept; the condition rides `condition[].code.text` (no machine-guessed
|
||||
* code). `status: "completed"` marks the record as captured. Patient-reported.
|
||||
*/
|
||||
export interface FhirFamilyMemberHistory {
|
||||
resourceType: "FamilyMemberHistory";
|
||||
id: string;
|
||||
status: "partial" | "completed" | "entered-in-error" | "health-unknown";
|
||||
patient: FhirReference;
|
||||
relationship: FhirCodeableConcept;
|
||||
condition?: {
|
||||
code: FhirCodeableConcept;
|
||||
onsetAge?: FhirQuantity;
|
||||
}[];
|
||||
note?: FhirAnnotation[];
|
||||
}
|
||||
|
||||
export type FhirResource =
|
||||
| FhirComposition
|
||||
| FhirPatient
|
||||
@@ -254,6 +298,8 @@ export type FhirResource =
|
||||
| FhirMedicationAdministration
|
||||
| FhirCondition
|
||||
| FhirEncounter
|
||||
| FhirAllergyIntolerance
|
||||
| FhirFamilyMemberHistory
|
||||
| FhirDiagnosticReport;
|
||||
|
||||
export interface FhirBundleEntry {
|
||||
|
||||
@@ -75,6 +75,7 @@ const openApiBase: Pick<
|
||||
{ name: "MeasurementReminders" },
|
||||
{ name: "Labs" },
|
||||
{ name: "Illness" },
|
||||
{ name: "Records" },
|
||||
{ name: "Admin" },
|
||||
{ name: "Meta" },
|
||||
],
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
/**
|
||||
* OpenAPI route table for structured allergies (`/api/allergies`).
|
||||
*
|
||||
* Part of the OpenAPI route table; aggregated in `./index.ts`. The request
|
||||
* bodies + queries reuse the runtime Zod schemas from
|
||||
* `@/lib/validations/allergy` so the wire contract stays single-source.
|
||||
* Response shapes are declared here to mirror the server-authoritative DTOs in
|
||||
* `@/lib/records/dto.ts` (the routes serialise a decrypted `reaction` + `note`
|
||||
* the input schemas don't carry; iOS renders the DTO, it never recomputes it).
|
||||
*
|
||||
* A structured AllergyIntolerance-style RECORD — patient-reported, never a
|
||||
* clinical diagnosis. Owner-scoped; auth-gated.
|
||||
*/
|
||||
import type { ZodOpenApiObject } from "zod-openapi";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import {
|
||||
allergyCreateSchema,
|
||||
allergyUpdateSchema,
|
||||
allergyListQuerySchema,
|
||||
allergyCategoryEnum,
|
||||
allergyTypeEnum,
|
||||
allergySeverityEnum,
|
||||
allergyStatusEnum,
|
||||
} from "@/lib/validations/allergy";
|
||||
|
||||
import { dataEnvelope, errorEnvelope, stdResponses } from "./shared";
|
||||
|
||||
allergyCreateSchema.meta({
|
||||
id: "CreateAllergyRequest",
|
||||
description:
|
||||
"Record an allergy/intolerance. `substance` is the user-facing allergen name (the FHIR AllergyIntolerance `code.text` anchor — never a machine-guessed code). The free-text `reaction` + `note` are encrypted at rest. `onsetAt` is optional (unknown when omitted). Patient-reported.",
|
||||
});
|
||||
|
||||
allergyUpdateSchema.meta({
|
||||
id: "UpdateAllergyRequest",
|
||||
description:
|
||||
"Partial edit of an allergy; an omitted key leaves the column untouched. A `null` `severity`/`onsetAt`/`reaction`/`note` clears that field. Rejects unknown keys.",
|
||||
});
|
||||
|
||||
allergyListQuerySchema.meta({
|
||||
id: "ListAllergiesQuery",
|
||||
description:
|
||||
"Query params for the allergy list: optional `limit` (1–200, default 100) and `includeInactive` ('true' | 'false'); 'false' returns only ACTIVE records. Newest-first.",
|
||||
});
|
||||
|
||||
const allergy = z
|
||||
.object({
|
||||
id: z.string(),
|
||||
substance: z.string(),
|
||||
category: allergyCategoryEnum,
|
||||
type: allergyTypeEnum,
|
||||
severity: allergySeverityEnum.nullable(),
|
||||
status: allergyStatusEnum,
|
||||
onsetAt: z.string().nullable(),
|
||||
reaction: z.string().nullable(),
|
||||
note: z.string().nullable(),
|
||||
createdAt: z.string(),
|
||||
updatedAt: z.string(),
|
||||
})
|
||||
.meta({
|
||||
id: "Allergy",
|
||||
description:
|
||||
"A stored allergy/intolerance record. `reaction` + `note` are the decrypted free-text (or null on a key-rotation gap — fail-soft, never 500). `severity`/`onsetAt` are null when not assessed/known.",
|
||||
});
|
||||
|
||||
const allergyNotFound = {
|
||||
"404": {
|
||||
description: "Allergy not found (or owned by another user).",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
} as const;
|
||||
|
||||
export const allergyPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
"/api/allergies": {
|
||||
get: {
|
||||
tags: ["Records"],
|
||||
summary: "List allergies (v1.25)",
|
||||
description:
|
||||
"Returns the caller's live (non-deleted) allergy/intolerance records, newest-first. `includeInactive=false` returns only ACTIVE records.",
|
||||
requestParams: { query: allergyListQuerySchema },
|
||||
responses: {
|
||||
"200": {
|
||||
description: "The caller's allergy records.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(z.array(allergy), "ListAllergiesEnvelope"),
|
||||
},
|
||||
},
|
||||
},
|
||||
...stdResponses,
|
||||
},
|
||||
},
|
||||
post: {
|
||||
tags: ["Records"],
|
||||
summary: "Record an allergy (v1.25)",
|
||||
description:
|
||||
"Creates one allergy/intolerance record for the caller. The free-text reaction + note are AES-256-GCM encrypted before write. Audits as `allergy.create`.",
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: { "application/json": { schema: allergyCreateSchema } },
|
||||
},
|
||||
responses: {
|
||||
"201": {
|
||||
description: "Allergy created.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(allergy, "CreateAllergyEnvelope"),
|
||||
},
|
||||
},
|
||||
},
|
||||
...stdResponses,
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/allergies/{id}": {
|
||||
get: {
|
||||
tags: ["Records"],
|
||||
summary: "Read a single allergy (v1.25)",
|
||||
description:
|
||||
"Returns the record including its decrypted `reaction` + `note`. Owner-scoped; a cross-user or tombstoned id 404s.",
|
||||
requestParams: { path: z.object({ id: z.string() }) },
|
||||
responses: {
|
||||
"200": {
|
||||
description: "The allergy record.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(allergy, "GetAllergyEnvelope"),
|
||||
},
|
||||
},
|
||||
},
|
||||
...allergyNotFound,
|
||||
...stdResponses,
|
||||
},
|
||||
},
|
||||
patch: {
|
||||
tags: ["Records"],
|
||||
summary: "Edit an allergy (v1.25)",
|
||||
description:
|
||||
"Partial edit; omitted fields are left untouched. Audits as `allergy.update`. Owner-scoped.",
|
||||
requestParams: { path: z.object({ id: z.string() }) },
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: { "application/json": { schema: allergyUpdateSchema } },
|
||||
},
|
||||
responses: {
|
||||
"200": {
|
||||
description: "Allergy updated.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(allergy, "UpdateAllergyEnvelope"),
|
||||
},
|
||||
},
|
||||
},
|
||||
...allergyNotFound,
|
||||
...stdResponses,
|
||||
},
|
||||
},
|
||||
delete: {
|
||||
tags: ["Records"],
|
||||
summary: "Soft-delete an allergy (v1.25)",
|
||||
description:
|
||||
"Stamps `deletedAt` (tombstone). Idempotent — a re-delete is a no-op. Returns the `{ deleted: true }` envelope. Audits as `allergy.delete`. Owner-scoped.",
|
||||
requestParams: { path: z.object({ id: z.string() }) },
|
||||
responses: {
|
||||
"200": {
|
||||
description: "Soft-deleted.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(
|
||||
z.object({ deleted: z.boolean() }),
|
||||
"DeleteAllergyEnvelope",
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
...allergyNotFound,
|
||||
...stdResponses,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,191 @@
|
||||
/**
|
||||
* OpenAPI route table for structured family history (`/api/family-history`).
|
||||
*
|
||||
* Part of the OpenAPI route table; aggregated in `./index.ts`. The request
|
||||
* bodies + queries reuse the runtime Zod schemas from
|
||||
* `@/lib/validations/family-history` so the wire contract stays single-source.
|
||||
* Response shapes mirror the server-authoritative DTOs in
|
||||
* `@/lib/records/dto.ts` (the routes serialise a decrypted `note` the input
|
||||
* schemas don't carry; iOS renders the DTO, it never recomputes it).
|
||||
*
|
||||
* A structured FamilyMemberHistory-style RECORD — patient-reported, never a
|
||||
* clinical diagnosis. Owner-scoped; auth-gated.
|
||||
*/
|
||||
import type { ZodOpenApiObject } from "zod-openapi";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import {
|
||||
familyHistoryCreateSchema,
|
||||
familyHistoryUpdateSchema,
|
||||
familyHistoryListQuerySchema,
|
||||
familyRelationshipEnum,
|
||||
} from "@/lib/validations/family-history";
|
||||
|
||||
import { dataEnvelope, errorEnvelope, stdResponses } from "./shared";
|
||||
|
||||
familyHistoryCreateSchema.meta({
|
||||
id: "CreateFamilyHistoryRequest",
|
||||
description:
|
||||
"Record one condition for one relative (a FHIR FamilyMemberHistory with a single condition). `condition` is the user-facing label (the `condition.code.text` anchor — never a machine-guessed code); `ageAtOnset` is the relative's age (years) when it began. The free-text `note` is encrypted at rest. Patient-reported.",
|
||||
});
|
||||
|
||||
familyHistoryUpdateSchema.meta({
|
||||
id: "UpdateFamilyHistoryRequest",
|
||||
description:
|
||||
"Partial edit of a family-history entry; an omitted key leaves the column untouched. A `null` `ageAtOnset`/`note` clears that field. Rejects unknown keys.",
|
||||
});
|
||||
|
||||
familyHistoryListQuerySchema.meta({
|
||||
id: "ListFamilyHistoryQuery",
|
||||
description:
|
||||
"Query params for the family-history list: optional `limit` (1–200, default 100). Newest-first.",
|
||||
});
|
||||
|
||||
const familyHistoryEntry = z
|
||||
.object({
|
||||
id: z.string(),
|
||||
relationship: familyRelationshipEnum,
|
||||
condition: z.string(),
|
||||
ageAtOnset: z.number().int().nullable(),
|
||||
note: z.string().nullable(),
|
||||
createdAt: z.string(),
|
||||
updatedAt: z.string(),
|
||||
})
|
||||
.meta({
|
||||
id: "FamilyHistoryEntry",
|
||||
description:
|
||||
"A stored family-history entry: one condition for one relative. `note` is the decrypted free-text (or null on a key-rotation gap — fail-soft, never 500). `ageAtOnset` is null when unknown.",
|
||||
});
|
||||
|
||||
const entryNotFound = {
|
||||
"404": {
|
||||
description: "Family history entry not found (or owned by another user).",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
} as const;
|
||||
|
||||
export const familyHistoryPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
"/api/family-history": {
|
||||
get: {
|
||||
tags: ["Records"],
|
||||
summary: "List family history (v1.25)",
|
||||
description:
|
||||
"Returns the caller's live (non-deleted) family-history entries, newest-first.",
|
||||
requestParams: { query: familyHistoryListQuerySchema },
|
||||
responses: {
|
||||
"200": {
|
||||
description: "The caller's family-history entries.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(
|
||||
z.array(familyHistoryEntry),
|
||||
"ListFamilyHistoryEnvelope",
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
...stdResponses,
|
||||
},
|
||||
},
|
||||
post: {
|
||||
tags: ["Records"],
|
||||
summary: "Record a family-history entry (v1.25)",
|
||||
description:
|
||||
"Creates one condition-by-relative record for the caller. The free-text note is AES-256-GCM encrypted before write. Audits as `family-history.create`.",
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
"application/json": { schema: familyHistoryCreateSchema },
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
"201": {
|
||||
description: "Entry created.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(
|
||||
familyHistoryEntry,
|
||||
"CreateFamilyHistoryEnvelope",
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
...stdResponses,
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/family-history/{id}": {
|
||||
get: {
|
||||
tags: ["Records"],
|
||||
summary: "Read a single family-history entry (v1.25)",
|
||||
description:
|
||||
"Returns the entry including its decrypted `note`. Owner-scoped; a cross-user or tombstoned id 404s.",
|
||||
requestParams: { path: z.object({ id: z.string() }) },
|
||||
responses: {
|
||||
"200": {
|
||||
description: "The family-history entry.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(
|
||||
familyHistoryEntry,
|
||||
"GetFamilyHistoryEnvelope",
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
...entryNotFound,
|
||||
...stdResponses,
|
||||
},
|
||||
},
|
||||
patch: {
|
||||
tags: ["Records"],
|
||||
summary: "Edit a family-history entry (v1.25)",
|
||||
description:
|
||||
"Partial edit; omitted fields are left untouched. Audits as `family-history.update`. Owner-scoped.",
|
||||
requestParams: { path: z.object({ id: z.string() }) },
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
"application/json": { schema: familyHistoryUpdateSchema },
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
"200": {
|
||||
description: "Entry updated.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(
|
||||
familyHistoryEntry,
|
||||
"UpdateFamilyHistoryEnvelope",
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
...entryNotFound,
|
||||
...stdResponses,
|
||||
},
|
||||
},
|
||||
delete: {
|
||||
tags: ["Records"],
|
||||
summary: "Soft-delete a family-history entry (v1.25)",
|
||||
description:
|
||||
"Stamps `deletedAt` (tombstone). Idempotent — a re-delete is a no-op. Returns the `{ deleted: true }` envelope. Audits as `family-history.delete`. Owner-scoped.",
|
||||
requestParams: { path: z.object({ id: z.string() }) },
|
||||
responses: {
|
||||
"200": {
|
||||
description: "Soft-deleted.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(
|
||||
z.object({ deleted: z.boolean() }),
|
||||
"DeleteFamilyHistoryEnvelope",
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
...entryNotFound,
|
||||
...stdResponses,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -22,6 +22,7 @@
|
||||
import type { ZodOpenApiObject } from "zod-openapi";
|
||||
|
||||
import { adminDiagnosticPaths, adminInvitePaths } from "./admin";
|
||||
import { allergyPaths } from "./allergies";
|
||||
import { authPaths } from "./auth";
|
||||
import {
|
||||
coachFeedbackPaths,
|
||||
@@ -35,6 +36,7 @@ import { cyclePaths } from "./cycle";
|
||||
import { biomarkerPaths } from "./biomarkers";
|
||||
import { devicePaths } from "./devices";
|
||||
import { exportPaths } from "./export";
|
||||
import { familyHistoryPaths } from "./family-history";
|
||||
import { healthRecordPaths } from "./health-record";
|
||||
import { illnessPaths } from "./illness";
|
||||
import { importPaths } from "./import";
|
||||
@@ -85,6 +87,8 @@ export const openApiPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
...biomarkerPaths,
|
||||
...illnessPaths,
|
||||
...mentalHealthPaths,
|
||||
...allergyPaths,
|
||||
...familyHistoryPaths,
|
||||
...onboardingPaths,
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* Query keys — allergies (v1.25, W-RECORDS). The allergy list + a single
|
||||
* allergy read. Part of the centralized factory; aggregated in `./index.ts`.
|
||||
*/
|
||||
export const allergyKeys = {
|
||||
/**
|
||||
* `allergies()` is the root prefix every allergy write invalidates through.
|
||||
* `allergyList(includeInactive)` keys the list (the inactive toggle is part
|
||||
* of the key so the two response sets cache distinctly). Any allergy write
|
||||
* evicts the whole `["allergies"]` prefix so the list + open sheet repaint
|
||||
* in lockstep.
|
||||
*/
|
||||
allergies: () => ["allergies"] as const,
|
||||
allergyList: (includeInactive: boolean) =>
|
||||
["allergies", "list", includeInactive] as const,
|
||||
allergy: (id: string) => ["allergies", "detail", id] as const,
|
||||
};
|
||||
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* Query keys — family history (v1.25, W-RECORDS). The entry list + a single
|
||||
* entry read. Part of the centralized factory; aggregated in `./index.ts`.
|
||||
*/
|
||||
export const familyHistoryKeys = {
|
||||
/**
|
||||
* `familyHistory()` is the root prefix every family-history write
|
||||
* invalidates through. Any write evicts the whole `["family-history"]`
|
||||
* prefix so the list + open sheet repaint in lockstep.
|
||||
*/
|
||||
familyHistory: () => ["family-history"] as const,
|
||||
familyHistoryList: () => ["family-history", "list"] as const,
|
||||
familyHistoryEntry: (id: string) => ["family-history", "detail", id] as const,
|
||||
};
|
||||
@@ -15,8 +15,10 @@
|
||||
import type { QueryClient, QueryKey } from "@tanstack/react-query";
|
||||
|
||||
import { adminKeys } from "./admin";
|
||||
import { allergyKeys } from "./allergies";
|
||||
import { authKeys } from "./auth";
|
||||
import { coachKeys } from "./coach";
|
||||
import { familyHistoryKeys } from "./family-history";
|
||||
import { cycleKeys } from "./cycle";
|
||||
import { dashboardKeys } from "./dashboard";
|
||||
import { illnessKeys } from "./illness";
|
||||
@@ -48,6 +50,8 @@ export const queryKeys = {
|
||||
...labKeys,
|
||||
...illnessKeys,
|
||||
...mentalHealthKeys,
|
||||
...allergyKeys,
|
||||
...familyHistoryKeys,
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* Structured-record row → wire-DTO mappers (v1.25, W-RECORDS).
|
||||
*
|
||||
* The canonical `AllergyDTO` / `FamilyHistoryEntryDTO` shapes the iOS client
|
||||
* mirrors (server-authoritative — iOS renders, never recomputes). The
|
||||
* sensitive free-text fields live in `*Encrypted` (Bytes) columns and are
|
||||
* decrypted fail-soft on read (a key-rotation gap on one row reads `null`,
|
||||
* never 500s the whole page) — the IllnessEpisode DTO precedent.
|
||||
*/
|
||||
import { decryptFromBytes } from "@/lib/ai/coach/bytes-codec";
|
||||
import type { Allergy, FamilyHistoryEntry } from "@/generated/prisma/client";
|
||||
|
||||
export interface AllergyDTO {
|
||||
id: string;
|
||||
substance: string;
|
||||
category: string;
|
||||
type: string;
|
||||
severity: string | null;
|
||||
status: string;
|
||||
onsetAt: string | null;
|
||||
reaction: string | null;
|
||||
note: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface FamilyHistoryEntryDTO {
|
||||
id: string;
|
||||
relationship: string;
|
||||
condition: string;
|
||||
ageAtOnset: number | null;
|
||||
note: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
/** Decrypt a Bytes payload fail-soft (null on missing / undecryptable). */
|
||||
function decryptOptional(buf: Uint8Array | null): string | null {
|
||||
if (!buf || buf.byteLength === 0) return null;
|
||||
try {
|
||||
return decryptFromBytes(buf);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function toAllergyDTO(row: Allergy): AllergyDTO {
|
||||
return {
|
||||
id: row.id,
|
||||
substance: row.substance,
|
||||
category: row.category,
|
||||
type: row.type,
|
||||
severity: row.severity,
|
||||
status: row.status,
|
||||
onsetAt: row.onsetAt ? row.onsetAt.toISOString() : null,
|
||||
reaction: decryptOptional(row.reactionEncrypted),
|
||||
note: decryptOptional(row.notesEncrypted),
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
updatedAt: row.updatedAt.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
export function toFamilyHistoryEntryDTO(
|
||||
row: FamilyHistoryEntry,
|
||||
): FamilyHistoryEntryDTO {
|
||||
return {
|
||||
id: row.id,
|
||||
relationship: row.relationship,
|
||||
condition: row.condition,
|
||||
ageAtOnset: row.ageAtOnset,
|
||||
note: decryptOptional(row.notesEncrypted),
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
updatedAt: row.updatedAt.toISOString(),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* Allergy / intolerance request/response validation (v1.25, W-RECORDS).
|
||||
*
|
||||
* Source of truth for the `/api/allergies/*` 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`.
|
||||
*
|
||||
* A structured AllergyIntolerance-style RECORD (not a time-series signal):
|
||||
* `substance` is the queryable human label; the free-text reaction
|
||||
* description + notes are encrypted at rest (`reactionEncrypted` /
|
||||
* `notesEncrypted` Bytes columns). Patient-reported — never a clinical
|
||||
* diagnosis the app asserts.
|
||||
*/
|
||||
import { z } from "zod/v4";
|
||||
import { isPlausibleEntryInstant } from "@/lib/validations/entry-instant";
|
||||
|
||||
/**
|
||||
* Plausible-instant bound shared with the measurement / illness 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)",
|
||||
});
|
||||
|
||||
/* ── enums (mirror the Prisma enums) ─────────────────────────────── */
|
||||
|
||||
export const allergyCategoryEnum = z.enum([
|
||||
"FOOD",
|
||||
"MEDICATION",
|
||||
"ENVIRONMENT",
|
||||
"BIOLOGIC",
|
||||
"OTHER",
|
||||
]);
|
||||
|
||||
export const allergyTypeEnum = z.enum(["ALLERGY", "INTOLERANCE"]);
|
||||
|
||||
export const allergySeverityEnum = z.enum(["MILD", "MODERATE", "SEVERE"]);
|
||||
|
||||
export const allergyStatusEnum = z.enum(["ACTIVE", "INACTIVE", "RESOLVED"]);
|
||||
|
||||
/* ── CRUD ─────────────────────────────────────────────────────────── */
|
||||
|
||||
/**
|
||||
* Create an allergy. `substance` is the user-facing allergen name; the
|
||||
* free-text `reaction` + `note` are encrypted at rest. `onsetAt` is optional
|
||||
* (unknown when omitted).
|
||||
*/
|
||||
export const allergyCreateSchema = z.object({
|
||||
substance: z.string().min(1).max(160),
|
||||
category: allergyCategoryEnum.optional().default("OTHER"),
|
||||
type: allergyTypeEnum.optional().default("ALLERGY"),
|
||||
severity: allergySeverityEnum.nullable().optional(),
|
||||
status: allergyStatusEnum.optional().default("ACTIVE"),
|
||||
onsetAt: boundedInstant.nullable().optional(),
|
||||
reaction: z.string().max(2000).nullable().optional(),
|
||||
note: z.string().max(2000).nullable().optional(),
|
||||
});
|
||||
|
||||
export type AllergyCreate = z.infer<typeof allergyCreateSchema>;
|
||||
|
||||
/**
|
||||
* Edit an allergy — every field optional; an omitted field is left
|
||||
* untouched. Rejects unknown keys.
|
||||
*/
|
||||
export const allergyUpdateSchema = z
|
||||
.object({
|
||||
substance: z.string().min(1).max(160).optional(),
|
||||
category: allergyCategoryEnum.optional(),
|
||||
type: allergyTypeEnum.optional(),
|
||||
severity: allergySeverityEnum.nullable().optional(),
|
||||
status: allergyStatusEnum.optional(),
|
||||
onsetAt: boundedInstant.nullable().optional(),
|
||||
reaction: z.string().max(2000).nullable().optional(),
|
||||
note: z.string().max(2000).nullable().optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export type AllergyUpdate = z.infer<typeof allergyUpdateSchema>;
|
||||
|
||||
/** History/list query — newest-first, bounded. */
|
||||
export const allergyListQuerySchema = z.object({
|
||||
limit: z.coerce.number().int().min(1).max(200).optional(),
|
||||
includeInactive: z.union([z.literal("true"), z.literal("false")]).optional(),
|
||||
});
|
||||
|
||||
export type AllergyListQuery = z.infer<typeof allergyListQuerySchema>;
|
||||
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* Family-history request/response validation (v1.25, W-RECORDS).
|
||||
*
|
||||
* Source of truth for the `/api/family-history/*` 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`.
|
||||
*
|
||||
* A structured FamilyMemberHistory-style RECORD (one condition per relative;
|
||||
* not a time-series signal): `relationship` + `condition` label + optional
|
||||
* `ageAtOnset`; the optional free-text note is encrypted at rest
|
||||
* (`notesEncrypted` Bytes column). Patient-reported.
|
||||
*/
|
||||
import { z } from "zod/v4";
|
||||
|
||||
/* ── enum (mirrors the Prisma enum) ──────────────────────────────── */
|
||||
|
||||
export const familyRelationshipEnum = z.enum([
|
||||
"MOTHER",
|
||||
"FATHER",
|
||||
"SISTER",
|
||||
"BROTHER",
|
||||
"DAUGHTER",
|
||||
"SON",
|
||||
"GRANDMOTHER_MATERNAL",
|
||||
"GRANDFATHER_MATERNAL",
|
||||
"GRANDMOTHER_PATERNAL",
|
||||
"GRANDFATHER_PATERNAL",
|
||||
"AUNT",
|
||||
"UNCLE",
|
||||
"COUSIN",
|
||||
"HALF_SIBLING",
|
||||
"OTHER",
|
||||
]);
|
||||
|
||||
/* ── CRUD ─────────────────────────────────────────────────────────── */
|
||||
|
||||
/**
|
||||
* Create a family-history entry. `condition` is the user-facing condition
|
||||
* name; `ageAtOnset` is the relative's age (years) when it began; the
|
||||
* free-text `note` is encrypted at rest.
|
||||
*/
|
||||
export const familyHistoryCreateSchema = z.object({
|
||||
relationship: familyRelationshipEnum,
|
||||
condition: z.string().min(1).max(160),
|
||||
ageAtOnset: z.number().int().min(0).max(120).nullable().optional(),
|
||||
note: z.string().max(2000).nullable().optional(),
|
||||
});
|
||||
|
||||
export type FamilyHistoryCreate = z.infer<typeof familyHistoryCreateSchema>;
|
||||
|
||||
/**
|
||||
* Edit a family-history entry — every field optional; an omitted field is
|
||||
* left untouched. Rejects unknown keys.
|
||||
*/
|
||||
export const familyHistoryUpdateSchema = z
|
||||
.object({
|
||||
relationship: familyRelationshipEnum.optional(),
|
||||
condition: z.string().min(1).max(160).optional(),
|
||||
ageAtOnset: z.number().int().min(0).max(120).nullable().optional(),
|
||||
note: z.string().max(2000).nullable().optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export type FamilyHistoryUpdate = z.infer<typeof familyHistoryUpdateSchema>;
|
||||
|
||||
/** History/list query — bounded. */
|
||||
export const familyHistoryListQuerySchema = z.object({
|
||||
limit: z.coerce.number().int().min(1).max(200).optional(),
|
||||
});
|
||||
|
||||
export type FamilyHistoryListQuery = z.infer<
|
||||
typeof familyHistoryListQuerySchema
|
||||
>;
|
||||
Reference in new issue
Block a user