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:
Marc-André Bombeck committed 2026-06-28 16:03:05 +02:00
1 parent 3ded891641
commit 5052b2cc08
15 files changed
+1913

No files matched your search

+733
View File
@@ -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:
+144
View File
@@ -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 });
},
);
+121
View File
@@ -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);
}
+151
View File
@@ -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 });
},
);
+112
View File
@@ -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);
}
+1
View File
@@ -75,6 +75,7 @@ const openApiBase: Pick<
{ name: "MeasurementReminders" },
{ name: "Labs" },
{ name: "Illness" },
{ name: "Records" },
{ name: "Admin" },
{ name: "Meta" },
],
+182
View File
@@ -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,
},
},
},
};
+191
View File
@@ -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,
},
},
},
};
+4
View File
@@ -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,
};
+17
View File
@@ -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,
};
+14
View File
@@ -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,
};
+4
View File
@@ -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,
};
/**
+75
View File
@@ -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(),
};
}
+90
View File
@@ -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>;
+74
View File
@@ -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
>;