mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 22:02:16 +00:00
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.
203 lines
6.7 KiB
TypeScript
203 lines
6.7 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,
|
||
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,
|
||
},
|
||
},
|
||
},
|
||
};
|