diff --git a/eslint-plugins/healthlog/queryKey-factory.js b/eslint-plugins/healthlog/queryKey-factory.js index f48e929ac..2dd2dca3a 100644 --- a/eslint-plugins/healthlog/queryKey-factory.js +++ b/eslint-plugins/healthlog/queryKey-factory.js @@ -22,7 +22,7 @@ * test-guard substitute holds the "guarded surface" line until a * future wave extends both the guard and this whitelist together. * - * @see src/lib/query-keys.ts — factory home (do not lint as offender) + * @see src/lib/query-keys/ — factory home (do not lint as offender) * @see src/lib/__tests__/query-keys.test.ts — test-guard substitute * @see .planning/phase-W-PROCESS-DOCS-v1441-report.md */ @@ -35,14 +35,16 @@ // exempt for now. Path matches use `String#includes` semantics against // the absolute filename — keep entries as posix-style suffix paths. const FACTORY_HOME_FILES = [ - // The factory definition file is allowed to declare literal keys — - // that's the whole point of the factory. - "src/lib/query-keys.ts", // The factory's own unit-test file constructs literal keys to assert // the factory output shape. "src/lib/__tests__/query-keys.test.ts", ]; +// The factory definition files are allowed to declare literal keys — +// that's the whole point of the factory. The factory is split into +// per-feature files under this directory (barrel at index.ts). +const FACTORY_HOME_DIRECTORY = "src/lib/query-keys"; + // Directories under which any `queryKey: [ … ]` literal is an error. // Extend in lockstep with the test-guard substitute's `guardedRoots`. const GUARDED_DIRECTORIES = [ @@ -81,6 +83,7 @@ function isGuarded(filename) { for (const home of FACTORY_HOME_FILES) { if (filename.endsWith(home)) return false; } + if (filename.includes(`/${FACTORY_HOME_DIRECTORY}/`)) return false; for (const dir of GUARDED_DIRECTORIES) { if (filename.includes(`/${dir}/`)) return true; } @@ -96,7 +99,7 @@ const queryKeyFactoryRule = { type: "problem", docs: { description: - "Disallow literal-array `queryKey` / `mutationKey` declarations in guarded files. Use `queryKeys.()` from `src/lib/query-keys.ts` instead.", + "Disallow literal-array `queryKey` / `mutationKey` declarations in guarded files. Use `queryKeys.()` from `src/lib/query-keys/` instead.", recommended: false, }, messages: { diff --git a/scripts/generate-openapi.ts b/scripts/generate-openapi.ts index 6f87bd9ba..cb266b5e0 100644 --- a/scripts/generate-openapi.ts +++ b/scripts/generate-openapi.ts @@ -4,15 +4,15 @@ * table in `src/lib/openapi/registry.ts`. * * v1.4.23 baseline: only the iOS-touched routes are registered (see - * `src/lib/openapi/routes.ts`). The CI step that wraps this script runs + * `src/lib/openapi/routes/`). The CI step that wraps this script runs * with `continue-on-error: true` until the registry catches up with the * rest of the hand-maintained `docs/api/openapi.yaml` — once coverage is * complete, the gate flips to hard-fail in v1.4.24+. * * The output is stable across re-runs because the upstream order is * itself deterministic: Zod preserves schema property declaration - * order and the path table is a single object literal in - * `src/lib/openapi/routes.ts`. `yaml@2`'s `sortMapEntries` option is + * order and the path table is assembled with a fixed spread order in + * `src/lib/openapi/routes/index.ts`. `yaml@2`'s `sortMapEntries` option is * intentionally NOT set — when enabled, the emitter reorders keys * alphabetically and can place an `*alias` reference before its * `&anchor` definition, which triggers `verifyAliasOrder` and throws diff --git a/src/README.md b/src/README.md index 1f460f2e5..cdbfccf46 100644 --- a/src/README.md +++ b/src/README.md @@ -23,4 +23,4 @@ client → src/proxy.ts → src/app//route.ts → src/lib/api-handler.ts ## Conventions -See [`../CLAUDE.md`](../CLAUDE.md) for the full set. The load-bearing ones: RSC by default (`"use client"` only when a hook/state/browser API needs it); every API route wraps `apiHandler` and parses its body with Zod `safeParse`; `userId` is always narrowed from session or Bearer, never accepted as a body field; TanStack Query keys come only from `src/lib/query-keys.ts`; outbound HTTP goes through `src/lib/safe-fetch.ts`. +See [`../CLAUDE.md`](../CLAUDE.md) for the full set. The load-bearing ones: RSC by default (`"use client"` only when a hook/state/browser API needs it); every API route wraps `apiHandler` and parses its body with Zod `safeParse`; `userId` is always narrowed from session or Bearer, never accepted as a body field; TanStack Query keys come only from `src/lib/query-keys/`; outbound HTTP goes through `src/lib/safe-fetch.ts`. diff --git a/src/lib/README.md b/src/lib/README.md index f46fde331..d475e2001 100644 --- a/src/lib/README.md +++ b/src/lib/README.md @@ -8,7 +8,7 @@ The server and runtime toolkit. Everything that is not a page, component, or rou - **`api-response.ts`** — the `{ data, error, meta? }` envelope: `apiSuccess` / `apiError`, `safeJson`, trusted-proxy IP resolver, `returnAllZodIssues` + `sanitiseZodIssues`. - **`rate-limit.ts`** — Postgres-backed sliding-window limiter (single atomic upsert); multi-instance correct. - **`idempotency.ts`** — user-scoped idempotency keys that refuse to cache secret-shaped responses. -- **`query-keys.ts`** — the only legal source of TanStack Query `queryKey` / `mutationKey` arrays (enforced by an ESLint rule). +- **`query-keys/`** — the only legal source of TanStack Query `queryKey` / `mutationKey` arrays (enforced by an ESLint rule); per-feature files behind the `index.ts` barrel. - **`safe-fetch.ts`** — the one documented outbound-egress entry (`redirect: "manual"` + timeout; optional connect-time DNS-rebinding pin). Raw `fetch(` under `lib/` + `app/` is banned by ESLint. - **`db.ts`** — the Prisma client singleton. - **`crypto.ts`** — AES-256-GCM at rest with versioned key ids, fail-closed loader, rotation primitives. diff --git a/src/lib/openapi/routes.ts b/src/lib/openapi/routes.ts deleted file mode 100644 index c768d87d2..000000000 --- a/src/lib/openapi/routes.ts +++ /dev/null @@ -1,5029 +0,0 @@ -/** - * OpenAPI route table — populated incrementally. - * - * v1.4.23 baseline covers the routes the v1.5 iOS app touches end-to-end: - * - POST /api/auth/login - * - POST /api/auth/passkey/login-verify - * - POST /api/auth/refresh - * - GET /api/measurements - * - POST /api/measurements - * - POST /api/measurements/batch (W2 — Apple Health ingest) - * - POST /api/devices (W3 — APNs registration) - * - GET /api/insights/comprehensive - * - * Schemas come from `src/lib/validations/*` so the wire contract stays - * single-source-of-truth. The `.meta()` annotations on each schema land - * the title + description in `components.schemas.*` automatically. - * - * Routes are intentionally registered as inline operation objects - * rather than via a wrapping helper — `zod-openapi`'s `createDocument` - * type-checks the route table directly, which catches schema-shape - * regressions at typecheck time. - */ -import { z } from "zod/v4"; -import type { ZodOpenApiObject } from "zod-openapi"; - -import { - createMeasurementSchema, - listMeasurementsSchema, - measurementTypeEnum, - measurementSourceEnum, -} from "@/lib/validations/measurement"; -import { loginPasswordSchema } from "@/lib/validations/auth"; -import { aboutMePutSchema } from "@/lib/validations/about-me"; -import { inviteCreateSchema } from "@/lib/validations/invite"; -import { coachPrefsSchema } from "@/lib/validations/coach-prefs"; -import { - deviceTypeEnum, - sourcePrioritySchema, -} from "@/lib/validations/source-priority"; -import { createBatchWorkoutSchema } from "@/lib/validations/workout"; -import { - createMedicationSchema, - updateMedicationSchema, - intakeSchema, - MEDICATION_CATEGORY_VALUES, - MEDICATION_TREATMENT_CLASS_VALUES, -} from "@/lib/validations/medication"; -import { medicationExtractionSchema } from "@/lib/ai/coach/medication-extract-prompt"; -import { - ACCEPTED_INSIGHTS_TILE_IDS, - INSIGHTS_SECTION_IDS, -} from "@/lib/insights-layout"; -import { COACH_FACT_CATEGORIES } from "@/lib/ai/coach/facts"; -import { exportSelectionSchema } from "@/lib/validations/health-record-export"; -import { createShareLinkSchema } from "@/lib/validations/clinician-share-link"; -import { METRIC_STATUS_IDS } from "@/lib/insights/metric-status-registry"; -import { - DERIVED_METRIC_IDS, - VITALS_BASELINE_TYPES, -} from "@/lib/insights/derived/registry"; -import { ANALYTICS_RANGES } from "@/lib/analytics/range-delta"; -import { - flowLevelEnum, - ovulationTestEnum, - cervicalMucusEnum, - homeTestResultEnum, - cycleTrackingGoalEnum, - cycleDayLogInputSchema, - cycleDayLogPatchSchema, - cycleDayLogQuerySchema, - cycleBulkSchema, - cyclePeriodSchema, - cyclePrefsSchema, -} from "@/lib/validations/cycle"; - -/** - * Common envelopes — every HealthLog API response wraps payload in - * `{ data, error, meta? }`. The OpenAPI surface mirrors that contract - * so iOS / external-ingest clients can decode uniformly. - */ -const errorEnvelope = z - .object({ - data: z.null(), - error: z.string(), - meta: z - .object({ - requestId: z.string().optional(), - errorCode: z.string().optional(), - }) - .optional(), - }) - .meta({ - id: "ErrorEnvelope", - description: "Standard error response: data is null, error is human prose.", - }); - -function dataEnvelope(payload: T, id: string) { - return z - .object({ - data: payload, - error: z.null(), - meta: z.object({ requestId: z.string().optional() }).optional(), - }) - .meta({ id }); -} - -// ── Schemas — annotated for spec emission ──────────────────────────── - -measurementTypeEnum.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({ - id: "MeasurementSource", - description: - "Origin of the measurement. v1.4.23 added APPLE_HEALTH for the iOS HealthKit batch ingest path.", -}); - -loginPasswordSchema.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({ - id: "CreateMeasurementRequest", - description: - "Single-measurement ingest body. Plausibility-range guard runs server-side; out-of-range values fail 422.", -}); - -listMeasurementsSchema.meta({ - id: "ListMeasurementsQuery", - description: - "Query params for the measurements list endpoint. `limit` capped at 500.", -}); - -coachPrefsSchema.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({ - 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).", -}); - -const coachMessageFeedbackBody = z - .object({ - rating: z.enum(["helpful", "unhelpful"]), - reason: z.string().min(1).max(200).optional(), - }) - .meta({ - id: "CoachMessageFeedbackRequest", - description: - "Per-message helpful/unhelpful feedback (v1.4.23 H7). Optional `reason` is free-form prose, capped at 200 chars.", - }); - -// v1.4.49 — single schema for the per-user Coach opt-out flag. Previously -// split into `disableCoachBody` (PATCH request) and `disableCoachData` -// (response payload), both `z.object({ disableCoach: z.boolean() })`. The -// payload was passed through `dataEnvelope(..., "GetDisableCoachResponse")` -// / `dataEnvelope(..., "PatchDisableCoachResponse")` which IDs the -// envelope wrapper — the inner flag carries its own `.meta()` and now -// renders as a single `$ref: "#/components/schemas/DisableCoachFlag"` in -// both the request body and both response envelopes. -const disableCoachFlag = z - .object({ - disableCoach: z.boolean(), - }) - .meta({ - id: "DisableCoachFlag", - description: - "Per-account Coach opt-out toggle (v1.4.47 W3). `true` hides the Coach FAB and short-circuits its API gates.", - }); - -// v1.8.6 — profile read/write surface for the native client. The -// runtime validation lives in `src/lib/validations/auth.ts` -// (`profileSchema`, whose transforms normalise empty → null and run the -// KVNR/IKNR refines); the OpenAPI shapes below mirror the wire contract -// without those transforms so the spec stays a clean nullable-field -// document. `insurerIkNumber` is the new v1.8.6 field: optional, 9-digit -// German IKNR, surfaced on the FHIR `Coverage` payor. -const profileUpdateRequest = z - .object({ - email: z.email().nullable().optional(), - heightCm: z.number().min(50).max(300).nullable().optional(), - dateOfBirth: z.string().nullable().optional(), - gender: z.enum(["MALE", "FEMALE"]).nullable().optional(), - displayName: z.string().min(1).max(80).nullable().optional(), - locale: z.enum(["de", "en"]).nullable().optional(), - timezone: z.string().min(1).max(64).optional(), - timeFormat: z - .enum(["AUTO", "H12", "H24"]) - .optional() - .describe( - "Hour-cycle display preference. AUTO follows the locale convention, H12 forces AM/PM, H24 forces 24-hour.", - ), - moodReminderEnabled: z.boolean().optional(), - fullName: z.string().max(120).nullable().optional(), - insurerName: z.string().max(120).nullable().optional(), - insuranceNumber: z - .string() - .nullable() - .optional() - .describe("German KVNR. Empty/null clears it; mod-10 check enforced."), - insurerIkNumber: z - .string() - .nullable() - .optional() - .describe( - "German insurer institution number (IKNR). Optional; empty/null clears it. A non-empty value must be exactly 9 digits (no checksum enforced).", - ), - }) - .meta({ - id: "ProfileUpdateRequest", - description: - "Partial profile update. Every field is optional; an omitted field is left untouched, an explicit null (or empty string) clears it. `userId` is never accepted — it is narrowed from the session/token.", - }); - -const profileResponse = z - .object({ - username: z.string(), - displayName: z.string().nullable().optional(), - email: z.string().nullable(), - dateOfBirth: z.iso.datetime({ offset: true }).nullable(), - gender: z.enum(["MALE", "FEMALE"]).nullable(), - heightCm: z.number().nullable(), - locale: z.string().nullable(), - timezone: z.string(), - timeFormat: z - .enum(["AUTO", "H12", "H24"]) - .describe( - "Hour-cycle display preference. AUTO follows the locale convention, H12 forces AM/PM, H24 forces 24-hour.", - ), - moodReminderEnabled: z.boolean(), - fullName: z.string().nullable(), - insurerName: z.string().nullable(), - insurerIkNumber: z - .string() - .nullable() - .describe("German insurer institution number (IKNR), 9 digits."), - insuranceNumber: z - .string() - .nullable() - .describe("German KVNR, decrypted for the form prefill."), - }) - .meta({ - id: "ProfileResponse", - description: - "Flattened profile fields for the native client (GET). The KVNR is decrypted server-side; the IKNR (v1.8.6) is plaintext at rest.", - }); - -// The PATCH echo mirrors GET but reports KVNR presence as a boolean -// (`hasInsuranceNumber`) rather than re-emitting the decrypted value. -const profileUpdateResponse = z - .object({ - username: z.string(), - displayName: z.string().nullable().optional(), - email: z.string().nullable(), - dateOfBirth: z.iso.datetime({ offset: true }).nullable(), - gender: z.enum(["MALE", "FEMALE"]).nullable(), - heightCm: z.number().nullable(), - locale: z.string().nullable(), - timezone: z.string(), - timeFormat: z - .enum(["AUTO", "H12", "H24"]) - .describe( - "Hour-cycle display preference. AUTO follows the locale convention, H12 forces AM/PM, H24 forces 24-hour.", - ), - moodReminderEnabled: z.boolean(), - fullName: z.string().nullable(), - insurerName: z.string().nullable(), - insurerIkNumber: z - .string() - .nullable() - .describe("German insurer institution number (IKNR), 9 digits."), - hasInsuranceNumber: z - .boolean() - .describe("Whether a KVNR is on file (the value itself is not echoed)."), - }) - .meta({ - id: "ProfileUpdateResponse", - description: - "Profile echo returned by the PATCH/PUT update path. Reports KVNR presence as a boolean rather than re-emitting the decrypted value.", - }); - -// ── Sub-schemas owned here (route-specific shapes) ─────────────────── - -const passkeyLoginVerifyRequest = z - .object({ - challengeId: z - .string() - .min(1) - .describe("Server-issued WebAuthn challenge id."), - credential: z - .object({ - id: z.string(), - rawId: z.string(), - type: z.literal("public-key"), - response: z.record(z.string(), z.unknown()), - authenticatorAttachment: z - .enum(["platform", "cross-platform"]) - .optional(), - clientExtensionResults: z.record(z.string(), z.unknown()).optional(), - }) - .describe("SimpleWebAuthn-style assertion response payload."), - }) - .meta({ - id: "PasskeyLoginVerifyRequest", - description: - "Passkey assertion verification. Same native-client token issuance as the password path.", - }); - -const refreshRequest = z - .object({ - refreshToken: z - .string() - .min(1) - .describe("Caller-presented refresh token (`hlr_<64hex>`)."), - revoke: z - .boolean() - .optional() - .describe("When true, revoke the supplied token instead of rotating."), - }) - .meta({ - id: "RefreshTokenRequest", - description: - "Exchange a one-time-use refresh token for a fresh access + refresh pair.", - }); - -const accessRefreshBundle = z - .object({ - user: z.object({ id: z.string(), username: z.string() }).optional(), - token: z.string().describe("Access token (`hlk_<64hex>`)."), - tokenExpiresAt: z.iso.datetime({ offset: true }), - refreshToken: z - .string() - .optional() - .describe( - "Refresh token (`hlr_<64hex>`); only present for native-policy callers.", - ), - refreshTokenExpiresAt: z.iso.datetime({ offset: true }).optional(), - }) - .meta({ - id: "AccessRefreshBundle", - description: - "Native-client token bundle returned by login + refresh. Web cookie-only callers see only `user`.", - }); - -const deviceRegisterRequest = z - .object({ - token: z - .string() - .min(8) - .max(512) - .regex(/^[A-Za-z0-9+/=._:-]+$/) - .describe("Generic device identifier (legacy; APNs token below)."), - bundleId: z.string().min(1).max(128), - locale: z.string().min(2).max(16).optional(), - appVersion: z.string().min(1).max(32).optional(), - model: z.string().min(1).max(64).optional(), - apnsToken: z - .string() - .min(8) - .max(256) - .regex(/^[A-Fa-f0-9]+$/) - .optional() - .describe( - "Hex-encoded APNs device token. Must be paired with `apnsEnvironment`.", - ), - apnsEnvironment: z - .enum(["sandbox", "production"]) - .optional() - .describe( - "Gateway the iOS client received `apnsToken` from. Server never auto-detects.", - ), - medicationDelivery: z - .enum(["server", "client"]) - .nullable() - .optional() - .describe( - 'v1.7.0 per-device medication-delivery override. NULL / omitted = inherit the user-level roaming default. "server" forces server APNs for this device; "client" forces local. Stored + echoed; cron suppression stays user-level.', - ), - }) - .meta({ - id: "DeviceRegisterRequest", - description: - "Native device registration. Re-registering an APNs token belonging to another user returns 409 (cross-user-hijack guard).", - }); - -const batchEntrySchema = z - .object({ - hkIdentifier: z - .string() - .min(1) - .max(120) - .describe( - "HealthKit identifier (e.g. `HKQuantityTypeIdentifierBodyMass`).", - ), - value: z.number().finite(), - unit: z.string().min(1).max(60), - startDate: z.iso.datetime({ offset: true }), - endDate: z.iso.datetime({ offset: true }), - sleepStage: z - .number() - .int() - .min(0) - .max(20) - .optional() - .describe( - "HKCategoryValueSleepAnalysis codepoint; only for sleep samples.", - ), - categoryValue: z - .number() - .int() - .min(0) - .max(20) - .optional() - .describe( - "v1.10.0 — `HKCategoryValue` codepoint for an EVENT-class category sample (irregular-rhythm, high/low-HR, walking-steadiness, breathing-disturbance). Carries the device's own classification verdict / severity, which the server resolves to a stored `rhythmClassification`. HealthLog stores ONLY the device's result — it never re-classifies. Ignored for non-event identifiers.", - ), - externalId: z - .string() - .min(1) - .max(120) - .describe("HKSample.uuid string — the dedup key."), - externalSourceVersion: z.string().min(1).max(120).optional(), - // v1.8.6 W6 — optional per-entry source tag. Defaults to - // `APPLE_HEALTH` server-side when omitted, so legacy clients are - // unchanged. Restricted to the `{APPLE_HEALTH, MANUAL}` subset of - // `MeasurementSource`: `WITHINGS` / `IMPORT` are server-owned and - // rejected on this client-facing route. - source: z - .enum(["APPLE_HEALTH", "MANUAL"]) - .optional() - .describe( - "Source attribution for the row. Defaults to `APPLE_HEALTH` when omitted. Send `MANUAL` to tag rows the user entered by hand on-device (e.g. the standalone adopt-on-pair backfill). Part of the `(userId, type, source, externalId)` dedup key, so the same externalId under a different source is a distinct row. `WITHINGS` / `IMPORT` are not accepted here.", - ), - // v1.4.25 W8c — optional device-type tag fed into the canonical - // source picker's second axis. NULL is treated as `unknown`; - // legacy iOS builds that don't ship the field continue to work. - deviceType: deviceTypeEnum - .nullable() - .optional() - .describe( - "Device class mapped from `HKDevice.model`. Used by the analytics aggregator to break ties when the same source contributed multiple devices for the same day. Omit (or send null) on legacy clients — the server treats it as `unknown` and the picker falls through.", - ), - }) - .meta({ id: "AppleHealthBatchEntry" }); - -const batchPayloadSchema = z - .object({ - entries: z.array(batchEntrySchema).min(1).max(500), - }) - .meta({ - id: "AppleHealthBatchRequest", - description: - "Apple Health batch ingest. ≤500 entries per call; idempotent via `Idempotency-Key`.", - }); - -const batchEntryResult = z - .object({ - index: z.number().int().nonnegative(), - status: z.enum(["inserted", "duplicate", "skipped"]), - reason: z.string().optional(), - }) - .meta({ id: "AppleHealthBatchEntryResult" }); - -const batchResponse = z - .object({ - inserted: z.number().int().nonnegative(), - duplicate: z.number().int().nonnegative(), - skipped: z.number().int().nonnegative(), - entries: z.array(batchEntryResult), - }) - .meta({ id: "AppleHealthBatchResponse" }); - -// v1.4.25 W16b — typed workout batch ingest response envelope. Mirrors -// the measurements batch shape but reports the `workouts` count rather -// than the entries count, and the `skipped` field is reserved (the -// Zod schema rejects malformed entries with a 400 before the per-entry -// pass, so today's responses always carry an empty array). -const workoutBatchEntryResult = z - .object({ - index: z.number().int().nonnegative(), - status: z.enum(["inserted", "duplicate", "skipped"]), - reason: z.string().optional(), - }) - .meta({ id: "WorkoutBatchEntryResult" }); - -const workoutBatchResponse = z - .object({ - processed: z.number().int().nonnegative(), - inserted: z.number().int().nonnegative(), - duplicates: z.number().int().nonnegative(), - skipped: z.array( - z.object({ - index: z.number().int().nonnegative(), - reason: z.string(), - }), - ), - entries: z.array(workoutBatchEntryResult), - }) - .meta({ id: "WorkoutBatchResponse" }); - -// v1.4.32 — workout list / detail wire shape. The field names follow -// the iOS handoff contract (`distanceM` + `activeEnergyKcal`) rather -// than the Prisma column names so iOS and web consumers share one -// JSON envelope. -const workoutListEntry = z - .object({ - id: z.string(), - sportType: z.string(), - startedAt: z.iso.datetime({ offset: true }), - endedAt: z.iso.datetime({ offset: true }), - durationSec: z.number().int().nonnegative(), - distanceM: z.number().nullable(), - activeEnergyKcal: z.number().nullable(), - avgHr: z.number().int().nullable(), - maxHr: z.number().int().nullable(), - source: measurementSourceEnum, - externalId: z.string().nullable(), - }) - .meta({ id: "WorkoutListEntry" }); - -const workoutListResponse = z - .object({ - workouts: z.array(workoutListEntry), - meta: z.object({ - total: z.number().int().nonnegative(), - limit: z.number().int().positive(), - offset: z.number().int().nonnegative(), - droppedDuplicates: z.number().int().nonnegative(), - }), - }) - .meta({ id: "WorkoutListResponse" }); - -const workoutRouteGeometry = z - .object({ - geometry: z.unknown(), - sampleTimestamps: z.array(z.string()).nullable(), - }) - .meta({ id: "WorkoutRouteGeometry" }); - -// v1.10.0 — route-independent per-workout heart-rate series. Present -// for indoor and outdoor workouts that shipped a `samples` array on -// ingest. `samples` mirrors the ingest shape `[{ t, hr?, speedMs?, -// power?, cadence? }]`; `sampleCount` is the denormalised length. -const workoutHrSeries = z - .object({ - sampleCount: z.number().int().nonnegative(), - samples: z.unknown(), - }) - .meta({ id: "WorkoutHrSeries" }); - -const workoutDetailResponse = z - .object({ - id: z.string(), - sportType: z.string(), - startedAt: z.iso.datetime({ offset: true }), - endedAt: z.iso.datetime({ offset: true }), - durationSec: z.number().int().nonnegative(), - distanceM: z.number().nullable(), - activeEnergyKcal: z.number().nullable(), - avgHr: z.number().int().nullable(), - maxHr: z.number().int().nullable(), - minHr: z.number().int().nullable(), - stepCount: z.number().int().nullable(), - elevationM: z.number().nullable(), - pauseDurationSec: z.number().int().nullable(), - source: measurementSourceEnum, - externalId: z.string().nullable(), - metadata: z.unknown().nullable(), - route: workoutRouteGeometry.nullable(), - samples: workoutHrSeries.nullable(), - canonicalId: z.string(), - }) - .meta({ id: "WorkoutDetailResponse" }); - -const deleteByExternalIdsRequest = z - .object({ - externalIds: z.array(z.string().min(1).max(120)).min(0).max(500), - }) - .meta({ - id: "MeasurementsDeleteByExternalIdsRequest", - description: - "iOS deletion-sync. Up to 500 externalIds per call; matching rows owned by other users are silently skipped (cross-user 404 guard).", - }); - -const deleteByExternalIdsResponse = z - .object({ - deletedCount: z.number().int().nonnegative(), - }) - .meta({ id: "MeasurementsDeleteByExternalIdsResult" }); - -// v1.15.13 — multi-select bulk soft-delete for the measurements -// management list. Ids are scoped to the caller; forged / foreign ids -// are silent no-ops (no existence leak). Soft-delete (tombstone), so the -// rows surface in the `/api/sync/changes` delta feed. -const bulkDeleteMeasurementsRequest = z - .object({ - ids: z.array(z.string().min(1)).min(1).max(200), - }) - .meta({ - id: "MeasurementsBulkDeleteRequest", - description: - "1..200 measurement ids, scoped to the caller. Forged / foreign ids are silently skipped (no existence leak).", - }); - -const bulkDeleteMeasurementsResponse = z - .object({ - deleted: z.number().int().nonnegative(), - }) - .meta({ id: "MeasurementsBulkDeleteResult" }); - -const measurementResource = z - .object({ - id: z.string(), - type: measurementTypeEnum, - value: z.number(), - unit: z.string(), - measuredAt: z.iso.datetime({ offset: true }), - source: measurementSourceEnum, - notes: z.string().nullable().optional(), - }) - .meta({ - id: "MeasurementResource", - description: "Server-shaped measurement row returned by GET endpoints.", - }); - -// ── Sleep night (v1.11.5 hypnogram source) ────────────────────────── - -const sleepStageEnum = z.enum([ - "IN_BED", - "AWAKE", - "ASLEEP", - "REM", - "CORE", - "DEEP", -]); - -const sleepSegmentResource = z.object({ - stage: sleepStageEnum.nullable(), - start: z.iso.datetime({ offset: true }), - end: z.iso.datetime({ offset: true }), - minutes: z.number().int().nonnegative(), -}); - -const sleepSessionResource = z.object({ - night: z.string().describe("Wake-day key (YYYY-MM-DD)."), - source: measurementSourceEnum.nullable(), - start: z.iso.datetime({ offset: true }), - end: z.iso.datetime({ offset: true }), - asleepMinutes: z.number().int().nonnegative(), - inBedMinutes: z.number().int().nonnegative().nullable(), - awakeMinutes: z.number().int().nonnegative().nullable(), - awakenings: z.number().int().nonnegative(), - stages: z.record(sleepStageEnum, z.number()), - segments: z.array(sleepSegmentResource), -}); - -const sleepNightResponse = z - .object({ - night: z.string().nullable(), - main: sleepSessionResource.nullable(), - naps: z.array(sleepSessionResource), - }) - .meta({ - id: "SleepNightResource", - description: - "One reconstructed sleep night: the main session's hypnogram segments + breakdown, plus same-wake-day naps surfaced separately.", - }); - -// ── Sync (v1.7.0 offline / server-optional) ───────────────────────── - -const syncStateResponse = z - .object({ - userId: z.string(), - timezone: z.string(), - lastSyncedAt: z.iso.datetime({ offset: true }).nullable(), - serverNow: z.iso.datetime({ offset: true }), - measurements: z.object({ - lastUpdatedAt: z.iso.datetime({ offset: true }).nullable(), - liveCount: z.number().int().nonnegative(), - tombstonedCount: z.number().int().nonnegative(), - }), - mood: z.object({ - lastUpdatedAt: z.iso.datetime({ offset: true }).nullable(), - liveCount: z.number().int().nonnegative(), - tombstonedCount: z.number().int().nonnegative(), - }), - intakes: z.object({ - lastUpdatedAt: z.iso.datetime({ offset: true }).nullable(), - liveCount: z.number().int().nonnegative(), - tombstonedCount: z.number().int().nonnegative(), - }), - sync: z - .object({ - incrementalWindowDays: z - .number() - .int() - .positive() - .describe( - "Days an incremental delta stays valid; tracks the native refresh-token lifetime. Beyond it a device re-pairs with a full backfill. iOS derives its window from this rather than hardcoding 60.", - ), - tombstoneRetentionDays: z - .number() - .int() - .positive() - .describe( - "Horizon past which tombstones may be pruned. A cursor older than this gets `cursorExpired` on `/api/sync/changes`.", - ), - }) - .describe("Sync-window metadata the client reads instead of hardcoding."), - }) - .meta({ - id: "SyncStateResponse", - description: - "iOS SyncMode handshake. Each GET also advances the server-side `lastSyncedAt` checkpoint and returns the previous value. The cheap 'should I sync?' summary; the durable delta cursor lives on `/api/sync/changes`.", - }); - -const syncMeasurementUpsert = measurementResource - .extend({ - externalId: z - .string() - .nullable() - .describe("Cross-device dedup key (UUID string or `stats::`)."), - syncVersion: z - .number() - .int() - .positive() - .describe( - "LWW reconciliation counter; echo to keep the mirror monotonic.", - ), - updatedAt: z.iso.datetime({ offset: true }), - }) - .meta({ id: "SyncMeasurementUpsert" }); - -const syncMeasurementTombstone = z - .object({ - id: z.string(), - externalId: z - .string() - .nullable() - .describe("The identity key the client dedups on for measurements."), - syncVersion: z.number().int().positive(), - deletedAt: z.iso.datetime({ offset: true }), - updatedAt: z.iso.datetime({ offset: true }), - }) - .meta({ - id: "SyncMeasurementTombstone", - description: - "A soft-deleted measurement. Apply tombstones BEFORE upserts within a page to avoid resurrecting a row.", - }); - -const syncMoodUpsert = z - .object({ - id: z.string(), - date: z.string().describe("YYYY-MM-DD anchored to the row's `tz`."), - mood: z.string(), - score: z.number().int(), - tags: z.string().nullable().describe("JSON array of tag keys, or null."), - note: z.string().nullable(), - moodLoggedAt: z.iso.datetime({ offset: true }), - source: z.string(), - syncVersion: z - .number() - .int() - .nonnegative() - .describe("LWW reconciliation counter; mood is last-writer-wins by it."), - updatedAt: z.iso.datetime({ offset: true }), - }) - .meta({ id: "SyncMoodUpsert" }); - -const syncMoodTombstone = z - .object({ - id: z - .string() - .describe("Server id — the identity key the client dedups on for mood."), - syncVersion: z.number().int().nonnegative(), - deletedAt: z.iso.datetime({ offset: true }), - updatedAt: z.iso.datetime({ offset: true }), - }) - .meta({ - id: "SyncMoodTombstone", - description: - "A soft-deleted mood entry, keyed on server `id`. Apply before upserts within the domain page.", - }); - -const syncIntakeUpsert = z - .object({ - id: z.string(), - medicationId: z.string(), - scheduledFor: z.iso.datetime({ offset: true }), - takenAt: z.iso.datetime({ offset: true }).nullable(), - skipped: z.boolean(), - source: z.string(), - syncVersion: z - .number() - .int() - .nonnegative() - .describe( - "Reconciliation counter. An intake is immutable; a correction is a tombstone + re-insert.", - ), - updatedAt: z.iso.datetime({ offset: true }), - }) - .meta({ id: "SyncIntakeUpsert" }); - -const syncIntakeTombstone = z - .object({ - id: z - .string() - .describe( - "Server id — the identity key the client dedups on for intakes.", - ), - syncVersion: z.number().int().nonnegative(), - deletedAt: z.iso.datetime({ offset: true }), - updatedAt: z.iso.datetime({ offset: true }), - }) - .meta({ - id: "SyncIntakeTombstone", - description: - "A soft-deleted medication intake, keyed on server `id`. Apply before upserts within the domain page.", - }); - -const syncChangesQuery = z - .object({ - cursor: z - .string() - .min(1) - .max(2048) - .optional() - .describe( - "Opaque multi-domain keyset cursor from the previous page. Treat as fully opaque — echo, never parse. Omit for the initial sync.", - ), - limit: z.coerce - .number() - .int() - .min(1) - .max(500) - .optional() - .describe("Page size, default 200, hard cap 500."), - }) - .meta({ id: "SyncChangesQuery" }); - -const syncChangesResponse = z - .object({ - serverNow: z.iso.datetime({ offset: true }), - cursor: z - .string() - .nullable() - .describe("Opaque cursor to echo into the next request."), - hasMore: z - .boolean() - .describe("False once the client is caught up as of `serverNow`."), - cursorExpired: z - .boolean() - .describe( - "True when the supplied cursor predates tombstone retention — drop the cursor and do a clean initial sync.", - ), - changes: z.object({ - measurements: z.object({ - upserts: z.array(syncMeasurementUpsert), - tombstones: z.array(syncMeasurementTombstone), - }), - mood: z.object({ - upserts: z.array(syncMoodUpsert), - tombstones: z.array(syncMoodTombstone), - }), - intakes: z.object({ - upserts: z.array(syncIntakeUpsert), - tombstones: z.array(syncIntakeTombstone), - }), - }), - }) - .meta({ - id: "SyncChangesResponse", - description: - "Multi-domain delta page (v1.7.0): measurements + mood + intakes. One opaque multi-domain keyset cursor; tombstones apply before upserts within each domain. Tombstone identity: measurements key on externalId, mood + intakes on server id. The iOS consumer is measurements-only this cycle; mood + intakes are forward-prep.", - }); - -// v1.4.48 H-APNs-1 — admin diagnostic endpoint for the notification -// subsystem. Mirrors the runtime types in -// `src/app/api/admin/notifications/diagnostic/route.ts`: APNs tokens -// are surfaced only as 8-char hex prefix + suffix (never the full -// token), per-channel `enabled` + `configPresent` booleans, and a -// `recentPushAttempts` array reserved for the v1.4.48+ follow-up that -// adds the dedicated PushAttempt table (currently always `[]` so the -// shape stays stable for iOS consumers). -const adminDiagnosticDevice = z - .object({ - id: z.string(), - platform: z.string(), - hasApnsToken: z.boolean(), - apnsTokenPrefix: z.string().nullable(), - apnsTokenSuffix: z.string().nullable(), - apnsEnvironment: z.string().nullable(), - lastSeenAt: z.iso.datetime({ offset: true }), - }) - .meta({ - id: "AdminDiagnosticDevice", - description: - "Per-device APNs registration snapshot. The APNs token is masked to its 8-char hex prefix + 8-char suffix so an operator can correlate with iOS-side logs without disclosing the delivery target.", - }); - -const adminDiagnosticChannel = z - .object({ - type: z.string(), - enabled: z.boolean(), - configPresent: z.boolean(), - }) - .meta({ - id: "AdminDiagnosticChannel", - description: - "Per-channel state. `configPresent` is true when the channel's encrypted-JSON config blob carries the field the corresponding sender will read (chatId+botToken for TELEGRAM, topic for NTFY); WEB_PUSH/APNS rely on sibling tables so the row's existence is the signal.", - }); - -const adminDiagnosticPushAttempt = z - .object({ - eventType: z.string(), - channel: z.string(), - result: z.string(), - reason: z.string().nullable(), - at: z.iso.datetime({ offset: true }), - }) - .meta({ - id: "AdminDiagnosticPushAttempt", - description: - "Single push delivery attempt. Reserved for the v1.4.48+ follow-up that adds the dedicated PushAttempt table — currently always returned as an empty array so the response shape stays stable for iOS consumers.", - }); - -const adminDiagnosticData = z - .object({ - devices: z.array(adminDiagnosticDevice), - notificationChannels: z.array(adminDiagnosticChannel), - recentPushAttempts: z.array(adminDiagnosticPushAttempt), - }) - .meta({ - id: "AdminDiagnosticData", - description: - "Admin notification diagnostic snapshot for the calling user — what the dispatcher would see when targeting this account. Surfaces device tokens (masked), channel state, and recent push attempts so an operator can debug an iOS / Web Push / Telegram / ntfy issue without DB shell access.", - }); - -// ── Medications (v1.5 scheduling) ──────────────────────────────────── -// -// The Medication + MedicationSchedule resource shapes documented below -// follow the wire envelope the seven routes registered at the bottom of -// this file emit. `windowStart` / `windowEnd` / `daysOfWeek` / -// `intervalWeeks` are the legacy primitives kept for backwards -// compatibility through the v1.5.x line; `timesOfDay`, `rrule`, -// `rollingIntervalDays`, and `reminderGraceMinutes` are the v1.5 -// first-class primitives the wizard + iOS cadence picker write. The -// XOR between `rrule` and `rollingIntervalDays` is documented at the -// schema description AND enforced by the route + a DB CHECK constraint -// so iOS code-gen surfaces the mutual exclusion. - -const medicationCategoryEnum = z.enum(MEDICATION_CATEGORY_VALUES).meta({ - id: "MedicationCategory", - description: - "Clinical taxonomy stored in the `medication_categories` side-table. Orthogonal to `MedicationTreatmentClass`.", -}); - -const medicationTreatmentClassEnum = z - .enum(MEDICATION_TREATMENT_CLASS_VALUES) - .meta({ - id: "MedicationTreatmentClass", - description: - "Prisma-level treatment-class discriminator. `GLP1` unlocks the GLP-1 specialist surfaces (injection-site rotation, titration history, pen inventory, GLP-1-aware Coach).", - }); - -const medicationScheduleResource = z - .object({ - id: z.string(), - medicationId: z.string(), - windowStart: z - .string() - .describe( - "Legacy single-time-of-intake (HH:mm, user local). Preserved for backwards compatibility; the new `timesOfDay` array supersedes it.", - ), - windowEnd: z - .string() - .describe( - "Legacy reminder-window upper bound (HH:mm). Used to derive the late-classification grace span when `reminderGraceMinutes` is null.", - ), - label: z.string().nullable(), - dose: z - .string() - .nullable() - .describe( - "Per-schedule dose override. NULL means the schedule inherits `Medication.dose`.", - ), - daysOfWeek: z - .string() - .nullable() - .describe( - "Legacy persisted recurrence encoding (`null` | `1,3,5` | `i2;1,3,5`). v1.5 readers consult `rrule` first; the field is kept for pre-v1.5 rows. v1.6.0 drops the column.", - ), - timesOfDay: z - .array(z.string()) - .describe( - "v1.5 first-class points-in-time the dose is taken (HH:mm, user local). Backfilled to `[windowStart]` for every pre-v1.5 row.", - ), - reminderGraceMinutes: z - .number() - .int() - .nullable() - .describe( - "Reminder grace window in minutes. NULL falls back to the legacy `windowEnd - windowStart` span.", - ), - rrule: z - .string() - .nullable() - .describe( - "RFC 5545 RRULE string (subset). Used for calendar-anchored cadences. **Mutually exclusive with `rollingIntervalDays`** — exactly one of the two is non-null on any v1.5+ schedule (or both are null on legacy rows that haven't been touched since the migration).", - ), - rollingIntervalDays: z - .number() - .int() - .nullable() - .describe( - "Flexible-rolling interval in days, counted forward from the latest `MedicationIntakeEvent.takenAt`. **Mutually exclusive with `rrule`.**", - ), - scheduleType: z - .enum(["SCHEDULED", "PRN", "CYCLIC"]) - .describe( - "v1.7.0 schedule-type discriminator. SCHEDULED = rrule / rolling / legacy cadence. PRN = as-needed (never projected, reminded, or counted in compliance expected; still loggable via the intake route). CYCLIC = N weeks on / M weeks off, gating whichever inner cadence the rrule / legacy fields describe.", - ), - cyclicOnWeeks: z - .number() - .int() - .nullable() - .describe( - 'v1.7.0 cyclic "on" weeks. Only meaningful when `scheduleType` is CYCLIC; null otherwise.', - ), - cyclicOffWeeks: z - .number() - .int() - .nullable() - .describe( - 'v1.7.0 cyclic "off" weeks. Only meaningful when `scheduleType` is CYCLIC; null otherwise.', - ), - }) - .meta({ - id: "MedicationSchedule", - description: - "Schedule entry attached to a medication. v1.5 promotes `timesOfDay` to first-class and introduces `rrule` (calendar-anchored cadences) and `rollingIntervalDays` (flexible-rolling cadences). The two recurrence primitives are mutually exclusive — enforced by the Zod refine on writes, the route layer, and a DB CHECK constraint (`medication_schedules_rrule_xor_rolling`). v1.7.0 adds `scheduleType` (SCHEDULED / PRN / CYCLIC) and the cyclic on/off-week fields.", - }); - -const medicationResource = z - .object({ - id: z.string(), - name: z.string(), - dose: z.string(), - treatmentClass: medicationTreatmentClassEnum, - dosesPerUnit: z - .number() - .int() - .nullable() - .describe( - "Doses per pen / vial for inventory tracking. NULL = inventory tracking off.", - ), - active: z.boolean(), - notificationsEnabled: z.boolean(), - liveActivityEnabled: z - .boolean() - .describe( - "v1.7.0 iOS Live Activity opt-in for this medication's reminders. Default false. The iOS client owns the ActivityKit lifecycle; the server only stores + echoes the flag.", - ), - criticalAlarmEnabled: z - .boolean() - .describe( - "v1.7.0 iOS 26 AlarmKit critical-reminder opt-in. Default false. Critical alarms bypass the device mute switch / Focus; the server stores the preference only.", - ), - atcCode: z - .string() - .nullable() - .describe( - "v1.9.0 optional WHO ATC classification code (active-substance class, e.g. `A10BX10`). User/clinician-asserted; never machine-guessed. Emitted on the FHIR `medicationCodeableConcept` under `http://www.whocc.no/atc`. NULL = no code captured.", - ), - rxNormCode: z - .string() - .nullable() - .describe( - "v1.9.0 optional RxNorm RxCUI (numeric, US identifier, e.g. `2601723`). Secondary FHIR coding under `http://www.nlm.nih.gov/research/umls/rxnorm`, alongside any ATC code. NULL = no code captured.", - ), - pausedAt: z.iso.datetime({ offset: true }).nullable(), - snoozedUntil: z.iso.datetime({ offset: true }).nullable(), - nextDueAt: z.iso - .datetime({ offset: true }) - .nullable() - .describe( - "v1.7.0 server-computed next due instant across all the medication's schedules (earliest `nextOccurrenceAfter`). Read-only — computed, not stored. NULL when no schedule has an upcoming slot (paused, one-shot in the past, `endsOn` crossed, every schedule PRN). v1.16.4 — when an unresolved slot's anchor has passed but the catch-up band is still open (`anchor < now <= overdueEnd`, current schedule era), this carries THAT slot (a past instant) with `nextDueOverdue: true` instead of jumping to the next future slot. The list GET is cached 60 s, so a 60 s staleness is accepted.", - ), - nextDueOverdue: z - .boolean() - .describe( - "v1.16.4 — true when `nextDueAt` is an OPEN overdue slot: its anchor has passed, `now` is still inside the slot's catch-up band, and no taken / skipped / auto-missed row resolves it. False for a regular future next-due (and when `nextDueAt` is null). Read-only — computed, not stored.", - ), - startsOn: z.iso - .datetime({ offset: true }) - .nullable() - .describe( - "v1.5 course start (ISO date). Anchors RRULE BYDAY / BYMONTHDAY patterns and the rolling-interval countdown's first window. NULL means active from creation.", - ), - endsOn: z.iso - .datetime({ offset: true }) - .nullable() - .describe( - "v1.5 course end (ISO date). NULL means chronic. Equals `startsOn` when `oneShot` is true.", - ), - oneShot: z - .boolean() - .describe( - "v1.5 single-administration flag. When true the medication has at most one schedule (no `rrule` / `rollingIntervalDays`), and `active` auto-flips to false once the dose is logged.", - ), - createdAt: z.iso.datetime({ offset: true }), - updatedAt: z.iso.datetime({ offset: true }), - schedules: z.array(medicationScheduleResource), - }) - .meta({ - id: "Medication", - description: - "Server-shaped medication row returned by GET / POST / PUT endpoints. Carries the v1.5 course-window fields (`startsOn`, `endsOn`, `oneShot`) at the medication level and the per-schedule cadence fields on the nested `schedules` array.", - }); - -const medicationListEntry = medicationResource - .extend({ - category: medicationCategoryEnum, - lastTakenAt: z.iso - .datetime({ offset: true }) - .nullable() - .describe( - "Latest non-skipped `MedicationIntakeEvent.takenAt` for the medication. Drives the rolling-cadence countdown surface.", - ), - todayEventCount: z - .number() - .int() - .nonnegative() - .describe( - "Number of intake events scheduled for today (user-local day window).", - ), - }) - .meta({ - id: "MedicationListEntry", - description: - "List-row variant of the medication resource enriched with the joined `category`, `lastTakenAt`, and `todayEventCount` fields the dashboard + iOS client consume. The base medication fields (`id`, `name`, `dose`, `treatmentClass`, `dosesPerUnit`, `active`, `notificationsEnabled`, `pausedAt`, `snoozedUntil`, `startsOn`, `endsOn`, `oneShot`, `createdAt`, `updatedAt`, `schedules`) are inlined; see the `Medication` component for their semantics.", - }); - -const medicationDetailEntry = medicationResource - .extend({ - category: medicationCategoryEnum, - }) - .meta({ - id: "MedicationDetail", - description: - "Detail variant of the medication resource enriched with the joined `category`. The base medication fields are inlined; see the `Medication` component for their semantics.", - }); - -const medicationIntakeEventResource = z - .object({ - id: z.string(), - userId: z.string(), - medicationId: z.string(), - scheduledFor: z.iso.datetime({ offset: true }), - takenAt: z.iso.datetime({ offset: true }).nullable(), - skipped: z.boolean(), - source: z.enum(["WEB", "API", "REMINDER", "IMPORT"]), - idempotencyKey: z.string().nullable(), - createdAt: z.iso.datetime({ offset: true }), - }) - .meta({ - id: "MedicationIntakeEvent", - description: - "Single dose log row. `takenAt` is non-null for confirmed intakes; `skipped:true` represents a deliberately-missed dose (no inventory consumption).", - }); - -const medicationCadenceTimelinePoint = z - .object({ - day: z.iso.datetime({ offset: true }), - windowStart: z.iso.datetime({ offset: true }), - windowEnd: z.iso.datetime({ offset: true }), - scheduleIndex: z.number().int().nonnegative(), - status: z.string(), - }) - .meta({ - id: "MedicationCadenceTimelinePoint", - description: - "One expected-vs-actual dose slot for the cadence timeline chart. `status` is one of `taken | skipped | missed | pending | future` and drives the chip colour.", - }); - -const medicationCadenceChips = z - .object({ - adherenceRate: z.number(), - streakDays: z.number().int().nonnegative(), - expectedSlots: z.number().int().nonnegative(), - actualDoses: z.number().int().nonnegative(), - }) - .meta({ - id: "MedicationCadenceChips", - description: - "Four compliance summary values for the medication detail page chip row.", - }); - -const medicationCadenceResponse = z - .object({ - windowDays: z.number().int().positive(), - anchorIso: z.iso.datetime({ offset: true }), - next: z - .object({ - windowStart: z.iso.datetime({ offset: true }), - windowEnd: z.iso.datetime({ offset: true }), - scheduleIndex: z.number().int().nonnegative(), - }) - .nullable(), - chips: medicationCadenceChips, - timeline: z.array(medicationCadenceTimelinePoint), - }) - .meta({ - id: "MedicationCadenceResponse", - description: - "Cadence + compliance read for a single medication. `next` is the upcoming-dose envelope (null when the course has ended or the rolling clock has no pinning intake yet); `timeline` walks the requested `windowDays` worth of slots in ascending time order.", - }); - -const complianceResult = z - .object({ - totalExpected: z - .number() - .int() - .nonnegative() - .describe( - "Full denominator over the window: `taken + skipped + missed`. Cadence-aware and clamped to the medication's `createdAt`.", - ), - taken: z.number().int().nonnegative(), - skipped: z - .number() - .int() - .nonnegative() - .describe( - "Doses the user explicitly skipped — excluded from the `rate` denominator.", - ), - missed: z.number().int().nonnegative(), - rate: z - .number() - .int() - .min(0) - .max(100) - .describe( - "Adherence percentage `round(taken / (taken + missed) * 100)` — `skipped` is excluded from the denominator.", - ), - streak: z - .number() - .int() - .nonnegative() - .describe("Consecutive days with every due dose taken."), - }) - .meta({ - id: "ComplianceResult", - description: - "Rolling-window adherence summary. `compliance30` is the authoritative 'last 30 days, taken vs expected' read — clients should display `rate` and use `totalExpected` as the denominator rather than re-deriving it from the daily map.", - }); - -const dailyComplianceEntry = z - .object({ - expected: z - .number() - .int() - .nonnegative() - .describe( - "Engine-computed due-slot count for the day. Equals `expectedCount`; kept for existing consumers.", - ), - expectedCount: z - .number() - .int() - .nonnegative() - .describe( - "True due-slot count for the day (additive field clients key off so they don't infer due-ness from `expected`).", - ), - due: z - .boolean() - .describe( - "`expectedCount > 0`. Paint a per-day glyph as expected/missed ONLY when `due === true`; off-cadence / pre-creation / PRN days are not misses.", - ), - taken: z.number().int().nonnegative(), - skipped: z.number().int().nonnegative(), - onTime: z - .number() - .int() - .nonnegative() - .describe( - "Doses taken in the on-time band, including the `early` bucket (early counts as compliant).", - ), - late: z.number().int().nonnegative(), - veryLate: z.number().int().nonnegative(), - early: z - .number() - .int() - .nonnegative() - .optional() - .describe( - "Doses taken before the on-time band's grace start; already folded into `onTime`, surfaced separately for consumers that differentiate.", - ), - }) - .meta({ - id: "DailyComplianceEntry", - description: - "Per-day compliance cell with the timing breakdown that drives the history glyph track.", - }); - -const complianceDisplay = z - .object({ - shortDays: z.number().int().positive(), - longDays: z.number().int().positive(), - expectedShort: z.number().int().nonnegative(), - expectedLong: z.number().int().nonnegative(), - minStableDoses: z.number().int().nonnegative(), - short: z.object({ - rate: z.number().int().min(0).max(100), - taken: z.number().int().nonnegative(), - expected: z - .number() - .int() - .nonnegative() - .describe( - "v1.15.9 rate denominator over the short window (`taken + missed`); user skips are excluded. Render `taken / expected · rate%`.", - ), - missed: z - .number() - .int() - .nonnegative() - .describe( - "v1.15.9 doses counted against the rate over the short window — includes forgotten doses the auto-miss cron flipped.", - ), - streak: z.number().int().nonnegative(), - }), - long: z.object({ - rate: z.number().int().min(0).max(100), - taken: z.number().int().nonnegative(), - expected: z - .number() - .int() - .nonnegative() - .describe("v1.15.9 rate denominator over the long window (`taken + missed`)."), - missed: z - .number() - .int() - .nonnegative() - .describe("v1.15.9 doses counted against the rate over the long window."), - }), - currentCycle: z - .object({ - state: z - .enum(["on_track", "due", "missed", "none"]) - .describe( - "Open-cycle state, decoupled from the percentage rows: `on_track` = next dose not yet due; `due` = due now / in grace; `missed` = past grace with no logged intake (the only red state); `none` = no projected next dose (PRN / paused / ended).", - ), - nextDueAt: z.iso - .datetime({ offset: true }) - .nullable() - .describe( - "The open cycle's due instant. Null when `state` is `none`.", - ), - graceUntil: z.iso - .datetime({ offset: true }) - .nullable() - .describe( - "End of the due slot's grace window. Null when `state` is `none`.", - ), - hasClosedCycles: z - .boolean() - .describe( - "False for a brand-new sparse med with zero closed dose cycles — the percentage rows are vacuous and the card should show a neutral 'not enough data yet' state.", - ), - }) - .describe( - "v1.13.x — the current (open) dose cycle, surfaced so a between-doses sparse med renders a neutral 'next dose in N days' line instead of a scary red 0%. The percentage rows above already exclude the open forward cycle from their denominator.", - ), - currentDose: z - .object({ - status: z - .enum([ - "upcoming", - "on_time_window", - "overdue", - "missed", - "taken_on_time", - "taken_late", - "skipped", - ]) - .describe( - "Per-dose state of the open cycle, server-derived from the window model so the card renders the take-window (green) / overdue / heavily-overdue escalation from one authority. `upcoming` when no dose is open (PRN / paused / ended).", - ), - targetAt: z.iso - .datetime({ offset: true }) - .nullable() - .describe("Target instant of the open dose. Null when no dose is open."), - }) - .describe( - "v1.15.9 — the open dose's per-dose status + target, so the card highlights the actionable take-window green and escalates an overdue dose without re-deriving the window math client-side.", - ), - }) - .meta({ - id: "ComplianceDisplay", - description: - "The two-row card block whose windows scale with dosing cadence (dense meds keep 7 / 30 days, sparse meds step both windows up). NOT the 30-day denominator — read `compliance30.totalExpected` for that.", - }); - -const medicationComplianceResponse = z - .object({ - compliance7: complianceResult, - compliance30: complianceResult, - dailyCompliance: z - .record(z.string(), dailyComplianceEntry) - .describe( - "Flat per-day map keyed `YYYY-MM-DD` in the user timezone, one entry per day for up to 90 days back, clamped to the medication's `createdAt` (so a recently-created med has fewer entries). No weekly/monthly collapse — this is the raw daily grid.", - ), - complianceDisplay, - }) - .meta({ - id: "MedicationComplianceResponse", - description: - "Adherence read for a single medication. `compliance30` is the authoritative 30-day taken-vs-expected summary; `dailyCompliance` is the per-day grid for the history glyph track. The graded raw→week→month→year series used elsewhere for AI prompts does NOT apply here — this response is never downsampled.", - }); - -const insightsComprehensiveResponse = z - .object({ - summary: z.string(), - recommendations: z.array(z.record(z.string(), z.unknown())), - citations: z.array(z.record(z.string(), z.unknown())), - warnings: z.array(z.record(z.string(), z.unknown())), - dailyBriefing: z.record(z.string(), z.unknown()).nullable().optional(), - trendAnnotations: z.record(z.string(), z.unknown()).nullable().optional(), - storyboardAnnotations: z - .array(z.record(z.string(), z.unknown())) - .optional(), - metricSource: z.record(z.string(), z.unknown()).optional(), - }) - .meta({ - id: "InsightsComprehensiveResponse", - description: - "AI-generated insights bundle. Strict-schema validated server-side; Coach-routed when the insight surface needs day-level grounding.", - }); - -// v1.8.7.1 — generic per-HealthKit-metric assessment. The query enum is -// derived from the same registry the route validates against, so the -// spec, the route, and the cache scope cannot drift. The seven -// specialised metrics (weight / blood-pressure / pulse / bmi / mood / -// medication-compliance) keep their own routes and are NOT accepted here. -const metricStatusQuery = z - .object({ - metric: z - .enum(METRIC_STATUS_IDS as [string, ...string[]]) - .describe( - "HealthKit metric id to assess (e.g. RESTING_HEART_RATE, SLEEP_DURATION). Closed enum: an unknown id 422s. The seven specialised metrics are served by their own routes and are not accepted here.", - ), - locale: z - .enum(["de", "en"]) - .optional() - .describe("Optional UI-locale override; defaults to the session locale."), - }) - .meta({ id: "MetricStatusQuery" }); - -const metricStatusResponse = z - .object({ - hasProvider: z - .boolean() - .describe( - "False when the user has no usable AI provider — `text` then carries the generic no-key guidance.", - ), - text: z - .string() - .nullable() - .describe( - "The assessment narrative (plain text, rendered as React text children). Null while a first generation is preparing, or when the metric has insufficient data.", - ), - cached: z - .boolean() - .describe("True when `text` is served from cache (incl. last-good)."), - updatedAt: z.iso - .datetime({ offset: true }) - .nullable() - .describe("When the served assessment was generated; null when none."), - preparing: z - .boolean() - .optional() - .describe( - "True when a first assessment is being generated out of band and no prior text exists yet — the client polls until it lands.", - ), - revalidating: z - .boolean() - .optional() - .describe( - "True when `text` is served from last-good cache (stale-while-revalidate) while a fresh generation is in flight. The payload is otherwise terminal; the client keeps polling on `preparing || revalidating` (bounded) so the open card upgrades to the warmed assessment without a remount.", - ), - insufficient: z - .boolean() - .optional() - .describe( - "True when the metric has no readings; no assessment is generated (no LLM call). The card shows its insufficient-data state.", - ), - }) - .meta({ - id: "MetricStatusResponse", - description: - "Generic per-metric assessment envelope. Identical shape to the seven specialised `*-status` cards so the `InsightStatusCard` consumes it unchanged. Read-only + stale-while-revalidate: a cache miss warms a generation out of band and serves the last-good text meanwhile.", - }); - -// v1.10.0 — generic derived-wellness-metric route. The query enum is -// derived from the same registry the route validates against, so spec + -// route + cache scope cannot drift. `type` sub-targets the single vital -// a baseline metric (VITALS_BASELINE) bands over. -const derivedMetricQuery = z - .object({ - metric: z - .enum(DERIVED_METRIC_IDS as [string, ...string[]]) - .describe( - "Derived-metric id to compute (e.g. VITALS_BASELINE, FITNESS_AGE, VASCULAR_AGE_DELTA, HRV_BALANCE, BMI, READINESS). Closed enum: an unknown id 422s. Metrics whose compute has not yet landed return an `insufficient` value with reason `not_implemented`.", - ), - type: z - .enum(VITALS_BASELINE_TYPES as [string, ...string[]]) - .optional() - .describe( - "For VITALS_BASELINE only — the single vital to band (defaults to RESTING_HEART_RATE). Ignored by composites. An unsupported value yields an `insufficient` value rather than a 422 so iOS metric combinations stay forgiving.", - ), - }) - .meta({ id: "DerivedMetricQuery" }); - -const derivedCoverage = z - .object({ - requiredInputs: z - .number() - .int() - .describe("Inputs the metric wants (its full input set)."), - presentInputs: z - .number() - .int() - .describe("Inputs actually present in the user's data."), - historyDays: z - .number() - .int() - .describe( - "Distinct days of history backing the value (the gating floor).", - ), - missing: z - .array(z.string()) - .describe( - "Named inputs still missing — drives the 'track N more' nudge.", - ), - }) - .meta({ id: "DerivedCoverage" }); - -const derivedConfidence = z - .object({ - score: z - .number() - .describe( - "0..100 confidence; feeds the shared coverage meter unchanged.", - ), - band: z - .enum(["high", "medium", "low", "draft"]) - .describe("Confidence band the meter renders."), - }) - .meta({ id: "DerivedConfidence" }); - -const derivedProvenance = z - .object({ - inputs: z - .array(z.string()) - .describe("Named inputs that actually backed the value."), - source: z - .enum(["DAY", "WEEK", "MONTH", "YEAR", "live", "none"]) - .describe( - "Granularity the dominant read resolved against. 'live' = a coverage-miss live-SQL fallback; 'none' = no data backed the value.", - ), - windowDays: z - .number() - .int() - .describe("Trailing window the value summarises, in days."), - computedAt: z.iso - .datetime({ offset: true }) - .describe("Compute time (for cache-staleness + the 'as of' chip)."), - }) - .meta({ id: "DerivedProvenance" }); - -// v1.13.2 — per-derived-SCORE assessment text. Additive, non-breaking field -// on the derived response; the iOS field-name contract is LOCKED. -const derivedAssessment = z - .object({ - text: z - .string() - .describe( - "Short, non-empty explanation of why the score sits where it does, referencing the score's contributors.", - ), - source: z - .string() - .describe( - "'deterministic' for the always-on template text, or 'ai' when warmer provider prose has been cached.", - ), - updatedAt: z.iso - .datetime({ offset: true }) - .describe("When the text was produced / last warmed."), - }) - .meta({ id: "DerivedAssessment" }); - -const derivedMetricResponse = z - .object({ - metric: z - .enum(DERIVED_METRIC_IDS as [string, ...string[]]) - .describe("Echoes the requested derived-metric id (tags the union)."), - status: z - .enum(["ok", "insufficient"]) - .describe( - "'ok' carries `value` + `confidence`; 'insufficient' carries `reason` and no value, but still carries `coverage` + `provenance` so the surface renders the same gating UI.", - ), - value: z - .record(z.string(), z.unknown()) - .nullable() - .describe( - "Metric-specific value object when status is 'ok' (e.g. { type, center, low, high, spread, sampleDays, k, series } for VITALS_BASELINE, where `series` is the trailing per-day mean values for the inline sparkline); null when 'insufficient'.", - ), - coverage: derivedCoverage, - confidence: derivedConfidence - .nullable() - .describe("Present when status is 'ok'; null when 'insufficient'."), - provenance: derivedProvenance, - reason: z - .string() - .nullable() - .describe( - "Why the value could not be produced; null when status is 'ok'.", - ), - assessment: derivedAssessment - .nullable() - .describe( - "v1.13.2 — short 'why is this score what it is' explanation, keyed to the SAME requested id (only for the per-score ids READINESS, SLEEP_SCORE, RECOVERY_SCORE, STRAIN_SCORE, STRESS_SCORE). Null for any other metric and whenever status !== 'ok'. Always non-empty when present: a deterministic text fills it (so provider-less accounts + the demo always get one) and warmer AI prose overrides it once cached.", - ), - }) - .meta({ - id: "DerivedMetricResponse", - description: - "Flat `Derived` envelope for one derived wellness metric. Pure compute over the rollup tier (no LLM, no narrative). iOS decodes one stable shape and combines values across metrics; coverage/confidence/provenance let it render the same honesty chips.", - }); - -// v1.10.0 — batched derived-metric query. The `metrics` CSV carries one -// or more `metric` / `metric:type` tokens; the route fans out server-side -// under a bounded limiter with the profile loaded once, collapsing the -// dashboard's cold-mount fan-out of N single-metric requests into one. -const derivedBatchQuery = z - .object({ - metrics: z - .string() - .min(1) - .max(1024) - .describe( - "Comma-separated derived-metric tokens. Each is a `` or `:` (the colon sub-targets a VITALS_BASELINE vital). An unknown id 422s; at most 24 tokens; duplicates collapse.", - ), - }) - .meta({ id: "DerivedBatchQuery" }); - -const derivedBatchResponse = z - .object({ - metrics: z - .record(z.string(), derivedMetricResponse) - .describe( - "Map keyed by the per-request token (`` or `:`). Each value is the same flat `Derived` envelope the single-metric route returns, so a client decodes one shape and reads back exactly the tokens it asked for.", - ), - }) - .meta({ - id: "DerivedBatchResponse", - description: - "Batched derived-metric values. One request resolves the whole dashboard grid (the wellness scores + the derived re-frames + one baseline per vital) instead of N concurrent single-metric requests sharing the Prisma pool. Pure compute over the rollup tier — no LLM, no narrative, no cache table.", - }); - -// v1.10.0 — FDR-controlled correlation discovery result. One discovered, -// statistically-defensible behaviour → next-day-outcome pair. -const discoveredCorrelation = z - .object({ - behaviour: z - .string() - .describe("Behaviour channel (lag source), e.g. TIME_IN_DAYLIGHT, MOOD."), - outcome: z - .string() - .describe( - "Outcome channel (lag target), e.g. SLEEP_DURATION, HEART_RATE_VARIABILITY.", - ), - n: z - .number() - .int() - .describe("Paired-day count after the day+1 lag join (≥ 20)."), - r: z.number().describe("Pearson r over the lag-joined daily series."), - pValue: z.number().describe("Two-sided exact Student-t p-value (< 0.05)."), - qValue: z - .number() - .describe( - "Benjamini-Hochberg FDR-adjusted q-value (≤ the surface threshold).", - ), - interpretation: z - .string() - .describe("Conservative, descriptive interpretation — never causal."), - lagDays: z.number().int().describe("Lag in days applied (1)."), - }) - .meta({ id: "DiscoveredCorrelation" }); - -const correlationDiscoveryResponse = z - .object({ - discovered: z - .array(discoveredCorrelation) - .describe("Pairs surviving n ≥ 20, p < 0.05, AND the BH-FDR control."), - pairsTested: z - .number() - .int() - .describe("Behaviour × outcome pairs assessed (for the honest footer)."), - fdrQ: z.number().describe("The FDR target the surface used."), - minPairs: z - .number() - .int() - .describe("Minimum paired-day count enforced per pair."), - }) - .meta({ - id: "CorrelationDiscoveryResponse", - description: - "v1.10.0 — FDR-controlled correlation discovery over a curated behaviour × outcome matrix, lagged behaviour → next-day outcome. Only statistically-defensible pairs surface; descriptive, never causal.", - }); - -// The seven specialised `*-status` routes accept an optional locale -// override (the metric is fixed by the route path, unlike the generic -// metric-status route which carries it as a query field). -const insightStatusQuery = z - .object({ - locale: z - .enum(["de", "en"]) - .optional() - .describe("Optional UI-locale override; defaults to the session locale."), - }) - .meta({ id: "InsightStatusQuery" }); - -// Shared response shape for the five text-bearing specialised status -// routes (blood-pressure, pulse, weight, bmi, mood). Same envelope as -// the generic metric-status card minus the `insufficient` flag, which is -// metric-status-only. Read-only + stale-while-revalidate. -const insightStatusResponse = z - .object({ - hasProvider: z - .boolean() - .describe( - "False when the user has no usable AI provider — `text` then carries the generic no-key guidance.", - ), - text: z - .string() - .nullable() - .describe( - "The assessment narrative (plain text, rendered as React text children). Null while a first generation is preparing.", - ), - cached: z - .boolean() - .describe("True when `text` is served from cache (incl. last-good)."), - updatedAt: z.iso - .datetime({ offset: true }) - .nullable() - .describe("When the served assessment was generated; null when none."), - preparing: z - .boolean() - .optional() - .describe( - "True when a first assessment is being generated out of band and no prior text exists yet — the client polls until it lands.", - ), - revalidating: z - .boolean() - .optional() - .describe( - "True when `text` is served from last-good cache (stale-while-revalidate) while a fresh generation is in flight. The client keeps polling on `preparing || revalidating` (bounded) so the open card upgrades to the warmed assessment without a remount.", - ), - }) - .meta({ - id: "InsightStatusResponse", - description: - "Specialised per-metric assessment envelope (blood-pressure, pulse, weight, bmi, mood). Identical shape to the generic metric-status card so the `InsightStatusCard` consumes it unchanged. Read-only + stale-while-revalidate: a cache miss warms a generation out of band and serves the last-good text meanwhile.", - }); - -// The medication-compliance route carries a richer envelope than the -// other six: a `summary` narrative plus a per-medication `text` array, -// instead of a single `text` field. -const medicationComplianceStatusResponse = z - .object({ - hasProvider: z - .boolean() - .describe( - "False when the user has no usable AI provider — `summary` then carries the generic no-key guidance.", - ), - summary: z - .string() - .nullable() - .describe( - "The overall compliance narrative (plain text). Null while a first generation is preparing.", - ), - medications: z - .array( - z - .object({ - medicationId: z - .string() - .describe("The medication this note belongs to."), - text: z - .string() - .describe("Per-medication compliance note (plain text)."), - }) - .meta({ id: "MedicationComplianceStatusItem" }), - ) - .describe( - "Per-medication compliance notes. Empty while preparing or when no medication qualifies.", - ), - cached: z - .boolean() - .describe( - "True when the envelope is served from cache (incl. last-good).", - ), - updatedAt: z.iso - .datetime({ offset: true }) - .nullable() - .describe("When the served assessment was generated; null when none."), - preparing: z - .boolean() - .optional() - .describe( - "True when a first assessment is being generated out of band and no prior summary exists yet — the client polls until it lands.", - ), - revalidating: z - .boolean() - .optional() - .describe( - "True when the envelope is served from last-good cache (stale-while-revalidate) while a fresh generation is in flight. The client keeps polling on `preparing || revalidating` (bounded).", - ), - }) - .meta({ - id: "MedicationComplianceStatusResponse", - description: - "Medication-compliance assessment envelope. Unlike the other six specialised cards it carries a `summary` plus a per-medication `text` array rather than a single `text` field. Read-only + stale-while-revalidate.", - }); - -const analyticsRangeQuery = z - .object({ - type: measurementTypeEnum.describe( - "The measurement type to read (single metric — no fan-out). Closed enum: an unknown type 422s.", - ), - range: z - .enum(ANALYTICS_RANGES) - .describe( - "Trailing window: `7d` / `30d` / `90d` / `1y`. The previous comparable window is the equally-sized span immediately before it.", - ), - }) - .meta({ id: "AnalyticsRangeQuery" }); - -const analyticsWindowAggregate = z - .object({ - count: z.number().int().describe("Reading count composed across buckets."), - min: z.number().nullable().describe("Window minimum; null when empty."), - max: z.number().nullable().describe("Window maximum; null when empty."), - mean: z - .number() - .nullable() - .describe("Count-weighted mean across buckets; null when empty."), - sum: z - .number() - .nullable() - .describe( - "Cumulative total for cumulative metrics (steps, energy, distance); null when no bucket carries a sum.", - ), - }) - .meta({ id: "AnalyticsWindowAggregate" }); - -const analyticsRangeResponse = z - .object({ - range: z - .enum(ANALYTICS_RANGES) - .describe("The range that was read (echoes the request)."), - windowDays: z - .number() - .int() - .describe("Trailing-window length in days for the chosen range."), - granularity: z - .string() - .describe( - "Rollup granularity the read resolved against (`DAY` / `WEEK` / `MONTH` / `YEAR`, or `none` on a coverage miss).", - ), - current: analyticsWindowAggregate.describe( - "Aggregate over the current window `[now-N, now)`.", - ), - previous: analyticsWindowAggregate.describe( - "Aggregate over the previous comparable window `[now-2N, now-N)`.", - ), - delta: z - .number() - .nullable() - .describe( - "`current.mean - previous.mean`; null when either window has no data (never a misleading 0).", - ), - deltaPct: z - .number() - .nullable() - .describe( - "`delta / previous.mean` as a fraction (0.03 = +3 %); null when the prior window has no / zero mean (no divide-by-zero). The client shows 'no prior-period data' in that case.", - ), - }) - .meta({ - id: "AnalyticsRangeResponse", - description: - "Single-metric period-over-period aggregate. Reads the current and previous comparable windows from the WMY rollup tier and composes a count-weighted-mean delta. `count/min/max/mean/sum` are linearly composable across buckets; SD/slope/r² are intentionally excluded (not composable).", - }); - -const insightsPregenerateRequest = z.object({}).meta({ - id: "InsightsPregenerateRequest", - description: - "No body fields. The user is taken from the session / Bearer and the locale from the session; the warm covers every assessment for that user.", -}); - -const insightsPregenerateResponse = z - .object({ - queued: z - .boolean() - .describe("True when the full warm was accepted and enqueued."), - locale: z - .enum(["de", "en"]) - .describe("The locale the assessments are being warmed in."), - }) - .meta({ - id: "InsightsPregenerateResponse", - description: - "Acknowledgement that a full assessment warm was enqueued for the calling user. The generation runs out of band; the text lands in the read-only status routes.", - }); - -// v1.7.0 — unified dashboard first-paint snapshot. One GET that -// assembles every above-the-fold tile field in a single round-trip. -// Two-phase shape: `tiles` (fast, always present) + `extras` (thick, -// nullable on a rollup-coverage miss). The nested AI / DataSummary -// blocks are typed loosely (`z.record`) to match the comprehensive -// response style above — the strict shapes live in their own Zod -// modules and the iOS client does not consume this web-only route. -const dataSummaryRecord = z.record(z.string(), z.unknown()); - -const dashboardSnapshotResponse = z - .object({ - user: z.object({ - username: z.string(), - timezone: z.string(), - heightCm: z.number().nullable(), - dateOfBirth: z.string().nullable(), - gender: z.enum(["MALE", "FEMALE"]).nullable(), - glucoseUnit: z.string().nullable(), - onboardingTourCompleted: z.boolean(), - greetingHour: z.number().int(), - }), - layout: z.record(z.string(), z.unknown()), - // v1.7.0 — full 27-id widget catalogue (16 server-known + 11 - // iOS-only) so a cold-launch first-paint seeds every tile and the - // layout round-trips in one key. Additive alongside the web - // `layout` block, which stays byte-identical. - layoutCatalogue: z - .array( - z.object({ - id: z.string(), - visible: z.boolean(), - order: z.number().int(), - }), - ) - .describe( - "Full 27-id widget catalogue (server-known + iOS-only) with per-widget visibility + order. iOS-only ids are appended default-invisible. The web dashboard reads `layout`; this block is the cold-launch seed for the native client.", - ), - // v1.7.0 — per-chartable-metric latest reading keyed by iOS - // `MetricKind` raw value (e.g. `oxygenSaturation`, - // `heartRateVariability`, `bodyMassIndex`). Derived in-process from - // the slim summaries slice — no extra DB read. - metricStates: z - .record( - z.string(), - z.object({ - value: z.number(), - measuredAt: z.string(), - unit: z.string(), - }), - ) - .describe( - "Latest reading per chartable metric, keyed by the iOS `MetricKind` raw value (the non-obvious raws: `oxygenSaturation`, `totalBodyWater`, `heartRateVariability`, `bodyMassIndex`, `walkingAsymmetryPercentage`, `walkingDoubleSupportPercentage`, `environmentalAudioExposure`, `headphoneAudioExposure`, `activeEnergyBurned`). Each entry carries `value`, `measuredAt` (ISO8601), and the canonical `unit`. Types the user has never logged are omitted.", - ), - tiles: z.object({ - summaries: dataSummaryRecord, - lastSeenByType: z.record(z.string(), z.unknown()), - mood: z.object({ - summary: dataSummaryRecord.nullable(), - entries: z.array( - z.object({ - date: z.string(), - score: z.number(), - samples: z.number().int(), - }), - ), - }), - }), - extras: z - .object({ - bpInTargetPct: z.number().nullable(), - bpInTargetPct7d: z.number().nullable(), - bpInTargetPct30d: z.number().nullable(), - bpInTargetPctAllTime: z.number().nullable(), - bpInTargetPctPriorMonth: z.number().nullable(), - bpInTargetPctPriorYear: z.number().nullable(), - glucoseByContext: dataSummaryRecord, - }) - .nullable(), - briefing: z.record(z.string(), z.unknown()).nullable(), - briefingState: z.enum(["ready", "preparing", "disabled", "no-provider"]), - briefingUpdatedAt: z.string().nullable(), - briefingStale: z - .boolean() - .describe( - "True when `briefing` carries the last good (expired-TTL) briefing while a refresh is pending (`preparing`) or impossible (`no-provider`). Render the stale content with its `briefingUpdatedAt` timestamp instead of a blank tile.", - ), - generatedAt: z.string(), - }) - .meta({ - id: "DashboardSnapshotResponse", - description: - "Unified above-the-fold dashboard payload. `tiles` always arrives (slim summaries + mood + resolved widget layout); `extras` (BD-in-target + per-context glucose) is null on a rollup-coverage miss so the strip never waits on the slowest read. `briefing` is lifted read-only from the pre-generated insight cache — never generated synchronously — and reports `ready` / `preparing` / `disabled` / `no-provider` via `briefingState` (`no-provider` = stale-or-missing cache with no AI provider configured anywhere, so no warm pass will fill it; stop polling and surface a connect-provider hint). A stale-but-parseable briefing is still delivered with `briefingStale: true`. `layoutCatalogue` (full 27-id widget catalogue) and `metricStates` (latest reading per metric, keyed by iOS `MetricKind` raw value) are additive cold-launch seeds for the native client; both derive in-process from data already fetched, adding no DB round-trip.", - }); - -// v1.5.0 — natural-language medication extraction route. The wizard's -// optional "Beschreiben" overlay POSTs a free-text description and -// receives a partial structured payload the form merges onto whatever -// the user already typed. Citation-guarded (`name` and `dose` are -// dropped when not substring-matched in the original text) and -// closed-enum-validated. -const medicationExtractRequest = z - .object({ - text: z - .string() - .min(1) - .max(2000) - .describe( - "Free-text medication description (any locale). Up to 2 000 characters. The model never echoes the text back into another tenant — it is only used to produce the structured fields.", - ), - locale: z - .enum(["en", "de", "es", "fr", "it", "pl"]) - .optional() - .describe("Optional UI locale hint for the model."), - today: z - .string() - .regex(/^\d{4}-\d{2}-\d{2}$/) - .optional() - .describe( - 'Optional override of the reference date used to resolve relative phrases ("tomorrow", "next Monday"). Format: `YYYY-MM-DD`. Defaults to the server\'s UTC day.', - ), - }) - .meta({ - id: "MedicationExtractRequest", - description: - "Free-text medication description payload. The route runs the text through the Coach provider chain and returns a partial structured payload the wizard merges. Rate-limited 10 requests / 5 minutes / user; budget-gated against the daily Coach token ceiling.", - }); - -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.", -}); - -// Insights tile layout — mirrors the Zod schema in -// `src/app/api/insights/layout/route.ts`. The tile-id enum is derived -// from the same `ACCEPTED_INSIGHTS_TILE_IDS` source so the contract -// cannot drift. -// -// v1.8.0 — the canonical ids are English (`blood-pressure`, `pulse`, -// `oxygen`, `body-temperature`, `weight`, `active-energy`, `sleep`, -// `resting-pulse`, `mood`, `medications`). The endpoint still ACCEPTS -// the legacy German ids (`blutdruck`, `puls`, `sauerstoff`, -// `koerpertemperatur`, `gewicht`, `aktive-energie`, `schlaf`, -// `ruhepuls`, `stimmung`, `medikamente`) on input so existing iOS -// layouts keep validating; the server normalises them to the canonical -// English id before persisting, and GET always returns canonical ids. -// The legacy ids are accepted-but-deprecated and will be dropped from -// the accepted set in a future major. -// v1.15.11 — layout v2 adds an optional `sections` array on top of the -// per-metric `tiles` list so the overview's big semantic blocks can be -// reordered/hidden in their own right. `sections` is additive and -// optional: a client PUTting only `tiles` (the pre-v2 iOS contract) still -// validates, and a v1 blob resolves forward to a valid v2 layout with all -// sections default-visible. Section ids are English from birth — no -// legacy-alias widening. `tiles` is likewise optional now (a section-only -// PUT fills the default tile set), but must carry at least one entry when -// present. -const insightsLayoutSchema = z - .object({ - // v1.15.11 QA C1 — both v1 and v2 are accepted on input. The live iOS - // client still PUTs `version: 1`; the server normalises to the canonical - // v2 blob on persist, so a v1 body never 422s on the layout-schema bump. - version: z.union([z.literal(1), z.literal(2)]), - sections: z - .array( - z.object({ - id: z.enum(INSIGHTS_SECTION_IDS), - visible: z.boolean(), - order: z.number().int().min(0).max(99), - }), - ) - .max(50) - .optional(), - tiles: z - .array( - z.object({ - id: z.enum(ACCEPTED_INSIGHTS_TILE_IDS), - visible: z.boolean(), - order: z.number().int().min(0).max(99), - }), - ) - .min(1) - .max(50) - .optional(), - }) - .meta({ - id: "InsightsLayoutBody", - description: - "Per-user Insights layout (v2). `tiles` is the per-metric pill list (ordered, with a visibility flag); `sections` is the additive v2 list of the overview's big semantic blocks (wellness-scores, daily-briefing, vitals, trends, period-review, cycle-summary, signals, rhythm-events), each with order + visibility. `version` is the layout schema version: BOTH 1 and 2 are accepted on input (a pre-v2 iOS client still PUTs `version: 1`); the server always normalises to the canonical v2 blob before persisting, and GET responses always carry `version: 2`. Both arrays are optional on input — a client sending only `tiles` (the pre-v2 contract) still validates, and the server fills missing defaults; a v1 blob with no `sections` resolves forward with all sections default-visible. Tile ids are a closed enum: the canonical ids are English (matching the routed `/insights/` sub-pages). The legacy German tile ids (blutdruck, puls, sauerstoff, koerpertemperatur, gewicht, aktive-energie, schlaf, ruhepuls, stimmung, medikamente) remain accepted on input for backward compatibility and are normalised to their English equivalents before persisting; GET responses always carry the canonical English ids. Section ids are English-only. The legacy tile ids are deprecated and will be removed in a future major version.", - }); - -// 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). `resourceTypes` scopes the FHIR resources the link may serve; `allowFhirApi` toggles REST reachability. 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.", -}); - -// v1.11.0 — clinician share-link owner-facing summary (never the raw token). -const shareLinkSummary = z.object({ - id: z.string(), - label: z.string(), - rangeStart: z.string(), - rangeEnd: z.string().nullable(), - resourceTypes: z.array(z.string()), - allowFhirApi: z.boolean(), - expiresAt: z.string(), - createdAt: z.string(), - revokedAt: z.string().nullable(), - lastAccessAt: z.string().nullable(), - accessCount: z.number(), - active: z.boolean(), -}); - -const shareLinkCreatedResponse = shareLinkSummary.extend({ - token: z - .string() - .describe("Raw `hls_` token — returned ONCE and unrecoverable thereafter."), -}); - -const shareLinkListResponse = z.object({ - shareLinks: z.array(shareLinkSummary), -}); - -const shareLinkRevokedResponse = z.object({ - id: z.string(), - revoked: z.boolean(), -}); - -// v1.10.2 — live capability / discovery response. Every list is sourced -// server-side from the canonical registry it documents, so the wire shape -// here is the contract; the runtime values are authoritative and never -// hand-duplicated. Used by the native client to gate its UI / decoder -// against what the server actually ships (retires the doc-vs-server -// enum-drift class). -const capabilitiesResponse = z - .object({ - apiContractVersion: z - .string() - .describe("Running build version — mirrors GET /api/version `version`."), - derivedMetricIds: z - .array(z.string()) - .describe("Closed derived-metric id set (GET /api/insights/derived)."), - vitalsBaselineTypes: z - .array(z.string()) - .describe("MeasurementTypes the typical-range baseline engine supports."), - layoutTileIds: z - .array(z.string()) - .describe("Canonical insights layout tile-id set (English ids)."), - metricStatusIds: z - .array(z.string()) - .describe("Closed metric-status / assessment id set."), - ingest: z - .object({ - quantityTypes: z - .array( - z.object({ - type: z.string().describe("HealthLog MeasurementType."), - hk: z.string().describe("HealthKit identifier."), - unit: z.string().describe("Canonical DB unit."), - }), - ) - .describe("Accepted HealthKit quantity-sample mappings."), - eventTypes: z - .array(z.string()) - .describe( - "MeasurementTypes for device-flagged EVENT-class HealthKit samples.", - ), - computedScores: z - .array(z.string()) - .describe("Server-owned nightly composite score types."), - writeAllowlist: z - .array(z.string()) - .describe( - "MeasurementSources a client may attribute on a write (others are server-owned).", - ), - }) - .describe("Ingest vocabularies the batch / single-write paths accept."), - fhir: z - .object({ - atcSystem: z.string().describe("WHO ATC CodeSystem URI."), - snomedRoute: z.string().describe("SNOMED CT CodeSystem URI."), - germanAtcDefaultLocales: z - .array(z.string()) - .describe( - "App locales that default the additive BfArM ATC coding on.", - ), - restBaseUrl: z - .string() - .describe("Base path of the read-only FHIR R4 REST face."), - readScope: z - .string() - .describe("Bearer scope a narrow token needs to read the FHIR face."), - resourceTypes: z - .array(z.string()) - .describe( - "FHIR resource types the REST face serves (read + search).", - ), - operations: z - .array(z.string()) - .describe("Whole-record operations exposed (e.g. $everything)."), - searchParams: z - .array(z.string()) - .describe( - "Search parameters honoured uniformly across the search routes.", - ), - }) - .describe( - "FHIR coding constants + the read-only REST face descriptor (v1.11).", - ), - share: z - .object({ - supported: z - .boolean() - .describe("Whether clinician share links are served."), - maxDays: z - .number() - .int() - .describe( - "Maximum lifetime of a share link, in days. No never-expiring share.", - ), - resourceTypes: z - .array(z.string()) - .describe("FHIR resource types a share link may be scoped to serve."), - sections: z - .array(z.string()) - .describe("Scopeable report sections a share link may toggle."), - }) - .describe("Clinician share-link surface descriptor (v1.11)."), - }) - .meta({ - id: "CapabilitiesResponse", - description: - "Live id vocabularies + contract version. Every list is derived server-side from the canonical registry it documents, so it cannot drift from the values the routes actually accept/emit.", - }); - -// ── Standard 401 / 422 / 429 responses ─────────────────────────────── - -const stdResponses = { - "401": { - description: "Authentication required or invalid credentials.", - content: { "application/json": { schema: errorEnvelope } }, - }, - "422": { - description: "Request validation failed.", - content: { "application/json": { schema: errorEnvelope } }, - }, - "429": { - description: "Rate limit exceeded.", - content: { "application/json": { schema: errorEnvelope } }, - }, -}; - -// ── 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 -// shape — only list, bulk-clear, and single-delete responses. - -const coachFactItem = z.object({ - id: z.string(), - category: z - .enum(COACH_FACT_CATEGORIES) - .describe( - "App-side closed category: preference | condition | goal | constraint | context.", - ), - text: z.string().describe("Decrypted fact text."), - confidence: z - .number() - .int() - .describe("0..100 server-assigned extraction confidence."), - createdAt: z.iso.datetime({ offset: true }), -}); - -const coachFactsListResponse = z.object({ - facts: z - .array(coachFactItem) - .describe( - "The caller's active facts, highest-confidence then newest first. Undecryptable rows are omitted.", - ), -}); - -const coachFactsClearedResponse = z.object({ - cleared: z - .number() - .int() - .describe("Number of active facts soft-deleted by the bulk clear."), -}); - -const coachFactDeletedResponse = z.object({ - deleted: z - .boolean() - .describe( - "True when a fact owned by the caller was soft-deleted; false for an unknown / cross-user / already-deleted id (idempotent no-op).", - ), -}); - -// ── Cycle tracking (v1.15.0) ───────────────────────────────────────── -// The `/api/cycle/*` capture / calendar / history / settings surface + -// the cycle-prefs PATCH. Request bodies come from the Zod validation -// module so the spec stays single-source; response DTOs are declared -// here mirroring `src/lib/cycle/dto.ts`. Every `/api/cycle/*` route also -// 403s `{ errorCode:"cycle.disabled" }` when the feature gate is off. - -const predictionMethodEnumOpenapi = z - .enum(["CALENDAR", "SYMPTOTHERMAL", "TEMPERATURE_TREND", "BLENDED"]) - .meta({ id: "CyclePredictionMethod" }); - -const cyclePhaseEnumOpenapi = z - .enum(["MENSTRUAL", "FOLLICULAR", "OVULATORY", "LUTEAL"]) - .meta({ id: "CyclePhase" }); - -flowLevelEnum.meta({ - id: "FlowLevel", - description: "Menstrual-flow intensity.", -}); -ovulationTestEnum.meta({ - id: "OvulationTest", - description: "Ovulation predictor-kit (OPK) reading.", -}); -cervicalMucusEnum.meta({ - id: "CervicalMucus", - description: "Cervical-mucus quality.", -}); -homeTestResultEnum.meta({ - id: "HomeTestResult", - description: "At-home test result (pregnancy / progesterone).", -}); -cycleTrackingGoalEnum.meta({ - id: "CycleTrackingGoal", - description: "Drives cycle copy + fertile-window gating.", -}); - -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({ - 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({ - 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({ - id: "CyclePrefsRequest", - description: - "Partial cycle-preferences deep-merge. `enabled` flips the feature gate (`cycleTrackingEnabled`). Omitted fields are left untouched.", -}); - -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.", -}); - -const cycleSymptomDto = z.object({ - key: z.string(), - severity: z.number().int().min(1).max(4).nullable(), -}); - -const cycleDayLogDto = z - .object({ - id: z.string(), - date: z.string(), - cycleId: z.string().nullable(), - flow: flowLevelEnum.nullable(), - intermenstrualBleeding: z.boolean(), - basalBodyTempC: z.number().nullable(), - ovulationTest: ovulationTestEnum.nullable(), - cervicalMucus: cervicalMucusEnum.nullable(), - sexualActivity: z.boolean(), - protectedSex: z.boolean().nullable(), - pregnancyTest: homeTestResultEnum.nullable(), - progesteroneTest: homeTestResultEnum.nullable(), - contraceptive: z.string().nullable(), - symptoms: z.array(cycleSymptomDto), - note: z.string().nullable(), - source: z.string(), - externalId: z.string().nullable(), - syncVersion: z.number().int(), - updatedAt: z.iso.datetime({ offset: true }), - deletedAt: z.iso.datetime({ offset: true }).nullable(), - }) - .meta({ - id: "CycleDayLogDTO", - description: - "The canonical day-log row iOS mirrors. `note` is decrypted on read. Soft-deleted rows ride `/api/sync/changes` as tombstones.", - }); - -const menstrualCycleDto = z - .object({ - id: z.string(), - startDate: z.string(), - endDate: z.string().nullable(), - periodEndDate: z.string().nullable(), - lengthDays: z.number().int().nullable(), - ovulationDate: z.string().nullable(), - ovulationConfirmed: z.boolean(), - isPredicted: z.boolean(), - syncVersion: z.number().int(), - updatedAt: z.iso.datetime({ offset: true }), - }) - .meta({ - id: "MenstrualCycleDTO", - description: "One menstrual cycle (observed or forward-predicted).", - }); - -const cyclePredictionDto = z - .object({ - method: predictionMethodEnumOpenapi, - nextPeriodStart: z.string(), - nextPeriodStartLow: z.string(), - nextPeriodStartHigh: z.string(), - fertileWindowStart: z.string().nullable(), - fertileWindowEnd: z.string().nullable(), - predictedOvulation: z.string().nullable(), - ovulationConfirmed: z.boolean(), - confidence: z.number(), - cyclesObserved: z.number().int(), - stillLearning: z.boolean(), - disclaimer: z.string(), - }) - .meta({ - id: "CyclePredictionDTO", - description: - "The materialised forecast. Fertile-window fields (and predictedOvulation/ovulationConfirmed) are server-suppressed (null/false) unless the goal is TRYING_TO_CONCEIVE or AVOID_PREGNANCY.", - }); - -const cycleCalendarDayDto = z.object({ - date: z.string(), - phase: cyclePhaseEnumOpenapi.nullable(), - isPredictedPeriod: z.boolean(), - isFertileWindow: z.boolean(), - isPredictedOvulation: z.boolean(), - isPeriodLogged: z.boolean(), - flow: flowLevelEnum.nullable(), - hasSymptoms: z.boolean(), - confidence: z.number(), - // v1.15.0 — logged basal-body-temperature + fertility-sign markers, surfaced - // so the web BBT chart renders from the calendar read (the values are already - // loaded server-side for the symptothermal layer; no extra query). - basalBodyTempC: z.number().nullable(), - ovulationTest: ovulationTestEnum.nullable(), - cervicalMucus: cervicalMucusEnum.nullable(), -}); - -const cycleProfileDto = z - .object({ - goal: cycleTrackingGoalEnum, - cycleTrackingEnabled: z.boolean(), - rawChartMode: z.boolean(), - predictionEnabled: z.boolean(), - discreetNotifications: z.boolean(), - sensitiveCategoryEncryption: z.boolean(), - typicalCycleLength: z.number().int().nullable(), - typicalPeriodLength: z.number().int().nullable(), - lutealPhaseLength: z.number().int().nullable(), - updatedAt: z.iso.datetime({ offset: true }), - }) - .meta({ - id: "CycleProfileDTO", - description: "The full per-user cycle settings row.", - }); - -const cycleCalendarResponse = z.object({ - profile: z.object({ - goal: cycleTrackingGoalEnum, - rawChartMode: z.boolean(), - predictionEnabled: z.boolean(), - cyclesObserved: z.number().int(), - }), - prediction: cyclePredictionDto.nullable(), - days: z.array(cycleCalendarDayDto), - meta: z.object({ generatedAt: z.iso.datetime({ offset: true }) }), -}); - -const cycleHistoryResponse = z.object({ - cycles: z.array(menstrualCycleDto), - stats: z.object({ - avgLengthDays: z.number().int().nullable(), - lengthVariabilityDays: z.number().nullable(), - avgPeriodLengthDays: z.number().int().nullable(), - regularity: z.enum(["REGULAR", "IRREGULAR", "LEARNING"]), - }), -}); - -const cyclePeriodResponse = z.object({ - cycle: menstrualCycleDto.nullable(), - dayLog: cycleDayLogDto.nullable(), -}); - -const cyclePhaseCrosstabRow = z - .object({ - metricKey: z.enum([ - "restingHeartRate", - "heartRateVariability", - "sleepDuration", - "steps", - "weight", - "basalBodyTemp", - "wristTemperature", - "skinTemperature", - "bloodGlucose", - "mood", - ]), - display: z.enum([ - "hours", - "steps", - "bpm", - "ms", - "kg", - "celsius", - "glucose", - "mood", - ]), - lutealDays: z.number().int(), - follicularDays: z.number().int(), - lutealAvg: z.number(), - follicularAvg: z.number(), - delta: z.number(), - pValue: z.number(), - qValue: z.number(), - confidence: z.enum(["low", "medium", "high"]), - }) - .meta({ - id: "CyclePhaseCrosstabRow", - description: - "One FDR-surviving luteal-vs-follicular contrast for an outcome metric. `delta = lutealAvg − follicularAvg`. Observational, never causal.", - }); - -const cyclePhaseLaggedPair = z - .object({ - behaviour: z.string(), - outcome: z.string(), - n: z.number().int(), - r: z.number(), - pValue: z.number(), - qValue: z.number(), - interpretation: z.string(), - lagDays: z.number().int(), - }) - .meta({ - id: "CyclePhaseLaggedPair", - description: - "One FDR-surviving lagged-Pearson pair from the continuous CYCLE_PHASE ordinal × outcome matrix (mechanism B). Descriptive, never causal.", - }); - -const cyclePhaseEnumForCount = z.enum([ - "MENSTRUAL", - "FOLLICULAR", - "OVULATORY", - "LUTEAL", -]); - -const cycleSymptomPhaseRow = z - .object({ - symptomKey: z.string(), - counts: z.object({ - MENSTRUAL: z.number().int(), - FOLLICULAR: z.number().int(), - OVULATORY: z.number().int(), - LUTEAL: z.number().int(), - }), - total: z.number().int(), - topPhase: cyclePhaseEnumForCount, - topShare: z.number(), - }) - .meta({ - id: "CycleSymptomPhaseRow", - description: - "Where a logged symptom clusters across the cycle phases. Surfaced only once logged on ≥3 phase-labelled days. Observational, never causal.", - }); - -const cycleInsightsResponse = z.object({ - rows: z.array(cyclePhaseCrosstabRow), - headline: cyclePhaseCrosstabRow.nullable(), - lagged: z.object({ - discovered: z.array(cyclePhaseLaggedPair), - pairsTested: z.number().int(), - fdrQ: z.number(), - minPairs: z.number().int(), - }), - symptomPatterns: z.array(cycleSymptomPhaseRow), - contrast: z.object({ - high: z.literal("LUTEAL"), - low: z.literal("FOLLICULAR"), - }), - windowDays: z.number().int(), - cyclesObserved: z.number().int(), -}); - -const cycleBulkEntryResult = z.object({ - index: z.number().int(), - status: z.enum(["inserted", "duplicate", "updated", "skipped"]), - id: z.string().optional(), - externalId: z.string().optional(), - reason: z.string().optional(), -}); - -const cycleBulkResponse = z.object({ - processed: z.number().int(), - inserted: z.number().int(), - updated: z.number().int(), - duplicates: z.number().int(), - skipped: z.number().int(), - entries: z.array(cycleBulkEntryResult), -}); - -// A reusable 403 the cycle routes carry (the feature gate). -const cycleDisabledResponse = { - "403": { - description: - "Cycle tracking is not enabled for this account (errorCode `cycle.disabled`).", - content: { "application/json": { schema: errorEnvelope } }, - }, -} as const; - -// ── Path table ─────────────────────────────────────────────────────── - -export const openApiPaths: NonNullable = { - "/api/cycle/day-logs": { - get: { - tags: ["Cycle"], - summary: "Read a single day's cycle day-log (v1.15.0)", - description: - "Returns the full `CycleDayLogDTO` for the tz-anchored `date`, or `null` when nothing is logged that day. Lets a client pre-fill an edit sheet. Gated; owner-scoped; soft-deleted rows excluded.", - requestParams: { query: cycleDayLogQuerySchema }, - responses: { - "200": { - description: "The day-log for that date, or null.", - content: { - "application/json": { - schema: dataEnvelope( - cycleDayLogDto.nullable(), - "CycleDayLogReadEnvelope", - ), - }, - }, - }, - ...cycleDisabledResponse, - ...stdResponses, - }, - }, - post: { - tags: ["Cycle"], - summary: "Capture a single cycle day-log (v1.15.0)", - description: - "Upserts on `(userId, source, externalId)` when externalId present, else `(userId, date)`. `note` encrypts at rest. 201 on insert, 200 on update. Gated: `cycle.disabled` 403 when the feature is off.", - requestBody: { - required: true, - content: { "application/json": { schema: cycleDayLogInputSchema } }, - }, - responses: { - "200": { - description: "Existing day-log updated.", - content: { - "application/json": { - schema: dataEnvelope(cycleDayLogDto, "CycleDayLogEnvelope"), - }, - }, - }, - "201": { - description: "New day-log created.", - content: { - "application/json": { - schema: dataEnvelope( - cycleDayLogDto, - "CycleDayLogCreatedEnvelope", - ), - }, - }, - }, - ...cycleDisabledResponse, - ...stdResponses, - }, - }, - }, - "/api/cycle/day-logs/{id}": { - patch: { - tags: ["Cycle"], - summary: "Edit a single cycle day-log (v1.15.0)", - description: - "Partial edit; an omitted field is left untouched. Owner-scoped (a cross-user id 404s). Gated.", - requestParams: { path: z.object({ id: z.string() }) }, - requestBody: { - required: true, - content: { "application/json": { schema: cycleDayLogPatchSchema } }, - }, - responses: { - "200": { - description: "Day-log updated.", - content: { - "application/json": { - schema: dataEnvelope(cycleDayLogDto, "CycleDayLogPatchEnvelope"), - }, - }, - }, - "404": { - description: "Day-log not found / not owned.", - content: { "application/json": { schema: errorEnvelope } }, - }, - ...cycleDisabledResponse, - ...stdResponses, - }, - }, - delete: { - tags: ["Cycle"], - summary: "Soft-delete a cycle day-log (v1.15.0)", - description: - "Sets `deletedAt` + bumps `syncVersion`; surfaces as a tombstone on the next `/api/sync/changes` page. 204. Idempotent.", - requestParams: { path: z.object({ id: z.string() }) }, - responses: { - "204": { description: "Soft-deleted (no body)." }, - "404": { - description: "Day-log not found / not owned.", - content: { "application/json": { schema: errorEnvelope } }, - }, - ...cycleDisabledResponse, - ...stdResponses, - }, - }, - }, - "/api/cycle/day-logs/bulk": { - post: { - tags: ["Cycle"], - summary: "Bulk drain cycle day-logs (Outbox / HealthKit) (v1.15.0)", - description: - "Up to 500 entries; `withIdempotency`; rate-limited `cycle:day-logs:bulk:` 60/min. Per-entry status: inserted | duplicate | updated | skipped. Always 200.", - requestBody: { - required: true, - content: { "application/json": { schema: cycleBulkSchema } }, - }, - responses: { - "200": { - description: "Batch processed (per-entry results).", - content: { - "application/json": { - schema: dataEnvelope(cycleBulkResponse, "CycleBulkEnvelope"), - }, - }, - }, - ...cycleDisabledResponse, - ...stdResponses, - }, - }, - }, - "/api/cycle/period": { - post: { - tags: ["Cycle"], - summary: "Period-boundary shortcut (v1.15.0)", - description: - "One-tap started/ended period. `start` opens a new cycle (closing the prior); `end` stamps periodEndDate. Writes the boundary day-log. Gated.", - requestBody: { - required: true, - content: { "application/json": { schema: cyclePeriodSchema } }, - }, - responses: { - "200": { - description: "Cycle + boundary day-log.", - content: { - "application/json": { - schema: dataEnvelope(cyclePeriodResponse, "CyclePeriodEnvelope"), - }, - }, - }, - ...cycleDisabledResponse, - ...stdResponses, - }, - }, - }, - "/api/cycle/calendar": { - get: { - tags: ["Cycle"], - summary: "Predicted cycle calendar (v1.15.0)", - description: - "Runs the deterministic engine to build `{ profile, prediction, days }`. Fertile-window fields are server-suppressed unless goal is TRYING_TO_CONCEIVE. Default range: today − 90d … +180d. Gated.", - requestParams: { - query: z.object({ - from: z.string().optional(), - to: z.string().optional(), - }), - }, - responses: { - "200": { - description: "Calendar grid + forecast.", - content: { - "application/json": { - schema: dataEnvelope( - cycleCalendarResponse, - "CycleCalendarEnvelope", - ), - }, - }, - }, - ...cycleDisabledResponse, - ...stdResponses, - }, - }, - }, - "/api/cycle/cycles": { - get: { - tags: ["Cycle"], - summary: "Cycle history + stats (v1.15.0)", - description: - "Most-recent cycles (newest first) + `{ avgLengthDays, lengthVariabilityDays (MAD), avgPeriodLengthDays, regularity }`. `limit` default 24. Gated.", - requestParams: { - query: z.object({ - limit: z.coerce.number().int().min(1).max(60).optional(), - }), - }, - responses: { - "200": { - description: "Cycle history.", - content: { - "application/json": { - schema: dataEnvelope( - cycleHistoryResponse, - "CycleHistoryEnvelope", - ), - }, - }, - }, - ...cycleDisabledResponse, - ...stdResponses, - }, - }, - }, - "/api/cycle/insights": { - get: { - tags: ["Cycle"], - summary: "Cycle-phase correlation insights (v1.15.0)", - description: - "FDR-guarded luteal-vs-follicular phase contrast per outcome metric (RHR / HRV / sleep / steps / weight / temperatures), plus the single headline finding (resting-heart-rate-by-phase, falling back to HRV). The same Welch t-test + Benjamini-Hochberg machinery the mood-factor crosstab runs; only rows with p < 0.05 AND q ≤ 0.10 surface. Strictly gender-gated — phase never appears on the general `/api/insights/correlations` route. Observational only, never causal. Gated: `cycle.disabled` 403 when the feature is off.", - responses: { - "200": { - description: "Phase-correlation rows + headline.", - content: { - "application/json": { - schema: dataEnvelope( - cycleInsightsResponse, - "CycleInsightsEnvelope", - ), - }, - }, - }, - ...cycleDisabledResponse, - ...stdResponses, - }, - }, - }, - "/api/cycle/cycles/{id}": { - delete: { - tags: ["Cycle"], - summary: "Soft-delete a menstrual cycle (v1.15.0)", - description: - "Sets `deletedAt` + bumps `syncVersion`; tombstones on the next sync page. 204. Idempotent.", - requestParams: { path: z.object({ id: z.string() }) }, - responses: { - "204": { description: "Soft-deleted (no body)." }, - "404": { - description: "Cycle not found / not owned.", - content: { "application/json": { schema: errorEnvelope } }, - }, - ...cycleDisabledResponse, - ...stdResponses, - }, - }, - }, - "/api/cycle/all": { - delete: { - tags: ["Cycle"], - summary: "Hard-purge all cycle data (v1.15.0)", - description: - "One-click privacy purge: HARD-deletes every cycle day-log (+ symptom links by cascade), menstrual cycle, prediction, the cycle audit trail, and the cycle reminder rows in the push-attempts ledger — no dated reproductive trace survives. The CycleProfile row is left in place. Gated + owner-scoped + audited.", - responses: { - "200": { - description: "Purge counts.", - content: { - "application/json": { - schema: dataEnvelope( - z.object({ - purged: z.boolean(), - dayLogs: z.number().int(), - predictions: z.number().int(), - cycles: z.number().int(), - auditRows: z.number().int(), - pushRows: z.number().int(), - }), - "CyclePurgeEnvelope", - ), - }, - }, - }, - ...cycleDisabledResponse, - ...stdResponses, - }, - }, - }, - "/api/cycle/profile": { - get: { - tags: ["Cycle"], - summary: "Read the full cycle profile (v1.15.0)", - description: "Returns the resolved CycleProfileDTO. Gated.", - responses: { - "200": { - description: "Cycle profile.", - content: { - "application/json": { - schema: dataEnvelope(cycleProfileDto, "CycleProfileEnvelope"), - }, - }, - }, - ...cycleDisabledResponse, - ...stdResponses, - }, - }, - }, - "/api/auth/me/cycle-prefs": { - get: { - tags: ["Cycle"], - summary: "Read cycle preferences (v1.15.0)", - description: - "Returns the resolved CycleProfileDTO. NOT gated — this is the surface that flips the gate.", - responses: { - "200": { - description: "Cycle preferences.", - content: { - "application/json": { - schema: dataEnvelope(cycleProfileDto, "CyclePrefsGetEnvelope"), - }, - }, - }, - ...stdResponses, - }, - }, - patch: { - tags: ["Cycle"], - summary: "Update cycle preferences (v1.15.0)", - description: - "Deep-merges the supplied fields. `enabled` flips `cycleTrackingEnabled`. Returns the merged CycleProfileDTO. NOT gated.", - requestBody: { - required: true, - content: { "application/json": { schema: cyclePrefsSchema } }, - }, - responses: { - "200": { - description: "Merged cycle preferences.", - content: { - "application/json": { - schema: dataEnvelope(cycleProfileDto, "CyclePrefsPatchEnvelope"), - }, - }, - }, - ...stdResponses, - }, - }, - }, - "/api/meta/capabilities": { - get: { - tags: ["Meta"], - summary: "Live server capability / id-vocabulary discovery", - description: - "Returns the server's REAL id vocabularies (derived-metric ids, vitals-baseline types, layout tile-ids, metric-status ids, the HealthKit ingest mapping, the FHIR coding constants) plus the running API contract version. Every list is derived server-side from the canonical registry it documents, so a client can gate its UI / decoder against what the server actually ships rather than a hand-maintained copy. Auth via cookie or Bearer (not admin).", - responses: { - "200": { - description: "Capability snapshot.", - content: { - "application/json": { - schema: dataEnvelope( - capabilitiesResponse, - "CapabilitiesEnvelope", - ), - }, - }, - }, - ...stdResponses, - }, - }, - }, - "/api/export/health-record": { - post: { - tags: ["Export"], - summary: "Generate a health-record export (PDF / FHIR / package)", - description: - "v1.7.0 flagship export. Returns the doctor-handover artefact in the requested `format`: `pdf` → application/pdf, `fhir` → application/fhir+json (HL7 FHIR R4 document Bundle), `package` → application/zip (PDF + FHIR + README). Auth via cookie or Bearer; shared `export:` rate bucket (10/h). Strict validation: unknown keys 422.", - requestBody: { - required: true, - content: { - "application/json": { schema: exportSelectionSchema }, - }, - }, - responses: { - "200": { - description: - "Export generated. Content-Type varies by `format`: application/pdf, application/fhir+json, or application/zip.", - content: { - "application/pdf": { - schema: z.string().meta({ format: "binary" }), - }, - "application/fhir+json": { - schema: z.string().meta({ format: "binary" }), - }, - "application/zip": { - schema: z.string().meta({ format: "binary" }), - }, - }, - }, - ...stdResponses, - }, - }, - }, - "/api/share-links": { - post: { - tags: ["Export"], - summary: "Create a clinician share link (v1.11.0)", - description: - "Owner-only. Mints an `hls_` token (192-bit), stores only its HMAC hash, and returns the raw token EXACTLY ONCE in the response. Every scope column (window, sections, FHIR resource types, API toggle) is frozen write-once. `expiresAt` is required and capped at 90 days. Auth via cookie or Bearer; rate-limited (`share-link:`, 20/h). Strict: unknown keys 422.", - requestBody: { - required: true, - content: { - "application/json": { schema: createShareLinkSchema }, - }, - }, - responses: { - "201": { - description: - "Share link created. `token` carries the raw `hls_` value and is unrecoverable after this response.", - content: { - "application/json": { - schema: dataEnvelope( - shareLinkCreatedResponse, - "ShareLinkCreated", - ), - }, - }, - }, - ...stdResponses, - }, - }, - get: { - tags: ["Export"], - summary: "List own clinician share links (v1.11.0)", - description: - "Owner-only. Returns the caller's own share links (never the raw token — it is unrecoverable after creation). Auth via cookie or Bearer.", - responses: { - "200": { - description: "Share links owned by the caller.", - content: { - "application/json": { - schema: dataEnvelope(shareLinkListResponse, "ShareLinkList"), - }, - }, - }, - ...stdResponses, - }, - }, - }, - "/api/share-links/{id}": { - delete: { - tags: ["Export"], - summary: "Revoke a clinician share link (v1.11.0)", - description: - "Owner-only. Sets `revokedAt` on the caller's own link. A cross-user or unknown id is sealed as 404. Auth via cookie or Bearer; rate-limited.", - requestParams: { path: z.object({ id: z.string() }) }, - responses: { - "200": { - description: "Link revoked.", - content: { - "application/json": { - schema: dataEnvelope( - shareLinkRevokedResponse, - "ShareLinkRevoked", - ), - }, - }, - }, - "404": { - description: "Link not found (or owned by another user).", - content: { "application/json": { schema: errorEnvelope } }, - }, - ...stdResponses, - }, - }, - }, - "/api/fhir/metadata": { - get: { - tags: ["FHIR"], - summary: "FHIR R4 CapabilityStatement (v1.11.0)", - description: - "Read-only FHIR R4 capability statement for the REST face. Declares the served resource types (Patient, Observation, MedicationStatement, MedicationAdministration), the `$everything` operation, and the `application/fhir+json` format. Auth: `fhir:read` scope (cookie sessions also pass).", - responses: { - "200": { - description: "CapabilityStatement (application/fhir+json).", - content: { - "application/fhir+json": { - schema: z.string().meta({ format: "binary" }), - }, - }, - }, - ...stdResponses, - }, - }, - }, - "/api/fhir/Patient": { - get: { - tags: ["FHIR"], - summary: "FHIR R4 Patient search (v1.11.0)", - description: - "Read-only `searchset` Bundle of the caller's own Patient resource. Auth: `fhir:read` scope. Offset paging via `_count` (clamped ≤200) / `_offset`. `userId` is narrowed from auth.", - requestParams: { - query: z.object({ - _count: z.coerce.number().optional(), - _offset: z.coerce.number().optional(), - }), - }, - responses: { - "200": { - description: "searchset Bundle (application/fhir+json).", - content: { - "application/fhir+json": { - schema: z.string().meta({ format: "binary" }), - }, - }, - }, - ...stdResponses, - }, - }, - }, - "/api/fhir/Observation": { - get: { - tags: ["FHIR"], - summary: "FHIR R4 Observation search (v1.11.0)", - description: - "Read-only `searchset` Bundle of the caller's own Observations (vitals / activity / lab / survey). Auth: `fhir:read` scope. Offset paging via `_count` (clamped ≤200) / `_offset`.", - requestParams: { - query: z.object({ - _count: z.coerce.number().optional(), - _offset: z.coerce.number().optional(), - }), - }, - responses: { - "200": { - description: "searchset Bundle (application/fhir+json).", - content: { - "application/fhir+json": { - schema: z.string().meta({ format: "binary" }), - }, - }, - }, - ...stdResponses, - }, - }, - }, - "/api/fhir/MedicationStatement": { - get: { - tags: ["FHIR"], - summary: "FHIR R4 MedicationStatement search (v1.11.0)", - description: - "Read-only `searchset` Bundle of the caller's own active-medication statements. Auth: `fhir:read` scope. Offset paging via `_count` (≤200) / `_offset`.", - requestParams: { - query: z.object({ - _count: z.coerce.number().optional(), - _offset: z.coerce.number().optional(), - }), - }, - responses: { - "200": { - description: "searchset Bundle (application/fhir+json).", - content: { - "application/fhir+json": { - schema: z.string().meta({ format: "binary" }), - }, - }, - }, - ...stdResponses, - }, - }, - }, - "/api/fhir/MedicationAdministration": { - get: { - tags: ["FHIR"], - summary: "FHIR R4 MedicationAdministration search (v1.11.0)", - description: - "Read-only `searchset` Bundle of the caller's own acted intakes (completed / not-done). Auth: `fhir:read` scope. Offset paging via `_count` (≤200) / `_offset`.", - requestParams: { - query: z.object({ - _count: z.coerce.number().optional(), - _offset: z.coerce.number().optional(), - }), - }, - responses: { - "200": { - description: "searchset Bundle (application/fhir+json).", - content: { - "application/fhir+json": { - schema: z.string().meta({ format: "binary" }), - }, - }, - }, - ...stdResponses, - }, - }, - }, - "/api/fhir/$everything": { - get: { - tags: ["FHIR"], - summary: "FHIR R4 $everything (v1.11.0)", - description: - "Read-only `$everything` operation: every resource in the caller's own record (Patient, Coverage, Observations, MedicationStatements, MedicationAdministrations) in one `searchset` Bundle. Auth: `fhir:read` scope. Offset paging via `_count` (≤200) / `_offset`.", - requestParams: { - query: z.object({ - _count: z.coerce.number().optional(), - _offset: z.coerce.number().optional(), - }), - }, - responses: { - "200": { - description: "searchset Bundle (application/fhir+json).", - content: { - "application/fhir+json": { - schema: z.string().meta({ format: "binary" }), - }, - }, - }, - ...stdResponses, - }, - }, - }, - "/api/auth/login": { - post: { - tags: ["Auth"], - summary: "Email-or-username login (password)", - description: - "Browser callers receive a session cookie. Native callers (X-Client-Type: native or HealthLog-iOS UA prefix) additionally receive a paired access + refresh token.", - requestBody: { - required: true, - content: { "application/json": { schema: loginPasswordSchema } }, - }, - responses: { - "200": { - description: "Login succeeded.", - content: { - "application/json": { - schema: dataEnvelope(accessRefreshBundle, "LoginResponse"), - }, - }, - }, - ...stdResponses, - }, - }, - }, - "/api/auth/passkey/login-verify": { - post: { - tags: ["Auth"], - summary: "Passkey assertion verification", - requestBody: { - required: true, - content: { "application/json": { schema: passkeyLoginVerifyRequest } }, - }, - responses: { - "200": { - description: "Assertion verified — session + optional bearer issued.", - content: { - "application/json": { - schema: dataEnvelope( - accessRefreshBundle, - "PasskeyLoginVerifyResponse", - ), - }, - }, - }, - ...stdResponses, - }, - }, - }, - "/api/auth/refresh": { - post: { - tags: ["Auth"], - summary: "Rotate refresh token (one-time use)", - description: - "Reuse of a consumed refresh token revokes every refresh token still active for the originating device (per-device blast radius from v1.4.23). Legacy tokens issued before v1.4.23 with a null deviceId fall back to revoke-all-for-user.\n\n" + - "On a 401, `meta.errorCode` is a stable machine code so the client can branch terminal re-auth from a transient blip without parsing the prose `error`: `auth.refresh.reuse` (a consumed token was replayed — device family revoked, re-pair required), `auth.refresh.revoked` (family revoked out-of-band — re-pair required), `auth.refresh.invalid` (not found / expired — drop the token and re-authenticate).", - requestBody: { - required: true, - content: { "application/json": { schema: refreshRequest } }, - }, - responses: { - "200": { - description: "Rotation succeeded — new pair issued.", - content: { - "application/json": { - schema: dataEnvelope(accessRefreshBundle, "RefreshResponse"), - }, - }, - }, - ...stdResponses, - }, - }, - }, - "/api/sync/state": { - get: { - tags: ["Sync"], - summary: "Sync handshake + window metadata", - description: - "Cheap 'should I sync?' summary. Returns the previous `lastSyncedAt` checkpoint and advances it server-side on each call. The `sync` block carries the incremental-delta window + tombstone retention so the client reads them rather than hardcoding.", - responses: { - "200": { - description: "Sync state summary.", - content: { - "application/json": { - schema: dataEnvelope(syncStateResponse, "SyncStateEnvelope"), - }, - }, - }, - ...stdResponses, - }, - }, - }, - "/api/sync/changes": { - get: { - tags: ["Sync"], - summary: "Measurements delta feed", - description: - "Incremental catch-up after the first-pair backfill (never a replacement for it). Pages over an opaque keyset cursor; each page carries `tombstones` (soft-deleted rows, keyed on `externalId`) and `upserts` (live rows). Apply tombstones before upserts within a page. `cursorExpired: true` forces a clean re-init.", - requestParams: { - query: syncChangesQuery, - }, - responses: { - "200": { - description: "Delta page.", - content: { - "application/json": { - schema: dataEnvelope(syncChangesResponse, "SyncChangesEnvelope"), - }, - }, - }, - ...stdResponses, - }, - }, - }, - "/api/measurements": { - get: { - tags: ["Measurements"], - summary: "List measurements", - description: - "Filter by type + date range. Response is paged via offset/limit; default limit 100, hard cap 500.", - requestParams: { - query: listMeasurementsSchema, - }, - responses: { - "200": { - description: "Measurement page.", - content: { - "application/json": { - schema: dataEnvelope( - z.object({ - measurements: z.array(measurementResource), - total: z.number().int().nonnegative(), - }), - "ListMeasurementsResponse", - ), - }, - }, - }, - ...stdResponses, - }, - }, - post: { - tags: ["Measurements"], - summary: "Create one measurement", - description: - "Single ingest. Use `/api/measurements/batch` for Apple Health upload streams.", - requestBody: { - required: true, - content: { "application/json": { schema: createMeasurementSchema } }, - }, - responses: { - "201": { - description: "Created.", - content: { - "application/json": { - schema: dataEnvelope( - measurementResource, - "CreateMeasurementResponse", - ), - }, - }, - }, - ...stdResponses, - }, - }, - }, - "/api/measurements/batch": { - post: { - tags: ["Measurements"], - summary: "Apple Health batch ingest", - description: - "Up to 500 HealthKit entries per call. Idempotent via the `Idempotency-Key` header (replay window 24h). Per-entry status lets the iOS client advance its sync cursor accurately. v1.4.25 W8c adds an optional `deviceType` per entry — feed it from `HKDevice.model` so the cross-source canonical picker can break Apple-Watch-vs-iPhone ties; null/absent stays backward-compatible with v1.4.23 clients.", - requestBody: { - required: true, - content: { "application/json": { schema: batchPayloadSchema } }, - }, - responses: { - "200": { - description: "Batch processed.", - content: { - "application/json": { - schema: dataEnvelope(batchResponse, "BatchMeasurementsResponse"), - }, - }, - }, - ...stdResponses, - }, - }, - }, - "/api/measurements/by-external-ids": { - delete: { - tags: ["Measurements"], - summary: "Delete measurements by external ID (iOS deletion-sync)", - description: - "Removes the user's measurement rows whose externalId is in the request list. Rows owned by another user are silently skipped (cross-user 404 guard). Up to 500 externalIds per call.", - requestBody: { - required: true, - content: { - "application/json": { schema: deleteByExternalIdsRequest }, - }, - }, - responses: { - "200": { - description: "Delete batch processed.", - content: { - "application/json": { - schema: dataEnvelope( - deleteByExternalIdsResponse, - "MeasurementsDeleteByExternalIdsResponse", - ), - }, - }, - }, - ...stdResponses, - }, - }, - }, - "/api/measurements/bulk-delete": { - post: { - tags: ["Measurements"], - summary: "Bulk soft-delete measurements", - description: - "Soft-deletes (tombstones) up to 200 of the caller's measurement rows in one call, mirroring the single-DELETE contract. Idempotent via the `Idempotency-Key` header; rate-limited `measurements:bulk-delete:`. Forged / foreign / already-deleted ids are silently skipped — `deleted` is the count of rows actually tombstoned. The deletions surface in `/api/sync/changes` as tombstones.", - requestBody: { - required: true, - content: { - "application/json": { schema: bulkDeleteMeasurementsRequest }, - }, - }, - responses: { - "200": { - description: "Bulk delete processed.", - content: { - "application/json": { - schema: dataEnvelope( - bulkDeleteMeasurementsResponse, - "MeasurementsBulkDeleteResponse", - ), - }, - }, - }, - ...stdResponses, - }, - }, - }, - "/api/sleep/night": { - get: { - tags: ["Measurements"], - summary: "Reconstructed sleep night (hypnogram source, v1.11.5)", - description: - "Returns one night's reconstructed sleep session for the phase-progression (hypnogram) view: the canonical source's stage segments (each with an absolute start/end), the per-stage breakdown, asleep/in-bed/awake totals, the mid-sleep awakenings count, and same-wake-day naps surfaced separately. Session-clustered, keyed by the local wake day, collapsed to one source via the sleep priority ladder so two sources never overlay. `date` omitted returns the most recent night. A read-only view over existing per-stage SLEEP_DURATION rows — no schema, no new measurement type.", - requestParams: { - query: z.object({ - date: z - .string() - .regex(/^\d{4}-\d{2}-\d{2}$/) - .optional() - .describe( - "Wake-day key (YYYY-MM-DD). Omit for the most recent night.", - ), - }), - }, - responses: { - "200": { - description: "Reconstructed night.", - content: { - "application/json": { - schema: dataEnvelope(sleepNightResponse, "SleepNightResponse"), - }, - }, - }, - ...stdResponses, - }, - }, - }, - "/api/workouts": { - get: { - tags: ["Measurements"], - summary: "List workouts (v1.4.32)", - description: - "Paginated workout list with cross-source canonical-row dedup. The picker reads the per-user source-priority ladder and buckets by `(startedAt 5 min slot, sportType)` — twin workouts (Apple Watch + Withings ScanWatch) collapse to a single canonical row per cluster. Field names mirror the iOS handoff contract: `distanceM`, `activeEnergyKcal`, `avgHr`, `maxHr`.", - requestParams: { - query: z.object({ - limit: z.coerce.number().int().min(1).max(200).optional(), - offset: z.coerce.number().int().min(0).optional(), - since: z.iso.datetime({ offset: true }).optional(), - until: z.iso.datetime({ offset: true }).optional(), - sportType: z.string().optional(), - }), - }, - responses: { - "200": { - description: "Canonical workout page.", - content: { - "application/json": { - schema: dataEnvelope(workoutListResponse, "ListWorkoutsResponse"), - }, - }, - }, - ...stdResponses, - }, - }, - }, - "/api/workouts/{id}": { - get: { - tags: ["Measurements"], - summary: "Workout detail (v1.4.32)", - description: - "Single-workout envelope. Owns the optional `WorkoutRoute` GeoJSON geometry + `canonicalId` pointer that resolves to the cluster winner so deep-links into non-canonical twin rows can redirect cleanly. Cross-user rows surface as 404 (existence channel sealed).", - requestParams: { - path: z.object({ id: z.string() }), - }, - responses: { - "200": { - description: "Workout detail.", - content: { - "application/json": { - schema: dataEnvelope(workoutDetailResponse, "GetWorkoutResponse"), - }, - }, - }, - "404": { - description: "Workout not found (or owned by another user).", - content: { "application/json": { schema: errorEnvelope } }, - }, - ...stdResponses, - }, - }, - }, - "/api/workouts/batch": { - post: { - tags: ["Measurements"], - summary: "Typed workout batch ingest (v1.4.25 W16b)", - description: - "Server-side ingest endpoint for HKWorkout records (iOS) and Withings activity rows (server-to-server). Up to 100 workouts per call; nested route geometry (GeoJSON LineString) is capped at 20 000 points and stored in a 1:1 `WorkoutRoute` row keyed by `workoutId`. Idempotent via the `Idempotency-Key` header (replay window 24h). Per-entry status (`inserted | duplicate | skipped`) lets the iOS sync cursor checkpoint accurately. The request body ceiling is 5 MB enforced at the HTTP layer — clients above the ceiling receive a 413 with `workout.batch.payload_too_large`.", - requestBody: { - required: true, - content: { "application/json": { schema: createBatchWorkoutSchema } }, - }, - responses: { - "200": { - description: "Batch processed.", - content: { - "application/json": { - schema: dataEnvelope( - workoutBatchResponse, - "BatchWorkoutsResponse", - ), - }, - }, - }, - "400": { - description: - "Batch validation failed (over-cap, schema reject, or oversized route).", - content: { "application/json": { schema: errorEnvelope } }, - }, - "413": { - description: "Request body exceeds the 5 MB ceiling.", - content: { "application/json": { schema: errorEnvelope } }, - }, - ...stdResponses, - }, - }, - }, - "/api/devices": { - post: { - tags: ["Devices"], - summary: "Register native device + APNs token", - description: - "Idempotent upsert by `token`. APNs token + environment are paired — supplying one without the other returns 422. Cross-user re-registration of either identifier returns 409.", - requestBody: { - required: true, - content: { "application/json": { schema: deviceRegisterRequest } }, - }, - responses: { - "201": { - description: "Device registered or refreshed.", - content: { - "application/json": { - schema: dataEnvelope( - z.object({ id: z.string() }), - "DeviceRegisterResponse", - ), - }, - }, - }, - "409": { - description: - "Device or APNs token already registered to another user.", - content: { "application/json": { schema: errorEnvelope } }, - }, - ...stdResponses, - }, - }, - }, - "/api/medications": { - get: { - tags: ["Medications"], - summary: "List medications for the calling user", - description: - "Returns every medication owned by the caller (active + paused), ordered by `createdAt DESC`. Each row carries its nested `schedules`, the joined clinical `category`, the latest non-skipped `lastTakenAt`, and the count of intake events scheduled for today (`todayEventCount`). The response is cached server-side for 60 s per user; writes flush the cache.", - responses: { - "200": { - description: "Medication list.", - content: { - "application/json": { - schema: dataEnvelope( - z.array(medicationListEntry), - "ListMedicationsResponse", - ), - }, - }, - }, - ...stdResponses, - }, - }, - post: { - tags: ["Medications"], - summary: "Create a medication with at least one schedule", - description: - "Validates the body against `CreateMedicationRequest`, applies the v1.5 cross-field invariants (one-shot consistency, recurring default `FREQ=DAILY`, `timesOfDay` dual-write), and creates the medication + its schedules in a single Prisma write. Audits as `medication.create`.", - requestBody: { - required: true, - content: { "application/json": { schema: createMedicationSchema } }, - }, - responses: { - "201": { - description: "Created medication with its schedules.", - content: { - "application/json": { - schema: dataEnvelope( - medicationDetailEntry, - "CreateMedicationResponse", - ), - }, - }, - }, - ...stdResponses, - }, - }, - }, - "/api/medications/{id}": { - get: { - tags: ["Medications"], - summary: "Fetch a single medication", - description: - "Returns the medication + its schedules + the joined `category`. Cross-user rows surface as 404 (existence channel sealed).", - requestParams: { - path: z.object({ id: z.string() }), - }, - responses: { - "200": { - description: "Medication detail.", - content: { - "application/json": { - schema: dataEnvelope( - medicationDetailEntry, - "GetMedicationResponse", - ), - }, - }, - }, - "404": { - description: "Medication not found (or owned by another user).", - content: { "application/json": { schema: errorEnvelope } }, - }, - ...stdResponses, - }, - }, - put: { - tags: ["Medications"], - summary: "Replace a medication (partial fields)", - description: - "Every field on the body is optional; omitted fields are left untouched. Supplying `schedules` REPLACES the medication's full schedule list (the route deletes existing rows before re-creating). Flipping `active` to false stamps `pausedAt`; flipping back to true clears it. v1.5 invariants on the `schedules` array match `POST /api/medications`. Audits as `medication.update`.", - requestParams: { - path: z.object({ id: z.string() }), - }, - requestBody: { - required: true, - content: { "application/json": { schema: updateMedicationSchema } }, - }, - responses: { - "200": { - description: "Updated medication.", - content: { - "application/json": { - schema: dataEnvelope( - medicationDetailEntry, - "UpdateMedicationResponse", - ), - }, - }, - }, - "404": { - description: "Medication not found (or owned by another user).", - content: { "application/json": { schema: errorEnvelope } }, - }, - ...stdResponses, - }, - }, - delete: { - tags: ["Medications"], - summary: "Delete a medication", - description: - "Cascades to the medication's schedules, intake events, dose changes, inventory rows, and side-effect logs. Revokes every API token scoped to `medication::ingest`. Audits as `medication.delete`.", - requestParams: { - path: z.object({ id: z.string() }), - }, - responses: { - "200": { - description: "Deletion succeeded.", - content: { - "application/json": { - schema: dataEnvelope( - z.object({ deleted: z.boolean() }), - "DeleteMedicationResponse", - ), - }, - }, - }, - "404": { - description: "Medication not found (or owned by another user).", - content: { "application/json": { schema: errorEnvelope } }, - }, - ...stdResponses, - }, - }, - }, - "/api/medications/{id}/intake": { - post: { - tags: ["Medications"], - summary: "Log an intake event for a medication", - description: - "Records a taken or skipped dose. Idempotent via the `Idempotency-Key` header AND the optional `idempotencyKey` body field (the route walks both paths); a re-post inside the 60 s server-side dedup window returns the original event. Non-skipped intakes auto-decrement pen inventory (best-effort), refresh the per-day compliance rollup, and — for `oneShot:true` medications — flip `active` to false.", - requestParams: { - path: z.object({ id: z.string() }), - }, - requestBody: { - required: true, - content: { "application/json": { schema: intakeSchema } }, - }, - responses: { - "201": { - description: "Intake event created.", - content: { - "application/json": { - schema: dataEnvelope( - medicationIntakeEventResource, - "CreateMedicationIntakeResponse", - ), - }, - }, - }, - "200": { - description: - "Idempotent replay — the original event is returned without creating a new row.", - content: { - "application/json": { - schema: dataEnvelope( - medicationIntakeEventResource, - "ReplayMedicationIntakeResponse", - ), - }, - }, - }, - "404": { - description: "Medication not found (or owned by another user).", - content: { "application/json": { schema: errorEnvelope } }, - }, - ...stdResponses, - }, - }, - }, - "/api/medications/extract": { - post: { - tags: ["Medications"], - summary: - "Extract scheduling fields from a free-text medication description", - description: - "Runs the user's free-text description through the Coach provider chain and returns a citation-guarded partial payload the wizard merges onto whatever the user already typed. `name` and `dose` are dropped when not substring-matched in the original text so the wizard cannot land a hallucinated brand or dose. `cadenceKind` / `doseUnit` / `weekdays` are closed enums; numeric fields are clamped. Rate-limited 10 requests / 5 minutes / user, gated against the daily Coach token budget.", - requestBody: { - required: true, - content: { - "application/json": { schema: medicationExtractRequest }, - }, - }, - responses: { - "200": { - description: "Citation-guarded partial extraction.", - content: { - "application/json": { - schema: dataEnvelope( - medicationExtractionSchema, - "MedicationExtractResponse", - ), - }, - }, - }, - "502": { - description: - "Upstream provider returned an empty, unparseable, or off-schema reply.", - content: { "application/json": { schema: errorEnvelope } }, - }, - "503": { - description: - "No AI provider configured for the calling user (or operator).", - content: { "application/json": { schema: errorEnvelope } }, - }, - ...stdResponses, - }, - }, - }, - "/api/medications/{id}/cadence": { - get: { - tags: ["Medications"], - summary: "Cadence + compliance read for a medication", - description: - "Returns the expected-vs-actual dose timeline for the requested window plus the four compliance chip values that drive the detail-page section. Pure computation — no writes. Day boundaries are resolved in the user's IANA timezone so a Tokyo user and a Berlin user see the same chips for the same medication. The `days` query parameter caps at 180.", - requestParams: { - path: z.object({ id: z.string() }), - query: z.object({ - days: z.coerce - .number() - .int() - .min(1) - .max(180) - .optional() - .describe("Window size in days (default 30, max 180)."), - }), - }, - responses: { - "200": { - description: "Cadence response.", - content: { - "application/json": { - schema: dataEnvelope( - medicationCadenceResponse, - "GetMedicationCadenceResponse", - ), - }, - }, - }, - "404": { - description: "Medication not found (or owned by another user).", - content: { "application/json": { schema: errorEnvelope } }, - }, - ...stdResponses, - }, - }, - }, - "/api/medications/{id}/compliance": { - get: { - tags: ["Medications"], - summary: "Adherence read for a medication", - description: - "Returns the 7- and 30-day adherence summaries, the per-day compliance grid for the history glyph track, and the two-row display block. Pure computation — no writes. Day boundaries are resolved in the user's IANA timezone, and the expected-dose denominator is cadence-aware (RRULE / rolling / one-shot / PRN / cyclic) and clamped to the medication's `createdAt`. Read `compliance30` for the headline 30-day taken-vs-expected percentage; build the per-day glyph track from `dailyCompliance` (draw a cell only where `due === true`).", - requestParams: { - path: z.object({ id: z.string() }), - }, - responses: { - "200": { - description: "Compliance response.", - content: { - "application/json": { - schema: dataEnvelope( - medicationComplianceResponse, - "GetMedicationComplianceResponse", - ), - }, - }, - }, - "404": { - description: "Medication not found (or owned by another user).", - content: { "application/json": { schema: errorEnvelope } }, - }, - ...stdResponses, - }, - }, - }, - "/api/auth/me/coach-prefs": { - get: { - tags: ["Auth"], - summary: "Read per-user Coach prompt-tuning preferences", - description: - "Returns the persisted preferences with defaults filled in. Null persisted state resolves to the legacy v1.4.22 defaults.", - responses: { - "200": { - description: "Resolved preferences.", - content: { - "application/json": { - schema: dataEnvelope(coachPrefsSchema, "GetCoachPrefsResponse"), - }, - }, - }, - ...stdResponses, - }, - }, - put: { - tags: ["Auth"], - summary: "Replace per-user Coach prompt-tuning preferences", - description: - "Persists the supplied prefs. Body is validated against `CoachPrefs`; missing keys fall back to the documented defaults.", - requestBody: { - required: true, - content: { "application/json": { schema: coachPrefsSchema } }, - }, - responses: { - "200": { - description: "Saved preferences echoed back.", - content: { - "application/json": { - schema: dataEnvelope(coachPrefsSchema, "PutCoachPrefsResponse"), - }, - }, - }, - ...stdResponses, - }, - }, - }, - "/api/auth/me/disable-coach": { - get: { - tags: ["Auth"], - summary: "Read per-user Coach opt-out flag", - description: - 'Returns the user\'s per-account Coach opt-out flag. Default `false` (Coach visible). Powers the Settings → Insights "Hide Coach" Switch and the layout FAB short-circuit.', - responses: { - "200": { - description: "Resolved flag.", - content: { - "application/json": { - schema: dataEnvelope(disableCoachFlag, "GetDisableCoachResponse"), - }, - }, - }, - ...stdResponses, - }, - }, - patch: { - tags: ["Auth"], - summary: "Toggle per-user Coach opt-out flag", - description: - "Flips the per-account Coach opt-out flag. Idempotent — the DB write fires even when the value matches so the audit-log row mirrors the API call. Rate-limit 60/min per user.", - requestBody: { - required: true, - content: { "application/json": { schema: disableCoachFlag } }, - }, - responses: { - "200": { - description: - "Resolved next-state echoed back for optimistic-update consumers.", - content: { - "application/json": { - schema: dataEnvelope( - disableCoachFlag, - "PatchDisableCoachResponse", - ), - }, - }, - }, - ...stdResponses, - }, - }, - }, - "/api/auth/me/source-priority": { - get: { - tags: ["Auth"], - summary: "Read per-user source priority", - description: - "Returns the per-metric-class source priority used by the analytics aggregator. Missing keys fall back to `DEFAULT_SOURCE_PRIORITY`. Null persisted state resolves to the documented defaults.", - responses: { - "200": { - description: "Resolved priority.", - content: { - "application/json": { - schema: dataEnvelope( - sourcePrioritySchema, - "GetSourcePriorityResponse", - ), - }, - }, - }, - ...stdResponses, - }, - }, - put: { - tags: ["Auth"], - summary: "Replace per-user source priority", - description: - "Persists the supplied priority. Body is validated against `SourcePriority`; missing keys read as `DEFAULT_SOURCE_PRIORITY` at the call site.", - requestBody: { - required: true, - content: { "application/json": { schema: sourcePrioritySchema } }, - }, - responses: { - "200": { - description: "Saved priority (defaulted) echoed back.", - content: { - "application/json": { - schema: dataEnvelope( - sourcePrioritySchema, - "PutSourcePriorityResponse", - ), - }, - }, - }, - ...stdResponses, - }, - }, - }, - "/api/user/avatar": { - post: { - tags: ["Auth"], - summary: "Upload the calling user's avatar", - description: - "Multipart upload (field `file`). Accepts image/jpeg, image/png, image/webp; rejects anything else at the magic-byte sniff (the multipart Content-Type header is informational only). Hard caps: 2 MiB body, 2048×2048 dimensions. Replaces the v1.4.22 Gravatar leak by storing the bytes on the User row + serving them from same-origin.", - requestBody: { - required: true, - content: { - "multipart/form-data": { - schema: z.object({ - file: z - .string() - .describe( - "Binary image payload (JPEG / PNG / WebP). Hard-capped at 2 MiB and 2048×2048.", - ), - }), - }, - }, - }, - responses: { - "201": { - description: - "Avatar saved. `avatarUrl` is the same value the /me payload returns; the `?v=` suffix busts client caches on re-upload.", - content: { - "application/json": { - schema: dataEnvelope( - z.object({ - avatarUrl: z.string(), - contentType: z.enum([ - "image/jpeg", - "image/png", - "image/webp", - ]), - updatedAt: z.iso.datetime({ offset: true }), - }), - "UploadAvatarResponse", - ), - }, - }, - }, - "413": { - description: - "Upload exceeds the 2 MiB byte limit or the 2048×2048 dimension limit.", - content: { "application/json": { schema: errorEnvelope } }, - }, - "415": { - description: - "Image format is not one of JPEG / PNG / WebP (magic-byte sniff).", - content: { "application/json": { schema: errorEnvelope } }, - }, - ...stdResponses, - }, - }, - delete: { - tags: ["Auth"], - summary: "Clear the calling user's avatar", - description: - "Resets the avatar columns to null. Idempotent — a delete on an already-empty row returns 204 with no audit-log row.", - responses: { - "204": { - description: "Avatar cleared (or already empty).", - }, - ...stdResponses, - }, - }, - }, - "/api/user/avatar/{id}": { - get: { - tags: ["Auth"], - summary: "Serve a user's avatar bytes", - description: - "Owner-scoped. The `{id}` path segment must match the calling user's id — cross-user reads return 403. Response body is the raw image bytes with the persisted `Content-Type`; the /me payload appends `?v={updatedAtMs}` so clients can cache aggressively.", - requestParams: { - path: z.object({ - id: z.string().describe("User id (must match the calling user)."), - }), - }, - responses: { - "200": { - description: "Avatar bytes.", - content: { - "image/jpeg": { schema: z.string().describe("Raw JPEG bytes.") }, - "image/png": { schema: z.string().describe("Raw PNG bytes.") }, - "image/webp": { schema: z.string().describe("Raw WebP bytes.") }, - }, - }, - "403": { - description: "Caller is not the avatar's owner.", - content: { "application/json": { schema: errorEnvelope } }, - }, - "404": { - description: "The user has no uploaded avatar.", - content: { "application/json": { schema: errorEnvelope } }, - }, - ...stdResponses, - }, - }, - }, - "/api/user/profile": { - get: { - tags: ["Auth"], - summary: "Read the calling user's profile", - description: - "Flattened profile fields for the native client. Aliased over the same data exposed by /api/auth/me. The KVNR is decrypted server-side; the IKNR (v1.8.6) is returned plaintext.", - responses: { - "200": { - description: "The calling user's profile.", - content: { - "application/json": { - schema: dataEnvelope(profileResponse, "GetProfileResponse"), - }, - }, - }, - ...stdResponses, - }, - }, - patch: { - tags: ["Auth"], - summary: "Update the calling user's profile", - description: - "Partial profile update — every field is optional. An explicit null (or empty string) clears a field. A non-empty `insurerIkNumber` must be exactly 9 digits (422 otherwise); a non-empty `insuranceNumber` (KVNR) must pass the mod-10 check. `userId` is never accepted from the body.", - requestBody: { - required: true, - content: { "application/json": { schema: profileUpdateRequest } }, - }, - responses: { - "200": { - description: "Saved profile echoed back.", - content: { - "application/json": { - schema: dataEnvelope( - profileUpdateResponse, - "PatchProfileResponse", - ), - }, - }, - }, - ...stdResponses, - }, - }, - }, - "/api/insights/layout": { - get: { - tags: ["Insights"], - summary: "Read the calling user's Insights tile layout", - description: - "Returns the per-user Insights tile layout (visibility + order). Falls back to the default layout when the user has not customised it. Mirrors the dashboard-widgets contract.", - responses: { - "200": { - description: "The resolved layout (custom or default).", - content: { - "application/json": { - schema: dataEnvelope(insightsLayoutSchema, "InsightsLayout"), - }, - }, - }, - ...stdResponses, - }, - }, - put: { - tags: ["Insights"], - summary: "Replace the calling user's Insights tile layout", - description: - "Persists the full tile layout. The normalised layout is returned. Invalid bodies return the multi-issue 422 envelope, matching the dashboard-widgets route.", - requestBody: { - required: true, - content: { - "application/json": { schema: insightsLayoutSchema }, - }, - }, - responses: { - "200": { - description: "Layout saved; the normalised layout is echoed back.", - content: { - "application/json": { - schema: dataEnvelope(insightsLayoutSchema, "InsightsLayoutSaved"), - }, - }, - }, - // 422 (multi-issue validation envelope) comes from stdResponses. - ...stdResponses, - }, - }, - delete: { - tags: ["Insights"], - summary: "Reset the calling user's Insights tile layout", - description: - "Clears the persisted layout and returns the default layout. Idempotent.", - responses: { - "200": { - description: "Layout reset; the default layout is returned.", - content: { - "application/json": { - schema: dataEnvelope(insightsLayoutSchema, "InsightsLayoutReset"), - }, - }, - }, - ...stdResponses, - }, - }, - }, - "/api/insights/coach/facts": { - get: { - tags: ["Insights"], - summary: "List the caller's durable Coach facts", - description: - "v1.11.1 — returns the active facts the Coach has extracted about the caller (highest-confidence then newest first), each decrypted on the fly. The GDPR 'what do you know about me' surface. Coach-gated (`requireAssistantSurface(\"coach\")`). Auth via cookie or Bearer; the owner is always narrowed from the session, never the body. Undecryptable rows are omitted rather than failing the read.", - responses: { - "200": { - description: "The caller's active facts.", - content: { - "application/json": { - schema: dataEnvelope(coachFactsListResponse, "CoachFactsList"), - }, - }, - }, - ...stdResponses, - }, - }, - delete: { - tags: ["Insights"], - summary: "Forget all of the caller's Coach facts", - description: - "v1.11.1 — bulk 'forget what you know about me': soft-deletes every active fact for the caller and returns the count cleared. Idempotent (a second call clears 0). Coach-gated. Auth via cookie or Bearer.", - responses: { - "200": { - description: "All active facts cleared; the count is returned.", - content: { - "application/json": { - schema: dataEnvelope( - coachFactsClearedResponse, - "CoachFactsCleared", - ), - }, - }, - }, - ...stdResponses, - }, - }, - }, - "/api/insights/coach/facts/{id}": { - delete: { - tags: ["Insights"], - summary: "Forget one Coach fact", - description: - "v1.11.1 — soft-deletes a single fact owned by the caller. An unknown / cross-user / already-deleted id is an idempotent no-op returning `{ deleted: false }`, never revealing whether the id exists under another account. Coach-gated. Auth via cookie or Bearer.", - responses: { - "200": { - description: - "The fact was soft-deleted (`deleted: true`) or the id matched nothing the caller owns (`deleted: false`).", - content: { - "application/json": { - schema: dataEnvelope( - coachFactDeletedResponse, - "CoachFactDeleted", - ), - }, - }, - }, - ...stdResponses, - }, - }, - }, - "/api/coach/about-me": { - get: { - tags: ["Insights"], - summary: "Read the caller's self-context", - description: - "v1.16.0 — returns the structured self-context (free text plus chronic conditions, allergies, coach focus) the Coach system prompt and the daily briefing inject as a delimited, user-provided context block, alongside any pending clarifying questions. Every field is stored encrypted at rest; an undecryptable payload reads as null (fail closed). Auth via cookie or Bearer; the owner is always narrowed from the session.", - responses: { - "200": { - description: - "The stored fields (null when never written / cleared) plus pending clarifying questions.", - content: { - "application/json": { - schema: dataEnvelope( - z.object({ - aboutMe: z.string().nullable(), - conditions: z.string().nullable(), - allergies: z.string().nullable(), - coachFocus: z.string().nullable(), - pendingQuestions: z.array(z.string()), - updatedAt: z.iso.datetime({ offset: true }).nullable(), - maxChars: z.number().int(), - fieldMaxChars: z.number().int(), - }), - "GetCoachAboutMeResponse", - ), - }, - }, - }, - ...stdResponses, - }, - }, - put: { - tags: ["Insights"], - summary: "Write (or clear) the caller's self-context", - description: - "v1.16.0 — persists the free text (4 000-char cap) and the three structured fields (500-char cap each) encrypted at rest; caps are enforced before encryption. Structured fields are optional: omitted leaves the stored value untouched, an empty string clears it. After a non-empty save the server derives up to 3 clarifying questions (AI when a provider and the daily Coach token budget allow, deterministic completion hints otherwise) and returns them as `pendingQuestions`. Rate-limited per user.", - requestBody: { - required: true, - content: { "application/json": { schema: aboutMePutSchema } }, - }, - responses: { - "200": { - description: - "The effective (trimmed) state echoed back plus the freshly derived pending questions.", - content: { - "application/json": { - schema: dataEnvelope( - z.object({ - aboutMe: z.string().nullable(), - conditions: z.string().nullable(), - allergies: z.string().nullable(), - coachFocus: z.string().nullable(), - pendingQuestions: z.array(z.string()), - updatedAt: z.iso.datetime({ offset: true }), - maxChars: z.number().int(), - fieldMaxChars: z.number().int(), - }), - "PutCoachAboutMeResponse", - ), - }, - }, - }, - ...stdResponses, - }, - }, - }, - "/api/coach/about-me/questions": { - get: { - tags: ["Insights"], - summary: "Read the pending clarifying questions", - description: - "v1.16.0 — the up-to-3 clarifying questions derived after the last self-context save. The Coach composer renders them as tappable suggestion chips. Stored encrypted; an undecryptable payload reads as an empty list (fail closed).", - responses: { - "200": { - description: "The pending questions (possibly empty).", - content: { - "application/json": { - schema: dataEnvelope( - z.object({ questions: z.array(z.string()) }), - "GetCoachAboutMeQuestionsResponse", - ), - }, - }, - }, - ...stdResponses, - }, - }, - delete: { - tags: ["Insights"], - summary: "Dismiss pending clarifying questions", - description: - "v1.16.0 — dismisses one question (body `{ question }`, exact match) or all of them (empty body). Tapping a chip in the Coach composer inserts the question into the chat input and dismisses it here.", - requestBody: { - required: false, - content: { - "application/json": { - schema: z.object({ - question: z - .string() - .optional() - .describe( - "Exact question text to dismiss. Omitted = dismiss all.", - ), - }), - }, - }, - }, - responses: { - "200": { - description: "The remaining questions after the dismissal.", - content: { - "application/json": { - schema: dataEnvelope( - z.object({ questions: z.array(z.string()) }), - "DeleteCoachAboutMeQuestionsResponse", - ), - }, - }, - }, - ...stdResponses, - }, - }, - }, - "/api/admin/invites": { - get: { - tags: ["Admin"], - summary: "List registration invites", - description: - "v1.16.0 — every invite with creator / consumer usernames, use counters, soft-revocation timestamp, and the full per-signup redemption ledger (`redemptions`; `consumer` only carries the LAST account on a multi-use invite). Metadata only — the raw token is never derivable from this endpoint (only its HMAC hash is persisted). Admin session cookie required; Bearer tokens cannot reach admin endpoints.", - responses: { - "200": { - description: "All invites, newest first.", - content: { - "application/json": { - schema: dataEnvelope( - z.array( - z.object({ - id: z.string(), - createdAt: z.iso.datetime({ offset: true }), - expiresAt: z.iso.datetime({ offset: true }), - usedAt: z.iso.datetime({ offset: true }).nullable(), - revokedAt: z.iso.datetime({ offset: true }).nullable(), - uses: z.number().int(), - maxUses: z.number().int(), - creator: z - .object({ id: z.string(), username: z.string() }) - .nullable(), - consumer: z - .object({ id: z.string(), username: z.string() }) - .nullable(), - redemptions: z.array( - z.object({ - id: z.string(), - redeemedAt: z.iso.datetime({ offset: true }), - user: z - .object({ - id: z.string(), - username: z.string(), - email: z.string().nullable(), - }) - .nullable(), - }), - ), - }), - ), - "AdminInviteList", - ), - }, - }, - }, - ...stdResponses, - }, - }, - post: { - tags: ["Admin"], - summary: "Mint a registration invite", - description: - "v1.15.20 — creates an invite that admits a signup even while open registration is disabled. The raw `hlv_<64hex>` token and the composed registration URL appear EXACTLY ONCE in this response; only the keyed hash is persisted. Lifetime is capped at 30 days; `maxUses` makes multi-use invites possible (consumption is an atomic guarded increment). Admin session cookie required.", - requestBody: { - required: true, - content: { "application/json": { schema: inviteCreateSchema } }, - }, - responses: { - "201": { - description: "The minted invite, including the one-time raw token.", - content: { - "application/json": { - schema: dataEnvelope( - z.object({ - id: z.string(), - createdAt: z.iso.datetime({ offset: true }), - expiresAt: z.iso.datetime({ offset: true }), - uses: z.number().int(), - maxUses: z.number().int(), - token: z - .string() - .describe( - "Raw invite token (`hlv_<64hex>`). Shown exactly once — never persisted in plaintext.", - ), - url: z - .string() - .describe( - "Composed registration deep link (`/auth/register?invite=…`).", - ), - }), - "AdminInviteCreated", - ), - }, - }, - }, - ...stdResponses, - }, - }, - }, - "/api/admin/invites/{id}": { - delete: { - tags: ["Admin"], - requestParams: { path: z.object({ id: z.string() }) }, - summary: "Revoke a registration invite", - description: - "v1.16.0 — soft-revokes the invite (`revokedAt`): the row keeps its redemption history visible in the admin table while the consume path refuses the link like an expired one. Idempotent: an unknown or already-revoked id returns `{ revoked: false }` instead of 404. Admin session cookie required.", - responses: { - "200": { - description: - "The invite was revoked (`revoked: true`) or nothing matched (`revoked: false`).", - content: { - "application/json": { - schema: dataEnvelope( - z.object({ revoked: z.boolean() }), - "AdminInviteRevoked", - ), - }, - }, - }, - ...stdResponses, - }, - }, - }, - "/api/insights/chat/messages/{id}/feedback": { - post: { - tags: ["Insights"], - summary: "Rate a Coach assistant message", - description: - "Persists a helpful/unhelpful rating for a single Coach reply. Reuses the v1.4.16 RecommendationFeedback table via the polymorphic `targetType` column. The aggregator buckets ratings by (promptVersion, tone, verbosity).", - requestBody: { - required: true, - content: { - "application/json": { schema: coachMessageFeedbackBody }, - }, - }, - responses: { - "201": { - description: "Feedback saved.", - content: { - "application/json": { - schema: dataEnvelope( - z.object({ - id: z.string(), - createdAt: z.iso.datetime({ offset: true }), - }), - "CoachMessageFeedbackResponse", - ), - }, - }, - }, - "404": { - description: "Message not found or not owned by the caller.", - content: { "application/json": { schema: errorEnvelope } }, - }, - "409": { - description: "Caller has already rated this message text.", - content: { "application/json": { schema: errorEnvelope } }, - }, - ...stdResponses, - }, - }, - }, - "/api/admin/notifications/diagnostic": { - get: { - tags: ["Admin"], - summary: "Admin notification diagnostic snapshot", - description: - "Admin-only. Returns what the dispatcher would see when targeting the calling admin's account: registered devices (APNs tokens masked to prefix + suffix), per-channel enabled + configPresent flags, and recent push attempts (currently always `[]` pending the v1.4.48+ PushAttempt table). Cookie auth only — never Bearer.", - responses: { - "200": { - description: "Diagnostic snapshot.", - content: { - "application/json": { - schema: dataEnvelope( - adminDiagnosticData, - "AdminDiagnosticResponse", - ), - }, - }, - }, - "403": { - description: "Caller is not an admin.", - content: { "application/json": { schema: errorEnvelope } }, - }, - ...stdResponses, - }, - }, - }, - "/api/dashboard/snapshot": { - get: { - tags: ["Dashboard"], - summary: "Unified dashboard first-paint snapshot", - description: - "Assembles every above-the-fold tile field in one round-trip from the rollup / mood / widget helpers plus a read-only lift of the pre-generated daily briefing. Two-phase: `tiles` always present, `extras` nullable on a rollup-coverage miss. No LLM is reachable from this path. Cookie or Bearer auth.", - responses: { - "200": { - description: "Dashboard snapshot.", - content: { - "application/json": { - schema: dataEnvelope( - dashboardSnapshotResponse, - "DashboardSnapshotResponseEnvelope", - ), - }, - }, - }, - ...stdResponses, - }, - }, - }, - "/api/insights/comprehensive": { - get: { - tags: ["Insights"], - summary: "Comprehensive AI insights bundle", - description: - "Full Insights surface — daily briefing, recommendations with rationale, optional weekly report + storyboard annotations. Strict-schema validated server-side.", - responses: { - "200": { - description: "Insights bundle.", - content: { - "application/json": { - schema: dataEnvelope( - insightsComprehensiveResponse, - "InsightsComprehensiveResponseEnvelope", - ), - }, - }, - }, - ...stdResponses, - }, - }, - }, - "/api/insights/pregenerate": { - post: { - tags: ["Insights"], - summary: "Warm all AI assessments for the calling user", - description: - "v1.8.7.1 — enqueue a full warm of every AI assessment for the authenticated user (comprehensive insight + the seven specialised status cards + every data-bearing generic metric assessment) in the active locale, so the read-only status GETs serve cached text instantly. Returns immediately; the generation runs out of band on the worker. Empty metrics and provider-less accounts never trigger an LLM call. Short anti-spam bucket (`insights-warm:`, one warm per 3 minutes) → 429 on a tight loop. Auth via cookie or Bearer; `userId` is taken from the session, never the body.", - requestBody: { - required: false, - content: { - "application/json": { schema: insightsPregenerateRequest }, - }, - }, - responses: { - "200": { - description: - "Warm accepted and enqueued. The work runs on the worker; poll the read-only status routes for the text.", - content: { - "application/json": { - schema: dataEnvelope( - insightsPregenerateResponse, - "InsightsPregenerateResponseEnvelope", - ), - }, - }, - }, - ...stdResponses, - }, - }, - }, - "/api/analytics/range": { - get: { - tags: ["Analytics"], - summary: "Single-metric period-over-period range delta", - description: - "v1.9.0 — returns the current-window aggregate, the previous comparable window, and the composed delta for ONE metric type over a `7d` / `30d` / `90d` / `1y` range. Single-type by construction (the metric page is single-metric), so the read is one rollup-tier call covering the trailing 2N days sliced into the two halves — no per-type fan-out. Additive route; the `/api/analytics` envelope is unchanged. Auth via cookie or Bearer.", - requestParams: { - query: analyticsRangeQuery, - }, - responses: { - "200": { - description: "Current + previous window aggregates and the delta.", - content: { - "application/json": { - schema: dataEnvelope( - analyticsRangeResponse, - "AnalyticsRangeResponseEnvelope", - ), - }, - }, - }, - ...stdResponses, - }, - }, - }, - "/api/insights/blood-pressure-status": { - get: { - tags: ["Insights"], - summary: "Blood-pressure assessment", - description: - "Data-driven plain-language assessment of the user's recent blood-pressure readings. Read-only: a cache miss warms a generation out of band and serves the last-good text meanwhile (stale-while-revalidate). Auth via cookie or Bearer.", - requestParams: { - query: insightStatusQuery, - }, - responses: { - "200": { - description: "Assessment envelope (fresh, cached, or preparing).", - content: { - "application/json": { - schema: dataEnvelope( - insightStatusResponse, - "BloodPressureStatusResponseEnvelope", - ), - }, - }, - }, - ...stdResponses, - }, - }, - }, - "/api/insights/pulse-status": { - get: { - tags: ["Insights"], - summary: "Pulse assessment", - description: - "Data-driven plain-language assessment of the user's recent resting-pulse readings. Read-only: a cache miss warms a generation out of band and serves the last-good text meanwhile (stale-while-revalidate). Auth via cookie or Bearer.", - requestParams: { - query: insightStatusQuery, - }, - responses: { - "200": { - description: "Assessment envelope (fresh, cached, or preparing).", - content: { - "application/json": { - schema: dataEnvelope( - insightStatusResponse, - "PulseStatusResponseEnvelope", - ), - }, - }, - }, - ...stdResponses, - }, - }, - }, - "/api/insights/weight-status": { - get: { - tags: ["Insights"], - summary: "Weight assessment", - description: - "Data-driven plain-language assessment of the user's recent weight trend. Read-only: a cache miss warms a generation out of band and serves the last-good text meanwhile (stale-while-revalidate). Auth via cookie or Bearer.", - requestParams: { - query: insightStatusQuery, - }, - responses: { - "200": { - description: "Assessment envelope (fresh, cached, or preparing).", - content: { - "application/json": { - schema: dataEnvelope( - insightStatusResponse, - "WeightStatusResponseEnvelope", - ), - }, - }, - }, - ...stdResponses, - }, - }, - }, - "/api/insights/bmi-status": { - get: { - tags: ["Insights"], - summary: "BMI assessment", - description: - "Data-driven plain-language assessment of the user's body-mass index. Read-only: a cache miss warms a generation out of band and serves the last-good text meanwhile (stale-while-revalidate). Auth via cookie or Bearer.", - requestParams: { - query: insightStatusQuery, - }, - responses: { - "200": { - description: "Assessment envelope (fresh, cached, or preparing).", - content: { - "application/json": { - schema: dataEnvelope( - insightStatusResponse, - "BmiStatusResponseEnvelope", - ), - }, - }, - }, - ...stdResponses, - }, - }, - }, - "/api/insights/mood-status": { - get: { - tags: ["Insights"], - summary: "Mood assessment", - description: - "Data-driven plain-language assessment of the user's recent mood entries. Read-only: a cache miss warms a generation out of band and serves the last-good text meanwhile (stale-while-revalidate). Auth via cookie or Bearer.", - requestParams: { - query: insightStatusQuery, - }, - responses: { - "200": { - description: "Assessment envelope (fresh, cached, or preparing).", - content: { - "application/json": { - schema: dataEnvelope( - insightStatusResponse, - "MoodStatusResponseEnvelope", - ), - }, - }, - }, - ...stdResponses, - }, - }, - }, - "/api/insights/medication-compliance-status": { - get: { - tags: ["Insights"], - summary: "Medication-compliance assessment", - description: - "Data-driven plain-language assessment of the user's medication compliance — an overall `summary` plus a per-medication note array. Read-only: a cache miss warms a generation out of band and serves the last-good envelope meanwhile (stale-while-revalidate). Auth via cookie or Bearer.", - requestParams: { - query: insightStatusQuery, - }, - responses: { - "200": { - description: - "Compliance assessment envelope (fresh, cached, or preparing).", - content: { - "application/json": { - schema: dataEnvelope( - medicationComplianceStatusResponse, - "MedicationComplianceStatusResponseEnvelope", - ), - }, - }, - }, - ...stdResponses, - }, - }, - }, - "/api/insights/metric-status": { - get: { - tags: ["Insights"], - summary: "Generic per-HealthKit-metric assessment", - description: - "v1.8.7.1 — data-driven plain-language assessment for any registered HealthKit metric (resting heart rate, sleep, glucose, body composition, gait, audio exposure, …). One generic route covering ~30 metric pages via archetype prompt templates + per-metric metadata. Read-only: a cache miss warms a generation out of band and serves the last-good text meanwhile (stale-while-revalidate). An unknown `metric` 422s against the closed registry enum. Auth via cookie or Bearer.", - requestParams: { - query: metricStatusQuery, - }, - responses: { - "200": { - description: "Assessment envelope (fresh, cached, or preparing).", - content: { - "application/json": { - schema: dataEnvelope( - metricStatusResponse, - "MetricStatusResponseEnvelope", - ), - }, - }, - }, - ...stdResponses, - }, - }, - }, - "/api/insights/derived": { - get: { - tags: ["Insights"], - summary: "Derived wellness metric (compute-once)", - description: - "v1.10.0 — the compute-once `Derived` value for any registered derived wellness metric (personal typical-range vitals baseline, cardio-fitness band, vascular-age delta, sleep score, readiness, coincident-deviation flag). One generic route over a closed registry enum; an unknown `metric` 422s. Pure compute over the rollup tier with a per-type live fallback on a coverage miss — no LLM call, no narrative, no cache table. Returns the flat `Derived` union so the native client can decode one stable shape and combine values across metrics. Auth via cookie or Bearer.", - requestParams: { - query: derivedMetricQuery, - }, - responses: { - "200": { - description: "The flat derived-metric value (ok or insufficient).", - content: { - "application/json": { - schema: dataEnvelope( - derivedMetricResponse, - "DerivedMetricResponseEnvelope", - ), - }, - }, - }, - ...stdResponses, - }, - }, - }, - "/api/insights/derived/batch": { - get: { - tags: ["Insights"], - summary: "Derived wellness metrics (batched compute-once)", - description: - "v1.10.0 — resolve several derived wellness metrics in ONE request. The `metrics` CSV names the metrics (a `metric:type` token sub-targets a VITALS_BASELINE vital); the server fans out under a bounded limiter with the profile loaded once and returns a map keyed by the per-request token. Collapses the Insights cold-mount fan-out of 14+ independent single-metric requests — the pool-starvation class that surfaces as a hang-then-recover. The single-metric route stays for the per-score detail pages. Auth via cookie or Bearer.", - requestParams: { - query: derivedBatchQuery, - }, - responses: { - "200": { - description: "The map of derived-metric values, keyed by token.", - content: { - "application/json": { - schema: dataEnvelope( - derivedBatchResponse, - "DerivedBatchResponseEnvelope", - ), - }, - }, - }, - ...stdResponses, - }, - }, - }, - "/api/insights/correlations": { - get: { - tags: ["Insights"], - summary: "Correlation discovery (FDR-controlled)", - description: - "v1.10.0 — scans a curated behaviour × outcome matrix (daylight / mood / glucose / BP / steps × sleep / HRV / resting HR / weight), lag-joins each behaviour day to the next day's outcome, runs Pearson with the exact Student-t p-value, and applies Benjamini-Hochberg FDR control across every tested pair. Only statistically-defensible pairs surface, each carrying n, r, p, and the BH-adjusted q. Descriptive, never causal. Gated by the operator `correlations` assistant surface. Auth via cookie or Bearer.", - responses: { - "200": { - description: "The discovered correlations + the tested-pair count.", - content: { - "application/json": { - schema: dataEnvelope( - correlationDiscoveryResponse, - "CorrelationDiscoveryResponseEnvelope", - ), - }, - }, - }, - ...stdResponses, - }, - }, - }, -}; - -export const openApiComponents: NonNullable = { - // Schemas listed here are forced into `components.schemas` even when no - // route references them directly. `Medication` lives in this slot - // because its only consumers are the `MedicationListEntry` / - // `MedicationDetail` variants which extend it — `.extend()` inlines - // the base shape into the derived schema, so without an explicit - // registration the standalone `Medication` component would never - // emit. The iOS codegen reads from `Medication` directly for the - // shared Swift struct backing both variants. - schemas: { - Medication: medicationResource, - }, - securitySchemes: { - bearerAuth: { - type: "http", - scheme: "bearer", - bearerFormat: "hlk_<64hex>", - description: - "Native-client API token. Use the `token` field returned by /api/auth/login on a native client.", - }, - cookieAuth: { - type: "apiKey", - in: "cookie", - name: "healthlog_session", - description: "Browser session cookie — set by /api/auth/login.", - }, - }, -}; diff --git a/src/lib/openapi/routes/admin.ts b/src/lib/openapi/routes/admin.ts new file mode 100644 index 000000000..c0cdda382 --- /dev/null +++ b/src/lib/openapi/routes/admin.ts @@ -0,0 +1,220 @@ +/** + * OpenAPI route table — admin invites + notification diagnostics. + * + * Part of the OpenAPI route table; aggregated in `./index.ts`. + * Schemas come from `src/lib/validations/*` where shared with the + * runtime request parsing, so the wire contract stays single-source. + */ +import { z } from "zod/v4"; +import type { ZodOpenApiObject } from "zod-openapi"; +import { inviteCreateSchema } from "@/lib/validations/invite"; +import { dataEnvelope, errorEnvelope, stdResponses } from "./shared"; + +// v1.4.48 H-APNs-1 — admin diagnostic endpoint for the notification +// subsystem. Mirrors the runtime types in +// `src/app/api/admin/notifications/diagnostic/route.ts`: APNs tokens +// are surfaced only as 8-char hex prefix + suffix (never the full +// token), per-channel `enabled` + `configPresent` booleans, and a +// `recentPushAttempts` array reserved for the v1.4.48+ follow-up that +// adds the dedicated PushAttempt table (currently always `[]` so the +// shape stays stable for iOS consumers). +const adminDiagnosticDevice = z + .object({ + id: z.string(), + platform: z.string(), + hasApnsToken: z.boolean(), + apnsTokenPrefix: z.string().nullable(), + apnsTokenSuffix: z.string().nullable(), + apnsEnvironment: z.string().nullable(), + lastSeenAt: z.iso.datetime({ offset: true }), + }) + .meta({ + id: "AdminDiagnosticDevice", + description: + "Per-device APNs registration snapshot. The APNs token is masked to its 8-char hex prefix + 8-char suffix so an operator can correlate with iOS-side logs without disclosing the delivery target.", + }); + +const adminDiagnosticChannel = z + .object({ + type: z.string(), + enabled: z.boolean(), + configPresent: z.boolean(), + }) + .meta({ + id: "AdminDiagnosticChannel", + description: + "Per-channel state. `configPresent` is true when the channel's encrypted-JSON config blob carries the field the corresponding sender will read (chatId+botToken for TELEGRAM, topic for NTFY); WEB_PUSH/APNS rely on sibling tables so the row's existence is the signal.", + }); + +const adminDiagnosticPushAttempt = z + .object({ + eventType: z.string(), + channel: z.string(), + result: z.string(), + reason: z.string().nullable(), + at: z.iso.datetime({ offset: true }), + }) + .meta({ + id: "AdminDiagnosticPushAttempt", + description: + "Single push delivery attempt. Reserved for the v1.4.48+ follow-up that adds the dedicated PushAttempt table — currently always returned as an empty array so the response shape stays stable for iOS consumers.", + }); + +const adminDiagnosticData = z + .object({ + devices: z.array(adminDiagnosticDevice), + notificationChannels: z.array(adminDiagnosticChannel), + recentPushAttempts: z.array(adminDiagnosticPushAttempt), + }) + .meta({ + id: "AdminDiagnosticData", + description: + "Admin notification diagnostic snapshot for the calling user — what the dispatcher would see when targeting this account. Surfaces device tokens (masked), channel state, and recent push attempts so an operator can debug an iOS / Web Push / Telegram / ntfy issue without DB shell access.", + }); + +export const adminInvitePaths: NonNullable = { + "/api/admin/invites": { + get: { + tags: ["Admin"], + summary: "List registration invites", + description: + "v1.16.0 — every invite with creator / consumer usernames, use counters, soft-revocation timestamp, and the full per-signup redemption ledger (`redemptions`; `consumer` only carries the LAST account on a multi-use invite). Metadata only — the raw token is never derivable from this endpoint (only its HMAC hash is persisted). Admin session cookie required; Bearer tokens cannot reach admin endpoints.", + responses: { + "200": { + description: "All invites, newest first.", + content: { + "application/json": { + schema: dataEnvelope( + z.array( + z.object({ + id: z.string(), + createdAt: z.iso.datetime({ offset: true }), + expiresAt: z.iso.datetime({ offset: true }), + usedAt: z.iso.datetime({ offset: true }).nullable(), + revokedAt: z.iso.datetime({ offset: true }).nullable(), + uses: z.number().int(), + maxUses: z.number().int(), + creator: z + .object({ id: z.string(), username: z.string() }) + .nullable(), + consumer: z + .object({ id: z.string(), username: z.string() }) + .nullable(), + redemptions: z.array( + z.object({ + id: z.string(), + redeemedAt: z.iso.datetime({ offset: true }), + user: z + .object({ + id: z.string(), + username: z.string(), + email: z.string().nullable(), + }) + .nullable(), + }), + ), + }), + ), + "AdminInviteList", + ), + }, + }, + }, + ...stdResponses, + }, + }, + post: { + tags: ["Admin"], + summary: "Mint a registration invite", + description: + "v1.15.20 — creates an invite that admits a signup even while open registration is disabled. The raw `hlv_<64hex>` token and the composed registration URL appear EXACTLY ONCE in this response; only the keyed hash is persisted. Lifetime is capped at 30 days; `maxUses` makes multi-use invites possible (consumption is an atomic guarded increment). Admin session cookie required.", + requestBody: { + required: true, + content: { "application/json": { schema: inviteCreateSchema } }, + }, + responses: { + "201": { + description: "The minted invite, including the one-time raw token.", + content: { + "application/json": { + schema: dataEnvelope( + z.object({ + id: z.string(), + createdAt: z.iso.datetime({ offset: true }), + expiresAt: z.iso.datetime({ offset: true }), + uses: z.number().int(), + maxUses: z.number().int(), + token: z + .string() + .describe( + "Raw invite token (`hlv_<64hex>`). Shown exactly once — never persisted in plaintext.", + ), + url: z + .string() + .describe( + "Composed registration deep link (`/auth/register?invite=…`).", + ), + }), + "AdminInviteCreated", + ), + }, + }, + }, + ...stdResponses, + }, + }, + }, + "/api/admin/invites/{id}": { + delete: { + tags: ["Admin"], + requestParams: { path: z.object({ id: z.string() }) }, + summary: "Revoke a registration invite", + description: + "v1.16.0 — soft-revokes the invite (`revokedAt`): the row keeps its redemption history visible in the admin table while the consume path refuses the link like an expired one. Idempotent: an unknown or already-revoked id returns `{ revoked: false }` instead of 404. Admin session cookie required.", + responses: { + "200": { + description: + "The invite was revoked (`revoked: true`) or nothing matched (`revoked: false`).", + content: { + "application/json": { + schema: dataEnvelope( + z.object({ revoked: z.boolean() }), + "AdminInviteRevoked", + ), + }, + }, + }, + ...stdResponses, + }, + }, + }, +}; + +export const adminDiagnosticPaths: NonNullable = { + "/api/admin/notifications/diagnostic": { + get: { + tags: ["Admin"], + summary: "Admin notification diagnostic snapshot", + description: + "Admin-only. Returns what the dispatcher would see when targeting the calling admin's account: registered devices (APNs tokens masked to prefix + suffix), per-channel enabled + configPresent flags, and recent push attempts (currently always `[]` pending the v1.4.48+ PushAttempt table). Cookie auth only — never Bearer.", + responses: { + "200": { + description: "Diagnostic snapshot.", + content: { + "application/json": { + schema: dataEnvelope( + adminDiagnosticData, + "AdminDiagnosticResponse", + ), + }, + }, + }, + "403": { + description: "Caller is not an admin.", + content: { "application/json": { schema: errorEnvelope } }, + }, + ...stdResponses, + }, + }, + }, +}; diff --git a/src/lib/openapi/routes/auth.ts b/src/lib/openapi/routes/auth.ts new file mode 100644 index 000000000..77a6002ac --- /dev/null +++ b/src/lib/openapi/routes/auth.ts @@ -0,0 +1,148 @@ +/** + * OpenAPI route table — auth surface (login, passkey verify, token refresh). + * + * Part of the OpenAPI route table; aggregated in `./index.ts`. + * Schemas come from `src/lib/validations/*` where shared with the + * runtime request parsing, so the wire contract stays single-source. + */ +import { z } from "zod/v4"; +import type { ZodOpenApiObject } from "zod-openapi"; +import { loginPasswordSchema } from "@/lib/validations/auth"; +import { dataEnvelope, stdResponses } from "./shared"; + +// ── Sub-schemas owned here (route-specific shapes) ─────────────────── + +const passkeyLoginVerifyRequest = z + .object({ + challengeId: z + .string() + .min(1) + .describe("Server-issued WebAuthn challenge id."), + credential: z + .object({ + id: z.string(), + rawId: z.string(), + type: z.literal("public-key"), + response: z.record(z.string(), z.unknown()), + authenticatorAttachment: z + .enum(["platform", "cross-platform"]) + .optional(), + clientExtensionResults: z.record(z.string(), z.unknown()).optional(), + }) + .describe("SimpleWebAuthn-style assertion response payload."), + }) + .meta({ + id: "PasskeyLoginVerifyRequest", + description: + "Passkey assertion verification. Same native-client token issuance as the password path.", + }); + +const refreshRequest = z + .object({ + refreshToken: z + .string() + .min(1) + .describe("Caller-presented refresh token (`hlr_<64hex>`)."), + revoke: z + .boolean() + .optional() + .describe("When true, revoke the supplied token instead of rotating."), + }) + .meta({ + id: "RefreshTokenRequest", + description: + "Exchange a one-time-use refresh token for a fresh access + refresh pair.", + }); + +const accessRefreshBundle = z + .object({ + user: z.object({ id: z.string(), username: z.string() }).optional(), + token: z.string().describe("Access token (`hlk_<64hex>`)."), + tokenExpiresAt: z.iso.datetime({ offset: true }), + refreshToken: z + .string() + .optional() + .describe( + "Refresh token (`hlr_<64hex>`); only present for native-policy callers.", + ), + refreshTokenExpiresAt: z.iso.datetime({ offset: true }).optional(), + }) + .meta({ + id: "AccessRefreshBundle", + description: + "Native-client token bundle returned by login + refresh. Web cookie-only callers see only `user`.", + }); + +export const authPaths: NonNullable = { + "/api/auth/login": { + post: { + tags: ["Auth"], + summary: "Email-or-username login (password)", + description: + "Browser callers receive a session cookie. Native callers (X-Client-Type: native or HealthLog-iOS UA prefix) additionally receive a paired access + refresh token.", + requestBody: { + required: true, + content: { "application/json": { schema: loginPasswordSchema } }, + }, + responses: { + "200": { + description: "Login succeeded.", + content: { + "application/json": { + schema: dataEnvelope(accessRefreshBundle, "LoginResponse"), + }, + }, + }, + ...stdResponses, + }, + }, + }, + "/api/auth/passkey/login-verify": { + post: { + tags: ["Auth"], + summary: "Passkey assertion verification", + requestBody: { + required: true, + content: { "application/json": { schema: passkeyLoginVerifyRequest } }, + }, + responses: { + "200": { + description: "Assertion verified — session + optional bearer issued.", + content: { + "application/json": { + schema: dataEnvelope( + accessRefreshBundle, + "PasskeyLoginVerifyResponse", + ), + }, + }, + }, + ...stdResponses, + }, + }, + }, + "/api/auth/refresh": { + post: { + tags: ["Auth"], + summary: "Rotate refresh token (one-time use)", + description: + "Reuse of a consumed refresh token revokes every refresh token still active for the originating device (per-device blast radius from v1.4.23). Legacy tokens issued before v1.4.23 with a null deviceId fall back to revoke-all-for-user.\n\n" + + "On a 401, `meta.errorCode` is a stable machine code so the client can branch terminal re-auth from a transient blip without parsing the prose `error`: `auth.refresh.reuse` (a consumed token was replayed — device family revoked, re-pair required), `auth.refresh.revoked` (family revoked out-of-band — re-pair required), `auth.refresh.invalid` (not found / expired — drop the token and re-authenticate).", + requestBody: { + required: true, + content: { "application/json": { schema: refreshRequest } }, + }, + responses: { + "200": { + description: "Rotation succeeded — new pair issued.", + content: { + "application/json": { + schema: dataEnvelope(accessRefreshBundle, "RefreshResponse"), + }, + }, + }, + ...stdResponses, + }, + }, + }, +}; diff --git a/src/lib/openapi/routes/coach.ts b/src/lib/openapi/routes/coach.ts new file mode 100644 index 000000000..19222890c --- /dev/null +++ b/src/lib/openapi/routes/coach.ts @@ -0,0 +1,438 @@ +/** + * OpenAPI route table — insights layout, coach facts, about-me, chat message feedback. + * + * Part of the OpenAPI route table; aggregated in `./index.ts`. + * Schemas come from `src/lib/validations/*` where shared with the + * runtime request parsing, so the wire contract stays single-source. + */ +import { z } from "zod/v4"; +import type { ZodOpenApiObject } from "zod-openapi"; +import { aboutMePutSchema } from "@/lib/validations/about-me"; +import { + ACCEPTED_INSIGHTS_TILE_IDS, + INSIGHTS_SECTION_IDS, +} from "@/lib/insights-layout"; +import { COACH_FACT_CATEGORIES } from "@/lib/ai/coach/facts"; +import { exportSelectionSchema } from "@/lib/validations/health-record-export"; +import { createShareLinkSchema } from "@/lib/validations/clinician-share-link"; +import { dataEnvelope, errorEnvelope, stdResponses } from "./shared"; + +const coachMessageFeedbackBody = z + .object({ + rating: z.enum(["helpful", "unhelpful"]), + reason: z.string().min(1).max(200).optional(), + }) + .meta({ + id: "CoachMessageFeedbackRequest", + description: + "Per-message helpful/unhelpful feedback (v1.4.23 H7). Optional `reason` is free-form prose, capped at 200 chars.", + }); + +// Insights tile layout — mirrors the Zod schema in +// `src/app/api/insights/layout/route.ts`. The tile-id enum is derived +// from the same `ACCEPTED_INSIGHTS_TILE_IDS` source so the contract +// cannot drift. +// +// v1.8.0 — the canonical ids are English (`blood-pressure`, `pulse`, +// `oxygen`, `body-temperature`, `weight`, `active-energy`, `sleep`, +// `resting-pulse`, `mood`, `medications`). The endpoint still ACCEPTS +// the legacy German ids (`blutdruck`, `puls`, `sauerstoff`, +// `koerpertemperatur`, `gewicht`, `aktive-energie`, `schlaf`, +// `ruhepuls`, `stimmung`, `medikamente`) on input so existing iOS +// layouts keep validating; the server normalises them to the canonical +// English id before persisting, and GET always returns canonical ids. +// The legacy ids are accepted-but-deprecated and will be dropped from +// the accepted set in a future major. +// v1.15.11 — layout v2 adds an optional `sections` array on top of the +// per-metric `tiles` list so the overview's big semantic blocks can be +// reordered/hidden in their own right. `sections` is additive and +// optional: a client PUTting only `tiles` (the pre-v2 iOS contract) still +// validates, and a v1 blob resolves forward to a valid v2 layout with all +// sections default-visible. Section ids are English from birth — no +// legacy-alias widening. `tiles` is likewise optional now (a section-only +// PUT fills the default tile set), but must carry at least one entry when +// present. +const insightsLayoutSchema = z + .object({ + // v1.15.11 QA C1 — both v1 and v2 are accepted on input. The live iOS + // client still PUTs `version: 1`; the server normalises to the canonical + // v2 blob on persist, so a v1 body never 422s on the layout-schema bump. + version: z.union([z.literal(1), z.literal(2)]), + sections: z + .array( + z.object({ + id: z.enum(INSIGHTS_SECTION_IDS), + visible: z.boolean(), + order: z.number().int().min(0).max(99), + }), + ) + .max(50) + .optional(), + tiles: z + .array( + z.object({ + id: z.enum(ACCEPTED_INSIGHTS_TILE_IDS), + visible: z.boolean(), + order: z.number().int().min(0).max(99), + }), + ) + .min(1) + .max(50) + .optional(), + }) + .meta({ + id: "InsightsLayoutBody", + description: + "Per-user Insights layout (v2). `tiles` is the per-metric pill list (ordered, with a visibility flag); `sections` is the additive v2 list of the overview's big semantic blocks (wellness-scores, daily-briefing, vitals, trends, period-review, cycle-summary, signals, rhythm-events), each with order + visibility. `version` is the layout schema version: BOTH 1 and 2 are accepted on input (a pre-v2 iOS client still PUTs `version: 1`); the server always normalises to the canonical v2 blob before persisting, and GET responses always carry `version: 2`. Both arrays are optional on input — a client sending only `tiles` (the pre-v2 contract) still validates, and the server fills missing defaults; a v1 blob with no `sections` resolves forward with all sections default-visible. Tile ids are a closed enum: the canonical ids are English (matching the routed `/insights/` sub-pages). The legacy German tile ids (blutdruck, puls, sauerstoff, koerpertemperatur, gewicht, aktive-energie, schlaf, ruhepuls, stimmung, medikamente) remain accepted on input for backward compatibility and are normalised to their English equivalents before persisting; GET responses always carry the canonical English ids. Section ids are English-only. The legacy tile ids are deprecated and will be removed in a future major version.", + }); + +// 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). `resourceTypes` scopes the FHIR resources the link may serve; `allowFhirApi` toggles REST reachability. 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 +// shape — only list, bulk-clear, and single-delete responses. + +const coachFactItem = z.object({ + id: z.string(), + category: z + .enum(COACH_FACT_CATEGORIES) + .describe( + "App-side closed category: preference | condition | goal | constraint | context.", + ), + text: z.string().describe("Decrypted fact text."), + confidence: z + .number() + .int() + .describe("0..100 server-assigned extraction confidence."), + createdAt: z.iso.datetime({ offset: true }), +}); + +const coachFactsListResponse = z.object({ + facts: z + .array(coachFactItem) + .describe( + "The caller's active facts, highest-confidence then newest first. Undecryptable rows are omitted.", + ), +}); + +const coachFactsClearedResponse = z.object({ + cleared: z + .number() + .int() + .describe("Number of active facts soft-deleted by the bulk clear."), +}); + +const coachFactDeletedResponse = z.object({ + deleted: z + .boolean() + .describe( + "True when a fact owned by the caller was soft-deleted; false for an unknown / cross-user / already-deleted id (idempotent no-op).", + ), +}); + +export const coachPaths: NonNullable = { + "/api/insights/layout": { + get: { + tags: ["Insights"], + summary: "Read the calling user's Insights tile layout", + description: + "Returns the per-user Insights tile layout (visibility + order). Falls back to the default layout when the user has not customised it. Mirrors the dashboard-widgets contract.", + responses: { + "200": { + description: "The resolved layout (custom or default).", + content: { + "application/json": { + schema: dataEnvelope(insightsLayoutSchema, "InsightsLayout"), + }, + }, + }, + ...stdResponses, + }, + }, + put: { + tags: ["Insights"], + summary: "Replace the calling user's Insights tile layout", + description: + "Persists the full tile layout. The normalised layout is returned. Invalid bodies return the multi-issue 422 envelope, matching the dashboard-widgets route.", + requestBody: { + required: true, + content: { + "application/json": { schema: insightsLayoutSchema }, + }, + }, + responses: { + "200": { + description: "Layout saved; the normalised layout is echoed back.", + content: { + "application/json": { + schema: dataEnvelope(insightsLayoutSchema, "InsightsLayoutSaved"), + }, + }, + }, + // 422 (multi-issue validation envelope) comes from stdResponses. + ...stdResponses, + }, + }, + delete: { + tags: ["Insights"], + summary: "Reset the calling user's Insights tile layout", + description: + "Clears the persisted layout and returns the default layout. Idempotent.", + responses: { + "200": { + description: "Layout reset; the default layout is returned.", + content: { + "application/json": { + schema: dataEnvelope(insightsLayoutSchema, "InsightsLayoutReset"), + }, + }, + }, + ...stdResponses, + }, + }, + }, + "/api/insights/coach/facts": { + get: { + tags: ["Insights"], + summary: "List the caller's durable Coach facts", + description: + "v1.11.1 — returns the active facts the Coach has extracted about the caller (highest-confidence then newest first), each decrypted on the fly. The GDPR 'what do you know about me' surface. Coach-gated (`requireAssistantSurface(\"coach\")`). Auth via cookie or Bearer; the owner is always narrowed from the session, never the body. Undecryptable rows are omitted rather than failing the read.", + responses: { + "200": { + description: "The caller's active facts.", + content: { + "application/json": { + schema: dataEnvelope(coachFactsListResponse, "CoachFactsList"), + }, + }, + }, + ...stdResponses, + }, + }, + delete: { + tags: ["Insights"], + summary: "Forget all of the caller's Coach facts", + description: + "v1.11.1 — bulk 'forget what you know about me': soft-deletes every active fact for the caller and returns the count cleared. Idempotent (a second call clears 0). Coach-gated. Auth via cookie or Bearer.", + responses: { + "200": { + description: "All active facts cleared; the count is returned.", + content: { + "application/json": { + schema: dataEnvelope( + coachFactsClearedResponse, + "CoachFactsCleared", + ), + }, + }, + }, + ...stdResponses, + }, + }, + }, + "/api/insights/coach/facts/{id}": { + delete: { + tags: ["Insights"], + summary: "Forget one Coach fact", + description: + "v1.11.1 — soft-deletes a single fact owned by the caller. An unknown / cross-user / already-deleted id is an idempotent no-op returning `{ deleted: false }`, never revealing whether the id exists under another account. Coach-gated. Auth via cookie or Bearer.", + responses: { + "200": { + description: + "The fact was soft-deleted (`deleted: true`) or the id matched nothing the caller owns (`deleted: false`).", + content: { + "application/json": { + schema: dataEnvelope( + coachFactDeletedResponse, + "CoachFactDeleted", + ), + }, + }, + }, + ...stdResponses, + }, + }, + }, + "/api/coach/about-me": { + get: { + tags: ["Insights"], + summary: "Read the caller's self-context", + description: + "v1.16.0 — returns the structured self-context (free text plus chronic conditions, allergies, coach focus) the Coach system prompt and the daily briefing inject as a delimited, user-provided context block, alongside any pending clarifying questions. Every field is stored encrypted at rest; an undecryptable payload reads as null (fail closed). Auth via cookie or Bearer; the owner is always narrowed from the session.", + responses: { + "200": { + description: + "The stored fields (null when never written / cleared) plus pending clarifying questions.", + content: { + "application/json": { + schema: dataEnvelope( + z.object({ + aboutMe: z.string().nullable(), + conditions: z.string().nullable(), + allergies: z.string().nullable(), + coachFocus: z.string().nullable(), + pendingQuestions: z.array(z.string()), + updatedAt: z.iso.datetime({ offset: true }).nullable(), + maxChars: z.number().int(), + fieldMaxChars: z.number().int(), + }), + "GetCoachAboutMeResponse", + ), + }, + }, + }, + ...stdResponses, + }, + }, + put: { + tags: ["Insights"], + summary: "Write (or clear) the caller's self-context", + description: + "v1.16.0 — persists the free text (4 000-char cap) and the three structured fields (500-char cap each) encrypted at rest; caps are enforced before encryption. Structured fields are optional: omitted leaves the stored value untouched, an empty string clears it. After a non-empty save the server derives up to 3 clarifying questions (AI when a provider and the daily Coach token budget allow, deterministic completion hints otherwise) and returns them as `pendingQuestions`. Rate-limited per user.", + requestBody: { + required: true, + content: { "application/json": { schema: aboutMePutSchema } }, + }, + responses: { + "200": { + description: + "The effective (trimmed) state echoed back plus the freshly derived pending questions.", + content: { + "application/json": { + schema: dataEnvelope( + z.object({ + aboutMe: z.string().nullable(), + conditions: z.string().nullable(), + allergies: z.string().nullable(), + coachFocus: z.string().nullable(), + pendingQuestions: z.array(z.string()), + updatedAt: z.iso.datetime({ offset: true }), + maxChars: z.number().int(), + fieldMaxChars: z.number().int(), + }), + "PutCoachAboutMeResponse", + ), + }, + }, + }, + ...stdResponses, + }, + }, + }, + "/api/coach/about-me/questions": { + get: { + tags: ["Insights"], + summary: "Read the pending clarifying questions", + description: + "v1.16.0 — the up-to-3 clarifying questions derived after the last self-context save. The Coach composer renders them as tappable suggestion chips. Stored encrypted; an undecryptable payload reads as an empty list (fail closed).", + responses: { + "200": { + description: "The pending questions (possibly empty).", + content: { + "application/json": { + schema: dataEnvelope( + z.object({ questions: z.array(z.string()) }), + "GetCoachAboutMeQuestionsResponse", + ), + }, + }, + }, + ...stdResponses, + }, + }, + delete: { + tags: ["Insights"], + summary: "Dismiss pending clarifying questions", + description: + "v1.16.0 — dismisses one question (body `{ question }`, exact match) or all of them (empty body). Tapping a chip in the Coach composer inserts the question into the chat input and dismisses it here.", + requestBody: { + required: false, + content: { + "application/json": { + schema: z.object({ + question: z + .string() + .optional() + .describe( + "Exact question text to dismiss. Omitted = dismiss all.", + ), + }), + }, + }, + }, + responses: { + "200": { + description: "The remaining questions after the dismissal.", + content: { + "application/json": { + schema: dataEnvelope( + z.object({ questions: z.array(z.string()) }), + "DeleteCoachAboutMeQuestionsResponse", + ), + }, + }, + }, + ...stdResponses, + }, + }, + }, +}; + +export const coachFeedbackPaths: NonNullable = { + "/api/insights/chat/messages/{id}/feedback": { + post: { + tags: ["Insights"], + summary: "Rate a Coach assistant message", + description: + "Persists a helpful/unhelpful rating for a single Coach reply. Reuses the v1.4.16 RecommendationFeedback table via the polymorphic `targetType` column. The aggregator buckets ratings by (promptVersion, tone, verbosity).", + requestBody: { + required: true, + content: { + "application/json": { schema: coachMessageFeedbackBody }, + }, + }, + responses: { + "201": { + description: "Feedback saved.", + content: { + "application/json": { + schema: dataEnvelope( + z.object({ + id: z.string(), + createdAt: z.iso.datetime({ offset: true }), + }), + "CoachMessageFeedbackResponse", + ), + }, + }, + }, + "404": { + description: "Message not found or not owned by the caller.", + content: { "application/json": { schema: errorEnvelope } }, + }, + "409": { + description: "Caller has already rated this message text.", + content: { "application/json": { schema: errorEnvelope } }, + }, + ...stdResponses, + }, + }, + }, +}; diff --git a/src/lib/openapi/routes/cycle.ts b/src/lib/openapi/routes/cycle.ts new file mode 100644 index 000000000..418424c93 --- /dev/null +++ b/src/lib/openapi/routes/cycle.ts @@ -0,0 +1,691 @@ +/** + * OpenAPI route table — cycle tracking (day logs, periods, calendar, insights, prefs). + * + * Part of the OpenAPI route table; aggregated in `./index.ts`. + * Schemas come from `src/lib/validations/*` where shared with the + * runtime request parsing, so the wire contract stays single-source. + */ +import { z } from "zod/v4"; +import type { ZodOpenApiObject } from "zod-openapi"; +import { + flowLevelEnum, + ovulationTestEnum, + cervicalMucusEnum, + homeTestResultEnum, + cycleTrackingGoalEnum, + cycleDayLogInputSchema, + cycleDayLogPatchSchema, + cycleDayLogQuerySchema, + cycleBulkSchema, + cyclePeriodSchema, + cyclePrefsSchema, +} from "@/lib/validations/cycle"; +import { dataEnvelope, errorEnvelope, stdResponses } from "./shared"; + +// ── Cycle tracking (v1.15.0) ───────────────────────────────────────── +// The `/api/cycle/*` capture / calendar / history / settings surface + +// the cycle-prefs PATCH. Request bodies come from the Zod validation +// module so the spec stays single-source; response DTOs are declared +// here mirroring `src/lib/cycle/dto.ts`. Every `/api/cycle/*` route also +// 403s `{ errorCode:"cycle.disabled" }` when the feature gate is off. + +const predictionMethodEnumOpenapi = z + .enum(["CALENDAR", "SYMPTOTHERMAL", "TEMPERATURE_TREND", "BLENDED"]) + .meta({ id: "CyclePredictionMethod" }); + +const cyclePhaseEnumOpenapi = z + .enum(["MENSTRUAL", "FOLLICULAR", "OVULATORY", "LUTEAL"]) + .meta({ id: "CyclePhase" }); + +flowLevelEnum.meta({ + id: "FlowLevel", + description: "Menstrual-flow intensity.", +}); +ovulationTestEnum.meta({ + id: "OvulationTest", + description: "Ovulation predictor-kit (OPK) reading.", +}); +cervicalMucusEnum.meta({ + id: "CervicalMucus", + description: "Cervical-mucus quality.", +}); +homeTestResultEnum.meta({ + id: "HomeTestResult", + description: "At-home test result (pregnancy / progesterone).", +}); +cycleTrackingGoalEnum.meta({ + id: "CycleTrackingGoal", + description: "Drives cycle copy + fertile-window gating.", +}); + +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({ + 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({ + 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({ + id: "CyclePrefsRequest", + description: + "Partial cycle-preferences deep-merge. `enabled` flips the feature gate (`cycleTrackingEnabled`). Omitted fields are left untouched.", +}); + +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.", +}); + +const cycleSymptomDto = z.object({ + key: z.string(), + severity: z.number().int().min(1).max(4).nullable(), +}); + +const cycleDayLogDto = z + .object({ + id: z.string(), + date: z.string(), + cycleId: z.string().nullable(), + flow: flowLevelEnum.nullable(), + intermenstrualBleeding: z.boolean(), + basalBodyTempC: z.number().nullable(), + ovulationTest: ovulationTestEnum.nullable(), + cervicalMucus: cervicalMucusEnum.nullable(), + sexualActivity: z.boolean(), + protectedSex: z.boolean().nullable(), + pregnancyTest: homeTestResultEnum.nullable(), + progesteroneTest: homeTestResultEnum.nullable(), + contraceptive: z.string().nullable(), + symptoms: z.array(cycleSymptomDto), + note: z.string().nullable(), + source: z.string(), + externalId: z.string().nullable(), + syncVersion: z.number().int(), + updatedAt: z.iso.datetime({ offset: true }), + deletedAt: z.iso.datetime({ offset: true }).nullable(), + }) + .meta({ + id: "CycleDayLogDTO", + description: + "The canonical day-log row iOS mirrors. `note` is decrypted on read. Soft-deleted rows ride `/api/sync/changes` as tombstones.", + }); + +const menstrualCycleDto = z + .object({ + id: z.string(), + startDate: z.string(), + endDate: z.string().nullable(), + periodEndDate: z.string().nullable(), + lengthDays: z.number().int().nullable(), + ovulationDate: z.string().nullable(), + ovulationConfirmed: z.boolean(), + isPredicted: z.boolean(), + syncVersion: z.number().int(), + updatedAt: z.iso.datetime({ offset: true }), + }) + .meta({ + id: "MenstrualCycleDTO", + description: "One menstrual cycle (observed or forward-predicted).", + }); + +const cyclePredictionDto = z + .object({ + method: predictionMethodEnumOpenapi, + nextPeriodStart: z.string(), + nextPeriodStartLow: z.string(), + nextPeriodStartHigh: z.string(), + fertileWindowStart: z.string().nullable(), + fertileWindowEnd: z.string().nullable(), + predictedOvulation: z.string().nullable(), + ovulationConfirmed: z.boolean(), + confidence: z.number(), + cyclesObserved: z.number().int(), + stillLearning: z.boolean(), + disclaimer: z.string(), + }) + .meta({ + id: "CyclePredictionDTO", + description: + "The materialised forecast. Fertile-window fields (and predictedOvulation/ovulationConfirmed) are server-suppressed (null/false) unless the goal is TRYING_TO_CONCEIVE or AVOID_PREGNANCY.", + }); + +const cycleCalendarDayDto = z.object({ + date: z.string(), + phase: cyclePhaseEnumOpenapi.nullable(), + isPredictedPeriod: z.boolean(), + isFertileWindow: z.boolean(), + isPredictedOvulation: z.boolean(), + isPeriodLogged: z.boolean(), + flow: flowLevelEnum.nullable(), + hasSymptoms: z.boolean(), + confidence: z.number(), + // v1.15.0 — logged basal-body-temperature + fertility-sign markers, surfaced + // so the web BBT chart renders from the calendar read (the values are already + // loaded server-side for the symptothermal layer; no extra query). + basalBodyTempC: z.number().nullable(), + ovulationTest: ovulationTestEnum.nullable(), + cervicalMucus: cervicalMucusEnum.nullable(), +}); + +const cycleProfileDto = z + .object({ + goal: cycleTrackingGoalEnum, + cycleTrackingEnabled: z.boolean(), + rawChartMode: z.boolean(), + predictionEnabled: z.boolean(), + discreetNotifications: z.boolean(), + sensitiveCategoryEncryption: z.boolean(), + typicalCycleLength: z.number().int().nullable(), + typicalPeriodLength: z.number().int().nullable(), + lutealPhaseLength: z.number().int().nullable(), + updatedAt: z.iso.datetime({ offset: true }), + }) + .meta({ + id: "CycleProfileDTO", + description: "The full per-user cycle settings row.", + }); + +const cycleCalendarResponse = z.object({ + profile: z.object({ + goal: cycleTrackingGoalEnum, + rawChartMode: z.boolean(), + predictionEnabled: z.boolean(), + cyclesObserved: z.number().int(), + }), + prediction: cyclePredictionDto.nullable(), + days: z.array(cycleCalendarDayDto), + meta: z.object({ generatedAt: z.iso.datetime({ offset: true }) }), +}); + +const cycleHistoryResponse = z.object({ + cycles: z.array(menstrualCycleDto), + stats: z.object({ + avgLengthDays: z.number().int().nullable(), + lengthVariabilityDays: z.number().nullable(), + avgPeriodLengthDays: z.number().int().nullable(), + regularity: z.enum(["REGULAR", "IRREGULAR", "LEARNING"]), + }), +}); + +const cyclePeriodResponse = z.object({ + cycle: menstrualCycleDto.nullable(), + dayLog: cycleDayLogDto.nullable(), +}); + +const cyclePhaseCrosstabRow = z + .object({ + metricKey: z.enum([ + "restingHeartRate", + "heartRateVariability", + "sleepDuration", + "steps", + "weight", + "basalBodyTemp", + "wristTemperature", + "skinTemperature", + "bloodGlucose", + "mood", + ]), + display: z.enum([ + "hours", + "steps", + "bpm", + "ms", + "kg", + "celsius", + "glucose", + "mood", + ]), + lutealDays: z.number().int(), + follicularDays: z.number().int(), + lutealAvg: z.number(), + follicularAvg: z.number(), + delta: z.number(), + pValue: z.number(), + qValue: z.number(), + confidence: z.enum(["low", "medium", "high"]), + }) + .meta({ + id: "CyclePhaseCrosstabRow", + description: + "One FDR-surviving luteal-vs-follicular contrast for an outcome metric. `delta = lutealAvg − follicularAvg`. Observational, never causal.", + }); + +const cyclePhaseLaggedPair = z + .object({ + behaviour: z.string(), + outcome: z.string(), + n: z.number().int(), + r: z.number(), + pValue: z.number(), + qValue: z.number(), + interpretation: z.string(), + lagDays: z.number().int(), + }) + .meta({ + id: "CyclePhaseLaggedPair", + description: + "One FDR-surviving lagged-Pearson pair from the continuous CYCLE_PHASE ordinal × outcome matrix (mechanism B). Descriptive, never causal.", + }); + +const cyclePhaseEnumForCount = z.enum([ + "MENSTRUAL", + "FOLLICULAR", + "OVULATORY", + "LUTEAL", +]); + +const cycleSymptomPhaseRow = z + .object({ + symptomKey: z.string(), + counts: z.object({ + MENSTRUAL: z.number().int(), + FOLLICULAR: z.number().int(), + OVULATORY: z.number().int(), + LUTEAL: z.number().int(), + }), + total: z.number().int(), + topPhase: cyclePhaseEnumForCount, + topShare: z.number(), + }) + .meta({ + id: "CycleSymptomPhaseRow", + description: + "Where a logged symptom clusters across the cycle phases. Surfaced only once logged on ≥3 phase-labelled days. Observational, never causal.", + }); + +const cycleInsightsResponse = z.object({ + rows: z.array(cyclePhaseCrosstabRow), + headline: cyclePhaseCrosstabRow.nullable(), + lagged: z.object({ + discovered: z.array(cyclePhaseLaggedPair), + pairsTested: z.number().int(), + fdrQ: z.number(), + minPairs: z.number().int(), + }), + symptomPatterns: z.array(cycleSymptomPhaseRow), + contrast: z.object({ + high: z.literal("LUTEAL"), + low: z.literal("FOLLICULAR"), + }), + windowDays: z.number().int(), + cyclesObserved: z.number().int(), +}); + +const cycleBulkEntryResult = z.object({ + index: z.number().int(), + status: z.enum(["inserted", "duplicate", "updated", "skipped"]), + id: z.string().optional(), + externalId: z.string().optional(), + reason: z.string().optional(), +}); + +const cycleBulkResponse = z.object({ + processed: z.number().int(), + inserted: z.number().int(), + updated: z.number().int(), + duplicates: z.number().int(), + skipped: z.number().int(), + entries: z.array(cycleBulkEntryResult), +}); + +// A reusable 403 the cycle routes carry (the feature gate). +const cycleDisabledResponse = { + "403": { + description: + "Cycle tracking is not enabled for this account (errorCode `cycle.disabled`).", + content: { "application/json": { schema: errorEnvelope } }, + }, +} as const; + +export const cyclePaths: NonNullable = { + "/api/cycle/day-logs": { + get: { + tags: ["Cycle"], + summary: "Read a single day's cycle day-log (v1.15.0)", + description: + "Returns the full `CycleDayLogDTO` for the tz-anchored `date`, or `null` when nothing is logged that day. Lets a client pre-fill an edit sheet. Gated; owner-scoped; soft-deleted rows excluded.", + requestParams: { query: cycleDayLogQuerySchema }, + responses: { + "200": { + description: "The day-log for that date, or null.", + content: { + "application/json": { + schema: dataEnvelope( + cycleDayLogDto.nullable(), + "CycleDayLogReadEnvelope", + ), + }, + }, + }, + ...cycleDisabledResponse, + ...stdResponses, + }, + }, + post: { + tags: ["Cycle"], + summary: "Capture a single cycle day-log (v1.15.0)", + description: + "Upserts on `(userId, source, externalId)` when externalId present, else `(userId, date)`. `note` encrypts at rest. 201 on insert, 200 on update. Gated: `cycle.disabled` 403 when the feature is off.", + requestBody: { + required: true, + content: { "application/json": { schema: cycleDayLogInputSchema } }, + }, + responses: { + "200": { + description: "Existing day-log updated.", + content: { + "application/json": { + schema: dataEnvelope(cycleDayLogDto, "CycleDayLogEnvelope"), + }, + }, + }, + "201": { + description: "New day-log created.", + content: { + "application/json": { + schema: dataEnvelope( + cycleDayLogDto, + "CycleDayLogCreatedEnvelope", + ), + }, + }, + }, + ...cycleDisabledResponse, + ...stdResponses, + }, + }, + }, + "/api/cycle/day-logs/{id}": { + patch: { + tags: ["Cycle"], + summary: "Edit a single cycle day-log (v1.15.0)", + description: + "Partial edit; an omitted field is left untouched. Owner-scoped (a cross-user id 404s). Gated.", + requestParams: { path: z.object({ id: z.string() }) }, + requestBody: { + required: true, + content: { "application/json": { schema: cycleDayLogPatchSchema } }, + }, + responses: { + "200": { + description: "Day-log updated.", + content: { + "application/json": { + schema: dataEnvelope(cycleDayLogDto, "CycleDayLogPatchEnvelope"), + }, + }, + }, + "404": { + description: "Day-log not found / not owned.", + content: { "application/json": { schema: errorEnvelope } }, + }, + ...cycleDisabledResponse, + ...stdResponses, + }, + }, + delete: { + tags: ["Cycle"], + summary: "Soft-delete a cycle day-log (v1.15.0)", + description: + "Sets `deletedAt` + bumps `syncVersion`; surfaces as a tombstone on the next `/api/sync/changes` page. 204. Idempotent.", + requestParams: { path: z.object({ id: z.string() }) }, + responses: { + "204": { description: "Soft-deleted (no body)." }, + "404": { + description: "Day-log not found / not owned.", + content: { "application/json": { schema: errorEnvelope } }, + }, + ...cycleDisabledResponse, + ...stdResponses, + }, + }, + }, + "/api/cycle/day-logs/bulk": { + post: { + tags: ["Cycle"], + summary: "Bulk drain cycle day-logs (Outbox / HealthKit) (v1.15.0)", + description: + "Up to 500 entries; `withIdempotency`; rate-limited `cycle:day-logs:bulk:` 60/min. Per-entry status: inserted | duplicate | updated | skipped. Always 200.", + requestBody: { + required: true, + content: { "application/json": { schema: cycleBulkSchema } }, + }, + responses: { + "200": { + description: "Batch processed (per-entry results).", + content: { + "application/json": { + schema: dataEnvelope(cycleBulkResponse, "CycleBulkEnvelope"), + }, + }, + }, + ...cycleDisabledResponse, + ...stdResponses, + }, + }, + }, + "/api/cycle/period": { + post: { + tags: ["Cycle"], + summary: "Period-boundary shortcut (v1.15.0)", + description: + "One-tap started/ended period. `start` opens a new cycle (closing the prior); `end` stamps periodEndDate. Writes the boundary day-log. Gated.", + requestBody: { + required: true, + content: { "application/json": { schema: cyclePeriodSchema } }, + }, + responses: { + "200": { + description: "Cycle + boundary day-log.", + content: { + "application/json": { + schema: dataEnvelope(cyclePeriodResponse, "CyclePeriodEnvelope"), + }, + }, + }, + ...cycleDisabledResponse, + ...stdResponses, + }, + }, + }, + "/api/cycle/calendar": { + get: { + tags: ["Cycle"], + summary: "Predicted cycle calendar (v1.15.0)", + description: + "Runs the deterministic engine to build `{ profile, prediction, days }`. Fertile-window fields are server-suppressed unless goal is TRYING_TO_CONCEIVE. Default range: today − 90d … +180d. Gated.", + requestParams: { + query: z.object({ + from: z.string().optional(), + to: z.string().optional(), + }), + }, + responses: { + "200": { + description: "Calendar grid + forecast.", + content: { + "application/json": { + schema: dataEnvelope( + cycleCalendarResponse, + "CycleCalendarEnvelope", + ), + }, + }, + }, + ...cycleDisabledResponse, + ...stdResponses, + }, + }, + }, + "/api/cycle/cycles": { + get: { + tags: ["Cycle"], + summary: "Cycle history + stats (v1.15.0)", + description: + "Most-recent cycles (newest first) + `{ avgLengthDays, lengthVariabilityDays (MAD), avgPeriodLengthDays, regularity }`. `limit` default 24. Gated.", + requestParams: { + query: z.object({ + limit: z.coerce.number().int().min(1).max(60).optional(), + }), + }, + responses: { + "200": { + description: "Cycle history.", + content: { + "application/json": { + schema: dataEnvelope( + cycleHistoryResponse, + "CycleHistoryEnvelope", + ), + }, + }, + }, + ...cycleDisabledResponse, + ...stdResponses, + }, + }, + }, + "/api/cycle/insights": { + get: { + tags: ["Cycle"], + summary: "Cycle-phase correlation insights (v1.15.0)", + description: + "FDR-guarded luteal-vs-follicular phase contrast per outcome metric (RHR / HRV / sleep / steps / weight / temperatures), plus the single headline finding (resting-heart-rate-by-phase, falling back to HRV). The same Welch t-test + Benjamini-Hochberg machinery the mood-factor crosstab runs; only rows with p < 0.05 AND q ≤ 0.10 surface. Strictly gender-gated — phase never appears on the general `/api/insights/correlations` route. Observational only, never causal. Gated: `cycle.disabled` 403 when the feature is off.", + responses: { + "200": { + description: "Phase-correlation rows + headline.", + content: { + "application/json": { + schema: dataEnvelope( + cycleInsightsResponse, + "CycleInsightsEnvelope", + ), + }, + }, + }, + ...cycleDisabledResponse, + ...stdResponses, + }, + }, + }, + "/api/cycle/cycles/{id}": { + delete: { + tags: ["Cycle"], + summary: "Soft-delete a menstrual cycle (v1.15.0)", + description: + "Sets `deletedAt` + bumps `syncVersion`; tombstones on the next sync page. 204. Idempotent.", + requestParams: { path: z.object({ id: z.string() }) }, + responses: { + "204": { description: "Soft-deleted (no body)." }, + "404": { + description: "Cycle not found / not owned.", + content: { "application/json": { schema: errorEnvelope } }, + }, + ...cycleDisabledResponse, + ...stdResponses, + }, + }, + }, + "/api/cycle/all": { + delete: { + tags: ["Cycle"], + summary: "Hard-purge all cycle data (v1.15.0)", + description: + "One-click privacy purge: HARD-deletes every cycle day-log (+ symptom links by cascade), menstrual cycle, prediction, the cycle audit trail, and the cycle reminder rows in the push-attempts ledger — no dated reproductive trace survives. The CycleProfile row is left in place. Gated + owner-scoped + audited.", + responses: { + "200": { + description: "Purge counts.", + content: { + "application/json": { + schema: dataEnvelope( + z.object({ + purged: z.boolean(), + dayLogs: z.number().int(), + predictions: z.number().int(), + cycles: z.number().int(), + auditRows: z.number().int(), + pushRows: z.number().int(), + }), + "CyclePurgeEnvelope", + ), + }, + }, + }, + ...cycleDisabledResponse, + ...stdResponses, + }, + }, + }, + "/api/cycle/profile": { + get: { + tags: ["Cycle"], + summary: "Read the full cycle profile (v1.15.0)", + description: "Returns the resolved CycleProfileDTO. Gated.", + responses: { + "200": { + description: "Cycle profile.", + content: { + "application/json": { + schema: dataEnvelope(cycleProfileDto, "CycleProfileEnvelope"), + }, + }, + }, + ...cycleDisabledResponse, + ...stdResponses, + }, + }, + }, + "/api/auth/me/cycle-prefs": { + get: { + tags: ["Cycle"], + summary: "Read cycle preferences (v1.15.0)", + description: + "Returns the resolved CycleProfileDTO. NOT gated — this is the surface that flips the gate.", + responses: { + "200": { + description: "Cycle preferences.", + content: { + "application/json": { + schema: dataEnvelope(cycleProfileDto, "CyclePrefsGetEnvelope"), + }, + }, + }, + ...stdResponses, + }, + }, + patch: { + tags: ["Cycle"], + summary: "Update cycle preferences (v1.15.0)", + description: + "Deep-merges the supplied fields. `enabled` flips `cycleTrackingEnabled`. Returns the merged CycleProfileDTO. NOT gated.", + requestBody: { + required: true, + content: { "application/json": { schema: cyclePrefsSchema } }, + }, + responses: { + "200": { + description: "Merged cycle preferences.", + content: { + "application/json": { + schema: dataEnvelope(cycleProfileDto, "CyclePrefsPatchEnvelope"), + }, + }, + }, + ...stdResponses, + }, + }, + }, +}; diff --git a/src/lib/openapi/routes/devices.ts b/src/lib/openapi/routes/devices.ts new file mode 100644 index 000000000..f88f7f9e3 --- /dev/null +++ b/src/lib/openapi/routes/devices.ts @@ -0,0 +1,85 @@ +/** + * OpenAPI route table — device (APNs) registration. + * + * Part of the OpenAPI route table; aggregated in `./index.ts`. + * Schemas come from `src/lib/validations/*` where shared with the + * runtime request parsing, so the wire contract stays single-source. + */ +import { z } from "zod/v4"; +import type { ZodOpenApiObject } from "zod-openapi"; +import { dataEnvelope, errorEnvelope, stdResponses } from "./shared"; + +const deviceRegisterRequest = z + .object({ + token: z + .string() + .min(8) + .max(512) + .regex(/^[A-Za-z0-9+/=._:-]+$/) + .describe("Generic device identifier (legacy; APNs token below)."), + bundleId: z.string().min(1).max(128), + locale: z.string().min(2).max(16).optional(), + appVersion: z.string().min(1).max(32).optional(), + model: z.string().min(1).max(64).optional(), + apnsToken: z + .string() + .min(8) + .max(256) + .regex(/^[A-Fa-f0-9]+$/) + .optional() + .describe( + "Hex-encoded APNs device token. Must be paired with `apnsEnvironment`.", + ), + apnsEnvironment: z + .enum(["sandbox", "production"]) + .optional() + .describe( + "Gateway the iOS client received `apnsToken` from. Server never auto-detects.", + ), + medicationDelivery: z + .enum(["server", "client"]) + .nullable() + .optional() + .describe( + 'v1.7.0 per-device medication-delivery override. NULL / omitted = inherit the user-level roaming default. "server" forces server APNs for this device; "client" forces local. Stored + echoed; cron suppression stays user-level.', + ), + }) + .meta({ + id: "DeviceRegisterRequest", + description: + "Native device registration. Re-registering an APNs token belonging to another user returns 409 (cross-user-hijack guard).", + }); + +export const devicePaths: NonNullable = { + "/api/devices": { + post: { + tags: ["Devices"], + summary: "Register native device + APNs token", + description: + "Idempotent upsert by `token`. APNs token + environment are paired — supplying one without the other returns 422. Cross-user re-registration of either identifier returns 409.", + requestBody: { + required: true, + content: { "application/json": { schema: deviceRegisterRequest } }, + }, + responses: { + "201": { + description: "Device registered or refreshed.", + content: { + "application/json": { + schema: dataEnvelope( + z.object({ id: z.string() }), + "DeviceRegisterResponse", + ), + }, + }, + }, + "409": { + description: + "Device or APNs token already registered to another user.", + content: { "application/json": { schema: errorEnvelope } }, + }, + ...stdResponses, + }, + }, + }, +}; diff --git a/src/lib/openapi/routes/health-record.ts b/src/lib/openapi/routes/health-record.ts new file mode 100644 index 000000000..22c4e76eb --- /dev/null +++ b/src/lib/openapi/routes/health-record.ts @@ -0,0 +1,295 @@ +/** + * OpenAPI route table — health-record export, clinician share links, FHIR R4 read surface. + * + * Part of the OpenAPI route table; aggregated in `./index.ts`. + * Schemas come from `src/lib/validations/*` where shared with the + * runtime request parsing, so the wire contract stays single-source. + */ +import { z } from "zod/v4"; +import type { ZodOpenApiObject } from "zod-openapi"; +import { exportSelectionSchema } from "@/lib/validations/health-record-export"; +import { createShareLinkSchema } from "@/lib/validations/clinician-share-link"; +import { dataEnvelope, errorEnvelope, stdResponses } from "./shared"; + +// v1.11.0 — clinician share-link owner-facing summary (never the raw token). +const shareLinkSummary = z.object({ + id: z.string(), + label: z.string(), + rangeStart: z.string(), + rangeEnd: z.string().nullable(), + resourceTypes: z.array(z.string()), + allowFhirApi: z.boolean(), + expiresAt: z.string(), + createdAt: z.string(), + revokedAt: z.string().nullable(), + lastAccessAt: z.string().nullable(), + accessCount: z.number(), + active: z.boolean(), +}); + +const shareLinkCreatedResponse = shareLinkSummary.extend({ + token: z + .string() + .describe("Raw `hls_` token — returned ONCE and unrecoverable thereafter."), +}); + +const shareLinkListResponse = z.object({ + shareLinks: z.array(shareLinkSummary), +}); + +const shareLinkRevokedResponse = z.object({ + id: z.string(), + revoked: z.boolean(), +}); + +export const healthRecordPaths: NonNullable = { + "/api/export/health-record": { + post: { + tags: ["Export"], + summary: "Generate a health-record export (PDF / FHIR / package)", + description: + "v1.7.0 flagship export. Returns the doctor-handover artefact in the requested `format`: `pdf` → application/pdf, `fhir` → application/fhir+json (HL7 FHIR R4 document Bundle), `package` → application/zip (PDF + FHIR + README). Auth via cookie or Bearer; shared `export:` rate bucket (10/h). Strict validation: unknown keys 422.", + requestBody: { + required: true, + content: { + "application/json": { schema: exportSelectionSchema }, + }, + }, + responses: { + "200": { + description: + "Export generated. Content-Type varies by `format`: application/pdf, application/fhir+json, or application/zip.", + content: { + "application/pdf": { + schema: z.string().meta({ format: "binary" }), + }, + "application/fhir+json": { + schema: z.string().meta({ format: "binary" }), + }, + "application/zip": { + schema: z.string().meta({ format: "binary" }), + }, + }, + }, + ...stdResponses, + }, + }, + }, + "/api/share-links": { + post: { + tags: ["Export"], + summary: "Create a clinician share link (v1.11.0)", + description: + "Owner-only. Mints an `hls_` token (192-bit), stores only its HMAC hash, and returns the raw token EXACTLY ONCE in the response. Every scope column (window, sections, FHIR resource types, API toggle) is frozen write-once. `expiresAt` is required and capped at 90 days. Auth via cookie or Bearer; rate-limited (`share-link:`, 20/h). Strict: unknown keys 422.", + requestBody: { + required: true, + content: { + "application/json": { schema: createShareLinkSchema }, + }, + }, + responses: { + "201": { + description: + "Share link created. `token` carries the raw `hls_` value and is unrecoverable after this response.", + content: { + "application/json": { + schema: dataEnvelope( + shareLinkCreatedResponse, + "ShareLinkCreated", + ), + }, + }, + }, + ...stdResponses, + }, + }, + get: { + tags: ["Export"], + summary: "List own clinician share links (v1.11.0)", + description: + "Owner-only. Returns the caller's own share links (never the raw token — it is unrecoverable after creation). Auth via cookie or Bearer.", + responses: { + "200": { + description: "Share links owned by the caller.", + content: { + "application/json": { + schema: dataEnvelope(shareLinkListResponse, "ShareLinkList"), + }, + }, + }, + ...stdResponses, + }, + }, + }, + "/api/share-links/{id}": { + delete: { + tags: ["Export"], + summary: "Revoke a clinician share link (v1.11.0)", + description: + "Owner-only. Sets `revokedAt` on the caller's own link. A cross-user or unknown id is sealed as 404. Auth via cookie or Bearer; rate-limited.", + requestParams: { path: z.object({ id: z.string() }) }, + responses: { + "200": { + description: "Link revoked.", + content: { + "application/json": { + schema: dataEnvelope( + shareLinkRevokedResponse, + "ShareLinkRevoked", + ), + }, + }, + }, + "404": { + description: "Link not found (or owned by another user).", + content: { "application/json": { schema: errorEnvelope } }, + }, + ...stdResponses, + }, + }, + }, + "/api/fhir/metadata": { + get: { + tags: ["FHIR"], + summary: "FHIR R4 CapabilityStatement (v1.11.0)", + description: + "Read-only FHIR R4 capability statement for the REST face. Declares the served resource types (Patient, Observation, MedicationStatement, MedicationAdministration), the `$everything` operation, and the `application/fhir+json` format. Auth: `fhir:read` scope (cookie sessions also pass).", + responses: { + "200": { + description: "CapabilityStatement (application/fhir+json).", + content: { + "application/fhir+json": { + schema: z.string().meta({ format: "binary" }), + }, + }, + }, + ...stdResponses, + }, + }, + }, + "/api/fhir/Patient": { + get: { + tags: ["FHIR"], + summary: "FHIR R4 Patient search (v1.11.0)", + description: + "Read-only `searchset` Bundle of the caller's own Patient resource. Auth: `fhir:read` scope. Offset paging via `_count` (clamped ≤200) / `_offset`. `userId` is narrowed from auth.", + requestParams: { + query: z.object({ + _count: z.coerce.number().optional(), + _offset: z.coerce.number().optional(), + }), + }, + responses: { + "200": { + description: "searchset Bundle (application/fhir+json).", + content: { + "application/fhir+json": { + schema: z.string().meta({ format: "binary" }), + }, + }, + }, + ...stdResponses, + }, + }, + }, + "/api/fhir/Observation": { + get: { + tags: ["FHIR"], + summary: "FHIR R4 Observation search (v1.11.0)", + description: + "Read-only `searchset` Bundle of the caller's own Observations (vitals / activity / lab / survey). Auth: `fhir:read` scope. Offset paging via `_count` (clamped ≤200) / `_offset`.", + requestParams: { + query: z.object({ + _count: z.coerce.number().optional(), + _offset: z.coerce.number().optional(), + }), + }, + responses: { + "200": { + description: "searchset Bundle (application/fhir+json).", + content: { + "application/fhir+json": { + schema: z.string().meta({ format: "binary" }), + }, + }, + }, + ...stdResponses, + }, + }, + }, + "/api/fhir/MedicationStatement": { + get: { + tags: ["FHIR"], + summary: "FHIR R4 MedicationStatement search (v1.11.0)", + description: + "Read-only `searchset` Bundle of the caller's own active-medication statements. Auth: `fhir:read` scope. Offset paging via `_count` (≤200) / `_offset`.", + requestParams: { + query: z.object({ + _count: z.coerce.number().optional(), + _offset: z.coerce.number().optional(), + }), + }, + responses: { + "200": { + description: "searchset Bundle (application/fhir+json).", + content: { + "application/fhir+json": { + schema: z.string().meta({ format: "binary" }), + }, + }, + }, + ...stdResponses, + }, + }, + }, + "/api/fhir/MedicationAdministration": { + get: { + tags: ["FHIR"], + summary: "FHIR R4 MedicationAdministration search (v1.11.0)", + description: + "Read-only `searchset` Bundle of the caller's own acted intakes (completed / not-done). Auth: `fhir:read` scope. Offset paging via `_count` (≤200) / `_offset`.", + requestParams: { + query: z.object({ + _count: z.coerce.number().optional(), + _offset: z.coerce.number().optional(), + }), + }, + responses: { + "200": { + description: "searchset Bundle (application/fhir+json).", + content: { + "application/fhir+json": { + schema: z.string().meta({ format: "binary" }), + }, + }, + }, + ...stdResponses, + }, + }, + }, + "/api/fhir/$everything": { + get: { + tags: ["FHIR"], + summary: "FHIR R4 $everything (v1.11.0)", + description: + "Read-only `$everything` operation: every resource in the caller's own record (Patient, Coverage, Observations, MedicationStatements, MedicationAdministrations) in one `searchset` Bundle. Auth: `fhir:read` scope. Offset paging via `_count` (≤200) / `_offset`.", + requestParams: { + query: z.object({ + _count: z.coerce.number().optional(), + _offset: z.coerce.number().optional(), + }), + }, + responses: { + "200": { + description: "searchset Bundle (application/fhir+json).", + content: { + "application/fhir+json": { + schema: z.string().meta({ format: "binary" }), + }, + }, + }, + ...stdResponses, + }, + }, + }, +}; diff --git a/src/lib/openapi/routes/index.ts b/src/lib/openapi/routes/index.ts new file mode 100644 index 000000000..dd60a0933 --- /dev/null +++ b/src/lib/openapi/routes/index.ts @@ -0,0 +1,83 @@ +/** + * OpenAPI route table — thin aggregation index. + * + * The route table is split into domain modules under this directory; + * this index assembles them into the single `openApiPaths` object the + * registry consumes. The spread order below is load-bearing: the YAML + * emitter runs with `sortMapEntries: false` (see + * `scripts/generate-openapi.ts`), so the key order of this object is + * the path order of the committed `docs/api/openapi.yaml`. Keep the + * spread sequence stable when adding a module — append inside the + * domain module, not by reordering here. + * + * Schemas come from `src/lib/validations/*` so the wire contract stays + * single-source-of-truth. The `.meta()` annotations on each schema land + * the title + description in `components.schemas.*` automatically. + * + * Routes are intentionally registered as inline operation objects + * rather than via a wrapping helper — `zod-openapi`'s `createDocument` + * type-checks the route table directly, which catches schema-shape + * regressions at typecheck time. + */ +import type { ZodOpenApiObject } from "zod-openapi"; + +import { adminDiagnosticPaths, adminInvitePaths } from "./admin"; +import { authPaths } from "./auth"; +import { coachFeedbackPaths, coachPaths } from "./coach"; +import { cyclePaths } from "./cycle"; +import { devicePaths } from "./devices"; +import { healthRecordPaths } from "./health-record"; +import { insightsPaths } from "./insights"; +import { measurementPaths } from "./measurements"; +import { medicationPaths, medicationResource } from "./medications"; +import { metaPaths } from "./meta"; +import { profilePaths } from "./profile"; +import { syncPaths } from "./sync"; +import { workoutPaths } from "./workouts"; + +export const openApiPaths: NonNullable = { + ...cyclePaths, + ...metaPaths, + ...healthRecordPaths, + ...authPaths, + ...syncPaths, + ...measurementPaths, + ...workoutPaths, + ...devicePaths, + ...medicationPaths, + ...profilePaths, + ...coachPaths, + ...adminInvitePaths, + ...coachFeedbackPaths, + ...adminDiagnosticPaths, + ...insightsPaths, +}; + +export const openApiComponents: NonNullable = { + // Schemas listed here are forced into `components.schemas` even when no + // route references them directly. `Medication` lives in this slot + // because its only consumers are the `MedicationListEntry` / + // `MedicationDetail` variants which extend it — `.extend()` inlines + // the base shape into the derived schema, so without an explicit + // registration the standalone `Medication` component would never + // emit. The iOS codegen reads from `Medication` directly for the + // shared Swift struct backing both variants. + schemas: { + Medication: medicationResource, + }, + securitySchemes: { + bearerAuth: { + type: "http", + scheme: "bearer", + bearerFormat: "hlk_<64hex>", + description: + "Native-client API token. Use the `token` field returned by /api/auth/login on a native client.", + }, + cookieAuth: { + type: "apiKey", + in: "cookie", + name: "healthlog_session", + description: "Browser session cookie — set by /api/auth/login.", + }, + }, +}; diff --git a/src/lib/openapi/routes/insights.ts b/src/lib/openapi/routes/insights.ts new file mode 100644 index 000000000..aeec01ce5 --- /dev/null +++ b/src/lib/openapi/routes/insights.ts @@ -0,0 +1,966 @@ +/** + * OpenAPI route table — dashboard snapshot, comprehensive insights, analytics range, metric status, derived metrics, correlations. + * + * Part of the OpenAPI route table; aggregated in `./index.ts`. + * Schemas come from `src/lib/validations/*` where shared with the + * runtime request parsing, so the wire contract stays single-source. + */ +import { z } from "zod/v4"; +import type { ZodOpenApiObject } from "zod-openapi"; +import { measurementTypeEnum } from "@/lib/validations/measurement"; +import { METRIC_STATUS_IDS } from "@/lib/insights/metric-status-registry"; +import { + DERIVED_METRIC_IDS, + VITALS_BASELINE_TYPES, +} from "@/lib/insights/derived/registry"; +import { ANALYTICS_RANGES } from "@/lib/analytics/range-delta"; +import { dataEnvelope, stdResponses } from "./shared"; + +const insightsComprehensiveResponse = z + .object({ + summary: z.string(), + recommendations: z.array(z.record(z.string(), z.unknown())), + citations: z.array(z.record(z.string(), z.unknown())), + warnings: z.array(z.record(z.string(), z.unknown())), + dailyBriefing: z.record(z.string(), z.unknown()).nullable().optional(), + trendAnnotations: z.record(z.string(), z.unknown()).nullable().optional(), + storyboardAnnotations: z + .array(z.record(z.string(), z.unknown())) + .optional(), + metricSource: z.record(z.string(), z.unknown()).optional(), + }) + .meta({ + id: "InsightsComprehensiveResponse", + description: + "AI-generated insights bundle. Strict-schema validated server-side; Coach-routed when the insight surface needs day-level grounding.", + }); + +// v1.8.7.1 — generic per-HealthKit-metric assessment. The query enum is +// derived from the same registry the route validates against, so the +// spec, the route, and the cache scope cannot drift. The seven +// specialised metrics (weight / blood-pressure / pulse / bmi / mood / +// medication-compliance) keep their own routes and are NOT accepted here. +const metricStatusQuery = z + .object({ + metric: z + .enum(METRIC_STATUS_IDS as [string, ...string[]]) + .describe( + "HealthKit metric id to assess (e.g. RESTING_HEART_RATE, SLEEP_DURATION). Closed enum: an unknown id 422s. The seven specialised metrics are served by their own routes and are not accepted here.", + ), + locale: z + .enum(["de", "en"]) + .optional() + .describe("Optional UI-locale override; defaults to the session locale."), + }) + .meta({ id: "MetricStatusQuery" }); + +const metricStatusResponse = z + .object({ + hasProvider: z + .boolean() + .describe( + "False when the user has no usable AI provider — `text` then carries the generic no-key guidance.", + ), + text: z + .string() + .nullable() + .describe( + "The assessment narrative (plain text, rendered as React text children). Null while a first generation is preparing, or when the metric has insufficient data.", + ), + cached: z + .boolean() + .describe("True when `text` is served from cache (incl. last-good)."), + updatedAt: z.iso + .datetime({ offset: true }) + .nullable() + .describe("When the served assessment was generated; null when none."), + preparing: z + .boolean() + .optional() + .describe( + "True when a first assessment is being generated out of band and no prior text exists yet — the client polls until it lands.", + ), + revalidating: z + .boolean() + .optional() + .describe( + "True when `text` is served from last-good cache (stale-while-revalidate) while a fresh generation is in flight. The payload is otherwise terminal; the client keeps polling on `preparing || revalidating` (bounded) so the open card upgrades to the warmed assessment without a remount.", + ), + insufficient: z + .boolean() + .optional() + .describe( + "True when the metric has no readings; no assessment is generated (no LLM call). The card shows its insufficient-data state.", + ), + }) + .meta({ + id: "MetricStatusResponse", + description: + "Generic per-metric assessment envelope. Identical shape to the seven specialised `*-status` cards so the `InsightStatusCard` consumes it unchanged. Read-only + stale-while-revalidate: a cache miss warms a generation out of band and serves the last-good text meanwhile.", + }); + +// v1.10.0 — generic derived-wellness-metric route. The query enum is +// derived from the same registry the route validates against, so spec + +// route + cache scope cannot drift. `type` sub-targets the single vital +// a baseline metric (VITALS_BASELINE) bands over. +const derivedMetricQuery = z + .object({ + metric: z + .enum(DERIVED_METRIC_IDS as [string, ...string[]]) + .describe( + "Derived-metric id to compute (e.g. VITALS_BASELINE, FITNESS_AGE, VASCULAR_AGE_DELTA, HRV_BALANCE, BMI, READINESS). Closed enum: an unknown id 422s. Metrics whose compute has not yet landed return an `insufficient` value with reason `not_implemented`.", + ), + type: z + .enum(VITALS_BASELINE_TYPES as [string, ...string[]]) + .optional() + .describe( + "For VITALS_BASELINE only — the single vital to band (defaults to RESTING_HEART_RATE). Ignored by composites. An unsupported value yields an `insufficient` value rather than a 422 so iOS metric combinations stay forgiving.", + ), + }) + .meta({ id: "DerivedMetricQuery" }); + +const derivedCoverage = z + .object({ + requiredInputs: z + .number() + .int() + .describe("Inputs the metric wants (its full input set)."), + presentInputs: z + .number() + .int() + .describe("Inputs actually present in the user's data."), + historyDays: z + .number() + .int() + .describe( + "Distinct days of history backing the value (the gating floor).", + ), + missing: z + .array(z.string()) + .describe( + "Named inputs still missing — drives the 'track N more' nudge.", + ), + }) + .meta({ id: "DerivedCoverage" }); + +const derivedConfidence = z + .object({ + score: z + .number() + .describe( + "0..100 confidence; feeds the shared coverage meter unchanged.", + ), + band: z + .enum(["high", "medium", "low", "draft"]) + .describe("Confidence band the meter renders."), + }) + .meta({ id: "DerivedConfidence" }); + +const derivedProvenance = z + .object({ + inputs: z + .array(z.string()) + .describe("Named inputs that actually backed the value."), + source: z + .enum(["DAY", "WEEK", "MONTH", "YEAR", "live", "none"]) + .describe( + "Granularity the dominant read resolved against. 'live' = a coverage-miss live-SQL fallback; 'none' = no data backed the value.", + ), + windowDays: z + .number() + .int() + .describe("Trailing window the value summarises, in days."), + computedAt: z.iso + .datetime({ offset: true }) + .describe("Compute time (for cache-staleness + the 'as of' chip)."), + }) + .meta({ id: "DerivedProvenance" }); + +// v1.13.2 — per-derived-SCORE assessment text. Additive, non-breaking field +// on the derived response; the iOS field-name contract is LOCKED. +const derivedAssessment = z + .object({ + text: z + .string() + .describe( + "Short, non-empty explanation of why the score sits where it does, referencing the score's contributors.", + ), + source: z + .string() + .describe( + "'deterministic' for the always-on template text, or 'ai' when warmer provider prose has been cached.", + ), + updatedAt: z.iso + .datetime({ offset: true }) + .describe("When the text was produced / last warmed."), + }) + .meta({ id: "DerivedAssessment" }); + +const derivedMetricResponse = z + .object({ + metric: z + .enum(DERIVED_METRIC_IDS as [string, ...string[]]) + .describe("Echoes the requested derived-metric id (tags the union)."), + status: z + .enum(["ok", "insufficient"]) + .describe( + "'ok' carries `value` + `confidence`; 'insufficient' carries `reason` and no value, but still carries `coverage` + `provenance` so the surface renders the same gating UI.", + ), + value: z + .record(z.string(), z.unknown()) + .nullable() + .describe( + "Metric-specific value object when status is 'ok' (e.g. { type, center, low, high, spread, sampleDays, k, series } for VITALS_BASELINE, where `series` is the trailing per-day mean values for the inline sparkline); null when 'insufficient'.", + ), + coverage: derivedCoverage, + confidence: derivedConfidence + .nullable() + .describe("Present when status is 'ok'; null when 'insufficient'."), + provenance: derivedProvenance, + reason: z + .string() + .nullable() + .describe( + "Why the value could not be produced; null when status is 'ok'.", + ), + assessment: derivedAssessment + .nullable() + .describe( + "v1.13.2 — short 'why is this score what it is' explanation, keyed to the SAME requested id (only for the per-score ids READINESS, SLEEP_SCORE, RECOVERY_SCORE, STRAIN_SCORE, STRESS_SCORE). Null for any other metric and whenever status !== 'ok'. Always non-empty when present: a deterministic text fills it (so provider-less accounts + the demo always get one) and warmer AI prose overrides it once cached.", + ), + }) + .meta({ + id: "DerivedMetricResponse", + description: + "Flat `Derived` envelope for one derived wellness metric. Pure compute over the rollup tier (no LLM, no narrative). iOS decodes one stable shape and combines values across metrics; coverage/confidence/provenance let it render the same honesty chips.", + }); + +// v1.10.0 — batched derived-metric query. The `metrics` CSV carries one +// or more `metric` / `metric:type` tokens; the route fans out server-side +// under a bounded limiter with the profile loaded once, collapsing the +// dashboard's cold-mount fan-out of N single-metric requests into one. +const derivedBatchQuery = z + .object({ + metrics: z + .string() + .min(1) + .max(1024) + .describe( + "Comma-separated derived-metric tokens. Each is a `` or `:` (the colon sub-targets a VITALS_BASELINE vital). An unknown id 422s; at most 24 tokens; duplicates collapse.", + ), + }) + .meta({ id: "DerivedBatchQuery" }); + +const derivedBatchResponse = z + .object({ + metrics: z + .record(z.string(), derivedMetricResponse) + .describe( + "Map keyed by the per-request token (`` or `:`). Each value is the same flat `Derived` envelope the single-metric route returns, so a client decodes one shape and reads back exactly the tokens it asked for.", + ), + }) + .meta({ + id: "DerivedBatchResponse", + description: + "Batched derived-metric values. One request resolves the whole dashboard grid (the wellness scores + the derived re-frames + one baseline per vital) instead of N concurrent single-metric requests sharing the Prisma pool. Pure compute over the rollup tier — no LLM, no narrative, no cache table.", + }); + +// v1.10.0 — FDR-controlled correlation discovery result. One discovered, +// statistically-defensible behaviour → next-day-outcome pair. +const discoveredCorrelation = z + .object({ + behaviour: z + .string() + .describe("Behaviour channel (lag source), e.g. TIME_IN_DAYLIGHT, MOOD."), + outcome: z + .string() + .describe( + "Outcome channel (lag target), e.g. SLEEP_DURATION, HEART_RATE_VARIABILITY.", + ), + n: z + .number() + .int() + .describe("Paired-day count after the day+1 lag join (≥ 20)."), + r: z.number().describe("Pearson r over the lag-joined daily series."), + pValue: z.number().describe("Two-sided exact Student-t p-value (< 0.05)."), + qValue: z + .number() + .describe( + "Benjamini-Hochberg FDR-adjusted q-value (≤ the surface threshold).", + ), + interpretation: z + .string() + .describe("Conservative, descriptive interpretation — never causal."), + lagDays: z.number().int().describe("Lag in days applied (1)."), + }) + .meta({ id: "DiscoveredCorrelation" }); + +const correlationDiscoveryResponse = z + .object({ + discovered: z + .array(discoveredCorrelation) + .describe("Pairs surviving n ≥ 20, p < 0.05, AND the BH-FDR control."), + pairsTested: z + .number() + .int() + .describe("Behaviour × outcome pairs assessed (for the honest footer)."), + fdrQ: z.number().describe("The FDR target the surface used."), + minPairs: z + .number() + .int() + .describe("Minimum paired-day count enforced per pair."), + }) + .meta({ + id: "CorrelationDiscoveryResponse", + description: + "v1.10.0 — FDR-controlled correlation discovery over a curated behaviour × outcome matrix, lagged behaviour → next-day outcome. Only statistically-defensible pairs surface; descriptive, never causal.", + }); + +// The seven specialised `*-status` routes accept an optional locale +// override (the metric is fixed by the route path, unlike the generic +// metric-status route which carries it as a query field). +const insightStatusQuery = z + .object({ + locale: z + .enum(["de", "en"]) + .optional() + .describe("Optional UI-locale override; defaults to the session locale."), + }) + .meta({ id: "InsightStatusQuery" }); + +// Shared response shape for the five text-bearing specialised status +// routes (blood-pressure, pulse, weight, bmi, mood). Same envelope as +// the generic metric-status card minus the `insufficient` flag, which is +// metric-status-only. Read-only + stale-while-revalidate. +const insightStatusResponse = z + .object({ + hasProvider: z + .boolean() + .describe( + "False when the user has no usable AI provider — `text` then carries the generic no-key guidance.", + ), + text: z + .string() + .nullable() + .describe( + "The assessment narrative (plain text, rendered as React text children). Null while a first generation is preparing.", + ), + cached: z + .boolean() + .describe("True when `text` is served from cache (incl. last-good)."), + updatedAt: z.iso + .datetime({ offset: true }) + .nullable() + .describe("When the served assessment was generated; null when none."), + preparing: z + .boolean() + .optional() + .describe( + "True when a first assessment is being generated out of band and no prior text exists yet — the client polls until it lands.", + ), + revalidating: z + .boolean() + .optional() + .describe( + "True when `text` is served from last-good cache (stale-while-revalidate) while a fresh generation is in flight. The client keeps polling on `preparing || revalidating` (bounded) so the open card upgrades to the warmed assessment without a remount.", + ), + }) + .meta({ + id: "InsightStatusResponse", + description: + "Specialised per-metric assessment envelope (blood-pressure, pulse, weight, bmi, mood). Identical shape to the generic metric-status card so the `InsightStatusCard` consumes it unchanged. Read-only + stale-while-revalidate: a cache miss warms a generation out of band and serves the last-good text meanwhile.", + }); + +// The medication-compliance route carries a richer envelope than the +// other six: a `summary` narrative plus a per-medication `text` array, +// instead of a single `text` field. +const medicationComplianceStatusResponse = z + .object({ + hasProvider: z + .boolean() + .describe( + "False when the user has no usable AI provider — `summary` then carries the generic no-key guidance.", + ), + summary: z + .string() + .nullable() + .describe( + "The overall compliance narrative (plain text). Null while a first generation is preparing.", + ), + medications: z + .array( + z + .object({ + medicationId: z + .string() + .describe("The medication this note belongs to."), + text: z + .string() + .describe("Per-medication compliance note (plain text)."), + }) + .meta({ id: "MedicationComplianceStatusItem" }), + ) + .describe( + "Per-medication compliance notes. Empty while preparing or when no medication qualifies.", + ), + cached: z + .boolean() + .describe( + "True when the envelope is served from cache (incl. last-good).", + ), + updatedAt: z.iso + .datetime({ offset: true }) + .nullable() + .describe("When the served assessment was generated; null when none."), + preparing: z + .boolean() + .optional() + .describe( + "True when a first assessment is being generated out of band and no prior summary exists yet — the client polls until it lands.", + ), + revalidating: z + .boolean() + .optional() + .describe( + "True when the envelope is served from last-good cache (stale-while-revalidate) while a fresh generation is in flight. The client keeps polling on `preparing || revalidating` (bounded).", + ), + }) + .meta({ + id: "MedicationComplianceStatusResponse", + description: + "Medication-compliance assessment envelope. Unlike the other six specialised cards it carries a `summary` plus a per-medication `text` array rather than a single `text` field. Read-only + stale-while-revalidate.", + }); + +const analyticsRangeQuery = z + .object({ + type: measurementTypeEnum.describe( + "The measurement type to read (single metric — no fan-out). Closed enum: an unknown type 422s.", + ), + range: z + .enum(ANALYTICS_RANGES) + .describe( + "Trailing window: `7d` / `30d` / `90d` / `1y`. The previous comparable window is the equally-sized span immediately before it.", + ), + }) + .meta({ id: "AnalyticsRangeQuery" }); + +const analyticsWindowAggregate = z + .object({ + count: z.number().int().describe("Reading count composed across buckets."), + min: z.number().nullable().describe("Window minimum; null when empty."), + max: z.number().nullable().describe("Window maximum; null when empty."), + mean: z + .number() + .nullable() + .describe("Count-weighted mean across buckets; null when empty."), + sum: z + .number() + .nullable() + .describe( + "Cumulative total for cumulative metrics (steps, energy, distance); null when no bucket carries a sum.", + ), + }) + .meta({ id: "AnalyticsWindowAggregate" }); + +const analyticsRangeResponse = z + .object({ + range: z + .enum(ANALYTICS_RANGES) + .describe("The range that was read (echoes the request)."), + windowDays: z + .number() + .int() + .describe("Trailing-window length in days for the chosen range."), + granularity: z + .string() + .describe( + "Rollup granularity the read resolved against (`DAY` / `WEEK` / `MONTH` / `YEAR`, or `none` on a coverage miss).", + ), + current: analyticsWindowAggregate.describe( + "Aggregate over the current window `[now-N, now)`.", + ), + previous: analyticsWindowAggregate.describe( + "Aggregate over the previous comparable window `[now-2N, now-N)`.", + ), + delta: z + .number() + .nullable() + .describe( + "`current.mean - previous.mean`; null when either window has no data (never a misleading 0).", + ), + deltaPct: z + .number() + .nullable() + .describe( + "`delta / previous.mean` as a fraction (0.03 = +3 %); null when the prior window has no / zero mean (no divide-by-zero). The client shows 'no prior-period data' in that case.", + ), + }) + .meta({ + id: "AnalyticsRangeResponse", + description: + "Single-metric period-over-period aggregate. Reads the current and previous comparable windows from the WMY rollup tier and composes a count-weighted-mean delta. `count/min/max/mean/sum` are linearly composable across buckets; SD/slope/r² are intentionally excluded (not composable).", + }); + +const insightsPregenerateRequest = z.object({}).meta({ + id: "InsightsPregenerateRequest", + description: + "No body fields. The user is taken from the session / Bearer and the locale from the session; the warm covers every assessment for that user.", +}); + +const insightsPregenerateResponse = z + .object({ + queued: z + .boolean() + .describe("True when the full warm was accepted and enqueued."), + locale: z + .enum(["de", "en"]) + .describe("The locale the assessments are being warmed in."), + }) + .meta({ + id: "InsightsPregenerateResponse", + description: + "Acknowledgement that a full assessment warm was enqueued for the calling user. The generation runs out of band; the text lands in the read-only status routes.", + }); + +// v1.7.0 — unified dashboard first-paint snapshot. One GET that +// assembles every above-the-fold tile field in a single round-trip. +// Two-phase shape: `tiles` (fast, always present) + `extras` (thick, +// nullable on a rollup-coverage miss). The nested AI / DataSummary +// blocks are typed loosely (`z.record`) to match the comprehensive +// response style above — the strict shapes live in their own Zod +// modules and the iOS client does not consume this web-only route. +const dataSummaryRecord = z.record(z.string(), z.unknown()); + +const dashboardSnapshotResponse = z + .object({ + user: z.object({ + username: z.string(), + timezone: z.string(), + heightCm: z.number().nullable(), + dateOfBirth: z.string().nullable(), + gender: z.enum(["MALE", "FEMALE"]).nullable(), + glucoseUnit: z.string().nullable(), + onboardingTourCompleted: z.boolean(), + greetingHour: z.number().int(), + }), + layout: z.record(z.string(), z.unknown()), + // v1.7.0 — full 27-id widget catalogue (16 server-known + 11 + // iOS-only) so a cold-launch first-paint seeds every tile and the + // layout round-trips in one key. Additive alongside the web + // `layout` block, which stays byte-identical. + layoutCatalogue: z + .array( + z.object({ + id: z.string(), + visible: z.boolean(), + order: z.number().int(), + }), + ) + .describe( + "Full 27-id widget catalogue (server-known + iOS-only) with per-widget visibility + order. iOS-only ids are appended default-invisible. The web dashboard reads `layout`; this block is the cold-launch seed for the native client.", + ), + // v1.7.0 — per-chartable-metric latest reading keyed by iOS + // `MetricKind` raw value (e.g. `oxygenSaturation`, + // `heartRateVariability`, `bodyMassIndex`). Derived in-process from + // the slim summaries slice — no extra DB read. + metricStates: z + .record( + z.string(), + z.object({ + value: z.number(), + measuredAt: z.string(), + unit: z.string(), + }), + ) + .describe( + "Latest reading per chartable metric, keyed by the iOS `MetricKind` raw value (the non-obvious raws: `oxygenSaturation`, `totalBodyWater`, `heartRateVariability`, `bodyMassIndex`, `walkingAsymmetryPercentage`, `walkingDoubleSupportPercentage`, `environmentalAudioExposure`, `headphoneAudioExposure`, `activeEnergyBurned`). Each entry carries `value`, `measuredAt` (ISO8601), and the canonical `unit`. Types the user has never logged are omitted.", + ), + tiles: z.object({ + summaries: dataSummaryRecord, + lastSeenByType: z.record(z.string(), z.unknown()), + mood: z.object({ + summary: dataSummaryRecord.nullable(), + entries: z.array( + z.object({ + date: z.string(), + score: z.number(), + samples: z.number().int(), + }), + ), + }), + }), + extras: z + .object({ + bpInTargetPct: z.number().nullable(), + bpInTargetPct7d: z.number().nullable(), + bpInTargetPct30d: z.number().nullable(), + bpInTargetPctAllTime: z.number().nullable(), + bpInTargetPctPriorMonth: z.number().nullable(), + bpInTargetPctPriorYear: z.number().nullable(), + glucoseByContext: dataSummaryRecord, + }) + .nullable(), + briefing: z.record(z.string(), z.unknown()).nullable(), + briefingState: z.enum(["ready", "preparing", "disabled", "no-provider"]), + briefingUpdatedAt: z.string().nullable(), + briefingStale: z + .boolean() + .describe( + "True when `briefing` carries the last good (expired-TTL) briefing while a refresh is pending (`preparing`) or impossible (`no-provider`). Render the stale content with its `briefingUpdatedAt` timestamp instead of a blank tile.", + ), + generatedAt: z.string(), + }) + .meta({ + id: "DashboardSnapshotResponse", + description: + "Unified above-the-fold dashboard payload. `tiles` always arrives (slim summaries + mood + resolved widget layout); `extras` (BD-in-target + per-context glucose) is null on a rollup-coverage miss so the strip never waits on the slowest read. `briefing` is lifted read-only from the pre-generated insight cache — never generated synchronously — and reports `ready` / `preparing` / `disabled` / `no-provider` via `briefingState` (`no-provider` = stale-or-missing cache with no AI provider configured anywhere, so no warm pass will fill it; stop polling and surface a connect-provider hint). A stale-but-parseable briefing is still delivered with `briefingStale: true`. `layoutCatalogue` (full 27-id widget catalogue) and `metricStates` (latest reading per metric, keyed by iOS `MetricKind` raw value) are additive cold-launch seeds for the native client; both derive in-process from data already fetched, adding no DB round-trip.", + }); + +export const insightsPaths: NonNullable = { + "/api/dashboard/snapshot": { + get: { + tags: ["Dashboard"], + summary: "Unified dashboard first-paint snapshot", + description: + "Assembles every above-the-fold tile field in one round-trip from the rollup / mood / widget helpers plus a read-only lift of the pre-generated daily briefing. Two-phase: `tiles` always present, `extras` nullable on a rollup-coverage miss. No LLM is reachable from this path. Cookie or Bearer auth.", + responses: { + "200": { + description: "Dashboard snapshot.", + content: { + "application/json": { + schema: dataEnvelope( + dashboardSnapshotResponse, + "DashboardSnapshotResponseEnvelope", + ), + }, + }, + }, + ...stdResponses, + }, + }, + }, + "/api/insights/comprehensive": { + get: { + tags: ["Insights"], + summary: "Comprehensive AI insights bundle", + description: + "Full Insights surface — daily briefing, recommendations with rationale, optional weekly report + storyboard annotations. Strict-schema validated server-side.", + responses: { + "200": { + description: "Insights bundle.", + content: { + "application/json": { + schema: dataEnvelope( + insightsComprehensiveResponse, + "InsightsComprehensiveResponseEnvelope", + ), + }, + }, + }, + ...stdResponses, + }, + }, + }, + "/api/insights/pregenerate": { + post: { + tags: ["Insights"], + summary: "Warm all AI assessments for the calling user", + description: + "v1.8.7.1 — enqueue a full warm of every AI assessment for the authenticated user (comprehensive insight + the seven specialised status cards + every data-bearing generic metric assessment) in the active locale, so the read-only status GETs serve cached text instantly. Returns immediately; the generation runs out of band on the worker. Empty metrics and provider-less accounts never trigger an LLM call. Short anti-spam bucket (`insights-warm:`, one warm per 3 minutes) → 429 on a tight loop. Auth via cookie or Bearer; `userId` is taken from the session, never the body.", + requestBody: { + required: false, + content: { + "application/json": { schema: insightsPregenerateRequest }, + }, + }, + responses: { + "200": { + description: + "Warm accepted and enqueued. The work runs on the worker; poll the read-only status routes for the text.", + content: { + "application/json": { + schema: dataEnvelope( + insightsPregenerateResponse, + "InsightsPregenerateResponseEnvelope", + ), + }, + }, + }, + ...stdResponses, + }, + }, + }, + "/api/analytics/range": { + get: { + tags: ["Analytics"], + summary: "Single-metric period-over-period range delta", + description: + "v1.9.0 — returns the current-window aggregate, the previous comparable window, and the composed delta for ONE metric type over a `7d` / `30d` / `90d` / `1y` range. Single-type by construction (the metric page is single-metric), so the read is one rollup-tier call covering the trailing 2N days sliced into the two halves — no per-type fan-out. Additive route; the `/api/analytics` envelope is unchanged. Auth via cookie or Bearer.", + requestParams: { + query: analyticsRangeQuery, + }, + responses: { + "200": { + description: "Current + previous window aggregates and the delta.", + content: { + "application/json": { + schema: dataEnvelope( + analyticsRangeResponse, + "AnalyticsRangeResponseEnvelope", + ), + }, + }, + }, + ...stdResponses, + }, + }, + }, + "/api/insights/blood-pressure-status": { + get: { + tags: ["Insights"], + summary: "Blood-pressure assessment", + description: + "Data-driven plain-language assessment of the user's recent blood-pressure readings. Read-only: a cache miss warms a generation out of band and serves the last-good text meanwhile (stale-while-revalidate). Auth via cookie or Bearer.", + requestParams: { + query: insightStatusQuery, + }, + responses: { + "200": { + description: "Assessment envelope (fresh, cached, or preparing).", + content: { + "application/json": { + schema: dataEnvelope( + insightStatusResponse, + "BloodPressureStatusResponseEnvelope", + ), + }, + }, + }, + ...stdResponses, + }, + }, + }, + "/api/insights/pulse-status": { + get: { + tags: ["Insights"], + summary: "Pulse assessment", + description: + "Data-driven plain-language assessment of the user's recent resting-pulse readings. Read-only: a cache miss warms a generation out of band and serves the last-good text meanwhile (stale-while-revalidate). Auth via cookie or Bearer.", + requestParams: { + query: insightStatusQuery, + }, + responses: { + "200": { + description: "Assessment envelope (fresh, cached, or preparing).", + content: { + "application/json": { + schema: dataEnvelope( + insightStatusResponse, + "PulseStatusResponseEnvelope", + ), + }, + }, + }, + ...stdResponses, + }, + }, + }, + "/api/insights/weight-status": { + get: { + tags: ["Insights"], + summary: "Weight assessment", + description: + "Data-driven plain-language assessment of the user's recent weight trend. Read-only: a cache miss warms a generation out of band and serves the last-good text meanwhile (stale-while-revalidate). Auth via cookie or Bearer.", + requestParams: { + query: insightStatusQuery, + }, + responses: { + "200": { + description: "Assessment envelope (fresh, cached, or preparing).", + content: { + "application/json": { + schema: dataEnvelope( + insightStatusResponse, + "WeightStatusResponseEnvelope", + ), + }, + }, + }, + ...stdResponses, + }, + }, + }, + "/api/insights/bmi-status": { + get: { + tags: ["Insights"], + summary: "BMI assessment", + description: + "Data-driven plain-language assessment of the user's body-mass index. Read-only: a cache miss warms a generation out of band and serves the last-good text meanwhile (stale-while-revalidate). Auth via cookie or Bearer.", + requestParams: { + query: insightStatusQuery, + }, + responses: { + "200": { + description: "Assessment envelope (fresh, cached, or preparing).", + content: { + "application/json": { + schema: dataEnvelope( + insightStatusResponse, + "BmiStatusResponseEnvelope", + ), + }, + }, + }, + ...stdResponses, + }, + }, + }, + "/api/insights/mood-status": { + get: { + tags: ["Insights"], + summary: "Mood assessment", + description: + "Data-driven plain-language assessment of the user's recent mood entries. Read-only: a cache miss warms a generation out of band and serves the last-good text meanwhile (stale-while-revalidate). Auth via cookie or Bearer.", + requestParams: { + query: insightStatusQuery, + }, + responses: { + "200": { + description: "Assessment envelope (fresh, cached, or preparing).", + content: { + "application/json": { + schema: dataEnvelope( + insightStatusResponse, + "MoodStatusResponseEnvelope", + ), + }, + }, + }, + ...stdResponses, + }, + }, + }, + "/api/insights/medication-compliance-status": { + get: { + tags: ["Insights"], + summary: "Medication-compliance assessment", + description: + "Data-driven plain-language assessment of the user's medication compliance — an overall `summary` plus a per-medication note array. Read-only: a cache miss warms a generation out of band and serves the last-good envelope meanwhile (stale-while-revalidate). Auth via cookie or Bearer.", + requestParams: { + query: insightStatusQuery, + }, + responses: { + "200": { + description: + "Compliance assessment envelope (fresh, cached, or preparing).", + content: { + "application/json": { + schema: dataEnvelope( + medicationComplianceStatusResponse, + "MedicationComplianceStatusResponseEnvelope", + ), + }, + }, + }, + ...stdResponses, + }, + }, + }, + "/api/insights/metric-status": { + get: { + tags: ["Insights"], + summary: "Generic per-HealthKit-metric assessment", + description: + "v1.8.7.1 — data-driven plain-language assessment for any registered HealthKit metric (resting heart rate, sleep, glucose, body composition, gait, audio exposure, …). One generic route covering ~30 metric pages via archetype prompt templates + per-metric metadata. Read-only: a cache miss warms a generation out of band and serves the last-good text meanwhile (stale-while-revalidate). An unknown `metric` 422s against the closed registry enum. Auth via cookie or Bearer.", + requestParams: { + query: metricStatusQuery, + }, + responses: { + "200": { + description: "Assessment envelope (fresh, cached, or preparing).", + content: { + "application/json": { + schema: dataEnvelope( + metricStatusResponse, + "MetricStatusResponseEnvelope", + ), + }, + }, + }, + ...stdResponses, + }, + }, + }, + "/api/insights/derived": { + get: { + tags: ["Insights"], + summary: "Derived wellness metric (compute-once)", + description: + "v1.10.0 — the compute-once `Derived` value for any registered derived wellness metric (personal typical-range vitals baseline, cardio-fitness band, vascular-age delta, sleep score, readiness, coincident-deviation flag). One generic route over a closed registry enum; an unknown `metric` 422s. Pure compute over the rollup tier with a per-type live fallback on a coverage miss — no LLM call, no narrative, no cache table. Returns the flat `Derived` union so the native client can decode one stable shape and combine values across metrics. Auth via cookie or Bearer.", + requestParams: { + query: derivedMetricQuery, + }, + responses: { + "200": { + description: "The flat derived-metric value (ok or insufficient).", + content: { + "application/json": { + schema: dataEnvelope( + derivedMetricResponse, + "DerivedMetricResponseEnvelope", + ), + }, + }, + }, + ...stdResponses, + }, + }, + }, + "/api/insights/derived/batch": { + get: { + tags: ["Insights"], + summary: "Derived wellness metrics (batched compute-once)", + description: + "v1.10.0 — resolve several derived wellness metrics in ONE request. The `metrics` CSV names the metrics (a `metric:type` token sub-targets a VITALS_BASELINE vital); the server fans out under a bounded limiter with the profile loaded once and returns a map keyed by the per-request token. Collapses the Insights cold-mount fan-out of 14+ independent single-metric requests — the pool-starvation class that surfaces as a hang-then-recover. The single-metric route stays for the per-score detail pages. Auth via cookie or Bearer.", + requestParams: { + query: derivedBatchQuery, + }, + responses: { + "200": { + description: "The map of derived-metric values, keyed by token.", + content: { + "application/json": { + schema: dataEnvelope( + derivedBatchResponse, + "DerivedBatchResponseEnvelope", + ), + }, + }, + }, + ...stdResponses, + }, + }, + }, + "/api/insights/correlations": { + get: { + tags: ["Insights"], + summary: "Correlation discovery (FDR-controlled)", + description: + "v1.10.0 — scans a curated behaviour × outcome matrix (daylight / mood / glucose / BP / steps × sleep / HRV / resting HR / weight), lag-joins each behaviour day to the next day's outcome, runs Pearson with the exact Student-t p-value, and applies Benjamini-Hochberg FDR control across every tested pair. Only statistically-defensible pairs surface, each carrying n, r, p, and the BH-adjusted q. Descriptive, never causal. Gated by the operator `correlations` assistant surface. Auth via cookie or Bearer.", + responses: { + "200": { + description: "The discovered correlations + the tested-pair count.", + content: { + "application/json": { + schema: dataEnvelope( + correlationDiscoveryResponse, + "CorrelationDiscoveryResponseEnvelope", + ), + }, + }, + }, + ...stdResponses, + }, + }, + }, +}; diff --git a/src/lib/openapi/routes/measurements.ts b/src/lib/openapi/routes/measurements.ts new file mode 100644 index 000000000..6f9f5a830 --- /dev/null +++ b/src/lib/openapi/routes/measurements.ts @@ -0,0 +1,362 @@ +/** + * OpenAPI route table — measurements, batch ingest, bulk delete, sleep night. + * + * Part of the OpenAPI route table; aggregated in `./index.ts`. + * Schemas come from `src/lib/validations/*` where shared with the + * runtime request parsing, so the wire contract stays single-source. + */ +import { z } from "zod/v4"; +import type { ZodOpenApiObject } from "zod-openapi"; +import { + createMeasurementSchema, + listMeasurementsSchema, + measurementTypeEnum, + measurementSourceEnum, +} from "@/lib/validations/measurement"; +import { deviceTypeEnum } from "@/lib/validations/source-priority"; +import { dataEnvelope, stdResponses } from "./shared"; + +const batchEntrySchema = z + .object({ + hkIdentifier: z + .string() + .min(1) + .max(120) + .describe( + "HealthKit identifier (e.g. `HKQuantityTypeIdentifierBodyMass`).", + ), + value: z.number().finite(), + unit: z.string().min(1).max(60), + startDate: z.iso.datetime({ offset: true }), + endDate: z.iso.datetime({ offset: true }), + sleepStage: z + .number() + .int() + .min(0) + .max(20) + .optional() + .describe( + "HKCategoryValueSleepAnalysis codepoint; only for sleep samples.", + ), + categoryValue: z + .number() + .int() + .min(0) + .max(20) + .optional() + .describe( + "v1.10.0 — `HKCategoryValue` codepoint for an EVENT-class category sample (irregular-rhythm, high/low-HR, walking-steadiness, breathing-disturbance). Carries the device's own classification verdict / severity, which the server resolves to a stored `rhythmClassification`. HealthLog stores ONLY the device's result — it never re-classifies. Ignored for non-event identifiers.", + ), + externalId: z + .string() + .min(1) + .max(120) + .describe("HKSample.uuid string — the dedup key."), + externalSourceVersion: z.string().min(1).max(120).optional(), + // v1.8.6 W6 — optional per-entry source tag. Defaults to + // `APPLE_HEALTH` server-side when omitted, so legacy clients are + // unchanged. Restricted to the `{APPLE_HEALTH, MANUAL}` subset of + // `MeasurementSource`: `WITHINGS` / `IMPORT` are server-owned and + // rejected on this client-facing route. + source: z + .enum(["APPLE_HEALTH", "MANUAL"]) + .optional() + .describe( + "Source attribution for the row. Defaults to `APPLE_HEALTH` when omitted. Send `MANUAL` to tag rows the user entered by hand on-device (e.g. the standalone adopt-on-pair backfill). Part of the `(userId, type, source, externalId)` dedup key, so the same externalId under a different source is a distinct row. `WITHINGS` / `IMPORT` are not accepted here.", + ), + // v1.4.25 W8c — optional device-type tag fed into the canonical + // source picker's second axis. NULL is treated as `unknown`; + // legacy iOS builds that don't ship the field continue to work. + deviceType: deviceTypeEnum + .nullable() + .optional() + .describe( + "Device class mapped from `HKDevice.model`. Used by the analytics aggregator to break ties when the same source contributed multiple devices for the same day. Omit (or send null) on legacy clients — the server treats it as `unknown` and the picker falls through.", + ), + }) + .meta({ id: "AppleHealthBatchEntry" }); + +const batchPayloadSchema = z + .object({ + entries: z.array(batchEntrySchema).min(1).max(500), + }) + .meta({ + id: "AppleHealthBatchRequest", + description: + "Apple Health batch ingest. ≤500 entries per call; idempotent via `Idempotency-Key`.", + }); + +const batchEntryResult = z + .object({ + index: z.number().int().nonnegative(), + status: z.enum(["inserted", "duplicate", "skipped"]), + reason: z.string().optional(), + }) + .meta({ id: "AppleHealthBatchEntryResult" }); + +const batchResponse = z + .object({ + inserted: z.number().int().nonnegative(), + duplicate: z.number().int().nonnegative(), + skipped: z.number().int().nonnegative(), + entries: z.array(batchEntryResult), + }) + .meta({ id: "AppleHealthBatchResponse" }); + +const deleteByExternalIdsRequest = z + .object({ + externalIds: z.array(z.string().min(1).max(120)).min(0).max(500), + }) + .meta({ + id: "MeasurementsDeleteByExternalIdsRequest", + description: + "iOS deletion-sync. Up to 500 externalIds per call; matching rows owned by other users are silently skipped (cross-user 404 guard).", + }); + +const deleteByExternalIdsResponse = z + .object({ + deletedCount: z.number().int().nonnegative(), + }) + .meta({ id: "MeasurementsDeleteByExternalIdsResult" }); + +// v1.15.13 — multi-select bulk soft-delete for the measurements +// management list. Ids are scoped to the caller; forged / foreign ids +// are silent no-ops (no existence leak). Soft-delete (tombstone), so the +// rows surface in the `/api/sync/changes` delta feed. +const bulkDeleteMeasurementsRequest = z + .object({ + ids: z.array(z.string().min(1)).min(1).max(200), + }) + .meta({ + id: "MeasurementsBulkDeleteRequest", + description: + "1..200 measurement ids, scoped to the caller. Forged / foreign ids are silently skipped (no existence leak).", + }); + +const bulkDeleteMeasurementsResponse = z + .object({ + deleted: z.number().int().nonnegative(), + }) + .meta({ id: "MeasurementsBulkDeleteResult" }); + +export const measurementResource = z + .object({ + id: z.string(), + type: measurementTypeEnum, + value: z.number(), + unit: z.string(), + measuredAt: z.iso.datetime({ offset: true }), + source: measurementSourceEnum, + notes: z.string().nullable().optional(), + }) + .meta({ + id: "MeasurementResource", + description: "Server-shaped measurement row returned by GET endpoints.", + }); + +// ── Sleep night (v1.11.5 hypnogram source) ────────────────────────── + +const sleepStageEnum = z.enum([ + "IN_BED", + "AWAKE", + "ASLEEP", + "REM", + "CORE", + "DEEP", +]); + +const sleepSegmentResource = z.object({ + stage: sleepStageEnum.nullable(), + start: z.iso.datetime({ offset: true }), + end: z.iso.datetime({ offset: true }), + minutes: z.number().int().nonnegative(), +}); + +const sleepSessionResource = z.object({ + night: z.string().describe("Wake-day key (YYYY-MM-DD)."), + source: measurementSourceEnum.nullable(), + start: z.iso.datetime({ offset: true }), + end: z.iso.datetime({ offset: true }), + asleepMinutes: z.number().int().nonnegative(), + inBedMinutes: z.number().int().nonnegative().nullable(), + awakeMinutes: z.number().int().nonnegative().nullable(), + awakenings: z.number().int().nonnegative(), + stages: z.record(sleepStageEnum, z.number()), + segments: z.array(sleepSegmentResource), +}); + +const sleepNightResponse = z + .object({ + night: z.string().nullable(), + main: sleepSessionResource.nullable(), + naps: z.array(sleepSessionResource), + }) + .meta({ + id: "SleepNightResource", + description: + "One reconstructed sleep night: the main session's hypnogram segments + breakdown, plus same-wake-day naps surfaced separately.", + }); + +export const measurementPaths: NonNullable = { + "/api/measurements": { + get: { + tags: ["Measurements"], + summary: "List measurements", + description: + "Filter by type + date range. Response is paged via offset/limit; default limit 100, hard cap 500.", + requestParams: { + query: listMeasurementsSchema, + }, + responses: { + "200": { + description: "Measurement page.", + content: { + "application/json": { + schema: dataEnvelope( + z.object({ + measurements: z.array(measurementResource), + total: z.number().int().nonnegative(), + }), + "ListMeasurementsResponse", + ), + }, + }, + }, + ...stdResponses, + }, + }, + post: { + tags: ["Measurements"], + summary: "Create one measurement", + description: + "Single ingest. Use `/api/measurements/batch` for Apple Health upload streams.", + requestBody: { + required: true, + content: { "application/json": { schema: createMeasurementSchema } }, + }, + responses: { + "201": { + description: "Created.", + content: { + "application/json": { + schema: dataEnvelope( + measurementResource, + "CreateMeasurementResponse", + ), + }, + }, + }, + ...stdResponses, + }, + }, + }, + "/api/measurements/batch": { + post: { + tags: ["Measurements"], + summary: "Apple Health batch ingest", + description: + "Up to 500 HealthKit entries per call. Idempotent via the `Idempotency-Key` header (replay window 24h). Per-entry status lets the iOS client advance its sync cursor accurately. v1.4.25 W8c adds an optional `deviceType` per entry — feed it from `HKDevice.model` so the cross-source canonical picker can break Apple-Watch-vs-iPhone ties; null/absent stays backward-compatible with v1.4.23 clients.", + requestBody: { + required: true, + content: { "application/json": { schema: batchPayloadSchema } }, + }, + responses: { + "200": { + description: "Batch processed.", + content: { + "application/json": { + schema: dataEnvelope(batchResponse, "BatchMeasurementsResponse"), + }, + }, + }, + ...stdResponses, + }, + }, + }, + "/api/measurements/by-external-ids": { + delete: { + tags: ["Measurements"], + summary: "Delete measurements by external ID (iOS deletion-sync)", + description: + "Removes the user's measurement rows whose externalId is in the request list. Rows owned by another user are silently skipped (cross-user 404 guard). Up to 500 externalIds per call.", + requestBody: { + required: true, + content: { + "application/json": { schema: deleteByExternalIdsRequest }, + }, + }, + responses: { + "200": { + description: "Delete batch processed.", + content: { + "application/json": { + schema: dataEnvelope( + deleteByExternalIdsResponse, + "MeasurementsDeleteByExternalIdsResponse", + ), + }, + }, + }, + ...stdResponses, + }, + }, + }, + "/api/measurements/bulk-delete": { + post: { + tags: ["Measurements"], + summary: "Bulk soft-delete measurements", + description: + "Soft-deletes (tombstones) up to 200 of the caller's measurement rows in one call, mirroring the single-DELETE contract. Idempotent via the `Idempotency-Key` header; rate-limited `measurements:bulk-delete:`. Forged / foreign / already-deleted ids are silently skipped — `deleted` is the count of rows actually tombstoned. The deletions surface in `/api/sync/changes` as tombstones.", + requestBody: { + required: true, + content: { + "application/json": { schema: bulkDeleteMeasurementsRequest }, + }, + }, + responses: { + "200": { + description: "Bulk delete processed.", + content: { + "application/json": { + schema: dataEnvelope( + bulkDeleteMeasurementsResponse, + "MeasurementsBulkDeleteResponse", + ), + }, + }, + }, + ...stdResponses, + }, + }, + }, + "/api/sleep/night": { + get: { + tags: ["Measurements"], + summary: "Reconstructed sleep night (hypnogram source, v1.11.5)", + description: + "Returns one night's reconstructed sleep session for the phase-progression (hypnogram) view: the canonical source's stage segments (each with an absolute start/end), the per-stage breakdown, asleep/in-bed/awake totals, the mid-sleep awakenings count, and same-wake-day naps surfaced separately. Session-clustered, keyed by the local wake day, collapsed to one source via the sleep priority ladder so two sources never overlay. `date` omitted returns the most recent night. A read-only view over existing per-stage SLEEP_DURATION rows — no schema, no new measurement type.", + requestParams: { + query: z.object({ + date: z + .string() + .regex(/^\d{4}-\d{2}-\d{2}$/) + .optional() + .describe( + "Wake-day key (YYYY-MM-DD). Omit for the most recent night.", + ), + }), + }, + responses: { + "200": { + description: "Reconstructed night.", + content: { + "application/json": { + schema: dataEnvelope(sleepNightResponse, "SleepNightResponse"), + }, + }, + }, + ...stdResponses, + }, + }, + }, +}; diff --git a/src/lib/openapi/routes/medications.ts b/src/lib/openapi/routes/medications.ts new file mode 100644 index 000000000..7cfa3bc82 --- /dev/null +++ b/src/lib/openapi/routes/medications.ts @@ -0,0 +1,830 @@ +/** + * OpenAPI route table — medications CRUD, intake, cadence, compliance, AI extraction. + * + * Part of the OpenAPI route table; aggregated in `./index.ts`. + * Schemas come from `src/lib/validations/*` where shared with the + * runtime request parsing, so the wire contract stays single-source. + */ +import { z } from "zod/v4"; +import type { ZodOpenApiObject } from "zod-openapi"; +import { + createMedicationSchema, + updateMedicationSchema, + intakeSchema, + MEDICATION_CATEGORY_VALUES, + MEDICATION_TREATMENT_CLASS_VALUES, +} from "@/lib/validations/medication"; +import { medicationExtractionSchema } from "@/lib/ai/coach/medication-extract-prompt"; +import { dataEnvelope, errorEnvelope, stdResponses } from "./shared"; + +// ── Medications (v1.5 scheduling) ──────────────────────────────────── +// +// The Medication + MedicationSchedule resource shapes documented below +// follow the wire envelope the seven routes registered at the bottom of +// this file emit. `windowStart` / `windowEnd` / `daysOfWeek` / +// `intervalWeeks` are the legacy primitives kept for backwards +// compatibility through the v1.5.x line; `timesOfDay`, `rrule`, +// `rollingIntervalDays`, and `reminderGraceMinutes` are the v1.5 +// first-class primitives the wizard + iOS cadence picker write. The +// XOR between `rrule` and `rollingIntervalDays` is documented at the +// schema description AND enforced by the route + a DB CHECK constraint +// so iOS code-gen surfaces the mutual exclusion. + +const medicationCategoryEnum = z.enum(MEDICATION_CATEGORY_VALUES).meta({ + id: "MedicationCategory", + description: + "Clinical taxonomy stored in the `medication_categories` side-table. Orthogonal to `MedicationTreatmentClass`.", +}); + +const medicationTreatmentClassEnum = z + .enum(MEDICATION_TREATMENT_CLASS_VALUES) + .meta({ + id: "MedicationTreatmentClass", + description: + "Prisma-level treatment-class discriminator. `GLP1` unlocks the GLP-1 specialist surfaces (injection-site rotation, titration history, pen inventory, GLP-1-aware Coach).", + }); + +const medicationScheduleResource = z + .object({ + id: z.string(), + medicationId: z.string(), + windowStart: z + .string() + .describe( + "Legacy single-time-of-intake (HH:mm, user local). Preserved for backwards compatibility; the new `timesOfDay` array supersedes it.", + ), + windowEnd: z + .string() + .describe( + "Legacy reminder-window upper bound (HH:mm). Used to derive the late-classification grace span when `reminderGraceMinutes` is null.", + ), + label: z.string().nullable(), + dose: z + .string() + .nullable() + .describe( + "Per-schedule dose override. NULL means the schedule inherits `Medication.dose`.", + ), + daysOfWeek: z + .string() + .nullable() + .describe( + "Legacy persisted recurrence encoding (`null` | `1,3,5` | `i2;1,3,5`). v1.5 readers consult `rrule` first; the field is kept for pre-v1.5 rows. v1.6.0 drops the column.", + ), + timesOfDay: z + .array(z.string()) + .describe( + "v1.5 first-class points-in-time the dose is taken (HH:mm, user local). Backfilled to `[windowStart]` for every pre-v1.5 row.", + ), + reminderGraceMinutes: z + .number() + .int() + .nullable() + .describe( + "Reminder grace window in minutes. NULL falls back to the legacy `windowEnd - windowStart` span.", + ), + rrule: z + .string() + .nullable() + .describe( + "RFC 5545 RRULE string (subset). Used for calendar-anchored cadences. **Mutually exclusive with `rollingIntervalDays`** — exactly one of the two is non-null on any v1.5+ schedule (or both are null on legacy rows that haven't been touched since the migration).", + ), + rollingIntervalDays: z + .number() + .int() + .nullable() + .describe( + "Flexible-rolling interval in days, counted forward from the latest `MedicationIntakeEvent.takenAt`. **Mutually exclusive with `rrule`.**", + ), + scheduleType: z + .enum(["SCHEDULED", "PRN", "CYCLIC"]) + .describe( + "v1.7.0 schedule-type discriminator. SCHEDULED = rrule / rolling / legacy cadence. PRN = as-needed (never projected, reminded, or counted in compliance expected; still loggable via the intake route). CYCLIC = N weeks on / M weeks off, gating whichever inner cadence the rrule / legacy fields describe.", + ), + cyclicOnWeeks: z + .number() + .int() + .nullable() + .describe( + 'v1.7.0 cyclic "on" weeks. Only meaningful when `scheduleType` is CYCLIC; null otherwise.', + ), + cyclicOffWeeks: z + .number() + .int() + .nullable() + .describe( + 'v1.7.0 cyclic "off" weeks. Only meaningful when `scheduleType` is CYCLIC; null otherwise.', + ), + }) + .meta({ + id: "MedicationSchedule", + description: + "Schedule entry attached to a medication. v1.5 promotes `timesOfDay` to first-class and introduces `rrule` (calendar-anchored cadences) and `rollingIntervalDays` (flexible-rolling cadences). The two recurrence primitives are mutually exclusive — enforced by the Zod refine on writes, the route layer, and a DB CHECK constraint (`medication_schedules_rrule_xor_rolling`). v1.7.0 adds `scheduleType` (SCHEDULED / PRN / CYCLIC) and the cyclic on/off-week fields.", + }); + +export const medicationResource = z + .object({ + id: z.string(), + name: z.string(), + dose: z.string(), + treatmentClass: medicationTreatmentClassEnum, + dosesPerUnit: z + .number() + .int() + .nullable() + .describe( + "Doses per pen / vial for inventory tracking. NULL = inventory tracking off.", + ), + active: z.boolean(), + notificationsEnabled: z.boolean(), + liveActivityEnabled: z + .boolean() + .describe( + "v1.7.0 iOS Live Activity opt-in for this medication's reminders. Default false. The iOS client owns the ActivityKit lifecycle; the server only stores + echoes the flag.", + ), + criticalAlarmEnabled: z + .boolean() + .describe( + "v1.7.0 iOS 26 AlarmKit critical-reminder opt-in. Default false. Critical alarms bypass the device mute switch / Focus; the server stores the preference only.", + ), + atcCode: z + .string() + .nullable() + .describe( + "v1.9.0 optional WHO ATC classification code (active-substance class, e.g. `A10BX10`). User/clinician-asserted; never machine-guessed. Emitted on the FHIR `medicationCodeableConcept` under `http://www.whocc.no/atc`. NULL = no code captured.", + ), + rxNormCode: z + .string() + .nullable() + .describe( + "v1.9.0 optional RxNorm RxCUI (numeric, US identifier, e.g. `2601723`). Secondary FHIR coding under `http://www.nlm.nih.gov/research/umls/rxnorm`, alongside any ATC code. NULL = no code captured.", + ), + pausedAt: z.iso.datetime({ offset: true }).nullable(), + snoozedUntil: z.iso.datetime({ offset: true }).nullable(), + nextDueAt: z.iso + .datetime({ offset: true }) + .nullable() + .describe( + "v1.7.0 server-computed next due instant across all the medication's schedules (earliest `nextOccurrenceAfter`). Read-only — computed, not stored. NULL when no schedule has an upcoming slot (paused, one-shot in the past, `endsOn` crossed, every schedule PRN). v1.16.4 — when an unresolved slot's anchor has passed but the catch-up band is still open (`anchor < now <= overdueEnd`, current schedule era), this carries THAT slot (a past instant) with `nextDueOverdue: true` instead of jumping to the next future slot. The list GET is cached 60 s, so a 60 s staleness is accepted.", + ), + nextDueOverdue: z + .boolean() + .describe( + "v1.16.4 — true when `nextDueAt` is an OPEN overdue slot: its anchor has passed, `now` is still inside the slot's catch-up band, and no taken / skipped / auto-missed row resolves it. False for a regular future next-due (and when `nextDueAt` is null). Read-only — computed, not stored.", + ), + startsOn: z.iso + .datetime({ offset: true }) + .nullable() + .describe( + "v1.5 course start (ISO date). Anchors RRULE BYDAY / BYMONTHDAY patterns and the rolling-interval countdown's first window. NULL means active from creation.", + ), + endsOn: z.iso + .datetime({ offset: true }) + .nullable() + .describe( + "v1.5 course end (ISO date). NULL means chronic. Equals `startsOn` when `oneShot` is true.", + ), + oneShot: z + .boolean() + .describe( + "v1.5 single-administration flag. When true the medication has at most one schedule (no `rrule` / `rollingIntervalDays`), and `active` auto-flips to false once the dose is logged.", + ), + createdAt: z.iso.datetime({ offset: true }), + updatedAt: z.iso.datetime({ offset: true }), + schedules: z.array(medicationScheduleResource), + }) + .meta({ + id: "Medication", + description: + "Server-shaped medication row returned by GET / POST / PUT endpoints. Carries the v1.5 course-window fields (`startsOn`, `endsOn`, `oneShot`) at the medication level and the per-schedule cadence fields on the nested `schedules` array.", + }); + +const medicationListEntry = medicationResource + .extend({ + category: medicationCategoryEnum, + lastTakenAt: z.iso + .datetime({ offset: true }) + .nullable() + .describe( + "Latest non-skipped `MedicationIntakeEvent.takenAt` for the medication. Drives the rolling-cadence countdown surface.", + ), + todayEventCount: z + .number() + .int() + .nonnegative() + .describe( + "Number of intake events scheduled for today (user-local day window).", + ), + }) + .meta({ + id: "MedicationListEntry", + description: + "List-row variant of the medication resource enriched with the joined `category`, `lastTakenAt`, and `todayEventCount` fields the dashboard + iOS client consume. The base medication fields (`id`, `name`, `dose`, `treatmentClass`, `dosesPerUnit`, `active`, `notificationsEnabled`, `pausedAt`, `snoozedUntil`, `startsOn`, `endsOn`, `oneShot`, `createdAt`, `updatedAt`, `schedules`) are inlined; see the `Medication` component for their semantics.", + }); + +const medicationDetailEntry = medicationResource + .extend({ + category: medicationCategoryEnum, + }) + .meta({ + id: "MedicationDetail", + description: + "Detail variant of the medication resource enriched with the joined `category`. The base medication fields are inlined; see the `Medication` component for their semantics.", + }); + +const medicationIntakeEventResource = z + .object({ + id: z.string(), + userId: z.string(), + medicationId: z.string(), + scheduledFor: z.iso.datetime({ offset: true }), + takenAt: z.iso.datetime({ offset: true }).nullable(), + skipped: z.boolean(), + source: z.enum(["WEB", "API", "REMINDER", "IMPORT"]), + idempotencyKey: z.string().nullable(), + createdAt: z.iso.datetime({ offset: true }), + }) + .meta({ + id: "MedicationIntakeEvent", + description: + "Single dose log row. `takenAt` is non-null for confirmed intakes; `skipped:true` represents a deliberately-missed dose (no inventory consumption).", + }); + +const medicationCadenceTimelinePoint = z + .object({ + day: z.iso.datetime({ offset: true }), + windowStart: z.iso.datetime({ offset: true }), + windowEnd: z.iso.datetime({ offset: true }), + scheduleIndex: z.number().int().nonnegative(), + status: z.string(), + }) + .meta({ + id: "MedicationCadenceTimelinePoint", + description: + "One expected-vs-actual dose slot for the cadence timeline chart. `status` is one of `taken | skipped | missed | pending | future` and drives the chip colour.", + }); + +const medicationCadenceChips = z + .object({ + adherenceRate: z.number(), + streakDays: z.number().int().nonnegative(), + expectedSlots: z.number().int().nonnegative(), + actualDoses: z.number().int().nonnegative(), + }) + .meta({ + id: "MedicationCadenceChips", + description: + "Four compliance summary values for the medication detail page chip row.", + }); + +const medicationCadenceResponse = z + .object({ + windowDays: z.number().int().positive(), + anchorIso: z.iso.datetime({ offset: true }), + next: z + .object({ + windowStart: z.iso.datetime({ offset: true }), + windowEnd: z.iso.datetime({ offset: true }), + scheduleIndex: z.number().int().nonnegative(), + }) + .nullable(), + chips: medicationCadenceChips, + timeline: z.array(medicationCadenceTimelinePoint), + }) + .meta({ + id: "MedicationCadenceResponse", + description: + "Cadence + compliance read for a single medication. `next` is the upcoming-dose envelope (null when the course has ended or the rolling clock has no pinning intake yet); `timeline` walks the requested `windowDays` worth of slots in ascending time order.", + }); + +const complianceResult = z + .object({ + totalExpected: z + .number() + .int() + .nonnegative() + .describe( + "Full denominator over the window: `taken + skipped + missed`. Cadence-aware and clamped to the medication's `createdAt`.", + ), + taken: z.number().int().nonnegative(), + skipped: z + .number() + .int() + .nonnegative() + .describe( + "Doses the user explicitly skipped — excluded from the `rate` denominator.", + ), + missed: z.number().int().nonnegative(), + rate: z + .number() + .int() + .min(0) + .max(100) + .describe( + "Adherence percentage `round(taken / (taken + missed) * 100)` — `skipped` is excluded from the denominator.", + ), + streak: z + .number() + .int() + .nonnegative() + .describe("Consecutive days with every due dose taken."), + }) + .meta({ + id: "ComplianceResult", + description: + "Rolling-window adherence summary. `compliance30` is the authoritative 'last 30 days, taken vs expected' read — clients should display `rate` and use `totalExpected` as the denominator rather than re-deriving it from the daily map.", + }); + +const dailyComplianceEntry = z + .object({ + expected: z + .number() + .int() + .nonnegative() + .describe( + "Engine-computed due-slot count for the day. Equals `expectedCount`; kept for existing consumers.", + ), + expectedCount: z + .number() + .int() + .nonnegative() + .describe( + "True due-slot count for the day (additive field clients key off so they don't infer due-ness from `expected`).", + ), + due: z + .boolean() + .describe( + "`expectedCount > 0`. Paint a per-day glyph as expected/missed ONLY when `due === true`; off-cadence / pre-creation / PRN days are not misses.", + ), + taken: z.number().int().nonnegative(), + skipped: z.number().int().nonnegative(), + onTime: z + .number() + .int() + .nonnegative() + .describe( + "Doses taken in the on-time band, including the `early` bucket (early counts as compliant).", + ), + late: z.number().int().nonnegative(), + veryLate: z.number().int().nonnegative(), + early: z + .number() + .int() + .nonnegative() + .optional() + .describe( + "Doses taken before the on-time band's grace start; already folded into `onTime`, surfaced separately for consumers that differentiate.", + ), + }) + .meta({ + id: "DailyComplianceEntry", + description: + "Per-day compliance cell with the timing breakdown that drives the history glyph track.", + }); + +const complianceDisplay = z + .object({ + shortDays: z.number().int().positive(), + longDays: z.number().int().positive(), + expectedShort: z.number().int().nonnegative(), + expectedLong: z.number().int().nonnegative(), + minStableDoses: z.number().int().nonnegative(), + short: z.object({ + rate: z.number().int().min(0).max(100), + taken: z.number().int().nonnegative(), + expected: z + .number() + .int() + .nonnegative() + .describe( + "v1.15.9 rate denominator over the short window (`taken + missed`); user skips are excluded. Render `taken / expected · rate%`.", + ), + missed: z + .number() + .int() + .nonnegative() + .describe( + "v1.15.9 doses counted against the rate over the short window — includes forgotten doses the auto-miss cron flipped.", + ), + streak: z.number().int().nonnegative(), + }), + long: z.object({ + rate: z.number().int().min(0).max(100), + taken: z.number().int().nonnegative(), + expected: z + .number() + .int() + .nonnegative() + .describe( + "v1.15.9 rate denominator over the long window (`taken + missed`).", + ), + missed: z + .number() + .int() + .nonnegative() + .describe( + "v1.15.9 doses counted against the rate over the long window.", + ), + }), + currentCycle: z + .object({ + state: z + .enum(["on_track", "due", "missed", "none"]) + .describe( + "Open-cycle state, decoupled from the percentage rows: `on_track` = next dose not yet due; `due` = due now / in grace; `missed` = past grace with no logged intake (the only red state); `none` = no projected next dose (PRN / paused / ended).", + ), + nextDueAt: z.iso + .datetime({ offset: true }) + .nullable() + .describe( + "The open cycle's due instant. Null when `state` is `none`.", + ), + graceUntil: z.iso + .datetime({ offset: true }) + .nullable() + .describe( + "End of the due slot's grace window. Null when `state` is `none`.", + ), + hasClosedCycles: z + .boolean() + .describe( + "False for a brand-new sparse med with zero closed dose cycles — the percentage rows are vacuous and the card should show a neutral 'not enough data yet' state.", + ), + }) + .describe( + "v1.13.x — the current (open) dose cycle, surfaced so a between-doses sparse med renders a neutral 'next dose in N days' line instead of a scary red 0%. The percentage rows above already exclude the open forward cycle from their denominator.", + ), + currentDose: z + .object({ + status: z + .enum([ + "upcoming", + "on_time_window", + "overdue", + "missed", + "taken_on_time", + "taken_late", + "skipped", + ]) + .describe( + "Per-dose state of the open cycle, server-derived from the window model so the card renders the take-window (green) / overdue / heavily-overdue escalation from one authority. `upcoming` when no dose is open (PRN / paused / ended).", + ), + targetAt: z.iso + .datetime({ offset: true }) + .nullable() + .describe( + "Target instant of the open dose. Null when no dose is open.", + ), + }) + .describe( + "v1.15.9 — the open dose's per-dose status + target, so the card highlights the actionable take-window green and escalates an overdue dose without re-deriving the window math client-side.", + ), + }) + .meta({ + id: "ComplianceDisplay", + description: + "The two-row card block whose windows scale with dosing cadence (dense meds keep 7 / 30 days, sparse meds step both windows up). NOT the 30-day denominator — read `compliance30.totalExpected` for that.", + }); + +const medicationComplianceResponse = z + .object({ + compliance7: complianceResult, + compliance30: complianceResult, + dailyCompliance: z + .record(z.string(), dailyComplianceEntry) + .describe( + "Flat per-day map keyed `YYYY-MM-DD` in the user timezone, one entry per day for up to 90 days back, clamped to the medication's `createdAt` (so a recently-created med has fewer entries). No weekly/monthly collapse — this is the raw daily grid.", + ), + complianceDisplay, + }) + .meta({ + id: "MedicationComplianceResponse", + description: + "Adherence read for a single medication. `compliance30` is the authoritative 30-day taken-vs-expected summary; `dailyCompliance` is the per-day grid for the history glyph track. The graded raw→week→month→year series used elsewhere for AI prompts does NOT apply here — this response is never downsampled.", + }); + +// v1.5.0 — natural-language medication extraction route. The wizard's +// optional "Beschreiben" overlay POSTs a free-text description and +// receives a partial structured payload the form merges onto whatever +// the user already typed. Citation-guarded (`name` and `dose` are +// dropped when not substring-matched in the original text) and +// closed-enum-validated. +const medicationExtractRequest = z + .object({ + text: z + .string() + .min(1) + .max(2000) + .describe( + "Free-text medication description (any locale). Up to 2 000 characters. The model never echoes the text back into another tenant — it is only used to produce the structured fields.", + ), + locale: z + .enum(["en", "de", "es", "fr", "it", "pl"]) + .optional() + .describe("Optional UI locale hint for the model."), + today: z + .string() + .regex(/^\d{4}-\d{2}-\d{2}$/) + .optional() + .describe( + 'Optional override of the reference date used to resolve relative phrases ("tomorrow", "next Monday"). Format: `YYYY-MM-DD`. Defaults to the server\'s UTC day.', + ), + }) + .meta({ + id: "MedicationExtractRequest", + description: + "Free-text medication description payload. The route runs the text through the Coach provider chain and returns a partial structured payload the wizard merges. Rate-limited 10 requests / 5 minutes / user; budget-gated against the daily Coach token ceiling.", + }); + +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.", +}); + +export const medicationPaths: NonNullable = { + "/api/medications": { + get: { + tags: ["Medications"], + summary: "List medications for the calling user", + description: + "Returns every medication owned by the caller (active + paused), ordered by `createdAt DESC`. Each row carries its nested `schedules`, the joined clinical `category`, the latest non-skipped `lastTakenAt`, and the count of intake events scheduled for today (`todayEventCount`). The response is cached server-side for 60 s per user; writes flush the cache.", + responses: { + "200": { + description: "Medication list.", + content: { + "application/json": { + schema: dataEnvelope( + z.array(medicationListEntry), + "ListMedicationsResponse", + ), + }, + }, + }, + ...stdResponses, + }, + }, + post: { + tags: ["Medications"], + summary: "Create a medication with at least one schedule", + description: + "Validates the body against `CreateMedicationRequest`, applies the v1.5 cross-field invariants (one-shot consistency, recurring default `FREQ=DAILY`, `timesOfDay` dual-write), and creates the medication + its schedules in a single Prisma write. Audits as `medication.create`.", + requestBody: { + required: true, + content: { "application/json": { schema: createMedicationSchema } }, + }, + responses: { + "201": { + description: "Created medication with its schedules.", + content: { + "application/json": { + schema: dataEnvelope( + medicationDetailEntry, + "CreateMedicationResponse", + ), + }, + }, + }, + ...stdResponses, + }, + }, + }, + "/api/medications/{id}": { + get: { + tags: ["Medications"], + summary: "Fetch a single medication", + description: + "Returns the medication + its schedules + the joined `category`. Cross-user rows surface as 404 (existence channel sealed).", + requestParams: { + path: z.object({ id: z.string() }), + }, + responses: { + "200": { + description: "Medication detail.", + content: { + "application/json": { + schema: dataEnvelope( + medicationDetailEntry, + "GetMedicationResponse", + ), + }, + }, + }, + "404": { + description: "Medication not found (or owned by another user).", + content: { "application/json": { schema: errorEnvelope } }, + }, + ...stdResponses, + }, + }, + put: { + tags: ["Medications"], + summary: "Replace a medication (partial fields)", + description: + "Every field on the body is optional; omitted fields are left untouched. Supplying `schedules` REPLACES the medication's full schedule list (the route deletes existing rows before re-creating). Flipping `active` to false stamps `pausedAt`; flipping back to true clears it. v1.5 invariants on the `schedules` array match `POST /api/medications`. Audits as `medication.update`.", + requestParams: { + path: z.object({ id: z.string() }), + }, + requestBody: { + required: true, + content: { "application/json": { schema: updateMedicationSchema } }, + }, + responses: { + "200": { + description: "Updated medication.", + content: { + "application/json": { + schema: dataEnvelope( + medicationDetailEntry, + "UpdateMedicationResponse", + ), + }, + }, + }, + "404": { + description: "Medication not found (or owned by another user).", + content: { "application/json": { schema: errorEnvelope } }, + }, + ...stdResponses, + }, + }, + delete: { + tags: ["Medications"], + summary: "Delete a medication", + description: + "Cascades to the medication's schedules, intake events, dose changes, inventory rows, and side-effect logs. Revokes every API token scoped to `medication::ingest`. Audits as `medication.delete`.", + requestParams: { + path: z.object({ id: z.string() }), + }, + responses: { + "200": { + description: "Deletion succeeded.", + content: { + "application/json": { + schema: dataEnvelope( + z.object({ deleted: z.boolean() }), + "DeleteMedicationResponse", + ), + }, + }, + }, + "404": { + description: "Medication not found (or owned by another user).", + content: { "application/json": { schema: errorEnvelope } }, + }, + ...stdResponses, + }, + }, + }, + "/api/medications/{id}/intake": { + post: { + tags: ["Medications"], + summary: "Log an intake event for a medication", + description: + "Records a taken or skipped dose. Idempotent via the `Idempotency-Key` header AND the optional `idempotencyKey` body field (the route walks both paths); a re-post inside the 60 s server-side dedup window returns the original event. Non-skipped intakes auto-decrement pen inventory (best-effort), refresh the per-day compliance rollup, and — for `oneShot:true` medications — flip `active` to false.", + requestParams: { + path: z.object({ id: z.string() }), + }, + requestBody: { + required: true, + content: { "application/json": { schema: intakeSchema } }, + }, + responses: { + "201": { + description: "Intake event created.", + content: { + "application/json": { + schema: dataEnvelope( + medicationIntakeEventResource, + "CreateMedicationIntakeResponse", + ), + }, + }, + }, + "200": { + description: + "Idempotent replay — the original event is returned without creating a new row.", + content: { + "application/json": { + schema: dataEnvelope( + medicationIntakeEventResource, + "ReplayMedicationIntakeResponse", + ), + }, + }, + }, + "404": { + description: "Medication not found (or owned by another user).", + content: { "application/json": { schema: errorEnvelope } }, + }, + ...stdResponses, + }, + }, + }, + "/api/medications/extract": { + post: { + tags: ["Medications"], + summary: + "Extract scheduling fields from a free-text medication description", + description: + "Runs the user's free-text description through the Coach provider chain and returns a citation-guarded partial payload the wizard merges onto whatever the user already typed. `name` and `dose` are dropped when not substring-matched in the original text so the wizard cannot land a hallucinated brand or dose. `cadenceKind` / `doseUnit` / `weekdays` are closed enums; numeric fields are clamped. Rate-limited 10 requests / 5 minutes / user, gated against the daily Coach token budget.", + requestBody: { + required: true, + content: { + "application/json": { schema: medicationExtractRequest }, + }, + }, + responses: { + "200": { + description: "Citation-guarded partial extraction.", + content: { + "application/json": { + schema: dataEnvelope( + medicationExtractionSchema, + "MedicationExtractResponse", + ), + }, + }, + }, + "502": { + description: + "Upstream provider returned an empty, unparseable, or off-schema reply.", + content: { "application/json": { schema: errorEnvelope } }, + }, + "503": { + description: + "No AI provider configured for the calling user (or operator).", + content: { "application/json": { schema: errorEnvelope } }, + }, + ...stdResponses, + }, + }, + }, + "/api/medications/{id}/cadence": { + get: { + tags: ["Medications"], + summary: "Cadence + compliance read for a medication", + description: + "Returns the expected-vs-actual dose timeline for the requested window plus the four compliance chip values that drive the detail-page section. Pure computation — no writes. Day boundaries are resolved in the user's IANA timezone so a Tokyo user and a Berlin user see the same chips for the same medication. The `days` query parameter caps at 180.", + requestParams: { + path: z.object({ id: z.string() }), + query: z.object({ + days: z.coerce + .number() + .int() + .min(1) + .max(180) + .optional() + .describe("Window size in days (default 30, max 180)."), + }), + }, + responses: { + "200": { + description: "Cadence response.", + content: { + "application/json": { + schema: dataEnvelope( + medicationCadenceResponse, + "GetMedicationCadenceResponse", + ), + }, + }, + }, + "404": { + description: "Medication not found (or owned by another user).", + content: { "application/json": { schema: errorEnvelope } }, + }, + ...stdResponses, + }, + }, + }, + "/api/medications/{id}/compliance": { + get: { + tags: ["Medications"], + summary: "Adherence read for a medication", + description: + "Returns the 7- and 30-day adherence summaries, the per-day compliance grid for the history glyph track, and the two-row display block. Pure computation — no writes. Day boundaries are resolved in the user's IANA timezone, and the expected-dose denominator is cadence-aware (RRULE / rolling / one-shot / PRN / cyclic) and clamped to the medication's `createdAt`. Read `compliance30` for the headline 30-day taken-vs-expected percentage; build the per-day glyph track from `dailyCompliance` (draw a cell only where `due === true`).", + requestParams: { + path: z.object({ id: z.string() }), + }, + responses: { + "200": { + description: "Compliance response.", + content: { + "application/json": { + schema: dataEnvelope( + medicationComplianceResponse, + "GetMedicationComplianceResponse", + ), + }, + }, + }, + "404": { + description: "Medication not found (or owned by another user).", + content: { "application/json": { schema: errorEnvelope } }, + }, + ...stdResponses, + }, + }, + }, +}; diff --git a/src/lib/openapi/routes/meta.ts b/src/lib/openapi/routes/meta.ts new file mode 100644 index 000000000..5f515b019 --- /dev/null +++ b/src/lib/openapi/routes/meta.ts @@ -0,0 +1,142 @@ +/** + * OpenAPI route table — server capabilities discovery. + * + * Part of the OpenAPI route table; aggregated in `./index.ts`. + * Schemas come from `src/lib/validations/*` where shared with the + * runtime request parsing, so the wire contract stays single-source. + */ +import { z } from "zod/v4"; +import type { ZodOpenApiObject } from "zod-openapi"; +import { dataEnvelope, stdResponses } from "./shared"; + +// v1.10.2 — live capability / discovery response. Every list is sourced +// server-side from the canonical registry it documents, so the wire shape +// here is the contract; the runtime values are authoritative and never +// hand-duplicated. Used by the native client to gate its UI / decoder +// against what the server actually ships (retires the doc-vs-server +// enum-drift class). +const capabilitiesResponse = z + .object({ + apiContractVersion: z + .string() + .describe("Running build version — mirrors GET /api/version `version`."), + derivedMetricIds: z + .array(z.string()) + .describe("Closed derived-metric id set (GET /api/insights/derived)."), + vitalsBaselineTypes: z + .array(z.string()) + .describe("MeasurementTypes the typical-range baseline engine supports."), + layoutTileIds: z + .array(z.string()) + .describe("Canonical insights layout tile-id set (English ids)."), + metricStatusIds: z + .array(z.string()) + .describe("Closed metric-status / assessment id set."), + ingest: z + .object({ + quantityTypes: z + .array( + z.object({ + type: z.string().describe("HealthLog MeasurementType."), + hk: z.string().describe("HealthKit identifier."), + unit: z.string().describe("Canonical DB unit."), + }), + ) + .describe("Accepted HealthKit quantity-sample mappings."), + eventTypes: z + .array(z.string()) + .describe( + "MeasurementTypes for device-flagged EVENT-class HealthKit samples.", + ), + computedScores: z + .array(z.string()) + .describe("Server-owned nightly composite score types."), + writeAllowlist: z + .array(z.string()) + .describe( + "MeasurementSources a client may attribute on a write (others are server-owned).", + ), + }) + .describe("Ingest vocabularies the batch / single-write paths accept."), + fhir: z + .object({ + atcSystem: z.string().describe("WHO ATC CodeSystem URI."), + snomedRoute: z.string().describe("SNOMED CT CodeSystem URI."), + germanAtcDefaultLocales: z + .array(z.string()) + .describe( + "App locales that default the additive BfArM ATC coding on.", + ), + restBaseUrl: z + .string() + .describe("Base path of the read-only FHIR R4 REST face."), + readScope: z + .string() + .describe("Bearer scope a narrow token needs to read the FHIR face."), + resourceTypes: z + .array(z.string()) + .describe( + "FHIR resource types the REST face serves (read + search).", + ), + operations: z + .array(z.string()) + .describe("Whole-record operations exposed (e.g. $everything)."), + searchParams: z + .array(z.string()) + .describe( + "Search parameters honoured uniformly across the search routes.", + ), + }) + .describe( + "FHIR coding constants + the read-only REST face descriptor (v1.11).", + ), + share: z + .object({ + supported: z + .boolean() + .describe("Whether clinician share links are served."), + maxDays: z + .number() + .int() + .describe( + "Maximum lifetime of a share link, in days. No never-expiring share.", + ), + resourceTypes: z + .array(z.string()) + .describe("FHIR resource types a share link may be scoped to serve."), + sections: z + .array(z.string()) + .describe("Scopeable report sections a share link may toggle."), + }) + .describe("Clinician share-link surface descriptor (v1.11)."), + }) + .meta({ + id: "CapabilitiesResponse", + description: + "Live id vocabularies + contract version. Every list is derived server-side from the canonical registry it documents, so it cannot drift from the values the routes actually accept/emit.", + }); + +export const metaPaths: NonNullable = { + "/api/meta/capabilities": { + get: { + tags: ["Meta"], + summary: "Live server capability / id-vocabulary discovery", + description: + "Returns the server's REAL id vocabularies (derived-metric ids, vitals-baseline types, layout tile-ids, metric-status ids, the HealthKit ingest mapping, the FHIR coding constants) plus the running API contract version. Every list is derived server-side from the canonical registry it documents, so a client can gate its UI / decoder against what the server actually ships rather than a hand-maintained copy. Auth via cookie or Bearer (not admin).", + responses: { + "200": { + description: "Capability snapshot.", + content: { + "application/json": { + schema: dataEnvelope( + capabilitiesResponse, + "CapabilitiesEnvelope", + ), + }, + }, + }, + ...stdResponses, + }, + }, + }, +}; diff --git a/src/lib/openapi/routes/profile.ts b/src/lib/openapi/routes/profile.ts new file mode 100644 index 000000000..1934c296f --- /dev/null +++ b/src/lib/openapi/routes/profile.ts @@ -0,0 +1,416 @@ +/** + * OpenAPI route table — account profile, avatar, coach prefs, disable-coach, source priority. + * + * Part of the OpenAPI route table; aggregated in `./index.ts`. + * Schemas come from `src/lib/validations/*` where shared with the + * runtime request parsing, so the wire contract stays single-source. + */ +import { z } from "zod/v4"; +import type { ZodOpenApiObject } from "zod-openapi"; +import { coachPrefsSchema } from "@/lib/validations/coach-prefs"; +import { sourcePrioritySchema } from "@/lib/validations/source-priority"; +import { dataEnvelope, errorEnvelope, stdResponses } from "./shared"; + +// v1.4.49 — single schema for the per-user Coach opt-out flag. Previously +// split into `disableCoachBody` (PATCH request) and `disableCoachData` +// (response payload), both `z.object({ disableCoach: z.boolean() })`. The +// payload was passed through `dataEnvelope(..., "GetDisableCoachResponse")` +// / `dataEnvelope(..., "PatchDisableCoachResponse")` which IDs the +// envelope wrapper — the inner flag carries its own `.meta()` and now +// renders as a single `$ref: "#/components/schemas/DisableCoachFlag"` in +// both the request body and both response envelopes. +const disableCoachFlag = z + .object({ + disableCoach: z.boolean(), + }) + .meta({ + id: "DisableCoachFlag", + description: + "Per-account Coach opt-out toggle (v1.4.47 W3). `true` hides the Coach FAB and short-circuits its API gates.", + }); + +// v1.8.6 — profile read/write surface for the native client. The +// runtime validation lives in `src/lib/validations/auth.ts` +// (`profileSchema`, whose transforms normalise empty → null and run the +// KVNR/IKNR refines); the OpenAPI shapes below mirror the wire contract +// without those transforms so the spec stays a clean nullable-field +// document. `insurerIkNumber` is the new v1.8.6 field: optional, 9-digit +// German IKNR, surfaced on the FHIR `Coverage` payor. +const profileUpdateRequest = z + .object({ + email: z.email().nullable().optional(), + heightCm: z.number().min(50).max(300).nullable().optional(), + dateOfBirth: z.string().nullable().optional(), + gender: z.enum(["MALE", "FEMALE"]).nullable().optional(), + displayName: z.string().min(1).max(80).nullable().optional(), + locale: z.enum(["de", "en"]).nullable().optional(), + timezone: z.string().min(1).max(64).optional(), + timeFormat: z + .enum(["AUTO", "H12", "H24"]) + .optional() + .describe( + "Hour-cycle display preference. AUTO follows the locale convention, H12 forces AM/PM, H24 forces 24-hour.", + ), + moodReminderEnabled: z.boolean().optional(), + fullName: z.string().max(120).nullable().optional(), + insurerName: z.string().max(120).nullable().optional(), + insuranceNumber: z + .string() + .nullable() + .optional() + .describe("German KVNR. Empty/null clears it; mod-10 check enforced."), + insurerIkNumber: z + .string() + .nullable() + .optional() + .describe( + "German insurer institution number (IKNR). Optional; empty/null clears it. A non-empty value must be exactly 9 digits (no checksum enforced).", + ), + }) + .meta({ + id: "ProfileUpdateRequest", + description: + "Partial profile update. Every field is optional; an omitted field is left untouched, an explicit null (or empty string) clears it. `userId` is never accepted — it is narrowed from the session/token.", + }); + +const profileResponse = z + .object({ + username: z.string(), + displayName: z.string().nullable().optional(), + email: z.string().nullable(), + dateOfBirth: z.iso.datetime({ offset: true }).nullable(), + gender: z.enum(["MALE", "FEMALE"]).nullable(), + heightCm: z.number().nullable(), + locale: z.string().nullable(), + timezone: z.string(), + timeFormat: z + .enum(["AUTO", "H12", "H24"]) + .describe( + "Hour-cycle display preference. AUTO follows the locale convention, H12 forces AM/PM, H24 forces 24-hour.", + ), + moodReminderEnabled: z.boolean(), + fullName: z.string().nullable(), + insurerName: z.string().nullable(), + insurerIkNumber: z + .string() + .nullable() + .describe("German insurer institution number (IKNR), 9 digits."), + insuranceNumber: z + .string() + .nullable() + .describe("German KVNR, decrypted for the form prefill."), + }) + .meta({ + id: "ProfileResponse", + description: + "Flattened profile fields for the native client (GET). The KVNR is decrypted server-side; the IKNR (v1.8.6) is plaintext at rest.", + }); + +// The PATCH echo mirrors GET but reports KVNR presence as a boolean +// (`hasInsuranceNumber`) rather than re-emitting the decrypted value. +const profileUpdateResponse = z + .object({ + username: z.string(), + displayName: z.string().nullable().optional(), + email: z.string().nullable(), + dateOfBirth: z.iso.datetime({ offset: true }).nullable(), + gender: z.enum(["MALE", "FEMALE"]).nullable(), + heightCm: z.number().nullable(), + locale: z.string().nullable(), + timezone: z.string(), + timeFormat: z + .enum(["AUTO", "H12", "H24"]) + .describe( + "Hour-cycle display preference. AUTO follows the locale convention, H12 forces AM/PM, H24 forces 24-hour.", + ), + moodReminderEnabled: z.boolean(), + fullName: z.string().nullable(), + insurerName: z.string().nullable(), + insurerIkNumber: z + .string() + .nullable() + .describe("German insurer institution number (IKNR), 9 digits."), + hasInsuranceNumber: z + .boolean() + .describe("Whether a KVNR is on file (the value itself is not echoed)."), + }) + .meta({ + id: "ProfileUpdateResponse", + description: + "Profile echo returned by the PATCH/PUT update path. Reports KVNR presence as a boolean rather than re-emitting the decrypted value.", + }); + +export const profilePaths: NonNullable = { + "/api/auth/me/coach-prefs": { + get: { + tags: ["Auth"], + summary: "Read per-user Coach prompt-tuning preferences", + description: + "Returns the persisted preferences with defaults filled in. Null persisted state resolves to the legacy v1.4.22 defaults.", + responses: { + "200": { + description: "Resolved preferences.", + content: { + "application/json": { + schema: dataEnvelope(coachPrefsSchema, "GetCoachPrefsResponse"), + }, + }, + }, + ...stdResponses, + }, + }, + put: { + tags: ["Auth"], + summary: "Replace per-user Coach prompt-tuning preferences", + description: + "Persists the supplied prefs. Body is validated against `CoachPrefs`; missing keys fall back to the documented defaults.", + requestBody: { + required: true, + content: { "application/json": { schema: coachPrefsSchema } }, + }, + responses: { + "200": { + description: "Saved preferences echoed back.", + content: { + "application/json": { + schema: dataEnvelope(coachPrefsSchema, "PutCoachPrefsResponse"), + }, + }, + }, + ...stdResponses, + }, + }, + }, + "/api/auth/me/disable-coach": { + get: { + tags: ["Auth"], + summary: "Read per-user Coach opt-out flag", + description: + 'Returns the user\'s per-account Coach opt-out flag. Default `false` (Coach visible). Powers the Settings → Insights "Hide Coach" Switch and the layout FAB short-circuit.', + responses: { + "200": { + description: "Resolved flag.", + content: { + "application/json": { + schema: dataEnvelope(disableCoachFlag, "GetDisableCoachResponse"), + }, + }, + }, + ...stdResponses, + }, + }, + patch: { + tags: ["Auth"], + summary: "Toggle per-user Coach opt-out flag", + description: + "Flips the per-account Coach opt-out flag. Idempotent — the DB write fires even when the value matches so the audit-log row mirrors the API call. Rate-limit 60/min per user.", + requestBody: { + required: true, + content: { "application/json": { schema: disableCoachFlag } }, + }, + responses: { + "200": { + description: + "Resolved next-state echoed back for optimistic-update consumers.", + content: { + "application/json": { + schema: dataEnvelope( + disableCoachFlag, + "PatchDisableCoachResponse", + ), + }, + }, + }, + ...stdResponses, + }, + }, + }, + "/api/auth/me/source-priority": { + get: { + tags: ["Auth"], + summary: "Read per-user source priority", + description: + "Returns the per-metric-class source priority used by the analytics aggregator. Missing keys fall back to `DEFAULT_SOURCE_PRIORITY`. Null persisted state resolves to the documented defaults.", + responses: { + "200": { + description: "Resolved priority.", + content: { + "application/json": { + schema: dataEnvelope( + sourcePrioritySchema, + "GetSourcePriorityResponse", + ), + }, + }, + }, + ...stdResponses, + }, + }, + put: { + tags: ["Auth"], + summary: "Replace per-user source priority", + description: + "Persists the supplied priority. Body is validated against `SourcePriority`; missing keys read as `DEFAULT_SOURCE_PRIORITY` at the call site.", + requestBody: { + required: true, + content: { "application/json": { schema: sourcePrioritySchema } }, + }, + responses: { + "200": { + description: "Saved priority (defaulted) echoed back.", + content: { + "application/json": { + schema: dataEnvelope( + sourcePrioritySchema, + "PutSourcePriorityResponse", + ), + }, + }, + }, + ...stdResponses, + }, + }, + }, + "/api/user/avatar": { + post: { + tags: ["Auth"], + summary: "Upload the calling user's avatar", + description: + "Multipart upload (field `file`). Accepts image/jpeg, image/png, image/webp; rejects anything else at the magic-byte sniff (the multipart Content-Type header is informational only). Hard caps: 2 MiB body, 2048×2048 dimensions. Replaces the v1.4.22 Gravatar leak by storing the bytes on the User row + serving them from same-origin.", + requestBody: { + required: true, + content: { + "multipart/form-data": { + schema: z.object({ + file: z + .string() + .describe( + "Binary image payload (JPEG / PNG / WebP). Hard-capped at 2 MiB and 2048×2048.", + ), + }), + }, + }, + }, + responses: { + "201": { + description: + "Avatar saved. `avatarUrl` is the same value the /me payload returns; the `?v=` suffix busts client caches on re-upload.", + content: { + "application/json": { + schema: dataEnvelope( + z.object({ + avatarUrl: z.string(), + contentType: z.enum([ + "image/jpeg", + "image/png", + "image/webp", + ]), + updatedAt: z.iso.datetime({ offset: true }), + }), + "UploadAvatarResponse", + ), + }, + }, + }, + "413": { + description: + "Upload exceeds the 2 MiB byte limit or the 2048×2048 dimension limit.", + content: { "application/json": { schema: errorEnvelope } }, + }, + "415": { + description: + "Image format is not one of JPEG / PNG / WebP (magic-byte sniff).", + content: { "application/json": { schema: errorEnvelope } }, + }, + ...stdResponses, + }, + }, + delete: { + tags: ["Auth"], + summary: "Clear the calling user's avatar", + description: + "Resets the avatar columns to null. Idempotent — a delete on an already-empty row returns 204 with no audit-log row.", + responses: { + "204": { + description: "Avatar cleared (or already empty).", + }, + ...stdResponses, + }, + }, + }, + "/api/user/avatar/{id}": { + get: { + tags: ["Auth"], + summary: "Serve a user's avatar bytes", + description: + "Owner-scoped. The `{id}` path segment must match the calling user's id — cross-user reads return 403. Response body is the raw image bytes with the persisted `Content-Type`; the /me payload appends `?v={updatedAtMs}` so clients can cache aggressively.", + requestParams: { + path: z.object({ + id: z.string().describe("User id (must match the calling user)."), + }), + }, + responses: { + "200": { + description: "Avatar bytes.", + content: { + "image/jpeg": { schema: z.string().describe("Raw JPEG bytes.") }, + "image/png": { schema: z.string().describe("Raw PNG bytes.") }, + "image/webp": { schema: z.string().describe("Raw WebP bytes.") }, + }, + }, + "403": { + description: "Caller is not the avatar's owner.", + content: { "application/json": { schema: errorEnvelope } }, + }, + "404": { + description: "The user has no uploaded avatar.", + content: { "application/json": { schema: errorEnvelope } }, + }, + ...stdResponses, + }, + }, + }, + "/api/user/profile": { + get: { + tags: ["Auth"], + summary: "Read the calling user's profile", + description: + "Flattened profile fields for the native client. Aliased over the same data exposed by /api/auth/me. The KVNR is decrypted server-side; the IKNR (v1.8.6) is returned plaintext.", + responses: { + "200": { + description: "The calling user's profile.", + content: { + "application/json": { + schema: dataEnvelope(profileResponse, "GetProfileResponse"), + }, + }, + }, + ...stdResponses, + }, + }, + patch: { + tags: ["Auth"], + summary: "Update the calling user's profile", + description: + "Partial profile update — every field is optional. An explicit null (or empty string) clears a field. A non-empty `insurerIkNumber` must be exactly 9 digits (422 otherwise); a non-empty `insuranceNumber` (KVNR) must pass the mod-10 check. `userId` is never accepted from the body.", + requestBody: { + required: true, + content: { "application/json": { schema: profileUpdateRequest } }, + }, + responses: { + "200": { + description: "Saved profile echoed back.", + content: { + "application/json": { + schema: dataEnvelope( + profileUpdateResponse, + "PatchProfileResponse", + ), + }, + }, + }, + ...stdResponses, + }, + }, + }, +}; diff --git a/src/lib/openapi/routes/shared.ts b/src/lib/openapi/routes/shared.ts new file mode 100644 index 000000000..2beef9b97 --- /dev/null +++ b/src/lib/openapi/routes/shared.ts @@ -0,0 +1,109 @@ +/** + * Shared OpenAPI building blocks — response envelopes and standard error responses used across every route module. + * + * Part of the OpenAPI route table; aggregated in `./index.ts`. + * Schemas come from `src/lib/validations/*` where shared with the + * runtime request parsing, so the wire contract stays single-source. + */ +import { z } from "zod/v4"; +import { + createMeasurementSchema, + listMeasurementsSchema, + measurementTypeEnum, + measurementSourceEnum, +} from "@/lib/validations/measurement"; +import { loginPasswordSchema } from "@/lib/validations/auth"; +import { coachPrefsSchema } from "@/lib/validations/coach-prefs"; +import { createBatchWorkoutSchema } from "@/lib/validations/workout"; + +/** + * Common envelopes — every HealthLog API response wraps payload in + * `{ data, error, meta? }`. The OpenAPI surface mirrors that contract + * so iOS / external-ingest clients can decode uniformly. + */ +export const errorEnvelope = z + .object({ + data: z.null(), + error: z.string(), + meta: z + .object({ + requestId: z.string().optional(), + errorCode: z.string().optional(), + }) + .optional(), + }) + .meta({ + id: "ErrorEnvelope", + description: "Standard error response: data is null, error is human prose.", + }); + +export function dataEnvelope(payload: T, id: string) { + return z + .object({ + data: payload, + error: z.null(), + meta: z.object({ requestId: z.string().optional() }).optional(), + }) + .meta({ id }); +} + +// ── Schemas — annotated for spec emission ──────────────────────────── + +measurementTypeEnum.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({ + id: "MeasurementSource", + description: + "Origin of the measurement. v1.4.23 added APPLE_HEALTH for the iOS HealthKit batch ingest path.", +}); + +loginPasswordSchema.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({ + id: "CreateMeasurementRequest", + description: + "Single-measurement ingest body. Plausibility-range guard runs server-side; out-of-range values fail 422.", +}); + +listMeasurementsSchema.meta({ + id: "ListMeasurementsQuery", + description: + "Query params for the measurements list endpoint. `limit` capped at 500.", +}); + +coachPrefsSchema.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({ + 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).", +}); + +// ── Standard 401 / 422 / 429 responses ─────────────────────────────── + +export const stdResponses = { + "401": { + description: "Authentication required or invalid credentials.", + content: { "application/json": { schema: errorEnvelope } }, + }, + "422": { + description: "Request validation failed.", + content: { "application/json": { schema: errorEnvelope } }, + }, + "429": { + description: "Rate limit exceeded.", + content: { "application/json": { schema: errorEnvelope } }, + }, +}; diff --git a/src/lib/openapi/routes/sync.ts b/src/lib/openapi/routes/sync.ts new file mode 100644 index 000000000..001dc5623 --- /dev/null +++ b/src/lib/openapi/routes/sync.ts @@ -0,0 +1,263 @@ +/** + * OpenAPI route table — offline sync state + changes feed. + * + * Part of the OpenAPI route table; aggregated in `./index.ts`. + * Schemas come from `src/lib/validations/*` where shared with the + * runtime request parsing, so the wire contract stays single-source. + */ +import { z } from "zod/v4"; +import type { ZodOpenApiObject } from "zod-openapi"; +import { measurementResource } from "./measurements"; +import { dataEnvelope, stdResponses } from "./shared"; + +// ── Sync (v1.7.0 offline / server-optional) ───────────────────────── + +const syncStateResponse = z + .object({ + userId: z.string(), + timezone: z.string(), + lastSyncedAt: z.iso.datetime({ offset: true }).nullable(), + serverNow: z.iso.datetime({ offset: true }), + measurements: z.object({ + lastUpdatedAt: z.iso.datetime({ offset: true }).nullable(), + liveCount: z.number().int().nonnegative(), + tombstonedCount: z.number().int().nonnegative(), + }), + mood: z.object({ + lastUpdatedAt: z.iso.datetime({ offset: true }).nullable(), + liveCount: z.number().int().nonnegative(), + tombstonedCount: z.number().int().nonnegative(), + }), + intakes: z.object({ + lastUpdatedAt: z.iso.datetime({ offset: true }).nullable(), + liveCount: z.number().int().nonnegative(), + tombstonedCount: z.number().int().nonnegative(), + }), + sync: z + .object({ + incrementalWindowDays: z + .number() + .int() + .positive() + .describe( + "Days an incremental delta stays valid; tracks the native refresh-token lifetime. Beyond it a device re-pairs with a full backfill. iOS derives its window from this rather than hardcoding 60.", + ), + tombstoneRetentionDays: z + .number() + .int() + .positive() + .describe( + "Horizon past which tombstones may be pruned. A cursor older than this gets `cursorExpired` on `/api/sync/changes`.", + ), + }) + .describe("Sync-window metadata the client reads instead of hardcoding."), + }) + .meta({ + id: "SyncStateResponse", + description: + "iOS SyncMode handshake. Each GET also advances the server-side `lastSyncedAt` checkpoint and returns the previous value. The cheap 'should I sync?' summary; the durable delta cursor lives on `/api/sync/changes`.", + }); + +const syncMeasurementUpsert = measurementResource + .extend({ + externalId: z + .string() + .nullable() + .describe("Cross-device dedup key (UUID string or `stats::`)."), + syncVersion: z + .number() + .int() + .positive() + .describe( + "LWW reconciliation counter; echo to keep the mirror monotonic.", + ), + updatedAt: z.iso.datetime({ offset: true }), + }) + .meta({ id: "SyncMeasurementUpsert" }); + +const syncMeasurementTombstone = z + .object({ + id: z.string(), + externalId: z + .string() + .nullable() + .describe("The identity key the client dedups on for measurements."), + syncVersion: z.number().int().positive(), + deletedAt: z.iso.datetime({ offset: true }), + updatedAt: z.iso.datetime({ offset: true }), + }) + .meta({ + id: "SyncMeasurementTombstone", + description: + "A soft-deleted measurement. Apply tombstones BEFORE upserts within a page to avoid resurrecting a row.", + }); + +const syncMoodUpsert = z + .object({ + id: z.string(), + date: z.string().describe("YYYY-MM-DD anchored to the row's `tz`."), + mood: z.string(), + score: z.number().int(), + tags: z.string().nullable().describe("JSON array of tag keys, or null."), + note: z.string().nullable(), + moodLoggedAt: z.iso.datetime({ offset: true }), + source: z.string(), + syncVersion: z + .number() + .int() + .nonnegative() + .describe("LWW reconciliation counter; mood is last-writer-wins by it."), + updatedAt: z.iso.datetime({ offset: true }), + }) + .meta({ id: "SyncMoodUpsert" }); + +const syncMoodTombstone = z + .object({ + id: z + .string() + .describe("Server id — the identity key the client dedups on for mood."), + syncVersion: z.number().int().nonnegative(), + deletedAt: z.iso.datetime({ offset: true }), + updatedAt: z.iso.datetime({ offset: true }), + }) + .meta({ + id: "SyncMoodTombstone", + description: + "A soft-deleted mood entry, keyed on server `id`. Apply before upserts within the domain page.", + }); + +const syncIntakeUpsert = z + .object({ + id: z.string(), + medicationId: z.string(), + scheduledFor: z.iso.datetime({ offset: true }), + takenAt: z.iso.datetime({ offset: true }).nullable(), + skipped: z.boolean(), + source: z.string(), + syncVersion: z + .number() + .int() + .nonnegative() + .describe( + "Reconciliation counter. An intake is immutable; a correction is a tombstone + re-insert.", + ), + updatedAt: z.iso.datetime({ offset: true }), + }) + .meta({ id: "SyncIntakeUpsert" }); + +const syncIntakeTombstone = z + .object({ + id: z + .string() + .describe( + "Server id — the identity key the client dedups on for intakes.", + ), + syncVersion: z.number().int().nonnegative(), + deletedAt: z.iso.datetime({ offset: true }), + updatedAt: z.iso.datetime({ offset: true }), + }) + .meta({ + id: "SyncIntakeTombstone", + description: + "A soft-deleted medication intake, keyed on server `id`. Apply before upserts within the domain page.", + }); + +const syncChangesQuery = z + .object({ + cursor: z + .string() + .min(1) + .max(2048) + .optional() + .describe( + "Opaque multi-domain keyset cursor from the previous page. Treat as fully opaque — echo, never parse. Omit for the initial sync.", + ), + limit: z.coerce + .number() + .int() + .min(1) + .max(500) + .optional() + .describe("Page size, default 200, hard cap 500."), + }) + .meta({ id: "SyncChangesQuery" }); + +const syncChangesResponse = z + .object({ + serverNow: z.iso.datetime({ offset: true }), + cursor: z + .string() + .nullable() + .describe("Opaque cursor to echo into the next request."), + hasMore: z + .boolean() + .describe("False once the client is caught up as of `serverNow`."), + cursorExpired: z + .boolean() + .describe( + "True when the supplied cursor predates tombstone retention — drop the cursor and do a clean initial sync.", + ), + changes: z.object({ + measurements: z.object({ + upserts: z.array(syncMeasurementUpsert), + tombstones: z.array(syncMeasurementTombstone), + }), + mood: z.object({ + upserts: z.array(syncMoodUpsert), + tombstones: z.array(syncMoodTombstone), + }), + intakes: z.object({ + upserts: z.array(syncIntakeUpsert), + tombstones: z.array(syncIntakeTombstone), + }), + }), + }) + .meta({ + id: "SyncChangesResponse", + description: + "Multi-domain delta page (v1.7.0): measurements + mood + intakes. One opaque multi-domain keyset cursor; tombstones apply before upserts within each domain. Tombstone identity: measurements key on externalId, mood + intakes on server id. The iOS consumer is measurements-only this cycle; mood + intakes are forward-prep.", + }); + +export const syncPaths: NonNullable = { + "/api/sync/state": { + get: { + tags: ["Sync"], + summary: "Sync handshake + window metadata", + description: + "Cheap 'should I sync?' summary. Returns the previous `lastSyncedAt` checkpoint and advances it server-side on each call. The `sync` block carries the incremental-delta window + tombstone retention so the client reads them rather than hardcoding.", + responses: { + "200": { + description: "Sync state summary.", + content: { + "application/json": { + schema: dataEnvelope(syncStateResponse, "SyncStateEnvelope"), + }, + }, + }, + ...stdResponses, + }, + }, + }, + "/api/sync/changes": { + get: { + tags: ["Sync"], + summary: "Measurements delta feed", + description: + "Incremental catch-up after the first-pair backfill (never a replacement for it). Pages over an opaque keyset cursor; each page carries `tombstones` (soft-deleted rows, keyed on `externalId`) and `upserts` (live rows). Apply tombstones before upserts within a page. `cursorExpired: true` forces a clean re-init.", + requestParams: { + query: syncChangesQuery, + }, + responses: { + "200": { + description: "Delta page.", + content: { + "application/json": { + schema: dataEnvelope(syncChangesResponse, "SyncChangesEnvelope"), + }, + }, + }, + ...stdResponses, + }, + }, + }, +}; diff --git a/src/lib/openapi/routes/workouts.ts b/src/lib/openapi/routes/workouts.ts new file mode 100644 index 000000000..0e38a58bb --- /dev/null +++ b/src/lib/openapi/routes/workouts.ts @@ -0,0 +1,206 @@ +/** + * OpenAPI route table — workouts list/detail/batch. + * + * Part of the OpenAPI route table; aggregated in `./index.ts`. + * Schemas come from `src/lib/validations/*` where shared with the + * runtime request parsing, so the wire contract stays single-source. + */ +import { z } from "zod/v4"; +import type { ZodOpenApiObject } from "zod-openapi"; +import { measurementSourceEnum } from "@/lib/validations/measurement"; +import { createBatchWorkoutSchema } from "@/lib/validations/workout"; +import { dataEnvelope, errorEnvelope, stdResponses } from "./shared"; + +// v1.4.25 W16b — typed workout batch ingest response envelope. Mirrors +// the measurements batch shape but reports the `workouts` count rather +// than the entries count, and the `skipped` field is reserved (the +// Zod schema rejects malformed entries with a 400 before the per-entry +// pass, so today's responses always carry an empty array). +const workoutBatchEntryResult = z + .object({ + index: z.number().int().nonnegative(), + status: z.enum(["inserted", "duplicate", "skipped"]), + reason: z.string().optional(), + }) + .meta({ id: "WorkoutBatchEntryResult" }); + +const workoutBatchResponse = z + .object({ + processed: z.number().int().nonnegative(), + inserted: z.number().int().nonnegative(), + duplicates: z.number().int().nonnegative(), + skipped: z.array( + z.object({ + index: z.number().int().nonnegative(), + reason: z.string(), + }), + ), + entries: z.array(workoutBatchEntryResult), + }) + .meta({ id: "WorkoutBatchResponse" }); + +// v1.4.32 — workout list / detail wire shape. The field names follow +// the iOS handoff contract (`distanceM` + `activeEnergyKcal`) rather +// than the Prisma column names so iOS and web consumers share one +// JSON envelope. +const workoutListEntry = z + .object({ + id: z.string(), + sportType: z.string(), + startedAt: z.iso.datetime({ offset: true }), + endedAt: z.iso.datetime({ offset: true }), + durationSec: z.number().int().nonnegative(), + distanceM: z.number().nullable(), + activeEnergyKcal: z.number().nullable(), + avgHr: z.number().int().nullable(), + maxHr: z.number().int().nullable(), + source: measurementSourceEnum, + externalId: z.string().nullable(), + }) + .meta({ id: "WorkoutListEntry" }); + +const workoutListResponse = z + .object({ + workouts: z.array(workoutListEntry), + meta: z.object({ + total: z.number().int().nonnegative(), + limit: z.number().int().positive(), + offset: z.number().int().nonnegative(), + droppedDuplicates: z.number().int().nonnegative(), + }), + }) + .meta({ id: "WorkoutListResponse" }); + +const workoutRouteGeometry = z + .object({ + geometry: z.unknown(), + sampleTimestamps: z.array(z.string()).nullable(), + }) + .meta({ id: "WorkoutRouteGeometry" }); + +// v1.10.0 — route-independent per-workout heart-rate series. Present +// for indoor and outdoor workouts that shipped a `samples` array on +// ingest. `samples` mirrors the ingest shape `[{ t, hr?, speedMs?, +// power?, cadence? }]`; `sampleCount` is the denormalised length. +const workoutHrSeries = z + .object({ + sampleCount: z.number().int().nonnegative(), + samples: z.unknown(), + }) + .meta({ id: "WorkoutHrSeries" }); + +const workoutDetailResponse = z + .object({ + id: z.string(), + sportType: z.string(), + startedAt: z.iso.datetime({ offset: true }), + endedAt: z.iso.datetime({ offset: true }), + durationSec: z.number().int().nonnegative(), + distanceM: z.number().nullable(), + activeEnergyKcal: z.number().nullable(), + avgHr: z.number().int().nullable(), + maxHr: z.number().int().nullable(), + minHr: z.number().int().nullable(), + stepCount: z.number().int().nullable(), + elevationM: z.number().nullable(), + pauseDurationSec: z.number().int().nullable(), + source: measurementSourceEnum, + externalId: z.string().nullable(), + metadata: z.unknown().nullable(), + route: workoutRouteGeometry.nullable(), + samples: workoutHrSeries.nullable(), + canonicalId: z.string(), + }) + .meta({ id: "WorkoutDetailResponse" }); + +export const workoutPaths: NonNullable = { + "/api/workouts": { + get: { + tags: ["Measurements"], + summary: "List workouts (v1.4.32)", + description: + "Paginated workout list with cross-source canonical-row dedup. The picker reads the per-user source-priority ladder and buckets by `(startedAt 5 min slot, sportType)` — twin workouts (Apple Watch + Withings ScanWatch) collapse to a single canonical row per cluster. Field names mirror the iOS handoff contract: `distanceM`, `activeEnergyKcal`, `avgHr`, `maxHr`.", + requestParams: { + query: z.object({ + limit: z.coerce.number().int().min(1).max(200).optional(), + offset: z.coerce.number().int().min(0).optional(), + since: z.iso.datetime({ offset: true }).optional(), + until: z.iso.datetime({ offset: true }).optional(), + sportType: z.string().optional(), + }), + }, + responses: { + "200": { + description: "Canonical workout page.", + content: { + "application/json": { + schema: dataEnvelope(workoutListResponse, "ListWorkoutsResponse"), + }, + }, + }, + ...stdResponses, + }, + }, + }, + "/api/workouts/{id}": { + get: { + tags: ["Measurements"], + summary: "Workout detail (v1.4.32)", + description: + "Single-workout envelope. Owns the optional `WorkoutRoute` GeoJSON geometry + `canonicalId` pointer that resolves to the cluster winner so deep-links into non-canonical twin rows can redirect cleanly. Cross-user rows surface as 404 (existence channel sealed).", + requestParams: { + path: z.object({ id: z.string() }), + }, + responses: { + "200": { + description: "Workout detail.", + content: { + "application/json": { + schema: dataEnvelope(workoutDetailResponse, "GetWorkoutResponse"), + }, + }, + }, + "404": { + description: "Workout not found (or owned by another user).", + content: { "application/json": { schema: errorEnvelope } }, + }, + ...stdResponses, + }, + }, + }, + "/api/workouts/batch": { + post: { + tags: ["Measurements"], + summary: "Typed workout batch ingest (v1.4.25 W16b)", + description: + "Server-side ingest endpoint for HKWorkout records (iOS) and Withings activity rows (server-to-server). Up to 100 workouts per call; nested route geometry (GeoJSON LineString) is capped at 20 000 points and stored in a 1:1 `WorkoutRoute` row keyed by `workoutId`. Idempotent via the `Idempotency-Key` header (replay window 24h). Per-entry status (`inserted | duplicate | skipped`) lets the iOS sync cursor checkpoint accurately. The request body ceiling is 5 MB enforced at the HTTP layer — clients above the ceiling receive a 413 with `workout.batch.payload_too_large`.", + requestBody: { + required: true, + content: { "application/json": { schema: createBatchWorkoutSchema } }, + }, + responses: { + "200": { + description: "Batch processed.", + content: { + "application/json": { + schema: dataEnvelope( + workoutBatchResponse, + "BatchWorkoutsResponse", + ), + }, + }, + }, + "400": { + description: + "Batch validation failed (over-cap, schema reject, or oversized route).", + content: { "application/json": { schema: errorEnvelope } }, + }, + "413": { + description: "Request body exceeds the 5 MB ceiling.", + content: { "application/json": { schema: errorEnvelope } }, + }, + ...stdResponses, + }, + }, + }, +}; diff --git a/src/lib/query-keys/admin.ts b/src/lib/query-keys/admin.ts new file mode 100644 index 000000000..4a2f91f83 --- /dev/null +++ b/src/lib/query-keys/admin.ts @@ -0,0 +1,63 @@ +/** + * Query keys — admin surfaces (users, tokens, logs, backups, feedback, + * audit log, host metrics, invites). + * Part of the centralized factory; aggregated in `./index.ts`. + */ +export const adminKeys = { + /** + * v1.4.41 — admin surfaces. Pre-fix every admin section declared its + * own bare-literal `["admin", ""]`. Routing through the factory + * lets a single rename change every consumer in lockstep. + */ + adminAiQuality: () => ["admin", "ai-quality"] as const, + adminAppLogs: ( + traceId: string | undefined, + action: string | undefined, + level: string | undefined, + range: string | undefined, + ) => ["admin", "app-logs", traceId, action, level, range] as const, + adminAssistantFlags: () => ["admin", "settings", "assistant-flags"] as const, + adminBackups: () => ["admin", "backups"] as const, + adminCoachFeedback: () => ["admin", "coach-feedback"] as const, + adminFeedback: (status: string) => ["admin", "feedback", status] as const, + adminFeedbackRoot: () => ["admin", "feedback"] as const, + adminHostMetrics: (window: string) => + ["admin", "host-metrics", window] as const, + /** v1.15.20 — registration invite list (admin Users section). */ + adminInvites: () => ["admin", "invites"] as const, + adminAuditActions: () => ["admin", "audit-log", "actions"] as const, + adminAuditOverview: () => ["admin", "audit-log", "overview-preview"] as const, + /** + * v1.4.42 W3-QUERYKEY-LONGTAIL — paginated + filtered audit-log + * read used by the login-overview admin section. The `filtered` + * discriminator at index 2 keeps the no-arg `adminAuditLog(filter)` + * cache slot byte-distinct so its consumers don't collide. + */ + adminAuditLogFiltered: (params: { + filter: string; + page: number; + perPage: number; + actor: string; + actionFilter: string; + target: string; + range: string; + }) => + [ + "admin", + "audit-log", + "filtered", + params.filter, + params.page, + params.perPage, + params.actor, + params.actionFilter, + params.target, + params.range, + ] as const, + + adminSettings: () => ["admin", "settings"] as const, + adminStatus: () => ["admin", "status"] as const, + adminUsers: () => ["admin", "users"] as const, + adminTokens: () => ["admin", "tokens"] as const, + adminAuditLog: (filter: unknown) => ["admin", "audit-log", filter] as const, +}; diff --git a/src/lib/query-keys/auth.ts b/src/lib/query-keys/auth.ts new file mode 100644 index 000000000..d02beb3fe --- /dev/null +++ b/src/lib/query-keys/auth.ts @@ -0,0 +1,55 @@ +/** + * Query keys — auth session, passkeys, and per-user account preferences. + * Part of the centralized factory; aggregated in `./index.ts`. + */ +export const authKeys = { + auth: () => ["auth"] as const, + /** + * v1.4.40 W-RSC — the `useAuth` hook's `["auth", "me"]` shape was a + * recurring source of factory drift (audit H1 — "`useAuth` uses + * `["auth", "me"]` but `queryKeys.auth()` returns `["auth"]`"). Both + * shapes share the `["auth"]` prefix so existing prefix-invalidations + * still match, but the centralised name makes the call site + * obviously factory-routed. + */ + authMe: () => ["auth", "me"] as const, + authRegistrationStatus: () => ["auth", "registration-status"] as const, + /** + * v1.4.40 W-RSC — Settings → AI surfaces and the targets editor + * subscribe to the user-thresholds API. Centralise the key so a + * future rename (e.g. `["user","limits"]`) doesn't drift across the + * three call sites (settings/thresholds-editor-section, + * settings/ai-section, targets/target-edit-sheet). + */ + userThresholds: () => ["user", "thresholds"] as const, + /** + * v1.7.0 — Settings → Display metric/imperial control reads its + * current value from `GET /api/auth/me/unit-preference`. The PATCH + * mutation also invalidates `authMe()` so `useAuth().unitPreference` + * (and every chart display transform that keys off it) re-renders + * without a manual reload. + */ + userUnitPreference: () => ["user", "unit-preference"] as const, + + passkeys: () => ["passkeys"] as const, + + userAiProvider: () => ["user", "ai-provider"] as const, + userProfile: () => ["user", "profile"] as const, + /** + * v1.7.0 — the roaming notification prefs blob behind + * `GET/PATCH /api/auth/me/notification-prefs` (medication delivery + * default + mood reminder hour). Distinct from + * `notificationsPreferences()` (the per-event push toggles on the + * `/notifications` page) so the two never collide in the cache. + */ + authNotificationPrefs: () => ["auth", "me", "notification-prefs"] as const, + + /** + * v1.4.25 W5e — per-user, per-metric-class source priority. The + * Settings → Sources surface reads + writes this key; saving + * invalidates `analytics()` because the cumulative-metric aggregator + * folds the new priority into the SLEEP_DURATION daily total + * immediately. + */ + sourcePriority: () => ["auth", "source-priority"] as const, +}; diff --git a/src/lib/query-keys/coach.ts b/src/lib/query-keys/coach.ts new file mode 100644 index 000000000..e759d3342 --- /dev/null +++ b/src/lib/query-keys/coach.ts @@ -0,0 +1,14 @@ +/** + * Query keys — Coach preferences, facts, about-me, and nudge state. + * Part of the centralized factory; aggregated in `./index.ts`. + */ +export const coachKeys = { + coachPrefs: () => ["coach-prefs"] as const, + coachFacts: () => ["coach-facts"] as const, + /** v1.15.20 — Settings → AI "about me" self-description. */ + coachAboutMe: () => ["coach-about-me"] as const, + /** v1.16.0 — pending clarifying questions (Coach composer chips). */ + coachAboutMeQuestions: () => ["coach-about-me", "questions"] as const, + /** v1.16.1 — unseen proactive-nudge state for the floating Coach bubble. */ + coachNudgeStatus: () => ["coach-nudge-status"] as const, +}; diff --git a/src/lib/query-keys/cycle.ts b/src/lib/query-keys/cycle.ts new file mode 100644 index 000000000..c4ca520d9 --- /dev/null +++ b/src/lib/query-keys/cycle.ts @@ -0,0 +1,30 @@ +/** + * Query keys — cycle tracking: calendar windows, history, profile, + * prefs, insights, day logs, and custom symptoms. + * Part of the centralized factory; aggregated in `./index.ts`. + */ +export const cycleKeys = { + /** + * v1.15.0 — cycle-tracking surfaces. `cycle()` is the root prefix every + * cycle write invalidates through (`cycleDependentKeys` in `./index.ts`). + * The calendar read is keyed by `(from, to)` so paging the month strip + * caches each window independently; the history + profile reads each get + * their own slot. A day-log / period write evicts the whole `["cycle"]` + * prefix so the calendar, the wheel, the predictions panel, and the + * history stats repaint in lockstep. + */ + cycle: () => ["cycle"] as const, + cycleCalendar: (from: string, to: string) => + ["cycle", "calendar", from, to] as const, + cycleHistory: (limit: number) => ["cycle", "history", limit] as const, + cycleProfile: () => ["cycle", "profile"] as const, + /** The UNGATED enable/prefs read (`/api/auth/me/cycle-prefs`) — the settings + * on-ramp reads this so a non-FEMALE account can opt in before the gated + * cycle page is reachable. */ + cyclePrefs: () => ["cycle", "prefs"] as const, + cycleInsights: () => ["cycle", "insights"] as const, + cycleDayLog: (date: string) => ["cycle", "day-log", date] as const, + /** The caller's own custom symptoms (decrypted labels) the log-day sheet + * merges into the seeded chip grid. */ + cycleCustomSymptoms: () => ["cycle", "custom-symptoms"] as const, +}; diff --git a/src/lib/query-keys/dashboard.ts b/src/lib/query-keys/dashboard.ts new file mode 100644 index 000000000..30ea6ea41 --- /dev/null +++ b/src/lib/query-keys/dashboard.ts @@ -0,0 +1,48 @@ +/** + * Query keys — dashboard snapshot, widget layout, and analytics reads. + * Part of the centralized factory; aggregated in `./index.ts`. + */ +export const dashboardKeys = { + /** + * v1.4.33 IW2 — the analytics queryKey now optionally carries a + * `slice` discriminator so the dashboard tile-strip can subscribe to + * the slim `?slice=summaries` server slice (IW1 / C1) without + * colliding with the thick-payload consumers on the Insights tree. + * Calling `queryKeys.analytics()` without a slice keeps the legacy + * shape `["analytics"]` so mutation invalidations and the bulk-key + * lists stay byte-identical. + */ + analytics: (slice?: "summaries") => + slice ? (["analytics", slice] as const) : (["analytics"] as const), + /** + * v1.9.0 — single-metric period-over-period range read + * (`GET /api/analytics/range`). A dedicated cache slot per `(type, range)` + * so switching the time-range pills is a cheap cache hit after the first + * fetch and never collides with the shared `["analytics", "summaries"]` + * slot the dashboard tile-strip subscribes to. `["analytics"]` is a prefix + * so a blanket `queryKeys.analytics()` invalidation still reaches it. + */ + analyticsRange: (type: string, range: string) => + ["analytics", "range", type, range] as const, + + /** + * v1.7.0 W6 — unified dashboard first-paint snapshot. One client cell + * hydrates every above-the-fold tile from `GET /api/dashboard/snapshot`, + * replacing the four independent analytics-slim / analytics-thick / + * mood / widget-layout cells. A measurement / mood / medication / + * widget / insight write evicts the matching server cache bucket via + * `src/lib/cache/invalidate.ts`; the client read carries the same + * 60 s `staleTime` as `DASHBOARD_QUERY_OPTS` so a warm return-to- + * dashboard is a free cache hit. + */ + dashboardSnapshot: () => ["dashboard", "snapshot"] as const, + + /** + * v1.4.22 W5 reconcile (Code-LOW-5) — `["user", "dashboardWidgets"]` + * was duplicated as a literal at three call sites (dashboard, + * insights, settings/dashboard-layout). One typo turns into a + * silent cache miss + extra fetch; the centralised key defends + * against the same query-key-collision class as `analytics()`. + */ + dashboardWidgets: () => ["user", "dashboardWidgets"] as const, +}; diff --git a/src/lib/query-keys/index.ts b/src/lib/query-keys/index.ts new file mode 100644 index 000000000..c91b1a386 --- /dev/null +++ b/src/lib/query-keys/index.ts @@ -0,0 +1,144 @@ +/** + * Centralized TanStack Query key factory. + * + * Every useQuery/invalidateQueries call should go through this factory so that + * mutations invalidate the right consumers. Hard-coded string arrays drifted in + * the past (e.g. ["measurements"] didn't invalidate ["analytics"] on the + * dashboard), so treat this module as the single source of truth. + * + * The factory is split into per-feature files under this directory; this + * index assembles them into the one `queryKeys` object every call site + * imports (`@/lib/query-keys` resolves here). The dependent-key bundles + * live here too because they fan out across feature boundaries. + */ + +import type { QueryClient, QueryKey } from "@tanstack/react-query"; + +import { adminKeys } from "./admin"; +import { authKeys } from "./auth"; +import { coachKeys } from "./coach"; +import { cycleKeys } from "./cycle"; +import { dashboardKeys } from "./dashboard"; +import { insightsKeys } from "./insights"; +import { integrationKeys } from "./integrations"; +import { measurementKeys } from "./measurements"; +import { medicationKeys } from "./medications"; +import { moodKeys } from "./mood"; +import { settingsKeys } from "./settings"; +import { workoutKeys } from "./workouts"; + +export const queryKeys = { + ...authKeys, + ...measurementKeys, + ...moodKeys, + ...dashboardKeys, + ...insightsKeys, + ...medicationKeys, + ...coachKeys, + ...adminKeys, + ...integrationKeys, + ...workoutKeys, + ...settingsKeys, + ...cycleKeys, +}; + +/** + * Keys that should be invalidated when a measurement is created, updated or + * deleted. Kept here so dashboards, insights, and targets always stay in sync. + * + * v1.4.40 W-RSC — `["chart-data"]` prefix now lives in the bundle so a + * fresh measurement evicts every per-chart daily-aggregate cache. The + * prefix matches every key returned from `queryKeys.chartData(…)` via + * TanStack's hierarchical-prefix semantics — adding a measurement now + * refreshes the tile strip *and* the chart row in lockstep instead of + * leaving the chart row 60 s stale (audit C2). + */ +export const measurementDependentKeys = [ + queryKeys.measurements(), + queryKeys.analytics(), + queryKeys.insightsRoot(), + queryKeys.insightsTargets(), + queryKeys.gamificationAchievements(), + ["chart-data"] as const, + // v1.8.5 — re-run the diversity-nudge clustering when readings change. + ["measurement-diversity"] as const, + // v1.11.4 item J — refresh the Trends-row deterministic caption series + // when a reading changes, in lockstep with the chart row above it. + ["trend-series"] as const, + // v1.11.5 — refresh the last-night hypnogram when sleep rows change. + ["sleep-night"] as const, +]; + +/** + * Keys that should be invalidated when a mood entry is created, updated or + * deleted. + */ +export const moodDependentKeys = [ + queryKeys.moodEntries(), + queryKeys.moodAnalytics(), + queryKeys.moodInsights(), + queryKeys.insightsRoot(), + queryKeys.insightsTargets(), + queryKeys.gamificationAchievements(), +]; + +/** + * Keys invalidated when medications change (CRUD or intake). + * + * v1.4.40 W-RSC — the dashboard's aggregate compliance chart now + * rides the factory under `dashboardMedicationCompliance`. The prefix + * `["dashboard-medication-compliance"]` lands in the bundle so an + * intake POST refreshes the chart immediately rather than waiting for + * `staleTime` (audit L4). + * + * v1.5.5 D-3 §10 invariant 20 (was C-E2-1 / H-cluster-G) — the + * per-medication inline compliance chart used to mount under + * `queryKeys.medicationComplianceChart(medicationId)` which expands to + * `["compliance-chart-inline", id]`. The prefix `["compliance-chart-inline"]` + * lands in the bundle so every detail-page mutation (today's-dose, + * Pausieren, end, purge, edit) evicts the inline compliance tile in + * one tick. The TanStack hierarchical-prefix semantics catch every + * per-medication slot under that prefix. + * + * `queryKeys.medicationDetail(id)` rides under the + * `["medications"]` prefix already so a single medication invalidation + * also evicts its detail-page read. + */ +export const medicationDependentKeys = [ + queryKeys.medications(), + queryKeys.analytics(), + queryKeys.insightsRoot(), + queryKeys.insightsTargets(), + queryKeys.gamificationAchievements(), + ["dashboard-medication-compliance"] as const, + ["compliance-chart-inline"] as const, +]; + +/** + * Keys invalidated when cycle data changes (a day-log capture, a period + * boundary, a day-log delete). The `["cycle"]` prefix catches the calendar + * windows, the history stats, and the profile read in one tick so the + * calendar/wheel and predictions panel never read stale rows after a quick + * log. `insightsRoot()` rides along because phase-correlation cards depend + * on the same rows. + */ +export const cycleDependentKeys = [queryKeys.cycle(), queryKeys.insightsRoot()]; + +/** + * Invalidate every key in the bundle in parallel. Use this from mutation + * `onSuccess` handlers so the call site stays a one-liner instead of repeating + * `Promise.all(keys.map(...))` everywhere. + * + * Uses `allSettled` so one transient network failure doesn't abort subsequent + * invalidations (cache would otherwise be left half-stale) and so the `void + * invalidateKeys(...)` fire-and-forget pattern in delete handlers never + * surfaces an unhandled rejection. + */ +export function invalidateKeys( + queryClient: QueryClient, + keys: readonly QueryKey[], +): Promise[]> { + return Promise.allSettled( + keys.map((queryKey) => queryClient.invalidateQueries({ queryKey })), + ); +} diff --git a/src/lib/query-keys/insights.ts b/src/lib/query-keys/insights.ts new file mode 100644 index 000000000..d90fade89 --- /dev/null +++ b/src/lib/query-keys/insights.ts @@ -0,0 +1,129 @@ +/** + * Query keys — insights tree: comprehensive payloads, status assessments, + * derived metrics, correlations, layout, and AI settings. + * Part of the centralized factory; aggregated in `./index.ts`. + */ +export const insightsKeys = { + insightsRoot: () => ["insights"] as const, + insightsComprehensive: () => ["insights", "comprehensive"] as const, + insightsTargets: () => ["insights", "targets"] as const, + /** + * Shared cache key for the rich `/api/insights/generate` advisor + * payload. Every surface that subscribes under this key shares the + * same cache so a regenerate on one surface refreshes the others + * without a second LLM round-trip. + */ + insightsAdvisor: () => ["insights", "advisor"] as const, + /** + * v1.11.0 — period-narrative summary (`/api/insights/narrative?period=…`). + * Keyed by period + locale so the week and month summaries cache + * independently and a locale switch fetches the matching prose. + */ + insightsNarrative: (period: string, locale: string) => + ["insights", "narrative", period, locale] as const, + insightsBpStatus: (locale: string) => + ["insights", "blood-pressure-status", locale] as const, + insightsWeightStatus: (locale: string) => + ["insights", "weight-status", locale] as const, + insightsPulseStatus: (locale: string) => + ["insights", "pulse-status", locale] as const, + insightsBmiStatus: (locale: string) => + ["insights", "bmi-status", locale] as const, + insightsMoodStatus: (locale: string) => + ["insights", "mood-status", locale] as const, + insightsMedicationComplianceStatus: (locale: string) => + ["insights", "medication-compliance-status", locale] as const, + /** + * v1.8.7.1 — per-metric insight assessment for the HealthKit metric + * sub-pages. One generic route (`/api/insights/metric-status?metric=…`) + * backs every HealthKit metric, so the key is parameterised by the + * metric id alongside the locale rather than minting a bespoke factory + * entry per metric. The seven bespoke `*-status` routes above keep + * their own keys; this one covers the ~29 generic HealthKit pages. + */ + insightsMetricStatus: (metric: string, locale: string) => + ["insights", "metric-status", metric, locale] as const, + /** + * v1.8.7.1 — mutationKey for the on-demand full-warm POST + * (`/api/insights/pregenerate`). Lives in the factory so the bare-array + * ESLint rule stays satisfied; the warm enqueues every assessment + * generation on the worker, so there is no read cache to invalidate + * here — the existing status queries pick up the warmed text via their + * own stale-while-revalidate. + */ + insightsPregenerate: () => ["insights", "pregenerate"] as const, + /** + * v1.10.0 — categorical events (WX-B). The device-flagged event + * awareness timeline (`/api/insights/rhythm-events`). No locale segment + * — the payload is verdicts + timestamps; the surface localises its own + * prose. The `["insights"]` prefix keeps it in the standard insights + * invalidation fan-out (an Apple Health batch write that lands an event + * row busts it via the prefix). + */ + insightsRhythmEvents: () => ["insights", "rhythm-events"] as const, + /** + * v1.10.0 — derived-wellness metrics. One generic route + * (`/api/insights/derived?metric=…[&type=…]`) backs the vitals + * dashboard tiles, the composite score-anatomy view, and the home + * wellness strip — all through the one `useDerivedMetric` hook. + * + * One generic route backs every derived metric (sleep score, readiness, + * coincident-deviation, vitals baseline, …); the optional sub-type is the + * chosen vital for `VITALS_BASELINE`. The fourth tuple slot is always + * present (`null` when no sub-type) so the cache never poisons between a + * 3-element and a 4-element shape for the same metric prefix. Pure compute + * over the rollup tier — a measurement write keeps it fresh by construction + * (next read sees new buckets), so the `["insights"]` prefix carries the + * invalidation fan-out. + */ + insightsDerived: (metric: string, type?: string | null) => + ["insights", "derived", metric, type ?? null] as const, + /** + * v1.10.0 — batched derived-metric read (`/api/insights/derived/batch`). + * One request resolves the whole dashboard grid server-side instead of N + * concurrent single-metric reads sharing the Prisma pool. Keyed by the + * sorted token list so the same grid always hits one cache entry; sits + * under the `["insights", "derived"]` prefix so a measurement-write + * invalidation fan-out reaches it exactly like the single-metric reads. + */ + insightsDerivedBatch: (tokens: readonly string[]) => + ["insights", "derived", "batch", [...tokens].sort().join(",")] as const, + /** + * v1.10.0 — FDR-controlled correlation discovery + * (`/api/insights/correlations`). Read-only descriptive surface; the + * `["insights"]` prefix keeps it in the existing invalidation fan-out. + */ + insightsCorrelations: () => ["insights", "correlations"] as const, + + /** + * v1.4.41 W-FRONTEND-FACTORY — Settings → AI surfaces (provider chain, + * insights settings, user provider preference) and the targets editor + * all read these endpoints; centralising the keys keeps invalidation + * symmetrical with the user-thresholds + auth surfaces. + */ + insightsSettings: () => ["insights", "settings"] as const, + insightsProviderChain: () => ["insights", "provider-chain"] as const, + insightsGlp1Timeline: (limit: number | string) => + ["insights", "glp1-timeline", limit] as const, + + /** + * v1.5.5 — per-user insights tile layout (mirrors + * `dashboardWidgets`). The Settings UI + the iOS client + the + * `/insights` shell all read this key; the PUT mutation invalidates + * it on save so every consumer paints the new layout in lockstep. + */ + insightsLayout: () => ["user", "insightsLayout"] as const, + + /** + * v1.11.4 item J — bounded 30-day daily-aggregate read backing the + * Insights Trends-row deterministic caption (`trend-descriptor.ts`). + * Keyed by the comma-joined measurement type set so each card's slot + * caches its own small window (≤ 30 rollup rows). Distinct from + * `chartData(...)` so the caption never re-keys when the chart's + * internal value-mode / scale / range state changes; it only needs the + * raw 30-day series to derive direction + magnitude. Shares the + * `["trend-series"]` invalidation prefix so a measurement write + * refreshes the caption in lockstep with the chart. + */ + insightsTrendSeries: (types: string) => ["trend-series", types] as const, +}; diff --git a/src/lib/query-keys/integrations.ts b/src/lib/query-keys/integrations.ts new file mode 100644 index 000000000..60a309111 --- /dev/null +++ b/src/lib/query-keys/integrations.ts @@ -0,0 +1,45 @@ +/** + * Query keys — external integrations: API tokens, Telegram, Withings, + * WHOOP, Fitbit, moodLog, and the Apple Health export import job. + * Part of the centralized factory; aggregated in `./index.ts`. + */ +export const integrationKeys = { + tokens: () => ["tokens"] as const, + telegram: () => ["telegram"] as const, + telegramSettings: () => ["telegram", "settings"] as const, + withings: () => ["withings"] as const, + /** + * v1.4.42 W3-QUERYKEY-LONGTAIL — the per-card Withings status read. + * Shares the `["withings"]` prefix with `withings()` so a disconnect + * mutation invalidates both at once. + */ + withingsStatus: () => ["withings", "status"] as const, + whoop: () => ["whoop"] as const, + /** + * Per-card WHOOP status read. Shares the `["whoop"]` prefix with `whoop()` + * so a disconnect / credentials mutation invalidates both at once. + */ + whoopStatus: () => ["whoop", "status"] as const, + // v1.12.0 — Fitbit/Pixel integration card, mirroring the WHOOP keys. + fitbit: () => ["fitbit"] as const, + /** + * Per-card Fitbit status read. Shares the `["fitbit"]` prefix with `fitbit()` + * so a disconnect / credentials mutation invalidates both at once. + */ + fitbitStatus: () => ["fitbit", "status"] as const, + + moodlogStatus: () => ["moodlog-status"] as const, + integrationsStatus: () => ["integrations", "status"] as const, + + /** + * v1.15.7 — in-flight Apple Health `export.zip` import. Keyed by the + * `jobId` returned from `POST /api/import/apple-health-export` so the + * Export & Import settings area can poll + * `GET /api/import/apple-health-export/[jobId]/status` with a + * `refetchInterval` while the job is running and stop on a terminal + * state. Each upload mints its own key, so a re-upload (or a second + * archive) never collides with a previous job's cached status. + */ + importJobStatus: (jobId: string) => + ["import", "apple-health", "status", jobId] as const, +}; diff --git a/src/lib/query-keys/measurements.ts b/src/lib/query-keys/measurements.ts new file mode 100644 index 000000000..e4081bc0a --- /dev/null +++ b/src/lib/query-keys/measurements.ts @@ -0,0 +1,97 @@ +/** + * Query keys — measurements, sleep night, and per-chart aggregate reads. + * Part of the centralized factory; aggregated in `./index.ts`. + */ +export const measurementKeys = { + measurements: () => ["measurements"] as const, + + /** + * v1.15.13 — the measurements management-list read with its full filter + * + sort + pagination state baked into the key so the cache slot is + * correct for every filter combination (a `sourceEq` / date-range / + * type / page / sort change re-keys instead of poisoning a shared + * slot). Rides under the `["measurements"]` prefix so + * `measurementDependentKeys` (and a bulk-delete invalidation) reaches + * every slot at once. `mode` distinguishes the synthetic day-grouped / + * sleep-night branches from the plain `raw` list. + */ + measurementsList: (params: { + type: string | undefined; + sourceEq: string | undefined; + from: string | undefined; + to: string | undefined; + page: number; + sortBy: string; + sortDir: string; + mode: "raw" | "groupBy=day" | "sleep-night"; + }) => + [ + "measurements", + "list", + params.type ?? null, + params.sourceEq ?? null, + params.from ?? null, + params.to ?? null, + params.page, + params.sortBy, + params.sortDir, + params.mode, + ] as const, + + /** + * v1.11.5 — last-night hypnogram (`GET /api/sleep/night`). The `date` + * discriminator lets the night-picker step back through recent nights + * without colliding caches; `undefined` is the most-recent night. + */ + sleepNight: (date?: string) => ["sleep-night", date ?? "latest"] as const, + + /** + * v1.4.40 W-RSC — per-chart daily-aggregate fetch from the dashboard + * + insights chart row. Pre-fix the key was bare `["chart-data", …]` + * across the codebase, which excluded it from + * `measurementDependentKeys` and left chart caches stale for up to + * 60 s after a measurement save (audit-C2). Routing through the + * factory pulls every variant under a single + * `["chart-data"]` invalidation prefix so a mutation refreshes the + * tile strip + the chart row in lockstep. + * + * The shape carries the heavy parameter list because the chart query + * is bounded by metric set, value mode, BMI divisor, timezone, and + * fetch window; the factory packs those into a single tuple to keep + * the cache layout byte-identical with the pre-v1.4.40 layout. + */ + chartData: ( + types: string, + valueMode: string, + bmiDivisor: string | number, + timezone: string, + fromIso: string, + toIso: string, + // v1.7.0 — display-time value scale (e.g. m/s → km/h via 3.6). + // Defaults to 1 so every pre-v1.7.0 caller packs a byte-identical + // tuple; a non-default scale re-keys the cache so the processed + // (scaled) series doesn't bleed across charts that share the + // underlying raw window. + valueScale: number = 1, + ) => + [ + "chart-data", + types, + valueMode, + bmiDivisor, + timezone, + fromIso, + toIso, + valueScale, + ] as const, + + /** + * v1.8.5 — bounded recent-timestamp read powering the + * measurement-diversity nudge on an insights category page. Keyed by + * the page's `MeasurementType` so each metric caches its own window. + * Shares the `measurement-dependent` invalidation prefix below so an + * edit/delete in the values subpage re-runs the clustering check. + */ + measurementDiversity: (type: string) => + ["measurement-diversity", type] as const, +}; diff --git a/src/lib/query-keys/medications.ts b/src/lib/query-keys/medications.ts new file mode 100644 index 000000000..02384524c --- /dev/null +++ b/src/lib/query-keys/medications.ts @@ -0,0 +1,96 @@ +/** + * Query keys — medications: detail, compliance, cadence, inventory, + * dose history, intake lists, and the api-endpoint status row. + * Part of the centralized factory; aggregated in `./index.ts`. + */ +export const medicationKeys = { + medications: () => ["medications"] as const, + medicationDetail: (id: string) => ["medications", id] as const, + medicationComplianceChart: (medicationId: string) => + ["compliance-chart-inline", medicationId] as const, + /** + * v1.4.42 W3-QUERYKEY-LONGTAIL — per-medication compliance KPI used + * by `medication-card` + `glp1-medication-card`. Centralising lets + * the intake mutation invalidate every compliance read through the + * `["medications"]` prefix instead of one bare-literal at a time. + */ + medicationCompliance: (medicationId: string) => + ["medications", medicationId, "compliance"] as const, + medicationCadence: (medicationId: string) => + ["medications", medicationId, "cadence"] as const, + medicationGlp1Details: (medicationId: string) => + ["medications", medicationId, "glp1-details"] as const, + /** + * v1.15.18 — per-medication pen/vial inventory list, consumed by the + * detail page's Bestand (supply) tab. Rides under the `["medications"]` + * prefix so an intake mutation that decrements a pen invalidates it. + */ + medicationInventory: (medicationId: string) => + ["medications", medicationId, "inventory"] as const, + /** + * v1.15.18 — per-medication dose-history ledger + * (`GET /api/medications/[id]/dose-history?from=&to=`), consumed by the + * detail page's Verlauf tab. Keyed by the `(from, to)` window so paging the + * range caches each span independently; rides under the `["medications"]` + * prefix so an intake mutation (take / skip / edit / delete) reaches it + * through `medicationDependentKeys`. The Verlauf tab also mutates this slot + * optimistically on Genommen / Übersprungen so the row status + the + * Übersicht headline % flip in the same paint, then reconciles on the + * authoritative refetch. + */ + medicationDoseHistory: (medicationId: string, from: string, to: string) => + ["medications", medicationId, "dose-history", from, to] as const, + medicationIntakeDrugLevelChart: (medicationId: string) => + ["medications", medicationId, "intake", "drug-level-chart"] as const, + /** + * v1.4.42 — intake-history list with sort / paging / status filter. + * The opaque params object lives at index 4 so the + * `["medications", id, "intake", "list"]` prefix invalidates every + * sort/page combination on an intake mutation. + */ + medicationIntakeList: ( + medicationId: string, + params: { + sortBy: string; + sortDir: string; + limit: number; + offset: number; + status: string; + }, + ) => + [ + "medications", + medicationId, + "intake", + "list", + params.sortBy, + params.sortDir, + params.limit, + params.offset, + params.status, + ] as const, + /** + * v1.4.40 W-RSC — the dashboard-level compliance chart (aggregate + * across every scheduled medication) was a bare `["medication- + * compliance-chart", days]` key; route it through the factory so + * `medicationDependentKeys` invalidates it on intake-mutation just + * like the per-medication compliance-chart-inline tile. `days` is the + * range (7 / 30 / 90); kept as the only param so the prefix + * `["dashboard-medication-compliance"]` invalidates every range at + * once. + */ + dashboardMedicationCompliance: (days: number) => + ["dashboard-medication-compliance", days] as const, + medicationPhaseConfig: (medicationId: string) => + ["phase-config", medicationId] as const, + /** + * v1.5.5 F-1 H-2 — per-medication api-endpoint status (enabled + + * active-token-count) used by the detail-page Externe Integration + * row. The key rides under the `["medications", id, …]` prefix so + * `medicationDependentKeys` catches it on token mint / disable. + * Centralising the tuple closes the bare-array bypass the + * useMemo inside `` was using. + */ + medicationApiEndpoint: (medicationId: string) => + ["medications", medicationId, "api-endpoint"] as const, +}; diff --git a/src/lib/query-keys/mood.ts b/src/lib/query-keys/mood.ts new file mode 100644 index 000000000..9fb35b159 --- /dev/null +++ b/src/lib/query-keys/mood.ts @@ -0,0 +1,51 @@ +/** + * Query keys — mood entries, mood analytics, and the tag catalog. + * Part of the centralized factory; aggregated in `./index.ts`. + */ +export const moodKeys = { + moodEntries: () => ["mood-entries"] as const, + + /** + * v1.15.13 — the mood management-list read with its filter + sort + + * pagination state baked into the key, mirroring `measurementsList`. + * Rides under the `["mood-entries"]` prefix so `moodDependentKeys` + * (and a bulk-delete invalidation) reaches every slot. + */ + moodEntriesList: (params: { + mood: string | undefined; + source: string | undefined; + from: string | undefined; + to: string | undefined; + page: number; + sortBy: string; + sortDir: string; + }) => + [ + "mood-entries", + "list", + params.mood ?? null, + params.source ?? null, + params.from ?? null, + params.to ?? null, + params.page, + params.sortBy, + params.sortDir, + ] as const, + + moodAnalytics: () => ["mood-analytics"] as const, + /** + * v1.8.5 — pre-computed mood-insights aggregates (heatmap, distribution, + * weekday, tag breakdown, cross-metric correlations) for the Mood + * Insights page. Read-only; invalidated on a mood write through the + * `moodDependentKeys` fan-out in `./index.ts`. + */ + moodInsights: () => ["mood-insights"] as const, + /** + * v1.8.5 — structured mood-tag taxonomy catalog (global reference + * data, identical for every user). Read by the mood-logging form's + * tag-category capture surface. Not invalidated on a mood write — the + * catalog only changes on a migration / admin edit, so a long + * `staleTime` is fine. + */ + moodTagCatalog: () => ["mood-tag-catalog"] as const, +}; diff --git a/src/lib/query-keys/settings.ts b/src/lib/query-keys/settings.ts new file mode 100644 index 000000000..0751f987b --- /dev/null +++ b/src/lib/query-keys/settings.ts @@ -0,0 +1,33 @@ +/** + * Query keys — settings + notification surfaces, version/meta reads, + * gamification, share links, and feature flags. + * Part of the centralized factory; aggregated in `./index.ts`. + */ +export const settingsKeys = { + gamificationAchievements: () => ["gamification", "achievements"] as const, + + notificationsPreferences: () => ["notifications", "preferences"] as const, + notificationsStatus: () => ["notifications", "status"] as const, + + settingsGlobalServices: () => ["settings", "global-services"] as const, + settingsNtfy: () => ["settings", "ntfy"] as const, + settingsReminderThresholds: () => + ["settings", "reminder-thresholds"] as const, + + /** v1.8.5 — user-level injection-site preferences (global exclusion). */ + injectionSitePrefs: () => ["settings", "injection-site-prefs"] as const, + + apiVersion: () => ["api", "version"] as const, + publicVersion: () => ["public", "version"] as const, + /** v1.15.12 H1 — admin overview "update available" check against the + * latest GitHub release tag (best-effort, day-stale). */ + versionUpdateCheck: () => ["version", "update-check"] as const, + researchMode: () => ["research-mode"] as const, + + /** v1.11.0 — owner's clinician share links (Settings → Sharing). */ + shareLinks: () => ["share-links"] as const, + + featureFlags: () => ["feature-flags"] as const, + + bugreportStatus: () => ["bugreport", "status"] as const, +}; diff --git a/src/lib/query-keys/workouts.ts b/src/lib/query-keys/workouts.ts new file mode 100644 index 000000000..0cba3f919 --- /dev/null +++ b/src/lib/query-keys/workouts.ts @@ -0,0 +1,25 @@ +/** + * Query keys — workout list, recent strip, and detail caches. + * Part of the centralized factory; aggregated in `./index.ts`. + */ +export const workoutKeys = { + // v1.4.32 — workout list + detail caches. `workouts()` is the + // root key invalidated by the batch-ingest mutation; the recent + + // detail sub-keys ride underneath so the dashboard tile and the + // detail page share a cache slot with the list page. + workouts: () => ["workouts"] as const, + workoutsRecent: () => ["workouts", "recent"] as const, + /** + * v1.4.42 W3-QUERYKEY-LONGTAIL — the `useWorkouts` hook used to + * spread `workoutsRecent()` and append an opts object inline. The + * factory now owns the full shape so the hook never reaches for a + * literal-array wrapper. + */ + workoutsRecentList: (opts: { + limit?: number; + offset?: number; + since?: string; + sportType?: string; + }) => ["workouts", "recent", opts] as const, + workoutDetail: (id: string) => ["workouts", id] as const, +};