mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 13:22:14 +00:00
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.
This commit is contained in:
1 parent
b7074e04b8
commit
02c6658a35
2 files changed
+586
-1
No files matched your search
@@ -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:
|
||||
|
||||
@@ -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<ZodOpenApiObject["paths"]> = {
|
||||
"/api/integrations/healthkit": {
|
||||
get: {
|
||||
@@ -190,4 +295,131 @@ export const integrationPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
},
|
||||
},
|
||||
},
|
||||
"/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,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
Reference in new issue
Block a user