mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-13 22:32:04 +00:00
Add the resolved `modules` map to ProfileResponse (shared ModuleMap schema) so the spec matches the alias payload, and add a reusable module-disabled 403 response documenting `meta.errorCode = module.disabled` plus `meta.module` (the disabled key). Wire that 403 onto the module-gated sleep night/rhythm routes, which emit it via requireModuleEnabled. Regenerate docs/api/openapi.yaml; openapi:check is in sync.
143 lines
6.0 KiB
TypeScript
143 lines
6.0 KiB
TypeScript
/**
|
|
* Shared OpenAPI building blocks — response envelopes and standard error responses used across every route module.
|
|
*
|
|
* Part of the OpenAPI route table; aggregated in `./index.ts`.
|
|
* Schemas come from `src/lib/validations/*` where shared with the
|
|
* runtime request parsing, so the wire contract stays single-source.
|
|
*/
|
|
import { z } from "zod/v4";
|
|
import {
|
|
createMeasurementSchema,
|
|
listMeasurementsSchema,
|
|
measurementTypeEnum,
|
|
measurementSourceEnum,
|
|
} from "@/lib/validations/measurement";
|
|
import { loginPasswordSchema } from "@/lib/validations/auth";
|
|
import { coachPrefsSchema } from "@/lib/validations/coach-prefs";
|
|
import { createBatchWorkoutSchema } from "@/lib/validations/workout";
|
|
|
|
/**
|
|
* Common envelopes — every HealthLog API response wraps payload in
|
|
* `{ data, error, meta? }`. The OpenAPI surface mirrors that contract
|
|
* so iOS / external-ingest clients can decode uniformly.
|
|
*/
|
|
export const errorEnvelope = z
|
|
.object({
|
|
data: z.null(),
|
|
error: z.string(),
|
|
meta: z
|
|
.object({
|
|
requestId: z.string().optional(),
|
|
errorCode: z.string().optional(),
|
|
})
|
|
.optional(),
|
|
})
|
|
.meta({
|
|
id: "ErrorEnvelope",
|
|
description: "Standard error response: data is null, error is human prose.",
|
|
});
|
|
|
|
export function dataEnvelope<T extends z.ZodType>(payload: T, id: string) {
|
|
return z
|
|
.object({
|
|
data: payload,
|
|
error: z.null(),
|
|
meta: z.object({ requestId: z.string().optional() }).optional(),
|
|
})
|
|
.meta({ id });
|
|
}
|
|
|
|
// ── Schemas — annotated for spec emission ────────────────────────────
|
|
|
|
measurementTypeEnum.meta({
|
|
id: "MeasurementType",
|
|
description:
|
|
"DB-stored measurement category. v1.4.23 added 7 Apple Health values (HRV, resting HR, active energy, flights, walking/running distance, VO2 max, body temperature).",
|
|
});
|
|
|
|
measurementSourceEnum.meta({
|
|
id: "MeasurementSource",
|
|
description:
|
|
"Origin of the measurement. v1.4.23 added APPLE_HEALTH for the iOS HealthKit batch ingest path.",
|
|
});
|
|
|
|
loginPasswordSchema.meta({
|
|
id: "LoginPasswordRequest",
|
|
description:
|
|
"Email-or-username login. The native-client flow returns a paired access + refresh token when X-Client-Type: native or the iOS UA prefix is present.",
|
|
});
|
|
|
|
createMeasurementSchema.meta({
|
|
id: "CreateMeasurementRequest",
|
|
description:
|
|
"Single-measurement ingest body. Plausibility-range guard runs server-side; out-of-range values fail 422.",
|
|
});
|
|
|
|
listMeasurementsSchema.meta({
|
|
id: "ListMeasurementsQuery",
|
|
description:
|
|
"Query params for the measurements list endpoint. `limit` capped at 500.",
|
|
});
|
|
|
|
coachPrefsSchema.meta({
|
|
id: "CoachPrefs",
|
|
description:
|
|
"Per-user Coach prompt-tuning preferences (v1.4.23 H4). All fields default to the legacy v1.4.22 behaviour when omitted.",
|
|
});
|
|
|
|
createBatchWorkoutSchema.meta({
|
|
id: "CreateBatchWorkoutRequest",
|
|
description:
|
|
"Typed workout batch ingest. Each entry is an HKWorkout-aligned record with an optional nested GeoJSON LineString route AND an optional route-independent per-workout heart-rate series (`samples`: `[{ t, hr?, speedMs?, power?, cadence? }]`, up to 30 000 points). The `samples` series is the strain-engine input for indoor workouts that have no GPS route. Up to 100 workouts per call; nested route geometry capped at 20 000 points. Withings server-to-server callers pass source: WITHINGS and ship no route (Withings reports aggregates only).",
|
|
});
|
|
|
|
// ── Standard 401 / 422 / 429 responses ───────────────────────────────
|
|
|
|
export const stdResponses = {
|
|
"401": {
|
|
description: "Authentication required or invalid credentials.",
|
|
content: { "application/json": { schema: errorEnvelope } },
|
|
},
|
|
"422": {
|
|
description: "Request validation failed.",
|
|
content: { "application/json": { schema: errorEnvelope } },
|
|
},
|
|
"429": {
|
|
description: "Rate limit exceeded.",
|
|
content: { "application/json": { schema: errorEnvelope } },
|
|
},
|
|
};
|
|
|
|
// ── AI-consent precondition (v1.16.13) ───────────────────────────────
|
|
// The server-managed AI-egress gate requires an active ConsentReceipt
|
|
// (`ai_full`, or the surface-specific `ai_insights_only` / `ai_coach`)
|
|
// before any health snapshot leaves for the operator's global LLM key.
|
|
// Interactive routes surface this as a 403 with
|
|
// `meta.errorCode = "consent.ai.required"`; clients render an inline
|
|
// grant-consent notice and call POST /api/consent/ai (or, on web, POST
|
|
// /api/consent/ai/web) to mint the receipt. BYOK / local / ChatGPT-OAuth
|
|
// chains are the user's own egress and never trip this gate.
|
|
export const consentRequiredResponse = {
|
|
"403": {
|
|
description:
|
|
"AI consent required: no active ConsentReceipt for the server-managed provider. `meta.errorCode` = `consent.ai.required`. Mint a receipt via POST /api/consent/ai before retrying.",
|
|
content: { "application/json": { schema: errorEnvelope } },
|
|
},
|
|
};
|
|
|
|
// ── Module-disabled gate (v1.18.0) ───────────────────────────────────
|
|
// Every module-scoped route runs `requireModuleEnabled(userId, key)`,
|
|
// which returns a 403 when the account has the module turned off — even
|
|
// with a valid Bearer token. The envelope carries
|
|
// `meta.errorCode = "module.disabled"` and `meta.module` (the disabled
|
|
// module key) so the iOS retry classifier branches on it and the client
|
|
// can drop the whole surface. The errorEnvelope shape already declares
|
|
// `meta.errorCode`; `meta.module` is documented here in prose.
|
|
export const moduleDisabledResponse = {
|
|
"403": {
|
|
description:
|
|
'Module disabled for this account: the user (or operator) has the module turned off. `meta.errorCode` = `module.disabled` and `meta.module` carries the disabled module key (e.g. "sleep"). Returned even for a valid Bearer token. Clients hide the whole module surface end-to-end rather than retry.',
|
|
content: { "application/json": { schema: errorEnvelope } },
|
|
},
|
|
};
|