Merge pull request #824 from MBombeck/fix/openapi-meta

Register the schemas that annotated themselves into nothing
This commit is contained in:
Marc-André Bombeck authored and GitHub committed 2026-08-22 16:24:41 +02:00
commit 1efac85a68
28 files changed
+3807 -4439

No files matched your search

+3506 -4204
View File
File diff suppressed because it is too large. Load diff
+10 -6
View File
@@ -33,19 +33,23 @@ import {
stdResponses,
} from "./shared";
allergyCreateSchema.meta({
// `.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.",
});
allergyUpdateSchema.meta({
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.",
});
allergyListQuerySchema.meta({
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.",
@@ -85,7 +89,7 @@ export const allergyPaths: NonNullable<ZodOpenApiObject["paths"]> = {
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 },
requestParams: { query: listAllergiesQuery },
responses: {
...recordRefusal(),
"200": {
@@ -107,7 +111,7 @@ export const allergyPaths: NonNullable<ZodOpenApiObject["paths"]> = {
parameters: [idempotencyKeyParameter],
requestBody: {
required: true,
content: { "application/json": { schema: allergyCreateSchema } },
content: { "application/json": { schema: createAllergyRequest } },
},
responses: {
...idempotentWrite(),
@@ -153,7 +157,7 @@ export const allergyPaths: NonNullable<ZodOpenApiObject["paths"]> = {
requestParams: { path: z.object({ id: z.string() }) },
requestBody: {
required: true,
content: { "application/json": { schema: allergyUpdateSchema } },
content: { "application/json": { schema: updateAllergyRequest } },
},
responses: {
...recordRefusal(),
+6 -2
View File
@@ -14,7 +14,6 @@ import { z } from "zod/v4";
import type { ZodOpenApiObject } from "zod-openapi";
import {
changePasswordSchema,
loginPasswordSchema,
passkeyRenameSchema,
registerSchema,
} from "@/lib/validations/auth";
@@ -36,7 +35,12 @@ import {
stepUpMintSchema,
stepUpOptionsSchema,
} from "@/lib/validations/step-up";
import { dataEnvelope, stdResponses, errorEnvelope } from "./shared";
import {
dataEnvelope,
stdResponses,
errorEnvelope,
loginPasswordSchema,
} from "./shared";
// ── Sub-schemas owned here (route-specific shapes) ───────────────────
+2 -4
View File
@@ -12,14 +12,12 @@
import { z } from "zod/v4";
import type { ZodOpenApiObject } from "zod-openapi";
import {
measurementSourceEnum,
measurementTypeEnum,
} from "@/lib/validations/measurement";
import {
MODULE_DISABLED_DESCRIPTION,
dataEnvelope,
errorEnvelope,
measurementSourceEnum,
measurementTypeEnum,
recordRefusal,
stdResponses,
} from "./shared";
+8 -4
View File
@@ -28,13 +28,17 @@ import {
stdResponses,
} from "./shared";
createBiomarkerSchema.meta({
// `.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 createBiomarkerRequest = createBiomarkerSchema.meta({
id: "CreateBiomarkerRequest",
description:
"Define a user-scoped biomarker ONCE: canonical `name`, `unit`, optional reference bounds (`lowerBound` / `upperBound`; when both present `lowerBound` must not exceed `upperBound`), an optional encrypted `context` note, and an optional `panel` grouping. The name is unique per user (no second 'LDL'). Recording a value later just picks this marker — its unit + range are never re-entered.",
});
updateBiomarkerSchema.meta({
const updateBiomarkerRequest = updateBiomarkerSchema.meta({
id: "UpdateBiomarkerRequest",
description:
"Partial edit of a biomarker. An omitted key leaves the column untouched; an explicit `null` on `context` / `panel` / a bound clears it. Set `hidden` to drop / restore the marker in the active list + lab-entry pickers. A rename that collides with another of the caller's markers is rejected 409.",
@@ -106,7 +110,7 @@ export const biomarkerPaths: NonNullable<ZodOpenApiObject["paths"]> = {
parameters: [idempotencyKeyParameter],
requestBody: {
required: true,
content: { "application/json": { schema: createBiomarkerSchema } },
content: { "application/json": { schema: createBiomarkerRequest } },
},
responses: {
...idempotentWrite(),
@@ -153,7 +157,7 @@ export const biomarkerPaths: NonNullable<ZodOpenApiObject["paths"]> = {
requestParams: { path: z.object({ id: z.string() }) },
requestBody: {
required: true,
content: { "application/json": { schema: updateBiomarkerSchema } },
content: { "application/json": { schema: updateBiomarkerRequest } },
},
responses: {
...recordRefusal(),
+20 -37
View File
@@ -33,15 +33,12 @@ import { COACH_PLAN_STATUSES } from "@/lib/ai/coach/plans";
import {
COACH_PLAN_SCOPES,
coachPlanPatchSchema,
coachPlansListQuerySchema,
} from "@/lib/validations/coach-plan";
import { coachChatRequestSchema } from "@/lib/ai/coach/types";
import {
coachAttachmentCreateSchema,
fencedChatRequestSchema,
} from "@/lib/validations/inbound-documents";
import { exportSelectionSchema } from "@/lib/validations/health-record-export";
import { createShareLinkSchema } from "@/lib/validations/clinician-share-link";
import { coachReminderSuggestionActionSchema } from "@/lib/validations/coach-reminder-suggestion";
import { COACH_REMINDER_STATUSES } from "@/lib/ai/coach/reminders";
import {
@@ -71,13 +68,13 @@ const aboutMeAdoptRequest = aboutMeAdoptSchema.meta({
"An answer to fold into the stored self-context. `question` is the clarifying question it belongs to and is what the server matches the target field from; omit it for the remember-a-message path and the field is matched from `answer` instead. `answer` is capped at 500 characters — the same cap as a structured self-context field.",
});
emergencyProfileUpdateSchema.meta({
const updateEmergencyProfileRequest = emergencyProfileUpdateSchema.meta({
id: "UpdateEmergencyProfileRequest",
description:
"Partial edit of the emergency (Notfalldaten) profile. An omitted key leaves the column untouched; a `null` enum or an emptied free-text field clears it. `bloodType`, `organDonor` and `advanceDirective` are closed enums; `contacts`, `implants` and `note` are encrypted at rest. Rejects unknown keys.",
});
emergencyProfileDtoSchema.meta({
const emergencyProfile = emergencyProfileDtoSchema.meta({
id: "EmergencyProfile",
description:
"The caller's emergency profile: three closed-enum facts plus three decrypted free-text fields. A free-text field is null when unset; its `*Unreadable` flag is true when ciphertext was present but could not be decrypted (a key-rotation gap, fail-soft rather than 500).",
@@ -88,7 +85,7 @@ emergencyProfileDtoSchema.meta({
// client sends ONLY the cadence id + the action; the server resolves the
// metric + schedule + course window from the closed catalog and (for
// `accept`) mints a `MeasurementReminder` with `origin: COACH`.
coachReminderSuggestionActionSchema.meta({
const coachReminderSuggestionAction = coachReminderSuggestionActionSchema.meta({
id: "CoachReminderSuggestionAction",
description:
"v1.18.1 — act on a Coach cadence suggestion. `cadenceId` names a closed-catalog preset (e.g. `weight_daily`, `bp_7_2_2`); the client never sends a schedule. `action`: `accept` creates a `MeasurementReminder` (origin: COACH) through the same engine the Vorsorge surface uses; `dismiss` records dismissal memory; `stop` suppresses all future cadence suggestions. Strict: unknown keys 422.",
@@ -130,7 +127,7 @@ export const coachReminderSuggestionPaths: NonNullable<
requestBody: {
required: true,
content: {
"application/json": { schema: coachReminderSuggestionActionSchema },
"application/json": { schema: coachReminderSuggestionAction },
},
},
responses: {
@@ -264,24 +261,6 @@ const insightsLayoutResult = insightsLayoutSchema
"Resolved Insights layout plus the optimistic-concurrency `updatedAt` token.",
});
// v1.7.0 — health-record export selection. Strict shape: unknown keys
// (including any attempt to smuggle a userId) 422 via returnAllZodIssues.
// v1.11.0 — clinician share-link create payload. Strict; no `userId` field
// (the owner is always narrowed from the session/Bearer). `expiresAt` is
// required and capped at SHARE_LINK_MAX_DAYS; the scope columns are frozen
// write-once at creation.
createShareLinkSchema.meta({
id: "CreateShareLinkRequest",
description:
"v1.11.0 — owner request to mint a clinician share link to their own health record. `expiresAt` is required (absolute ISO instant) and capped at 90 days. `rangeStart`/`rangeEnd` freeze the reporting window (rangeEnd null = rolling). `sections` freezes which record domains the link may serve. A share link serves the rendered page and the documents frozen onto it — there is no FHIR or other machine-readable face behind a share token. Strict: unknown keys 422.",
});
exportSelectionSchema.meta({
id: "HealthRecordExportRequest",
description:
"v1.7.0 — health-record / doctor-handover export selection. `format` picks PDF, FHIR R4 document Bundle, or a combined zip package. Grouped `sections` toggles drive which domains are read (mood is opt-in, off by default). No `userId` field — the user is always narrowed from the session/Bearer. The route is strict: unknown keys 422.",
});
// ── Coach facts (v1.11.1) ────────────────────────────────────────────
// Read + delete surface for the durable facts the Coach extracts. Facts
// are server-extracted, not user-authored, so there is no create/update
@@ -396,17 +375,21 @@ const coachPlanDeletedResponse = z.object({
),
});
coachPlanPatchSchema.meta({
const coachPlanPatchRequest = coachPlanPatchSchema.meta({
id: "CoachPlanPatchRequest",
description:
"v1.21.3 — confirm or update a Coach plan's lifecycle. `status` moves a `proposed` plan to `active` (confirm), or to `met` / `abandoned`. `reviewDate` pins (ISO instant) or clears (null) a check-in checkpoint. At least one field is required. Strict: unknown keys 422 — the body can never carry the metric, the encrypted text, or a userId.",
});
coachPlansListQuerySchema.meta({
id: "CoachPlansListQuery",
description:
"Optional `?status=` filter for the plans list (a single lifecycle status), or `?scope=` for a named group (open = proposed + active + review_due, past = met + abandoned + reviewed, all = every non-deleted plan). Mutually exclusive. Both omitted returns the non-terminal set (proposed + active).",
});
// `GET /api/coach/plans` publishes its two query parameters by hand below,
// not through `requestParams.query`: the runtime schema carries a cross-field
// `.refine()` (status and scope are mutually exclusive) that the expansion
// cannot express, and the hand-written pair carries per-parameter prose it
// would drop. A `CoachPlansListQuery` annotation sat here claiming a component
// id that nothing referenced and that a query object could never reach anyway
// — the emitter inlines query objects into `parameters`. Every clause of it is
// already published on the operation and on both parameters, so it is gone
// rather than left registering nothing.
// ── Coach episodic reminders (v1.22 B2/B6) ──────────────────────────────
const coachReminderItem = z
@@ -727,7 +710,7 @@ const coachAttachmentsResponseSchema = z
})
.meta({ id: "CoachAttachments" });
coachChatRequestSchema.meta({
const coachChatRequest = coachChatRequestSchema.meta({
id: "CoachChatRequest",
description:
"Inbound Coach turn. `message` is the user's turn (1–4 000 chars). `conversationId` is omitted to start a new conversation (the server mints a title from the first message) and supplied to continue one. `scope` narrows which metrics the snapshot ships and which window the timeline covers; omitted fields fall back to server defaults. `locale` picks the reply language. `guidedQuestion` carries the clarifying question a message answers (client-side bubble, never persisted). No `userId` field — the owner is narrowed from the session / Bearer.",
@@ -804,7 +787,7 @@ export const coachPaths: NonNullable<ZodOpenApiObject["paths"]> = {
requestBody: {
required: true,
content: {
"application/json": { schema: coachChatRequestSchema },
"application/json": { schema: coachChatRequest },
},
},
responses: {
@@ -1468,7 +1451,7 @@ export const coachPaths: NonNullable<ZodOpenApiObject["paths"]> = {
content: {
"application/json": {
schema: dataEnvelope(
emergencyProfileDtoSchema,
emergencyProfile,
"GetEmergencyProfileResponse",
),
},
@@ -1485,7 +1468,7 @@ export const coachPaths: NonNullable<ZodOpenApiObject["paths"]> = {
requestBody: {
required: true,
content: {
"application/json": { schema: emergencyProfileUpdateSchema },
"application/json": { schema: updateEmergencyProfileRequest },
},
},
responses: {
@@ -1494,7 +1477,7 @@ export const coachPaths: NonNullable<ZodOpenApiObject["paths"]> = {
content: {
"application/json": {
schema: dataEnvelope(
emergencyProfileDtoSchema,
emergencyProfile,
"UpdateEmergencyProfileResponse",
),
},
@@ -1675,7 +1658,7 @@ export const coachPaths: NonNullable<ZodOpenApiObject["paths"]> = {
requestBody: {
required: true,
content: {
"application/json": { schema: coachPlanPatchSchema },
"application/json": { schema: coachPlanPatchRequest },
},
},
responses: {
+9 -4
View File
@@ -26,18 +26,23 @@ import { z } from "zod/v4";
import {
consentKindEnum,
consentPostBody,
webConsentGrantBody,
consentPostBody as consentPostBodyBase,
webConsentGrantBody as webConsentGrantBodyBase,
} from "@/lib/validations/consent";
import { dataEnvelope, errorEnvelope, stdResponses } from "./shared";
consentPostBody.meta({
// `.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. The imports are aliased so the annotated clone can keep the
// name every use site below already spells.
const consentPostBody = consentPostBodyBase.meta({
id: "ConsentPostBody",
description:
"Explicit AI-consent grant. `artefact` is an opaque signed receipt (base64 PDF or JWT, ≤ 64 KB UTF-8 bytes); `signedAt` is an ISO-8601 instant. Always appends a fresh row — re-granting after a revoke mints a new receipt.",
});
webConsentGrantBody.meta({
const webConsentGrantBody = webConsentGrantBodyBase.meta({
id: "WebConsentGrantBody",
description:
'Optional. Omitting the body (or sending `intent: "heal"`) is the AI-settings mount heal: it mints only for an account with no `ai_full` consent history at all, so a standing revocation is never resurrected. `intent: "affirmative"` is the user\'s own grant action and may supersede an earlier revocation, exactly as a first grant would.',
+11 -8
View File
@@ -30,25 +30,28 @@ import {
stdResponses,
} from "./shared";
createCustomMetricSchema.meta({
// Zod 4's `.meta()` returns a NEW instance carrying the id rather than
// annotating in place, so each annotated schema is bound to a const and the
// route table below references that const — a bare call would register nothing.
const createCustomMetricRequest = createCustomMetricSchema.meta({
id: "CreateCustomMetricRequest",
description:
"Define a user-scoped custom metric ONCE: free-text `name` + `unit`, an optional target window (`targetLow` / `targetHigh`; when both present `targetLow` must not exceed `targetHigh`), optional display `decimals`, an optional `description`, and explicit `correlationEnabled` opt-in. The name is unique per user. Logging a value later snapshots its unit.",
});
updateCustomMetricSchema.meta({
const updateCustomMetricRequest = updateCustomMetricSchema.meta({
id: "UpdateCustomMetricRequest",
description:
"Partial edit of a custom metric. An omitted key leaves the column untouched; an explicit `null` on a target bound / `decimals` / `description` clears it. `correlationEnabled` explicitly controls use as a bounded behaviour channel. A conflicting rename is rejected 409.",
});
createCustomMetricEntrySchema.meta({
const createCustomMetricEntryRequest = createCustomMetricEntrySchema.meta({
id: "CreateCustomMetricEntryRequest",
description:
"Log a value against a custom metric: numeric `value`, ISO 8601 `measuredAt`, optional free-text `note`. The metric's current unit is snapshotted onto the entry server-side.",
});
updateCustomMetricEntrySchema.meta({
const updateCustomMetricEntryRequest = updateCustomMetricEntrySchema.meta({
id: "UpdateCustomMetricEntryRequest",
description:
"Partial edit of a logged value. An omitted key leaves the column untouched; an explicit `null` on `note` clears it.",
@@ -164,7 +167,7 @@ export const customMetricPaths: NonNullable<ZodOpenApiObject["paths"]> = {
parameters: [idempotencyKeyParameter],
requestBody: {
required: true,
content: { "application/json": { schema: createCustomMetricSchema } },
content: { "application/json": { schema: createCustomMetricRequest } },
},
responses: {
...idempotentWrite(),
@@ -213,7 +216,7 @@ export const customMetricPaths: NonNullable<ZodOpenApiObject["paths"]> = {
requestParams: { path: z.object({ id: z.string() }) },
requestBody: {
required: true,
content: { "application/json": { schema: updateCustomMetricSchema } },
content: { "application/json": { schema: updateCustomMetricRequest } },
},
responses: {
"200": {
@@ -296,7 +299,7 @@ export const customMetricPaths: NonNullable<ZodOpenApiObject["paths"]> = {
requestBody: {
required: true,
content: {
"application/json": { schema: createCustomMetricEntrySchema },
"application/json": { schema: createCustomMetricEntryRequest },
},
},
responses: {
@@ -326,7 +329,7 @@ export const customMetricPaths: NonNullable<ZodOpenApiObject["paths"]> = {
requestBody: {
required: true,
content: {
"application/json": { schema: updateCustomMetricEntrySchema },
"application/json": { schema: updateCustomMetricEntryRequest },
},
},
responses: {
+29 -29
View File
@@ -52,52 +52,56 @@ const cyclePhaseEnumOpenapi = z
.enum(["MENSTRUAL", "FOLLICULAR", "OVULATORY", "LUTEAL"])
.meta({ id: "CyclePhase" });
flowLevelEnum.meta({
// `.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 flowLevelEnumOpenapi = flowLevelEnum.meta({
id: "FlowLevel",
description: "Menstrual-flow intensity.",
});
ovulationTestEnum.meta({
const ovulationTestEnumOpenapi = ovulationTestEnum.meta({
id: "OvulationTest",
description: "Ovulation predictor-kit (OPK) reading.",
});
cervicalMucusEnum.meta({
const cervicalMucusEnumOpenapi = cervicalMucusEnum.meta({
id: "CervicalMucus",
description: "Cervical-mucus quality.",
});
homeTestResultEnum.meta({
const homeTestResultEnumOpenapi = homeTestResultEnum.meta({
id: "HomeTestResult",
description: "At-home test result (pregnancy / progesterone).",
});
cycleTrackingGoalEnum.meta({
const cycleTrackingGoalEnumOpenapi = cycleTrackingGoalEnum.meta({
id: "CycleTrackingGoal",
description: "Drives cycle copy + fertile-window gating.",
});
cycleDayLogInputSchema.meta({
const cycleDayLogInput = cycleDayLogInputSchema.meta({
id: "CycleDayLogInput",
description:
"One day's cycle capture. `note` is encrypted at rest; every other field is queryable plaintext. UPSERT key: `(userId, source, externalId)` when externalId present, else `(userId, date)`. Shared by the single POST, the bulk drain, and the period shortcut.",
});
cycleBulkSchema.meta({
const cycleDayLogBulkRequest = cycleBulkSchema.meta({
id: "CycleDayLogBulkRequest",
description:
"Outbox / HealthKit drain. Up to 500 entries per call; wrapped in `withIdempotency`; rate-limited 60/min. Each entry upserts per the day-log key.",
});
cyclePeriodSchema.meta({
const cyclePeriodRequest = cyclePeriodSchema.meta({
id: "CyclePeriodRequest",
description:
"One-tap period boundary. `start` opens a new cycle (closing the prior), `end` stamps the current cycle's periodEndDate; both write a boundary day-log.",
});
cyclePrefsSchema.meta({
const cyclePrefsRequest = cyclePrefsSchema.meta({
id: "CyclePrefsRequest",
description:
"Partial cycle-preferences deep-merge. `enabled` flips the feature gate (`cycleTrackingEnabled`). Omitted fields are left untouched.",
});
cycleDayLogPatchSchema.meta({
const cycleDayLogPatchRequest = cycleDayLogPatchSchema.meta({
id: "CycleDayLogPatchRequest",
description:
"Partial day-log edit. Every field optional; `note` re-encrypts (explicit null clears it). `date` / `source` / `externalId` are immutable on update.",
@@ -113,19 +117,19 @@ const cycleDayLogDto = z
id: z.string(),
date: z.string(),
cycleId: z.string().nullable(),
flow: flowLevelEnum.nullable(),
flow: flowLevelEnumOpenapi.nullable(),
intermenstrualBleeding: z.boolean(),
basalBodyTempC: z.number().nullable(),
temperatureExcluded: z.boolean(),
ovulationTest: ovulationTestEnum.nullable(),
cervicalMucus: cervicalMucusEnum.nullable(),
ovulationTest: ovulationTestEnumOpenapi.nullable(),
cervicalMucus: cervicalMucusEnumOpenapi.nullable(),
cervixPosition: cervixPositionEnum.nullable(),
cervixFirmness: cervixFirmnessEnum.nullable(),
cervixOpening: cervixOpeningEnum.nullable(),
sexualActivity: z.boolean(),
protectedSex: z.boolean().nullable(),
pregnancyTest: homeTestResultEnum.nullable(),
progesteroneTest: homeTestResultEnum.nullable(),
pregnancyTest: homeTestResultEnumOpenapi.nullable(),
progesteroneTest: homeTestResultEnumOpenapi.nullable(),
contraceptive: z.string().nullable(),
symptoms: z.array(cycleSymptomDto),
note: z.string().nullable(),
@@ -191,7 +195,7 @@ const cycleCalendarDayDto = z.object({
description:
"Whether a logged cycle opens on this day. Deleting this day's log removes that cycle start with it.",
}),
flow: flowLevelEnum.nullable(),
flow: flowLevelEnumOpenapi.nullable(),
hasSymptoms: z.boolean(),
confidence: z.number(),
// v1.15.0 — logged basal-body-temperature + fertility-sign markers, surfaced
@@ -199,8 +203,8 @@ const cycleCalendarDayDto = z.object({
// loaded server-side for the symptothermal layer; no extra query).
basalBodyTempC: z.number().nullable(),
temperatureExcluded: z.boolean(),
ovulationTest: ovulationTestEnum.nullable(),
cervicalMucus: cervicalMucusEnum.nullable(),
ovulationTest: ovulationTestEnumOpenapi.nullable(),
cervicalMucus: cervicalMucusEnumOpenapi.nullable(),
cervixPosition: cervixPositionEnum.nullable(),
cervixFirmness: cervixFirmnessEnum.nullable(),
cervixOpening: cervixOpeningEnum.nullable(),
@@ -247,7 +251,7 @@ const cycleVerdictDto = z
const cycleProfileDto = z
.object({
goal: cycleTrackingGoalEnum,
goal: cycleTrackingGoalEnumOpenapi,
cycleTrackingEnabled: z.boolean(),
secondarySymptom: secondarySymptomEnum,
rawChartMode: z.boolean(),
@@ -266,7 +270,7 @@ const cycleProfileDto = z
const cycleCalendarResponse = z.object({
profile: z.object({
goal: cycleTrackingGoalEnum,
goal: cycleTrackingGoalEnumOpenapi,
rawChartMode: z.boolean(),
predictionEnabled: z.boolean(),
cyclesObserved: z.number().int(),
@@ -444,10 +448,6 @@ const cycleDisabledOnADelegableRoute = recordRefusal(
// rest and never reaches a wide event or an audit excerpt — only the icon and
// the minted key do.
// `.meta()` RETURNS a new schema in Zod 4 rather than annotating in place, so
// the result has to be captured and referenced. A bare `schema.meta({...})`
// statement registers nothing, and the component id it names never reaches
// the document.
const createCycleCustomSymptomRequest = createCustomSymptomSchema.meta({
id: "CreateCycleCustomSymptomRequest",
description:
@@ -682,7 +682,7 @@ export const cyclePaths: NonNullable<ZodOpenApiObject["paths"]> = {
parameters: [idempotencyKeyParameter],
requestBody: {
required: true,
content: { "application/json": { schema: cycleDayLogInputSchema } },
content: { "application/json": { schema: cycleDayLogInput } },
},
responses: {
...idempotentWrite(),
@@ -719,7 +719,7 @@ export const cyclePaths: NonNullable<ZodOpenApiObject["paths"]> = {
requestParams: { path: z.object({ id: z.string() }) },
requestBody: {
required: true,
content: { "application/json": { schema: cycleDayLogPatchSchema } },
content: { "application/json": { schema: cycleDayLogPatchRequest } },
},
responses: {
"200": {
@@ -764,7 +764,7 @@ export const cyclePaths: NonNullable<ZodOpenApiObject["paths"]> = {
parameters: [idempotencyKeyParameter],
requestBody: {
required: true,
content: { "application/json": { schema: cycleBulkSchema } },
content: { "application/json": { schema: cycleDayLogBulkRequest } },
},
responses: {
...idempotentWrite(),
@@ -790,7 +790,7 @@ export const cyclePaths: NonNullable<ZodOpenApiObject["paths"]> = {
parameters: [idempotencyKeyParameter],
requestBody: {
required: true,
content: { "application/json": { schema: cyclePeriodSchema } },
content: { "application/json": { schema: cyclePeriodRequest } },
},
responses: {
...idempotentWrite(),
@@ -979,7 +979,7 @@ export const cyclePaths: NonNullable<ZodOpenApiObject["paths"]> = {
"Deep-merges the supplied fields. `enabled` flips `cycleTrackingEnabled`. Returns the merged CycleProfileDTO. NOT gated.",
requestBody: {
required: true,
content: { "application/json": { schema: cyclePrefsSchema } },
content: { "application/json": { schema: cyclePrefsRequest } },
},
responses: {
"200": {
+11 -8
View File
@@ -78,25 +78,28 @@ const documentChatListSchema = z
})
.meta({ id: "DocumentChatList" });
inboundConfirmSchema.meta({
// Zod 4's `.meta()` returns a NEW instance carrying the id rather than
// annotating in place, so each annotated schema is bound to a const and the
// route table below references that const — a bare call would register nothing.
const inboundConfirmRequest = inboundConfirmSchema.meta({
id: "InboundConfirmRequest",
description:
"The approve/reject decisions a human made on the review screen. Each names a staged fact by id. Approved facts are committed to the structured stores (labs / conditions / medications) through their normal create paths; rejected facts are discarded. A fact still flagged `needsReview` (below the confidence floor, never edited) cannot be approved — it is reported back as `needsReview`. No `userId` field — it is narrowed from the session; every fact id is re-scoped to the document + caller.",
});
inboundFactEditSchema.meta({
const inboundFactEditRequest = inboundFactEditSchema.meta({
id: "InboundFactEditRequest",
description:
"A correction to a staged fact before approval (fixes OCR / units / dates / codes). Discriminated by `factType` so the edit cannot change the fact's resource type. A successful edit clears `needsReview` — the values become user-asserted.",
});
documentUpdateSchema.meta({
const documentUpdateRequest = documentUpdateSchema.meta({
id: "DocumentUpdateRequest",
description:
"Metadata edit for a stored document: `title` (user label; null clears it), `kind` (category), `documentDate` (user filing date, YYYY-MM-DD; null clears it), `episodeIds` (REPLACE-SET of condition links — the document's links become exactly this set; an empty array unlinks everything; every id must be a live episode of the caller or the whole request answers 404), `encounterIds` (the same replace-set semantics over the caller's visits). At least one field required. No `userId` field — narrowed from the session and fed to the Prisma `where` with the row id.",
});
documentBulkSchema.meta({
const documentBulkRequest = documentBulkSchema.meta({
id: "DocumentBulkRequest",
description:
"One bulk action over up to 100 owner-scoped documents: `setKind` (requires `kind`), `linkEpisode` / `unlinkEpisode` (require `episodeId`), `linkEncounter` / `unlinkEncounter` (require `encounterId`), `delete` (tombstone, undo-able for 30 days), `restore` (clear tombstone). Partial failures never abort the batch — see the per-id result array.",
@@ -514,7 +517,7 @@ export const inboundDocumentPaths: NonNullable<ZodOpenApiObject["paths"]> = {
"Applies one action to up to 100 owner-scoped documents and returns a per-id result array — a partial failure never aborts the batch. `error` is `notFound` (unknown / foreign / tombstoned where liveness is required) or `conflict` (restore blocked by a live duplicate of the same bytes).",
requestBody: {
required: true,
content: { "application/json": { schema: documentBulkSchema } },
content: { "application/json": { schema: documentBulkRequest } },
},
responses: {
"200": {
@@ -587,7 +590,7 @@ export const inboundDocumentPaths: NonNullable<ZodOpenApiObject["paths"]> = {
],
requestBody: {
required: true,
content: { "application/json": { schema: documentUpdateSchema } },
content: { "application/json": { schema: documentUpdateRequest } },
},
responses: {
"200": {
@@ -1078,7 +1081,7 @@ export const inboundDocumentPaths: NonNullable<ZodOpenApiObject["paths"]> = {
],
requestBody: {
required: true,
content: { "application/json": { schema: inboundFactEditSchema } },
content: { "application/json": { schema: inboundFactEditRequest } },
},
responses: {
"200": {
@@ -1110,7 +1113,7 @@ export const inboundDocumentPaths: NonNullable<ZodOpenApiObject["paths"]> = {
],
requestBody: {
required: true,
content: { "application/json": { schema: inboundConfirmSchema } },
content: { "application/json": { schema: inboundConfirmRequest } },
},
responses: {
...idempotentWrite(),
+21 -17
View File
@@ -41,49 +41,53 @@ import {
stdResponses,
} from "./shared";
encounterCreateSchema.meta({
// `.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 createEncounterRequest = encounterCreateSchema.meta({
id: "CreateEncounterRequest",
description:
"File a visit, or book one. `occurredAt` is the only required field — a visit saves with a date and nothing else. A future instant with `status: PLANNED` books an appointment and mints exactly one reminder for it. `reason` and `outcome` are encrypted at rest. The three id arrays pre-link documents, lab results and condition episodes; an id naming nothing the caller owns is dropped rather than refused, so a link never blocks a save.",
});
encounterUpdateSchema.meta({
const updateEncounterRequest = encounterUpdateSchema.meta({
id: "UpdateEncounterRequest",
description:
"Partial edit of a visit; an omitted key leaves the column untouched, and a body naming nothing is a 422. Moving `occurredAt` re-anchors the appointment reminder the visit already owns and never mints a second. Moving `status` to DONE closes the checkup named by `reminderId`, on the transition only. CANCELLED and NO_SHOW stop the reminder without deleting the row. A present id array replaces that link family, empty array included.",
});
encounterListQuerySchema.meta({
const listEncountersQuery = encounterListQuerySchema.meta({
id: "ListEncountersQuery",
description:
"Window and filters for the visit list: `from` / `to` (ISO-8601 instants), `status`, `practitionerId`, `episodeId` (only visits filed against that condition episode — the condition side of the link, and the only direction it is offered in), and `limit` (1–200, default 100).",
});
encounterSuggestQuerySchema.meta({
const suggestEncounterQuery = encounterSuggestQuerySchema.meta({
id: "SuggestEncounterQuery",
description:
"The date to resolve around, as an ISO-8601 instant with an offset. A document uses `reportDate ?? documentDate`; a lab panel uses `takenAt`.",
});
encounterLinkSchema.meta({
const encounterLinkRequest = encounterLinkSchema.meta({
id: "EncounterLinkRequest",
description:
"Targets to file against, or unfile from, a visit. Idempotent in both directions and capped at 100 ids. Ids naming nothing the caller owns come back in `unknown` rather than failing the request.",
});
practitionerCreateSchema.meta({
const createPractitionerRequest = practitionerCreateSchema.meta({
id: "CreatePractitionerRequest",
description:
"Add a doctor or practice to the caller's own address book. `name` is the only required field and is stored as queryable plaintext so the picker can search and sort it; `note` is encrypted at rest. `specialty` is free text rather than an enum. Nothing is unique across accounts.",
});
practitionerUpdateSchema.meta({
const updatePractitionerRequest = practitionerUpdateSchema.meta({
id: "UpdatePractitionerRequest",
description:
"Partial edit; an omitted key leaves the column untouched, an explicit null clears it, and a body naming nothing is a 422.",
});
practitionerListQuerySchema.meta({
const listPractitionersQuery = practitionerListQuerySchema.meta({
id: "ListPractitionersQuery",
description:
"Query params for the address book: `q` (case-insensitive substring of the name OR the practice — a person looks one up by whichever of the two they remember) and `limit` (1–200, default 100). Name-ordered.",
@@ -233,7 +237,7 @@ export const encounterPaths: NonNullable<ZodOpenApiObject["paths"]> = {
summary: "List visits",
description:
"Returns the caller's live visits, split into `upcoming` and `past`. Bounded and windowed; soft-deleted visits are excluded.",
requestParams: { query: encounterListQuerySchema },
requestParams: { query: listEncountersQuery },
responses: {
...recordRefusal(),
"200": {
@@ -255,7 +259,7 @@ export const encounterPaths: NonNullable<ZodOpenApiObject["paths"]> = {
"Creates one visit. A future instant with `status: PLANNED` books an appointment and mints its reminder; a past instant with `status: DONE` and a `reminderId` closes that checkup. Claiming both is a 422 — a visit that has not happened cannot have closed anything. Honours `Idempotency-Key`. Audits as `encounter.visit.create`.",
requestBody: {
required: true,
content: { "application/json": { schema: encounterCreateSchema } },
content: { "application/json": { schema: createEncounterRequest } },
},
responses: {
...idempotentWrite(),
@@ -278,7 +282,7 @@ export const encounterPaths: NonNullable<ZodOpenApiObject["paths"]> = {
summary: "Which visit does a record dated around this day belong to?",
description:
"Resolves the caller's live DONE or PLANNED visits within ±7 days of `anchor` into a verdict, so the three capture moments that ask the question — a document arriving, a lab panel committed from an extraction, a lab panel typed by hand — all read the same answer. `one` means pre-select it visibly with one undo; `many` means offer a picker with NOTHING pre-selected, because pre-selecting the first of two teaches a person to stop reading suggestions; `none` means offer nothing at all. Cancelled visits and no-shows are never candidates: neither happened, so neither produced the record being filed. No AI provider is involved and none is required.",
requestParams: { query: encounterSuggestQuerySchema },
requestParams: { query: suggestEncounterQuery },
responses: {
...recordRefusal(),
"200": {
@@ -325,7 +329,7 @@ export const encounterPaths: NonNullable<ZodOpenApiObject["paths"]> = {
requestParams: idPath,
requestBody: {
required: true,
content: { "application/json": { schema: encounterUpdateSchema } },
content: { "application/json": { schema: updateEncounterRequest } },
},
responses: {
...recordRefusal(),
@@ -396,7 +400,7 @@ export const encounterPaths: NonNullable<ZodOpenApiObject["paths"]> = {
requestParams: idPath,
requestBody: {
required: true,
content: { "application/json": { schema: encounterLinkSchema } },
content: { "application/json": { schema: encounterLinkRequest } },
},
responses: {
"200": {
@@ -425,7 +429,7 @@ export const encounterPaths: NonNullable<ZodOpenApiObject["paths"]> = {
requestParams: idPath,
requestBody: {
required: true,
content: { "application/json": { schema: encounterLinkSchema } },
content: { "application/json": { schema: encounterLinkRequest } },
},
responses: {
"200": {
@@ -453,7 +457,7 @@ export const encounterPaths: NonNullable<ZodOpenApiObject["paths"]> = {
summary: "List practitioners",
description:
"Returns the caller's address book, name-ordered, with an optional case-insensitive name search.",
requestParams: { query: practitionerListQuerySchema },
requestParams: { query: listPractitionersQuery },
responses: {
...recordRefusal(),
"200": {
@@ -478,7 +482,7 @@ export const encounterPaths: NonNullable<ZodOpenApiObject["paths"]> = {
"Creates one address-book entry. Honours `Idempotency-Key`. Audits as `practitioner.contact.create`.",
requestBody: {
required: true,
content: { "application/json": { schema: practitionerCreateSchema } },
content: { "application/json": { schema: createPractitionerRequest } },
},
responses: {
...idempotentWrite(),
@@ -524,7 +528,7 @@ export const encounterPaths: NonNullable<ZodOpenApiObject["paths"]> = {
requestParams: idPath,
requestBody: {
required: true,
content: { "application/json": { schema: practitionerUpdateSchema } },
content: { "application/json": { schema: updatePractitionerRequest } },
},
responses: {
...recordRefusal(),
+9 -6
View File
@@ -30,19 +30,22 @@ import {
stdResponses,
} from "./shared";
familyHistoryCreateSchema.meta({
// Zod 4's `.meta()` returns a NEW instance carrying the id rather than
// annotating in place, so each annotated schema is bound to a const and the
// route table below references that const — a bare call would register nothing.
const createFamilyHistoryRequest = 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({
const updateFamilyHistoryRequest = 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({
const listFamilyHistoryQuery = familyHistoryListQuerySchema.meta({
id: "ListFamilyHistoryQuery",
description:
"Query params for the family-history list: optional `limit` (1–200, default 100). Newest-first.",
@@ -78,7 +81,7 @@ export const familyHistoryPaths: NonNullable<ZodOpenApiObject["paths"]> = {
summary: "List family history (v1.25)",
description:
"Returns the caller's live (non-deleted) family-history entries, newest-first.",
requestParams: { query: familyHistoryListQuerySchema },
requestParams: { query: listFamilyHistoryQuery },
responses: {
...recordRefusal(),
"200": {
@@ -104,7 +107,7 @@ export const familyHistoryPaths: NonNullable<ZodOpenApiObject["paths"]> = {
requestBody: {
required: true,
content: {
"application/json": { schema: familyHistoryCreateSchema },
"application/json": { schema: createFamilyHistoryRequest },
},
},
responses: {
@@ -158,7 +161,7 @@ export const familyHistoryPaths: NonNullable<ZodOpenApiObject["paths"]> = {
requestBody: {
required: true,
content: {
"application/json": { schema: familyHistoryUpdateSchema },
"application/json": { schema: updateFamilyHistoryRequest },
},
},
responses: {
+28 -2
View File
@@ -19,6 +19,32 @@ import {
stdResponses,
} from "./shared";
// The two request bodies this module publishes. `.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. Both
// annotations used to sit in `./coach`, which imported the schemas for no
// other reason and never referenced them; the two operations that DO send
// these bodies are both here.
//
// v1.7.0 — health-record export selection. Strict shape: unknown keys
// (including any attempt to smuggle a userId) 422 via returnAllZodIssues.
// v1.11.0 — clinician share-link create payload. Strict; no `userId` field
// (the owner is always narrowed from the session/Bearer). `expiresAt` is
// required and capped at SHARE_LINK_MAX_DAYS; the scope columns are frozen
// write-once at creation.
const createShareLinkRequest = createShareLinkSchema.meta({
id: "CreateShareLinkRequest",
description:
"v1.11.0 — owner request to mint a clinician share link to their own health record. `expiresAt` is required (absolute ISO instant) and capped at 90 days. `rangeStart`/`rangeEnd` freeze the reporting window (rangeEnd null = rolling). `selection` freezes which record leaves the link may serve, and omitting it means an empty scope rather than a default one. `documentIds` freezes the documents the link carries; `documentOnly` mints a share that serves those documents and no record scope at all. A share link serves the rendered page and the documents frozen onto it — there is no FHIR or other machine-readable face behind a share token. Strict: unknown keys 422.",
});
const healthRecordExportRequest = exportSelectionSchema.meta({
id: "HealthRecordExportRequest",
description:
"v1.7.0 — health-record / doctor-handover export selection. `format` picks PDF, FHIR R4 document Bundle, or a combined zip package. `selection` is REQUIRED and carries the leaf inclusion list — membership is inclusion, absence is exclusion, and there is no server-side default; the retired grouped `sections` shape is a 422. No `userId` field — the user is always narrowed from the session/Bearer. The route is strict: unknown keys 422.",
});
// v1.11.0 — clinician share-link owner-facing summary (never the raw token).
const shareLinkSummary = z.object({
id: z.string(),
@@ -91,7 +117,7 @@ export const healthRecordPaths: NonNullable<ZodOpenApiObject["paths"]> = {
requestBody: {
required: true,
content: {
"application/json": { schema: exportSelectionSchema },
"application/json": { schema: healthRecordExportRequest },
},
},
responses: {
@@ -124,7 +150,7 @@ export const healthRecordPaths: NonNullable<ZodOpenApiObject["paths"]> = {
requestBody: {
required: true,
content: {
"application/json": { schema: createShareLinkSchema },
"application/json": { schema: createShareLinkRequest },
},
},
responses: {
+15 -26
View File
@@ -22,8 +22,6 @@ import {
illnessEpisodeResolveSchema,
illnessEpisodeListQuerySchema,
illnessDayLogInputSchema,
illnessDayLogQuerySchema,
illnessDayLogListQuerySchema,
illnessInsightsQuerySchema,
illnessTypeEnum,
illnessLifecycleEnum,
@@ -38,48 +36,39 @@ import {
stdResponses,
} from "./shared";
illnessEpisodeCreateSchema.meta({
// Zod 4's `.meta()` returns a NEW instance carrying the id rather than
// annotating in place, so each annotated schema is bound to a const and the
// route table below references that const — a bare call would register nothing.
const createIllnessEpisodeRequest = illnessEpisodeCreateSchema.meta({
id: "CreateIllnessEpisodeRequest",
description:
"Open an illness/condition episode. `label` is the user-facing name; `type` + `lifecycle` classify it. `onsetAt` defaults to 'now' server-side when omitted. `parentConditionId` threads a FLARE/RECURRING bout under a parent condition (must be an owned, live episode). The optional `note` is encrypted at rest.",
});
illnessEpisodeUpdateSchema.meta({
const updateIllnessEpisodeRequest = illnessEpisodeUpdateSchema.meta({
id: "UpdateIllnessEpisodeRequest",
description:
"Partial edit of an episode; an omitted key leaves the column untouched. A `null` `resolvedAt` re-opens a resolved episode; a `null` `parentConditionId` detaches it from its parent. Rejects unknown keys.",
});
illnessEpisodeResolveSchema.meta({
const resolveIllnessEpisodeRequest = illnessEpisodeResolveSchema.meta({
id: "ResolveIllnessEpisodeRequest",
description:
"Mark an episode recovered. `resolvedAt` defaults to 'now' when omitted, so the one-tap 'mark recovered' affordance can send an empty body. A CHRONIC_ONGOING episode has no recovery date by design and 422s.",
});
illnessEpisodeListQuerySchema.meta({
const listIllnessEpisodesQuery = illnessEpisodeListQuerySchema.meta({
id: "ListIllnessEpisodesQuery",
description:
"Query params for the episode history: optional `limit` (1–100, default 50) and `includeResolved` ('true' | 'false'); 'false' hides episodes that already carry a `resolvedAt`. Newest-first by `onsetAt`.",
});
illnessDayLogInputSchema.meta({
const upsertIllnessDayLogRequest = illnessDayLogInputSchema.meta({
id: "UpsertIllnessDayLogRequest",
description:
"Upsert one day's symptom / functional-impact / fever row for an episode (keyed on `(episodeId, date)`). `date` is a `YYYY-MM-DD` tz-anchored day. `functionalImpact` (0–3) + `feverC` are queryable plaintext; `symptoms` carry an optional 0–3 Jackson/WURSS severity per link; the optional `note` is encrypted at rest.",
});
illnessDayLogQuerySchema.meta({
id: "GetIllnessDayLogQuery",
description:
"Single-day read query: `date` is a `YYYY-MM-DD` day. Returns the matching day-log or `null` when nothing is logged for that day.",
});
illnessDayLogListQuerySchema.meta({
id: "ListIllnessDayLogsQuery",
description:
"Date-less LIST query (v1.18.3): omit `date` to page the episode's whole day-log history. `limit` (1–200, default 60) + `offset` (default 0) + `sortDir` ('asc' | 'desc', default 'desc') with `meta.total`. Mutually exclusive with the single-day `date` read — presence of `date` selects the single-day mode.",
});
/**
* The documented query for `GET .../day-logs`: a `date` (single-day read) OR
* the list pagination params (date-less list). Both are optional on the wire;
@@ -97,7 +86,7 @@ const illnessDayLogGetQuery = z
})
.meta({ id: "GetIllnessDayLogsQuery" });
illnessInsightsQuerySchema.meta({
const illnessInsightsQuery = illnessInsightsQuerySchema.meta({
id: "IllnessInsightsQuery",
description:
"Cross-episode retrospective window query: optional `windowDays` (30–1095, default 365) and optional `includeRecoveryGap` (default false). With `includeRecoveryGap` off the response carries the count breakdown and a null typical recovery gap on a single fast query; set it true to pay for the per-episode correlation that computes the recovery gap. Retrospective only — the engine summarises past episodes, never forecasts.",
@@ -298,7 +287,7 @@ export const illnessPaths: NonNullable<ZodOpenApiObject["paths"]> = {
summary: "List illness episodes (v1.18.1)",
description:
"Returns the caller's live (non-deleted) illness/condition episodes, newest-first by onset. `includeResolved=false` hides episodes that already carry a `resolvedAt`. Born-gated.",
requestParams: { query: illnessEpisodeListQuerySchema },
requestParams: { query: listIllnessEpisodesQuery },
responses: {
...recordRefusal(),
"200": {
@@ -324,7 +313,7 @@ export const illnessPaths: NonNullable<ZodOpenApiObject["paths"]> = {
requestBody: {
required: true,
content: {
"application/json": { schema: illnessEpisodeCreateSchema },
"application/json": { schema: createIllnessEpisodeRequest },
},
},
responses: {
@@ -375,7 +364,7 @@ export const illnessPaths: NonNullable<ZodOpenApiObject["paths"]> = {
requestBody: {
required: true,
content: {
"application/json": { schema: illnessEpisodeUpdateSchema },
"application/json": { schema: updateIllnessEpisodeRequest },
},
},
responses: {
@@ -456,7 +445,7 @@ export const illnessPaths: NonNullable<ZodOpenApiObject["paths"]> = {
requestBody: {
required: false,
content: {
"application/json": { schema: illnessEpisodeResolveSchema },
"application/json": { schema: resolveIllnessEpisodeRequest },
},
},
responses: {
@@ -514,7 +503,7 @@ export const illnessPaths: NonNullable<ZodOpenApiObject["paths"]> = {
requestBody: {
required: true,
content: {
"application/json": { schema: illnessDayLogInputSchema },
"application/json": { schema: upsertIllnessDayLogRequest },
},
},
responses: {
@@ -576,7 +565,7 @@ export const illnessPaths: NonNullable<ZodOpenApiObject["paths"]> = {
summary: "Cross-episode retrospective insights (v1.18.1)",
description:
"Returns the cross-episode retrospective summary over a trailing window: 'sick N times · typical recovery gap X days', a recurrence-by-month tally, and a per-type breakdown. The typical gap is withheld (null) below the min-sample floor (asserts nothing thin). Retrospective ONLY — the recurrence figure is a count of the past, never a forecast. Born-gated + owner-scoped.",
requestParams: { query: illnessInsightsQuerySchema },
requestParams: { query: illnessInsightsQuery },
responses: {
"200": {
description: "The cross-episode retrospective summary.",
+7 -5
View File
@@ -14,12 +14,14 @@
import type { ZodOpenApiObject } from "zod-openapi";
import { z } from "zod/v4";
import {
glucoseContextEnum,
measurementTypeEnum,
} from "@/lib/validations/measurement";
import { glucoseContextEnum } from "@/lib/validations/measurement";
import { moodLevelEnum } from "@/lib/validations/mood";
import { dataEnvelope, errorEnvelope, stdResponses } from "./shared";
import {
dataEnvelope,
errorEnvelope,
measurementTypeEnum,
stdResponses,
} from "./shared";
const csvImportRowResult = z
.object({
+8 -2
View File
@@ -72,7 +72,7 @@ import {
} from "./medications";
import { mcpPaths } from "./mcp";
import { metaPaths } from "./meta";
import { moodPaths } from "./mood";
import { moodPaths, moodTagLayout } from "./mood";
import { notificationTransportPaths } from "./notifications-transport";
import { notificationChannelPaths } from "./notifications";
import { nutrientPaths } from "./nutrients";
@@ -82,7 +82,7 @@ import { recordSettingsPaths } from "./record-settings";
import { settingsPaths } from "./settings";
import { syncPaths } from "./sync";
import { workoutPaths } from "./workouts";
import { coachPrefsSchema } from "@/lib/validations/coach-prefs";
import { coachPrefsSchema } from "./shared";
export const openApiPaths: NonNullable<ZodOpenApiObject["paths"]> = {
...cyclePaths,
@@ -183,6 +183,11 @@ export const openApiComponents: NonNullable<ZodOpenApiObject["components"]> = {
// this forced registration the schema the description points to would be
// silently absent from the published spec.
//
// `MoodTagLayout` joins them for exactly the same reason: its annotation is
// in the assigning form and correct, but both consumers `.extend()` it, so
// the base name is never referenced and the description the mood surface
// relies on would go unpublished.
//
// `CoachPrefs` is the same shape: the PUT `/api/auth/me/coach-prefs`
// description tells the reader "Body is validated against `CoachPrefs`",
// but every actual consumer is a `.extend()` of it (`PutCoachPrefsRequest`,
@@ -193,6 +198,7 @@ export const openApiComponents: NonNullable<ZodOpenApiObject["components"]> = {
MedicationDoseHistoryImportFatalReason:
medicationDoseHistoryImportFatalReasonEnum,
CoachPrefs: coachPrefsSchema,
MoodTagLayout: moodTagLayout,
},
// v1.36.0 — the per-request account selector, defined in the sharing route
// module so that `src/__tests__/acting-account-boundary-guard.test.ts` keeps
+1 -4
View File
@@ -6,10 +6,7 @@
* runtime request parsing, so the wire contract stays single-source.
*/
import { z } from "zod/v4";
import {
measurementTypeEnum,
measurementSourceEnum,
} from "@/lib/validations/measurement";
import { measurementSourceEnum, measurementTypeEnum } from "../shared";
import { METRIC_STATUS_IDS } from "@/lib/insights/metric-status-registry";
import {
DERIVED_METRIC_IDS,
+9 -6
View File
@@ -25,19 +25,22 @@ import {
stdResponses,
} from "./shared";
createLabResultSchema.meta({
// Zod 4's `.meta()` returns a NEW instance carrying the id rather than
// annotating in place, so each annotated schema is bound to a const and the
// route table below references that const — a bare call would register nothing.
const createLabResultRequest = createLabResultSchema.meta({
id: "CreateLabResultRequest",
description:
'Record a single biomarker reading (HbA1c, LDL, ferritin, TSH, …). A reading is EITHER numeric (`value` + `unit`, optional reference bounds) OR qualitative (`valueText`, e.g. "negativ" / "positiv") — exactly one, never both, never neither. `analyte` + `unit` are free-form (a lab prints its own naming); a qualitative reading needs no unit / bounds. When both numeric bounds are present `referenceLow` must not exceed `referenceHigh`. `takenAt` is a backdatable ISO instant (no future, ≤ 50 years past). The optional `note` is encrypted at rest. The optional `source` records provenance: `"OCR"` for an on-device-OCR capture (the raw image never reaches the server on this path), defaulting to `"MANUAL"` when omitted. The optional `encounterId` files the reading against a visit the caller owns — always optional, and a link that could not be made never fails the reading.',
});
updateLabResultSchema.meta({
const updateLabResultRequest = updateLabResultSchema.meta({
id: "UpdateLabResultRequest",
description:
"Partial edit of a lab result. An omitted key leaves the column untouched; an explicit `null` on `panel` / `note` / a reference bound clears it. Edit the matching value field for the row's type — `value` for a numeric row, `valueText` for a qualitative one; a request must not carry both, and a cross-type edit (the wrong field for the row) is rejected.",
});
listLabResultsSchema.meta({
const listLabResultsQuery = listLabResultsSchema.meta({
id: "ListLabResultsQuery",
description:
"Query params for the lab-result list: optional `analyte` (exact) + `panel` filters, an inclusive `from`/`to` date range, and `limit` (≤ 500) / `offset` pagination. Defaults to `takenAt` DESC.",
@@ -122,7 +125,7 @@ export const labsPaths: NonNullable<ZodOpenApiObject["paths"]> = {
summary: "List the caller's lab results",
description:
"Returns the caller's live (non-deleted) lab results, newest first, with optional analyte / panel / date-range filters. Each row carries a server-computed neutral `rangeStatus` and a `hasNote` flag (the encrypted note itself is not echoed).",
requestParams: { query: listLabResultsSchema },
requestParams: { query: listLabResultsQuery },
responses: {
...recordRefusal(),
"200": {
@@ -144,7 +147,7 @@ export const labsPaths: NonNullable<ZodOpenApiObject["paths"]> = {
parameters: [idempotencyKeyParameter],
requestBody: {
required: true,
content: { "application/json": { schema: createLabResultSchema } },
content: { "application/json": { schema: createLabResultRequest } },
},
responses: {
...idempotentWrite(),
@@ -190,7 +193,7 @@ export const labsPaths: NonNullable<ZodOpenApiObject["paths"]> = {
requestParams: { path: z.object({ id: z.string() }) },
requestBody: {
required: true,
content: { "application/json": { schema: updateLabResultSchema } },
content: { "application/json": { schema: updateLabResultRequest } },
},
responses: {
...recordRefusal(),
+5 -7
View File
@@ -7,21 +7,19 @@
*/
import { z } from "zod/v4";
import type { ZodOpenApiObject } from "zod-openapi";
import {
createMeasurementSchema,
listMeasurementsSchema,
measurementTypeEnum,
updateMeasurementSchema,
measurementSourceEnum,
} from "@/lib/validations/measurement";
import { updateMeasurementSchema } from "@/lib/validations/measurement";
import { seriesBatchQuerySchema } from "@/lib/validations/series-batch";
import { deviceTypeEnum } from "@/lib/validations/source-priority";
import {
MODULE_DISABLED_DESCRIPTION,
createMeasurementSchema,
dataEnvelope,
errorEnvelope,
idempotencyKeyParameter,
idempotentWrite,
listMeasurementsSchema,
measurementSourceEnum,
measurementTypeEnum,
recordRefusal,
stdResponses,
} from "./shared";
+9 -6
View File
@@ -19,7 +19,6 @@ import {
bulkDeleteIntakeEventsSchema,
updateIntakeEventSchema,
} from "@/lib/validations/medication";
import { medicationExtractionSchema } from "@/lib/ai/coach/medication-extract-prompt";
import {
scheduleRevisionCreateSchema,
scheduleRevisionUpdateSchema,
@@ -39,13 +38,16 @@ import {
stdResponses,
} from "../shared";
efficacyTargetOverrideSchema.meta({
// Zod 4's `.meta()` returns a NEW instance carrying the id rather than
// annotating in place, so each annotated schema is bound to a const and the
// route table below references that const — a bare call would register nothing.
const setMedicationEfficacyTargetRequest = efficacyTargetOverrideSchema.meta({
id: "SetMedicationEfficacyTargetRequest",
description:
"Set or clear the user's explicit efficacy-target override for a medication. `clear:true` removes the override so the resolver reverts to the derived (ATC class prefix → name inference) target; otherwise pin exactly ONE of `measurementType` (a metric series) / `biomarkerId` (a lab analyte). `userId` is never a field — ownership is narrowed through the medication (and the biomarker for a lab target).",
});
medicationEfficacyResponseSchema.meta({
const medicationEfficacyResponse = medicationEfficacyResponseSchema.meta({
id: "MedicationEfficacyResponse",
description:
"Server-authoritative, strictly-descriptive efficacy view relating a medication to the outcome metric(s)/lab(s) its class is prescribed to move, around its start. Carries the resolved target(s) with their series, the start/dose-change/pause markers, a before/after-start comparison (honest `{present:false}` below the per-side data floor), an adherence lane (cadence-aware per-day rate, never recomputed), an optional conservative level-shift note, and the retarget options. There is NO verdict / score / assessment field by construction — the client renders numbers and neutral connective phrasing only, never a causal or dose-advice claim.",
@@ -62,6 +64,7 @@ import {
scheduleRevisionResource,
scheduleRevisionListResponse,
medicationExtractRequest,
medicationExtractionResult,
medicationListLayoutPutBody,
medicationListLayoutResult,
doseHistoryQuery,
@@ -1336,7 +1339,7 @@ export const medicationPaths: NonNullable<ZodOpenApiObject["paths"]> = {
content: {
"application/json": {
schema: dataEnvelope(
medicationExtractionSchema,
medicationExtractionResult,
"MedicationExtractResponse",
),
},
@@ -1462,7 +1465,7 @@ export const medicationPaths: NonNullable<ZodOpenApiObject["paths"]> = {
content: {
"application/json": {
schema: dataEnvelope(
medicationEfficacyResponseSchema,
medicationEfficacyResponse,
"GetMedicationEfficacyResponse",
),
},
@@ -1488,7 +1491,7 @@ export const medicationPaths: NonNullable<ZodOpenApiObject["paths"]> = {
requestBody: {
required: true,
content: {
"application/json": { schema: efficacyTargetOverrideSchema },
"application/json": { schema: setMedicationEfficacyTargetRequest },
},
},
responses: {
@@ -967,7 +967,10 @@ export const doseHistoryResponse = z
"Per-slot dose ledger over [from, to]: every expected slot with a status plus every off-schedule intake tagged ad-hoc. Built from the same band minter + `reconstructDoseHistory` the compliance % consumes, so the history view and the rate never contradict each other.",
});
medicationExtractionSchema.meta({
// Zod 4's `.meta()` returns a NEW instance carrying the id rather than
// annotating in place, so the annotated clone is exported and the path table
// references it — a bare call would register nothing.
export const medicationExtractionResult = medicationExtractionSchema.meta({
id: "MedicationExtractionResult",
description:
"Citation-guarded partial extraction of medication scheduling fields. Every field is optional; the wizard merges what is present onto the form state and leaves the rest blank. `name` and `dose` are post-validated against the original free-text and dropped when not substring-matched, so the wizard cannot silently land a hallucinated brand or dose. `cadenceKind` / `doseUnit` / `weekdays` are closed enums; numeric fields are clamped to the wizard's wire bounds.",
+7 -4
View File
@@ -27,13 +27,16 @@ import {
stdResponses,
} from "./shared";
createAssessmentSchema.meta({
// Zod 4's `.meta()` returns a NEW instance carrying the id rather than
// annotating in place, so each annotated schema is bound to a const and the
// route table below references that const — a bare call would register nothing.
const createMentalHealthAssessmentRequest = createAssessmentSchema.meta({
id: "CreateMentalHealthAssessmentRequest",
description:
"Record one completed screener administration: PHQ-9 (9 items, 0–3), GAD-7 (7 items, 0–3), WHO-5 (5 items, 0–5) or SCI (8 items, 0–4). The array length and per-item ceiling must match the instrument. The item answers are encrypted at rest and never returned. The server computes the total (WHO-5 reports raw-sum × 4 as 0–100) + severity band + item-9 safety flag. Opt-in, beside mood tracking — this is a screening surface, not a diagnosis.",
});
listAssessmentsSchema.meta({
const listMentalHealthAssessmentsQuery = listAssessmentsSchema.meta({
id: "ListMentalHealthAssessmentsQuery",
description:
"Filter the caller's screener history by instrument; paginate with limit/offset.",
@@ -111,7 +114,7 @@ export const mentalHealthPaths: NonNullable<ZodOpenApiObject["paths"]> = {
summary: "List the caller's screener history",
description:
"Returns PHQ-9 / GAD-7 / WHO-5 / SCI administrations (newest first). Totals + bands + flags only; raw item answers are never returned.",
requestParams: { query: listAssessmentsSchema },
requestParams: { query: listMentalHealthAssessmentsQuery },
responses: {
...recordRefusal(),
"200": {
@@ -136,7 +139,7 @@ export const mentalHealthPaths: NonNullable<ZodOpenApiObject["paths"]> = {
parameters: [idempotencyKeyParameter],
requestBody: {
content: {
"application/json": { schema: createAssessmentSchema },
"application/json": { schema: createMentalHealthAssessmentRequest },
},
},
responses: {
+24 -15
View File
@@ -61,40 +61,41 @@ import {
} from "./shared";
// ── Request schemas — annotated for spec emission ────────────────────
//
// Assigned rather than annotated in place: Zod 4's `.meta()` returns a NEW
// instance carrying the id, so the route table below must reference these
// consts for the names to reach `components.schemas`.
createCustomTagSchema.meta({
const createMoodTagRequest = createCustomTagSchema.meta({
id: "CreateMoodTagRequest",
description:
"Create a per-user custom mood tag (BINARY only). `label` is encrypted at rest. `icon` must come from the curated allowlist (unknown name → 422). `categoryKey` picks the home group — any seeded category key or one of the caller's own `customcat:` group keys; omitted → the seeded `custom` category. Capped at 50 active custom tags per user (422).",
});
updateCustomTagSchema.meta({
const updateMoodTagRequest = updateCustomTagSchema.meta({
id: "UpdateMoodTagRequest",
description:
"Partial custom-tag edit; at least one field required. `isActive:false` archives (history intact), `isActive:true` restores. `categoryKey` moves the tag to another group (a real categoryId move).",
});
createCustomGroupSchema.meta({
const createMoodTagGroupRequest = createCustomGroupSchema.meta({
id: "CreateMoodTagGroupRequest",
description:
"Create a per-user custom mood-tag group. `label` is encrypted at rest; `icon` must come from the curated allowlist. Capped at 12 active custom groups per user (422).",
});
updateCustomGroupSchema.meta({
const updateMoodTagGroupRequest = updateCustomGroupSchema.meta({
id: "UpdateMoodTagGroupRequest",
description:
"Partial custom-group edit; at least one field required. `isActive:false` retires the group without touching its tags.",
});
hideCatalogueTagSchema.meta({
const hideMoodTagRequest = hideCatalogueTagSchema.meta({
id: "HideMoodTagRequest",
description:
"Hide / show a CATALOGUE tag for the calling user. Custom tags are hidden via their own `isActive` flag on the custom PATCH instead (this route 400s a `custom:` key).",
});
// Assigned rather than annotated in place: Zod 4's `.meta()` returns a NEW
// instance carrying the id, so the route table below must reference these
// consts for the names to reach `components.schemas`.
const createMoodEntryRequest = createMoodEntrySchema.meta({
id: "CreateMoodEntryRequest",
description:
@@ -113,7 +114,15 @@ const listMoodEntriesQuery = listMoodEntriesSchema.meta({
"Filters + paging for the mood-entry list. `from` / `to` bound the local-day `date` label (YYYY-MM-DD, inclusive). `limit` capped at 500.",
});
moodTagLayoutSchema.meta({
/**
* Exported for the forced registration in `./index.ts`.
*
* Both consumers are an `.extend()` of this base, and `.extend()` builds a new
* object rather than a reference, so the name never carries forward on its own
* even though the annotation is in the assigning form. Same shape as
* `CoachPrefs` and `Medication`, and the same remedy.
*/
export const moodTagLayout = moodTagLayoutSchema.meta({
id: "MoodTagLayout",
description:
"Per-user mood-tag presentation blob. `groupOrder`: category keys in display order (unknown dropped, missing appended in seeded order). `placements`: categoryKey → ordered tag keys; a placed tag renders in that group at that index, un-placed tags follow in their home category. Display-only — placements referencing hidden/archived/unknown keys are silently dropped at read time. Both fields optional: PUT merges preserve-when-absent.",
@@ -220,7 +229,7 @@ const moodTagLayoutResolved = z
// v1.32.22 (M2) — the PUT request extends the layout schema with the base
// token (stripped pre-Zod at runtime).
const moodTagLayoutPutRequest = moodTagLayoutSchema
const moodTagLayoutPutRequest = moodTagLayout
.extend({ baseUpdatedAt: baseUpdatedAtField })
.meta({
id: "MoodTagLayoutPutRequest",
@@ -1349,7 +1358,7 @@ export const moodPaths: NonNullable<ZodOpenApiObject["paths"]> = {
"Mints a `custom:<uuid>` key, encrypts the label at rest, stores a BINARY tag owned by the caller under the chosen group (default: the seeded `custom` category). 422 over the 50-tag cap or on an unknown / foreign `categoryKey`.",
requestBody: {
required: true,
content: { "application/json": { schema: createCustomTagSchema } },
content: { "application/json": { schema: createMoodTagRequest } },
},
responses: {
"201": {
@@ -1376,7 +1385,7 @@ export const moodPaths: NonNullable<ZodOpenApiObject["paths"]> = {
requestParams: { path: z.object({ key: z.string() }) },
requestBody: {
required: true,
content: { "application/json": { schema: updateCustomTagSchema } },
content: { "application/json": { schema: updateMoodTagRequest } },
},
responses: {
"200": {
@@ -1429,7 +1438,7 @@ export const moodPaths: NonNullable<ZodOpenApiObject["paths"]> = {
requestParams: { path: z.object({ key: z.string() }) },
requestBody: {
required: true,
content: { "application/json": { schema: hideCatalogueTagSchema } },
content: { "application/json": { schema: hideMoodTagRequest } },
},
responses: {
"200": {
@@ -1460,7 +1469,7 @@ export const moodPaths: NonNullable<ZodOpenApiObject["paths"]> = {
"Mints a `customcat:<uuid>` key, encrypts the label at rest, stores a group owned by the caller. 422 over the 12-group cap.",
requestBody: {
required: true,
content: { "application/json": { schema: createCustomGroupSchema } },
content: { "application/json": { schema: createMoodTagGroupRequest } },
},
responses: {
"201": {
@@ -1487,7 +1496,7 @@ export const moodPaths: NonNullable<ZodOpenApiObject["paths"]> = {
requestParams: { path: z.object({ key: z.string() }) },
requestBody: {
required: true,
content: { "application/json": { schema: updateCustomGroupSchema } },
content: { "application/json": { schema: updateMoodTagGroupRequest } },
},
responses: {
"200": {
+6 -2
View File
@@ -24,7 +24,11 @@ import {
stdResponses,
} from "./shared";
ocrCommitSchema.meta({
// `.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 ocrCommitRequest = ocrCommitSchema.meta({
id: "OcrCommitRequest",
description:
"The rows a human confirmed on the Lab-OCR review screen. Each row is EITHER numeric (`value` + `unit`, optional reference bounds) OR qualitative (`valueText`) — exactly one. `analyte` drives a resolve-or-mint of the user-scoped biomarker; `takenAt` is a backdatable ISO instant. No `userId` field — it is narrowed from the session. 1..100 rows. The route skips a row that duplicates a live reading (same analyte + day + value). The optional `encounterId` files the panel against a visit the caller owns — ONE link per written result row, so a marker re-run on a different day belongs to its own visit; always optional, and a link that could not be made never fails the commit.",
@@ -209,7 +213,7 @@ export const ocrPaths: NonNullable<ZodOpenApiObject["paths"]> = {
'Writes ONLY the rows the human confirmed on the review screen. Each row resolves-or-mints a user-scoped biomarker and creates a `LabResult` with `source: "OCR"`; a row that duplicates a live reading is skipped. Idempotent (Idempotency-Key). Audits as `labs.ocr.commit`. `userId` is narrowed from the session, never a body field.',
requestBody: {
required: true,
content: { "application/json": { schema: ocrCommitSchema } },
content: { "application/json": { schema: ocrCommitRequest } },
},
responses: {
...idempotentWrite(),
+1 -1
View File
@@ -7,7 +7,6 @@
*/
import { z } from "zod/v4";
import type { ZodOpenApiObject } from "zod-openapi";
import { coachPrefsSchema } from "@/lib/validations/coach-prefs";
import { notificationPrefsSchema } from "@/lib/validations/notification-prefs";
import { sourcePrioritySchema } from "@/lib/validations/source-priority";
import { modulePrefsPatchSchema } from "@/lib/validations/modules";
@@ -38,6 +37,7 @@ import {
healthProfileFactKindSchema,
} from "@/lib/validations/health-profile-facts";
import {
coachPrefsSchema,
dataEnvelope,
errorEnvelope,
stdResponses,
+23 -14
View File
@@ -7,14 +7,14 @@
*/
import { z } from "zod/v4";
import {
createMeasurementSchema,
listMeasurementsSchema,
measurementTypeEnum,
measurementSourceEnum,
createMeasurementSchema as createMeasurementSchemaBase,
listMeasurementsSchema as listMeasurementsSchemaBase,
measurementTypeEnum as measurementTypeEnumBase,
measurementSourceEnum as measurementSourceEnumBase,
} from "@/lib/validations/measurement";
import { loginPasswordSchema } from "@/lib/validations/auth";
import { coachPrefsSchema } from "@/lib/validations/coach-prefs";
import { createBatchWorkoutSchema } from "@/lib/validations/workout";
import { loginPasswordSchema as loginPasswordSchemaBase } from "@/lib/validations/auth";
import { coachPrefsSchema as coachPrefsSchemaBase } from "@/lib/validations/coach-prefs";
import { createBatchWorkoutSchema as createBatchWorkoutSchemaBase } from "@/lib/validations/workout";
/**
* Common envelopes — every HealthLog API response wraps payload in
@@ -48,44 +48,53 @@ export function dataEnvelope<T extends z.ZodType>(payload: T, id: string) {
}
// ── Schemas — annotated for spec emission ────────────────────────────
//
// `.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. These seven are shared across route modules, so the
// annotated clone is exported from here under the name the use sites already
// spell and the base import is aliased. A module that wants the published
// form imports it from `./shared`; importing the raw schema from
// `@/lib/validations/*` instead is what inlined these anonymously.
measurementTypeEnum.meta({
export const measurementTypeEnum = measurementTypeEnumBase.meta({
id: "MeasurementType",
description:
"DB-stored measurement category. v1.4.23 added 7 Apple Health values (HRV, resting HR, active energy, flights, walking/running distance, VO2 max, body temperature).",
});
measurementSourceEnum.meta({
export const measurementSourceEnum = measurementSourceEnumBase.meta({
id: "MeasurementSource",
description:
"Origin of the measurement. v1.4.23 added APPLE_HEALTH for the iOS HealthKit batch ingest path.",
});
loginPasswordSchema.meta({
export const loginPasswordSchema = loginPasswordSchemaBase.meta({
id: "LoginPasswordRequest",
description:
"Email-or-username login. The native-client flow returns a paired access + refresh token when X-Client-Type: native or the iOS UA prefix is present.",
});
createMeasurementSchema.meta({
export const createMeasurementSchema = createMeasurementSchemaBase.meta({
id: "CreateMeasurementRequest",
description:
"Single-measurement ingest body. Plausibility-range guard runs server-side; out-of-range values fail 422. `glucoseContext` stays REQUIRED on a `BLOOD_GLUCOSE` row here: this is the hand-entry surface, where the person taking the reading knows whether it was fasting or after a meal. The bulk ingest paths (CSV import, JSON import, device sync) accept a contextless reading, because a sensor export classifies nothing per sample.",
});
listMeasurementsSchema.meta({
export const listMeasurementsSchema = listMeasurementsSchemaBase.meta({
id: "ListMeasurementsQuery",
description:
"Query params for the measurements list endpoint. `limit` capped at 500.",
});
coachPrefsSchema.meta({
export const coachPrefsSchema = coachPrefsSchemaBase.meta({
id: "CoachPrefs",
description:
"Per-user Coach prompt-tuning preferences (v1.4.23 H4). All fields default to the legacy v1.4.22 behaviour when omitted.",
});
createBatchWorkoutSchema.meta({
export const createBatchWorkoutSchema = createBatchWorkoutSchemaBase.meta({
id: "CreateBatchWorkoutRequest",
description:
"Typed workout batch ingest. Each entry is an HKWorkout-aligned record with an optional nested GeoJSON LineString route AND an optional route-independent per-workout heart-rate series (`samples`: `[{ t, hr?, speedMs?, power?, cadence? }]`, up to 30 000 points). The `samples` series is the strain-engine input for indoor workouts that have no GPS route. Up to 100 workouts per call; nested route geometry capped at 20 000 points. Withings server-to-server callers pass source: WITHINGS and ship no route (Withings reports aggregates only).",
+16 -13
View File
@@ -41,37 +41,40 @@ import {
stdResponses,
} from "./shared";
vaccinationCreateSchema.meta({
// Zod 4's `.meta()` returns a NEW instance carrying the id rather than
// annotating in place, so each annotated schema is bound to a const and the
// route table below references that const — a bare call would register nothing.
const createVaccinationRequest = vaccinationCreateSchema.meta({
id: "CreateVaccinationRequest",
description:
"Log one administered dose. `occurredAt` is required and must not be in the future — a vaccination is recorded after the fact, and a planned booster is a reminder rather than a record. Exactly one identity arm is required: either `antigenSlug` (a slug the catalogue offers) or `vaccineName` (whatever the person's own record says, verbatim, which may be a trade name). Everything else is optional, so a decades-old paper entry with nothing but a date and a name is loggable. `note` is encrypted at rest. `documentIds` pre-links the scanned pages; an id naming nothing the caller owns is dropped rather than refused, so a link never blocks a save. Logging a dose satisfies any booster reminder keyed to one of its component antigens.",
});
vaccinationUpdateSchema.meta({
const updateVaccinationRequest = vaccinationUpdateSchema.meta({
id: "UpdateVaccinationRequest",
description:
"Partial edit of a dose; an omitted key leaves the column untouched, and a body naming nothing is a 422. `occurredAt` is editable because a transcription typo is the common case — and editing it deliberately does NOT re-run the booster satisfaction, so correcting a date can never move a reminder's due date. An edit that would leave the record with neither identity arm is refused. A present `documentIds` array replaces the links, empty array included.",
});
vaccinationListQuerySchema.meta({
const listVaccinationsQuery = vaccinationListQuerySchema.meta({
id: "ListVaccinationsQuery",
description:
"Filters for the immunization list: `antigenSlug`, `from` / `to` (ISO-8601 instants) and `limit` (1–500, default 300). The list is full and bounded rather than paged — a vaccination record is a lifetime document but a short one.",
});
vaccinationBoosterSchema.meta({
const vaccinationBoosterRequest = vaccinationBoosterSchema.meta({
id: "VaccinationBoosterRequest",
description:
"The confirm body for a booster reminder — the person's own values, accepted or edited from the catalogue's prefill. `intervalMonths` is bounded at 600 (a decade booster is 120); `label` is composed client-side so its locale is the person's own. There is no antigen field: the server reads the antigen from the dose's catalogue entry, never from the request, so a client cannot key a reminder onto an antigen the dose does not contain.",
});
vaccinationSuggestQuerySchema.meta({
const vaccinationSuggestQuery = vaccinationSuggestQuerySchema.meta({
id: "VaccinationSuggestQuery",
description:
"The single input to the upload suggestion: the document's anchor date (ISO-8601 with offset). No ids — the candidate doses come from the caller's own record, narrowed from the session.",
});
vaccinationLinkSchema.meta({
const vaccinationLinkRequest = vaccinationLinkSchema.meta({
id: "VaccinationLinkRequest",
description:
"Documents to file against, or unfile from, a dose. Idempotent in both directions and capped at 100 ids. Ids naming nothing the caller owns come back in `unknown` rather than failing the request.",
@@ -224,7 +227,7 @@ export const vaccinationPaths: NonNullable<ZodOpenApiObject["paths"]> = {
summary: "List vaccinations",
description:
"Returns the caller's live doses, newest first, each with its series resolved per component antigen. Soft-deleted doses are excluded.",
requestParams: { query: vaccinationListQuerySchema },
requestParams: { query: listVaccinationsQuery },
responses: {
...recordRefusal(),
"200": {
@@ -246,7 +249,7 @@ export const vaccinationPaths: NonNullable<ZodOpenApiObject["paths"]> = {
"Records one administered dose and satisfies every live booster reminder keyed to one of its component antigens — so a single combined dose settles each of the antigens it contains. Satisfaction is forward-only: a dose older than a reminder's last satisfaction is a no-op, which is what makes back-entering an old paper record safe. Honours `Idempotency-Key`. Audits as `vaccination.record.create`.",
requestBody: {
required: true,
content: { "application/json": { schema: vaccinationCreateSchema } },
content: { "application/json": { schema: createVaccinationRequest } },
},
responses: {
...idempotentWrite(),
@@ -292,7 +295,7 @@ export const vaccinationPaths: NonNullable<ZodOpenApiObject["paths"]> = {
requestParams: idPath,
requestBody: {
required: true,
content: { "application/json": { schema: vaccinationUpdateSchema } },
content: { "application/json": { schema: updateVaccinationRequest } },
},
responses: {
...recordRefusal(),
@@ -363,7 +366,7 @@ export const vaccinationPaths: NonNullable<ZodOpenApiObject["paths"]> = {
requestParams: idPath,
requestBody: {
required: true,
content: { "application/json": { schema: vaccinationBoosterSchema } },
content: { "application/json": { schema: vaccinationBoosterRequest } },
},
responses: {
...recordRefusal(),
@@ -406,7 +409,7 @@ export const vaccinationPaths: NonNullable<ZodOpenApiObject["paths"]> = {
summary: "Suggest the dose a document belongs to",
description:
"Which dose does a document dated `anchor` most plausibly belong to? The document upload review asks this when a scan is classified `VACCINATION`. The window (±7 days, shared with the visit moment) and the one-pre-selects / many-offer-a-picker verdict are decided server-side so the browser never re-derives them. Owner-scoped; the anchor is the only input and it is a date, not an id.",
requestParams: { query: vaccinationSuggestQuerySchema },
requestParams: { query: vaccinationSuggestQuery },
responses: {
...recordRefusal(),
"200": {
@@ -433,7 +436,7 @@ export const vaccinationPaths: NonNullable<ZodOpenApiObject["paths"]> = {
requestParams: idPath,
requestBody: {
required: true,
content: { "application/json": { schema: vaccinationLinkSchema } },
content: { "application/json": { schema: vaccinationLinkRequest } },
},
responses: {
"200": {
@@ -462,7 +465,7 @@ export const vaccinationPaths: NonNullable<ZodOpenApiObject["paths"]> = {
requestParams: idPath,
requestBody: {
required: true,
content: { "application/json": { schema: vaccinationLinkSchema } },
content: { "application/json": { schema: vaccinationLinkRequest } },
},
responses: {
"200": {
+2 -2
View File
@@ -7,14 +7,14 @@
*/
import { z } from "zod/v4";
import type { ZodOpenApiObject } from "zod-openapi";
import { measurementSourceEnum } from "@/lib/validations/measurement";
import { createBatchWorkoutSchema } from "@/lib/validations/workout";
import {
MODULE_DISABLED_DESCRIPTION,
createBatchWorkoutSchema,
dataEnvelope,
errorEnvelope,
idempotencyKeyParameter,
idempotentWrite,
measurementSourceEnum,
moduleDisabledResponse,
recordRefusal,
stdResponses,