mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 05:12:14 +00:00
Add the menstrual-cycle phase as a channel in the existing FDR-guarded correlation machinery, the same way rated mood factors were folded in — no new statistics, the same Welch + Benjamini-Hochberg pipeline, the same day floors and q = 0.10 gate. Two complementary mechanisms, both confined to a gender-gated surface: - a categorical luteal-vs-follicular contrast per outcome metric (resting heart rate, HRV, sleep, steps, weight, basal/wrist/skin temperature), surfacing one headline finding (resting-heart-rate-by-phase, falling back to HRV); and - the continuous phase ordinal folded into the lagged-Pearson matrix. Phase never reaches the general correlations route — the temperature channels held out of the general matrix are un-blocked only here, behind the cycle gate, so a reproductive inference can never leak onto a non-gated surface. Every row shows n, effect size, p and q; nothing is fabricated on thin data and nothing is framed as causal. Surface it through a new gated GET /api/cycle/insights and a drop-in card that mirrors the mood-factor crosstab. Add a cycle dimension to the Coach snapshot: current phase + day-of-cycle, the next predicted event (period range + confidence + method, fertile window goal-gated), and the headline phase finding — present only for a cycle-enabled account, so a non-cycle snapshot is unchanged. A new ground rule keeps the Coach descriptive: never contraception-grade, never a "safe day" claim. OpenAPI regenerated; phase-correlation + snapshot-inclusion tests added; copy localised across all six locales. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
4464 lines
164 KiB
TypeScript
4464 lines
164 KiB
TypeScript
/**
|
||
* OpenAPI route table — populated incrementally.
|
||
*
|
||
* v1.4.23 baseline covers the routes the v1.5 iOS app touches end-to-end:
|
||
* - POST /api/auth/login
|
||
* - POST /api/auth/passkey/login-verify
|
||
* - POST /api/auth/refresh
|
||
* - GET /api/measurements
|
||
* - POST /api/measurements
|
||
* - POST /api/measurements/batch (W2 — Apple Health ingest)
|
||
* - POST /api/devices (W3 — APNs registration)
|
||
* - GET /api/insights/comprehensive
|
||
*
|
||
* Schemas come from `src/lib/validations/*` so the wire contract stays
|
||
* single-source-of-truth. The `.meta()` annotations on each schema land
|
||
* the title + description in `components.schemas.*` automatically.
|
||
*
|
||
* Routes are intentionally registered as inline operation objects
|
||
* rather than via a wrapping helper — `zod-openapi`'s `createDocument`
|
||
* type-checks the route table directly, which catches schema-shape
|
||
* regressions at typecheck time.
|
||
*/
|
||
import { z } from "zod/v4";
|
||
import type { ZodOpenApiObject } from "zod-openapi";
|
||
|
||
import {
|
||
createMeasurementSchema,
|
||
listMeasurementsSchema,
|
||
measurementTypeEnum,
|
||
measurementSourceEnum,
|
||
} from "@/lib/validations/measurement";
|
||
import { loginPasswordSchema } from "@/lib/validations/auth";
|
||
import { coachPrefsSchema } from "@/lib/validations/coach-prefs";
|
||
import {
|
||
deviceTypeEnum,
|
||
sourcePrioritySchema,
|
||
} from "@/lib/validations/source-priority";
|
||
import { createBatchWorkoutSchema } from "@/lib/validations/workout";
|
||
import {
|
||
createMedicationSchema,
|
||
updateMedicationSchema,
|
||
intakeSchema,
|
||
MEDICATION_CATEGORY_VALUES,
|
||
MEDICATION_TREATMENT_CLASS_VALUES,
|
||
} from "@/lib/validations/medication";
|
||
import { medicationExtractionSchema } from "@/lib/ai/coach/medication-extract-prompt";
|
||
import { ACCEPTED_INSIGHTS_TILE_IDS } from "@/lib/insights-layout";
|
||
import { COACH_FACT_CATEGORIES } from "@/lib/ai/coach/facts";
|
||
import { exportSelectionSchema } from "@/lib/validations/health-record-export";
|
||
import { createShareLinkSchema } from "@/lib/validations/clinician-share-link";
|
||
import { METRIC_STATUS_IDS } from "@/lib/insights/metric-status-registry";
|
||
import {
|
||
DERIVED_METRIC_IDS,
|
||
VITALS_BASELINE_TYPES,
|
||
} from "@/lib/insights/derived/registry";
|
||
import { ANALYTICS_RANGES } from "@/lib/analytics/range-delta";
|
||
import {
|
||
flowLevelEnum,
|
||
ovulationTestEnum,
|
||
cervicalMucusEnum,
|
||
homeTestResultEnum,
|
||
cycleTrackingGoalEnum,
|
||
cycleDayLogInputSchema,
|
||
cycleDayLogPatchSchema,
|
||
cycleBulkSchema,
|
||
cyclePeriodSchema,
|
||
cyclePrefsSchema,
|
||
} from "@/lib/validations/cycle";
|
||
|
||
/**
|
||
* Common envelopes — every HealthLog API response wraps payload in
|
||
* `{ data, error, meta? }`. The OpenAPI surface mirrors that contract
|
||
* so iOS / external-ingest clients can decode uniformly.
|
||
*/
|
||
const errorEnvelope = z
|
||
.object({
|
||
data: z.null(),
|
||
error: z.string(),
|
||
meta: z
|
||
.object({
|
||
requestId: z.string().optional(),
|
||
errorCode: z.string().optional(),
|
||
})
|
||
.optional(),
|
||
})
|
||
.meta({
|
||
id: "ErrorEnvelope",
|
||
description: "Standard error response: data is null, error is human prose.",
|
||
});
|
||
|
||
function dataEnvelope<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).",
|
||
});
|
||
|
||
const coachMessageFeedbackBody = z
|
||
.object({
|
||
rating: z.enum(["helpful", "unhelpful"]),
|
||
reason: z.string().min(1).max(200).optional(),
|
||
})
|
||
.meta({
|
||
id: "CoachMessageFeedbackRequest",
|
||
description:
|
||
"Per-message helpful/unhelpful feedback (v1.4.23 H7). Optional `reason` is free-form prose, capped at 200 chars.",
|
||
});
|
||
|
||
// v1.4.49 — single schema for the per-user Coach opt-out flag. Previously
|
||
// split into `disableCoachBody` (PATCH request) and `disableCoachData`
|
||
// (response payload), both `z.object({ disableCoach: z.boolean() })`. The
|
||
// payload was passed through `dataEnvelope(..., "GetDisableCoachResponse")`
|
||
// / `dataEnvelope(..., "PatchDisableCoachResponse")` which IDs the
|
||
// envelope wrapper — the inner flag carries its own `.meta()` and now
|
||
// renders as a single `$ref: "#/components/schemas/DisableCoachFlag"` in
|
||
// both the request body and both response envelopes.
|
||
const disableCoachFlag = z
|
||
.object({
|
||
disableCoach: z.boolean(),
|
||
})
|
||
.meta({
|
||
id: "DisableCoachFlag",
|
||
description:
|
||
"Per-account Coach opt-out toggle (v1.4.47 W3). `true` hides the Coach FAB and short-circuits its API gates.",
|
||
});
|
||
|
||
// v1.8.6 — profile read/write surface for the native client. The
|
||
// runtime validation lives in `src/lib/validations/auth.ts`
|
||
// (`profileSchema`, whose transforms normalise empty → null and run the
|
||
// KVNR/IKNR refines); the OpenAPI shapes below mirror the wire contract
|
||
// without those transforms so the spec stays a clean nullable-field
|
||
// document. `insurerIkNumber` is the new v1.8.6 field: optional, 9-digit
|
||
// German IKNR, surfaced on the FHIR `Coverage` payor.
|
||
const profileUpdateRequest = z
|
||
.object({
|
||
email: z.email().nullable().optional(),
|
||
heightCm: z.number().min(50).max(300).nullable().optional(),
|
||
dateOfBirth: z.string().nullable().optional(),
|
||
gender: z.enum(["MALE", "FEMALE"]).nullable().optional(),
|
||
displayName: z.string().min(1).max(80).nullable().optional(),
|
||
locale: z.enum(["de", "en"]).nullable().optional(),
|
||
timezone: z.string().min(1).max(64).optional(),
|
||
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(),
|
||
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(),
|
||
moodReminderEnabled: z.boolean(),
|
||
fullName: z.string().nullable(),
|
||
insurerName: z.string().nullable(),
|
||
insurerIkNumber: z
|
||
.string()
|
||
.nullable()
|
||
.describe("German insurer institution number (IKNR), 9 digits."),
|
||
hasInsuranceNumber: z
|
||
.boolean()
|
||
.describe("Whether a KVNR is on file (the value itself is not echoed)."),
|
||
})
|
||
.meta({
|
||
id: "ProfileUpdateResponse",
|
||
description:
|
||
"Profile echo returned by the PATCH/PUT update path. Reports KVNR presence as a boolean rather than re-emitting the decrypted value.",
|
||
});
|
||
|
||
// ── Sub-schemas owned here (route-specific shapes) ───────────────────
|
||
|
||
const passkeyLoginVerifyRequest = z
|
||
.object({
|
||
challengeId: z
|
||
.string()
|
||
.min(1)
|
||
.describe("Server-issued WebAuthn challenge id."),
|
||
credential: z
|
||
.object({
|
||
id: z.string(),
|
||
rawId: z.string(),
|
||
type: z.literal("public-key"),
|
||
response: z.record(z.string(), z.unknown()),
|
||
authenticatorAttachment: z
|
||
.enum(["platform", "cross-platform"])
|
||
.optional(),
|
||
clientExtensionResults: z.record(z.string(), z.unknown()).optional(),
|
||
})
|
||
.describe("SimpleWebAuthn-style assertion response payload."),
|
||
})
|
||
.meta({
|
||
id: "PasskeyLoginVerifyRequest",
|
||
description:
|
||
"Passkey assertion verification. Same native-client token issuance as the password path.",
|
||
});
|
||
|
||
const refreshRequest = z
|
||
.object({
|
||
refreshToken: z
|
||
.string()
|
||
.min(1)
|
||
.describe("Caller-presented refresh token (`hlr_<64hex>`)."),
|
||
revoke: z
|
||
.boolean()
|
||
.optional()
|
||
.describe("When true, revoke the supplied token instead of rotating."),
|
||
})
|
||
.meta({
|
||
id: "RefreshTokenRequest",
|
||
description:
|
||
"Exchange a one-time-use refresh token for a fresh access + refresh pair.",
|
||
});
|
||
|
||
const accessRefreshBundle = z
|
||
.object({
|
||
user: z.object({ id: z.string(), username: z.string() }).optional(),
|
||
token: z.string().describe("Access token (`hlk_<64hex>`)."),
|
||
tokenExpiresAt: z.iso.datetime({ offset: true }),
|
||
refreshToken: z
|
||
.string()
|
||
.optional()
|
||
.describe(
|
||
"Refresh token (`hlr_<64hex>`); only present for native-policy callers.",
|
||
),
|
||
refreshTokenExpiresAt: z.iso.datetime({ offset: true }).optional(),
|
||
})
|
||
.meta({
|
||
id: "AccessRefreshBundle",
|
||
description:
|
||
"Native-client token bundle returned by login + refresh. Web cookie-only callers see only `user`.",
|
||
});
|
||
|
||
const deviceRegisterRequest = z
|
||
.object({
|
||
token: z
|
||
.string()
|
||
.min(8)
|
||
.max(512)
|
||
.regex(/^[A-Za-z0-9+/=._:-]+$/)
|
||
.describe("Generic device identifier (legacy; APNs token below)."),
|
||
bundleId: z.string().min(1).max(128),
|
||
locale: z.string().min(2).max(16).optional(),
|
||
appVersion: z.string().min(1).max(32).optional(),
|
||
model: z.string().min(1).max(64).optional(),
|
||
apnsToken: z
|
||
.string()
|
||
.min(8)
|
||
.max(256)
|
||
.regex(/^[A-Fa-f0-9]+$/)
|
||
.optional()
|
||
.describe(
|
||
"Hex-encoded APNs device token. Must be paired with `apnsEnvironment`.",
|
||
),
|
||
apnsEnvironment: z
|
||
.enum(["sandbox", "production"])
|
||
.optional()
|
||
.describe(
|
||
"Gateway the iOS client received `apnsToken` from. Server never auto-detects.",
|
||
),
|
||
medicationDelivery: z
|
||
.enum(["server", "client"])
|
||
.nullable()
|
||
.optional()
|
||
.describe(
|
||
"v1.7.0 per-device medication-delivery override. NULL / omitted = inherit the user-level roaming default. \"server\" forces server APNs for this device; \"client\" forces local. Stored + echoed; cron suppression stays user-level.",
|
||
),
|
||
})
|
||
.meta({
|
||
id: "DeviceRegisterRequest",
|
||
description:
|
||
"Native device registration. Re-registering an APNs token belonging to another user returns 409 (cross-user-hijack guard).",
|
||
});
|
||
|
||
const batchEntrySchema = z
|
||
.object({
|
||
hkIdentifier: z
|
||
.string()
|
||
.min(1)
|
||
.max(120)
|
||
.describe(
|
||
"HealthKit identifier (e.g. `HKQuantityTypeIdentifierBodyMass`).",
|
||
),
|
||
value: z.number().finite(),
|
||
unit: z.string().min(1).max(60),
|
||
startDate: z.iso.datetime({ offset: true }),
|
||
endDate: z.iso.datetime({ offset: true }),
|
||
sleepStage: z
|
||
.number()
|
||
.int()
|
||
.min(0)
|
||
.max(20)
|
||
.optional()
|
||
.describe(
|
||
"HKCategoryValueSleepAnalysis codepoint; only for sleep samples.",
|
||
),
|
||
categoryValue: z
|
||
.number()
|
||
.int()
|
||
.min(0)
|
||
.max(20)
|
||
.optional()
|
||
.describe(
|
||
"v1.10.0 — `HKCategoryValue` codepoint for an EVENT-class category sample (irregular-rhythm, high/low-HR, walking-steadiness, breathing-disturbance). Carries the device's own classification verdict / severity, which the server resolves to a stored `rhythmClassification`. HealthLog stores ONLY the device's result — it never re-classifies. Ignored for non-event identifiers.",
|
||
),
|
||
externalId: z
|
||
.string()
|
||
.min(1)
|
||
.max(120)
|
||
.describe("HKSample.uuid string — the dedup key."),
|
||
externalSourceVersion: z.string().min(1).max(120).optional(),
|
||
// v1.8.6 W6 — optional per-entry source tag. Defaults to
|
||
// `APPLE_HEALTH` server-side when omitted, so legacy clients are
|
||
// unchanged. Restricted to the `{APPLE_HEALTH, MANUAL}` subset of
|
||
// `MeasurementSource`: `WITHINGS` / `IMPORT` are server-owned and
|
||
// rejected on this client-facing route.
|
||
source: z
|
||
.enum(["APPLE_HEALTH", "MANUAL"])
|
||
.optional()
|
||
.describe(
|
||
"Source attribution for the row. Defaults to `APPLE_HEALTH` when omitted. Send `MANUAL` to tag rows the user entered by hand on-device (e.g. the standalone adopt-on-pair backfill). Part of the `(userId, type, source, externalId)` dedup key, so the same externalId under a different source is a distinct row. `WITHINGS` / `IMPORT` are not accepted here.",
|
||
),
|
||
// v1.4.25 W8c — optional device-type tag fed into the canonical
|
||
// source picker's second axis. NULL is treated as `unknown`;
|
||
// legacy iOS builds that don't ship the field continue to work.
|
||
deviceType: deviceTypeEnum
|
||
.nullable()
|
||
.optional()
|
||
.describe(
|
||
"Device class mapped from `HKDevice.model`. Used by the analytics aggregator to break ties when the same source contributed multiple devices for the same day. Omit (or send null) on legacy clients — the server treats it as `unknown` and the picker falls through.",
|
||
),
|
||
})
|
||
.meta({ id: "AppleHealthBatchEntry" });
|
||
|
||
const batchPayloadSchema = z
|
||
.object({
|
||
entries: z.array(batchEntrySchema).min(1).max(500),
|
||
})
|
||
.meta({
|
||
id: "AppleHealthBatchRequest",
|
||
description:
|
||
"Apple Health batch ingest. ≤500 entries per call; idempotent via `Idempotency-Key`.",
|
||
});
|
||
|
||
const batchEntryResult = z
|
||
.object({
|
||
index: z.number().int().nonnegative(),
|
||
status: z.enum(["inserted", "duplicate", "skipped"]),
|
||
reason: z.string().optional(),
|
||
})
|
||
.meta({ id: "AppleHealthBatchEntryResult" });
|
||
|
||
const batchResponse = z
|
||
.object({
|
||
inserted: z.number().int().nonnegative(),
|
||
duplicate: z.number().int().nonnegative(),
|
||
skipped: z.number().int().nonnegative(),
|
||
entries: z.array(batchEntryResult),
|
||
})
|
||
.meta({ id: "AppleHealthBatchResponse" });
|
||
|
||
// v1.4.25 W16b — typed workout batch ingest response envelope. Mirrors
|
||
// the measurements batch shape but reports the `workouts` count rather
|
||
// than the entries count, and the `skipped` field is reserved (the
|
||
// Zod schema rejects malformed entries with a 400 before the per-entry
|
||
// pass, so today's responses always carry an empty array).
|
||
const workoutBatchEntryResult = z
|
||
.object({
|
||
index: z.number().int().nonnegative(),
|
||
status: z.enum(["inserted", "duplicate", "skipped"]),
|
||
reason: z.string().optional(),
|
||
})
|
||
.meta({ id: "WorkoutBatchEntryResult" });
|
||
|
||
const workoutBatchResponse = z
|
||
.object({
|
||
processed: z.number().int().nonnegative(),
|
||
inserted: z.number().int().nonnegative(),
|
||
duplicates: z.number().int().nonnegative(),
|
||
skipped: z.array(
|
||
z.object({
|
||
index: z.number().int().nonnegative(),
|
||
reason: z.string(),
|
||
}),
|
||
),
|
||
entries: z.array(workoutBatchEntryResult),
|
||
})
|
||
.meta({ id: "WorkoutBatchResponse" });
|
||
|
||
// v1.4.32 — workout list / detail wire shape. The field names follow
|
||
// the iOS handoff contract (`distanceM` + `activeEnergyKcal`) rather
|
||
// than the Prisma column names so iOS and web consumers share one
|
||
// JSON envelope.
|
||
const workoutListEntry = z
|
||
.object({
|
||
id: z.string(),
|
||
sportType: z.string(),
|
||
startedAt: z.iso.datetime({ offset: true }),
|
||
endedAt: z.iso.datetime({ offset: true }),
|
||
durationSec: z.number().int().nonnegative(),
|
||
distanceM: z.number().nullable(),
|
||
activeEnergyKcal: z.number().nullable(),
|
||
avgHr: z.number().int().nullable(),
|
||
maxHr: z.number().int().nullable(),
|
||
source: measurementSourceEnum,
|
||
externalId: z.string().nullable(),
|
||
})
|
||
.meta({ id: "WorkoutListEntry" });
|
||
|
||
const workoutListResponse = z
|
||
.object({
|
||
workouts: z.array(workoutListEntry),
|
||
meta: z.object({
|
||
total: z.number().int().nonnegative(),
|
||
limit: z.number().int().positive(),
|
||
offset: z.number().int().nonnegative(),
|
||
droppedDuplicates: z.number().int().nonnegative(),
|
||
}),
|
||
})
|
||
.meta({ id: "WorkoutListResponse" });
|
||
|
||
const workoutRouteGeometry = z
|
||
.object({
|
||
geometry: z.unknown(),
|
||
sampleTimestamps: z.array(z.string()).nullable(),
|
||
})
|
||
.meta({ id: "WorkoutRouteGeometry" });
|
||
|
||
// v1.10.0 — route-independent per-workout heart-rate series. Present
|
||
// for indoor and outdoor workouts that shipped a `samples` array on
|
||
// ingest. `samples` mirrors the ingest shape `[{ t, hr?, speedMs?,
|
||
// power?, cadence? }]`; `sampleCount` is the denormalised length.
|
||
const workoutHrSeries = z
|
||
.object({
|
||
sampleCount: z.number().int().nonnegative(),
|
||
samples: z.unknown(),
|
||
})
|
||
.meta({ id: "WorkoutHrSeries" });
|
||
|
||
const workoutDetailResponse = z
|
||
.object({
|
||
id: z.string(),
|
||
sportType: z.string(),
|
||
startedAt: z.iso.datetime({ offset: true }),
|
||
endedAt: z.iso.datetime({ offset: true }),
|
||
durationSec: z.number().int().nonnegative(),
|
||
distanceM: z.number().nullable(),
|
||
activeEnergyKcal: z.number().nullable(),
|
||
avgHr: z.number().int().nullable(),
|
||
maxHr: z.number().int().nullable(),
|
||
minHr: z.number().int().nullable(),
|
||
stepCount: z.number().int().nullable(),
|
||
elevationM: z.number().nullable(),
|
||
pauseDurationSec: z.number().int().nullable(),
|
||
source: measurementSourceEnum,
|
||
externalId: z.string().nullable(),
|
||
metadata: z.unknown().nullable(),
|
||
route: workoutRouteGeometry.nullable(),
|
||
samples: workoutHrSeries.nullable(),
|
||
canonicalId: z.string(),
|
||
})
|
||
.meta({ id: "WorkoutDetailResponse" });
|
||
|
||
const deleteByExternalIdsRequest = z
|
||
.object({
|
||
externalIds: z.array(z.string().min(1).max(120)).min(0).max(500),
|
||
})
|
||
.meta({
|
||
id: "MeasurementsDeleteByExternalIdsRequest",
|
||
description:
|
||
"iOS deletion-sync. Up to 500 externalIds per call; matching rows owned by other users are silently skipped (cross-user 404 guard).",
|
||
});
|
||
|
||
const deleteByExternalIdsResponse = z
|
||
.object({
|
||
deletedCount: z.number().int().nonnegative(),
|
||
})
|
||
.meta({ id: "MeasurementsDeleteByExternalIdsResult" });
|
||
|
||
const measurementResource = z
|
||
.object({
|
||
id: z.string(),
|
||
type: measurementTypeEnum,
|
||
value: z.number(),
|
||
unit: z.string(),
|
||
measuredAt: z.iso.datetime({ offset: true }),
|
||
source: measurementSourceEnum,
|
||
notes: z.string().nullable().optional(),
|
||
})
|
||
.meta({
|
||
id: "MeasurementResource",
|
||
description: "Server-shaped measurement row returned by GET endpoints.",
|
||
});
|
||
|
||
// ── Sleep night (v1.11.5 hypnogram source) ──────────────────────────
|
||
|
||
const sleepStageEnum = z.enum([
|
||
"IN_BED",
|
||
"AWAKE",
|
||
"ASLEEP",
|
||
"REM",
|
||
"CORE",
|
||
"DEEP",
|
||
]);
|
||
|
||
const sleepSegmentResource = z.object({
|
||
stage: sleepStageEnum.nullable(),
|
||
start: z.iso.datetime({ offset: true }),
|
||
end: z.iso.datetime({ offset: true }),
|
||
minutes: z.number().int().nonnegative(),
|
||
});
|
||
|
||
const sleepSessionResource = z.object({
|
||
night: z.string().describe("Wake-day key (YYYY-MM-DD)."),
|
||
source: measurementSourceEnum.nullable(),
|
||
start: z.iso.datetime({ offset: true }),
|
||
end: z.iso.datetime({ offset: true }),
|
||
asleepMinutes: z.number().int().nonnegative(),
|
||
inBedMinutes: z.number().int().nonnegative().nullable(),
|
||
awakeMinutes: z.number().int().nonnegative().nullable(),
|
||
awakenings: z.number().int().nonnegative(),
|
||
stages: z.record(sleepStageEnum, z.number()),
|
||
segments: z.array(sleepSegmentResource),
|
||
});
|
||
|
||
const sleepNightResponse = z
|
||
.object({
|
||
night: z.string().nullable(),
|
||
main: sleepSessionResource.nullable(),
|
||
naps: z.array(sleepSessionResource),
|
||
})
|
||
.meta({
|
||
id: "SleepNightResource",
|
||
description:
|
||
"One reconstructed sleep night: the main session's hypnogram segments + breakdown, plus same-wake-day naps surfaced separately.",
|
||
});
|
||
|
||
// ── Sync (v1.7.0 offline / server-optional) ─────────────────────────
|
||
|
||
const syncStateResponse = z
|
||
.object({
|
||
userId: z.string(),
|
||
timezone: z.string(),
|
||
lastSyncedAt: z.iso.datetime({ offset: true }).nullable(),
|
||
serverNow: z.iso.datetime({ offset: true }),
|
||
measurements: z.object({
|
||
lastUpdatedAt: z.iso.datetime({ offset: true }).nullable(),
|
||
liveCount: z.number().int().nonnegative(),
|
||
tombstonedCount: z.number().int().nonnegative(),
|
||
}),
|
||
mood: z.object({
|
||
lastUpdatedAt: z.iso.datetime({ offset: true }).nullable(),
|
||
liveCount: z.number().int().nonnegative(),
|
||
tombstonedCount: z.number().int().nonnegative(),
|
||
}),
|
||
intakes: z.object({
|
||
lastUpdatedAt: z.iso.datetime({ offset: true }).nullable(),
|
||
liveCount: z.number().int().nonnegative(),
|
||
tombstonedCount: z.number().int().nonnegative(),
|
||
}),
|
||
sync: z
|
||
.object({
|
||
incrementalWindowDays: z
|
||
.number()
|
||
.int()
|
||
.positive()
|
||
.describe(
|
||
"Days an incremental delta stays valid; tracks the native refresh-token lifetime. Beyond it a device re-pairs with a full backfill. iOS derives its window from this rather than hardcoding 60.",
|
||
),
|
||
tombstoneRetentionDays: z
|
||
.number()
|
||
.int()
|
||
.positive()
|
||
.describe(
|
||
"Horizon past which tombstones may be pruned. A cursor older than this gets `cursorExpired` on `/api/sync/changes`.",
|
||
),
|
||
})
|
||
.describe("Sync-window metadata the client reads instead of hardcoding."),
|
||
})
|
||
.meta({
|
||
id: "SyncStateResponse",
|
||
description:
|
||
"iOS SyncMode handshake. Each GET also advances the server-side `lastSyncedAt` checkpoint and returns the previous value. The cheap 'should I sync?' summary; the durable delta cursor lives on `/api/sync/changes`.",
|
||
});
|
||
|
||
const syncMeasurementUpsert = measurementResource
|
||
.extend({
|
||
externalId: z
|
||
.string()
|
||
.nullable()
|
||
.describe("Cross-device dedup key (UUID string or `stats:<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.",
|
||
});
|
||
|
||
// v1.4.48 H-APNs-1 — admin diagnostic endpoint for the notification
|
||
// subsystem. Mirrors the runtime types in
|
||
// `src/app/api/admin/notifications/diagnostic/route.ts`: APNs tokens
|
||
// are surfaced only as 8-char hex prefix + suffix (never the full
|
||
// token), per-channel `enabled` + `configPresent` booleans, and a
|
||
// `recentPushAttempts` array reserved for the v1.4.48+ follow-up that
|
||
// adds the dedicated PushAttempt table (currently always `[]` so the
|
||
// shape stays stable for iOS consumers).
|
||
const adminDiagnosticDevice = z
|
||
.object({
|
||
id: z.string(),
|
||
platform: z.string(),
|
||
hasApnsToken: z.boolean(),
|
||
apnsTokenPrefix: z.string().nullable(),
|
||
apnsTokenSuffix: z.string().nullable(),
|
||
apnsEnvironment: z.string().nullable(),
|
||
lastSeenAt: z.iso.datetime({ offset: true }),
|
||
})
|
||
.meta({
|
||
id: "AdminDiagnosticDevice",
|
||
description:
|
||
"Per-device APNs registration snapshot. The APNs token is masked to its 8-char hex prefix + 8-char suffix so an operator can correlate with iOS-side logs without disclosing the delivery target.",
|
||
});
|
||
|
||
const adminDiagnosticChannel = z
|
||
.object({
|
||
type: z.string(),
|
||
enabled: z.boolean(),
|
||
configPresent: z.boolean(),
|
||
})
|
||
.meta({
|
||
id: "AdminDiagnosticChannel",
|
||
description:
|
||
"Per-channel state. `configPresent` is true when the channel's encrypted-JSON config blob carries the field the corresponding sender will read (chatId+botToken for TELEGRAM, topic for NTFY); WEB_PUSH/APNS rely on sibling tables so the row's existence is the signal.",
|
||
});
|
||
|
||
const adminDiagnosticPushAttempt = z
|
||
.object({
|
||
eventType: z.string(),
|
||
channel: z.string(),
|
||
result: z.string(),
|
||
reason: z.string().nullable(),
|
||
at: z.iso.datetime({ offset: true }),
|
||
})
|
||
.meta({
|
||
id: "AdminDiagnosticPushAttempt",
|
||
description:
|
||
"Single push delivery attempt. Reserved for the v1.4.48+ follow-up that adds the dedicated PushAttempt table — currently always returned as an empty array so the response shape stays stable for iOS consumers.",
|
||
});
|
||
|
||
const adminDiagnosticData = z
|
||
.object({
|
||
devices: z.array(adminDiagnosticDevice),
|
||
notificationChannels: z.array(adminDiagnosticChannel),
|
||
recentPushAttempts: z.array(adminDiagnosticPushAttempt),
|
||
})
|
||
.meta({
|
||
id: "AdminDiagnosticData",
|
||
description:
|
||
"Admin notification diagnostic snapshot for the calling user — what the dispatcher would see when targeting this account. Surfaces device tokens (masked), channel state, and recent push attempts so an operator can debug an iOS / Web Push / Telegram / ntfy issue without DB shell access.",
|
||
});
|
||
|
||
// ── Medications (v1.5 scheduling) ────────────────────────────────────
|
||
//
|
||
// The Medication + MedicationSchedule resource shapes documented below
|
||
// follow the wire envelope the seven routes registered at the bottom of
|
||
// this file emit. `windowStart` / `windowEnd` / `daysOfWeek` /
|
||
// `intervalWeeks` are the legacy primitives kept for backwards
|
||
// compatibility through the v1.5.x line; `timesOfDay`, `rrule`,
|
||
// `rollingIntervalDays`, and `reminderGraceMinutes` are the v1.5
|
||
// first-class primitives the wizard + iOS cadence picker write. The
|
||
// XOR between `rrule` and `rollingIntervalDays` is documented at the
|
||
// schema description AND enforced by the route + a DB CHECK constraint
|
||
// so iOS code-gen surfaces the mutual exclusion.
|
||
|
||
const medicationCategoryEnum = z
|
||
.enum(MEDICATION_CATEGORY_VALUES)
|
||
.meta({
|
||
id: "MedicationCategory",
|
||
description:
|
||
"Clinical taxonomy stored in the `medication_categories` side-table. Orthogonal to `MedicationTreatmentClass`.",
|
||
});
|
||
|
||
const medicationTreatmentClassEnum = z
|
||
.enum(MEDICATION_TREATMENT_CLASS_VALUES)
|
||
.meta({
|
||
id: "MedicationTreatmentClass",
|
||
description:
|
||
"Prisma-level treatment-class discriminator. `GLP1` unlocks the GLP-1 specialist surfaces (injection-site rotation, titration history, pen inventory, GLP-1-aware Coach).",
|
||
});
|
||
|
||
const medicationScheduleResource = z
|
||
.object({
|
||
id: z.string(),
|
||
medicationId: z.string(),
|
||
windowStart: z
|
||
.string()
|
||
.describe(
|
||
"Legacy single-time-of-intake (HH:mm, user local). Preserved for backwards compatibility; the new `timesOfDay` array supersedes it.",
|
||
),
|
||
windowEnd: z
|
||
.string()
|
||
.describe(
|
||
"Legacy reminder-window upper bound (HH:mm). Used to derive the late-classification grace span when `reminderGraceMinutes` is null.",
|
||
),
|
||
label: z.string().nullable(),
|
||
dose: z
|
||
.string()
|
||
.nullable()
|
||
.describe(
|
||
"Per-schedule dose override. NULL means the schedule inherits `Medication.dose`.",
|
||
),
|
||
daysOfWeek: z
|
||
.string()
|
||
.nullable()
|
||
.describe(
|
||
"Legacy persisted recurrence encoding (`null` | `1,3,5` | `i2;1,3,5`). v1.5 readers consult `rrule` first; the field is kept for pre-v1.5 rows. v1.6.0 drops the column.",
|
||
),
|
||
timesOfDay: z
|
||
.array(z.string())
|
||
.describe(
|
||
"v1.5 first-class points-in-time the dose is taken (HH:mm, user local). Backfilled to `[windowStart]` for every pre-v1.5 row.",
|
||
),
|
||
reminderGraceMinutes: z
|
||
.number()
|
||
.int()
|
||
.nullable()
|
||
.describe(
|
||
"Reminder grace window in minutes. NULL falls back to the legacy `windowEnd - windowStart` span.",
|
||
),
|
||
rrule: z
|
||
.string()
|
||
.nullable()
|
||
.describe(
|
||
"RFC 5545 RRULE string (subset). Used for calendar-anchored cadences. **Mutually exclusive with `rollingIntervalDays`** — exactly one of the two is non-null on any v1.5+ schedule (or both are null on legacy rows that haven't been touched since the migration).",
|
||
),
|
||
rollingIntervalDays: z
|
||
.number()
|
||
.int()
|
||
.nullable()
|
||
.describe(
|
||
"Flexible-rolling interval in days, counted forward from the latest `MedicationIntakeEvent.takenAt`. **Mutually exclusive with `rrule`.**",
|
||
),
|
||
scheduleType: z
|
||
.enum(["SCHEDULED", "PRN", "CYCLIC"])
|
||
.describe(
|
||
"v1.7.0 schedule-type discriminator. SCHEDULED = rrule / rolling / legacy cadence. PRN = as-needed (never projected, reminded, or counted in compliance expected; still loggable via the intake route). CYCLIC = N weeks on / M weeks off, gating whichever inner cadence the rrule / legacy fields describe.",
|
||
),
|
||
cyclicOnWeeks: z
|
||
.number()
|
||
.int()
|
||
.nullable()
|
||
.describe(
|
||
"v1.7.0 cyclic \"on\" weeks. Only meaningful when `scheduleType` is CYCLIC; null otherwise.",
|
||
),
|
||
cyclicOffWeeks: z
|
||
.number()
|
||
.int()
|
||
.nullable()
|
||
.describe(
|
||
"v1.7.0 cyclic \"off\" weeks. Only meaningful when `scheduleType` is CYCLIC; null otherwise.",
|
||
),
|
||
})
|
||
.meta({
|
||
id: "MedicationSchedule",
|
||
description:
|
||
"Schedule entry attached to a medication. v1.5 promotes `timesOfDay` to first-class and introduces `rrule` (calendar-anchored cadences) and `rollingIntervalDays` (flexible-rolling cadences). The two recurrence primitives are mutually exclusive — enforced by the Zod refine on writes, the route layer, and a DB CHECK constraint (`medication_schedules_rrule_xor_rolling`). v1.7.0 adds `scheduleType` (SCHEDULED / PRN / CYCLIC) and the cyclic on/off-week fields.",
|
||
});
|
||
|
||
const medicationResource = z
|
||
.object({
|
||
id: z.string(),
|
||
name: z.string(),
|
||
dose: z.string(),
|
||
treatmentClass: medicationTreatmentClassEnum,
|
||
dosesPerUnit: z
|
||
.number()
|
||
.int()
|
||
.nullable()
|
||
.describe(
|
||
"Doses per pen / vial for inventory tracking. NULL = inventory tracking off.",
|
||
),
|
||
active: z.boolean(),
|
||
notificationsEnabled: z.boolean(),
|
||
liveActivityEnabled: z
|
||
.boolean()
|
||
.describe(
|
||
"v1.7.0 iOS Live Activity opt-in for this medication's reminders. Default false. The iOS client owns the ActivityKit lifecycle; the server only stores + echoes the flag.",
|
||
),
|
||
criticalAlarmEnabled: z
|
||
.boolean()
|
||
.describe(
|
||
"v1.7.0 iOS 26 AlarmKit critical-reminder opt-in. Default false. Critical alarms bypass the device mute switch / Focus; the server stores the preference only.",
|
||
),
|
||
atcCode: z
|
||
.string()
|
||
.nullable()
|
||
.describe(
|
||
"v1.9.0 optional WHO ATC classification code (active-substance class, e.g. `A10BX10`). User/clinician-asserted; never machine-guessed. Emitted on the FHIR `medicationCodeableConcept` under `http://www.whocc.no/atc`. NULL = no code captured.",
|
||
),
|
||
rxNormCode: z
|
||
.string()
|
||
.nullable()
|
||
.describe(
|
||
"v1.9.0 optional RxNorm RxCUI (numeric, US identifier, e.g. `2601723`). Secondary FHIR coding under `http://www.nlm.nih.gov/research/umls/rxnorm`, alongside any ATC code. NULL = no code captured.",
|
||
),
|
||
pausedAt: z.iso.datetime({ offset: true }).nullable(),
|
||
snoozedUntil: z.iso.datetime({ offset: true }).nullable(),
|
||
nextDueAt: z.iso
|
||
.datetime({ offset: true })
|
||
.nullable()
|
||
.describe(
|
||
"v1.7.0 server-computed next due instant across all the medication's schedules (earliest `nextOccurrenceAfter`). Read-only — computed, not stored. NULL when no schedule has an upcoming slot (paused, one-shot in the past, `endsOn` crossed, every schedule PRN). The list GET is cached 60 s, so a 60 s staleness is accepted.",
|
||
),
|
||
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),
|
||
streak: z.number().int().nonnegative(),
|
||
}),
|
||
long: z.object({ rate: z.number().int().min(0).max(100) }),
|
||
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.",
|
||
),
|
||
})
|
||
.meta({
|
||
id: "ComplianceDisplay",
|
||
description:
|
||
"The two-row card block whose windows scale with dosing cadence (dense meds keep 7 / 30 days, sparse meds step both windows up). NOT the 30-day denominator — read `compliance30.totalExpected` for that.",
|
||
});
|
||
|
||
const medicationComplianceResponse = z
|
||
.object({
|
||
compliance7: complianceResult,
|
||
compliance30: complianceResult,
|
||
dailyCompliance: z
|
||
.record(z.string(), dailyComplianceEntry)
|
||
.describe("Flat per-day map keyed `YYYY-MM-DD` in the user timezone, one entry per day for up to 90 days back, clamped to the medication's `createdAt` (so a recently-created med has fewer entries). No weekly/monthly collapse — this is the raw daily grid."),
|
||
complianceDisplay,
|
||
})
|
||
.meta({
|
||
id: "MedicationComplianceResponse",
|
||
description:
|
||
"Adherence read for a single medication. `compliance30` is the authoritative 30-day taken-vs-expected summary; `dailyCompliance` is the per-day grid for the history glyph track. The graded raw→week→month→year series used elsewhere for AI prompts does NOT apply here — this response is never downsampled.",
|
||
});
|
||
|
||
const insightsComprehensiveResponse = z
|
||
.object({
|
||
summary: z.string(),
|
||
recommendations: z.array(z.record(z.string(), z.unknown())),
|
||
citations: z.array(z.record(z.string(), z.unknown())),
|
||
warnings: z.array(z.record(z.string(), z.unknown())),
|
||
dailyBriefing: z.record(z.string(), z.unknown()).nullable().optional(),
|
||
trendAnnotations: z.record(z.string(), z.unknown()).nullable().optional(),
|
||
storyboardAnnotations: z
|
||
.array(z.record(z.string(), z.unknown()))
|
||
.optional(),
|
||
metricSource: z.record(z.string(), z.unknown()).optional(),
|
||
})
|
||
.meta({
|
||
id: "InsightsComprehensiveResponse",
|
||
description:
|
||
"AI-generated insights bundle. Strict-schema validated server-side; Coach-routed when the insight surface needs day-level grounding.",
|
||
});
|
||
|
||
// v1.8.7.1 — generic per-HealthKit-metric assessment. The query enum is
|
||
// derived from the same registry the route validates against, so the
|
||
// spec, the route, and the cache scope cannot drift. The seven
|
||
// specialised metrics (weight / blood-pressure / pulse / bmi / mood /
|
||
// medication-compliance) keep their own routes and are NOT accepted here.
|
||
const metricStatusQuery = z
|
||
.object({
|
||
metric: z
|
||
.enum(METRIC_STATUS_IDS as [string, ...string[]])
|
||
.describe(
|
||
"HealthKit metric id to assess (e.g. RESTING_HEART_RATE, SLEEP_DURATION). Closed enum: an unknown id 422s. The seven specialised metrics are served by their own routes and are not accepted here.",
|
||
),
|
||
locale: z
|
||
.enum(["de", "en"])
|
||
.optional()
|
||
.describe("Optional UI-locale override; defaults to the session locale."),
|
||
})
|
||
.meta({ id: "MetricStatusQuery" });
|
||
|
||
const metricStatusResponse = z
|
||
.object({
|
||
hasProvider: z
|
||
.boolean()
|
||
.describe(
|
||
"False when the user has no usable AI provider — `text` then carries the generic no-key guidance.",
|
||
),
|
||
text: z
|
||
.string()
|
||
.nullable()
|
||
.describe(
|
||
"The assessment narrative (plain text, rendered as React text children). Null while a first generation is preparing, or when the metric has insufficient data.",
|
||
),
|
||
cached: z
|
||
.boolean()
|
||
.describe("True when `text` is served from cache (incl. last-good)."),
|
||
updatedAt: z.iso
|
||
.datetime({ offset: true })
|
||
.nullable()
|
||
.describe("When the served assessment was generated; null when none."),
|
||
preparing: z
|
||
.boolean()
|
||
.optional()
|
||
.describe(
|
||
"True when a first assessment is being generated out of band and no prior text exists yet — the client polls until it lands.",
|
||
),
|
||
revalidating: z
|
||
.boolean()
|
||
.optional()
|
||
.describe(
|
||
"True when `text` is served from last-good cache (stale-while-revalidate) while a fresh generation is in flight. The payload is otherwise terminal; the client keeps polling on `preparing || revalidating` (bounded) so the open card upgrades to the warmed assessment without a remount.",
|
||
),
|
||
insufficient: z
|
||
.boolean()
|
||
.optional()
|
||
.describe(
|
||
"True when the metric has no readings; no assessment is generated (no LLM call). The card shows its insufficient-data state.",
|
||
),
|
||
})
|
||
.meta({
|
||
id: "MetricStatusResponse",
|
||
description:
|
||
"Generic per-metric assessment envelope. Identical shape to the seven specialised `*-status` cards so the `InsightStatusCard` consumes it unchanged. Read-only + stale-while-revalidate: a cache miss warms a generation out of band and serves the last-good text meanwhile.",
|
||
});
|
||
|
||
// v1.10.0 — generic derived-wellness-metric route. The query enum is
|
||
// derived from the same registry the route validates against, so spec +
|
||
// route + cache scope cannot drift. `type` sub-targets the single vital
|
||
// a baseline metric (VITALS_BASELINE) bands over.
|
||
const derivedMetricQuery = z
|
||
.object({
|
||
metric: z
|
||
.enum(DERIVED_METRIC_IDS as [string, ...string[]])
|
||
.describe(
|
||
"Derived-metric id to compute (e.g. VITALS_BASELINE, FITNESS_AGE, VASCULAR_AGE_DELTA, HRV_BALANCE, BMI, READINESS). Closed enum: an unknown id 422s. Metrics whose compute has not yet landed return an `insufficient` value with reason `not_implemented`.",
|
||
),
|
||
type: z
|
||
.enum(VITALS_BASELINE_TYPES as [string, ...string[]])
|
||
.optional()
|
||
.describe(
|
||
"For VITALS_BASELINE only — the single vital to band (defaults to RESTING_HEART_RATE). Ignored by composites. An unsupported value yields an `insufficient` value rather than a 422 so iOS metric combinations stay forgiving.",
|
||
),
|
||
})
|
||
.meta({ id: "DerivedMetricQuery" });
|
||
|
||
const derivedCoverage = z
|
||
.object({
|
||
requiredInputs: z
|
||
.number()
|
||
.int()
|
||
.describe("Inputs the metric wants (its full input set)."),
|
||
presentInputs: z
|
||
.number()
|
||
.int()
|
||
.describe("Inputs actually present in the user's data."),
|
||
historyDays: z
|
||
.number()
|
||
.int()
|
||
.describe("Distinct days of history backing the value (the gating floor)."),
|
||
missing: z
|
||
.array(z.string())
|
||
.describe("Named inputs still missing — drives the 'track N more' nudge."),
|
||
})
|
||
.meta({ id: "DerivedCoverage" });
|
||
|
||
const derivedConfidence = z
|
||
.object({
|
||
score: z
|
||
.number()
|
||
.describe("0..100 confidence; feeds the shared coverage meter unchanged."),
|
||
band: z
|
||
.enum(["high", "medium", "low", "draft"])
|
||
.describe("Confidence band the meter renders."),
|
||
})
|
||
.meta({ id: "DerivedConfidence" });
|
||
|
||
const derivedProvenance = z
|
||
.object({
|
||
inputs: z
|
||
.array(z.string())
|
||
.describe("Named inputs that actually backed the value."),
|
||
source: z
|
||
.enum(["DAY", "WEEK", "MONTH", "YEAR", "live", "none"])
|
||
.describe(
|
||
"Granularity the dominant read resolved against. 'live' = a coverage-miss live-SQL fallback; 'none' = no data backed the value.",
|
||
),
|
||
windowDays: z
|
||
.number()
|
||
.int()
|
||
.describe("Trailing window the value summarises, in days."),
|
||
computedAt: z.iso
|
||
.datetime({ offset: true })
|
||
.describe("Compute time (for cache-staleness + the 'as of' chip)."),
|
||
})
|
||
.meta({ id: "DerivedProvenance" });
|
||
|
||
// v1.13.2 — per-derived-SCORE assessment text. Additive, non-breaking field
|
||
// on the derived response; the iOS field-name contract is LOCKED.
|
||
const derivedAssessment = z
|
||
.object({
|
||
text: z
|
||
.string()
|
||
.describe(
|
||
"Short, non-empty explanation of why the score sits where it does, referencing the score's contributors.",
|
||
),
|
||
source: z
|
||
.string()
|
||
.describe(
|
||
"'deterministic' for the always-on template text, or 'ai' when warmer provider prose has been cached.",
|
||
),
|
||
updatedAt: z.iso
|
||
.datetime({ offset: true })
|
||
.describe("When the text was produced / last warmed."),
|
||
})
|
||
.meta({ id: "DerivedAssessment" });
|
||
|
||
const derivedMetricResponse = z
|
||
.object({
|
||
metric: z
|
||
.enum(DERIVED_METRIC_IDS as [string, ...string[]])
|
||
.describe("Echoes the requested derived-metric id (tags the union)."),
|
||
status: z
|
||
.enum(["ok", "insufficient"])
|
||
.describe(
|
||
"'ok' carries `value` + `confidence`; 'insufficient' carries `reason` and no value, but still carries `coverage` + `provenance` so the surface renders the same gating UI.",
|
||
),
|
||
value: z
|
||
.record(z.string(), z.unknown())
|
||
.nullable()
|
||
.describe(
|
||
"Metric-specific value object when status is 'ok' (e.g. { type, center, low, high, spread, sampleDays, k, series } for VITALS_BASELINE, where `series` is the trailing per-day mean values for the inline sparkline); null when 'insufficient'.",
|
||
),
|
||
coverage: derivedCoverage,
|
||
confidence: derivedConfidence
|
||
.nullable()
|
||
.describe("Present when status is 'ok'; null when 'insufficient'."),
|
||
provenance: derivedProvenance,
|
||
reason: z
|
||
.string()
|
||
.nullable()
|
||
.describe("Why the value could not be produced; null when status is 'ok'."),
|
||
assessment: derivedAssessment
|
||
.nullable()
|
||
.describe(
|
||
"v1.13.2 — short 'why is this score what it is' explanation, keyed to the SAME requested id (only for the per-score ids READINESS, SLEEP_SCORE, RECOVERY_SCORE, STRAIN_SCORE, STRESS_SCORE). Null for any other metric and whenever status !== 'ok'. Always non-empty when present: a deterministic text fills it (so provider-less accounts + the demo always get one) and warmer AI prose overrides it once cached.",
|
||
),
|
||
})
|
||
.meta({
|
||
id: "DerivedMetricResponse",
|
||
description:
|
||
"Flat `Derived<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"]),
|
||
briefingUpdatedAt: z.string().nullable(),
|
||
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` via `briefingState`. `layoutCatalogue` (full 27-id widget catalogue) and `metricStates` (latest reading per metric, keyed by iOS `MetricKind` raw value) are additive cold-launch seeds for the native client; both derive in-process from data already fetched, adding no DB round-trip.",
|
||
});
|
||
|
||
// v1.5.0 — natural-language medication extraction route. The wizard's
|
||
// optional "Beschreiben" overlay POSTs a free-text description and
|
||
// receives a partial structured payload the form merges onto whatever
|
||
// the user already typed. Citation-guarded (`name` and `dose` are
|
||
// dropped when not substring-matched in the original text) and
|
||
// closed-enum-validated.
|
||
const medicationExtractRequest = z
|
||
.object({
|
||
text: z
|
||
.string()
|
||
.min(1)
|
||
.max(2000)
|
||
.describe(
|
||
"Free-text medication description (any locale). Up to 2 000 characters. The model never echoes the text back into another tenant — it is only used to produce the structured fields.",
|
||
),
|
||
locale: z
|
||
.enum(["en", "de", "es", "fr", "it", "pl"])
|
||
.optional()
|
||
.describe("Optional UI locale hint for the model."),
|
||
today: z
|
||
.string()
|
||
.regex(/^\d{4}-\d{2}-\d{2}$/)
|
||
.optional()
|
||
.describe(
|
||
"Optional override of the reference date used to resolve relative phrases (\"tomorrow\", \"next Monday\"). Format: `YYYY-MM-DD`. Defaults to the server's UTC day.",
|
||
),
|
||
})
|
||
.meta({
|
||
id: "MedicationExtractRequest",
|
||
description:
|
||
"Free-text medication description payload. The route runs the text through the Coach provider chain and returns a partial structured payload the wizard merges. Rate-limited 10 requests / 5 minutes / user; budget-gated against the daily Coach token ceiling.",
|
||
});
|
||
|
||
medicationExtractionSchema.meta({
|
||
id: "MedicationExtractionResult",
|
||
description:
|
||
"Citation-guarded partial extraction of medication scheduling fields. Every field is optional; the wizard merges what is present onto the form state and leaves the rest blank. `name` and `dose` are post-validated against the original free-text and dropped when not substring-matched, so the wizard cannot silently land a hallucinated brand or dose. `cadenceKind` / `doseUnit` / `weekdays` are closed enums; numeric fields are clamped to the wizard's wire bounds.",
|
||
});
|
||
|
||
// Insights tile layout — mirrors the Zod schema in
|
||
// `src/app/api/insights/layout/route.ts`. The tile-id enum is derived
|
||
// from the same `ACCEPTED_INSIGHTS_TILE_IDS` source so the contract
|
||
// cannot drift.
|
||
//
|
||
// v1.8.0 — the canonical ids are English (`blood-pressure`, `pulse`,
|
||
// `oxygen`, `body-temperature`, `weight`, `active-energy`, `sleep`,
|
||
// `resting-pulse`, `mood`, `medications`). The endpoint still ACCEPTS
|
||
// the legacy German ids (`blutdruck`, `puls`, `sauerstoff`,
|
||
// `koerpertemperatur`, `gewicht`, `aktive-energie`, `schlaf`,
|
||
// `ruhepuls`, `stimmung`, `medikamente`) on input so existing iOS
|
||
// layouts keep validating; the server normalises them to the canonical
|
||
// English id before persisting, and GET always returns canonical ids.
|
||
// The legacy ids are accepted-but-deprecated and will be dropped from
|
||
// the accepted set in a future major.
|
||
const insightsLayoutSchema = z
|
||
.object({
|
||
version: z.literal(1),
|
||
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),
|
||
})
|
||
.meta({
|
||
id: "InsightsLayoutBody",
|
||
description:
|
||
"Per-user Insights tile layout: an ordered list of tiles with a visibility flag. `version` is the layout schema version. Tile ids are a closed enum: the canonical ids are English (matching the routed `/insights/<slug>` sub-pages). The legacy German 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. The legacy ids are deprecated and will be removed in a future major version.",
|
||
});
|
||
|
||
// v1.7.0 — health-record export selection. Strict shape: unknown keys
|
||
// (including any attempt to smuggle a userId) 422 via returnAllZodIssues.
|
||
// v1.11.0 — clinician share-link create payload. Strict; no `userId` field
|
||
// (the owner is always narrowed from the session/Bearer). `expiresAt` is
|
||
// required and capped at SHARE_LINK_MAX_DAYS; the scope columns are frozen
|
||
// write-once at creation.
|
||
createShareLinkSchema.meta({
|
||
id: "CreateShareLinkRequest",
|
||
description:
|
||
"v1.11.0 — owner request to mint a clinician share link to their own health record. `expiresAt` is required (absolute ISO instant) and capped at 90 days. `rangeStart`/`rangeEnd` freeze the reporting window (rangeEnd null = rolling). `resourceTypes` scopes the FHIR resources the link may serve; `allowFhirApi` toggles REST reachability. Strict: unknown keys 422.",
|
||
});
|
||
|
||
exportSelectionSchema.meta({
|
||
id: "HealthRecordExportRequest",
|
||
description:
|
||
"v1.7.0 — health-record / doctor-handover export selection. `format` picks PDF, FHIR R4 document Bundle, or a combined zip package. Grouped `sections` toggles drive which domains are read (mood is opt-in, off by default). No `userId` field — the user is always narrowed from the session/Bearer. The route is strict: unknown keys 422.",
|
||
});
|
||
|
||
// v1.11.0 — clinician share-link owner-facing summary (never the raw token).
|
||
const shareLinkSummary = z.object({
|
||
id: z.string(),
|
||
label: z.string(),
|
||
rangeStart: z.string(),
|
||
rangeEnd: z.string().nullable(),
|
||
resourceTypes: z.array(z.string()),
|
||
allowFhirApi: z.boolean(),
|
||
expiresAt: z.string(),
|
||
createdAt: z.string(),
|
||
revokedAt: z.string().nullable(),
|
||
lastAccessAt: z.string().nullable(),
|
||
accessCount: z.number(),
|
||
active: z.boolean(),
|
||
});
|
||
|
||
const shareLinkCreatedResponse = shareLinkSummary.extend({
|
||
token: z
|
||
.string()
|
||
.describe("Raw `hls_` token — returned ONCE and unrecoverable thereafter."),
|
||
});
|
||
|
||
const shareLinkListResponse = z.object({
|
||
shareLinks: z.array(shareLinkSummary),
|
||
});
|
||
|
||
const shareLinkRevokedResponse = z.object({
|
||
id: z.string(),
|
||
revoked: z.boolean(),
|
||
});
|
||
|
||
// v1.10.2 — live capability / discovery response. Every list is sourced
|
||
// server-side from the canonical registry it documents, so the wire shape
|
||
// here is the contract; the runtime values are authoritative and never
|
||
// hand-duplicated. Used by the native client to gate its UI / decoder
|
||
// against what the server actually ships (retires the doc-vs-server
|
||
// enum-drift class).
|
||
const capabilitiesResponse = z
|
||
.object({
|
||
apiContractVersion: z
|
||
.string()
|
||
.describe("Running build version — mirrors GET /api/version `version`."),
|
||
derivedMetricIds: z
|
||
.array(z.string())
|
||
.describe("Closed derived-metric id set (GET /api/insights/derived)."),
|
||
vitalsBaselineTypes: z
|
||
.array(z.string())
|
||
.describe("MeasurementTypes the typical-range baseline engine supports."),
|
||
layoutTileIds: z
|
||
.array(z.string())
|
||
.describe("Canonical insights layout tile-id set (English ids)."),
|
||
metricStatusIds: z
|
||
.array(z.string())
|
||
.describe("Closed metric-status / assessment id set."),
|
||
ingest: z
|
||
.object({
|
||
quantityTypes: z
|
||
.array(
|
||
z.object({
|
||
type: z.string().describe("HealthLog MeasurementType."),
|
||
hk: z.string().describe("HealthKit identifier."),
|
||
unit: z.string().describe("Canonical DB unit."),
|
||
}),
|
||
)
|
||
.describe("Accepted HealthKit quantity-sample mappings."),
|
||
eventTypes: z
|
||
.array(z.string())
|
||
.describe(
|
||
"MeasurementTypes for device-flagged EVENT-class HealthKit samples.",
|
||
),
|
||
computedScores: z
|
||
.array(z.string())
|
||
.describe("Server-owned nightly composite score types."),
|
||
writeAllowlist: z
|
||
.array(z.string())
|
||
.describe(
|
||
"MeasurementSources a client may attribute on a write (others are server-owned).",
|
||
),
|
||
})
|
||
.describe("Ingest vocabularies the batch / single-write paths accept."),
|
||
fhir: z
|
||
.object({
|
||
atcSystem: z.string().describe("WHO ATC CodeSystem URI."),
|
||
snomedRoute: z.string().describe("SNOMED CT CodeSystem URI."),
|
||
germanAtcDefaultLocales: z
|
||
.array(z.string())
|
||
.describe("App locales that default the additive BfArM ATC coding on."),
|
||
restBaseUrl: z
|
||
.string()
|
||
.describe("Base path of the read-only FHIR R4 REST face."),
|
||
readScope: z
|
||
.string()
|
||
.describe("Bearer scope a narrow token needs to read the FHIR face."),
|
||
resourceTypes: z
|
||
.array(z.string())
|
||
.describe("FHIR resource types the REST face serves (read + search)."),
|
||
operations: z
|
||
.array(z.string())
|
||
.describe("Whole-record operations exposed (e.g. $everything)."),
|
||
searchParams: z
|
||
.array(z.string())
|
||
.describe("Search parameters honoured uniformly across the search routes."),
|
||
})
|
||
.describe(
|
||
"FHIR coding constants + the read-only REST face descriptor (v1.11).",
|
||
),
|
||
share: z
|
||
.object({
|
||
supported: z.boolean().describe("Whether clinician share links are served."),
|
||
maxDays: z
|
||
.number()
|
||
.int()
|
||
.describe("Maximum lifetime of a share link, in days. No never-expiring share."),
|
||
resourceTypes: z
|
||
.array(z.string())
|
||
.describe("FHIR resource types a share link may be scoped to serve."),
|
||
sections: z
|
||
.array(z.string())
|
||
.describe("Scopeable report sections a share link may toggle."),
|
||
})
|
||
.describe("Clinician share-link surface descriptor (v1.11)."),
|
||
})
|
||
.meta({
|
||
id: "CapabilitiesResponse",
|
||
description:
|
||
"Live id vocabularies + contract version. Every list is derived server-side from the canonical registry it documents, so it cannot drift from the values the routes actually accept/emit.",
|
||
});
|
||
|
||
// ── Standard 401 / 422 / 429 responses ───────────────────────────────
|
||
|
||
const stdResponses = {
|
||
"401": {
|
||
description: "Authentication required or invalid credentials.",
|
||
content: { "application/json": { schema: errorEnvelope } },
|
||
},
|
||
"422": {
|
||
description: "Request validation failed.",
|
||
content: { "application/json": { schema: errorEnvelope } },
|
||
},
|
||
"429": {
|
||
description: "Rate limit exceeded.",
|
||
content: { "application/json": { schema: errorEnvelope } },
|
||
},
|
||
};
|
||
|
||
// ── Coach facts (v1.11.1) ────────────────────────────────────────────
|
||
// Read + delete surface for the durable facts the Coach extracts. Facts
|
||
// are server-extracted, not user-authored, so there is no create/update
|
||
// shape — only list, bulk-clear, and single-delete responses.
|
||
|
||
const coachFactItem = z.object({
|
||
id: z.string(),
|
||
category: z
|
||
.enum(COACH_FACT_CATEGORIES)
|
||
.describe(
|
||
"App-side closed category: preference | condition | goal | constraint | context.",
|
||
),
|
||
text: z.string().describe("Decrypted fact text."),
|
||
confidence: z
|
||
.number()
|
||
.int()
|
||
.describe("0..100 server-assigned extraction confidence."),
|
||
createdAt: z.iso.datetime({ offset: true }),
|
||
});
|
||
|
||
const coachFactsListResponse = z.object({
|
||
facts: z
|
||
.array(coachFactItem)
|
||
.describe(
|
||
"The caller's active facts, highest-confidence then newest first. Undecryptable rows are omitted.",
|
||
),
|
||
});
|
||
|
||
const coachFactsClearedResponse = z.object({
|
||
cleared: z
|
||
.number()
|
||
.int()
|
||
.describe("Number of active facts soft-deleted by the bulk clear."),
|
||
});
|
||
|
||
const coachFactDeletedResponse = z.object({
|
||
deleted: z
|
||
.boolean()
|
||
.describe(
|
||
"True when a fact owned by the caller was soft-deleted; false for an unknown / cross-user / already-deleted id (idempotent no-op).",
|
||
),
|
||
});
|
||
|
||
// ── Cycle tracking (v1.15.0) ─────────────────────────────────────────
|
||
// The `/api/cycle/*` capture / calendar / history / settings surface +
|
||
// the cycle-prefs PATCH. Request bodies come from the Zod validation
|
||
// module so the spec stays single-source; response DTOs are declared
|
||
// here mirroring `src/lib/cycle/dto.ts`. Every `/api/cycle/*` route also
|
||
// 403s `{ errorCode:"cycle.disabled" }` when the feature gate is off.
|
||
|
||
const predictionMethodEnumOpenapi = z
|
||
.enum(["CALENDAR", "SYMPTOTHERMAL", "TEMPERATURE_TREND", "BLENDED"])
|
||
.meta({ id: "CyclePredictionMethod" });
|
||
|
||
const cyclePhaseEnumOpenapi = z
|
||
.enum(["MENSTRUAL", "FOLLICULAR", "OVULATORY", "LUTEAL"])
|
||
.meta({ id: "CyclePhase" });
|
||
|
||
flowLevelEnum.meta({ id: "FlowLevel", description: "Menstrual-flow intensity." });
|
||
ovulationTestEnum.meta({
|
||
id: "OvulationTest",
|
||
description: "Ovulation predictor-kit (OPK) reading.",
|
||
});
|
||
cervicalMucusEnum.meta({ id: "CervicalMucus", description: "Cervical-mucus quality." });
|
||
homeTestResultEnum.meta({
|
||
id: "HomeTestResult",
|
||
description: "At-home test result (pregnancy / progesterone).",
|
||
});
|
||
cycleTrackingGoalEnum.meta({
|
||
id: "CycleTrackingGoal",
|
||
description: "Drives cycle copy + fertile-window gating.",
|
||
});
|
||
|
||
cycleDayLogInputSchema.meta({
|
||
id: "CycleDayLogInput",
|
||
description:
|
||
"One day's cycle capture. `note` is encrypted at rest; every other field is queryable plaintext. UPSERT key: `(userId, source, externalId)` when externalId present, else `(userId, date)`. Shared by the single POST, the bulk drain, and the period shortcut.",
|
||
});
|
||
|
||
cycleBulkSchema.meta({
|
||
id: "CycleDayLogBulkRequest",
|
||
description:
|
||
"Outbox / HealthKit drain. Up to 500 entries per call; wrapped in `withIdempotency`; rate-limited 60/min. Each entry upserts per the day-log key.",
|
||
});
|
||
|
||
cyclePeriodSchema.meta({
|
||
id: "CyclePeriodRequest",
|
||
description:
|
||
"One-tap period boundary. `start` opens a new cycle (closing the prior), `end` stamps the current cycle's periodEndDate; both write a boundary day-log.",
|
||
});
|
||
|
||
cyclePrefsSchema.meta({
|
||
id: "CyclePrefsRequest",
|
||
description:
|
||
"Partial cycle-preferences deep-merge. `enabled` flips the feature gate (`cycleTrackingEnabled`). Omitted fields are left untouched.",
|
||
});
|
||
|
||
cycleDayLogPatchSchema.meta({
|
||
id: "CycleDayLogPatchRequest",
|
||
description:
|
||
"Partial day-log edit. Every field optional; `note` re-encrypts (explicit null clears it). `date` / `source` / `externalId` are immutable on update.",
|
||
});
|
||
|
||
const cycleSymptomDto = z.object({
|
||
key: z.string(),
|
||
severity: z.number().int().min(1).max(4).nullable(),
|
||
});
|
||
|
||
const cycleDayLogDto = z
|
||
.object({
|
||
id: z.string(),
|
||
date: z.string(),
|
||
cycleId: z.string().nullable(),
|
||
flow: flowLevelEnum.nullable(),
|
||
intermenstrualBleeding: z.boolean(),
|
||
basalBodyTempC: z.number().nullable(),
|
||
ovulationTest: ovulationTestEnum.nullable(),
|
||
cervicalMucus: cervicalMucusEnum.nullable(),
|
||
sexualActivity: z.boolean(),
|
||
protectedSex: z.boolean().nullable(),
|
||
pregnancyTest: homeTestResultEnum.nullable(),
|
||
progesteroneTest: homeTestResultEnum.nullable(),
|
||
contraceptive: z.string().nullable(),
|
||
symptoms: z.array(cycleSymptomDto),
|
||
note: z.string().nullable(),
|
||
source: z.string(),
|
||
externalId: z.string().nullable(),
|
||
syncVersion: z.number().int(),
|
||
updatedAt: z.iso.datetime({ offset: true }),
|
||
deletedAt: z.iso.datetime({ offset: true }).nullable(),
|
||
})
|
||
.meta({
|
||
id: "CycleDayLogDTO",
|
||
description:
|
||
"The canonical day-log row iOS mirrors. `note` is decrypted on read. Soft-deleted rows ride `/api/sync/changes` as tombstones.",
|
||
});
|
||
|
||
const menstrualCycleDto = z
|
||
.object({
|
||
id: z.string(),
|
||
startDate: z.string(),
|
||
endDate: z.string().nullable(),
|
||
periodEndDate: z.string().nullable(),
|
||
lengthDays: z.number().int().nullable(),
|
||
ovulationDate: z.string().nullable(),
|
||
ovulationConfirmed: z.boolean(),
|
||
isPredicted: z.boolean(),
|
||
syncVersion: z.number().int(),
|
||
updatedAt: z.iso.datetime({ offset: true }),
|
||
})
|
||
.meta({
|
||
id: "MenstrualCycleDTO",
|
||
description: "One menstrual cycle (observed or forward-predicted).",
|
||
});
|
||
|
||
const cyclePredictionDto = z
|
||
.object({
|
||
method: predictionMethodEnumOpenapi,
|
||
nextPeriodStart: z.string(),
|
||
nextPeriodStartLow: z.string(),
|
||
nextPeriodStartHigh: z.string(),
|
||
fertileWindowStart: z.string().nullable(),
|
||
fertileWindowEnd: z.string().nullable(),
|
||
predictedOvulation: z.string().nullable(),
|
||
confidence: z.number(),
|
||
cyclesObserved: z.number().int(),
|
||
stillLearning: z.boolean(),
|
||
disclaimer: z.string(),
|
||
})
|
||
.meta({
|
||
id: "CyclePredictionDTO",
|
||
description:
|
||
"The materialised forecast. Fertile-window fields are server-suppressed (null) unless the goal is TRYING_TO_CONCEIVE.",
|
||
});
|
||
|
||
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(),
|
||
});
|
||
|
||
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",
|
||
]),
|
||
display: z.enum(["hours", "steps", "bpm", "ms", "kg", "celsius"]),
|
||
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 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(),
|
||
}),
|
||
contrast: z.object({
|
||
high: z.literal("LUTEAL"),
|
||
low: z.literal("FOLLICULAR"),
|
||
}),
|
||
windowDays: z.number().int(),
|
||
cyclesObserved: z.number().int(),
|
||
});
|
||
|
||
const cycleBulkEntryResult = z.object({
|
||
index: z.number().int(),
|
||
status: z.enum(["inserted", "duplicate", "updated", "skipped"]),
|
||
id: z.string().optional(),
|
||
externalId: z.string().optional(),
|
||
reason: z.string().optional(),
|
||
});
|
||
|
||
const cycleBulkResponse = z.object({
|
||
processed: z.number().int(),
|
||
inserted: z.number().int(),
|
||
updated: z.number().int(),
|
||
duplicates: z.number().int(),
|
||
skipped: z.number().int(),
|
||
entries: z.array(cycleBulkEntryResult),
|
||
});
|
||
|
||
// A reusable 403 the cycle routes carry (the feature gate).
|
||
const cycleDisabledResponse = {
|
||
"403": {
|
||
description:
|
||
"Cycle tracking is not enabled for this account (errorCode `cycle.disabled`).",
|
||
content: { "application/json": { schema: errorEnvelope } },
|
||
},
|
||
} as const;
|
||
|
||
// ── Path table ───────────────────────────────────────────────────────
|
||
|
||
export const openApiPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||
"/api/cycle/day-logs": {
|
||
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/profile": {
|
||
get: {
|
||
tags: ["Cycle"],
|
||
summary: "Read the full cycle profile (v1.15.0)",
|
||
description: "Returns the resolved CycleProfileDTO. Gated.",
|
||
responses: {
|
||
"200": {
|
||
description: "Cycle profile.",
|
||
content: {
|
||
"application/json": {
|
||
schema: dataEnvelope(cycleProfileDto, "CycleProfileEnvelope"),
|
||
},
|
||
},
|
||
},
|
||
...cycleDisabledResponse,
|
||
...stdResponses,
|
||
},
|
||
},
|
||
},
|
||
"/api/auth/me/cycle-prefs": {
|
||
get: {
|
||
tags: ["Cycle"],
|
||
summary: "Read cycle preferences (v1.15.0)",
|
||
description:
|
||
"Returns the resolved CycleProfileDTO. NOT gated — this is the surface that flips the gate.",
|
||
responses: {
|
||
"200": {
|
||
description: "Cycle preferences.",
|
||
content: {
|
||
"application/json": {
|
||
schema: dataEnvelope(cycleProfileDto, "CyclePrefsGetEnvelope"),
|
||
},
|
||
},
|
||
},
|
||
...stdResponses,
|
||
},
|
||
},
|
||
patch: {
|
||
tags: ["Cycle"],
|
||
summary: "Update cycle preferences (v1.15.0)",
|
||
description:
|
||
"Deep-merges the supplied fields. `enabled` flips `cycleTrackingEnabled`. Returns the merged CycleProfileDTO. NOT gated.",
|
||
requestBody: {
|
||
required: true,
|
||
content: { "application/json": { schema: cyclePrefsSchema } },
|
||
},
|
||
responses: {
|
||
"200": {
|
||
description: "Merged cycle preferences.",
|
||
content: {
|
||
"application/json": {
|
||
schema: dataEnvelope(cycleProfileDto, "CyclePrefsPatchEnvelope"),
|
||
},
|
||
},
|
||
},
|
||
...stdResponses,
|
||
},
|
||
},
|
||
},
|
||
"/api/meta/capabilities": {
|
||
get: {
|
||
tags: ["Meta"],
|
||
summary: "Live server capability / id-vocabulary discovery",
|
||
description:
|
||
"Returns the server's REAL id vocabularies (derived-metric ids, vitals-baseline types, layout tile-ids, metric-status ids, the HealthKit ingest mapping, the FHIR coding constants) plus the running API contract version. Every list is derived server-side from the canonical registry it documents, so a client can gate its UI / decoder against what the server actually ships rather than a hand-maintained copy. Auth via cookie or Bearer (not admin).",
|
||
responses: {
|
||
"200": {
|
||
description: "Capability snapshot.",
|
||
content: {
|
||
"application/json": {
|
||
schema: dataEnvelope(
|
||
capabilitiesResponse,
|
||
"CapabilitiesEnvelope",
|
||
),
|
||
},
|
||
},
|
||
},
|
||
...stdResponses,
|
||
},
|
||
},
|
||
},
|
||
"/api/export/health-record": {
|
||
post: {
|
||
tags: ["Export"],
|
||
summary: "Generate a health-record export (PDF / FHIR / package)",
|
||
description:
|
||
"v1.7.0 flagship export. Returns the doctor-handover artefact in the requested `format`: `pdf` → application/pdf, `fhir` → application/fhir+json (HL7 FHIR R4 document Bundle), `package` → application/zip (PDF + FHIR + README). Auth via cookie or Bearer; shared `export:<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,
|
||
},
|
||
},
|
||
},
|
||
"/api/auth/login": {
|
||
post: {
|
||
tags: ["Auth"],
|
||
summary: "Email-or-username login (password)",
|
||
description:
|
||
"Browser callers receive a session cookie. Native callers (X-Client-Type: native or HealthLog-iOS UA prefix) additionally receive a paired access + refresh token.",
|
||
requestBody: {
|
||
required: true,
|
||
content: { "application/json": { schema: loginPasswordSchema } },
|
||
},
|
||
responses: {
|
||
"200": {
|
||
description: "Login succeeded.",
|
||
content: {
|
||
"application/json": {
|
||
schema: dataEnvelope(accessRefreshBundle, "LoginResponse"),
|
||
},
|
||
},
|
||
},
|
||
...stdResponses,
|
||
},
|
||
},
|
||
},
|
||
"/api/auth/passkey/login-verify": {
|
||
post: {
|
||
tags: ["Auth"],
|
||
summary: "Passkey assertion verification",
|
||
requestBody: {
|
||
required: true,
|
||
content: { "application/json": { schema: passkeyLoginVerifyRequest } },
|
||
},
|
||
responses: {
|
||
"200": {
|
||
description: "Assertion verified — session + optional bearer issued.",
|
||
content: {
|
||
"application/json": {
|
||
schema: dataEnvelope(
|
||
accessRefreshBundle,
|
||
"PasskeyLoginVerifyResponse",
|
||
),
|
||
},
|
||
},
|
||
},
|
||
...stdResponses,
|
||
},
|
||
},
|
||
},
|
||
"/api/auth/refresh": {
|
||
post: {
|
||
tags: ["Auth"],
|
||
summary: "Rotate refresh token (one-time use)",
|
||
description:
|
||
"Reuse of a consumed refresh token revokes every refresh token still active for the originating device (per-device blast radius from v1.4.23). Legacy tokens issued before v1.4.23 with a null deviceId fall back to revoke-all-for-user.\n\n" +
|
||
"On a 401, `meta.errorCode` is a stable machine code so the client can branch terminal re-auth from a transient blip without parsing the prose `error`: `auth.refresh.reuse` (a consumed token was replayed — device family revoked, re-pair required), `auth.refresh.revoked` (family revoked out-of-band — re-pair required), `auth.refresh.invalid` (not found / expired — drop the token and re-authenticate).",
|
||
requestBody: {
|
||
required: true,
|
||
content: { "application/json": { schema: refreshRequest } },
|
||
},
|
||
responses: {
|
||
"200": {
|
||
description: "Rotation succeeded — new pair issued.",
|
||
content: {
|
||
"application/json": {
|
||
schema: dataEnvelope(accessRefreshBundle, "RefreshResponse"),
|
||
},
|
||
},
|
||
},
|
||
...stdResponses,
|
||
},
|
||
},
|
||
},
|
||
"/api/sync/state": {
|
||
get: {
|
||
tags: ["Sync"],
|
||
summary: "Sync handshake + window metadata",
|
||
description:
|
||
"Cheap 'should I sync?' summary. Returns the previous `lastSyncedAt` checkpoint and advances it server-side on each call. The `sync` block carries the incremental-delta window + tombstone retention so the client reads them rather than hardcoding.",
|
||
responses: {
|
||
"200": {
|
||
description: "Sync state summary.",
|
||
content: {
|
||
"application/json": {
|
||
schema: dataEnvelope(syncStateResponse, "SyncStateEnvelope"),
|
||
},
|
||
},
|
||
},
|
||
...stdResponses,
|
||
},
|
||
},
|
||
},
|
||
"/api/sync/changes": {
|
||
get: {
|
||
tags: ["Sync"],
|
||
summary: "Measurements delta feed",
|
||
description:
|
||
"Incremental catch-up after the first-pair backfill (never a replacement for it). Pages over an opaque keyset cursor; each page carries `tombstones` (soft-deleted rows, keyed on `externalId`) and `upserts` (live rows). Apply tombstones before upserts within a page. `cursorExpired: true` forces a clean re-init.",
|
||
requestParams: {
|
||
query: syncChangesQuery,
|
||
},
|
||
responses: {
|
||
"200": {
|
||
description: "Delta page.",
|
||
content: {
|
||
"application/json": {
|
||
schema: dataEnvelope(syncChangesResponse, "SyncChangesEnvelope"),
|
||
},
|
||
},
|
||
},
|
||
...stdResponses,
|
||
},
|
||
},
|
||
},
|
||
"/api/measurements": {
|
||
get: {
|
||
tags: ["Measurements"],
|
||
summary: "List measurements",
|
||
description:
|
||
"Filter by type + date range. Response is paged via offset/limit; default limit 100, hard cap 500.",
|
||
requestParams: {
|
||
query: listMeasurementsSchema,
|
||
},
|
||
responses: {
|
||
"200": {
|
||
description: "Measurement page.",
|
||
content: {
|
||
"application/json": {
|
||
schema: dataEnvelope(
|
||
z.object({
|
||
measurements: z.array(measurementResource),
|
||
total: z.number().int().nonnegative(),
|
||
}),
|
||
"ListMeasurementsResponse",
|
||
),
|
||
},
|
||
},
|
||
},
|
||
...stdResponses,
|
||
},
|
||
},
|
||
post: {
|
||
tags: ["Measurements"],
|
||
summary: "Create one measurement",
|
||
description:
|
||
"Single ingest. Use `/api/measurements/batch` for Apple Health upload streams.",
|
||
requestBody: {
|
||
required: true,
|
||
content: { "application/json": { schema: createMeasurementSchema } },
|
||
},
|
||
responses: {
|
||
"201": {
|
||
description: "Created.",
|
||
content: {
|
||
"application/json": {
|
||
schema: dataEnvelope(
|
||
measurementResource,
|
||
"CreateMeasurementResponse",
|
||
),
|
||
},
|
||
},
|
||
},
|
||
...stdResponses,
|
||
},
|
||
},
|
||
},
|
||
"/api/measurements/batch": {
|
||
post: {
|
||
tags: ["Measurements"],
|
||
summary: "Apple Health batch ingest",
|
||
description:
|
||
"Up to 500 HealthKit entries per call. Idempotent via the `Idempotency-Key` header (replay window 24h). Per-entry status lets the iOS client advance its sync cursor accurately. v1.4.25 W8c adds an optional `deviceType` per entry — feed it from `HKDevice.model` so the cross-source canonical picker can break Apple-Watch-vs-iPhone ties; null/absent stays backward-compatible with v1.4.23 clients.",
|
||
requestBody: {
|
||
required: true,
|
||
content: { "application/json": { schema: batchPayloadSchema } },
|
||
},
|
||
responses: {
|
||
"200": {
|
||
description: "Batch processed.",
|
||
content: {
|
||
"application/json": {
|
||
schema: dataEnvelope(batchResponse, "BatchMeasurementsResponse"),
|
||
},
|
||
},
|
||
},
|
||
...stdResponses,
|
||
},
|
||
},
|
||
},
|
||
"/api/measurements/by-external-ids": {
|
||
delete: {
|
||
tags: ["Measurements"],
|
||
summary: "Delete measurements by external ID (iOS deletion-sync)",
|
||
description:
|
||
"Removes the user's measurement rows whose externalId is in the request list. Rows owned by another user are silently skipped (cross-user 404 guard). Up to 500 externalIds per call.",
|
||
requestBody: {
|
||
required: true,
|
||
content: {
|
||
"application/json": { schema: deleteByExternalIdsRequest },
|
||
},
|
||
},
|
||
responses: {
|
||
"200": {
|
||
description: "Delete batch processed.",
|
||
content: {
|
||
"application/json": {
|
||
schema: dataEnvelope(
|
||
deleteByExternalIdsResponse,
|
||
"MeasurementsDeleteByExternalIdsResponse",
|
||
),
|
||
},
|
||
},
|
||
},
|
||
...stdResponses,
|
||
},
|
||
},
|
||
},
|
||
"/api/sleep/night": {
|
||
get: {
|
||
tags: ["Measurements"],
|
||
summary: "Reconstructed sleep night (hypnogram source, v1.11.5)",
|
||
description:
|
||
"Returns one night's reconstructed sleep session for the phase-progression (hypnogram) view: the canonical source's stage segments (each with an absolute start/end), the per-stage breakdown, asleep/in-bed/awake totals, the mid-sleep awakenings count, and same-wake-day naps surfaced separately. Session-clustered, keyed by the local wake day, collapsed to one source via the sleep priority ladder so two sources never overlay. `date` omitted returns the most recent night. A read-only view over existing per-stage SLEEP_DURATION rows — no schema, no new measurement type.",
|
||
requestParams: {
|
||
query: z.object({
|
||
date: z
|
||
.string()
|
||
.regex(/^\d{4}-\d{2}-\d{2}$/)
|
||
.optional()
|
||
.describe(
|
||
"Wake-day key (YYYY-MM-DD). Omit for the most recent night.",
|
||
),
|
||
}),
|
||
},
|
||
responses: {
|
||
"200": {
|
||
description: "Reconstructed night.",
|
||
content: {
|
||
"application/json": {
|
||
schema: dataEnvelope(sleepNightResponse, "SleepNightResponse"),
|
||
},
|
||
},
|
||
},
|
||
...stdResponses,
|
||
},
|
||
},
|
||
},
|
||
"/api/workouts": {
|
||
get: {
|
||
tags: ["Measurements"],
|
||
summary: "List workouts (v1.4.32)",
|
||
description:
|
||
"Paginated workout list with cross-source canonical-row dedup. The picker reads the per-user source-priority ladder and buckets by `(startedAt 5 min slot, sportType)` — twin workouts (Apple Watch + Withings ScanWatch) collapse to a single canonical row per cluster. Field names mirror the iOS handoff contract: `distanceM`, `activeEnergyKcal`, `avgHr`, `maxHr`.",
|
||
requestParams: {
|
||
query: z.object({
|
||
limit: z.coerce.number().int().min(1).max(200).optional(),
|
||
offset: z.coerce.number().int().min(0).optional(),
|
||
since: z.iso.datetime({ offset: true }).optional(),
|
||
until: z.iso.datetime({ offset: true }).optional(),
|
||
sportType: z.string().optional(),
|
||
}),
|
||
},
|
||
responses: {
|
||
"200": {
|
||
description: "Canonical workout page.",
|
||
content: {
|
||
"application/json": {
|
||
schema: dataEnvelope(workoutListResponse, "ListWorkoutsResponse"),
|
||
},
|
||
},
|
||
},
|
||
...stdResponses,
|
||
},
|
||
},
|
||
},
|
||
"/api/workouts/{id}": {
|
||
get: {
|
||
tags: ["Measurements"],
|
||
summary: "Workout detail (v1.4.32)",
|
||
description:
|
||
"Single-workout envelope. Owns the optional `WorkoutRoute` GeoJSON geometry + `canonicalId` pointer that resolves to the cluster winner so deep-links into non-canonical twin rows can redirect cleanly. Cross-user rows surface as 404 (existence channel sealed).",
|
||
requestParams: {
|
||
path: z.object({ id: z.string() }),
|
||
},
|
||
responses: {
|
||
"200": {
|
||
description: "Workout detail.",
|
||
content: {
|
||
"application/json": {
|
||
schema: dataEnvelope(workoutDetailResponse, "GetWorkoutResponse"),
|
||
},
|
||
},
|
||
},
|
||
"404": {
|
||
description: "Workout not found (or owned by another user).",
|
||
content: { "application/json": { schema: errorEnvelope } },
|
||
},
|
||
...stdResponses,
|
||
},
|
||
},
|
||
},
|
||
"/api/workouts/batch": {
|
||
post: {
|
||
tags: ["Measurements"],
|
||
summary: "Typed workout batch ingest (v1.4.25 W16b)",
|
||
description:
|
||
"Server-side ingest endpoint for HKWorkout records (iOS) and Withings activity rows (server-to-server). Up to 100 workouts per call; nested route geometry (GeoJSON LineString) is capped at 20 000 points and stored in a 1:1 `WorkoutRoute` row keyed by `workoutId`. Idempotent via the `Idempotency-Key` header (replay window 24h). Per-entry status (`inserted | duplicate | skipped`) lets the iOS sync cursor checkpoint accurately. The request body ceiling is 5 MB enforced at the HTTP layer — clients above the ceiling receive a 413 with `workout.batch.payload_too_large`.",
|
||
requestBody: {
|
||
required: true,
|
||
content: { "application/json": { schema: createBatchWorkoutSchema } },
|
||
},
|
||
responses: {
|
||
"200": {
|
||
description: "Batch processed.",
|
||
content: {
|
||
"application/json": {
|
||
schema: dataEnvelope(
|
||
workoutBatchResponse,
|
||
"BatchWorkoutsResponse",
|
||
),
|
||
},
|
||
},
|
||
},
|
||
"400": {
|
||
description:
|
||
"Batch validation failed (over-cap, schema reject, or oversized route).",
|
||
content: { "application/json": { schema: errorEnvelope } },
|
||
},
|
||
"413": {
|
||
description: "Request body exceeds the 5 MB ceiling.",
|
||
content: { "application/json": { schema: errorEnvelope } },
|
||
},
|
||
...stdResponses,
|
||
},
|
||
},
|
||
},
|
||
"/api/devices": {
|
||
post: {
|
||
tags: ["Devices"],
|
||
summary: "Register native device + APNs token",
|
||
description:
|
||
"Idempotent upsert by `token`. APNs token + environment are paired — supplying one without the other returns 422. Cross-user re-registration of either identifier returns 409.",
|
||
requestBody: {
|
||
required: true,
|
||
content: { "application/json": { schema: deviceRegisterRequest } },
|
||
},
|
||
responses: {
|
||
"201": {
|
||
description: "Device registered or refreshed.",
|
||
content: {
|
||
"application/json": {
|
||
schema: dataEnvelope(
|
||
z.object({ id: z.string() }),
|
||
"DeviceRegisterResponse",
|
||
),
|
||
},
|
||
},
|
||
},
|
||
"409": {
|
||
description:
|
||
"Device or APNs token already registered to another user.",
|
||
content: { "application/json": { schema: errorEnvelope } },
|
||
},
|
||
...stdResponses,
|
||
},
|
||
},
|
||
},
|
||
"/api/medications": {
|
||
get: {
|
||
tags: ["Medications"],
|
||
summary: "List medications for the calling user",
|
||
description:
|
||
"Returns every medication owned by the caller (active + paused), ordered by `createdAt DESC`. Each row carries its nested `schedules`, the joined clinical `category`, the latest non-skipped `lastTakenAt`, and the count of intake events scheduled for today (`todayEventCount`). The response is cached server-side for 60 s per user; writes flush the cache.",
|
||
responses: {
|
||
"200": {
|
||
description: "Medication list.",
|
||
content: {
|
||
"application/json": {
|
||
schema: dataEnvelope(
|
||
z.array(medicationListEntry),
|
||
"ListMedicationsResponse",
|
||
),
|
||
},
|
||
},
|
||
},
|
||
...stdResponses,
|
||
},
|
||
},
|
||
post: {
|
||
tags: ["Medications"],
|
||
summary: "Create a medication with at least one schedule",
|
||
description:
|
||
"Validates the body against `CreateMedicationRequest`, applies the v1.5 cross-field invariants (one-shot consistency, recurring default `FREQ=DAILY`, `timesOfDay` dual-write), and creates the medication + its schedules in a single Prisma write. Audits as `medication.create`.",
|
||
requestBody: {
|
||
required: true,
|
||
content: { "application/json": { schema: createMedicationSchema } },
|
||
},
|
||
responses: {
|
||
"201": {
|
||
description: "Created medication with its schedules.",
|
||
content: {
|
||
"application/json": {
|
||
schema: dataEnvelope(
|
||
medicationDetailEntry,
|
||
"CreateMedicationResponse",
|
||
),
|
||
},
|
||
},
|
||
},
|
||
...stdResponses,
|
||
},
|
||
},
|
||
},
|
||
"/api/medications/{id}": {
|
||
get: {
|
||
tags: ["Medications"],
|
||
summary: "Fetch a single medication",
|
||
description:
|
||
"Returns the medication + its schedules + the joined `category`. Cross-user rows surface as 404 (existence channel sealed).",
|
||
requestParams: {
|
||
path: z.object({ id: z.string() }),
|
||
},
|
||
responses: {
|
||
"200": {
|
||
description: "Medication detail.",
|
||
content: {
|
||
"application/json": {
|
||
schema: dataEnvelope(
|
||
medicationDetailEntry,
|
||
"GetMedicationResponse",
|
||
),
|
||
},
|
||
},
|
||
},
|
||
"404": {
|
||
description: "Medication not found (or owned by another user).",
|
||
content: { "application/json": { schema: errorEnvelope } },
|
||
},
|
||
...stdResponses,
|
||
},
|
||
},
|
||
put: {
|
||
tags: ["Medications"],
|
||
summary: "Replace a medication (partial fields)",
|
||
description:
|
||
"Every field on the body is optional; omitted fields are left untouched. Supplying `schedules` REPLACES the medication's full schedule list (the route deletes existing rows before re-creating). Flipping `active` to false stamps `pausedAt`; flipping back to true clears it. v1.5 invariants on the `schedules` array match `POST /api/medications`. Audits as `medication.update`.",
|
||
requestParams: {
|
||
path: z.object({ id: z.string() }),
|
||
},
|
||
requestBody: {
|
||
required: true,
|
||
content: { "application/json": { schema: updateMedicationSchema } },
|
||
},
|
||
responses: {
|
||
"200": {
|
||
description: "Updated medication.",
|
||
content: {
|
||
"application/json": {
|
||
schema: dataEnvelope(
|
||
medicationDetailEntry,
|
||
"UpdateMedicationResponse",
|
||
),
|
||
},
|
||
},
|
||
},
|
||
"404": {
|
||
description: "Medication not found (or owned by another user).",
|
||
content: { "application/json": { schema: errorEnvelope } },
|
||
},
|
||
...stdResponses,
|
||
},
|
||
},
|
||
delete: {
|
||
tags: ["Medications"],
|
||
summary: "Delete a medication",
|
||
description:
|
||
"Cascades to the medication's schedules, intake events, dose changes, inventory rows, and side-effect logs. Revokes every API token scoped to `medication:<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,
|
||
},
|
||
},
|
||
},
|
||
"/api/auth/me/coach-prefs": {
|
||
get: {
|
||
tags: ["Auth"],
|
||
summary: "Read per-user Coach prompt-tuning preferences",
|
||
description:
|
||
"Returns the persisted preferences with defaults filled in. Null persisted state resolves to the legacy v1.4.22 defaults.",
|
||
responses: {
|
||
"200": {
|
||
description: "Resolved preferences.",
|
||
content: {
|
||
"application/json": {
|
||
schema: dataEnvelope(coachPrefsSchema, "GetCoachPrefsResponse"),
|
||
},
|
||
},
|
||
},
|
||
...stdResponses,
|
||
},
|
||
},
|
||
put: {
|
||
tags: ["Auth"],
|
||
summary: "Replace per-user Coach prompt-tuning preferences",
|
||
description:
|
||
"Persists the supplied prefs. Body is validated against `CoachPrefs`; missing keys fall back to the documented defaults.",
|
||
requestBody: {
|
||
required: true,
|
||
content: { "application/json": { schema: coachPrefsSchema } },
|
||
},
|
||
responses: {
|
||
"200": {
|
||
description: "Saved preferences echoed back.",
|
||
content: {
|
||
"application/json": {
|
||
schema: dataEnvelope(coachPrefsSchema, "PutCoachPrefsResponse"),
|
||
},
|
||
},
|
||
},
|
||
...stdResponses,
|
||
},
|
||
},
|
||
},
|
||
"/api/auth/me/disable-coach": {
|
||
get: {
|
||
tags: ["Auth"],
|
||
summary: "Read per-user Coach opt-out flag",
|
||
description:
|
||
"Returns the user's per-account Coach opt-out flag. Default `false` (Coach visible). Powers the Settings → Insights \"Hide Coach\" Switch and the layout FAB short-circuit.",
|
||
responses: {
|
||
"200": {
|
||
description: "Resolved flag.",
|
||
content: {
|
||
"application/json": {
|
||
schema: dataEnvelope(disableCoachFlag, "GetDisableCoachResponse"),
|
||
},
|
||
},
|
||
},
|
||
...stdResponses,
|
||
},
|
||
},
|
||
patch: {
|
||
tags: ["Auth"],
|
||
summary: "Toggle per-user Coach opt-out flag",
|
||
description:
|
||
"Flips the per-account Coach opt-out flag. Idempotent — the DB write fires even when the value matches so the audit-log row mirrors the API call. Rate-limit 60/min per user.",
|
||
requestBody: {
|
||
required: true,
|
||
content: { "application/json": { schema: disableCoachFlag } },
|
||
},
|
||
responses: {
|
||
"200": {
|
||
description:
|
||
"Resolved next-state echoed back for optimistic-update consumers.",
|
||
content: {
|
||
"application/json": {
|
||
schema: dataEnvelope(
|
||
disableCoachFlag,
|
||
"PatchDisableCoachResponse",
|
||
),
|
||
},
|
||
},
|
||
},
|
||
...stdResponses,
|
||
},
|
||
},
|
||
},
|
||
"/api/auth/me/source-priority": {
|
||
get: {
|
||
tags: ["Auth"],
|
||
summary: "Read per-user source priority",
|
||
description:
|
||
"Returns the per-metric-class source priority used by the analytics aggregator. Missing keys fall back to `DEFAULT_SOURCE_PRIORITY`. Null persisted state resolves to the documented defaults.",
|
||
responses: {
|
||
"200": {
|
||
description: "Resolved priority.",
|
||
content: {
|
||
"application/json": {
|
||
schema: dataEnvelope(
|
||
sourcePrioritySchema,
|
||
"GetSourcePriorityResponse",
|
||
),
|
||
},
|
||
},
|
||
},
|
||
...stdResponses,
|
||
},
|
||
},
|
||
put: {
|
||
tags: ["Auth"],
|
||
summary: "Replace per-user source priority",
|
||
description:
|
||
"Persists the supplied priority. Body is validated against `SourcePriority`; missing keys read as `DEFAULT_SOURCE_PRIORITY` at the call site.",
|
||
requestBody: {
|
||
required: true,
|
||
content: { "application/json": { schema: sourcePrioritySchema } },
|
||
},
|
||
responses: {
|
||
"200": {
|
||
description: "Saved priority (defaulted) echoed back.",
|
||
content: {
|
||
"application/json": {
|
||
schema: dataEnvelope(
|
||
sourcePrioritySchema,
|
||
"PutSourcePriorityResponse",
|
||
),
|
||
},
|
||
},
|
||
},
|
||
...stdResponses,
|
||
},
|
||
},
|
||
},
|
||
"/api/user/avatar": {
|
||
post: {
|
||
tags: ["Auth"],
|
||
summary: "Upload the calling user's avatar",
|
||
description:
|
||
"Multipart upload (field `file`). Accepts image/jpeg, image/png, image/webp; rejects anything else at the magic-byte sniff (the multipart Content-Type header is informational only). Hard caps: 2 MiB body, 2048×2048 dimensions. Replaces the v1.4.22 Gravatar leak by storing the bytes on the User row + serving them from same-origin.",
|
||
requestBody: {
|
||
required: true,
|
||
content: {
|
||
"multipart/form-data": {
|
||
schema: z.object({
|
||
file: z
|
||
.string()
|
||
.describe(
|
||
"Binary image payload (JPEG / PNG / WebP). Hard-capped at 2 MiB and 2048×2048.",
|
||
),
|
||
}),
|
||
},
|
||
},
|
||
},
|
||
responses: {
|
||
"201": {
|
||
description:
|
||
"Avatar saved. `avatarUrl` is the same value the /me payload returns; the `?v=` suffix busts client caches on re-upload.",
|
||
content: {
|
||
"application/json": {
|
||
schema: dataEnvelope(
|
||
z.object({
|
||
avatarUrl: z.string(),
|
||
contentType: z.enum(["image/jpeg", "image/png", "image/webp"]),
|
||
updatedAt: z.iso.datetime({ offset: true }),
|
||
}),
|
||
"UploadAvatarResponse",
|
||
),
|
||
},
|
||
},
|
||
},
|
||
"413": {
|
||
description:
|
||
"Upload exceeds the 2 MiB byte limit or the 2048×2048 dimension limit.",
|
||
content: { "application/json": { schema: errorEnvelope } },
|
||
},
|
||
"415": {
|
||
description:
|
||
"Image format is not one of JPEG / PNG / WebP (magic-byte sniff).",
|
||
content: { "application/json": { schema: errorEnvelope } },
|
||
},
|
||
...stdResponses,
|
||
},
|
||
},
|
||
delete: {
|
||
tags: ["Auth"],
|
||
summary: "Clear the calling user's avatar",
|
||
description:
|
||
"Resets the avatar columns to null. Idempotent — a delete on an already-empty row returns 204 with no audit-log row.",
|
||
responses: {
|
||
"204": {
|
||
description: "Avatar cleared (or already empty).",
|
||
},
|
||
...stdResponses,
|
||
},
|
||
},
|
||
},
|
||
"/api/user/avatar/{id}": {
|
||
get: {
|
||
tags: ["Auth"],
|
||
summary: "Serve a user's avatar bytes",
|
||
description:
|
||
"Owner-scoped. The `{id}` path segment must match the calling user's id — cross-user reads return 403. Response body is the raw image bytes with the persisted `Content-Type`; the /me payload appends `?v={updatedAtMs}` so clients can cache aggressively.",
|
||
requestParams: {
|
||
path: z.object({
|
||
id: z.string().describe("User id (must match the calling user)."),
|
||
}),
|
||
},
|
||
responses: {
|
||
"200": {
|
||
description: "Avatar bytes.",
|
||
content: {
|
||
"image/jpeg": { schema: z.string().describe("Raw JPEG bytes.") },
|
||
"image/png": { schema: z.string().describe("Raw PNG bytes.") },
|
||
"image/webp": { schema: z.string().describe("Raw WebP bytes.") },
|
||
},
|
||
},
|
||
"403": {
|
||
description: "Caller is not the avatar's owner.",
|
||
content: { "application/json": { schema: errorEnvelope } },
|
||
},
|
||
"404": {
|
||
description: "The user has no uploaded avatar.",
|
||
content: { "application/json": { schema: errorEnvelope } },
|
||
},
|
||
...stdResponses,
|
||
},
|
||
},
|
||
},
|
||
"/api/user/profile": {
|
||
get: {
|
||
tags: ["Auth"],
|
||
summary: "Read the calling user's profile",
|
||
description:
|
||
"Flattened profile fields for the native client. Aliased over the same data exposed by /api/auth/me. The KVNR is decrypted server-side; the IKNR (v1.8.6) is returned plaintext.",
|
||
responses: {
|
||
"200": {
|
||
description: "The calling user's profile.",
|
||
content: {
|
||
"application/json": {
|
||
schema: dataEnvelope(profileResponse, "GetProfileResponse"),
|
||
},
|
||
},
|
||
},
|
||
...stdResponses,
|
||
},
|
||
},
|
||
patch: {
|
||
tags: ["Auth"],
|
||
summary: "Update the calling user's profile",
|
||
description:
|
||
"Partial profile update — every field is optional. An explicit null (or empty string) clears a field. A non-empty `insurerIkNumber` must be exactly 9 digits (422 otherwise); a non-empty `insuranceNumber` (KVNR) must pass the mod-10 check. `userId` is never accepted from the body.",
|
||
requestBody: {
|
||
required: true,
|
||
content: { "application/json": { schema: profileUpdateRequest } },
|
||
},
|
||
responses: {
|
||
"200": {
|
||
description: "Saved profile echoed back.",
|
||
content: {
|
||
"application/json": {
|
||
schema: dataEnvelope(
|
||
profileUpdateResponse,
|
||
"PatchProfileResponse",
|
||
),
|
||
},
|
||
},
|
||
},
|
||
...stdResponses,
|
||
},
|
||
},
|
||
},
|
||
"/api/insights/layout": {
|
||
get: {
|
||
tags: ["Insights"],
|
||
summary: "Read the calling user's Insights tile layout",
|
||
description:
|
||
"Returns the per-user Insights tile layout (visibility + order). Falls back to the default layout when the user has not customised it. Mirrors the dashboard-widgets contract.",
|
||
responses: {
|
||
"200": {
|
||
description: "The resolved layout (custom or default).",
|
||
content: {
|
||
"application/json": {
|
||
schema: dataEnvelope(insightsLayoutSchema, "InsightsLayout"),
|
||
},
|
||
},
|
||
},
|
||
...stdResponses,
|
||
},
|
||
},
|
||
put: {
|
||
tags: ["Insights"],
|
||
summary: "Replace the calling user's Insights tile layout",
|
||
description:
|
||
"Persists the full tile layout. The normalised layout is returned. Invalid bodies return the multi-issue 422 envelope, matching the dashboard-widgets route.",
|
||
requestBody: {
|
||
required: true,
|
||
content: {
|
||
"application/json": { schema: insightsLayoutSchema },
|
||
},
|
||
},
|
||
responses: {
|
||
"200": {
|
||
description: "Layout saved; the normalised layout is echoed back.",
|
||
content: {
|
||
"application/json": {
|
||
schema: dataEnvelope(insightsLayoutSchema, "InsightsLayoutSaved"),
|
||
},
|
||
},
|
||
},
|
||
// 422 (multi-issue validation envelope) comes from stdResponses.
|
||
...stdResponses,
|
||
},
|
||
},
|
||
delete: {
|
||
tags: ["Insights"],
|
||
summary: "Reset the calling user's Insights tile layout",
|
||
description:
|
||
"Clears the persisted layout and returns the default layout. Idempotent.",
|
||
responses: {
|
||
"200": {
|
||
description: "Layout reset; the default layout is returned.",
|
||
content: {
|
||
"application/json": {
|
||
schema: dataEnvelope(
|
||
insightsLayoutSchema,
|
||
"InsightsLayoutReset",
|
||
),
|
||
},
|
||
},
|
||
},
|
||
...stdResponses,
|
||
},
|
||
},
|
||
},
|
||
"/api/insights/coach/facts": {
|
||
get: {
|
||
tags: ["Insights"],
|
||
summary: "List the caller's durable Coach facts",
|
||
description:
|
||
"v1.11.1 — returns the active facts the Coach has extracted about the caller (highest-confidence then newest first), each decrypted on the fly. The GDPR 'what do you know about me' surface. Coach-gated (`requireAssistantSurface(\"coach\")`). Auth via cookie or Bearer; the owner is always narrowed from the session, never the body. Undecryptable rows are omitted rather than failing the read.",
|
||
responses: {
|
||
"200": {
|
||
description: "The caller's active facts.",
|
||
content: {
|
||
"application/json": {
|
||
schema: dataEnvelope(coachFactsListResponse, "CoachFactsList"),
|
||
},
|
||
},
|
||
},
|
||
...stdResponses,
|
||
},
|
||
},
|
||
delete: {
|
||
tags: ["Insights"],
|
||
summary: "Forget all of the caller's Coach facts",
|
||
description:
|
||
"v1.11.1 — bulk 'forget what you know about me': soft-deletes every active fact for the caller and returns the count cleared. Idempotent (a second call clears 0). Coach-gated. Auth via cookie or Bearer.",
|
||
responses: {
|
||
"200": {
|
||
description: "All active facts cleared; the count is returned.",
|
||
content: {
|
||
"application/json": {
|
||
schema: dataEnvelope(
|
||
coachFactsClearedResponse,
|
||
"CoachFactsCleared",
|
||
),
|
||
},
|
||
},
|
||
},
|
||
...stdResponses,
|
||
},
|
||
},
|
||
},
|
||
"/api/insights/coach/facts/{id}": {
|
||
delete: {
|
||
tags: ["Insights"],
|
||
summary: "Forget one Coach fact",
|
||
description:
|
||
"v1.11.1 — soft-deletes a single fact owned by the caller. An unknown / cross-user / already-deleted id is an idempotent no-op returning `{ deleted: false }`, never revealing whether the id exists under another account. Coach-gated. Auth via cookie or Bearer.",
|
||
responses: {
|
||
"200": {
|
||
description:
|
||
"The fact was soft-deleted (`deleted: true`) or the id matched nothing the caller owns (`deleted: false`).",
|
||
content: {
|
||
"application/json": {
|
||
schema: dataEnvelope(
|
||
coachFactDeletedResponse,
|
||
"CoachFactDeleted",
|
||
),
|
||
},
|
||
},
|
||
},
|
||
...stdResponses,
|
||
},
|
||
},
|
||
},
|
||
"/api/insights/chat/messages/{id}/feedback": {
|
||
post: {
|
||
tags: ["Insights"],
|
||
summary: "Rate a Coach assistant message",
|
||
description:
|
||
"Persists a helpful/unhelpful rating for a single Coach reply. Reuses the v1.4.16 RecommendationFeedback table via the polymorphic `targetType` column. The aggregator buckets ratings by (promptVersion, tone, verbosity).",
|
||
requestBody: {
|
||
required: true,
|
||
content: {
|
||
"application/json": { schema: coachMessageFeedbackBody },
|
||
},
|
||
},
|
||
responses: {
|
||
"201": {
|
||
description: "Feedback saved.",
|
||
content: {
|
||
"application/json": {
|
||
schema: dataEnvelope(
|
||
z.object({
|
||
id: z.string(),
|
||
createdAt: z.iso.datetime({ offset: true }),
|
||
}),
|
||
"CoachMessageFeedbackResponse",
|
||
),
|
||
},
|
||
},
|
||
},
|
||
"404": {
|
||
description: "Message not found or not owned by the caller.",
|
||
content: { "application/json": { schema: errorEnvelope } },
|
||
},
|
||
"409": {
|
||
description: "Caller has already rated this message text.",
|
||
content: { "application/json": { schema: errorEnvelope } },
|
||
},
|
||
...stdResponses,
|
||
},
|
||
},
|
||
},
|
||
"/api/admin/notifications/diagnostic": {
|
||
get: {
|
||
tags: ["Admin"],
|
||
summary: "Admin notification diagnostic snapshot",
|
||
description:
|
||
"Admin-only. Returns what the dispatcher would see when targeting the calling admin's account: registered devices (APNs tokens masked to prefix + suffix), per-channel enabled + configPresent flags, and recent push attempts (currently always `[]` pending the v1.4.48+ PushAttempt table). Cookie auth only — never Bearer.",
|
||
responses: {
|
||
"200": {
|
||
description: "Diagnostic snapshot.",
|
||
content: {
|
||
"application/json": {
|
||
schema: dataEnvelope(
|
||
adminDiagnosticData,
|
||
"AdminDiagnosticResponse",
|
||
),
|
||
},
|
||
},
|
||
},
|
||
"403": {
|
||
description: "Caller is not an admin.",
|
||
content: { "application/json": { schema: errorEnvelope } },
|
||
},
|
||
...stdResponses,
|
||
},
|
||
},
|
||
},
|
||
"/api/dashboard/snapshot": {
|
||
get: {
|
||
tags: ["Dashboard"],
|
||
summary: "Unified dashboard first-paint snapshot",
|
||
description:
|
||
"Assembles every above-the-fold tile field in one round-trip from the rollup / mood / widget helpers plus a read-only lift of the pre-generated daily briefing. Two-phase: `tiles` always present, `extras` nullable on a rollup-coverage miss. No LLM is reachable from this path. Cookie or Bearer auth.",
|
||
responses: {
|
||
"200": {
|
||
description: "Dashboard snapshot.",
|
||
content: {
|
||
"application/json": {
|
||
schema: dataEnvelope(
|
||
dashboardSnapshotResponse,
|
||
"DashboardSnapshotResponseEnvelope",
|
||
),
|
||
},
|
||
},
|
||
},
|
||
...stdResponses,
|
||
},
|
||
},
|
||
},
|
||
"/api/insights/comprehensive": {
|
||
get: {
|
||
tags: ["Insights"],
|
||
summary: "Comprehensive AI insights bundle",
|
||
description:
|
||
"Full Insights surface — daily briefing, recommendations with rationale, optional weekly report + storyboard annotations. Strict-schema validated server-side.",
|
||
responses: {
|
||
"200": {
|
||
description: "Insights bundle.",
|
||
content: {
|
||
"application/json": {
|
||
schema: dataEnvelope(
|
||
insightsComprehensiveResponse,
|
||
"InsightsComprehensiveResponseEnvelope",
|
||
),
|
||
},
|
||
},
|
||
},
|
||
...stdResponses,
|
||
},
|
||
},
|
||
},
|
||
"/api/insights/pregenerate": {
|
||
post: {
|
||
tags: ["Insights"],
|
||
summary: "Warm all AI assessments for the calling user",
|
||
description:
|
||
"v1.8.7.1 — enqueue a full warm of every AI assessment for the authenticated user (comprehensive insight + the seven specialised status cards + every data-bearing generic metric assessment) in the active locale, so the read-only status GETs serve cached text instantly. Returns immediately; the generation runs out of band on the worker. Empty metrics and provider-less accounts never trigger an LLM call. Short anti-spam bucket (`insights-warm:<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,
|
||
},
|
||
},
|
||
},
|
||
};
|
||
|
||
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.",
|
||
},
|
||
},
|
||
};
|