mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 04:12:04 +00:00
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:
1 parent
fdf48e4890
commit
35e8f076a2
33 files changed
+6097
-5039
No files matched your search
@@ -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: {
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -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.",
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -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 } },
|
||||
},
|
||||
};
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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 })),
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
Reference in new issue
Block a user