mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 21:10:59 +00:00
feat(records): surface structured allergies and family history in the doctor report and AI context
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.
This commit is contained in:
1 parent
8d36e69f75
commit
9b4f3ce56d
15 files changed
+573
-14
No files matched your search
@@ -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
|
||||
|
||||
+15
-2
@@ -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.",
|
||||
|
||||
+15
-2
@@ -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.",
|
||||
|
||||
+15
-2
@@ -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.",
|
||||
|
||||
+15
-2
@@ -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.",
|
||||
|
||||
+15
-2
@@ -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.",
|
||||
|
||||
+15
-2
@@ -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.",
|
||||
|
||||
@@ -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")}
|
||||
/>
|
||||
<ToggleRow
|
||||
label={t("settings.healthRecord.allergies")}
|
||||
checked={sections.allergies}
|
||||
onToggle={() => toggle("allergies")}
|
||||
/>
|
||||
<ToggleRow
|
||||
label={t("settings.healthRecord.familyHistory")}
|
||||
checked={sections.familyHistory}
|
||||
onToggle={() => toggle("familyHistory")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{isPdfLike && (
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -113,6 +113,155 @@ const GENDER_LABELS: Record<"de" | "en", Record<string, string>> = {
|
||||
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<string, string>> = {
|
||||
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<string, string>> = {
|
||||
de: { INACTIVE: "inaktiv", RESOLVED: "abgeklungen" },
|
||||
en: { INACTIVE: "inactive", RESOLVED: "resolved" },
|
||||
};
|
||||
|
||||
const FAMILY_RELATIONSHIP_LABELS: Record<
|
||||
"de" | "en",
|
||||
Record<string, string>
|
||||
> = {
|
||||
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<string | null> {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user