From 02c6658a355d981547b98d1fe6b676d9e6f78058 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 30 Jul 2026 19:57:02 +0200 Subject: [PATCH] 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. --- docs/api/openapi.yaml | 353 +++++++++++++++++++++++++ src/lib/openapi/routes/integrations.ts | 234 +++++++++++++++- 2 files changed, 586 insertions(+), 1 deletion(-) diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index f9a3ce047..c2a4e6153 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -11889,6 +11889,134 @@ paths: "401": *a1 "422": *a3 "429": *a4 + /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: + $ref: "#/components/schemas/OuraSyncEnvelope" + "401": *a1 + "422": *a3 + "429": *a4 + "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: + $ref: "#/components/schemas/ErrorEnvelope" + /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: + $ref: "#/components/schemas/PolarSyncEnvelope" + "401": *a1 + "422": *a3 + "429": *a4 + "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: + $ref: "#/components/schemas/ErrorEnvelope" + /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: + $ref: "#/components/schemas/StravaSyncEnvelope" + "401": *a1 + "422": *a3 + "429": *a4 + "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: + $ref: "#/components/schemas/ErrorEnvelope" + /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: + $ref: "#/components/schemas/NightscoutSyncEnvelope" + "401": *a1 + "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: + $ref: "#/components/schemas/ErrorEnvelope" + "429": *a4 + "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: + $ref: "#/components/schemas/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: + $ref: "#/components/schemas/GoogleHealthSyncStatusEnvelope" + "401": *a1 + "422": *a3 + "429": *a4 /api/daily/digest: get: tags: @@ -33971,6 +34099,231 @@ components: - data - error additionalProperties: false + OuraSyncEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/ProviderSyncOutcome" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + ProviderSyncOutcome: + type: object + properties: + imported: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + description: Rows that reached the database this run. + failed: + type: boolean + description: True when any part of the run did not land — a row, a collection, a leg. + outcome: + type: string + enum: + - empty + - failed + - partial + - success + description: "`empty` — nothing new, nothing refused. `failed` — nothing written, something refused. `partial` — some + written, some refused. `success` — everything written." + required: + - imported + - failed + - outcome + additionalProperties: false + 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. + PolarSyncEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/ProviderSyncOutcome" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + StravaSyncEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/ProviderSyncOutcome" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + NightscoutSyncEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/ProviderSyncOutcome" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + GoogleHealthSyncStatusEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/GoogleHealthSyncStatusResponse" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + GoogleHealthSyncStatusResponse: + anyOf: + - type: object + properties: + runId: + type: string + description: Truncated to 128 chars. + state: + type: string + enum: + - in_progress + - complete + - partial + - zero + - truncated + - failed + - interrupted + description: Falls back to `failed` when the stored value is not one of this closed set. + startedAt: + type: string + description: ISO instant, or empty string when unset. + updatedAt: + type: string + description: ISO instant, or empty string when unset. + terminalAt: + description: ISO instant the run reached a terminal state. Present only once the run has finished; absent while `state` + is `in_progress`. + type: string + imported: + type: integer + minimum: 0 + maximum: 9007199254740991 + failed: + type: boolean + resources: + maxItems: 16 + type: array + items: + $ref: "#/components/schemas/GoogleHealthResourceOutcome" + description: Per-collection outcome for this run, capped at 16 entries. + required: + - runId + - state + - startedAt + - updatedAt + - imported + - failed + - resources + additionalProperties: false + - type: "null" + 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). + GoogleHealthResourceOutcome: + type: object + properties: + resource: + type: string + description: 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: + type: integer + minimum: 0 + maximum: 9007199254740991 + fetched: + type: integer + minimum: 0 + maximum: 9007199254740991 + mapped: + type: integer + minimum: 0 + maximum: 9007199254740991 + written: + type: integer + minimum: 0 + maximum: 9007199254740991 + status: + type: string + enum: + - pending + - complete + - partial + - empty + - truncated + - failed + description: Falls back to `failed` when the stored value is not one of this closed set. + durationMs: + type: integer + minimum: 0 + maximum: 9007199254740991 + truncated: + type: boolean + reasonCode: + anyOf: + - type: string + enum: + - collection_failed + - token_failed + - upsert_failed + - rollup_failed + - existing_page_limit + - type: "null" + description: Null when the stored value is not one of this closed set, or when the resource did not fail. + required: + - resource + - pages + - fetched + - mapped + - written + - status + - durationMs + - truncated + - reasonCode + additionalProperties: false DailyDigestEnvelope: type: object properties: diff --git a/src/lib/openapi/routes/integrations.ts b/src/lib/openapi/routes/integrations.ts index 2453dbc21..5318d9d57 100644 --- a/src/lib/openapi/routes/integrations.ts +++ b/src/lib/openapi/routes/integrations.ts @@ -8,7 +8,7 @@ */ import { z } from "zod/v4"; import type { ZodOpenApiObject } from "zod-openapi"; -import { dataEnvelope, stdResponses } from "./shared"; +import { dataEnvelope, errorEnvelope, stdResponses } from "./shared"; // Mirror the route's `directionEnum` — per-metric sync direction. const healthKitDirectionEnum = z @@ -141,6 +141,111 @@ const healthKitPatchRequest = z "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 = { "/api/integrations/healthkit": { get: { @@ -190,4 +295,131 @@ export const integrationPaths: NonNullable = { }, }, }, + "/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, + }, + }, + }, };