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