Files
HealthLog/src/lib/openapi/routes/allergies.ts
T
Marc-André Bombeck 97d1052dd1 feat(api): say when a list was cut at its ceiling
A list that stops at its own take and says nothing lets the last row read
as the oldest there is. Capped lists now fetch one row past the limit and
answer meta.truncated and meta.limit on the success envelope, so data keeps
its shape. Applied to the capped lists without a total count: rhythm
events, ECG recordings, sharing grants and activity, illness episodes, side
effects, personal records, security activity, cycles, family history,
allergies and mental-health assessments. The activity feed no longer
reports a list that is exactly full as cut.
2026-10-20 06:53:17 +02:00

204 lines
6.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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,
idempotencyKeyParameter,
idempotentWrite,
recordRefusal,
recordWriteRateLimitResponse,
stdResponses,
listEnvelope,
} from "./shared";
// `.meta()` CLONES in Zod 4 rather than annotating in place, so the returned
// schema has to be captured and referenced. A bare `schema.meta({...})`
// statement registers nothing and the component id it names never reaches the
// emitted document.
const createAllergyRequest = 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.",
});
const updateAllergyRequest = 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.",
});
const listAllergiesQuery = 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: listAllergiesQuery },
responses: {
...recordRefusal(),
"200": {
description: "The caller's allergy records.",
content: {
"application/json": {
schema: listEnvelope(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`.",
parameters: [idempotencyKeyParameter],
requestBody: {
required: true,
content: { "application/json": { schema: createAllergyRequest } },
},
responses: {
...idempotentWrite(),
...recordRefusal(),
"201": {
description: "Allergy created.",
content: {
"application/json": {
schema: dataEnvelope(allergy, "CreateAllergyEnvelope"),
},
},
},
...stdResponses,
...recordWriteRateLimitResponse,
},
},
},
"/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: {
...recordRefusal(),
"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: updateAllergyRequest } },
},
responses: {
...recordRefusal(),
"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: {
...recordRefusal(),
"200": {
description: "Soft-deleted.",
content: {
"application/json": {
schema: dataEnvelope(
z.object({ deleted: z.boolean() }),
"DeleteAllergyEnvelope",
),
},
},
},
...allergyNotFound,
...stdResponses,
},
},
},
};