diff --git a/messages/de.json b/messages/de.json index 7a75ad3a0..ffe344e28 100644 --- a/messages/de.json +++ b/messages/de.json @@ -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." + } + } } } diff --git a/messages/en.json b/messages/en.json index a8fbcc2b5..1f20e7493 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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." + } + } } } diff --git a/messages/es.json b/messages/es.json index b42bba6dc..8d38fbd75 100644 --- a/messages/es.json +++ b/messages/es.json @@ -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." + } + } } } diff --git a/messages/fr.json b/messages/fr.json index 9d590edec..611a88dd7 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -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." + } + } } } diff --git a/messages/it.json b/messages/it.json index 2f3d30a4f..b33713758 100644 --- a/messages/it.json +++ b/messages/it.json @@ -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." + } + } } } diff --git a/messages/pl.json b/messages/pl.json index 865758a4d..aac1023a3 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -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." + } + } } } diff --git a/prisma/migrations/0215_v125_allergies/migration.sql b/prisma/migrations/0215_v125_allergies/migration.sql new file mode 100644 index 000000000..25bd6dd11 --- /dev/null +++ b/prisma/migrations/0215_v125_allergies/migration.sql @@ -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 $$; diff --git a/prisma/migrations/0216_v125_family_history/migration.sql b/prisma/migrations/0216_v125_family_history/migration.sql new file mode 100644 index 000000000..274579f1d --- /dev/null +++ b/prisma/migrations/0216_v125_family_history/migration.sql @@ -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 $$; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 75c095418..e862c91f0 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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. diff --git a/scripts/rotate-encryption-key.ts b/scripts/rotate-encryption-key.ts index 992598d0c..d8a52e239 100644 --- a/scripts/rotate-encryption-key.ts +++ b/scripts/rotate-encryption-key.ts @@ -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; diff --git a/src/app/api/allergies/[id]/route.ts b/src/app/api/allergies/[id]/route.ts new file mode 100644 index 000000000..af618247e --- /dev/null +++ b/src/app/api/allergies/[id]/route.ts @@ -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 }); + }, +); diff --git a/src/app/api/allergies/route.ts b/src/app/api/allergies/route.ts new file mode 100644 index 000000000..385d87dce --- /dev/null +++ b/src/app/api/allergies/route.ts @@ -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 { + 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); +} diff --git a/src/app/api/export/health-record/__tests__/route.test.ts b/src/app/api/export/health-record/__tests__/route.test.ts index 67a9ba6eb..08ad8e15b 100644 --- a/src/app/api/export/health-record/__tests__/route.test.ts +++ b/src/app/api/export/health-record/__tests__/route.test.ts @@ -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", diff --git a/src/app/api/export/health-record/route.ts b/src/app/api/export/health-record/route.ts index f0cea3ba2..5c81f9372 100644 --- a/src/app/api/export/health-record/route.ts +++ b/src/app/api/export/health-record/route.ts @@ -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( { diff --git a/src/app/api/family-history/[id]/route.ts b/src/app/api/family-history/[id]/route.ts new file mode 100644 index 000000000..80d02ea8a --- /dev/null +++ b/src/app/api/family-history/[id]/route.ts @@ -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 }); + }, +); diff --git a/src/app/api/family-history/route.ts b/src/app/api/family-history/route.ts new file mode 100644 index 000000000..e005d523e --- /dev/null +++ b/src/app/api/family-history/route.ts @@ -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 { + 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); +} diff --git a/src/app/settings/[section]/page.tsx b/src/app/settings/[section]/page.tsx index ad65b2c60..2959110f4 100644 --- a/src/app/settings/[section]/page.tsx +++ b/src/app/settings/[section]/page.tsx @@ -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, diff --git a/src/components/records/allergy-form.tsx b/src/components/records/allergy-form.tsx new file mode 100644 index 000000000..2d6393b09 --- /dev/null +++ b/src/components/records/allergy-form.tsx @@ -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(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(`/api/allergies/${existing.id}`, body) + : await apiPost("/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 ? ( + + ) : null} + + + ); + + return ( +
+
+
+ + setSubstance(e.target.value)} + placeholder={t("records.allergies.form.substancePlaceholder")} + maxLength={160} + autoFocus + required + /> +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + setOnsetDate(e.target.value)} + /> +
+
+ +