Commit Graph
22 Commits
Author SHA1 Message Date
Marc-André Bombeck 7e54b6be81 refactor(integrations): split the provider clients into wire types, endpoints and mappers
Oura, Polar, WHOOP and Strava each keep the transport and endpoints in
`client.ts` and gain `wire-types.ts` (the record shapes) and `map.ts` (the
pure record-to-row mappers, no I/O). Google Health's 1,600-line client
becomes `budget.ts` (slice budget and outcome tally), `request.ts` (error
detail, retry, rate limits), `data-points.ts` (the paginated walker) and a
small `client.ts` for the profile and roll-up reads; its `export * from
"./mappers"` facade is gone and its importers name the module that defines
what they use.

The code moved verbatim; the mapper goldens written before the move and
the provider integration tests pass unchanged.
2026-10-23 13:30:10 +02:00
Marc-André Bombeck b6f45c0d88 refactor(integrations): move every provider OAuth client onto one factory
`oauth-kit/client.ts` builds a provider's consent URL, code exchange,
refresh and deauthorization from a declaration of what differs: endpoints
and scope, client authentication (form or HTTP Basic, with or without the
client id beside it), the fields a grant adds, how an answer is classified
and the refusal named, the error type, and where the token sits in the
body. The seven hand-written copies of the redirect URI, authorization URL,
token post, Basic header and PKCE pair are gone; each provider keeps an
`oauth.ts` with its declaration.

Shared behaviour is now uniform: a 2xx without a token object (an empty or
non-JSON body) is a transient `empty_token_body` failure for every
provider. WHOOP, Strava and Google Health used to return null, and
Withings threw an unclassified SyntaxError; the OAuth wire golden records
exactly these lines changing and nothing else. Oura's live/legacy endpoint
fallback, Google's invalid_grant lift, Strava's Fault summary and Fitbit's
errorType are declared per provider.
2026-10-23 12:57:47 +02:00
Marc-André Bombeck df6e182e0b test(integrations): pin what each provider mapper makes of sanitised wire fixtures 2026-10-23 10:56:03 +02:00
Marc-André Bombeck eda90eeb56 fix(strava): read a dead refresh token from Strava's own fault shape
Strava's token endpoint does not answer RFC 6749 errors. A refresh
token it no longer honours, after the athlete revoked the app or the
token was rotated away, is a 400 with the API's generic Fault:
{"message":"Bad Request","errors":[{"resource":"RefreshToken",
"field":"refresh_token","code":"invalid"}]}. The classifier looked for
invalid_grant, which never arrives, so the failure was booked
persistent and the connection never asked for a reconnect.

The token error now carries the fault reduced to its resource, field
and code (or an RFC error code, should one arrive), and the classifier
lifts a 400 naming RefreshToken to reauth_required, from the error and
from its message after a retry loses the prototype. Every other refusal
is classified by its status as before, including the faults that name
the Application.

Strava documents the Fault and Error model but not these values; the
body is the one several unrelated client libraries captured from live
traffic between 2023 and 2026.
2026-10-22 07:31:15 +02:00
Marc-André Bombeck 26df61f638 test(integration): drive the Strava sync against Postgres
The Strava writer had eight mocked test files and no test against a
database. strava-sync.test.ts runs the real sync behind a fake Strava API at
the safeFetch boundary: field mapping, the after= watermark, idempotent
re-sync on the (user, source, external id) key, in-place overwrites that keep
what only the detail read carries, a cursor that never moves backwards or
past a row that failed to write, the paged backfill with its resume point
and requeue, token refresh with both rotated tokens stored sealed, and the
integration ledger after transient and permanent failures. The sync, the
disconnect and the credential routes are covered through the kit.

The mocked cases that asserted call counts and mock ordering are removed;
the pure retry-after parsing stays a unit test.
2026-10-22 05:32:09 +02:00
Marc-André Bombeck a12174bcaa fix(strava): serialise the token refresh like the other providers
Strava rotates its refresh token on every use. The sync relied on a
compare-and-swap alone, so two overlapping runs could both spend the same
token; the refresh now runs under the per-user lock.
2026-10-21 19:13:35 +02:00
Marc-André Bombeck 23f6fe4c6e feat(integrations): add one token lifecycle for every OAuth provider
A provider describes where its grant lives and how its token endpoint is
called; the kit reads the grant, refreshes it under the per-user advisory
lock, re-reads under the lock so a peer's refresh or a reconnect wins, and
writes back only over the refresh token it spent. Retry-After is read in
one place, both forms, with a cap.
2026-10-21 19:13:16 +02:00
Marc-André Bombeck 3b1cdd7a1f fix(strava): keep a rate-limited run's progress and a summary-only row's calories
A 429 in the middle of the activity walk threw out of it before the
upsert, so everything the run had read was discarded and the next run
started over. The walk now ends where the limit hit: what was read
lands, the cursor moves only as far as an oldest-first walk proves,
the failure is booked transient, and the run reports the wait Strava's
X-RateLimit / X-ReadRateLimit headers name (next quarter hour, or
midnight UTC once the daily count is spent). The backfill waits that
long before its next run. `walkPages` learns an `interrupted` page for
this, reported truncated like a walk cut off at its ceiling.

An update mapped from the summary alone (detail budget spent, or the
detail read failed) wrote a null over the calories an earlier detail
read had stored, and over its heart rate and description too. Those
detail-only fields are now kept unless this run read the detail.
2026-10-21 12:51:19 +02:00
Marc-André Bombeck bd00845be3 fix(strava): clear the backfill markers when the connection is removed
Disconnecting Strava, or deleting its credentials, cleared the tokens
and the athlete but kept stravaBackfillCompletedAt and
stravaBackfillResumeBefore. A later connection inherited them: a
completed marker kept its history from being read, and a resume point
started its first walk below the previous account's oldest activity.
Both paths now clear one shared set of columns.
2026-10-20 11:02:50 +02:00
Marc-André Bombeck 34eea153a8 fix(strava): resume an unfinished backfill where the last run stopped
One run reads at most forty pages of a hundred activities. A longer history
ended as incomplete with no note of how far it got, so every boot read the
same newest pages again, spent up to 190 of the instance's 200 requests per
fifteen minutes on them, and never reached the older activities.

The run now records the start of the oldest activity it read and sends the
next run for after the rate window; that run asks Strava for the activities
before it. A run that gets no further records nothing and sends nothing. The
place is cleared when the backfill completes.
2026-10-20 09:33:15 +02:00
Marc-André Bombeck 93b3f36d96 fix(strava): walk activity pages through walkPages and gate the backfill on it
The activity loop stopped at its page ceiling and returned what it had read,
and the backfill stamped the account complete over it: a history longer than
forty pages was cut off for good. The walk now runs through walkPages, which
reports a walk cut off at its ceiling, and the backfill reads backfillVerdict
like the WHOOP and Fitbit backfills: a truncated walk leaves the marker unset
without a retry, and a connection waiting for re-authorisation returns
without walking.
2026-10-20 06:49:44 +02:00
Marc-André Bombeck e355c6120f fix(workouts): one canonical-workout picker with one source order
Three pickers decided which of two overlapping workouts to keep: the
read-time picker the list, the detail page and the Coach use, a
write-time batch picker with its own 90-second window, and an unwired
field-merge picker. They used different source orders (the `steps`
ladder, or a separate workout default when no order was saved), so the
batch route could keep a row the list then ranked below another.

The batch route now runs the read-time picker over the rows it is about
to insert, with the ladder from `workoutSourceLadder`, and reports what
it hides as duplicates. A session HealthKit hands over twice under two
ids is stored once, as the list already showed it. The write-time and
the unwired picker are removed.
2026-10-20 04:37:39 +02:00
Marc-André Bombeck cc60b82d84 refactor(integrations): read the OAuth redirect URIs through the env helpers
The seven integration clients now read their redirect URI and the app URL
through envValue/envOr instead of a local ?.trim() ||. The empty-env guard
drops its pending entry and floors its walk, and proves its matcher still
fires on a real client with the old read put back.
2026-09-26 17:08:47 +02:00
Marc-André Bombeck c366d4946c fix(integrations): treat an empty redirect URI variable as unset
The compose file passes every whitelisted variable through, so an unset
redirect URI reaches the process as an empty string. Withings then sent
redirect_uri= to the provider, and Fitbit and Google Health refused to start
the connection as not configured although NEXT_PUBLIC_APP_URL was set. Every
integration now treats an empty or blank value as unset and derives the
callback from the app URL.
2026-09-26 16:08:57 +02:00
Marc-André Bombeck 002d120b69 Reach every cached reader from the integration and context write paths
Integration syncs (Fitbit, Oura, Polar, WHOOP, Strava, Nightscout) now
mark the per-user analytics cells stale and sweep the workouts cache at
their sync tails — imported rows used to stay invisible to the cached
dashboard, analytics and workout readers until the TTL lapsed. The WHOOP
per-resource webhook/cron driver and the Polar workouts leg carry the
same call, matching the posture Google Health and the workout batch
route already had.

Illness, cycle and encounter write routes hard-evict the analytics
bucket through the new invalidateUserHealthContext helper: Rest Mode,
the cycle context and the Today-rail appointment feed all read from
cached cells under that bucket, and an interactive write must be true on
the very next read.
2026-08-13 18:33:46 +02:00
Marc-André Bombeck 8be7458c7a fix(sync): surface poll-cohort sync failures that previously vanished
Introduce a recorded-marker primitive on the integration ledger so a provider
that records-then-rethrows a sync failure is not recorded a second time at the
poll-cohort boundary, while an unrecorded escape (an Oura or Polar write-path
throw, or any future path) lands on the ledger exactly once, correctly
classified and redacted by the source that owns it. Without the marker a
boundary-blind recorder would double-count the failure streak and could persist
a Nightscout token-bearing URL into the stored error.

Give the poll-cohort factory a required per-provider failure recorder and wire
one for Oura, Polar, Nightscout, and Strava.

Nightscout now counts only freshly inserted readings instead of insert plus
re-confirm (the inflated count fired the reminder trigger every tick), records a
transient failure and skips the success stamp when some rows fail to write, and
emits the measurement-arrival spine for its inserts. Correct the docstring that
promised a far-history backfill that does not exist: the hourly window imports
about 48 hours and nothing deeper.

Strava now enqueues its history backfill at connect time, mirroring the WHOOP
and Fitbit callbacks, so deep history imports within the hour instead of waiting
for the next worker restart.
2026-07-24 20:05:23 +02:00
Marc-André Bombeck 2716f302c7 chore: align hardening tests and formatting 2026-07-20 12:24:15 +02:00
Marc-André Bombeck d31a33e14c fix(strava): preserve cursors on workout write failure 2026-07-20 11:08:50 +02:00
Marc-André Bombeck 4591899657 fix(release): harden v1.31 arrival contracts 2026-07-20 00:40:20 +02:00
Marc-André Bombeck 5d62236412 feat(arrivals): add the data-arrival event spine
Every ingest path in the product can now emit a typed, salience-classified
event into pg-boss, consumed by one worker that claims a durable per-day
reaction marker and fans out to the reaction surfaces.

The recency classifier runs at the seam, before anything is enqueued, so a
mass backfill — a multi-year Apple Health export, a provider re-sync, a
30-day catch-up — emits nothing at all. Non-salient writes are annotated and
dropped.

The worker makes no provider call by construction: it imports no provider
module, writes only markers, and enqueues nothing that is not budget-gated
downstream. Business refusals return a status rather than throwing, so a
refusal is never retried against a ceiling that will not move until the local
day rolls over.

Seams: sleep (one hook, inherited by all seven transports), workouts (the
batch route, the four provider syncs, the export importer), weight and blood
pressure (both measurement routes), labs (manual, OCR commit, document fact
commit).

The provider workout syncs could not previously tell a created row from an
updated one, so a re-sync of a recent session would have re-fired on every
poll; they now recover the distinction from the row the upsert already
returns.

Reaction lines are AES-256-GCM at rest, following the InsightNarrative
precedent, and the column is registered for key rotation. Markers are pruned
after 14 days by a daily maintenance job.
2026-07-19 13:17:34 +02:00
Marc-André Bombeck 4f7420905a fix(strava): normalise raw sport labels to canonical WorkoutSportType
Strava was the second workout integration, after WHOOP, that wrote its
raw vendor sport label ("Run", "Ride", "VirtualRide", ...) straight into
Workout.sportType instead of a canonical bucket. Every Strava row read
as generic, and the read-time picker treats two generic rows as
sport-compatible — a same-source brick session (Strava ride ending
~15:00, Strava run starting 15:03) could cluster into one and silently
drop a leg.

Add mapStravaSportType() (54 documented Strava sport_type values,
mirroring the WHOOP sport-map shape) and apply it at write time in
mapActivity(); the raw label stays in metadata.stravaType for
provenance. Backfill existing Strava rows via a hand-written migration
generated from the same table. Extend the cross-provider sport-map
parity test to cover Strava, and add a picker-level test proving a
back-to-back Ride+Run pair now survives as two rows instead of one.
2026-07-17 23:12:12 +02:00
Marc-André Bombeck 75eb0637e6 feat(strava): add per-user BYO-OAuth workout source + Garmin indirect-path docs
Add Strava as a per-user BYO-OAuth workout source, mirroring the shipped
WHOOP/Oura/Polar pattern. Strava caps every new app at single-player mode, so
each self-hoster registers their own app and pastes the client id/secret into
Settings (falling back to a shared env app). Strava is a workout source only —
it exposes no daily metrics and no training-depth analytics are derived.

- Schema (migration 0233): per-user encrypted client id/secret + access/refresh
  tokens, plaintext athlete id, incremental cursor, and backfill marker on
  `User`; `STRAVA` added to `MeasurementSource`.
- Token model follows Oura's reactive-refresh-on-401 with a compare-and-set on
  the stored refresh ciphertext, because Strava rotates its refresh token on
  every refresh.
- Client over safeFetch: authorization-code + refresh, activity list + budget-
  aware per-activity detail (for calories/HR), honouring the 200/15min cap; each
  activity maps to one `Workout` row. Activity name/description stay in metadata
  as data, never surfaced or interpolated.
- Dedup reuses the existing source-agnostic two-layer picker: `STRAVA` slots
  into the workout ladder below the device-native wearables and above
  MANUAL/IMPORT, so an Apple-Watch + Strava twin collapses to one canonical row.
- Poll-cohort hourly sync (:17) + self-converging boot backfill, wired into the
  integration-sync registrar. Webhook deferred.
- OAuth routes (connect/callback/credentials/disconnect/status/test) with signed
  state, cross-account re-auth rejection, and a surgical `www.strava.com`
  connect-src gated to the Strava surfaces.
- Settings card + consolidated status envelope entry; env + compose whitelist;
  i18n across all six locales.
- Garmin: no direct connector is possible (business-partner-only). Add
  docs/integrations/garmin.md documenting the Apple Health / Google Health
  Connect path and Garmin's withheld proprietary scores, cross-linked from both
  platform docs, plus a non-connector info note in the Connections panel.

Regenerate docs/api/openapi.yaml for the new `STRAVA` MeasurementSource value.
2026-07-09 21:25:25 +02:00