From 9b4f3ce56d5531d07e564f1f8e669ac0fe944765 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 5 Jul 2026 17:54:32 +0200 Subject: [PATCH] feat(records): surface structured allergies and family history in the doctor report and AI context MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The structured Allergy / FamilyHistoryEntry stores reached FHIR export only: the doctor-report PDF shipped without the two sections every clinical intake asks for first, and the Coach knew allergies only from the free-text answer — a penicillin allergy recorded in the structured records UI never reached either surface. Doctor report: two new section toggles (allergies, familyHistory; both default ON, same stance as labs) across the prefs schema, the export selection, the aggregator, the PDF renderer, and the export panel. The tables render stored fields only — substance/category/kind/severity/ reaction/status and relationship/condition/age-at-onset; the reaction decrypts fail-soft per row and free-text notes are never read, matching the illness-journal stance. OpenAPI regenerated for the extended sections shape. Coach: the about-me context block now appends the structured rows as clearly labelled entries (authoritative over the free-text answer on conflict); the projection is a pure composer with unit tests. New section labels land in all six locales. --- docs/api/openapi.yaml | 8 + messages/de.json | 17 +- messages/en.json | 17 +- messages/es.json | 17 +- messages/fr.json | 17 +- messages/it.json | 17 +- messages/pl.json | 17 +- .../settings/health-record-export-panel.tsx | 16 ++ .../ai/coach/__tests__/self-context.test.ts | 47 +++++ src/lib/ai/coach/about-me.ts | 185 +++++++++++++++++- src/lib/doctor-report-data.ts | 95 +++++++++ src/lib/doctor-report-pdf-core.ts | 115 +++++++++++ .../__tests__/doctor-report-prefs.test.ts | 2 + src/lib/validations/doctor-report-prefs.ts | 10 + src/lib/validations/health-record-export.ts | 7 + 15 files changed, 573 insertions(+), 14 deletions(-) diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index d3626456a..1d3349801 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -944,6 +944,10 @@ paths: type: boolean labs: type: boolean + allergies: + type: boolean + familyHistory: + type: boolean locale: type: string enum: @@ -1076,6 +1080,10 @@ paths: type: boolean labs: type: boolean + allergies: + type: boolean + familyHistory: + type: boolean resourceTypes: maxItems: 8 type: array diff --git a/messages/de.json b/messages/de.json index 76d77dcbb..709d25f44 100644 --- a/messages/de.json +++ b/messages/de.json @@ -187,7 +187,18 @@ "illnessColType": "Art", "illnessColLifecycle": "Verlauf", "illnessColOnset": "Beginn", - "illnessColResolved": "Abgeklungen" + "illnessColResolved": "Abgeklungen", + "allergiesTitle": "Allergien & Unverträglichkeiten", + "allergiesColSubstance": "Substanz", + "allergiesColCategory": "Kategorie", + "allergiesColKind": "Art", + "allergiesColSeverity": "Schweregrad", + "allergiesColReaction": "Reaktion", + "allergiesColStatus": "Status", + "familyHistoryTitle": "Familienanamnese", + "familyHistoryColRelationship": "Verwandtschaft", + "familyHistoryColCondition": "Erkrankung", + "familyHistoryColAgeAtOnset": "Alter bei Beginn" }, "errorBoundary": { "title": "Da ist etwas schiefgelaufen", @@ -5061,7 +5072,9 @@ "fhirNote": "Die strukturierte Ausgabe folgt dem FHIR-R4-Standard (ePA-kompatibel).", "generate": "Erstellen", "error": "Export fehlgeschlagen ({code}).", - "labs": "Laborwerte" + "labs": "Laborwerte", + "allergies": "Allergien & Unverträglichkeiten", + "familyHistory": "Familienanamnese" }, "globalExcludedInjectionSitesLabel": "Global ausgeschlossene Injektionsstellen", "globalExcludedInjectionSitesHint": "Hier gelistete Stellen werden für keine Medikation angeboten und beim Erfassen einer Dosis abgelehnt — auch wenn eine Medikation sie bevorzugt.", diff --git a/messages/en.json b/messages/en.json index 6e7531238..aff6b59f7 100644 --- a/messages/en.json +++ b/messages/en.json @@ -187,7 +187,18 @@ "illnessColType": "Type", "illnessColLifecycle": "Course", "illnessColOnset": "Onset", - "illnessColResolved": "Resolved" + "illnessColResolved": "Resolved", + "allergiesTitle": "Allergies & intolerances", + "allergiesColSubstance": "Substance", + "allergiesColCategory": "Category", + "allergiesColKind": "Kind", + "allergiesColSeverity": "Severity", + "allergiesColReaction": "Reaction", + "allergiesColStatus": "Status", + "familyHistoryTitle": "Family history", + "familyHistoryColRelationship": "Relative", + "familyHistoryColCondition": "Condition", + "familyHistoryColAgeAtOnset": "Age at onset" }, "errorBoundary": { "title": "Something went wrong", @@ -5061,7 +5072,9 @@ "fhirNote": "The structured output follows the FHIR R4 standard (ePA-compatible).", "generate": "Generate", "error": "Export failed ({code}).", - "labs": "Lab results" + "labs": "Lab results", + "allergies": "Allergies & intolerances", + "familyHistory": "Family history" }, "globalExcludedInjectionSitesLabel": "Globally excluded injection sites", "globalExcludedInjectionSitesHint": "Sites listed here are never offered for any medication and rejected when logging a dose, even if a medication prefers them.", diff --git a/messages/es.json b/messages/es.json index 63288bf42..0e0bbe983 100644 --- a/messages/es.json +++ b/messages/es.json @@ -187,7 +187,18 @@ "illnessColType": "Tipo", "illnessColLifecycle": "Curso", "illnessColOnset": "Inicio", - "illnessColResolved": "Resuelta" + "illnessColResolved": "Resuelta", + "allergiesTitle": "Alergias e intolerancias", + "allergiesColSubstance": "Sustancia", + "allergiesColCategory": "Categoría", + "allergiesColKind": "Tipo", + "allergiesColSeverity": "Gravedad", + "allergiesColReaction": "Reacción", + "allergiesColStatus": "Estado", + "familyHistoryTitle": "Antecedentes familiares", + "familyHistoryColRelationship": "Parentesco", + "familyHistoryColCondition": "Afección", + "familyHistoryColAgeAtOnset": "Edad de inicio" }, "errorBoundary": { "title": "Algo ha salido mal", @@ -5061,7 +5072,9 @@ "fhirNote": "La salida estructurada sigue el estándar FHIR R4 (compatible con ePA).", "generate": "Generar", "error": "Error en la exportación ({code}).", - "labs": "Resultados de análisis" + "labs": "Resultados de análisis", + "allergies": "Alergias e intolerancias", + "familyHistory": "Antecedentes familiares" }, "globalExcludedInjectionSitesLabel": "Sitios de inyección excluidos globalmente", "globalExcludedInjectionSitesHint": "Los sitios listados aquí no se ofrecen para ningún medicamento y se rechazan al registrar una dosis, aunque un medicamento los prefiera.", diff --git a/messages/fr.json b/messages/fr.json index b05f33f29..1760c656a 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -187,7 +187,18 @@ "illnessColType": "Type", "illnessColLifecycle": "Évolution", "illnessColOnset": "Début", - "illnessColResolved": "Résolue" + "illnessColResolved": "Résolue", + "allergiesTitle": "Allergies et intolérances", + "allergiesColSubstance": "Substance", + "allergiesColCategory": "Catégorie", + "allergiesColKind": "Type", + "allergiesColSeverity": "Sévérité", + "allergiesColReaction": "Réaction", + "allergiesColStatus": "Statut", + "familyHistoryTitle": "Antécédents familiaux", + "familyHistoryColRelationship": "Lien de parenté", + "familyHistoryColCondition": "Affection", + "familyHistoryColAgeAtOnset": "Âge au début" }, "errorBoundary": { "title": "Une erreur est survenue", @@ -5061,7 +5072,9 @@ "fhirNote": "La sortie structurée suit la norme FHIR R4 (compatible ePA).", "generate": "Générer", "error": "Échec de l’export ({code}).", - "labs": "Résultats d'analyses" + "labs": "Résultats d'analyses", + "allergies": "Allergies et intolérances", + "familyHistory": "Antécédents familiaux" }, "globalExcludedInjectionSitesLabel": "Sites d'injection exclus globalement", "globalExcludedInjectionSitesHint": "Les sites listés ici ne sont proposés pour aucun médicament et sont refusés lors de l'enregistrement d'une dose, même si un médicament les privilégie.", diff --git a/messages/it.json b/messages/it.json index 992787bc2..b510b0fbb 100644 --- a/messages/it.json +++ b/messages/it.json @@ -187,7 +187,18 @@ "illnessColType": "Tipo", "illnessColLifecycle": "Decorso", "illnessColOnset": "Inizio", - "illnessColResolved": "Risolta" + "illnessColResolved": "Risolta", + "allergiesTitle": "Allergie e intolleranze", + "allergiesColSubstance": "Sostanza", + "allergiesColCategory": "Categoria", + "allergiesColKind": "Tipo", + "allergiesColSeverity": "Gravità", + "allergiesColReaction": "Reazione", + "allergiesColStatus": "Stato", + "familyHistoryTitle": "Anamnesi familiare", + "familyHistoryColRelationship": "Parentela", + "familyHistoryColCondition": "Condizione", + "familyHistoryColAgeAtOnset": "Età di esordio" }, "errorBoundary": { "title": "Qualcosa è andato storto", @@ -5061,7 +5072,9 @@ "fhirNote": "L’output strutturato segue lo standard FHIR R4 (compatibile ePA).", "generate": "Genera", "error": "Esportazione non riuscita ({code}).", - "labs": "Risultati degli esami" + "labs": "Risultati degli esami", + "allergies": "Allergie e intolleranze", + "familyHistory": "Anamnesi familiare" }, "globalExcludedInjectionSitesLabel": "Siti di iniezione esclusi globalmente", "globalExcludedInjectionSitesHint": "I siti elencati qui non vengono proposti per alcun farmaco e vengono rifiutati al momento della registrazione di una dose, anche se un farmaco li preferisce.", diff --git a/messages/pl.json b/messages/pl.json index 0ea37c756..aa1917476 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -187,7 +187,18 @@ "illnessColType": "Typ", "illnessColLifecycle": "Przebieg", "illnessColOnset": "Początek", - "illnessColResolved": "Ustąpiła" + "illnessColResolved": "Ustąpiła", + "allergiesTitle": "Alergie i nietolerancje", + "allergiesColSubstance": "Substancja", + "allergiesColCategory": "Kategoria", + "allergiesColKind": "Rodzaj", + "allergiesColSeverity": "Nasilenie", + "allergiesColReaction": "Reakcja", + "allergiesColStatus": "Status", + "familyHistoryTitle": "Wywiad rodzinny", + "familyHistoryColRelationship": "Pokrewieństwo", + "familyHistoryColCondition": "Schorzenie", + "familyHistoryColAgeAtOnset": "Wiek zachorowania" }, "errorBoundary": { "title": "Coś poszło nie tak", @@ -5061,7 +5072,9 @@ "fhirNote": "Dane ustrukturyzowane są zgodne ze standardem FHIR R4 (kompatybilne z ePA).", "generate": "Generuj", "error": "Eksport nie powiódł się ({code}).", - "labs": "Wyniki badań" + "labs": "Wyniki badań", + "allergies": "Alergie i nietolerancje", + "familyHistory": "Wywiad rodzinny" }, "globalExcludedInjectionSitesLabel": "Globalnie wykluczone miejsca iniekcji", "globalExcludedInjectionSitesHint": "Miejsca wymienione tutaj nie są proponowane dla żadnego leku i są odrzucane przy rejestrowaniu dawki, nawet jeśli lek je preferuje.", diff --git a/src/components/settings/health-record-export-panel.tsx b/src/components/settings/health-record-export-panel.tsx index fb46b085c..93c0b118c 100644 --- a/src/components/settings/health-record-export-panel.tsx +++ b/src/components/settings/health-record-export-panel.tsx @@ -58,6 +58,8 @@ interface SectionState { mood: boolean; bmi: boolean; labs: boolean; + allergies: boolean; + familyHistory: boolean; } const DEFAULT_SECTIONS: SectionState = { @@ -79,6 +81,8 @@ const DEFAULT_SECTIONS: SectionState = { mood: false, // privacy default bmi: true, labs: true, + allergies: true, + familyHistory: true, }; function buildSelectionSections(s: SectionState) { @@ -106,6 +110,8 @@ function buildSelectionSections(s: SectionState) { mood: s.mood, bmi: s.bmi, labs: s.labs, + allergies: s.allergies, + familyHistory: s.familyHistory, }; } @@ -406,6 +412,16 @@ export function HealthRecordExportPanel() { checked={sections.labs} onToggle={() => toggle("labs")} /> + toggle("allergies")} + /> + toggle("familyHistory")} + /> {isPdfLike && ( diff --git a/src/lib/ai/coach/__tests__/self-context.test.ts b/src/lib/ai/coach/__tests__/self-context.test.ts index fea1fbc55..0786e6890 100644 --- a/src/lib/ai/coach/__tests__/self-context.test.ts +++ b/src/lib/ai/coach/__tests__/self-context.test.ts @@ -19,6 +19,7 @@ vi.mock("@/lib/db", () => ({ prisma: {} })); import { clampPendingQuestions, composeSelfContextText, + composeStructuredRecordsText, deriveAgeYears, PENDING_QUESTION_MAX_CHARS, type SelfContext, @@ -105,6 +106,52 @@ describe("composeSelfContextText", () => { }); }); +describe("composeStructuredRecordsText", () => { + it("stays null when both structured stores are empty", () => { + expect(composeStructuredRecordsText([], [], "en")).toBeNull(); + }); + + it("renders allergies from stored fields only (severity, reaction, non-active status)", () => { + const text = composeStructuredRecordsText( + [ + { + substance: "Penicillin", + type: "ALLERGY", + severity: "SEVERE", + status: "ACTIVE", + reaction: "Anaphylaxis", + }, + { + substance: "Lactose", + type: "INTOLERANCE", + severity: null, + status: "RESOLVED", + reaction: null, + }, + ], + [], + "en", + ); + expect(text).toContain("Penicillin (severe, reaction: Anaphylaxis)"); + expect(text).toContain("Lactose (intolerance, resolved)"); + }); + + it("renders family history with relationship labels and onset age (de)", () => { + const text = composeStructuredRecordsText( + [], + [ + { relationship: "MOTHER", condition: "Typ-2-Diabetes", ageAtOnset: 55 }, + { relationship: "FATHER", condition: "Hypertonie", ageAtOnset: null }, + ], + "de", + ); + expect(text).toContain("Familienanamnese"); + expect(text).toContain("Mutter: Typ-2-Diabetes (Beginn mit 55)"); + expect(text).toContain("Vater: Hypertonie"); + expect(text).not.toContain("Vater: Hypertonie ("); + }); +}); + describe("clampPendingQuestions", () => { it("caps at 3 questions and per-question length", () => { const long = "x".repeat(PENDING_QUESTION_MAX_CHARS + 50); diff --git a/src/lib/ai/coach/about-me.ts b/src/lib/ai/coach/about-me.ts index d6f5dcd50..e94dc3bdd 100644 --- a/src/lib/ai/coach/about-me.ts +++ b/src/lib/ai/coach/about-me.ts @@ -113,6 +113,155 @@ const GENDER_LABELS: Record<"de" | "en", Record> = { en: { MALE: "male", FEMALE: "female", OTHER: "non-binary" }, }; +/** + * v1.27.x — structured-record projection (link between the Anamnese + * stores and the AI context). A user who records a penicillin allergy in + * the structured records UI — the app's most explicit input for exactly + * this — must not have a Coach that only knows the free-text answer. + * Stored fields only, rendered descriptively; the structured rows are the + * deliberate record, so the block labels them as such and they take + * precedence over the free-text answer on conflict. + */ + +export interface StructuredAllergyForPrompt { + substance: string; + type: string; + severity: string | null; + status: string; + reaction: string | null; +} + +export interface StructuredFamilyHistoryForPrompt { + relationship: string; + condition: string; + ageAtOnset: number | null; +} + +const ALLERGY_SEVERITY_LABELS: Record<"de" | "en", Record> = { + de: { + NONE: "ohne Beschwerden", + MILD: "leicht", + MODERATE: "mittel", + SEVERE: "schwer", + }, + en: { + NONE: "no symptoms", + MILD: "mild", + MODERATE: "moderate", + SEVERE: "severe", + }, +}; + +const ALLERGY_STATUS_LABELS: Record<"de" | "en", Record> = { + de: { INACTIVE: "inaktiv", RESOLVED: "abgeklungen" }, + en: { INACTIVE: "inactive", RESOLVED: "resolved" }, +}; + +const FAMILY_RELATIONSHIP_LABELS: Record< + "de" | "en", + Record +> = { + de: { + MOTHER: "Mutter", + FATHER: "Vater", + SISTER: "Schwester", + BROTHER: "Bruder", + DAUGHTER: "Tochter", + SON: "Sohn", + GRANDMOTHER_MATERNAL: "Großmutter (mütterlicherseits)", + GRANDFATHER_MATERNAL: "Großvater (mütterlicherseits)", + GRANDMOTHER_PATERNAL: "Großmutter (väterlicherseits)", + GRANDFATHER_PATERNAL: "Großvater (väterlicherseits)", + AUNT: "Tante", + UNCLE: "Onkel", + COUSIN: "Cousin/Cousine", + HALF_SIBLING: "Halbgeschwister", + OTHER: "Weitere Verwandte", + }, + en: { + MOTHER: "mother", + FATHER: "father", + SISTER: "sister", + BROTHER: "brother", + DAUGHTER: "daughter", + SON: "son", + GRANDMOTHER_MATERNAL: "maternal grandmother", + GRANDFATHER_MATERNAL: "maternal grandfather", + GRANDMOTHER_PATERNAL: "paternal grandmother", + GRANDFATHER_PATERNAL: "paternal grandfather", + AUNT: "aunt", + UNCLE: "uncle", + COUSIN: "cousin", + HALF_SIBLING: "half-sibling", + OTHER: "other relative", + }, +}; + +/** + * Compose the structured-records lines for the prompt context block. + * Descriptive, stored fields only — substance/kind/severity/reaction/ + * status for allergies, relationship/condition/age-at-onset for family + * history. Returns `null` when both lists are empty so the caller can + * skip the block. + */ +export function composeStructuredRecordsText( + allergies: StructuredAllergyForPrompt[], + familyHistory: StructuredFamilyHistoryForPrompt[], + locale: string, +): string | null { + const de = locale === "de"; + const lang = de ? "de" : "en"; + const lines: string[] = []; + + if (allergies.length > 0) { + const items = allergies.map((a) => { + const details: string[] = []; + if (a.type === "INTOLERANCE") { + details.push(de ? "Unverträglichkeit" : "intolerance"); + } + if (a.severity) { + const label = ALLERGY_SEVERITY_LABELS[lang][a.severity]; + if (label) details.push(label); + } + if (a.reaction) { + details.push((de ? "Reaktion: " : "reaction: ") + a.reaction); + } + const statusLabel = ALLERGY_STATUS_LABELS[lang][a.status]; + if (statusLabel) details.push(statusLabel); + return details.length > 0 + ? `${a.substance} (${details.join(", ")})` + : a.substance; + }); + lines.push( + (de + ? "Dokumentierte Allergien/Unverträglichkeiten (strukturierte Einträge — bei Widerspruch maßgeblich): " + : "Recorded allergies/intolerances (structured entries — authoritative on conflict): ") + + items.join("; "), + ); + } + + if (familyHistory.length > 0) { + const items = familyHistory.map((f) => { + const rel = + FAMILY_RELATIONSHIP_LABELS[lang][f.relationship] ?? f.relationship; + const onset = + f.ageAtOnset !== null + ? de + ? ` (Beginn mit ${f.ageAtOnset})` + : ` (onset at ${f.ageAtOnset})` + : ""; + return `${rel}: ${f.condition}${onset}`; + }); + lines.push( + (de + ? "Familienanamnese (strukturierte Einträge): " + : "Family history (structured entries): ") + items.join("; "), + ); + } + + return lines.length > 0 ? lines.join("\n") : null; +} + /** * Compose the merged self-context text the prompt block quotes: profile * facts first (age/gender from the User row — the single source, never @@ -194,14 +343,33 @@ export async function getSelfContextTextForUser( locale: string, ): Promise { try { - const [ctx, user] = await Promise.all([ + const [ctx, user, allergyRows, familyRows] = await Promise.all([ getSelfContextForUser(userId), prisma.user.findUnique({ where: { id: userId }, select: { dateOfBirth: true, gender: true }, }), + // v1.27.x — structured records join the context block. Live rows + // only; the free-text notes are never selected. The reaction + // ciphertext decrypts fail-closed per row below. + prisma.allergy.findMany({ + where: { userId, deletedAt: null }, + orderBy: { createdAt: "asc" }, + select: { + substance: true, + type: true, + severity: true, + status: true, + reactionEncrypted: true, + }, + }), + prisma.familyHistoryEntry.findMany({ + where: { userId, deletedAt: null }, + orderBy: { createdAt: "asc" }, + select: { relationship: true, condition: true, ageAtOnset: true }, + }), ]); - return composeSelfContextText( + const selfText = composeSelfContextText( ctx, { ageYears: deriveAgeYears(user?.dateOfBirth ?? null), @@ -209,6 +377,19 @@ export async function getSelfContextTextForUser( }, locale, ); + const recordsText = composeStructuredRecordsText( + allergyRows.map((r) => ({ + substance: r.substance, + type: r.type, + severity: r.severity, + status: r.status, + reaction: decryptOrNull(r.reactionEncrypted), + })), + familyRows, + locale, + ); + if (!selfText && !recordsText) return null; + return [selfText, recordsText].filter(Boolean).join("\n"); } catch { return null; } diff --git a/src/lib/doctor-report-data.ts b/src/lib/doctor-report-data.ts index 112084178..b129d7ac7 100644 --- a/src/lib/doctor-report-data.ts +++ b/src/lib/doctor-report-data.ts @@ -29,6 +29,7 @@ import type { } from "@/generated/prisma/client"; import { pickCanonicalSourceRows } from "@/lib/analytics/source-priority"; import { readNote } from "@/lib/crypto/note-cipher"; +import { decryptFromBytes } from "@/lib/ai/coach/bytes-codec"; import { metricKeyForType } from "@/lib/measurements/cumulative-day-sum"; import { userDayKey } from "@/lib/tz/resolver"; import { resolveCanonicalRecovery } from "@/lib/insights/derived/recovery-resolve"; @@ -320,6 +321,46 @@ export interface DoctorReportData { /** ISO resolution instant, or null while ongoing. */ resolvedAt: string | null; }> | null; + /** + * v1.27.x — structured allergy / intolerance records. Reference data, + * not time-windowed: populated whenever the `allergies` section toggle + * is ON (default) and the user recorded at least one live row. The + * reaction description is decrypted fail-soft per row (a key-rotation + * gap omits the value, never fails the report); the free-text note is + * NEVER read here — same stance as the illness journal. Null/absent + * otherwise; the PDF builder then skips the section. Optional so older + * fixtures still typecheck. + */ + allergies?: Array<{ + /** Substance / allergen label as the user recorded it. */ + substance: string; + /** Category (FOOD / MEDICATION / ENVIRONMENT / BIOLOGIC / OTHER). */ + category: string; + /** Kind (ALLERGY / INTOLERANCE). */ + type: string; + /** Worst observed severity (NONE / MILD / MODERATE / SEVERE), or null. */ + severity: string | null; + /** Status (ACTIVE / INACTIVE / RESOLVED). */ + status: string; + /** Decrypted reaction description, or null (unset or key gap). */ + reaction: string | null; + }> | null; + /** + * v1.27.x — structured family-history records. Reference data, not + * time-windowed: populated whenever the `familyHistory` section toggle + * is ON (default) and the user recorded at least one live row. Labels + + * onset age only — the free-text note is never read here. Null/absent + * otherwise; the PDF builder then skips the section. Optional so older + * fixtures still typecheck. + */ + familyHistory?: Array<{ + /** Relationship (MOTHER / FATHER / SISTER / …). */ + relationship: string; + /** Condition label as the user recorded it. */ + condition: string; + /** The relative's age at onset (years), or null when unknown. */ + ageAtOnset: number | null; + }> | null; } /** The three persisted score types surfaced in the wellness summary. */ @@ -1410,6 +1451,58 @@ export async function collectDoctorReportData( illnessEpisodes = mapped.length > 0 ? mapped : null; } + // v1.27.x — structured allergies + family history. Reference data, not + // time-windowed (a penicillin allergy does not expire with the report + // window), so no date filter. Riding the section toggles keeps the "you + // can deselect any section" contract; both default ON — a clinical + // report without an allergy section is the riskier default. The + // reaction description decrypts fail-soft per row; the free-text notes + // are never selected, matching the illness-journal stance. + let allergies: DoctorReportData["allergies"] = null; + if (sections.allergies) { + const rows = await prisma.allergy.findMany({ + where: { userId, deletedAt: null }, + orderBy: { createdAt: "asc" }, + select: { + substance: true, + category: true, + type: true, + severity: true, + status: true, + reactionEncrypted: true, + }, + }); + const mapped = rows.map((r) => { + let reaction: string | null = null; + if (r.reactionEncrypted && r.reactionEncrypted.byteLength > 0) { + try { + reaction = decryptFromBytes(r.reactionEncrypted); + } catch { + reaction = null; + } + } + return { + substance: r.substance, + category: r.category, + type: r.type, + severity: r.severity, + status: r.status, + reaction, + }; + }); + allergies = mapped.length > 0 ? mapped : null; + } + + let familyHistory: DoctorReportData["familyHistory"] = null; + if (sections.familyHistory) { + const rows = await prisma.familyHistoryEntry.findMany({ + where: { userId, deletedAt: null }, + orderBy: { createdAt: "asc" }, + select: { relationship: true, condition: true, ageAtOnset: true }, + }); + familyHistory = rows.length > 0 ? rows : null; + } + return { period: { days, @@ -1464,6 +1557,8 @@ export async function collectDoctorReportData( cycle, labResults, illnessEpisodes, + allergies, + familyHistory, }; } diff --git a/src/lib/doctor-report-pdf-core.ts b/src/lib/doctor-report-pdf-core.ts index 59a69404c..40a06a450 100644 --- a/src/lib/doctor-report-pdf-core.ts +++ b/src/lib/doctor-report-pdf-core.ts @@ -1604,6 +1604,121 @@ export function buildDoctorReportPdfDocument( .finalY + 8; } + // v1.27.x — structured allergy / intolerance records. Reference data + // (not time-windowed) the aggregator populates when the `allergies` + // toggle is ON (default) and rows exist. Stored fields only — substance, + // category, kind, severity, reaction, status — in the same calm factual + // table register as the illness section; no colour, no severity tint. + if (data.allergies && data.allergies.length > 0) { + y = ensureSpace(y, 6 + 18); + doc.setFontSize(14); + doc.setFont("helvetica", "bold"); + doc.setTextColor(30, 30, 30); + doc.text(t("doctorReport.allergiesTitle"), margin, y); + y += 6; + + const allergyRows = data.allergies.map((al) => [ + al.substance, + t(`records.allergies.category.${al.category}`), + t(`records.allergies.type.${al.type}`), + al.severity ? t(`records.allergies.severity.${al.severity}`) : "—", + al.reaction ?? "—", + t(`records.allergies.status.${al.status}`), + ]); + + autoTable(doc, { + startY: y, + head: [ + [ + t("doctorReport.allergiesColSubstance"), + t("doctorReport.allergiesColCategory"), + t("doctorReport.allergiesColKind"), + t("doctorReport.allergiesColSeverity"), + t("doctorReport.allergiesColReaction"), + t("doctorReport.allergiesColStatus"), + ], + ], + body: allergyRows, + theme: "grid", + styles: { + fontSize: 9, + cellPadding: 3, + textColor: [30, 30, 30], + lineColor: [200, 200, 200], + lineWidth: 0.3, + }, + headStyles: { + fillColor: [245, 245, 245], + textColor: [30, 30, 30], + fontStyle: "bold", + }, + alternateRowStyles: { fillColor: [252, 252, 252] }, + margin: { + left: margin, + right: margin, + top: margin, + bottom: tableBottomMargin, + }, + }); + y = + (doc as jsPDF & { lastAutoTable: { finalY: number } }).lastAutoTable + .finalY + 8; + } + + // v1.27.x — structured family-history records. Reference data the + // aggregator populates when the `familyHistory` toggle is ON (default) + // and rows exist. Relationship + condition + age at onset only — the + // free-text note never reaches this surface. + if (data.familyHistory && data.familyHistory.length > 0) { + y = ensureSpace(y, 6 + 18); + doc.setFontSize(14); + doc.setFont("helvetica", "bold"); + doc.setTextColor(30, 30, 30); + doc.text(t("doctorReport.familyHistoryTitle"), margin, y); + y += 6; + + const familyRows = data.familyHistory.map((fh) => [ + t(`records.family.relationship.${fh.relationship}`), + fh.condition, + fh.ageAtOnset !== null ? String(fh.ageAtOnset) : "—", + ]); + + autoTable(doc, { + startY: y, + head: [ + [ + t("doctorReport.familyHistoryColRelationship"), + t("doctorReport.familyHistoryColCondition"), + t("doctorReport.familyHistoryColAgeAtOnset"), + ], + ], + body: familyRows, + theme: "grid", + styles: { + fontSize: 9, + cellPadding: 3, + textColor: [30, 30, 30], + lineColor: [200, 200, 200], + lineWidth: 0.3, + }, + headStyles: { + fillColor: [245, 245, 245], + textColor: [30, 30, 30], + fontStyle: "bold", + }, + alternateRowStyles: { fillColor: [252, 252, 252] }, + margin: { + left: margin, + right: margin, + top: margin, + bottom: tableBottomMargin, + }, + }); + y = + (doc as jsPDF & { lastAutoTable: { finalY: number } }).lastAutoTable + .finalY + 8; + } + // v1.7.0 — optional AI summary. OUT of the clinical PDF by default; // rendered ONLY when the user explicitly opted in. Clearly labelled and // flagged as not clinically validated so a physician never mistakes it diff --git a/src/lib/validations/__tests__/doctor-report-prefs.test.ts b/src/lib/validations/__tests__/doctor-report-prefs.test.ts index f57fa136c..59cbb0950 100644 --- a/src/lib/validations/__tests__/doctor-report-prefs.test.ts +++ b/src/lib/validations/__tests__/doctor-report-prefs.test.ts @@ -92,6 +92,8 @@ describe("resolveDoctorReportPrefs", () => { sleep: false, cycle: false, labs: false, + allergies: false, + familyHistory: false, }; const out = resolveDoctorReportPrefs(current, { mood: false }); expect(out).toEqual({ ...current, mood: false }); diff --git a/src/lib/validations/doctor-report-prefs.ts b/src/lib/validations/doctor-report-prefs.ts index cd104823d..e19e93df1 100644 --- a/src/lib/validations/doctor-report-prefs.ts +++ b/src/lib/validations/doctor-report-prefs.ts @@ -33,6 +33,8 @@ export const doctorReportPrefsSchema = z sleep: z.boolean(), cycle: z.boolean(), labs: z.boolean(), + allergies: z.boolean(), + familyHistory: z.boolean(), }) .partial(); @@ -53,6 +55,8 @@ export interface DoctorReportPrefs { sleep: boolean; cycle: boolean; labs: boolean; + allergies: boolean; + familyHistory: boolean; } /** @@ -75,6 +79,12 @@ export const DEFAULT_DOCTOR_REPORT_PREFS: DoctorReportPrefs = { // Lab results the user recorded for exactly this purpose — sharing // bloodwork with a clinician. ON by default, like BP / weight. labs: true, + // Structured allergy / intolerance records — the section every clinical + // intake asks for first. Reference data recorded to share, ON by default + // like labs. + allergies: true, + // Structured family history — same stance as allergies. + familyHistory: true, }; /** diff --git a/src/lib/validations/health-record-export.ts b/src/lib/validations/health-record-export.ts index 5f631c6a6..3cee53573 100644 --- a/src/lib/validations/health-record-export.ts +++ b/src/lib/validations/health-record-export.ts @@ -77,6 +77,11 @@ export const exportSectionsSchema = z // Structured lab results — ON by default (recorded to share with a // clinician, same stance as BP / weight). labs: z.boolean(), + // Structured allergy / intolerance records — ON by default (the + // section every clinical intake asks for first; same stance as labs). + allergies: z.boolean(), + // Structured family history — ON by default, same stance as allergies. + familyHistory: z.boolean(), }) .partial(); @@ -139,5 +144,7 @@ export function toDoctorReportPrefs( // Cycle is opt-in: only true when explicitly requested (privacy). cycle: s.cycle === true, labs: s.labs ?? fallback.labs, + allergies: s.allergies ?? fallback.allergies, + familyHistory: s.familyHistory ?? fallback.familyHistory, }; }