Files
HealthLog/src/lib/openapi/routes/integrations.ts
T
Marc-André Bombeck 02c6658a35 docs(openapi): add on-demand provider sync and Google Health status routes
Documents POST /api/oura/sync, /api/polar/sync, /api/strava/sync,
/api/nightscout/sync (all four share the resolveSyncOutcome shape) and
GET /api/google-health/sync/status, matching the routes' own
sanitising/clamping logic field for field.
2026-07-30 20:03:26 +02:00

426 lines
16 KiB
TypeScript

/**
* OpenAPI route table — third-party integration config (HealthKit).
*
* Part of the OpenAPI route table; aggregated in `./index.ts`.
* Response DTOs are declared here mirroring the route handler under
* `src/app/api/integrations/healthkit/route.ts`; the request schema
* mirrors the handler's `patchSchema`.
*/
import { z } from "zod/v4";
import type { ZodOpenApiObject } from "zod-openapi";
import { dataEnvelope, errorEnvelope, stdResponses } from "./shared";
// Mirror the route's `directionEnum` — per-metric sync direction.
const healthKitDirectionEnum = z
.enum(["bidirectional", "readOnly", "writeOnly", "disabled"])
.describe("Per-metric sync direction.");
// Mirror the batch route's `syncTriggerEnum` — what woke the client for a sync.
const healthKitSyncTriggerEnum = z
.enum(["foreground", "background", "push"])
.describe("What triggered a HealthKit batch.");
// Resolved entry (defaults merged): `kind` + `enabled` are always present.
const healthKitEntry = z
.object({
id: z.string().describe("Stable metric key (e.g. `bodyMass`)."),
kind: z.string().describe("HealthKit sample kind (e.g. `bloodPressure`)."),
direction: healthKitDirectionEnum,
enabled: z.boolean(),
})
.meta({
id: "HealthKitEntry",
description:
"One resolved HealthKit metric mapping (defaults merged with the user's stored overrides).",
});
// Mirrors `src/lib/integrations/sync-verdict.ts` — the server-resolved liveness
// verdict. Apple Health is push-based, so only the data-age arms apply.
const syncHealth = z
.object({
verdict: z
.enum([
"fresh",
"stale",
"stalled",
"failing",
"reauth_required",
"parked",
"pending_first_sync",
"disconnected",
])
.describe(
"Liveness verdict. For Apple Health: `fresh` when data arrived within the last 7 days, `stale` when it did not, `pending_first_sync` when none ever arrived.",
),
since: z.iso
.datetime({ offset: true })
.nullable()
.describe("The instant that triggered the verdict; null when none did."),
})
.meta({
id: "SyncHealth",
description:
"Server-resolved sync-health verdict. The single source of liveness truth — `lastSyncedAt` alone cannot express 'this pipe stopped delivering'.",
});
const metricFreshnessEntry = z
.object({
type: z
.string()
.describe(
"The measurement type (e.g. `RESPIRATORY_RATE`), or `WORKOUTS` for the workout leg.",
),
lastSeenAt: z.iso
.datetime({ offset: true })
.describe("Newest recorded reading for this type from this source."),
stale: z
.boolean()
.describe(
"This type has gone quiet while the source around it reads healthy — the dead-pipe signature (e.g. a single revoked HealthKit permission). Only ever true when the verdict is `fresh`.",
),
})
.meta({
id: "MetricFreshnessEntry",
description:
"Per-metric-type last-seen timestamp with the server-computed staleness flag. Only types that have actually delivered appear — absence is absence, never an invented row.",
});
const healthKitConfigResponse = z
.object({
entries: z.array(healthKitEntry),
lastSyncedAt: z.iso
.datetime({ offset: true })
.nullable()
.describe(
"When HealthKit last synced for this user; null when never (and always null on the PATCH echo).",
),
lastSyncTrigger: healthKitSyncTriggerEnum
.nullable()
.optional()
.describe(
"What the client declared triggered the most recent accepted batch. Null when the client sent no trigger — an older build, or a batch that predates the field. Present on the GET read; omitted from the PATCH echo.",
),
lastBackgroundSyncAt: z.iso
.datetime({ offset: true })
.nullable()
.optional()
.describe(
"When a batch last arrived without the app being opened (a `background` or `push` trigger). Null when that has never happened. `lastSyncedAt` alone cannot separate a phone that delivers on its own from one that only delivers while the app is open; this field is what does. Present on the GET read; omitted from the PATCH echo.",
),
syncHealth: syncHealth
.optional()
.describe("Present on the GET read; omitted from the PATCH echo."),
metricFreshness: z
.array(metricFreshnessEntry)
.optional()
.describe(
"Per-metric-type freshness for the `APPLE_HEALTH` source. Present on the GET read; omitted from the PATCH echo.",
),
})
.meta({
id: "HealthKitConfigResponse",
description:
"The resolved HealthKit integration config: the default metric set merged with the user's stored per-metric overrides, plus the sync-health verdict and per-metric freshness.",
});
// Mirror the route's `patchSchema` — merge-by-id; unknown ids are ignored.
const healthKitPatchEntry = z.object({
id: z.string().min(1).max(64),
kind: z.string().min(1).max(64).optional(),
direction: healthKitDirectionEnum,
enabled: z.boolean().optional(),
});
const healthKitPatchRequest = z
.object({
entries: z.array(healthKitPatchEntry).max(50),
})
.meta({
id: "HealthKitConfigPatchRequest",
description:
"Merge-by-`id` update of the HealthKit metric config. Unknown ids are silently ignored; omitted fields fall back to the stored (or default) value for that entry. Up to 50 entries per call.",
});
// v1.32.28 — on-demand provider sync trigger. Identical response shape
// across Oura / Polar / Strava / Nightscout (`resolveSyncOutcome`), so a
// client renders one outcome card for all four. `outcome` is the
// server-resolved tone, never derived from the HTTP status alone: 200 is the
// transport succeeding, not the write — a run where every row was refused
// still answers 200 with `outcome: "failed"`.
const providerSyncOutcome = z
.object({
imported: z
.number()
.int()
.describe("Rows that reached the database this run."),
failed: z
.boolean()
.describe(
"True when any part of the run did not land — a row, a collection, a leg.",
),
outcome: z
.enum(["empty", "failed", "partial", "success"])
.describe(
"`empty` — nothing new, nothing refused. `failed` — nothing written, something refused. `partial` — some written, some refused. `success` — everything written.",
),
})
.meta({
id: "ProviderSyncOutcome",
description:
"The result of an on-demand provider sync trigger. No request body; the sync window is derived server-side from the newest row already held, never a client-supplied lookback.",
});
// v1.32.x — Google Health background sync progress. Mirrors
// `GoogleHealthResourceOutcome` / the route's `resource()` sanitiser
// (`src/app/api/google-health/sync/status/route.ts`): every field is
// re-clamped/re-checked against a closed enum on read, so this documents
// the SANITISED wire shape, not the raw stored blob.
const googleHealthResourceOutcome = z
.object({
resource: z
.string()
.describe(
"Resource collection name, lower-cased and slug-sanitised (`[^a-z0-9-]` stripped), capped at 48 chars. `unknown` when the stored value was not a string.",
),
pages: z.number().int().min(0),
fetched: z.number().int().min(0),
mapped: z.number().int().min(0),
written: z.number().int().min(0),
status: z
.enum(["pending", "complete", "partial", "empty", "truncated", "failed"])
.describe(
"Falls back to `failed` when the stored value is not one of this closed set.",
),
durationMs: z.number().int().min(0),
truncated: z.boolean(),
reasonCode: z
.enum([
"collection_failed",
"token_failed",
"upsert_failed",
"rollup_failed",
"existing_page_limit",
])
.nullable()
.describe(
"Null when the stored value is not one of this closed set, or when the resource did not fail.",
),
})
.meta({ id: "GoogleHealthResourceOutcome" });
const googleHealthSyncStatusResponse = z
.object({
runId: z.string().describe("Truncated to 128 chars."),
state: z
.enum([
"in_progress",
"complete",
"partial",
"zero",
"truncated",
"failed",
"interrupted",
])
.describe(
"Falls back to `failed` when the stored value is not one of this closed set.",
),
startedAt: z.string().describe("ISO instant, or empty string when unset."),
updatedAt: z.string().describe("ISO instant, or empty string when unset."),
terminalAt: z
.string()
.optional()
.describe(
"ISO instant the run reached a terminal state. Present only once the run has finished; absent while `state` is `in_progress`.",
),
imported: z.number().int().min(0),
failed: z.boolean(),
resources: z
.array(googleHealthResourceOutcome)
.max(16)
.describe("Per-collection outcome for this run, capped at 16 entries."),
})
.nullable()
.meta({
id: "GoogleHealthSyncStatusResponse",
description:
"The caller's current/most-recent Google Health sync run, or null when none has ever run. `data` carries this shape directly (not nested under a named key).",
});
export const integrationPaths: NonNullable<ZodOpenApiObject["paths"]> = {
"/api/integrations/healthkit": {
get: {
tags: ["Integrations"],
summary: "Read the HealthKit integration config",
description:
"Returns the resolved per-metric HealthKit config — the default metric set merged with the user's stored overrides — plus the last HealthKit sync instant. Auth via cookie or Bearer.",
responses: {
"200": {
description: "Resolved HealthKit config.",
content: {
"application/json": {
schema: dataEnvelope(
healthKitConfigResponse,
"HealthKitConfigEnvelope",
),
},
},
},
...stdResponses,
},
},
patch: {
tags: ["Integrations"],
summary: "Update the HealthKit integration config",
description:
"Merges the supplied entries into the stored config by `id` (unknown ids are ignored) and returns the resolved config (defaults merged) so the client always sees a complete metric list. `lastSyncedAt` is null on the echo. Auth via cookie or Bearer.",
requestBody: {
required: true,
content: {
"application/json": { schema: healthKitPatchRequest },
},
},
responses: {
"200": {
description: "Updated + resolved HealthKit config.",
content: {
"application/json": {
schema: dataEnvelope(
healthKitConfigResponse,
"HealthKitConfigPatchEnvelope",
),
},
},
},
...stdResponses,
},
},
},
"/api/oura/sync": {
post: {
tags: ["Integrations"],
summary: "Trigger an on-demand Oura sync",
description:
"Runs the same sync entry point the hourly cron uses. No body: the window is derived from the newest row already held, not a client-supplied lookback. Rate-limited 5 requests / 60s per user.",
responses: {
"200": {
description: "Sync run complete.",
content: {
"application/json": {
schema: dataEnvelope(providerSyncOutcome, "OuraSyncEnvelope"),
},
},
},
"502": {
description:
"The sync failed upstream. The classified failure is already recorded on the `oura` ledger; the response body carries no upstream detail.",
content: { "application/json": { schema: errorEnvelope } },
},
...stdResponses,
},
},
},
"/api/polar/sync": {
post: {
tags: ["Integrations"],
summary: "Trigger an on-demand Polar sync",
description:
"Runs both Polar legs (vitals then workouts) through the same entry point the hourly poll uses, each with its own failure recording. No body: there is no full-history arm to call. Rate-limited 5 requests / 60s per user.",
responses: {
"200": {
description: "Sync run complete.",
content: {
"application/json": {
schema: dataEnvelope(providerSyncOutcome, "PolarSyncEnvelope"),
},
},
},
"502": {
description:
"The sync failed upstream. The classified failure is already recorded on the `polar` ledger; the response body carries no upstream detail.",
content: { "application/json": { schema: errorEnvelope } },
},
...stdResponses,
},
},
},
"/api/strava/sync": {
post: {
tags: ["Integrations"],
summary: "Trigger an on-demand Strava sync",
description:
"Incremental from the stored Strava cursor (`stravaLastActivityAt` minus an overlap) — a manual run picks up wherever the last one stopped. No body and no full-history arm; deep history belongs to the connect-time backfill queue. `failed` is always `false` on a 200: the underlying walk rethrows on the first row error rather than settling partially, so reaching a 200 means every fetched activity was written. Rate-limited 5 requests / 60s per user.",
responses: {
"200": {
description: "Sync run complete.",
content: {
"application/json": {
schema: dataEnvelope(providerSyncOutcome, "StravaSyncEnvelope"),
},
},
},
"502": {
description:
"The sync failed upstream. The classified failure is already recorded on the `strava` ledger; the response body carries no upstream detail.",
content: { "application/json": { schema: errorEnvelope } },
},
...stdResponses,
},
},
},
"/api/nightscout/sync": {
post: {
tags: ["Integrations"],
summary: "Trigger an on-demand Nightscout sync",
description:
"Pulls the recent SGV window from the configured Nightscout instance. No body: there is no full-history arm to call. The error body is always a fixed message, never the caught error's own — a Nightscout base URL carries its API token as a query parameter, so an upstream message could leak it. Rate-limited 5 requests / 60s per user.",
responses: {
"200": {
description: "Sync run complete.",
content: {
"application/json": {
schema: dataEnvelope(
providerSyncOutcome,
"NightscoutSyncEnvelope",
),
},
},
},
"502": {
description:
"The sync failed upstream. The classified failure is already recorded on the `nightscout` ledger; the response body carries no upstream detail.",
content: { "application/json": { schema: errorEnvelope } },
},
...stdResponses,
"422": {
description:
"The configured Nightscout origin is private (RFC 1918 / loopback / link-local) and the server operator has not approved private-origin access for this deployment.",
content: { "application/json": { schema: errorEnvelope } },
},
},
},
},
"/api/google-health/sync/status": {
get: {
tags: ["Integrations"],
summary: "Read the caller's Google Health sync progress",
description:
'Returns only the authenticated user\'s own bounded current/most-recent sync run; there is no way to address another run or another connection\'s progress by query parameter (query parameters are ignored by construction). Every field is re-clamped and re-checked against its closed enum on read, so a malformed or legacy stored blob degrades to safe defaults (e.g. `state: "failed"`, `resource: "unknown"`) rather than reaching the client verbatim. Auth via cookie or Bearer.',
responses: {
"200": {
description:
"The current/most-recent run, or `data: null` when none has ever run.",
content: {
"application/json": {
schema: dataEnvelope(
googleHealthSyncStatusResponse,
"GoogleHealthSyncStatusEnvelope",
),
},
},
},
...stdResponses,
},
},
},
};