refactor(openapi,queries): feature-located registries behind thin indexes

The five-thousand-line route registry splits into thirteen domain
modules and the query-key factory into twelve feature files — each
behind an index that preserves exact registration order, proven by a
byte-identical generated contract. The factory lint rule learns the new
home directory.
This commit is contained in:
Marc-André Bombeck committed 2026-06-11 00:10:00 +02:00
1 parent fdf48e4890
commit 35e8f076a2
33 files changed
+6097 -5039

No files matched your search

+8 -5
View File
@@ -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.<entry>()` from `src/lib/query-keys.ts` instead.",
"Disallow literal-array `queryKey` / `mutationKey` declarations in guarded files. Use `queryKeys.<entry>()` from `src/lib/query-keys/` instead.",
recommended: false,
},
messages: {
+3 -3
View File
@@ -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
+1 -1
View File
@@ -23,4 +23,4 @@ client → src/proxy.ts → src/app/<route>/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`.
+1 -1
View File
@@ -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.
File diff suppressed because it is too large. Load diff
+220
View File
@@ -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<ZodOpenApiObject["paths"]> = {
"/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<ZodOpenApiObject["paths"]> = {
"/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,
},
},
},
};
+148
View File
@@ -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<ZodOpenApiObject["paths"]> = {
"/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,
},
},
},
};
+438
View File
@@ -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/<slug>` 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<ZodOpenApiObject["paths"]> = {
"/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<ZodOpenApiObject["paths"]> = {
"/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,
},
},
},
};
+691
View File
@@ -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<ZodOpenApiObject["paths"]> = {
"/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:<userId>` 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,
},
},
},
};
+85
View File
@@ -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<ZodOpenApiObject["paths"]> = {
"/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,
},
},
},
};
+295
View File
@@ -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<ZodOpenApiObject["paths"]> = {
"/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:<userId>` 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:<userId>`, 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,
},
},
},
};
+83
View File
@@ -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<ZodOpenApiObject["paths"]> = {
...cyclePaths,
...metaPaths,
...healthRecordPaths,
...authPaths,
...syncPaths,
...measurementPaths,
...workoutPaths,
...devicePaths,
...medicationPaths,
...profilePaths,
...coachPaths,
...adminInvitePaths,
...coachFeedbackPaths,
...adminDiagnosticPaths,
...insightsPaths,
};
export const openApiComponents: NonNullable<ZodOpenApiObject["components"]> = {
// 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.",
},
},
};
+966
View File
@@ -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<T>` 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 `<DERIVED_METRIC_ID>` or `<DERIVED_METRIC_ID>:<MeasurementType>` (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 (`<metric>` or `<metric>:<type>`). Each value is the same flat `Derived<T>` 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<ZodOpenApiObject["paths"]> = {
"/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:<userId>`, 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<T>` 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<T>` 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,
},
},
},
};
+362
View File
@@ -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<ZodOpenApiObject["paths"]> = {
"/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:<userId>`. 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,
},
},
},
};
+830
View File
@@ -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<ZodOpenApiObject["paths"]> = {
"/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:<id>: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,
},
},
},
};
+142
View File
@@ -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<ZodOpenApiObject["paths"]> = {
"/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,
},
},
},
};
+416
View File
@@ -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<ZodOpenApiObject["paths"]> = {
"/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,
},
},
},
};
+109
View File
@@ -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<T extends z.ZodType>(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 } },
},
};
+263
View File
@@ -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:<id>:<date>`)."),
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<ZodOpenApiObject["paths"]> = {
"/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,
},
},
},
};
+206
View File
@@ -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<ZodOpenApiObject["paths"]> = {
"/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,
},
},
},
};
+63
View File
@@ -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", "<name>"]`. 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,
};
+55
View File
@@ -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,
};
+14
View File
@@ -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,
};
+30
View File
@@ -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,
};
+48
View File
@@ -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,
};
+144
View File
@@ -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<PromiseSettledResult<unknown>[]> {
return Promise.allSettled(
keys.map((queryKey) => queryClient.invalidateQueries({ queryKey })),
);
}
+129
View File
@@ -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,
};
+45
View File
@@ -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,
};
+97
View File
@@ -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,
};
+96
View File
@@ -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 `<ApiTokensRow>` was using.
*/
medicationApiEndpoint: (medicationId: string) =>
["medications", medicationId, "api-endpoint"] as const,
};
+51
View File
@@ -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,
};
+33
View File
@@ -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,
};
+25
View File
@@ -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,
};