Commit Graph
5 Commits
Author SHA1 Message Date
Marc-André Bombeck f3ac9942ee fix(whoop): don't stamp sync success on an all-403 grant-revoke cycle
After the graceful collection-403 soft-skip, a genuine grant revoke 403s every
data class: each resource soft-skips (returns 0, records no failure), so the
orchestrator sees no error and stamps recordSyncSuccess — leaving the dead
connection looking healthy until the token-refresh path catches the 401 up to
~1 h later. Track the soft-skip count across the resource loop via an
AsyncLocalStorage tracker scoped to the cycle; when every resource soft-skipped
and nothing imported, leave the status as-is instead of stamping success. A
partial cycle (any import, or any resource that did not soft-skip) stamps as
normal.
2026-06-04 18:38:14 +02:00
Marc-André Bombeck 1a88a3e6cf refactor(whoop): single-source the collection-403 soft-skip handling
Extract handleCollectionFetchError so the five resource syncs share one
soft-skip-vs-rethrow path instead of five copies. Behaviour is unchanged:
a per-resource 403 returns 0 and keeps the connection connected, anything
else records a classified failure and rethrows.
2026-06-04 15:57:14 +02:00
Marc-André Bombeck 0458c9aea8 feat(whoop): ingest body measurement — weight, max-HR, profile height
The body-measurement endpoint was fetched by a function nobody called, so WHOOP
weight, max heart rate, and height never reached the database despite being
documented in the mapping table.

Add sync-body.ts and wire it into the per-user sync loop (and, through it, the
backfill):

- weight_kilogram → a WEIGHT Measurement (source = WHOOP) on the stable
  externalId whoop:body:weight with overwrite semantics. A body measurement is
  a single self-reported profile value, not a time series, so a re-sync updates
  the same row in place rather than accumulating one row per poll. measuredAt is
  the fetch time; the source-priority picker ranks a real scale above WHOOP.
- max_heart_rate → WhoopConnection.maxHeartRate (a profile constant).
- height_meter → User.heightCm (m→cm), written ONLY when the user has no height
  yet — a user-set height is never overwritten and height is never minted as a
  Measurement.

Every write is field-by-field and idempotent across reruns. Document the body
mapping and the watch-only blood-pressure stance in mapping.md.
2026-06-04 15:36:06 +02:00
Marc-André Bombeck c8e8499472 fix(whoop): degrade a per-resource 403 gracefully instead of parking the connection
A 403 on a single WHOOP collection endpoint was classified reauth_required,
which parked the whole connection at error_reauth and short-circuited every
future sync. A lower-tier user missing one gated data class would brick the
entire integration.

Treat a per-resource collection 403 as a soft skip of that data class: log,
return 0, and continue so sibling resources still sync. Reserve connection-wide
reauth for a 401 (token rejected) and for a 403 on the token-refresh / profile
path, which run outside the per-resource catch blocks and remain unchanged.

Add isCollectionForbidden as the shared gate and cover both branches in tests.
2026-06-04 15:36:06 +02:00
Marc-André Bombeck 337f024a87 feat(whoop): pg-boss sync workers + self-converging backfill
Build the WHOOP sync layer on the W2 client, mirroring the Withings
queues. A per-resource sync (recovery/sleep/cycle/workout) pulls since
the last cursor with a 24h overlap on recovery/sleep to absorb WHOOP's
after-the-fact re-scoring, maps via the W2 mappers, and upserts
Measurements with source=WHOOP keyed on (userId,type,source,externalId)
so a re-post overwrites in place. Workouts upsert into Workout with
per-workout strain + HR zones in metadata and kJ->kcal energy.

Token refresh persists BOTH rotated tokens (WHOOP invalidates the prior
access and refresh token on every refresh). A self-converging boot
backfill enqueues one full-history sync per un-backfilled connection and
stamps backfillCompletedAt; the discovery query drops completed accounts,
idempotent across reboots.

Every new queue is registered in the worker's allQueues + cron schedule
(recovery/sleep/workout poll catch-all, cycle poll-only, backfill,
oauth-state sweep). IntegrationStatus gains the whoop key + label.

Tests: rotating-refresh persistence, idempotent measurement upsert,
incremental-window overlap, backfill self-convergence, and a
queue-registration inventory guard.
2026-06-04 01:21:42 +02:00