mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-15 15:02:04 +00:00
`.meta()` clones the schema in Zod 4 and registers the clone, so a bare
`schema.meta({ id, description })` statement registers nothing at all.
Forty-one of them sat across nine route modules. The ids they named —
`MeasurementType`, `MeasurementSource`, `LoginPasswordRequest`,
`CreateMeasurementRequest`, `CreateBatchWorkoutRequest` and thirty more —
reached `components.schemas` zero times, the descriptions written for them
were published nowhere, and every use site inlined an anonymous copy. A client
generated from the contract got duplicated nameless types where one named
model belongs, which is the part that matters, because the iOS client is why
the contract is published.
Forty of them now bind their clone, and every use site points at it. Where the
schema comes from `src/lib/validations/*` the import is aliased and the clone
takes the name the use sites already spell, so nothing mutates the runtime
validation schema. The seven that cross module boundaries are exported from
`./shared` and the eight consumers import them from there; importing the raw
schema from the validation module instead is exactly what inlined them.
Thirty-four named components appear where inline copies stood. Fully
dereferenced, the document differs by eighty-six added descriptions and
nothing else: no operation's request or response shape moves. `CoachPrefs`
was already force-registered in the index and finally carries its description.
Five ids legitimately stay out of `components.schemas` —
`ListMeasurementsQuery`, `ListAllergiesQuery`, `ListEncountersQuery`,
`SuggestEncounterQuery` and `ListPractitionersQuery`. The emitter expands a
`requestParams.query` object into inline `parameters`, so a query id has
nowhere to land; they stay in the assigning form for consistency.
Three findings the wiring turned up:
`CreateShareLinkRequest` and `HealthRecordExportRequest` were annotated in the
Coach module, which imported the two schemas for no other reason and never
referenced them. Both operations that send these bodies live in the
health-record module, so the annotations move there. Both descriptions
described a `sections` field: the export body has no such field and rejects it
with a 422, and the share-link body freezes scope through `selection` plus
`documentIds` / `documentOnly`. Publishing either sentence verbatim would have
published a lie, so both are corrected to the shape the routes actually take.
`CoachPlansListQuery` — the forty-first — named a component that nothing
referenced and that a query object could never reach. The operation publishes
its two parameters by hand, because the schema carries a cross-field
refinement the expansion cannot express, and every clause of the annotation is
already published on the operation and on both parameters. It is deleted
rather than left registering nothing.
201 lines
6.6 KiB
TypeScript
201 lines
6.6 KiB
TypeScript
/**
|
||
* 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,
|
||
stdResponses,
|
||
} 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: 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`.",
|
||
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,
|
||
},
|
||
},
|
||
},
|
||
"/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,
|
||
},
|
||
},
|
||
},
|
||
};
|