From 3ded891641448eba65fd87bcbf66e592421c72c0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 28 Jun 2026 16:02:54 +0200 Subject: [PATCH 1/4] feat(records): add allergy + family-history data model MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce two structured health-record tables, kept as reference records (not time-series signals): - `allergies` — AllergyIntolerance-style: substance + category + type + severity + status + optional onset. The substance label stays queryable plaintext; the free-text reaction and note are AES-256-GCM encrypted (`reaction_encrypted` / `notes_encrypted` BYTEA). - `family_history_entries` — one condition per relative: relationship + condition label + optional age-at-onset; the free-text note is encrypted. Both are owner-scoped and soft-deleted. Migrations 0215/0216 are additive and idempotent. The three new encrypted columns are registered in ENCRYPTED_COLUMNS and wired into the key-rotation script, so a key drop can never silently strand them undecryptable. --- .../0215_v125_allergies/migration.sql | 81 ++++++++++ .../0216_v125_family_history/migration.sql | 58 +++++++ prisma/schema.prisma | 142 ++++++++++++++++++ scripts/rotate-encryption-key.ts | 17 +++ src/lib/crypto/encrypted-columns.ts | 8 + 5 files changed, 306 insertions(+) create mode 100644 prisma/migrations/0215_v125_allergies/migration.sql create mode 100644 prisma/migrations/0216_v125_family_history/migration.sql 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 abc2d1249..50efd59c1 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -720,6 +720,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. @@ -5110,6 +5116,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 3663f3f56..f75d11deb 100644 --- a/scripts/rotate-encryption-key.ts +++ b/scripts/rotate-encryption-key.ts @@ -448,6 +448,23 @@ async function main() { ), ); + // ───── 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; let totalErrors = 0; diff --git a/src/lib/crypto/encrypted-columns.ts b/src/lib/crypto/encrypted-columns.ts index 4b0c91191..cf972a5cd 100644 --- a/src/lib/crypto/encrypted-columns.ts +++ b/src/lib/crypto/encrypted-columns.ts @@ -156,6 +156,14 @@ export const ENCRYPTED_COLUMNS: readonly EncryptedColumn[] = [ { model: "MedicationSideEffect", field: "notesEncrypted", kind: "bytes" }, { model: "MedicationDoseChange", field: "noteEncrypted", kind: "bytes" }, { model: "MedicationInventoryItem", field: "notesEncrypted", kind: "bytes" }, + + // ───── v1.25 structured health records (Bytes columns) ───── + // Allergy free-text reaction + note, and the family-history note. The + // structured enum/label columns stay queryable plaintext; only the + // sensitive free-text fields are encrypted. + { model: "Allergy", field: "reactionEncrypted", kind: "bytes" }, + { model: "Allergy", field: "notesEncrypted", kind: "bytes" }, + { model: "FamilyHistoryEntry", field: "notesEncrypted", kind: "bytes" }, ] as const; /** Stable `Model.field` key for a registry entry. */ From 5052b2cc0820d997cef494912e4fc3a5b1688224 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 28 Jun 2026 16:03:05 +0200 Subject: [PATCH 2/4] feat(records): CRUD API + OpenAPI for allergies and family history MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add the `/api/allergies` and `/api/family-history` endpoints (list / create / read / update / soft-delete), each wrapped in `apiHandler` with Zod `safeParse` + `returnAllZodIssues`. `userId` is always narrowed from the session/Bearer — never a body field — and the Prisma `data` is built field-by-field (no mass assignment). Creates wrap in `withIdempotency`. The free-text reaction/note are encrypted before write and decrypted fail-soft on read via the server-authoritative DTOs. Register the query-key factory entries and the OpenAPI route tables (new `Records` tag); regenerate `docs/api/openapi.yaml`. --- docs/api/openapi.yaml | 733 +++++++++++++++++++++++ src/app/api/allergies/[id]/route.ts | 144 +++++ src/app/api/allergies/route.ts | 121 ++++ src/app/api/family-history/[id]/route.ts | 151 +++++ src/app/api/family-history/route.ts | 112 ++++ src/lib/openapi/registry.ts | 1 + src/lib/openapi/routes/allergies.ts | 182 ++++++ src/lib/openapi/routes/family-history.ts | 191 ++++++ src/lib/openapi/routes/index.ts | 4 + src/lib/query-keys/allergies.ts | 17 + src/lib/query-keys/family-history.ts | 14 + src/lib/query-keys/index.ts | 4 + src/lib/records/dto.ts | 75 +++ src/lib/validations/allergy.ts | 90 +++ src/lib/validations/family-history.ts | 74 +++ 15 files changed, 1913 insertions(+) create mode 100644 src/app/api/allergies/[id]/route.ts create mode 100644 src/app/api/allergies/route.ts create mode 100644 src/app/api/family-history/[id]/route.ts create mode 100644 src/app/api/family-history/route.ts create mode 100644 src/lib/openapi/routes/allergies.ts create mode 100644 src/lib/openapi/routes/family-history.ts create mode 100644 src/lib/query-keys/allergies.ts create mode 100644 src/lib/query-keys/family-history.ts create mode 100644 src/lib/records/dto.ts create mode 100644 src/lib/validations/allergy.ts create mode 100644 src/lib/validations/family-history.ts diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index d11197593..09bb0193f 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -42,6 +42,7 @@ tags: - name: MeasurementReminders - name: Labs - name: Illness + - name: Records - name: Admin - name: Meta paths: @@ -7992,6 +7993,437 @@ paths: "401": *a1 "422": *a3 "429": *a4 + /api/allergies: + get: + tags: + - Records + summary: List allergies (v1.25) + description: Returns the caller's live (non-deleted) allergy/intolerance records, newest-first. `includeInactive=false` + returns only ACTIVE records. + parameters: + - in: query + name: limit + schema: + type: integer + minimum: 1 + maximum: 200 + - in: query + name: includeInactive + schema: + anyOf: + - type: string + const: "true" + - type: string + const: "false" + responses: + "200": + description: The caller's allergy records. + content: + application/json: + schema: + $ref: "#/components/schemas/ListAllergiesEnvelope" + "401": *a1 + "422": *a3 + "429": *a4 + post: + tags: + - Records + summary: Record an allergy (v1.25) + description: Creates one allergy/intolerance record for the caller. The free-text reaction + note are AES-256-GCM + encrypted before write. Audits as `allergy.create`. + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + substance: + type: string + minLength: 1 + maxLength: 160 + category: + default: OTHER + type: string + enum: + - FOOD + - MEDICATION + - ENVIRONMENT + - BIOLOGIC + - OTHER + type: + default: ALLERGY + type: string + enum: + - ALLERGY + - INTOLERANCE + severity: + anyOf: + - type: string + enum: + - MILD + - MODERATE + - SEVERE + - type: "null" + status: + default: ACTIVE + type: string + enum: + - ACTIVE + - INACTIVE + - RESOLVED + onsetAt: + anyOf: + - type: string + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ + - type: "null" + reaction: + anyOf: + - type: string + maxLength: 2000 + - type: "null" + note: + anyOf: + - type: string + maxLength: 2000 + - type: "null" + required: + - substance + responses: + "201": + description: Allergy created. + content: + application/json: + schema: + $ref: "#/components/schemas/CreateAllergyEnvelope" + "401": *a1 + "422": *a3 + "429": *a4 + /api/allergies/{id}: + get: + tags: + - Records + summary: Read a single allergy (v1.25) + description: Returns the record including its decrypted `reaction` + `note`. Owner-scoped; a cross-user or tombstoned id 404s. + parameters: + - in: path + name: id + schema: + type: string + required: true + responses: + "200": + description: The allergy record. + content: + application/json: + schema: + $ref: "#/components/schemas/GetAllergyEnvelope" + "401": *a1 + "404": &a12 + description: Allergy not found (or owned by another user). + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "422": *a3 + "429": *a4 + patch: + tags: + - Records + summary: Edit an allergy (v1.25) + description: Partial edit; omitted fields are left untouched. Audits as `allergy.update`. Owner-scoped. + parameters: + - in: path + name: id + schema: + type: string + required: true + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + substance: + type: string + minLength: 1 + maxLength: 160 + category: + type: string + enum: + - FOOD + - MEDICATION + - ENVIRONMENT + - BIOLOGIC + - OTHER + type: + type: string + enum: + - ALLERGY + - INTOLERANCE + severity: + anyOf: + - type: string + enum: + - MILD + - MODERATE + - SEVERE + - type: "null" + status: + type: string + enum: + - ACTIVE + - INACTIVE + - RESOLVED + onsetAt: + anyOf: + - type: string + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ + - type: "null" + reaction: + anyOf: + - type: string + maxLength: 2000 + - type: "null" + note: + anyOf: + - type: string + maxLength: 2000 + - type: "null" + additionalProperties: false + responses: + "200": + description: Allergy updated. + content: + application/json: + schema: + $ref: "#/components/schemas/UpdateAllergyEnvelope" + "401": *a1 + "404": *a12 + "422": *a3 + "429": *a4 + delete: + tags: + - Records + summary: Soft-delete an allergy (v1.25) + description: "Stamps `deletedAt` (tombstone). Idempotent — a re-delete is a no-op. Returns the `{ deleted: true }` + envelope. Audits as `allergy.delete`. Owner-scoped." + parameters: + - in: path + name: id + schema: + type: string + required: true + responses: + "200": + description: Soft-deleted. + content: + application/json: + schema: + $ref: "#/components/schemas/DeleteAllergyEnvelope" + "401": *a1 + "404": *a12 + "422": *a3 + "429": *a4 + /api/family-history: + get: + tags: + - Records + summary: List family history (v1.25) + description: Returns the caller's live (non-deleted) family-history entries, newest-first. + parameters: + - in: query + name: limit + schema: + type: integer + minimum: 1 + maximum: 200 + responses: + "200": + description: The caller's family-history entries. + content: + application/json: + schema: + $ref: "#/components/schemas/ListFamilyHistoryEnvelope" + "401": *a1 + "422": *a3 + "429": *a4 + post: + tags: + - Records + summary: Record a family-history entry (v1.25) + description: Creates one condition-by-relative record for the caller. The free-text note is AES-256-GCM encrypted before + write. Audits as `family-history.create`. + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + relationship: + type: string + enum: + - MOTHER + - FATHER + - SISTER + - BROTHER + - DAUGHTER + - SON + - GRANDMOTHER_MATERNAL + - GRANDFATHER_MATERNAL + - GRANDMOTHER_PATERNAL + - GRANDFATHER_PATERNAL + - AUNT + - UNCLE + - COUSIN + - HALF_SIBLING + - OTHER + condition: + type: string + minLength: 1 + maxLength: 160 + ageAtOnset: + anyOf: + - type: integer + minimum: 0 + maximum: 120 + - type: "null" + note: + anyOf: + - type: string + maxLength: 2000 + - type: "null" + required: + - relationship + - condition + responses: + "201": + description: Entry created. + content: + application/json: + schema: + $ref: "#/components/schemas/CreateFamilyHistoryEnvelope" + "401": *a1 + "422": *a3 + "429": *a4 + /api/family-history/{id}: + get: + tags: + - Records + summary: Read a single family-history entry (v1.25) + description: Returns the entry including its decrypted `note`. Owner-scoped; a cross-user or tombstoned id 404s. + parameters: + - in: path + name: id + schema: + type: string + required: true + responses: + "200": + description: The family-history entry. + content: + application/json: + schema: + $ref: "#/components/schemas/GetFamilyHistoryEnvelope" + "401": *a1 + "404": &a13 + description: Family history entry not found (or owned by another user). + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "422": *a3 + "429": *a4 + patch: + tags: + - Records + summary: Edit a family-history entry (v1.25) + description: Partial edit; omitted fields are left untouched. Audits as `family-history.update`. Owner-scoped. + parameters: + - in: path + name: id + schema: + type: string + required: true + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + relationship: + type: string + enum: + - MOTHER + - FATHER + - SISTER + - BROTHER + - DAUGHTER + - SON + - GRANDMOTHER_MATERNAL + - GRANDFATHER_MATERNAL + - GRANDMOTHER_PATERNAL + - GRANDFATHER_PATERNAL + - AUNT + - UNCLE + - COUSIN + - HALF_SIBLING + - OTHER + condition: + type: string + minLength: 1 + maxLength: 160 + ageAtOnset: + anyOf: + - type: integer + minimum: 0 + maximum: 120 + - type: "null" + note: + anyOf: + - type: string + maxLength: 2000 + - type: "null" + additionalProperties: false + responses: + "200": + description: Entry updated. + content: + application/json: + schema: + $ref: "#/components/schemas/UpdateFamilyHistoryEnvelope" + "401": *a1 + "404": *a13 + "422": *a3 + "429": *a4 + delete: + tags: + - Records + summary: Soft-delete a family-history entry (v1.25) + description: "Stamps `deletedAt` (tombstone). Idempotent — a re-delete is a no-op. Returns the `{ deleted: true }` + envelope. Audits as `family-history.delete`. Owner-scoped." + parameters: + - in: path + name: id + schema: + type: string + required: true + responses: + "200": + description: Soft-deleted. + content: + application/json: + schema: + $ref: "#/components/schemas/DeleteFamilyHistoryEnvelope" + "401": *a1 + "404": *a13 + "422": *a3 + "429": *a4 /api/onboarding/disclaimer: post: tags: @@ -23363,6 +23795,307 @@ components: (median) recovery gap (null below the min-sample floor — withholds a thin claim), a recurrence-by-month tally, a per-type breakdown, and the dominant driving vital (`gapDriverType`, null when no gap is surfaced). Retrospective only." + ListAllergiesEnvelope: + type: object + properties: + data: + type: array + items: + $ref: "#/components/schemas/Allergy" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + Allergy: + type: object + properties: + id: + type: string + substance: + type: string + category: + type: string + enum: + - FOOD + - MEDICATION + - ENVIRONMENT + - BIOLOGIC + - OTHER + type: + type: string + enum: + - ALLERGY + - INTOLERANCE + severity: + anyOf: + - type: string + enum: + - MILD + - MODERATE + - SEVERE + - type: "null" + status: + type: string + enum: + - ACTIVE + - INACTIVE + - RESOLVED + onsetAt: + anyOf: + - type: string + - type: "null" + reaction: + anyOf: + - type: string + - type: "null" + note: + anyOf: + - type: string + - type: "null" + createdAt: + type: string + updatedAt: + type: string + required: + - id + - substance + - category + - type + - severity + - status + - onsetAt + - reaction + - note + - createdAt + - updatedAt + additionalProperties: false + description: A stored allergy/intolerance record. `reaction` + `note` are the decrypted free-text (or null on a + key-rotation gap — fail-soft, never 500). `severity`/`onsetAt` are null when not assessed/known. + CreateAllergyEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/Allergy" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + GetAllergyEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/Allergy" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + UpdateAllergyEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/Allergy" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + DeleteAllergyEnvelope: + type: object + properties: + data: + type: object + properties: + deleted: + type: boolean + required: + - deleted + additionalProperties: false + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + ListFamilyHistoryEnvelope: + type: object + properties: + data: + type: array + items: + $ref: "#/components/schemas/FamilyHistoryEntry" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + FamilyHistoryEntry: + type: object + properties: + id: + type: string + relationship: + type: string + enum: + - MOTHER + - FATHER + - SISTER + - BROTHER + - DAUGHTER + - SON + - GRANDMOTHER_MATERNAL + - GRANDFATHER_MATERNAL + - GRANDMOTHER_PATERNAL + - GRANDFATHER_PATERNAL + - AUNT + - UNCLE + - COUSIN + - HALF_SIBLING + - OTHER + condition: + type: string + ageAtOnset: + anyOf: + - type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + - type: "null" + note: + anyOf: + - type: string + - type: "null" + createdAt: + type: string + updatedAt: + type: string + required: + - id + - relationship + - condition + - ageAtOnset + - note + - createdAt + - updatedAt + additionalProperties: false + description: "A stored family-history entry: one condition for one relative. `note` is the decrypted free-text (or null + on a key-rotation gap — fail-soft, never 500). `ageAtOnset` is null when unknown." + CreateFamilyHistoryEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/FamilyHistoryEntry" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + GetFamilyHistoryEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/FamilyHistoryEntry" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + UpdateFamilyHistoryEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/FamilyHistoryEntry" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + DeleteFamilyHistoryEnvelope: + type: object + properties: + data: + type: object + properties: + deleted: + type: boolean + required: + - deleted + additionalProperties: false + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false DisclaimerAckEnvelope: type: object properties: 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/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/lib/openapi/registry.ts b/src/lib/openapi/registry.ts index 147d50b24..6a6edc7d4 100644 --- a/src/lib/openapi/registry.ts +++ b/src/lib/openapi/registry.ts @@ -75,6 +75,7 @@ const openApiBase: Pick< { name: "MeasurementReminders" }, { name: "Labs" }, { name: "Illness" }, + { name: "Records" }, { name: "Admin" }, { name: "Meta" }, ], diff --git a/src/lib/openapi/routes/allergies.ts b/src/lib/openapi/routes/allergies.ts new file mode 100644 index 000000000..834ecd3a3 --- /dev/null +++ b/src/lib/openapi/routes/allergies.ts @@ -0,0 +1,182 @@ +/** + * OpenAPI route table for structured allergies (`/api/allergies`). + * + * Part of the OpenAPI route table; aggregated in `./index.ts`. The request + * bodies + queries reuse the runtime Zod schemas from + * `@/lib/validations/allergy` so the wire contract stays single-source. + * Response shapes are declared here to mirror the server-authoritative DTOs in + * `@/lib/records/dto.ts` (the routes serialise a decrypted `reaction` + `note` + * the input schemas don't carry; iOS renders the DTO, it never recomputes it). + * + * A structured AllergyIntolerance-style RECORD — patient-reported, never a + * clinical diagnosis. Owner-scoped; auth-gated. + */ +import type { ZodOpenApiObject } from "zod-openapi"; +import { z } from "zod/v4"; + +import { + allergyCreateSchema, + allergyUpdateSchema, + allergyListQuerySchema, + allergyCategoryEnum, + allergyTypeEnum, + allergySeverityEnum, + allergyStatusEnum, +} from "@/lib/validations/allergy"; + +import { dataEnvelope, errorEnvelope, stdResponses } from "./shared"; + +allergyCreateSchema.meta({ + id: "CreateAllergyRequest", + description: + "Record an allergy/intolerance. `substance` is the user-facing allergen name (the FHIR AllergyIntolerance `code.text` anchor — never a machine-guessed code). The free-text `reaction` + `note` are encrypted at rest. `onsetAt` is optional (unknown when omitted). Patient-reported.", +}); + +allergyUpdateSchema.meta({ + id: "UpdateAllergyRequest", + description: + "Partial edit of an allergy; an omitted key leaves the column untouched. A `null` `severity`/`onsetAt`/`reaction`/`note` clears that field. Rejects unknown keys.", +}); + +allergyListQuerySchema.meta({ + id: "ListAllergiesQuery", + description: + "Query params for the allergy list: optional `limit` (1–200, default 100) and `includeInactive` ('true' | 'false'); 'false' returns only ACTIVE records. Newest-first.", +}); + +const allergy = z + .object({ + id: z.string(), + substance: z.string(), + category: allergyCategoryEnum, + type: allergyTypeEnum, + severity: allergySeverityEnum.nullable(), + status: allergyStatusEnum, + onsetAt: z.string().nullable(), + reaction: z.string().nullable(), + note: z.string().nullable(), + createdAt: z.string(), + updatedAt: z.string(), + }) + .meta({ + id: "Allergy", + description: + "A stored allergy/intolerance record. `reaction` + `note` are the decrypted free-text (or null on a key-rotation gap — fail-soft, never 500). `severity`/`onsetAt` are null when not assessed/known.", + }); + +const allergyNotFound = { + "404": { + description: "Allergy not found (or owned by another user).", + content: { "application/json": { schema: errorEnvelope } }, + }, +} as const; + +export const allergyPaths: NonNullable = { + "/api/allergies": { + get: { + tags: ["Records"], + summary: "List allergies (v1.25)", + description: + "Returns the caller's live (non-deleted) allergy/intolerance records, newest-first. `includeInactive=false` returns only ACTIVE records.", + requestParams: { query: allergyListQuerySchema }, + responses: { + "200": { + description: "The caller's allergy records.", + content: { + "application/json": { + schema: dataEnvelope(z.array(allergy), "ListAllergiesEnvelope"), + }, + }, + }, + ...stdResponses, + }, + }, + post: { + tags: ["Records"], + summary: "Record an allergy (v1.25)", + description: + "Creates one allergy/intolerance record for the caller. The free-text reaction + note are AES-256-GCM encrypted before write. Audits as `allergy.create`.", + requestBody: { + required: true, + content: { "application/json": { schema: allergyCreateSchema } }, + }, + responses: { + "201": { + description: "Allergy created.", + content: { + "application/json": { + schema: dataEnvelope(allergy, "CreateAllergyEnvelope"), + }, + }, + }, + ...stdResponses, + }, + }, + }, + "/api/allergies/{id}": { + get: { + tags: ["Records"], + summary: "Read a single allergy (v1.25)", + description: + "Returns the record including its decrypted `reaction` + `note`. Owner-scoped; a cross-user or tombstoned id 404s.", + requestParams: { path: z.object({ id: z.string() }) }, + responses: { + "200": { + description: "The allergy record.", + content: { + "application/json": { + schema: dataEnvelope(allergy, "GetAllergyEnvelope"), + }, + }, + }, + ...allergyNotFound, + ...stdResponses, + }, + }, + patch: { + tags: ["Records"], + summary: "Edit an allergy (v1.25)", + description: + "Partial edit; omitted fields are left untouched. Audits as `allergy.update`. Owner-scoped.", + requestParams: { path: z.object({ id: z.string() }) }, + requestBody: { + required: true, + content: { "application/json": { schema: allergyUpdateSchema } }, + }, + responses: { + "200": { + description: "Allergy updated.", + content: { + "application/json": { + schema: dataEnvelope(allergy, "UpdateAllergyEnvelope"), + }, + }, + }, + ...allergyNotFound, + ...stdResponses, + }, + }, + delete: { + tags: ["Records"], + summary: "Soft-delete an allergy (v1.25)", + description: + "Stamps `deletedAt` (tombstone). Idempotent — a re-delete is a no-op. Returns the `{ deleted: true }` envelope. Audits as `allergy.delete`. Owner-scoped.", + requestParams: { path: z.object({ id: z.string() }) }, + responses: { + "200": { + description: "Soft-deleted.", + content: { + "application/json": { + schema: dataEnvelope( + z.object({ deleted: z.boolean() }), + "DeleteAllergyEnvelope", + ), + }, + }, + }, + ...allergyNotFound, + ...stdResponses, + }, + }, + }, +}; diff --git a/src/lib/openapi/routes/family-history.ts b/src/lib/openapi/routes/family-history.ts new file mode 100644 index 000000000..96ccf9a85 --- /dev/null +++ b/src/lib/openapi/routes/family-history.ts @@ -0,0 +1,191 @@ +/** + * OpenAPI route table for structured family history (`/api/family-history`). + * + * Part of the OpenAPI route table; aggregated in `./index.ts`. The request + * bodies + queries reuse the runtime Zod schemas from + * `@/lib/validations/family-history` so the wire contract stays single-source. + * Response shapes mirror the server-authoritative DTOs in + * `@/lib/records/dto.ts` (the routes serialise a decrypted `note` the input + * schemas don't carry; iOS renders the DTO, it never recomputes it). + * + * A structured FamilyMemberHistory-style RECORD — patient-reported, never a + * clinical diagnosis. Owner-scoped; auth-gated. + */ +import type { ZodOpenApiObject } from "zod-openapi"; +import { z } from "zod/v4"; + +import { + familyHistoryCreateSchema, + familyHistoryUpdateSchema, + familyHistoryListQuerySchema, + familyRelationshipEnum, +} from "@/lib/validations/family-history"; + +import { dataEnvelope, errorEnvelope, stdResponses } from "./shared"; + +familyHistoryCreateSchema.meta({ + id: "CreateFamilyHistoryRequest", + description: + "Record one condition for one relative (a FHIR FamilyMemberHistory with a single condition). `condition` is the user-facing label (the `condition.code.text` anchor — never a machine-guessed code); `ageAtOnset` is the relative's age (years) when it began. The free-text `note` is encrypted at rest. Patient-reported.", +}); + +familyHistoryUpdateSchema.meta({ + id: "UpdateFamilyHistoryRequest", + description: + "Partial edit of a family-history entry; an omitted key leaves the column untouched. A `null` `ageAtOnset`/`note` clears that field. Rejects unknown keys.", +}); + +familyHistoryListQuerySchema.meta({ + id: "ListFamilyHistoryQuery", + description: + "Query params for the family-history list: optional `limit` (1–200, default 100). Newest-first.", +}); + +const familyHistoryEntry = z + .object({ + id: z.string(), + relationship: familyRelationshipEnum, + condition: z.string(), + ageAtOnset: z.number().int().nullable(), + note: z.string().nullable(), + createdAt: z.string(), + updatedAt: z.string(), + }) + .meta({ + id: "FamilyHistoryEntry", + description: + "A stored family-history entry: one condition for one relative. `note` is the decrypted free-text (or null on a key-rotation gap — fail-soft, never 500). `ageAtOnset` is null when unknown.", + }); + +const entryNotFound = { + "404": { + description: "Family history entry not found (or owned by another user).", + content: { "application/json": { schema: errorEnvelope } }, + }, +} as const; + +export const familyHistoryPaths: NonNullable = { + "/api/family-history": { + get: { + tags: ["Records"], + summary: "List family history (v1.25)", + description: + "Returns the caller's live (non-deleted) family-history entries, newest-first.", + requestParams: { query: familyHistoryListQuerySchema }, + responses: { + "200": { + description: "The caller's family-history entries.", + content: { + "application/json": { + schema: dataEnvelope( + z.array(familyHistoryEntry), + "ListFamilyHistoryEnvelope", + ), + }, + }, + }, + ...stdResponses, + }, + }, + post: { + tags: ["Records"], + summary: "Record a family-history entry (v1.25)", + description: + "Creates one condition-by-relative record for the caller. The free-text note is AES-256-GCM encrypted before write. Audits as `family-history.create`.", + requestBody: { + required: true, + content: { + "application/json": { schema: familyHistoryCreateSchema }, + }, + }, + responses: { + "201": { + description: "Entry created.", + content: { + "application/json": { + schema: dataEnvelope( + familyHistoryEntry, + "CreateFamilyHistoryEnvelope", + ), + }, + }, + }, + ...stdResponses, + }, + }, + }, + "/api/family-history/{id}": { + get: { + tags: ["Records"], + summary: "Read a single family-history entry (v1.25)", + description: + "Returns the entry including its decrypted `note`. Owner-scoped; a cross-user or tombstoned id 404s.", + requestParams: { path: z.object({ id: z.string() }) }, + responses: { + "200": { + description: "The family-history entry.", + content: { + "application/json": { + schema: dataEnvelope( + familyHistoryEntry, + "GetFamilyHistoryEnvelope", + ), + }, + }, + }, + ...entryNotFound, + ...stdResponses, + }, + }, + patch: { + tags: ["Records"], + summary: "Edit a family-history entry (v1.25)", + description: + "Partial edit; omitted fields are left untouched. Audits as `family-history.update`. Owner-scoped.", + requestParams: { path: z.object({ id: z.string() }) }, + requestBody: { + required: true, + content: { + "application/json": { schema: familyHistoryUpdateSchema }, + }, + }, + responses: { + "200": { + description: "Entry updated.", + content: { + "application/json": { + schema: dataEnvelope( + familyHistoryEntry, + "UpdateFamilyHistoryEnvelope", + ), + }, + }, + }, + ...entryNotFound, + ...stdResponses, + }, + }, + delete: { + tags: ["Records"], + summary: "Soft-delete a family-history entry (v1.25)", + description: + "Stamps `deletedAt` (tombstone). Idempotent — a re-delete is a no-op. Returns the `{ deleted: true }` envelope. Audits as `family-history.delete`. Owner-scoped.", + requestParams: { path: z.object({ id: z.string() }) }, + responses: { + "200": { + description: "Soft-deleted.", + content: { + "application/json": { + schema: dataEnvelope( + z.object({ deleted: z.boolean() }), + "DeleteFamilyHistoryEnvelope", + ), + }, + }, + }, + ...entryNotFound, + ...stdResponses, + }, + }, + }, +}; diff --git a/src/lib/openapi/routes/index.ts b/src/lib/openapi/routes/index.ts index ae44bef96..2be82aa67 100644 --- a/src/lib/openapi/routes/index.ts +++ b/src/lib/openapi/routes/index.ts @@ -22,6 +22,7 @@ import type { ZodOpenApiObject } from "zod-openapi"; import { adminDiagnosticPaths, adminInvitePaths } from "./admin"; +import { allergyPaths } from "./allergies"; import { authPaths } from "./auth"; import { coachFeedbackPaths, @@ -35,6 +36,7 @@ import { cyclePaths } from "./cycle"; import { biomarkerPaths } from "./biomarkers"; import { devicePaths } from "./devices"; import { exportPaths } from "./export"; +import { familyHistoryPaths } from "./family-history"; import { healthRecordPaths } from "./health-record"; import { illnessPaths } from "./illness"; import { importPaths } from "./import"; @@ -83,6 +85,8 @@ export const openApiPaths: NonNullable = { ...ocrPaths, ...biomarkerPaths, ...illnessPaths, + ...allergyPaths, + ...familyHistoryPaths, ...onboardingPaths, }; diff --git a/src/lib/query-keys/allergies.ts b/src/lib/query-keys/allergies.ts new file mode 100644 index 000000000..2a5de1688 --- /dev/null +++ b/src/lib/query-keys/allergies.ts @@ -0,0 +1,17 @@ +/** + * Query keys — allergies (v1.25, W-RECORDS). The allergy list + a single + * allergy read. Part of the centralized factory; aggregated in `./index.ts`. + */ +export const allergyKeys = { + /** + * `allergies()` is the root prefix every allergy write invalidates through. + * `allergyList(includeInactive)` keys the list (the inactive toggle is part + * of the key so the two response sets cache distinctly). Any allergy write + * evicts the whole `["allergies"]` prefix so the list + open sheet repaint + * in lockstep. + */ + allergies: () => ["allergies"] as const, + allergyList: (includeInactive: boolean) => + ["allergies", "list", includeInactive] as const, + allergy: (id: string) => ["allergies", "detail", id] as const, +}; diff --git a/src/lib/query-keys/family-history.ts b/src/lib/query-keys/family-history.ts new file mode 100644 index 000000000..88cfad999 --- /dev/null +++ b/src/lib/query-keys/family-history.ts @@ -0,0 +1,14 @@ +/** + * Query keys — family history (v1.25, W-RECORDS). The entry list + a single + * entry read. Part of the centralized factory; aggregated in `./index.ts`. + */ +export const familyHistoryKeys = { + /** + * `familyHistory()` is the root prefix every family-history write + * invalidates through. Any write evicts the whole `["family-history"]` + * prefix so the list + open sheet repaint in lockstep. + */ + familyHistory: () => ["family-history"] as const, + familyHistoryList: () => ["family-history", "list"] as const, + familyHistoryEntry: (id: string) => ["family-history", "detail", id] as const, +}; diff --git a/src/lib/query-keys/index.ts b/src/lib/query-keys/index.ts index d84854923..c2c9e3945 100644 --- a/src/lib/query-keys/index.ts +++ b/src/lib/query-keys/index.ts @@ -15,8 +15,10 @@ import type { QueryClient, QueryKey } from "@tanstack/react-query"; import { adminKeys } from "./admin"; +import { allergyKeys } from "./allergies"; import { authKeys } from "./auth"; import { coachKeys } from "./coach"; +import { familyHistoryKeys } from "./family-history"; import { cycleKeys } from "./cycle"; import { dashboardKeys } from "./dashboard"; import { illnessKeys } from "./illness"; @@ -46,6 +48,8 @@ export const queryKeys = { ...measurementReminderKeys, ...labKeys, ...illnessKeys, + ...allergyKeys, + ...familyHistoryKeys, }; /** diff --git a/src/lib/records/dto.ts b/src/lib/records/dto.ts new file mode 100644 index 000000000..6df06b9ae --- /dev/null +++ b/src/lib/records/dto.ts @@ -0,0 +1,75 @@ +/** + * Structured-record row → wire-DTO mappers (v1.25, W-RECORDS). + * + * The canonical `AllergyDTO` / `FamilyHistoryEntryDTO` shapes the iOS client + * mirrors (server-authoritative — iOS renders, never recomputes). The + * sensitive free-text fields live in `*Encrypted` (Bytes) columns and are + * decrypted fail-soft on read (a key-rotation gap on one row reads `null`, + * never 500s the whole page) — the IllnessEpisode DTO precedent. + */ +import { decryptFromBytes } from "@/lib/ai/coach/bytes-codec"; +import type { Allergy, FamilyHistoryEntry } from "@/generated/prisma/client"; + +export interface AllergyDTO { + id: string; + substance: string; + category: string; + type: string; + severity: string | null; + status: string; + onsetAt: string | null; + reaction: string | null; + note: string | null; + createdAt: string; + updatedAt: string; +} + +export interface FamilyHistoryEntryDTO { + id: string; + relationship: string; + condition: string; + ageAtOnset: number | null; + note: string | null; + createdAt: string; + updatedAt: string; +} + +/** Decrypt a Bytes payload fail-soft (null on missing / undecryptable). */ +function decryptOptional(buf: Uint8Array | null): string | null { + if (!buf || buf.byteLength === 0) return null; + try { + return decryptFromBytes(buf); + } catch { + return null; + } +} + +export function toAllergyDTO(row: Allergy): AllergyDTO { + return { + id: row.id, + substance: row.substance, + category: row.category, + type: row.type, + severity: row.severity, + status: row.status, + onsetAt: row.onsetAt ? row.onsetAt.toISOString() : null, + reaction: decryptOptional(row.reactionEncrypted), + note: decryptOptional(row.notesEncrypted), + createdAt: row.createdAt.toISOString(), + updatedAt: row.updatedAt.toISOString(), + }; +} + +export function toFamilyHistoryEntryDTO( + row: FamilyHistoryEntry, +): FamilyHistoryEntryDTO { + return { + id: row.id, + relationship: row.relationship, + condition: row.condition, + ageAtOnset: row.ageAtOnset, + note: decryptOptional(row.notesEncrypted), + createdAt: row.createdAt.toISOString(), + updatedAt: row.updatedAt.toISOString(), + }; +} diff --git a/src/lib/validations/allergy.ts b/src/lib/validations/allergy.ts new file mode 100644 index 000000000..8f336ed20 --- /dev/null +++ b/src/lib/validations/allergy.ts @@ -0,0 +1,90 @@ +/** + * Allergy / intolerance request/response validation (v1.25, W-RECORDS). + * + * Source of truth for the `/api/allergies/*` wire contract. The Zod schemas + * here are reused by the OpenAPI registry so the spec stays single-source. + * `userId` is NEVER a body field — it is narrowed from the session/Bearer in + * every route and fed to the Prisma `where`. + * + * A structured AllergyIntolerance-style RECORD (not a time-series signal): + * `substance` is the queryable human label; the free-text reaction + * description + notes are encrypted at rest (`reactionEncrypted` / + * `notesEncrypted` Bytes columns). Patient-reported — never a clinical + * diagnosis the app asserts. + */ +import { z } from "zod/v4"; +import { isPlausibleEntryInstant } from "@/lib/validations/entry-instant"; + +/** + * Plausible-instant bound shared with the measurement / illness paths: no + * future instant beyond the 5-min skew, no instant before 1900. Stays a + * string on the wire (iOS reads it back verbatim). + */ +const boundedInstant = z.iso + .datetime({ offset: true }) + .refine((s) => isPlausibleEntryInstant(new Date(s)), { + message: "must be a plausible instant (not future, not pre-1900)", + }); + +/* ── enums (mirror the Prisma enums) ─────────────────────────────── */ + +export const allergyCategoryEnum = z.enum([ + "FOOD", + "MEDICATION", + "ENVIRONMENT", + "BIOLOGIC", + "OTHER", +]); + +export const allergyTypeEnum = z.enum(["ALLERGY", "INTOLERANCE"]); + +export const allergySeverityEnum = z.enum(["MILD", "MODERATE", "SEVERE"]); + +export const allergyStatusEnum = z.enum(["ACTIVE", "INACTIVE", "RESOLVED"]); + +/* ── CRUD ─────────────────────────────────────────────────────────── */ + +/** + * Create an allergy. `substance` is the user-facing allergen name; the + * free-text `reaction` + `note` are encrypted at rest. `onsetAt` is optional + * (unknown when omitted). + */ +export const allergyCreateSchema = z.object({ + substance: z.string().min(1).max(160), + category: allergyCategoryEnum.optional().default("OTHER"), + type: allergyTypeEnum.optional().default("ALLERGY"), + severity: allergySeverityEnum.nullable().optional(), + status: allergyStatusEnum.optional().default("ACTIVE"), + onsetAt: boundedInstant.nullable().optional(), + reaction: z.string().max(2000).nullable().optional(), + note: z.string().max(2000).nullable().optional(), +}); + +export type AllergyCreate = z.infer; + +/** + * Edit an allergy — every field optional; an omitted field is left + * untouched. Rejects unknown keys. + */ +export const allergyUpdateSchema = z + .object({ + substance: z.string().min(1).max(160).optional(), + category: allergyCategoryEnum.optional(), + type: allergyTypeEnum.optional(), + severity: allergySeverityEnum.nullable().optional(), + status: allergyStatusEnum.optional(), + onsetAt: boundedInstant.nullable().optional(), + reaction: z.string().max(2000).nullable().optional(), + note: z.string().max(2000).nullable().optional(), + }) + .strict(); + +export type AllergyUpdate = z.infer; + +/** History/list query — newest-first, bounded. */ +export const allergyListQuerySchema = z.object({ + limit: z.coerce.number().int().min(1).max(200).optional(), + includeInactive: z.union([z.literal("true"), z.literal("false")]).optional(), +}); + +export type AllergyListQuery = z.infer; diff --git a/src/lib/validations/family-history.ts b/src/lib/validations/family-history.ts new file mode 100644 index 000000000..e8f2821b2 --- /dev/null +++ b/src/lib/validations/family-history.ts @@ -0,0 +1,74 @@ +/** + * Family-history request/response validation (v1.25, W-RECORDS). + * + * Source of truth for the `/api/family-history/*` wire contract. The Zod + * schemas here are reused by the OpenAPI registry so the spec stays + * single-source. `userId` is NEVER a body field — it is narrowed from the + * session/Bearer in every route and fed to the Prisma `where`. + * + * A structured FamilyMemberHistory-style RECORD (one condition per relative; + * not a time-series signal): `relationship` + `condition` label + optional + * `ageAtOnset`; the optional free-text note is encrypted at rest + * (`notesEncrypted` Bytes column). Patient-reported. + */ +import { z } from "zod/v4"; + +/* ── enum (mirrors the Prisma enum) ──────────────────────────────── */ + +export const familyRelationshipEnum = z.enum([ + "MOTHER", + "FATHER", + "SISTER", + "BROTHER", + "DAUGHTER", + "SON", + "GRANDMOTHER_MATERNAL", + "GRANDFATHER_MATERNAL", + "GRANDMOTHER_PATERNAL", + "GRANDFATHER_PATERNAL", + "AUNT", + "UNCLE", + "COUSIN", + "HALF_SIBLING", + "OTHER", +]); + +/* ── CRUD ─────────────────────────────────────────────────────────── */ + +/** + * Create a family-history entry. `condition` is the user-facing condition + * name; `ageAtOnset` is the relative's age (years) when it began; the + * free-text `note` is encrypted at rest. + */ +export const familyHistoryCreateSchema = z.object({ + relationship: familyRelationshipEnum, + condition: z.string().min(1).max(160), + ageAtOnset: z.number().int().min(0).max(120).nullable().optional(), + note: z.string().max(2000).nullable().optional(), +}); + +export type FamilyHistoryCreate = z.infer; + +/** + * Edit a family-history entry — every field optional; an omitted field is + * left untouched. Rejects unknown keys. + */ +export const familyHistoryUpdateSchema = z + .object({ + relationship: familyRelationshipEnum.optional(), + condition: z.string().min(1).max(160).optional(), + ageAtOnset: z.number().int().min(0).max(120).nullable().optional(), + note: z.string().max(2000).nullable().optional(), + }) + .strict(); + +export type FamilyHistoryUpdate = z.infer; + +/** History/list query — bounded. */ +export const familyHistoryListQuerySchema = z.object({ + limit: z.coerce.number().int().min(1).max(200).optional(), +}); + +export type FamilyHistoryListQuery = z.infer< + typeof familyHistoryListQuerySchema +>; From 7ec2555d9a635b319914925347574d0f11046d4c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 28 Jun 2026 16:03:15 +0200 Subject: [PATCH 3/4] feat(records): map allergies + family history to FHIR R4 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Emit `AllergyIntolerance` and `FamilyMemberHistory` resources in the health-record document bundle. Vendor-blind and non-diagnostic, matching the illness `Condition` stance: the user's own label rides `code.text` (never a machine-guessed clinical code); only the FHIR-fixed value sets — allergy category, clinical/verification status, the v3 RoleCode family-member concept — carry codings. Every resource is marked patient-reported (allergy `verificationStatus: unconfirmed` plus an explicit "not a clinical diagnosis" note) so a clinician's viewer never reads it as an adjudicated finding. The records are loaded owner-scoped in the export route and folded into the fhir + package bundle formats with their own Composition sections. --- .../health-record/__tests__/route.test.ts | 4 + src/app/api/export/health-record/route.ts | 46 +++- src/lib/fhir/__tests__/records.test.ts | 100 ++++++++ src/lib/fhir/build-bundle.ts | 52 +++++ src/lib/fhir/records.ts | 214 ++++++++++++++++++ src/lib/fhir/types.ts | 46 ++++ 6 files changed, 451 insertions(+), 11 deletions(-) create mode 100644 src/lib/fhir/__tests__/records.test.ts create mode 100644 src/lib/fhir/records.ts 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/lib/fhir/__tests__/records.test.ts b/src/lib/fhir/__tests__/records.test.ts new file mode 100644 index 000000000..7478b0809 --- /dev/null +++ b/src/lib/fhir/__tests__/records.test.ts @@ -0,0 +1,100 @@ +import { describe, it, expect } from "vitest"; + +import { + allergyIntoleranceResources, + familyMemberHistoryResources, +} from "@/lib/fhir/records"; +import type { AllergyDTO, FamilyHistoryEntryDTO } from "@/lib/records/dto"; + +function allergy(overrides: Partial = {}): AllergyDTO { + return { + id: "a1", + substance: "Penicillin", + category: "MEDICATION", + type: "ALLERGY", + severity: "SEVERE", + status: "ACTIVE", + onsetAt: "2020-01-02T00:00:00.000Z", + reaction: "Anaphylaxis", + note: null, + createdAt: "2020-01-02T00:00:00.000Z", + updatedAt: "2020-01-02T00:00:00.000Z", + ...overrides, + }; +} + +function family( + overrides: Partial = {}, +): FamilyHistoryEntryDTO { + return { + id: "f1", + relationship: "MOTHER", + condition: "Type 2 diabetes", + ageAtOnset: 55, + note: null, + createdAt: "2020-01-02T00:00:00.000Z", + updatedAt: "2020-01-02T00:00:00.000Z", + ...overrides, + }; +} + +describe("allergyIntoleranceResources", () => { + it("maps a record to a patient-reported AllergyIntolerance with the label on code.text", () => { + const [r] = allergyIntoleranceResources([allergy()]); + expect(r.resourceType).toBe("AllergyIntolerance"); + expect(r.id).toBe("allergy-1"); + expect(r.code).toEqual({ text: "Penicillin" }); + expect(r.type).toBe("allergy"); + expect(r.category).toEqual(["medication"]); + expect(r.criticality).toBe("high"); + expect(r.clinicalStatus?.coding?.[0].code).toBe("active"); + // Patient-reported, never clinician-confirmed. + expect(r.verificationStatus?.coding?.[0].code).toBe("unconfirmed"); + expect(r.patient.reference).toBe("Patient/patient-1"); + expect(r.onsetDateTime).toBe("2020-01-02T00:00:00.000Z"); + expect(r.reaction?.[0].manifestation[0]).toEqual({ text: "Anaphylaxis" }); + expect(r.reaction?.[0].severity).toBe("severe"); + // First note is the non-diagnostic guard rail. + expect(r.note?.[0].text).toMatch(/not a clinical diagnosis/i); + }); + + it("omits category for OTHER and reaction when no free-text reaction", () => { + const [r] = allergyIntoleranceResources([ + allergy({ category: "OTHER", reaction: null, severity: null }), + ]); + expect(r.category).toBeUndefined(); + expect(r.reaction).toBeUndefined(); + expect(r.criticality).toBeUndefined(); + }); + + it("never machine-guesses a substance code", () => { + const [r] = allergyIntoleranceResources([allergy()]); + expect(r.code.coding).toBeUndefined(); + }); +}); + +describe("familyMemberHistoryResources", () => { + it("maps a record to a completed FamilyMemberHistory with the label on condition.code.text", () => { + const [r] = familyMemberHistoryResources([family()]); + expect(r.resourceType).toBe("FamilyMemberHistory"); + expect(r.id).toBe("famhist-1"); + expect(r.status).toBe("completed"); + expect(r.relationship.coding?.[0].code).toBe("MTH"); + expect(r.condition?.[0].code).toEqual({ text: "Type 2 diabetes" }); + expect(r.condition?.[0].onsetAge).toEqual({ + value: 55, + unit: "a", + system: "http://unitsofmeasure.org", + code: "a", + }); + expect(r.note?.[0].text).toMatch(/not a clinical diagnosis/i); + }); + + it("falls back to the generic family-member RoleCode for OTHER and omits onsetAge when unknown", () => { + const [r] = familyMemberHistoryResources([ + family({ relationship: "OTHER", ageAtOnset: null }), + ]); + expect(r.relationship.coding?.[0].code).toBe("FAMMEMB"); + expect(r.condition?.[0].onsetAge).toBeUndefined(); + }); +}); diff --git a/src/lib/fhir/build-bundle.ts b/src/lib/fhir/build-bundle.ts index 4003a75b0..a30a9e99b 100644 --- a/src/lib/fhir/build-bundle.ts +++ b/src/lib/fhir/build-bundle.ts @@ -24,6 +24,11 @@ * user-supplied HTML (no markdown library, no `dangerouslySetInnerHTML`). */ import type { DoctorReportData } from "@/lib/doctor-report-data"; +import type { AllergyDTO, FamilyHistoryEntryDTO } from "@/lib/records/dto"; +import { + allergyIntoleranceResources, + familyMemberHistoryResources, +} from "@/lib/fhir/records"; import { LOINC_SYSTEM } from "@/lib/fhir/loinc-map"; import { type FhirBuildOptions, @@ -66,6 +71,17 @@ function escapeXml(value: string): string { .replace(/'/g, "'"); } +/** + * v1.25 (W-RECORDS) — the structured records folded into the document bundle. + * Passed separately from `DoctorReportData` (they are always-available + * reference records, not part of the time-windowed report aggregation). + * Empty arrays emit nothing. + */ +export interface FhirRecordInputs { + allergies?: AllergyDTO[]; + familyHistory?: FamilyHistoryEntryDTO[]; +} + /** * Build a FHIR R4 document Bundle from the aggregated report data. * @@ -76,6 +92,7 @@ export function buildFhirDocumentBundle( identity: FhirPatientIdentity, now: Date = new Date(), options: FhirBuildOptions = {}, + records: FhirRecordInputs = {}, ): FhirBundle { const patientRef: FhirReference = { reference: `Patient/${PATIENT_RESOURCE_ID}`, @@ -86,6 +103,8 @@ export function buildFhirDocumentBundle( const medicationRefs: FhirReference[] = []; const administrationRefs: FhirReference[] = []; const conditionRefs: FhirReference[] = []; + const allergyRefs: FhirReference[] = []; + const familyHistoryRefs: FhirReference[] = []; // --- Patient ----------------------------------------------------------- const patient = patientResource(data, identity); @@ -140,6 +159,20 @@ export function buildFhirDocumentBundle( entries.push({ fullUrl: `urn:uuid:${encounter.id}`, resource: encounter }); } + // --- AllergyIntolerance per recorded allergy (v1.25) ------------------- + // Always-available structured records (not time-windowed). Patient-reported + // (verificationStatus unconfirmed); the substance rides `code.text`. + for (const allergy of allergyIntoleranceResources(records.allergies ?? [])) { + entries.push({ fullUrl: `urn:uuid:${allergy.id}`, resource: allergy }); + allergyRefs.push({ reference: `AllergyIntolerance/${allergy.id}` }); + } + + // --- FamilyMemberHistory per recorded entry (v1.25) ------------------- + for (const fmh of familyMemberHistoryResources(records.familyHistory ?? [])) { + entries.push({ fullUrl: `urn:uuid:${fmh.id}`, resource: fmh }); + familyHistoryRefs.push({ reference: `FamilyMemberHistory/${fmh.id}` }); + } + // --- Composition (leading "cover" resource) ---------------------------- // v1.9.0 — when the aggregator capped the administration set, disclose // it in the narrative so the export is honest: it carries the @@ -226,6 +259,25 @@ export function buildFhirDocumentBundle( }, ] : []), + // v1.25 — structured allergy records (AllergyIntolerance). Present only + // when the account recorded at least one allergy. + ...(allergyRefs.length > 0 + ? [ + { + title: "Allergies", + entry: allergyRefs, + }, + ] + : []), + // v1.25 — structured family-history records (FamilyMemberHistory). + ...(familyHistoryRefs.length > 0 + ? [ + { + title: "Family history", + entry: familyHistoryRefs, + }, + ] + : []), ], }; diff --git a/src/lib/fhir/records.ts b/src/lib/fhir/records.ts new file mode 100644 index 000000000..df86985f1 --- /dev/null +++ b/src/lib/fhir/records.ts @@ -0,0 +1,214 @@ +/** + * v1.25 (W-RECORDS) — FHIR R4 emitters for the structured health records. + * + * Pure functions over the server-authoritative DTOs (`@/lib/records/dto`): + * - Allergy → `AllergyIntolerance` + * - FamilyHistoryEntry → `FamilyMemberHistory` + * + * Vendor-blind + non-diagnostic, matching the illness `Condition` stance in + * `./resources`: the user's own label rides `code.text` (NEVER a + * machine-guessed SNOMED/RxNorm code); only the FHIR-defined value sets + * (category, clinical/verification status, family-member RoleCode) carry + * codings. Every resource is marked patient-reported (allergy + * `verificationStatus: unconfirmed`; an explicit "not a clinical diagnosis" + * note) so a clinician's viewer never reads it as an adjudicated finding. + * + * No FHIR SDK — narrow hand-rolled interfaces only (`./types`), matching the + * project's "hand-rolled over the documented wire" convention. All text is + * plain text; never user-supplied HTML. + */ +import type { AllergyDTO, FamilyHistoryEntryDTO } from "@/lib/records/dto"; +import { PATIENT_RESOURCE_ID } from "@/lib/fhir/resources"; +import type { + FhirAllergyIntolerance, + FhirFamilyMemberHistory, + FhirReference, +} from "@/lib/fhir/types"; + +const patientRef: FhirReference = { + reference: `Patient/${PATIENT_RESOURCE_ID}`, +}; + +const ALLERGY_CLINICAL_SYSTEM = + "http://terminology.hl7.org/CodeSystem/allergyintolerance-clinical"; +const ALLERGY_VERIFICATION_SYSTEM = + "http://terminology.hl7.org/CodeSystem/allergyintolerance-verification"; + +/** FHIR-fixed `AllergyIntolerance.clinicalStatus` per record status. */ +const ALLERGY_CLINICAL: Record = { + ACTIVE: { code: "active", display: "Active" }, + INACTIVE: { code: "inactive", display: "Inactive" }, + RESOLVED: { code: "resolved", display: "Resolved" }, +}; + +/** Record category → the fixed `AllergyIntolerance.category` value set. */ +const ALLERGY_CATEGORY: Record< + string, + "food" | "medication" | "environment" | "biologic" | undefined +> = { + FOOD: "food", + MEDICATION: "medication", + ENVIRONMENT: "environment", + BIOLOGIC: "biologic", + OTHER: undefined, +}; + +/** Record severity → the fixed `reaction.severity` value set. */ +const ALLERGY_SEVERITY: Record = { + MILD: "mild", + MODERATE: "moderate", + SEVERE: "severe", +}; + +const ALLERGY_TYPE: Record = { + ALLERGY: "allergy", + INTOLERANCE: "intolerance", +}; + +/** + * Emit one `AllergyIntolerance` per record. Ids run `allergy-1..N`. The + * substance rides `code.text`; the broad category + clinical/verification + * status carry their FHIR-fixed codings. `verificationStatus` is always + * `unconfirmed` (patient-reported). The decrypted reaction free-text rides + * `reaction[].manifestation.text` with the graded severity; an explicit note + * marks the record as self-recorded. + */ +export function allergyIntoleranceResources( + allergies: AllergyDTO[], +): FhirAllergyIntolerance[] { + const out: FhirAllergyIntolerance[] = []; + let seq = 0; + for (const a of allergies) { + seq += 1; + const clinical = ALLERGY_CLINICAL[a.status] ?? ALLERGY_CLINICAL.ACTIVE; + const category = ALLERGY_CATEGORY[a.category]; + const severity = a.severity ? ALLERGY_SEVERITY[a.severity] : undefined; + const resource: FhirAllergyIntolerance = { + resourceType: "AllergyIntolerance", + id: `allergy-${seq}`, + clinicalStatus: { + coding: [ + { + system: ALLERGY_CLINICAL_SYSTEM, + code: clinical.code, + display: clinical.display, + }, + ], + }, + verificationStatus: { + coding: [ + { + system: ALLERGY_VERIFICATION_SYSTEM, + // Patient-reported record → "unconfirmed", never clinician-confirmed. + code: "unconfirmed", + display: "Unconfirmed", + }, + ], + }, + type: ALLERGY_TYPE[a.type] ?? "allergy", + ...(category ? { category: [category] } : {}), + ...(severity + ? { criticality: severity === "severe" ? "high" : "low" } + : {}), + // The user's own substance label — never a machine-guessed code. + code: { text: a.substance }, + patient: patientRef, + ...(a.onsetAt ? { onsetDateTime: a.onsetAt } : {}), + note: [ + { + text: "Self-recorded allergy/intolerance; patient-reported, not a clinical diagnosis.", + }, + ...(a.note ? [{ text: a.note }] : []), + ], + }; + if (a.reaction) { + resource.reaction = [ + { + manifestation: [{ text: a.reaction }], + ...(severity ? { severity } : {}), + }, + ]; + } + out.push(resource); + } + return out; +} + +const ROLE_CODE_SYSTEM = "http://terminology.hl7.org/CodeSystem/v3-RoleCode"; + +/** + * Record relationship → HL7 v3 `RoleCode` family-member concept (the fixed + * code system FHIR `FamilyMemberHistory.relationship` examples use). `OTHER` + * falls back to the generic `FAMMEMB` (family member) concept — an honest + * fallback, never a fabricated specific relation. `display` carries a stable + * English label; the localized label lives on the client surface. + */ +const RELATIONSHIP_ROLE: Record = { + MOTHER: { code: "MTH", display: "Mother" }, + FATHER: { code: "FTH", display: "Father" }, + SISTER: { code: "SIS", display: "Sister" }, + BROTHER: { code: "BRO", display: "Brother" }, + DAUGHTER: { code: "DAUC", display: "Daughter" }, + SON: { code: "SONC", display: "Son" }, + GRANDMOTHER_MATERNAL: { code: "MGRMTH", display: "Maternal grandmother" }, + GRANDFATHER_MATERNAL: { code: "MGRFTH", display: "Maternal grandfather" }, + GRANDMOTHER_PATERNAL: { code: "PGRMTH", display: "Paternal grandmother" }, + GRANDFATHER_PATERNAL: { code: "PGRFTH", display: "Paternal grandfather" }, + AUNT: { code: "AUNT", display: "Aunt" }, + UNCLE: { code: "UNCLE", display: "Uncle" }, + COUSIN: { code: "COUSN", display: "Cousin" }, + HALF_SIBLING: { code: "HSIB", display: "Half-sibling" }, + OTHER: { code: "FAMMEMB", display: "Family member" }, +}; + +/** + * Emit one `FamilyMemberHistory` per entry (one condition each). Ids run + * `famhist-1..N`. The relationship carries the v3 RoleCode coding + a readable + * `.text`; the condition rides `condition[].code.text` (no machine-guessed + * code) with an optional `onsetAge` in years (UCUM `a`). `status: "completed"`. + */ +export function familyMemberHistoryResources( + entries: FamilyHistoryEntryDTO[], +): FhirFamilyMemberHistory[] { + const out: FhirFamilyMemberHistory[] = []; + let seq = 0; + for (const e of entries) { + seq += 1; + const role = RELATIONSHIP_ROLE[e.relationship] ?? RELATIONSHIP_ROLE.OTHER; + out.push({ + resourceType: "FamilyMemberHistory", + id: `famhist-${seq}`, + status: "completed", + patient: patientRef, + relationship: { + coding: [ + { system: ROLE_CODE_SYSTEM, code: role.code, display: role.display }, + ], + text: role.display, + }, + condition: [ + { + // The user's own condition label — never a machine-guessed code. + code: { text: e.condition }, + ...(e.ageAtOnset !== null + ? { + onsetAge: { + value: e.ageAtOnset, + unit: "a", + system: "http://unitsofmeasure.org", + code: "a", + }, + } + : {}), + }, + ], + note: [ + { + text: "Self-recorded family history; patient-reported, not a clinical diagnosis.", + }, + ...(e.note ? [{ text: e.note }] : []), + ], + }); + } + return out; +} diff --git a/src/lib/fhir/types.ts b/src/lib/fhir/types.ts index 303d327d7..95e29c9eb 100644 --- a/src/lib/fhir/types.ts +++ b/src/lib/fhir/types.ts @@ -245,6 +245,50 @@ export interface FhirEncounter { reasonReference?: FhirReference[]; } +/** + * v1.25 (W-RECORDS) — R4 `AllergyIntolerance`. A self-recorded allergy / + * intolerance. `clinicalStatus`/`verificationStatus` carry "what the user + * asserts" (verification is always `unconfirmed` — patient-reported, not a + * clinician-confirmed diagnosis). The substance rides `code.text` (no + * machine-guessed SNOMED/RxNorm code); the broad category rides `category[]`. + */ +export interface FhirAllergyIntolerance { + resourceType: "AllergyIntolerance"; + id: string; + clinicalStatus?: FhirCodeableConcept; + verificationStatus?: FhirCodeableConcept; + type?: "allergy" | "intolerance"; + category?: ("food" | "medication" | "environment" | "biologic")[]; + criticality?: "low" | "high" | "unable-to-assess"; + code: FhirCodeableConcept; + patient: FhirReference; + onsetDateTime?: string; + reaction?: { + manifestation: FhirCodeableConcept[]; + severity?: "mild" | "moderate" | "severe"; + }[]; + note?: FhirAnnotation[]; +} + +/** + * v1.25 (W-RECORDS) — R4 `FamilyMemberHistory`. One relative's condition as + * recorded by the user. `relationship` carries the SNOMED family-member + * concept; the condition rides `condition[].code.text` (no machine-guessed + * code). `status: "completed"` marks the record as captured. Patient-reported. + */ +export interface FhirFamilyMemberHistory { + resourceType: "FamilyMemberHistory"; + id: string; + status: "partial" | "completed" | "entered-in-error" | "health-unknown"; + patient: FhirReference; + relationship: FhirCodeableConcept; + condition?: { + code: FhirCodeableConcept; + onsetAge?: FhirQuantity; + }[]; + note?: FhirAnnotation[]; +} + export type FhirResource = | FhirComposition | FhirPatient @@ -254,6 +298,8 @@ export type FhirResource = | FhirMedicationAdministration | FhirCondition | FhirEncounter + | FhirAllergyIntolerance + | FhirFamilyMemberHistory | FhirDiagnosticReport; export interface FhirBundleEntry { From a37883e3f8d17e30d02bb5df876836cd263133fe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 28 Jun 2026 16:03:25 +0200 Subject: [PATCH 4/4] feat(records): surface allergies + family history in Settings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a new "Medical history" (Anamnese) Settings section that hosts the two structured-record managers — allergies and family history — each a list + add/edit sheet + delete-with-confirm built on the shared query-key factory and the ResponsiveSheet pattern. It sits between the illness journal and the preventive-care reminders and is always shown (not module-gated, like Vorsorge), since these are foundational health-profile records. The section carries a plain non-diagnostic note ("your own records, not a medical diagnosis"). Full i18n across all six locales (de/en/es/fr/it/pl). --- messages/de.json | 113 +++++++ messages/en.json | 113 +++++++ messages/es.json | 113 +++++++ messages/fr.json | 113 +++++++ messages/it.json | 113 +++++++ messages/pl.json | 113 +++++++ src/app/settings/[section]/page.tsx | 2 + src/components/records/allergy-form.tsx | 282 ++++++++++++++++++ src/components/records/allergy-manager.tsx | 179 +++++++++++ .../records/family-history-form.tsx | 205 +++++++++++++ .../records/family-history-manager.tsx | 162 ++++++++++ .../__tests__/settings-shell.test.tsx | 2 + src/components/settings/anamnesis-section.tsx | 56 ++++ .../settings/section-placeholder.tsx | 2 + src/components/settings/section-slugs.ts | 4 + src/components/settings/settings-shell.tsx | 10 + 16 files changed, 1582 insertions(+) create mode 100644 src/components/records/allergy-form.tsx create mode 100644 src/components/records/allergy-manager.tsx create mode 100644 src/components/records/family-history-form.tsx create mode 100644 src/components/records/family-history-manager.tsx create mode 100644 src/components/settings/anamnesis-section.tsx diff --git a/messages/de.json b/messages/de.json index 146bbed3a..1e0fb223e 100644 --- a/messages/de.json +++ b/messages/de.json @@ -4266,6 +4266,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", @@ -7426,5 +7431,113 @@ "reload": "Neu laden", "dismiss": "Schließen" } + }, + "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 23ae4f2b7..0ef19875b 100644 --- a/messages/en.json +++ b/messages/en.json @@ -4266,6 +4266,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", @@ -7426,5 +7431,113 @@ "reload": "Reload", "dismiss": "Dismiss" } + }, + "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 e2f12b186..02a843ffc 100644 --- a/messages/es.json +++ b/messages/es.json @@ -4266,6 +4266,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", @@ -7426,5 +7431,113 @@ "reload": "Recargar", "dismiss": "Descartar" } + }, + "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 5b953e533..6d5e46f33 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -4266,6 +4266,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", @@ -7426,5 +7431,113 @@ "reload": "Recharger", "dismiss": "Ignorer" } + }, + "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 1c446bda3..e54efa09b 100644 --- a/messages/it.json +++ b/messages/it.json @@ -4266,6 +4266,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", @@ -7426,5 +7431,113 @@ "reload": "Ricarica", "dismiss": "Ignora" } + }, + "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 cb6fd0d43..fa05344d5 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -4266,6 +4266,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", @@ -7426,5 +7431,113 @@ "reload": "Załaduj ponownie", "dismiss": "Odrzuć" } + }, + "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/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)} + /> +
+
+ +