Files
HealthLog/src/lib/openapi/routes/allergies.ts
T
Marc-André Bombeck ab6c1cd917 fix(api): put a ceiling under the single-record writes
The batch endpoints have been capped at 60 calls a minute since they
were written. The per-record siblings a naive or older client loops
over — the measurement create, the mood entry, both medication-intake
writes, a lab result, a custom-metric entry, a cycle day-log, a
vaccination, an allergy, a biomarker and an encounter — had no ceiling
at all. That is backwards from the risk: the batch endpoint is the one
a well-behaved client uses, and the unlimited path is the one a broken
loop finds.

All eleven share one generous per-account bucket, `record-write`, at
300 requests a minute. Five writes a second sustained for a full minute
is beyond any hand-driven session and any sync burst, and every one of
these routes is already wrapped in `withIdempotency`, so an honest
retry storm is deduplicated before it is counted — the bucket exists to
cut off the pathological loop, not to shape traffic. It keys on the
ACTING account, the precedent the delegable medication routes set, so a
delegate burns their own allowance rather than locking the record's
owner out of their own log.

The eleven operations publish the bucket name and the cap instead of
the generic refusal, on the shared 429 that already declares
`Retry-After` and the rate-limit triple. A test pulls the key and the
window out of the query the limiter runs and holds the published
sentence to them, so the paragraph cannot drift away from the bucket it
describes.
2026-09-10 07:42:07 +02:00

203 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,
} 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,
...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,
},
},
},
};