Commit Graph
12 Commits
Author SHA1 Message Date
Marc-André Bombeck 9ce0bd5027 fix(api): name the refused fields on every shape rejection
Fifty-nine 422s raised off a Zod `safeParse` answered with one opaque
sentence and nothing a client could map onto a field, while the other
166 sent `details.issues` — the multi-issue envelope that exists so a
caller with three bad fields fixes all three in one round-trip. Twelve
of the fifty-nine put a dotted machine token in `error` where the
sentence belongs, so `error` was sometimes text to show a person and
sometimes a token that must not be shown, with nothing in the payload
to tell them apart.

Every one of them now answers through `apiValidationError`, which keeps
the existing `error` string and the existing `meta` byte for byte and
adds the issue list beside them. The dotted tokens keep their place in
`error` for the clients that already match on them and are published in
`meta.errorCode` as well, which is the field a machine code belongs in
and the one a new client should read.

A guard reads the route tree per refusal site rather than per file —
per file is what let this grow, since a route that used the helper once
looked compliant to a `grep -l` sweep — and asserts it still finds the
sites it judges, so a matcher that stops matching fails instead of
reporting a clean zero.
2026-09-10 07:19:55 +02:00
Marc-André Bombeck b6e34d1264 Close five honesty gaps on the integration surface
Each of these made the server report something that was not so, and each now
has a test that fails without the fix.

The Withings credential DELETE wrote no audit row and left the ledger at
whatever state it last held. Fitbit, Google Health and WHOOP all park theirs,
and three of them say in their own comments that they do it for parity with a
list that never included Withings. A client reading `state` after the teardown
was told `connected` about a connection that no longer existed.

Oura's and Strava's credential DELETEs audited only when an access token
happened to be present, so removing a stored pair that had never completed
OAuth left no trail at all. Polar's twin has always done both unconditionally;
they match it now. The old Oura test asserted the skip, which is why the gap
survived a rewrite — that expectation is inverted here, with the reason next to
it.

The Withings and WHOOP manual syncs had no rate limiter, while `fullSync` walks
full history against a per-user provider budget. The Fitbit route's own comment
records that it was the outlier until it got one; these two were never brought
along. Both now carry the same 5/60s baseline and 1/hour full-sync buckets as
their siblings.

Every sync route parsed its body inside a catch commented "no body provided",
which was true for one of the three ways to reach it. An absent body still
means an incremental run — that is the documented shape and the web cards send
it. A body that is present and unparseable is now 400, and one that fails the
schema is 422: `{"fullSync": "true"}` used to be read as false and answered 200,
so a client that asked for full history and mistyped the value was told the run
it never requested had succeeded. Unknown keys are still ignored. The four
copies collapse into one helper.

The consolidated integration status swallowed a failed freshness query into an
empty array, which is exactly what a record with nothing in it also produces —
so the signal built to say "this metric has gone quiet" answered "nothing has
gone quiet" when it had failed to look. `metricFreshnessDegraded` separates the
two, as one flag rather than one per entry, because it is a single query for all
eight providers.

The contract follows the behaviour in the same commit.
2026-08-22 13:13:18 +02:00
Marc-André Bombeck 530f78dcf5 fix(sync): answer with what a run wrote, not that it completed
Six provider syncs returned a bare count, which narrowed their failures away:
a Nightscout window in which every SGV row was refused by a constraint
returned the same 0 as a quiet night, and an Oura or Polar run whose
collections all failed returned 0 too. The routes then answered 200 with a
count and the settings card drew a green tick over it.

Every sync now reports what it imported AND whether any part of it did not
land, and the route resolves the outcome server-side so the web card and the
native client cannot disagree. Google Health keeps its 502 for a run that
failed and wrote nothing, but a run that failed after writing some resources
is now a partial rather than a discarded result. The labs OCR commit carries
the same resolved outcome.
2026-07-27 22:53:43 +02:00
Marc-André Bombeck 4e3b029c38 test(integrations): pin the escalation ladder, leg-scoped clearing, and what the card says
Mutation-checked against the production paths they cover: the escalation
verdict, the leg guard in `recordSyncSuccess`, the streak anchor the verdict
dates itself by, the status envelope threading it, and the pill's relative
timestamp.

Also give the four legacy per-provider status routes the verdict the
consolidated envelope already carries. They answered `connected: true` plus a
`state` string and nothing else, so a client reading one of them saw a live
connection over a pipe that had delivered nothing for weeks.
2026-07-26 14:19:13 +02:00
Marc-André Bombeck a9f1629f82 feat(integrations): add manual sync for Oura, Polar, Strava and Nightscout
These four were the only connected providers with no way to ask for a
sync; they ran on their scheduled pass and nothing else. Each gets a POST
sync route on the hardened Fitbit and Google Health template: session auth,
a 5-per-minute per-user bucket, and a fixed 502 on failure. No body and no
full-sync flag, because none of the four entry points has a full-history
arm to call. Polar goes through the shared legs entry point so both legs
run with the attribution rules built for them.

The Nightscout error arm is the one that matters: a base URL carries the
API token as a query parameter, so the caught message can hold the user's
secret and never reaches the response body. The classified failure is
already on the ledger, recorded with the provider's own redaction.

Connecting also starts the first sync now. The poll handler has accepted a
single-user payload since it was written and never had a producer, so a
fresh connection sat empty for up to an hour waiting for the schedule. The
Oura and Polar callbacks and the Nightscout connect route enqueue one
targeted job each, best effort, with the hourly tick still the safety net.
Strava needs none: its callback already enqueues the history backfill,
which covers the recent window.

The four poll-queue names move into their own module so a request-path
caller can name a queue without dragging the whole worker handler graph
into the request bundle, following the backfill-queue precedent. Creation,
scheduling and handler binding stay in the registrar, and the dead-queue
guard follows the declarations.
2026-07-25 04:43:06 +02:00
Marc-André Bombeck fb73c97125 fix(onboarding): stop the Withings connect dead-end and disclose BYO setup
The onboarding "Connect Withings" tap opened /api/withings/connect in a new
tab, which returned a raw JSON 400 for every brand-new account (Withings
credentials are per-user BYO with no env fallback, and the wizard never
collects them). WHOOP, Fitbit, Polar, Oura, and Strava shared the identical
raw-JSON-on-no-creds pattern on their own /connect routes, while every other
failure branch already redirected with a toast reason. All six now redirect
to /settings/integrations?<provider>=error&reason=nocreds instead, and the
wizard's Withings card checks the integrations-status envelope first: unless
credentials are already configured, the CTA reads "Set up in Settings" and
links straight to the credentials form rather than launching a doomed OAuth
handshake.

Also surfaces the BYO-developer-app requirement, which was invisible until a
user was deep inside a Settings card. The source-picker step now says
up front that device sources need a free developer app on the user's own
account; each of the six provider cards gets a compact 3-step guide with the
exact callback URL to register, shown until the user has saved their own
credentials.
2026-07-17 23:12:12 +02:00
Marc-André Bombeck 1f4ba5939d style: apply prettier across the tree
One-shot prettier pass so the new format:check gate starts from a clean
baseline. Formatting only — no behaviour change. Generator-owned and
vendored artifacts stay untouched via .prettierignore.
2026-06-19 22:20:31 +02:00
Marc-André Bombeck a0662d1b20 fix(oura): audit credential deletes and trim integration vestiges
Deleting a user's stored Oura BYO credentials drops a live connection (a
token minted against the deleted app is orphaned), but the credentials
DELETE route skipped the auditLog + markDisconnected the disconnect route
performs, leaving the integration ledger parked at its last state with no
audit trail. Mirror the disconnect route when a token was present.

Drop two unused classifier type aliases and unexport the internal
invalid-grant helper, and refresh the stale 'full OAuth from env' headers on
the connect route and the env-credentials helper to the v1.17.1 DB-first
framing. Add the missing route tests for the BYO surface this release
touched: credentials GET/PUT/DELETE (422 reject, encrypt-on-write, the
audit/ledger branch) and the callback CSRF / cross-user / nocreds / success
paths.
2026-06-15 00:09:56 +02:00
Marc-André Bombeck 38c7604ce4 feat(integrations): per-user Polar and Oura OAuth credentials
Polar and Oura resolved their OAuth client id/secret from the server
environment only, forcing every user on a deploy to share one app. Both
now read per-user BYO keys first, falling back to the shared env app when
unset, mirroring the WHOOP and Fitbit model.

- Add DB-first-then-env resolvers in src/lib/{polar,oura}/credentials.ts,
  encrypting the client id/secret at rest on User.
- Repoint the connect, callback, status routes and the Oura refresh path
  to the user-scoped resolver.
- Add /api/{polar,oura}/credentials (GET/PUT/DELETE) with Zod-validated
  input and userId narrowed from the session.
- Render an optional 'your OAuth app credentials' form on the Polar and
  Oura cards; the status read exposes hasOwnCredentials for the saved
  placeholder. Existing env-only deploys keep working unchanged.
- Cover both resolvers (DB hit, env fallback, no creds) and the card form
  with unit tests; add the new strings across all six locales.
2026-06-14 23:19:47 +02:00
Marc-André Bombeck e5fad221a0 feat(integrations): add test-connection endpoints for Nightscout, Polar, Oura 2026-06-14 21:11:09 +02:00
Marc-André Bombeck c2d6772b03 Rate-limit the Polar and Oura disconnect routes
Both disconnect routes ran unbounded, unlike nightscout/disconnect and the
connect routes. Add the same per-user 20/60s checkRateLimit guard after
requireAuth so the surface returns 429 on bucket exhaustion.
2026-06-14 20:58:37 +02:00
Marc-André Bombeck 88139fb981 feat(integrations): add Oura Cloud v2 OAuth sync
Connect an Oura account over the Cloud v2 API: an authorization-code OAuth
flow (shared app from OURA_CLIENT_ID / OURA_CLIENT_SECRET) on the same signed
state machinery as Polar. Unlike Polar, Oura issues refresh tokens; the sync
refreshes reactively on a 401, rotating and re-encrypting both tokens, and a
403 stays a hard reauth rather than a refresh.

An hourly poll-cohort job maps daily readiness to RECOVERY_SCORE, sleep to
per-stage durations plus efficiency / HRV / resting heart rate / respiratory
rate, and daily activity to steps and active energy — all tagged source OURA,
idempotent on (userId, type, source, externalId). Readiness flows through the
canonical recovery ladder above the computed proxy. Settings gains a connect /
disconnect card. Tokens are never logged or returned; every egress goes
through safeFetch.
2026-06-14 20:10:35 +02:00