mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
feat(records): CRUD API + OpenAPI for allergies and family history
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`.
This commit is contained in:
1 parent
3ded891641
commit
5052b2cc08
15 files changed
+1913
No files matched your search
@@ -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:
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
/**
|
||||
* `GET /api/allergies/{id}` — one owned, live allergy record.
|
||||
* `PATCH /api/allergies/{id}` — edit it (partial; field-by-field).
|
||||
* `DELETE /api/allergies/{id}` — soft-delete it (idempotent; `{ deleted: true }`).
|
||||
*
|
||||
* Owner-scoped. `userId` is narrowed from auth and fed to the Prisma `where`;
|
||||
* the body never carries it. The free-text `reaction` + `note` are encrypted
|
||||
* at rest.
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import {
|
||||
apiSuccess,
|
||||
apiError,
|
||||
getClientIp,
|
||||
returnAllZodIssues,
|
||||
safeJson,
|
||||
} from "@/lib/api-response";
|
||||
import { encryptToBytes } from "@/lib/ai/coach/bytes-codec";
|
||||
import { allergyUpdateSchema } from "@/lib/validations/allergy";
|
||||
import { toAllergyDTO } from "@/lib/records/dto";
|
||||
import type { Prisma } from "@/generated/prisma/client";
|
||||
|
||||
type RouteParams = { params: Promise<{ id: string }> };
|
||||
|
||||
export const GET = apiHandler(
|
||||
async (request: NextRequest, { params }: RouteParams) => {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
const { id } = await params;
|
||||
const row = await prisma.allergy.findUnique({ where: { id } });
|
||||
if (!row || row.userId !== user.id || row.deletedAt !== null) {
|
||||
return apiError("Allergy not found", 404);
|
||||
}
|
||||
|
||||
annotate({
|
||||
action: { name: "allergy.read", entity_type: "allergy", entity_id: id },
|
||||
});
|
||||
|
||||
return apiSuccess(toAllergyDTO(row));
|
||||
},
|
||||
);
|
||||
|
||||
export const PATCH = apiHandler(
|
||||
async (request: NextRequest, { params }: RouteParams) => {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
const { id } = await params;
|
||||
const existing = await prisma.allergy.findUnique({
|
||||
where: { id },
|
||||
select: { id: true, userId: true, deletedAt: true },
|
||||
});
|
||||
if (
|
||||
!existing ||
|
||||
existing.userId !== user.id ||
|
||||
existing.deletedAt !== null
|
||||
) {
|
||||
return apiError("Allergy not found", 404);
|
||||
}
|
||||
|
||||
const { data: rawBody, error: jsonError } = await safeJson(request, {
|
||||
maxBytes: 16 * 1024,
|
||||
});
|
||||
if (jsonError) return jsonError;
|
||||
|
||||
const parsed = allergyUpdateSchema.safeParse(rawBody);
|
||||
if (!parsed.success) {
|
||||
return returnAllZodIssues(parsed.error, 422, {
|
||||
errorCode: "allergy.invalid",
|
||||
});
|
||||
}
|
||||
const entry = parsed.data;
|
||||
|
||||
// Field-by-field — only fields actually present in the body are written.
|
||||
const data: Prisma.AllergyUpdateInput = {};
|
||||
if (entry.substance !== undefined) data.substance = entry.substance;
|
||||
if (entry.category !== undefined) data.category = entry.category;
|
||||
if (entry.type !== undefined) data.type = entry.type;
|
||||
if (entry.severity !== undefined) data.severity = entry.severity;
|
||||
if (entry.status !== undefined) data.status = entry.status;
|
||||
if (entry.onsetAt !== undefined) {
|
||||
data.onsetAt = entry.onsetAt ? new Date(entry.onsetAt) : null;
|
||||
}
|
||||
if (entry.reaction !== undefined) {
|
||||
data.reactionEncrypted = entry.reaction
|
||||
? encryptToBytes(entry.reaction)
|
||||
: null;
|
||||
}
|
||||
if (entry.note !== undefined) {
|
||||
data.notesEncrypted = entry.note ? encryptToBytes(entry.note) : null;
|
||||
}
|
||||
|
||||
const updated = await prisma.allergy.update({ where: { id }, data });
|
||||
|
||||
await auditLog("allergy.update", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(request),
|
||||
details: { allergyId: id },
|
||||
});
|
||||
|
||||
annotate({
|
||||
action: { name: "allergy.update", entity_type: "allergy", entity_id: id },
|
||||
});
|
||||
|
||||
return apiSuccess(toAllergyDTO(updated));
|
||||
},
|
||||
);
|
||||
|
||||
export const DELETE = apiHandler(
|
||||
async (request: NextRequest, { params }: RouteParams) => {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
const { id } = await params;
|
||||
const existing = await prisma.allergy.findUnique({
|
||||
where: { id },
|
||||
select: { id: true, userId: true, deletedAt: true },
|
||||
});
|
||||
if (!existing || existing.userId !== user.id) {
|
||||
return apiError("Allergy not found", 404);
|
||||
}
|
||||
|
||||
if (existing.deletedAt === null) {
|
||||
await prisma.allergy.update({
|
||||
where: { id },
|
||||
data: { deletedAt: new Date() },
|
||||
});
|
||||
await auditLog("allergy.delete", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(request),
|
||||
details: { allergyId: id },
|
||||
});
|
||||
}
|
||||
|
||||
annotate({
|
||||
action: { name: "allergy.delete", entity_type: "allergy", entity_id: id },
|
||||
});
|
||||
|
||||
return apiSuccess({ deleted: true });
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,121 @@
|
||||
/**
|
||||
* `GET /api/allergies` — newest-first list of the account's allergies.
|
||||
* `POST /api/allergies` — create one allergy/intolerance record.
|
||||
*
|
||||
* A structured AllergyIntolerance-style RECORD, patient-reported — never a
|
||||
* clinical diagnosis the app asserts. `userId` is narrowed from auth and fed
|
||||
* to the Prisma `where`; it is never a body field. The `data` object is built
|
||||
* field-by-field (no mass assignment); the free-text `reaction` + `note` are
|
||||
* encrypted at rest.
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import {
|
||||
apiSuccess,
|
||||
getClientIp,
|
||||
returnAllZodIssues,
|
||||
safeJson,
|
||||
} from "@/lib/api-response";
|
||||
import { withIdempotency } from "@/lib/idempotency";
|
||||
import { encryptToBytes } from "@/lib/ai/coach/bytes-codec";
|
||||
import {
|
||||
allergyCreateSchema,
|
||||
allergyListQuerySchema,
|
||||
} from "@/lib/validations/allergy";
|
||||
import { toAllergyDTO } from "@/lib/records/dto";
|
||||
|
||||
export const GET = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
const params = new URL(request.url).searchParams;
|
||||
const parsed = allergyListQuerySchema.safeParse({
|
||||
limit: params.get("limit") ?? undefined,
|
||||
includeInactive: params.get("includeInactive") ?? undefined,
|
||||
});
|
||||
if (!parsed.success) {
|
||||
return returnAllZodIssues(parsed.error, 422, {
|
||||
errorCode: "allergy.invalid",
|
||||
});
|
||||
}
|
||||
|
||||
const limit = parsed.data.limit ?? 100;
|
||||
const rows = await prisma.allergy.findMany({
|
||||
where: {
|
||||
userId: user.id,
|
||||
deletedAt: null,
|
||||
...(parsed.data.includeInactive === "false" ? { status: "ACTIVE" } : {}),
|
||||
},
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: limit,
|
||||
});
|
||||
|
||||
annotate({
|
||||
action: { name: "allergy.list", entity_type: "allergy" },
|
||||
meta: { count: rows.length },
|
||||
});
|
||||
|
||||
return apiSuccess(rows.map(toAllergyDTO));
|
||||
});
|
||||
|
||||
// `withIdempotency` lets an iOS retry / double-tap re-send the same
|
||||
// `Idempotency-Key` without minting a duplicate record (the labs /
|
||||
// illness create precedent).
|
||||
export const POST = apiHandler(withIdempotency<[NextRequest]>(postAllergy));
|
||||
|
||||
async function postAllergy(request: NextRequest): Promise<Response> {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
const { data: rawBody, error: jsonError } = await safeJson(request, {
|
||||
maxBytes: 16 * 1024,
|
||||
});
|
||||
if (jsonError) return jsonError;
|
||||
|
||||
const parsed = allergyCreateSchema.safeParse(rawBody);
|
||||
if (!parsed.success) {
|
||||
annotate({
|
||||
action: { name: "allergy.validation-failed" },
|
||||
meta: { issue_count: parsed.error.issues.length },
|
||||
});
|
||||
return returnAllZodIssues(parsed.error, 422, {
|
||||
errorCode: "allergy.invalid",
|
||||
});
|
||||
}
|
||||
|
||||
const entry = parsed.data;
|
||||
|
||||
// Field-by-field — never spread the parsed object whole.
|
||||
const created = await prisma.allergy.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
substance: entry.substance,
|
||||
category: entry.category,
|
||||
type: entry.type,
|
||||
severity: entry.severity ?? null,
|
||||
status: entry.status,
|
||||
onsetAt: entry.onsetAt ? new Date(entry.onsetAt) : null,
|
||||
reactionEncrypted: entry.reaction ? encryptToBytes(entry.reaction) : null,
|
||||
notesEncrypted: entry.note ? encryptToBytes(entry.note) : null,
|
||||
},
|
||||
});
|
||||
|
||||
await auditLog("allergy.create", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(request),
|
||||
details: { allergyId: created.id, category: created.category },
|
||||
});
|
||||
|
||||
annotate({
|
||||
action: {
|
||||
name: "allergy.create",
|
||||
entity_type: "allergy",
|
||||
entity_id: created.id,
|
||||
},
|
||||
meta: { category: created.category, type: created.type },
|
||||
});
|
||||
|
||||
return apiSuccess(toAllergyDTO(created), 201);
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
/**
|
||||
* `GET /api/family-history/{id}` — one owned, live entry.
|
||||
* `PATCH /api/family-history/{id}` — edit it (partial; field-by-field).
|
||||
* `DELETE /api/family-history/{id}` — soft-delete it (idempotent;
|
||||
* returns `{ deleted: true }`).
|
||||
*
|
||||
* Owner-scoped. `userId` is narrowed from auth and fed to the Prisma `where`;
|
||||
* the body never carries it. The free-text `note` is encrypted at rest.
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import {
|
||||
apiSuccess,
|
||||
apiError,
|
||||
getClientIp,
|
||||
returnAllZodIssues,
|
||||
safeJson,
|
||||
} from "@/lib/api-response";
|
||||
import { encryptToBytes } from "@/lib/ai/coach/bytes-codec";
|
||||
import { familyHistoryUpdateSchema } from "@/lib/validations/family-history";
|
||||
import { toFamilyHistoryEntryDTO } from "@/lib/records/dto";
|
||||
import type { Prisma } from "@/generated/prisma/client";
|
||||
|
||||
type RouteParams = { params: Promise<{ id: string }> };
|
||||
|
||||
export const GET = apiHandler(
|
||||
async (request: NextRequest, { params }: RouteParams) => {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
const { id } = await params;
|
||||
const row = await prisma.familyHistoryEntry.findUnique({ where: { id } });
|
||||
if (!row || row.userId !== user.id || row.deletedAt !== null) {
|
||||
return apiError("Family history entry not found", 404);
|
||||
}
|
||||
|
||||
annotate({
|
||||
action: {
|
||||
name: "family-history.read",
|
||||
entity_type: "family_history",
|
||||
entity_id: id,
|
||||
},
|
||||
});
|
||||
|
||||
return apiSuccess(toFamilyHistoryEntryDTO(row));
|
||||
},
|
||||
);
|
||||
|
||||
export const PATCH = apiHandler(
|
||||
async (request: NextRequest, { params }: RouteParams) => {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
const { id } = await params;
|
||||
const existing = await prisma.familyHistoryEntry.findUnique({
|
||||
where: { id },
|
||||
select: { id: true, userId: true, deletedAt: true },
|
||||
});
|
||||
if (
|
||||
!existing ||
|
||||
existing.userId !== user.id ||
|
||||
existing.deletedAt !== null
|
||||
) {
|
||||
return apiError("Family history entry not found", 404);
|
||||
}
|
||||
|
||||
const { data: rawBody, error: jsonError } = await safeJson(request, {
|
||||
maxBytes: 16 * 1024,
|
||||
});
|
||||
if (jsonError) return jsonError;
|
||||
|
||||
const parsed = familyHistoryUpdateSchema.safeParse(rawBody);
|
||||
if (!parsed.success) {
|
||||
return returnAllZodIssues(parsed.error, 422, {
|
||||
errorCode: "familyHistory.invalid",
|
||||
});
|
||||
}
|
||||
const entry = parsed.data;
|
||||
|
||||
// Field-by-field — only fields actually present in the body are written.
|
||||
const data: Prisma.FamilyHistoryEntryUpdateInput = {};
|
||||
if (entry.relationship !== undefined) {
|
||||
data.relationship = entry.relationship;
|
||||
}
|
||||
if (entry.condition !== undefined) data.condition = entry.condition;
|
||||
if (entry.ageAtOnset !== undefined) data.ageAtOnset = entry.ageAtOnset;
|
||||
if (entry.note !== undefined) {
|
||||
data.notesEncrypted = entry.note ? encryptToBytes(entry.note) : null;
|
||||
}
|
||||
|
||||
const updated = await prisma.familyHistoryEntry.update({
|
||||
where: { id },
|
||||
data,
|
||||
});
|
||||
|
||||
await auditLog("family-history.update", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(request),
|
||||
details: { entryId: id },
|
||||
});
|
||||
|
||||
annotate({
|
||||
action: {
|
||||
name: "family-history.update",
|
||||
entity_type: "family_history",
|
||||
entity_id: id,
|
||||
},
|
||||
});
|
||||
|
||||
return apiSuccess(toFamilyHistoryEntryDTO(updated));
|
||||
},
|
||||
);
|
||||
|
||||
export const DELETE = apiHandler(
|
||||
async (request: NextRequest, { params }: RouteParams) => {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
const { id } = await params;
|
||||
const existing = await prisma.familyHistoryEntry.findUnique({
|
||||
where: { id },
|
||||
select: { id: true, userId: true, deletedAt: true },
|
||||
});
|
||||
if (!existing || existing.userId !== user.id) {
|
||||
return apiError("Family history entry not found", 404);
|
||||
}
|
||||
|
||||
if (existing.deletedAt === null) {
|
||||
await prisma.familyHistoryEntry.update({
|
||||
where: { id },
|
||||
data: { deletedAt: new Date() },
|
||||
});
|
||||
await auditLog("family-history.delete", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(request),
|
||||
details: { entryId: id },
|
||||
});
|
||||
}
|
||||
|
||||
annotate({
|
||||
action: {
|
||||
name: "family-history.delete",
|
||||
entity_type: "family_history",
|
||||
entity_id: id,
|
||||
},
|
||||
});
|
||||
|
||||
return apiSuccess({ deleted: true });
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,112 @@
|
||||
/**
|
||||
* `GET /api/family-history` — newest-first list of the account's family-history
|
||||
* entries.
|
||||
* `POST /api/family-history` — create one condition-by-relative record.
|
||||
*
|
||||
* A structured FamilyMemberHistory-style RECORD, patient-reported — never a
|
||||
* clinical diagnosis the app asserts. `userId` is narrowed from auth and fed
|
||||
* to the Prisma `where`; it is never a body field. The `data` object is built
|
||||
* field-by-field (no mass assignment); the free-text `note` is encrypted at
|
||||
* rest.
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import {
|
||||
apiSuccess,
|
||||
getClientIp,
|
||||
returnAllZodIssues,
|
||||
safeJson,
|
||||
} from "@/lib/api-response";
|
||||
import { withIdempotency } from "@/lib/idempotency";
|
||||
import { encryptToBytes } from "@/lib/ai/coach/bytes-codec";
|
||||
import {
|
||||
familyHistoryCreateSchema,
|
||||
familyHistoryListQuerySchema,
|
||||
} from "@/lib/validations/family-history";
|
||||
import { toFamilyHistoryEntryDTO } from "@/lib/records/dto";
|
||||
|
||||
export const GET = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
const params = new URL(request.url).searchParams;
|
||||
const parsed = familyHistoryListQuerySchema.safeParse({
|
||||
limit: params.get("limit") ?? undefined,
|
||||
});
|
||||
if (!parsed.success) {
|
||||
return returnAllZodIssues(parsed.error, 422, {
|
||||
errorCode: "familyHistory.invalid",
|
||||
});
|
||||
}
|
||||
|
||||
const limit = parsed.data.limit ?? 100;
|
||||
const rows = await prisma.familyHistoryEntry.findMany({
|
||||
where: { userId: user.id, deletedAt: null },
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: limit,
|
||||
});
|
||||
|
||||
annotate({
|
||||
action: { name: "family-history.list", entity_type: "family_history" },
|
||||
meta: { count: rows.length },
|
||||
});
|
||||
|
||||
return apiSuccess(rows.map(toFamilyHistoryEntryDTO));
|
||||
});
|
||||
|
||||
export const POST = apiHandler(
|
||||
withIdempotency<[NextRequest]>(postFamilyHistory),
|
||||
);
|
||||
|
||||
async function postFamilyHistory(request: NextRequest): Promise<Response> {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
const { data: rawBody, error: jsonError } = await safeJson(request, {
|
||||
maxBytes: 16 * 1024,
|
||||
});
|
||||
if (jsonError) return jsonError;
|
||||
|
||||
const parsed = familyHistoryCreateSchema.safeParse(rawBody);
|
||||
if (!parsed.success) {
|
||||
annotate({
|
||||
action: { name: "family-history.validation-failed" },
|
||||
meta: { issue_count: parsed.error.issues.length },
|
||||
});
|
||||
return returnAllZodIssues(parsed.error, 422, {
|
||||
errorCode: "familyHistory.invalid",
|
||||
});
|
||||
}
|
||||
|
||||
const entry = parsed.data;
|
||||
|
||||
// Field-by-field — never spread the parsed object whole.
|
||||
const created = await prisma.familyHistoryEntry.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
relationship: entry.relationship,
|
||||
condition: entry.condition,
|
||||
ageAtOnset: entry.ageAtOnset ?? null,
|
||||
notesEncrypted: entry.note ? encryptToBytes(entry.note) : null,
|
||||
},
|
||||
});
|
||||
|
||||
await auditLog("family-history.create", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(request),
|
||||
details: { entryId: created.id, relationship: created.relationship },
|
||||
});
|
||||
|
||||
annotate({
|
||||
action: {
|
||||
name: "family-history.create",
|
||||
entity_type: "family_history",
|
||||
entity_id: created.id,
|
||||
},
|
||||
meta: { relationship: created.relationship },
|
||||
});
|
||||
|
||||
return apiSuccess(toFamilyHistoryEntryDTO(created), 201);
|
||||
}
|
||||
@@ -75,6 +75,7 @@ const openApiBase: Pick<
|
||||
{ name: "MeasurementReminders" },
|
||||
{ name: "Labs" },
|
||||
{ name: "Illness" },
|
||||
{ name: "Records" },
|
||||
{ name: "Admin" },
|
||||
{ name: "Meta" },
|
||||
],
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
/**
|
||||
* OpenAPI route table for structured allergies (`/api/allergies`).
|
||||
*
|
||||
* Part of the OpenAPI route table; aggregated in `./index.ts`. The request
|
||||
* bodies + queries reuse the runtime Zod schemas from
|
||||
* `@/lib/validations/allergy` so the wire contract stays single-source.
|
||||
* Response shapes are declared here to mirror the server-authoritative DTOs in
|
||||
* `@/lib/records/dto.ts` (the routes serialise a decrypted `reaction` + `note`
|
||||
* the input schemas don't carry; iOS renders the DTO, it never recomputes it).
|
||||
*
|
||||
* A structured AllergyIntolerance-style RECORD — patient-reported, never a
|
||||
* clinical diagnosis. Owner-scoped; auth-gated.
|
||||
*/
|
||||
import type { ZodOpenApiObject } from "zod-openapi";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import {
|
||||
allergyCreateSchema,
|
||||
allergyUpdateSchema,
|
||||
allergyListQuerySchema,
|
||||
allergyCategoryEnum,
|
||||
allergyTypeEnum,
|
||||
allergySeverityEnum,
|
||||
allergyStatusEnum,
|
||||
} from "@/lib/validations/allergy";
|
||||
|
||||
import { dataEnvelope, errorEnvelope, stdResponses } from "./shared";
|
||||
|
||||
allergyCreateSchema.meta({
|
||||
id: "CreateAllergyRequest",
|
||||
description:
|
||||
"Record an allergy/intolerance. `substance` is the user-facing allergen name (the FHIR AllergyIntolerance `code.text` anchor — never a machine-guessed code). The free-text `reaction` + `note` are encrypted at rest. `onsetAt` is optional (unknown when omitted). Patient-reported.",
|
||||
});
|
||||
|
||||
allergyUpdateSchema.meta({
|
||||
id: "UpdateAllergyRequest",
|
||||
description:
|
||||
"Partial edit of an allergy; an omitted key leaves the column untouched. A `null` `severity`/`onsetAt`/`reaction`/`note` clears that field. Rejects unknown keys.",
|
||||
});
|
||||
|
||||
allergyListQuerySchema.meta({
|
||||
id: "ListAllergiesQuery",
|
||||
description:
|
||||
"Query params for the allergy list: optional `limit` (1–200, default 100) and `includeInactive` ('true' | 'false'); 'false' returns only ACTIVE records. Newest-first.",
|
||||
});
|
||||
|
||||
const allergy = z
|
||||
.object({
|
||||
id: z.string(),
|
||||
substance: z.string(),
|
||||
category: allergyCategoryEnum,
|
||||
type: allergyTypeEnum,
|
||||
severity: allergySeverityEnum.nullable(),
|
||||
status: allergyStatusEnum,
|
||||
onsetAt: z.string().nullable(),
|
||||
reaction: z.string().nullable(),
|
||||
note: z.string().nullable(),
|
||||
createdAt: z.string(),
|
||||
updatedAt: z.string(),
|
||||
})
|
||||
.meta({
|
||||
id: "Allergy",
|
||||
description:
|
||||
"A stored allergy/intolerance record. `reaction` + `note` are the decrypted free-text (or null on a key-rotation gap — fail-soft, never 500). `severity`/`onsetAt` are null when not assessed/known.",
|
||||
});
|
||||
|
||||
const allergyNotFound = {
|
||||
"404": {
|
||||
description: "Allergy not found (or owned by another user).",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
} as const;
|
||||
|
||||
export const allergyPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
"/api/allergies": {
|
||||
get: {
|
||||
tags: ["Records"],
|
||||
summary: "List allergies (v1.25)",
|
||||
description:
|
||||
"Returns the caller's live (non-deleted) allergy/intolerance records, newest-first. `includeInactive=false` returns only ACTIVE records.",
|
||||
requestParams: { query: allergyListQuerySchema },
|
||||
responses: {
|
||||
"200": {
|
||||
description: "The caller's allergy records.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(z.array(allergy), "ListAllergiesEnvelope"),
|
||||
},
|
||||
},
|
||||
},
|
||||
...stdResponses,
|
||||
},
|
||||
},
|
||||
post: {
|
||||
tags: ["Records"],
|
||||
summary: "Record an allergy (v1.25)",
|
||||
description:
|
||||
"Creates one allergy/intolerance record for the caller. The free-text reaction + note are AES-256-GCM encrypted before write. Audits as `allergy.create`.",
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: { "application/json": { schema: allergyCreateSchema } },
|
||||
},
|
||||
responses: {
|
||||
"201": {
|
||||
description: "Allergy created.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(allergy, "CreateAllergyEnvelope"),
|
||||
},
|
||||
},
|
||||
},
|
||||
...stdResponses,
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/allergies/{id}": {
|
||||
get: {
|
||||
tags: ["Records"],
|
||||
summary: "Read a single allergy (v1.25)",
|
||||
description:
|
||||
"Returns the record including its decrypted `reaction` + `note`. Owner-scoped; a cross-user or tombstoned id 404s.",
|
||||
requestParams: { path: z.object({ id: z.string() }) },
|
||||
responses: {
|
||||
"200": {
|
||||
description: "The allergy record.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(allergy, "GetAllergyEnvelope"),
|
||||
},
|
||||
},
|
||||
},
|
||||
...allergyNotFound,
|
||||
...stdResponses,
|
||||
},
|
||||
},
|
||||
patch: {
|
||||
tags: ["Records"],
|
||||
summary: "Edit an allergy (v1.25)",
|
||||
description:
|
||||
"Partial edit; omitted fields are left untouched. Audits as `allergy.update`. Owner-scoped.",
|
||||
requestParams: { path: z.object({ id: z.string() }) },
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: { "application/json": { schema: allergyUpdateSchema } },
|
||||
},
|
||||
responses: {
|
||||
"200": {
|
||||
description: "Allergy updated.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(allergy, "UpdateAllergyEnvelope"),
|
||||
},
|
||||
},
|
||||
},
|
||||
...allergyNotFound,
|
||||
...stdResponses,
|
||||
},
|
||||
},
|
||||
delete: {
|
||||
tags: ["Records"],
|
||||
summary: "Soft-delete an allergy (v1.25)",
|
||||
description:
|
||||
"Stamps `deletedAt` (tombstone). Idempotent — a re-delete is a no-op. Returns the `{ deleted: true }` envelope. Audits as `allergy.delete`. Owner-scoped.",
|
||||
requestParams: { path: z.object({ id: z.string() }) },
|
||||
responses: {
|
||||
"200": {
|
||||
description: "Soft-deleted.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(
|
||||
z.object({ deleted: z.boolean() }),
|
||||
"DeleteAllergyEnvelope",
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
...allergyNotFound,
|
||||
...stdResponses,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,191 @@
|
||||
/**
|
||||
* OpenAPI route table for structured family history (`/api/family-history`).
|
||||
*
|
||||
* Part of the OpenAPI route table; aggregated in `./index.ts`. The request
|
||||
* bodies + queries reuse the runtime Zod schemas from
|
||||
* `@/lib/validations/family-history` so the wire contract stays single-source.
|
||||
* Response shapes mirror the server-authoritative DTOs in
|
||||
* `@/lib/records/dto.ts` (the routes serialise a decrypted `note` the input
|
||||
* schemas don't carry; iOS renders the DTO, it never recomputes it).
|
||||
*
|
||||
* A structured FamilyMemberHistory-style RECORD — patient-reported, never a
|
||||
* clinical diagnosis. Owner-scoped; auth-gated.
|
||||
*/
|
||||
import type { ZodOpenApiObject } from "zod-openapi";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import {
|
||||
familyHistoryCreateSchema,
|
||||
familyHistoryUpdateSchema,
|
||||
familyHistoryListQuerySchema,
|
||||
familyRelationshipEnum,
|
||||
} from "@/lib/validations/family-history";
|
||||
|
||||
import { dataEnvelope, errorEnvelope, stdResponses } from "./shared";
|
||||
|
||||
familyHistoryCreateSchema.meta({
|
||||
id: "CreateFamilyHistoryRequest",
|
||||
description:
|
||||
"Record one condition for one relative (a FHIR FamilyMemberHistory with a single condition). `condition` is the user-facing label (the `condition.code.text` anchor — never a machine-guessed code); `ageAtOnset` is the relative's age (years) when it began. The free-text `note` is encrypted at rest. Patient-reported.",
|
||||
});
|
||||
|
||||
familyHistoryUpdateSchema.meta({
|
||||
id: "UpdateFamilyHistoryRequest",
|
||||
description:
|
||||
"Partial edit of a family-history entry; an omitted key leaves the column untouched. A `null` `ageAtOnset`/`note` clears that field. Rejects unknown keys.",
|
||||
});
|
||||
|
||||
familyHistoryListQuerySchema.meta({
|
||||
id: "ListFamilyHistoryQuery",
|
||||
description:
|
||||
"Query params for the family-history list: optional `limit` (1–200, default 100). Newest-first.",
|
||||
});
|
||||
|
||||
const familyHistoryEntry = z
|
||||
.object({
|
||||
id: z.string(),
|
||||
relationship: familyRelationshipEnum,
|
||||
condition: z.string(),
|
||||
ageAtOnset: z.number().int().nullable(),
|
||||
note: z.string().nullable(),
|
||||
createdAt: z.string(),
|
||||
updatedAt: z.string(),
|
||||
})
|
||||
.meta({
|
||||
id: "FamilyHistoryEntry",
|
||||
description:
|
||||
"A stored family-history entry: one condition for one relative. `note` is the decrypted free-text (or null on a key-rotation gap — fail-soft, never 500). `ageAtOnset` is null when unknown.",
|
||||
});
|
||||
|
||||
const entryNotFound = {
|
||||
"404": {
|
||||
description: "Family history entry not found (or owned by another user).",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
} as const;
|
||||
|
||||
export const familyHistoryPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
"/api/family-history": {
|
||||
get: {
|
||||
tags: ["Records"],
|
||||
summary: "List family history (v1.25)",
|
||||
description:
|
||||
"Returns the caller's live (non-deleted) family-history entries, newest-first.",
|
||||
requestParams: { query: familyHistoryListQuerySchema },
|
||||
responses: {
|
||||
"200": {
|
||||
description: "The caller's family-history entries.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(
|
||||
z.array(familyHistoryEntry),
|
||||
"ListFamilyHistoryEnvelope",
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
...stdResponses,
|
||||
},
|
||||
},
|
||||
post: {
|
||||
tags: ["Records"],
|
||||
summary: "Record a family-history entry (v1.25)",
|
||||
description:
|
||||
"Creates one condition-by-relative record for the caller. The free-text note is AES-256-GCM encrypted before write. Audits as `family-history.create`.",
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
"application/json": { schema: familyHistoryCreateSchema },
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
"201": {
|
||||
description: "Entry created.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(
|
||||
familyHistoryEntry,
|
||||
"CreateFamilyHistoryEnvelope",
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
...stdResponses,
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/family-history/{id}": {
|
||||
get: {
|
||||
tags: ["Records"],
|
||||
summary: "Read a single family-history entry (v1.25)",
|
||||
description:
|
||||
"Returns the entry including its decrypted `note`. Owner-scoped; a cross-user or tombstoned id 404s.",
|
||||
requestParams: { path: z.object({ id: z.string() }) },
|
||||
responses: {
|
||||
"200": {
|
||||
description: "The family-history entry.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(
|
||||
familyHistoryEntry,
|
||||
"GetFamilyHistoryEnvelope",
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
...entryNotFound,
|
||||
...stdResponses,
|
||||
},
|
||||
},
|
||||
patch: {
|
||||
tags: ["Records"],
|
||||
summary: "Edit a family-history entry (v1.25)",
|
||||
description:
|
||||
"Partial edit; omitted fields are left untouched. Audits as `family-history.update`. Owner-scoped.",
|
||||
requestParams: { path: z.object({ id: z.string() }) },
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
"application/json": { schema: familyHistoryUpdateSchema },
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
"200": {
|
||||
description: "Entry updated.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(
|
||||
familyHistoryEntry,
|
||||
"UpdateFamilyHistoryEnvelope",
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
...entryNotFound,
|
||||
...stdResponses,
|
||||
},
|
||||
},
|
||||
delete: {
|
||||
tags: ["Records"],
|
||||
summary: "Soft-delete a family-history entry (v1.25)",
|
||||
description:
|
||||
"Stamps `deletedAt` (tombstone). Idempotent — a re-delete is a no-op. Returns the `{ deleted: true }` envelope. Audits as `family-history.delete`. Owner-scoped.",
|
||||
requestParams: { path: z.object({ id: z.string() }) },
|
||||
responses: {
|
||||
"200": {
|
||||
description: "Soft-deleted.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(
|
||||
z.object({ deleted: z.boolean() }),
|
||||
"DeleteFamilyHistoryEnvelope",
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
...entryNotFound,
|
||||
...stdResponses,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -22,6 +22,7 @@
|
||||
import type { ZodOpenApiObject } from "zod-openapi";
|
||||
|
||||
import { adminDiagnosticPaths, adminInvitePaths } from "./admin";
|
||||
import { allergyPaths } from "./allergies";
|
||||
import { authPaths } from "./auth";
|
||||
import {
|
||||
coachFeedbackPaths,
|
||||
@@ -35,6 +36,7 @@ import { cyclePaths } from "./cycle";
|
||||
import { biomarkerPaths } from "./biomarkers";
|
||||
import { devicePaths } from "./devices";
|
||||
import { exportPaths } from "./export";
|
||||
import { familyHistoryPaths } from "./family-history";
|
||||
import { healthRecordPaths } from "./health-record";
|
||||
import { illnessPaths } from "./illness";
|
||||
import { importPaths } from "./import";
|
||||
@@ -83,6 +85,8 @@ export const openApiPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
...ocrPaths,
|
||||
...biomarkerPaths,
|
||||
...illnessPaths,
|
||||
...allergyPaths,
|
||||
...familyHistoryPaths,
|
||||
...onboardingPaths,
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* Query keys — allergies (v1.25, W-RECORDS). The allergy list + a single
|
||||
* allergy read. Part of the centralized factory; aggregated in `./index.ts`.
|
||||
*/
|
||||
export const allergyKeys = {
|
||||
/**
|
||||
* `allergies()` is the root prefix every allergy write invalidates through.
|
||||
* `allergyList(includeInactive)` keys the list (the inactive toggle is part
|
||||
* of the key so the two response sets cache distinctly). Any allergy write
|
||||
* evicts the whole `["allergies"]` prefix so the list + open sheet repaint
|
||||
* in lockstep.
|
||||
*/
|
||||
allergies: () => ["allergies"] as const,
|
||||
allergyList: (includeInactive: boolean) =>
|
||||
["allergies", "list", includeInactive] as const,
|
||||
allergy: (id: string) => ["allergies", "detail", id] as const,
|
||||
};
|
||||
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* Query keys — family history (v1.25, W-RECORDS). The entry list + a single
|
||||
* entry read. Part of the centralized factory; aggregated in `./index.ts`.
|
||||
*/
|
||||
export const familyHistoryKeys = {
|
||||
/**
|
||||
* `familyHistory()` is the root prefix every family-history write
|
||||
* invalidates through. Any write evicts the whole `["family-history"]`
|
||||
* prefix so the list + open sheet repaint in lockstep.
|
||||
*/
|
||||
familyHistory: () => ["family-history"] as const,
|
||||
familyHistoryList: () => ["family-history", "list"] as const,
|
||||
familyHistoryEntry: (id: string) => ["family-history", "detail", id] as const,
|
||||
};
|
||||
@@ -15,8 +15,10 @@
|
||||
import type { QueryClient, QueryKey } from "@tanstack/react-query";
|
||||
|
||||
import { adminKeys } from "./admin";
|
||||
import { allergyKeys } from "./allergies";
|
||||
import { authKeys } from "./auth";
|
||||
import { coachKeys } from "./coach";
|
||||
import { familyHistoryKeys } from "./family-history";
|
||||
import { cycleKeys } from "./cycle";
|
||||
import { dashboardKeys } from "./dashboard";
|
||||
import { illnessKeys } from "./illness";
|
||||
@@ -46,6 +48,8 @@ export const queryKeys = {
|
||||
...measurementReminderKeys,
|
||||
...labKeys,
|
||||
...illnessKeys,
|
||||
...allergyKeys,
|
||||
...familyHistoryKeys,
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* Structured-record row → wire-DTO mappers (v1.25, W-RECORDS).
|
||||
*
|
||||
* The canonical `AllergyDTO` / `FamilyHistoryEntryDTO` shapes the iOS client
|
||||
* mirrors (server-authoritative — iOS renders, never recomputes). The
|
||||
* sensitive free-text fields live in `*Encrypted` (Bytes) columns and are
|
||||
* decrypted fail-soft on read (a key-rotation gap on one row reads `null`,
|
||||
* never 500s the whole page) — the IllnessEpisode DTO precedent.
|
||||
*/
|
||||
import { decryptFromBytes } from "@/lib/ai/coach/bytes-codec";
|
||||
import type { Allergy, FamilyHistoryEntry } from "@/generated/prisma/client";
|
||||
|
||||
export interface AllergyDTO {
|
||||
id: string;
|
||||
substance: string;
|
||||
category: string;
|
||||
type: string;
|
||||
severity: string | null;
|
||||
status: string;
|
||||
onsetAt: string | null;
|
||||
reaction: string | null;
|
||||
note: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface FamilyHistoryEntryDTO {
|
||||
id: string;
|
||||
relationship: string;
|
||||
condition: string;
|
||||
ageAtOnset: number | null;
|
||||
note: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
/** Decrypt a Bytes payload fail-soft (null on missing / undecryptable). */
|
||||
function decryptOptional(buf: Uint8Array | null): string | null {
|
||||
if (!buf || buf.byteLength === 0) return null;
|
||||
try {
|
||||
return decryptFromBytes(buf);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function toAllergyDTO(row: Allergy): AllergyDTO {
|
||||
return {
|
||||
id: row.id,
|
||||
substance: row.substance,
|
||||
category: row.category,
|
||||
type: row.type,
|
||||
severity: row.severity,
|
||||
status: row.status,
|
||||
onsetAt: row.onsetAt ? row.onsetAt.toISOString() : null,
|
||||
reaction: decryptOptional(row.reactionEncrypted),
|
||||
note: decryptOptional(row.notesEncrypted),
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
updatedAt: row.updatedAt.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
export function toFamilyHistoryEntryDTO(
|
||||
row: FamilyHistoryEntry,
|
||||
): FamilyHistoryEntryDTO {
|
||||
return {
|
||||
id: row.id,
|
||||
relationship: row.relationship,
|
||||
condition: row.condition,
|
||||
ageAtOnset: row.ageAtOnset,
|
||||
note: decryptOptional(row.notesEncrypted),
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
updatedAt: row.updatedAt.toISOString(),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* Allergy / intolerance request/response validation (v1.25, W-RECORDS).
|
||||
*
|
||||
* Source of truth for the `/api/allergies/*` wire contract. The Zod schemas
|
||||
* here are reused by the OpenAPI registry so the spec stays single-source.
|
||||
* `userId` is NEVER a body field — it is narrowed from the session/Bearer in
|
||||
* every route and fed to the Prisma `where`.
|
||||
*
|
||||
* A structured AllergyIntolerance-style RECORD (not a time-series signal):
|
||||
* `substance` is the queryable human label; the free-text reaction
|
||||
* description + notes are encrypted at rest (`reactionEncrypted` /
|
||||
* `notesEncrypted` Bytes columns). Patient-reported — never a clinical
|
||||
* diagnosis the app asserts.
|
||||
*/
|
||||
import { z } from "zod/v4";
|
||||
import { isPlausibleEntryInstant } from "@/lib/validations/entry-instant";
|
||||
|
||||
/**
|
||||
* Plausible-instant bound shared with the measurement / illness paths: no
|
||||
* future instant beyond the 5-min skew, no instant before 1900. Stays a
|
||||
* string on the wire (iOS reads it back verbatim).
|
||||
*/
|
||||
const boundedInstant = z.iso
|
||||
.datetime({ offset: true })
|
||||
.refine((s) => isPlausibleEntryInstant(new Date(s)), {
|
||||
message: "must be a plausible instant (not future, not pre-1900)",
|
||||
});
|
||||
|
||||
/* ── enums (mirror the Prisma enums) ─────────────────────────────── */
|
||||
|
||||
export const allergyCategoryEnum = z.enum([
|
||||
"FOOD",
|
||||
"MEDICATION",
|
||||
"ENVIRONMENT",
|
||||
"BIOLOGIC",
|
||||
"OTHER",
|
||||
]);
|
||||
|
||||
export const allergyTypeEnum = z.enum(["ALLERGY", "INTOLERANCE"]);
|
||||
|
||||
export const allergySeverityEnum = z.enum(["MILD", "MODERATE", "SEVERE"]);
|
||||
|
||||
export const allergyStatusEnum = z.enum(["ACTIVE", "INACTIVE", "RESOLVED"]);
|
||||
|
||||
/* ── CRUD ─────────────────────────────────────────────────────────── */
|
||||
|
||||
/**
|
||||
* Create an allergy. `substance` is the user-facing allergen name; the
|
||||
* free-text `reaction` + `note` are encrypted at rest. `onsetAt` is optional
|
||||
* (unknown when omitted).
|
||||
*/
|
||||
export const allergyCreateSchema = z.object({
|
||||
substance: z.string().min(1).max(160),
|
||||
category: allergyCategoryEnum.optional().default("OTHER"),
|
||||
type: allergyTypeEnum.optional().default("ALLERGY"),
|
||||
severity: allergySeverityEnum.nullable().optional(),
|
||||
status: allergyStatusEnum.optional().default("ACTIVE"),
|
||||
onsetAt: boundedInstant.nullable().optional(),
|
||||
reaction: z.string().max(2000).nullable().optional(),
|
||||
note: z.string().max(2000).nullable().optional(),
|
||||
});
|
||||
|
||||
export type AllergyCreate = z.infer<typeof allergyCreateSchema>;
|
||||
|
||||
/**
|
||||
* Edit an allergy — every field optional; an omitted field is left
|
||||
* untouched. Rejects unknown keys.
|
||||
*/
|
||||
export const allergyUpdateSchema = z
|
||||
.object({
|
||||
substance: z.string().min(1).max(160).optional(),
|
||||
category: allergyCategoryEnum.optional(),
|
||||
type: allergyTypeEnum.optional(),
|
||||
severity: allergySeverityEnum.nullable().optional(),
|
||||
status: allergyStatusEnum.optional(),
|
||||
onsetAt: boundedInstant.nullable().optional(),
|
||||
reaction: z.string().max(2000).nullable().optional(),
|
||||
note: z.string().max(2000).nullable().optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export type AllergyUpdate = z.infer<typeof allergyUpdateSchema>;
|
||||
|
||||
/** History/list query — newest-first, bounded. */
|
||||
export const allergyListQuerySchema = z.object({
|
||||
limit: z.coerce.number().int().min(1).max(200).optional(),
|
||||
includeInactive: z.union([z.literal("true"), z.literal("false")]).optional(),
|
||||
});
|
||||
|
||||
export type AllergyListQuery = z.infer<typeof allergyListQuerySchema>;
|
||||
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* Family-history request/response validation (v1.25, W-RECORDS).
|
||||
*
|
||||
* Source of truth for the `/api/family-history/*` wire contract. The Zod
|
||||
* schemas here are reused by the OpenAPI registry so the spec stays
|
||||
* single-source. `userId` is NEVER a body field — it is narrowed from the
|
||||
* session/Bearer in every route and fed to the Prisma `where`.
|
||||
*
|
||||
* A structured FamilyMemberHistory-style RECORD (one condition per relative;
|
||||
* not a time-series signal): `relationship` + `condition` label + optional
|
||||
* `ageAtOnset`; the optional free-text note is encrypted at rest
|
||||
* (`notesEncrypted` Bytes column). Patient-reported.
|
||||
*/
|
||||
import { z } from "zod/v4";
|
||||
|
||||
/* ── enum (mirrors the Prisma enum) ──────────────────────────────── */
|
||||
|
||||
export const familyRelationshipEnum = z.enum([
|
||||
"MOTHER",
|
||||
"FATHER",
|
||||
"SISTER",
|
||||
"BROTHER",
|
||||
"DAUGHTER",
|
||||
"SON",
|
||||
"GRANDMOTHER_MATERNAL",
|
||||
"GRANDFATHER_MATERNAL",
|
||||
"GRANDMOTHER_PATERNAL",
|
||||
"GRANDFATHER_PATERNAL",
|
||||
"AUNT",
|
||||
"UNCLE",
|
||||
"COUSIN",
|
||||
"HALF_SIBLING",
|
||||
"OTHER",
|
||||
]);
|
||||
|
||||
/* ── CRUD ─────────────────────────────────────────────────────────── */
|
||||
|
||||
/**
|
||||
* Create a family-history entry. `condition` is the user-facing condition
|
||||
* name; `ageAtOnset` is the relative's age (years) when it began; the
|
||||
* free-text `note` is encrypted at rest.
|
||||
*/
|
||||
export const familyHistoryCreateSchema = z.object({
|
||||
relationship: familyRelationshipEnum,
|
||||
condition: z.string().min(1).max(160),
|
||||
ageAtOnset: z.number().int().min(0).max(120).nullable().optional(),
|
||||
note: z.string().max(2000).nullable().optional(),
|
||||
});
|
||||
|
||||
export type FamilyHistoryCreate = z.infer<typeof familyHistoryCreateSchema>;
|
||||
|
||||
/**
|
||||
* Edit a family-history entry — every field optional; an omitted field is
|
||||
* left untouched. Rejects unknown keys.
|
||||
*/
|
||||
export const familyHistoryUpdateSchema = z
|
||||
.object({
|
||||
relationship: familyRelationshipEnum.optional(),
|
||||
condition: z.string().min(1).max(160).optional(),
|
||||
ageAtOnset: z.number().int().min(0).max(120).nullable().optional(),
|
||||
note: z.string().max(2000).nullable().optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export type FamilyHistoryUpdate = z.infer<typeof familyHistoryUpdateSchema>;
|
||||
|
||||
/** History/list query — bounded. */
|
||||
export const familyHistoryListQuerySchema = z.object({
|
||||
limit: z.coerce.number().int().min(1).max(200).optional(),
|
||||
});
|
||||
|
||||
export type FamilyHistoryListQuery = z.infer<
|
||||
typeof familyHistoryListQuerySchema
|
||||
>;
|
||||
Reference in new issue
Block a user