feat(fitbit): add the Fitbit connection data model and FITBIT measurement source

Lay the data-model foundation for the Fitbit/Pixel integration over the
Google Health API, mirroring the WHOOP provider. No OAuth, sync, or UI yet.

- Prisma: FitbitConnection (1:1 per user, encrypted access/refresh tokens)
  and FitbitOAuthState models, two encrypted BYO-credential columns on User,
  and FITBIT appended to the MeasurementSource enum. Migration
  0117_v1120_fitbit_integration is additive only — one enum value plus two
  tables and two nullable columns, no new MeasurementType (every launch
  metric maps onto an existing type).
- Wire the two new encrypted columns and the FitbitConnection token columns
  into scripts/rotate-encryption-key.ts.
- FITBIT touchpoints: the read measurementSourceEnum (not the writable set),
  the DEFAULT_SOURCE_PRIORITY and workout-source ladders, the sources-section
  label map, the IntegrationKey union plus its display name, and a
  fitbit()/fitbitStatus() query-key pair. The source-rank SQL and read-tier
  collapse pick FITBIT up from the ladders with no code change.
- Add the FITBIT source label to all six locale bundles and regenerate the
  OpenAPI contract so the source enum carries FITBIT for the iOS decoder.
This commit is contained in:
Marc-André Bombeck committed 2026-06-04 21:02:10 +02:00
1 parent 045da70133
commit 86b2774567
18 files changed
+368 -20

No files matched your search

@@ -0,0 +1,44 @@
# v1.12.0 — server → iOS: new `FITBIT` MeasurementSource heads-up
**Date:** 2026-06-04
**From:** server
**Status:** landing in v1.12.0 (data-model foundation merged; sync/UI follow in
later waves of the same release)
## What is changing
v1.12.0 adds a Fitbit/Pixel integration over the Google Health API. Like WHOOP
and Withings, it is a **server-owned, read-only ingest** — there is no client
write path; the batch + single-POST surfaces reject it. The rows surface through
the same read/response shapes the iOS app already decodes (`/api/sync/changes`,
the measurement list, charts).
The one contract item for iOS in this wave:
- **New `MeasurementSource` enum value: `FITBIT`** (exact spelling, uppercase).
Add the `FITBIT` case to the iOS DTO's `MeasurementSource` decoding so
`/api/sync/changes` and the measurement reads decode Fitbit rows. This is the
same contract that gated `WHOOP` and `APPLE_HEALTH`.
> **Mismatched spelling = silent client-side decode drop.** A row tagged
> `source: "FITBIT"` that the DTO cannot decode is silently skipped, so the
> measurement count diverges from the server with no error surfaced. Pin the
> exact spelling.
## What is NOT changing
- **No new `MeasurementType` values.** Every Fitbit launch metric maps onto an
existing type (`WEIGHT`, `BODY_FAT`, `OXYGEN_SATURATION`,
`HEART_RATE_VARIABILITY`, `RESTING_HEART_RATE`, `RESPIRATORY_RATE`, `PULSE`,
`WRIST_TEMPERATURE`, `ACTIVITY_STEPS`, `WALKING_RUNNING_DISTANCE`,
`ACTIVE_ENERGY_BURNED`, `FLIGHTS_CLIMBED`, `VO2_MAX`, `SLEEP_DURATION`, plus
`Workout` rows). No DTO `MeasurementType` change is needed.
- **Blood glucose / blood pressure / body temperature are deferred.** Those
slots exist but are NOT in the launch sync set (gated on the Google Health
API roadmap). No action for iOS now.
## Confirmation requested
Confirm the iOS DTO carries the `FITBIT` case ahead of the v1.12.0 deploy. A
follow-up `v1.12.0-server-to-ios-fitbit-LIVE.md` will land once the enum is live
on apps01 (verified via `/api/version`).
+109
View File
@@ -1189,6 +1189,7 @@ paths:
- APPLE_HEALTH
- COMPUTED
- WHOOP
- FITBIT
externalId:
type: string
maxLength: 128
@@ -1801,6 +1802,7 @@ paths:
- APPLE_HEALTH
- COMPUTED
- WHOOP
- FITBIT
activeEnergy:
maxItems: 8
type: array
@@ -1813,6 +1815,7 @@ paths:
- APPLE_HEALTH
- COMPUTED
- WHOOP
- FITBIT
walkingRunningDistance:
maxItems: 8
type: array
@@ -1825,6 +1828,7 @@ paths:
- APPLE_HEALTH
- COMPUTED
- WHOOP
- FITBIT
flightsClimbed:
maxItems: 8
type: array
@@ -1837,6 +1841,7 @@ paths:
- APPLE_HEALTH
- COMPUTED
- WHOOP
- FITBIT
sleep:
maxItems: 8
type: array
@@ -1849,6 +1854,7 @@ paths:
- APPLE_HEALTH
- COMPUTED
- WHOOP
- FITBIT
weight:
maxItems: 8
type: array
@@ -1861,6 +1867,7 @@ paths:
- APPLE_HEALTH
- COMPUTED
- WHOOP
- FITBIT
bloodPressure:
maxItems: 8
type: array
@@ -1873,6 +1880,7 @@ paths:
- APPLE_HEALTH
- COMPUTED
- WHOOP
- FITBIT
pulse:
maxItems: 8
type: array
@@ -1885,6 +1893,7 @@ paths:
- APPLE_HEALTH
- COMPUTED
- WHOOP
- FITBIT
bodyFat:
maxItems: 8
type: array
@@ -1897,6 +1906,7 @@ paths:
- APPLE_HEALTH
- COMPUTED
- WHOOP
- FITBIT
bodyTemperature:
maxItems: 8
type: array
@@ -1909,6 +1919,7 @@ paths:
- APPLE_HEALTH
- COMPUTED
- WHOOP
- FITBIT
spo2:
maxItems: 8
type: array
@@ -1921,6 +1932,7 @@ paths:
- APPLE_HEALTH
- COMPUTED
- WHOOP
- FITBIT
hrv:
maxItems: 8
type: array
@@ -1933,6 +1945,7 @@ paths:
- APPLE_HEALTH
- COMPUTED
- WHOOP
- FITBIT
restingHeartRate:
maxItems: 8
type: array
@@ -1945,6 +1958,7 @@ paths:
- APPLE_HEALTH
- COMPUTED
- WHOOP
- FITBIT
vo2Max:
maxItems: 8
type: array
@@ -1957,6 +1971,7 @@ paths:
- APPLE_HEALTH
- COMPUTED
- WHOOP
- FITBIT
skinTemperature:
maxItems: 8
type: array
@@ -1969,6 +1984,7 @@ paths:
- APPLE_HEALTH
- COMPUTED
- WHOOP
- FITBIT
respiratoryRate:
maxItems: 8
type: array
@@ -1981,6 +1997,7 @@ paths:
- APPLE_HEALTH
- COMPUTED
- WHOOP
- FITBIT
recovery:
maxItems: 8
type: array
@@ -1993,6 +2010,7 @@ paths:
- APPLE_HEALTH
- COMPUTED
- WHOOP
- FITBIT
metricPriority:
type: object
properties:
@@ -2008,6 +2026,7 @@ paths:
- APPLE_HEALTH
- COMPUTED
- WHOOP
- FITBIT
activeEnergy:
maxItems: 8
type: array
@@ -2020,6 +2039,7 @@ paths:
- APPLE_HEALTH
- COMPUTED
- WHOOP
- FITBIT
walkingRunningDistance:
maxItems: 8
type: array
@@ -2032,6 +2052,7 @@ paths:
- APPLE_HEALTH
- COMPUTED
- WHOOP
- FITBIT
flightsClimbed:
maxItems: 8
type: array
@@ -2044,6 +2065,7 @@ paths:
- APPLE_HEALTH
- COMPUTED
- WHOOP
- FITBIT
sleep:
maxItems: 8
type: array
@@ -2056,6 +2078,7 @@ paths:
- APPLE_HEALTH
- COMPUTED
- WHOOP
- FITBIT
weight:
maxItems: 8
type: array
@@ -2068,6 +2091,7 @@ paths:
- APPLE_HEALTH
- COMPUTED
- WHOOP
- FITBIT
bloodPressure:
maxItems: 8
type: array
@@ -2080,6 +2104,7 @@ paths:
- APPLE_HEALTH
- COMPUTED
- WHOOP
- FITBIT
pulse:
maxItems: 8
type: array
@@ -2092,6 +2117,7 @@ paths:
- APPLE_HEALTH
- COMPUTED
- WHOOP
- FITBIT
bodyFat:
maxItems: 8
type: array
@@ -2104,6 +2130,7 @@ paths:
- APPLE_HEALTH
- COMPUTED
- WHOOP
- FITBIT
bodyTemperature:
maxItems: 8
type: array
@@ -2116,6 +2143,7 @@ paths:
- APPLE_HEALTH
- COMPUTED
- WHOOP
- FITBIT
spo2:
maxItems: 8
type: array
@@ -2128,6 +2156,7 @@ paths:
- APPLE_HEALTH
- COMPUTED
- WHOOP
- FITBIT
hrv:
maxItems: 8
type: array
@@ -2140,6 +2169,7 @@ paths:
- APPLE_HEALTH
- COMPUTED
- WHOOP
- FITBIT
restingHeartRate:
maxItems: 8
type: array
@@ -2152,6 +2182,7 @@ paths:
- APPLE_HEALTH
- COMPUTED
- WHOOP
- FITBIT
vo2Max:
maxItems: 8
type: array
@@ -2164,6 +2195,7 @@ paths:
- APPLE_HEALTH
- COMPUTED
- WHOOP
- FITBIT
skinTemperature:
maxItems: 8
type: array
@@ -2176,6 +2208,7 @@ paths:
- APPLE_HEALTH
- COMPUTED
- WHOOP
- FITBIT
respiratoryRate:
maxItems: 8
type: array
@@ -2188,6 +2221,7 @@ paths:
- APPLE_HEALTH
- COMPUTED
- WHOOP
- FITBIT
recovery:
maxItems: 8
type: array
@@ -2200,6 +2234,7 @@ paths:
- APPLE_HEALTH
- COMPUTED
- WHOOP
- FITBIT
deviceTypePriority:
type: object
properties:
@@ -4778,6 +4813,7 @@ components:
- APPLE_HEALTH
- COMPUTED
- WHOOP
- FITBIT
notes:
anyOf:
- type: string
@@ -5093,6 +5129,7 @@ components:
- APPLE_HEALTH
- COMPUTED
- WHOOP
- FITBIT
notes:
anyOf:
- type: string
@@ -5252,6 +5289,7 @@ components:
- APPLE_HEALTH
- COMPUTED
- WHOOP
- FITBIT
- type: "null"
start:
type: string
@@ -5366,6 +5404,7 @@ components:
- APPLE_HEALTH
- COMPUTED
- WHOOP
- FITBIT
- type: "null"
start:
type: string
@@ -5569,6 +5608,7 @@ components:
- APPLE_HEALTH
- COMPUTED
- WHOOP
- FITBIT
externalId:
anyOf:
- type: string
@@ -5673,6 +5713,7 @@ components:
- APPLE_HEALTH
- COMPUTED
- WHOOP
- FITBIT
externalId:
anyOf:
- type: string
@@ -6936,6 +6977,7 @@ components:
- APPLE_HEALTH
- COMPUTED
- WHOOP
- FITBIT
activeEnergy:
maxItems: 8
type: array
@@ -6948,6 +6990,7 @@ components:
- APPLE_HEALTH
- COMPUTED
- WHOOP
- FITBIT
walkingRunningDistance:
maxItems: 8
type: array
@@ -6960,6 +7003,7 @@ components:
- APPLE_HEALTH
- COMPUTED
- WHOOP
- FITBIT
flightsClimbed:
maxItems: 8
type: array
@@ -6972,6 +7016,7 @@ components:
- APPLE_HEALTH
- COMPUTED
- WHOOP
- FITBIT
sleep:
maxItems: 8
type: array
@@ -6984,6 +7029,7 @@ components:
- APPLE_HEALTH
- COMPUTED
- WHOOP
- FITBIT
weight:
maxItems: 8
type: array
@@ -6996,6 +7042,7 @@ components:
- APPLE_HEALTH
- COMPUTED
- WHOOP
- FITBIT
bloodPressure:
maxItems: 8
type: array
@@ -7008,6 +7055,7 @@ components:
- APPLE_HEALTH
- COMPUTED
- WHOOP
- FITBIT
pulse:
maxItems: 8
type: array
@@ -7020,6 +7068,7 @@ components:
- APPLE_HEALTH
- COMPUTED
- WHOOP
- FITBIT
bodyFat:
maxItems: 8
type: array
@@ -7032,6 +7081,7 @@ components:
- APPLE_HEALTH
- COMPUTED
- WHOOP
- FITBIT
bodyTemperature:
maxItems: 8
type: array
@@ -7044,6 +7094,7 @@ components:
- APPLE_HEALTH
- COMPUTED
- WHOOP
- FITBIT
spo2:
maxItems: 8
type: array
@@ -7056,6 +7107,7 @@ components:
- APPLE_HEALTH
- COMPUTED
- WHOOP
- FITBIT
hrv:
maxItems: 8
type: array
@@ -7068,6 +7120,7 @@ components:
- APPLE_HEALTH
- COMPUTED
- WHOOP
- FITBIT
restingHeartRate:
maxItems: 8
type: array
@@ -7080,6 +7133,7 @@ components:
- APPLE_HEALTH
- COMPUTED
- WHOOP
- FITBIT
vo2Max:
maxItems: 8
type: array
@@ -7092,6 +7146,7 @@ components:
- APPLE_HEALTH
- COMPUTED
- WHOOP
- FITBIT
skinTemperature:
maxItems: 8
type: array
@@ -7104,6 +7159,7 @@ components:
- APPLE_HEALTH
- COMPUTED
- WHOOP
- FITBIT
respiratoryRate:
maxItems: 8
type: array
@@ -7116,6 +7172,7 @@ components:
- APPLE_HEALTH
- COMPUTED
- WHOOP
- FITBIT
recovery:
maxItems: 8
type: array
@@ -7128,6 +7185,7 @@ components:
- APPLE_HEALTH
- COMPUTED
- WHOOP
- FITBIT
metricPriority:
type: object
properties:
@@ -7143,6 +7201,7 @@ components:
- APPLE_HEALTH
- COMPUTED
- WHOOP
- FITBIT
activeEnergy:
maxItems: 8
type: array
@@ -7155,6 +7214,7 @@ components:
- APPLE_HEALTH
- COMPUTED
- WHOOP
- FITBIT
walkingRunningDistance:
maxItems: 8
type: array
@@ -7167,6 +7227,7 @@ components:
- APPLE_HEALTH
- COMPUTED
- WHOOP
- FITBIT
flightsClimbed:
maxItems: 8
type: array
@@ -7179,6 +7240,7 @@ components:
- APPLE_HEALTH
- COMPUTED
- WHOOP
- FITBIT
sleep:
maxItems: 8
type: array
@@ -7191,6 +7253,7 @@ components:
- APPLE_HEALTH
- COMPUTED
- WHOOP
- FITBIT
weight:
maxItems: 8
type: array
@@ -7203,6 +7266,7 @@ components:
- APPLE_HEALTH
- COMPUTED
- WHOOP
- FITBIT
bloodPressure:
maxItems: 8
type: array
@@ -7215,6 +7279,7 @@ components:
- APPLE_HEALTH
- COMPUTED
- WHOOP
- FITBIT
pulse:
maxItems: 8
type: array
@@ -7227,6 +7292,7 @@ components:
- APPLE_HEALTH
- COMPUTED
- WHOOP
- FITBIT
bodyFat:
maxItems: 8
type: array
@@ -7239,6 +7305,7 @@ components:
- APPLE_HEALTH
- COMPUTED
- WHOOP
- FITBIT
bodyTemperature:
maxItems: 8
type: array
@@ -7251,6 +7318,7 @@ components:
- APPLE_HEALTH
- COMPUTED
- WHOOP
- FITBIT
spo2:
maxItems: 8
type: array
@@ -7263,6 +7331,7 @@ components:
- APPLE_HEALTH
- COMPUTED
- WHOOP
- FITBIT
hrv:
maxItems: 8
type: array
@@ -7275,6 +7344,7 @@ components:
- APPLE_HEALTH
- COMPUTED
- WHOOP
- FITBIT
restingHeartRate:
maxItems: 8
type: array
@@ -7287,6 +7357,7 @@ components:
- APPLE_HEALTH
- COMPUTED
- WHOOP
- FITBIT
vo2Max:
maxItems: 8
type: array
@@ -7299,6 +7370,7 @@ components:
- APPLE_HEALTH
- COMPUTED
- WHOOP
- FITBIT
skinTemperature:
maxItems: 8
type: array
@@ -7311,6 +7383,7 @@ components:
- APPLE_HEALTH
- COMPUTED
- WHOOP
- FITBIT
respiratoryRate:
maxItems: 8
type: array
@@ -7323,6 +7396,7 @@ components:
- APPLE_HEALTH
- COMPUTED
- WHOOP
- FITBIT
recovery:
maxItems: 8
type: array
@@ -7335,6 +7409,7 @@ components:
- APPLE_HEALTH
- COMPUTED
- WHOOP
- FITBIT
additionalProperties: false
deviceTypePriority:
type: object
@@ -7396,6 +7471,7 @@ components:
- APPLE_HEALTH
- COMPUTED
- WHOOP
- FITBIT
activeEnergy:
maxItems: 8
type: array
@@ -7408,6 +7484,7 @@ components:
- APPLE_HEALTH
- COMPUTED
- WHOOP
- FITBIT
walkingRunningDistance:
maxItems: 8
type: array
@@ -7420,6 +7497,7 @@ components:
- APPLE_HEALTH
- COMPUTED
- WHOOP
- FITBIT
flightsClimbed:
maxItems: 8
type: array
@@ -7432,6 +7510,7 @@ components:
- APPLE_HEALTH
- COMPUTED
- WHOOP
- FITBIT
sleep:
maxItems: 8
type: array
@@ -7444,6 +7523,7 @@ components:
- APPLE_HEALTH
- COMPUTED
- WHOOP
- FITBIT
weight:
maxItems: 8
type: array
@@ -7456,6 +7536,7 @@ components:
- APPLE_HEALTH
- COMPUTED
- WHOOP
- FITBIT
bloodPressure:
maxItems: 8
type: array
@@ -7468,6 +7549,7 @@ components:
- APPLE_HEALTH
- COMPUTED
- WHOOP
- FITBIT
pulse:
maxItems: 8
type: array
@@ -7480,6 +7562,7 @@ components:
- APPLE_HEALTH
- COMPUTED
- WHOOP
- FITBIT
bodyFat:
maxItems: 8
type: array
@@ -7492,6 +7575,7 @@ components:
- APPLE_HEALTH
- COMPUTED
- WHOOP
- FITBIT
bodyTemperature:
maxItems: 8
type: array
@@ -7504,6 +7588,7 @@ components:
- APPLE_HEALTH
- COMPUTED
- WHOOP
- FITBIT
spo2:
maxItems: 8
type: array
@@ -7516,6 +7601,7 @@ components:
- APPLE_HEALTH
- COMPUTED
- WHOOP
- FITBIT
hrv:
maxItems: 8
type: array
@@ -7528,6 +7614,7 @@ components:
- APPLE_HEALTH
- COMPUTED
- WHOOP
- FITBIT
restingHeartRate:
maxItems: 8
type: array
@@ -7540,6 +7627,7 @@ components:
- APPLE_HEALTH
- COMPUTED
- WHOOP
- FITBIT
vo2Max:
maxItems: 8
type: array
@@ -7552,6 +7640,7 @@ components:
- APPLE_HEALTH
- COMPUTED
- WHOOP
- FITBIT
skinTemperature:
maxItems: 8
type: array
@@ -7564,6 +7653,7 @@ components:
- APPLE_HEALTH
- COMPUTED
- WHOOP
- FITBIT
respiratoryRate:
maxItems: 8
type: array
@@ -7576,6 +7666,7 @@ components:
- APPLE_HEALTH
- COMPUTED
- WHOOP
- FITBIT
recovery:
maxItems: 8
type: array
@@ -7588,6 +7679,7 @@ components:
- APPLE_HEALTH
- COMPUTED
- WHOOP
- FITBIT
metricPriority:
type: object
properties:
@@ -7603,6 +7695,7 @@ components:
- APPLE_HEALTH
- COMPUTED
- WHOOP
- FITBIT
activeEnergy:
maxItems: 8
type: array
@@ -7615,6 +7708,7 @@ components:
- APPLE_HEALTH
- COMPUTED
- WHOOP
- FITBIT
walkingRunningDistance:
maxItems: 8
type: array
@@ -7627,6 +7721,7 @@ components:
- APPLE_HEALTH
- COMPUTED
- WHOOP
- FITBIT
flightsClimbed:
maxItems: 8
type: array
@@ -7639,6 +7734,7 @@ components:
- APPLE_HEALTH
- COMPUTED
- WHOOP
- FITBIT
sleep:
maxItems: 8
type: array
@@ -7651,6 +7747,7 @@ components:
- APPLE_HEALTH
- COMPUTED
- WHOOP
- FITBIT
weight:
maxItems: 8
type: array
@@ -7663,6 +7760,7 @@ components:
- APPLE_HEALTH
- COMPUTED
- WHOOP
- FITBIT
bloodPressure:
maxItems: 8
type: array
@@ -7675,6 +7773,7 @@ components:
- APPLE_HEALTH
- COMPUTED
- WHOOP
- FITBIT
pulse:
maxItems: 8
type: array
@@ -7687,6 +7786,7 @@ components:
- APPLE_HEALTH
- COMPUTED
- WHOOP
- FITBIT
bodyFat:
maxItems: 8
type: array
@@ -7699,6 +7799,7 @@ components:
- APPLE_HEALTH
- COMPUTED
- WHOOP
- FITBIT
bodyTemperature:
maxItems: 8
type: array
@@ -7711,6 +7812,7 @@ components:
- APPLE_HEALTH
- COMPUTED
- WHOOP
- FITBIT
spo2:
maxItems: 8
type: array
@@ -7723,6 +7825,7 @@ components:
- APPLE_HEALTH
- COMPUTED
- WHOOP
- FITBIT
hrv:
maxItems: 8
type: array
@@ -7735,6 +7838,7 @@ components:
- APPLE_HEALTH
- COMPUTED
- WHOOP
- FITBIT
restingHeartRate:
maxItems: 8
type: array
@@ -7747,6 +7851,7 @@ components:
- APPLE_HEALTH
- COMPUTED
- WHOOP
- FITBIT
vo2Max:
maxItems: 8
type: array
@@ -7759,6 +7864,7 @@ components:
- APPLE_HEALTH
- COMPUTED
- WHOOP
- FITBIT
skinTemperature:
maxItems: 8
type: array
@@ -7771,6 +7877,7 @@ components:
- APPLE_HEALTH
- COMPUTED
- WHOOP
- FITBIT
respiratoryRate:
maxItems: 8
type: array
@@ -7783,6 +7890,7 @@ components:
- APPLE_HEALTH
- COMPUTED
- WHOOP
- FITBIT
recovery:
maxItems: 8
type: array
@@ -7795,6 +7903,7 @@ components:
- APPLE_HEALTH
- COMPUTED
- WHOOP
- FITBIT
additionalProperties: false
deviceTypePriority:
type: object
+1
View File
@@ -3130,6 +3130,7 @@
"MANUAL": "Manuelle Eingabe",
"IMPORT": "Import",
"WHOOP": "WHOOP",
"FITBIT": "Fitbit",
"COMPUTED": "Berechnet"
},
"deviceLabels": {
+1
View File
@@ -3130,6 +3130,7 @@
"MANUAL": "Manual entry",
"IMPORT": "Import",
"WHOOP": "WHOOP",
"FITBIT": "Fitbit",
"COMPUTED": "Computed"
},
"deviceLabels": {
+1
View File
@@ -3130,6 +3130,7 @@
"MANUAL": "Entrada manual",
"IMPORT": "Importar",
"WHOOP": "WHOOP",
"FITBIT": "Fitbit",
"COMPUTED": "Calculado"
},
"deviceLabels": {
+1
View File
@@ -3130,6 +3130,7 @@
"MANUAL": "Saisie manuelle",
"IMPORT": "Importer",
"WHOOP": "WHOOP",
"FITBIT": "Fitbit",
"COMPUTED": "Calculé"
},
"deviceLabels": {
+1
View File
@@ -3130,6 +3130,7 @@
"MANUAL": "Inserimento manuale",
"IMPORT": "Importa",
"WHOOP": "WHOOP",
"FITBIT": "Fitbit",
"COMPUTED": "Calcolato"
},
"deviceLabels": {
+1
View File
@@ -3130,6 +3130,7 @@
"MANUAL": "Wpis ręczny",
"IMPORT": "Importuj",
"WHOOP": "WHOOP",
"FITBIT": "Fitbit",
"COMPUTED": "Obliczone"
},
"deviceLabels": {
@@ -0,0 +1,83 @@
-- v1.12.0 — Fitbit/Pixel integration via the Google Health API (schema + data
-- layer).
--
-- Purely-additive: one enum-extension, two new tables, and two new nullable
-- columns on `users`. No backfill, no existing row touched. Unlike the WHOOP
-- migration (0111) this adds NO new `measurement_type` values — every Fitbit
-- launch metric maps onto an existing type.
--
-- 1. `measurement_source` += `FITBIT` for the server-side native ingest.
-- 2. `fitbit_connections` — per-user encrypted token row (1:1 with users),
-- mirroring `whoop_connections` minus `max_heart_rate`, plus
-- `fitbit_user_id` and `backfill_completed_at`.
-- 3. `fitbit_oauth_states` — short-lived `(nonce → userId)` CSRF ledger,
-- identical in shape to `whoop_oauth_states`.
-- 4. `users` += `fitbit_client_id_encrypted` / `fitbit_client_secret_encrypted`
-- — per-user BYO-keys (Google's Testing-mode per-app authorized-user cap
-- makes a single shared client unworkable for a multi-operator product).
--
-- Idempotent guards (`IF NOT EXISTS`) make reruns safe. Forward-only.
--
-- Reversibility: Postgres cannot remove an enum value, so `FITBIT` stays; with
-- no rows carrying it it is inert. The two tables drop with `DROP TABLE IF
-- EXISTS`, the two columns with `DROP COLUMN IF EXISTS`.
-- ── 1. measurement_source — append the Fitbit server-owned source ──────
ALTER TYPE "measurement_source" ADD VALUE IF NOT EXISTS 'FITBIT';
-- ── 2. fitbit_connections ─────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS "fitbit_connections" (
"id" TEXT NOT NULL,
"user_id" TEXT NOT NULL,
"fitbit_user_id" TEXT NOT NULL,
"access_token" TEXT NOT NULL,
"refresh_token" TEXT NOT NULL,
"token_expires_at" TIMESTAMP(3) NOT NULL,
"scope" TEXT,
"last_synced_at" TIMESTAMP(3),
"backfill_completed_at" TIMESTAMP(3),
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "fitbit_connections_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX IF NOT EXISTS "fitbit_connections_user_id_key"
ON "fitbit_connections" ("user_id");
DO $$ BEGIN
ALTER TABLE "fitbit_connections"
ADD CONSTRAINT "fitbit_connections_user_id_fkey"
FOREIGN KEY ("user_id") REFERENCES "users"("id")
ON DELETE CASCADE ON UPDATE CASCADE;
EXCEPTION
WHEN duplicate_object THEN NULL;
END $$;
-- ── 3. fitbit_oauth_states ────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS "fitbit_oauth_states" (
"nonce" TEXT NOT NULL,
"user_id" TEXT NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"expires_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "fitbit_oauth_states_pkey" PRIMARY KEY ("nonce")
);
CREATE INDEX IF NOT EXISTS "fitbit_oauth_states_expires_at_idx"
ON "fitbit_oauth_states" ("expires_at");
DO $$ BEGIN
ALTER TABLE "fitbit_oauth_states"
ADD CONSTRAINT "fitbit_oauth_states_user_id_fkey"
FOREIGN KEY ("user_id") REFERENCES "users"("id")
ON DELETE CASCADE ON UPDATE CASCADE;
EXCEPTION
WHEN duplicate_object THEN NULL;
END $$;
-- ── 4. users — per-user Fitbit BYO-key columns (encrypted at app level) ─
ALTER TABLE "users"
ADD COLUMN IF NOT EXISTS "fitbit_client_id_encrypted" TEXT;
ALTER TABLE "users"
ADD COLUMN IF NOT EXISTS "fitbit_client_secret_encrypted" TEXT;
+57
View File
@@ -66,6 +66,14 @@ model User {
whoopClientIdEncrypted String? @map("whoop_client_id_encrypted")
whoopClientSecretEncrypted String? @map("whoop_client_secret_encrypted")
// v1.12.0 — Fitbit/Pixel OAuth credentials (per-user BYO-keys, encrypted at
// app level) over the Google Health API. The per-app authorized-user cap in
// Google's Testing mode makes a single shared client unworkable for a
// multi-operator product, so each self-hoster registers their own Google
// Cloud OAuth client and pastes the client id/secret into Settings.
fitbitClientIdEncrypted String? @map("fitbit_client_id_encrypted")
fitbitClientSecretEncrypted String? @map("fitbit_client_secret_encrypted")
// moodLog integration (per-user, encrypted)
moodLogUrlEncrypted String? @map("mood_log_url_encrypted")
moodLogApiKeyEncrypted String? @map("mood_log_api_key_encrypted")
@@ -340,6 +348,10 @@ model User {
// OAuth-state ledger (1:N) relations.
whoopConnection WhoopConnection?
whoopOAuthStates WhoopOAuthState[]
// v1.12.0 — Fitbit/Pixel integration. Mirrors the WHOOP connection (1:1) +
// OAuth-state ledger (1:N) relations.
fitbitConnection FitbitConnection?
fitbitOAuthStates FitbitOAuthState[]
moodEntries MoodEntry[]
notificationChannels NotificationChannel[]
pushSubscriptions PushSubscription[]
@@ -672,6 +684,10 @@ enum MeasurementSource {
// (no client write path) as `source = WHOOP` rows. iOS DTO mirrors this
// exact spelling so the `/api/sync/changes` delta feed decodes the rows.
WHOOP
// v1.12.0 — Fitbit/Pixel integration via the Google Health API. Server-owned
// (no client write path); rejected on batch + single POST like WITHINGS/WHOOP.
// iOS DTO mirrors this exact spelling so /api/sync/changes decodes the rows.
FITBIT
@@map("measurement_source")
}
@@ -1827,6 +1843,47 @@ model WhoopOAuthState {
@@map("whoop_oauth_states")
}
/// v1.12.0 — Fitbit/Pixel connection over the Google Health API. Mirrors
/// `WhoopConnection` 1:1 per user. Tokens are encrypted at the app level
/// before persistence. Unlike WHOOP, Google refresh tokens do not rotate per
/// use, so `refreshToken` is overwritten only when a token response carries a
/// fresh one.
model FitbitConnection {
id String @id @default(cuid())
userId String @unique @map("user_id")
fitbitUserId String @map("fitbit_user_id") // Google/Fitbit external id
accessToken String @map("access_token") // encrypted at app level
refreshToken String @map("refresh_token") // encrypted at app level
tokenExpiresAt DateTime @map("token_expires_at")
/// Space-separated OAuth scope string granted by the Google authorisation
/// flow. Null = legacy connection that predates scope tracking.
scope String? @map("scope")
lastSyncedAt DateTime? @map("last_synced_at")
/// Set once the self-converging history backfill walks every collection to
/// completion. Null = backfill still in flight (or never started).
backfillCompletedAt DateTime? @map("backfill_completed_at")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@map("fitbit_connections")
}
/// v1.12.0 — short-lived `(nonce → userId)` ledger backing the Fitbit OAuth
/// state check, identical in shape + lifecycle to `WhoopOAuthState`.
model FitbitOAuthState {
nonce String @id
userId String @map("user_id")
createdAt DateTime @default(now()) @map("created_at")
expiresAt DateTime @map("expires_at")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([expiresAt])
@@map("fitbit_oauth_states")
}
// ─── Mood Entries (moodLog) ───────────────────────────────────
model MoodEntry {
+24
View File
@@ -102,6 +102,8 @@ async function main() {
withingsClientSecretEncrypted: true,
whoopClientIdEncrypted: true,
whoopClientSecretEncrypted: true,
fitbitClientIdEncrypted: true,
fitbitClientSecretEncrypted: true,
},
});
@@ -119,6 +121,8 @@ async function main() {
"withingsClientSecretEncrypted",
"whoopClientIdEncrypted",
"whoopClientSecretEncrypted",
"fitbitClientIdEncrypted",
"fitbitClientSecretEncrypted",
];
for (const field of userFields) {
const r = await rotateField(
@@ -176,6 +180,26 @@ async function main() {
results.push(r);
}
// ───── FitbitConnection table (accessToken / refreshToken) ─────
const fitbit = await prisma.fitbitConnection.findMany({
select: { id: true, accessToken: true, refreshToken: true },
});
for (const field of ["accessToken", "refreshToken"] as const) {
const r = await rotateField(
"FitbitConnection",
field,
fitbit,
(f) => f[field],
async (id, ciphertext) => {
await prisma.fitbitConnection.update({
where: { id },
data: { [field]: ciphertext } as Record<string, string>,
});
},
);
results.push(r);
}
// ───── AppSettings table (singleton typically) ─────
const settings = await prisma.appSettings.findMany({
select: {
@@ -94,6 +94,8 @@ const SOURCE_LABEL_KEYS: Record<string, string> = {
// `recovery` ladder for native-vs-derived ordering).
WHOOP: "settings.sections.sources.sourceLabels.WHOOP",
COMPUTED: "settings.sections.sources.sourceLabels.COMPUTED",
// v1.12.0 — Fitbit/Pixel native source.
FITBIT: "settings.sections.sources.sourceLabels.FITBIT",
};
const DEVICE_TYPE_LABEL_KEYS: Record<DeviceType, string> = {
+4 -2
View File
@@ -39,7 +39,7 @@ import { auditLog } from "@/lib/auth/audit";
import { getEvent } from "@/lib/logging/context";
import { dispatchNotification } from "@/lib/notifications/dispatcher";
export type IntegrationKey = "withings" | "whoop" | "moodlog";
export type IntegrationKey = "withings" | "whoop" | "fitbit" | "moodlog";
/**
* Failure kinds carried into `recordSyncFailure`.
@@ -824,7 +824,9 @@ export function formatAdminAlertPayload(input: AlertInput): {
? "Withings"
: input.integration === "whoop"
? "WHOOP"
: "moodLog";
: input.integration === "fitbit"
? "Fitbit"
: "moodLog";
const subjectLabel = input.subjectLabel ?? input.userId;
const { reason: reasonLabel, action: actionLabel } =
FAILURE_KIND_COPY[input.kind];
+7
View File
@@ -374,6 +374,13 @@ export const queryKeys = {
* so a disconnect / credentials mutation invalidates both at once.
*/
whoopStatus: () => ["whoop", "status"] as const,
// v1.12.0 — Fitbit/Pixel integration card, mirroring the WHOOP keys.
fitbit: () => ["fitbit"] as const,
/**
* Per-card Fitbit status read. Shares the `["fitbit"]` prefix with `fitbit()`
* so a disconnect / credentials mutation invalidates both at once.
*/
fitbitStatus: () => ["fitbit", "status"] as const,
// v1.4.32 — workout list + detail caches. `workouts()` is the
// root key invalidated by the batch-ingest mutation; the recent +
@@ -94,6 +94,10 @@ export const DEFAULT_WORKOUT_SOURCE_PRIORITY: readonly MeasurementSource[] = [
// session. The read-time picker clusters a WHOOP run and an Apple-Health
// run by (activityType, startedAt ± window) and keeps the ladder winner.
"WHOOP",
// v1.12.0 — Fitbit/Pixel exercise logs rank just below WHOOP: a wrist
// wearable in the same class, the next-best when no watch logged the same
// session.
"FITBIT",
"WITHINGS",
"MANUAL",
"IMPORT",
@@ -102,7 +102,7 @@ describe("sourcePrioritySchema", () => {
it("rejects an unknown source string", () => {
const result = sourcePrioritySchema.safeParse({
weight: ["FITBIT", "WITHINGS"],
weight: ["GARMIN", "WITHINGS"],
});
expect(result.success).toBe(false);
});
+4
View File
@@ -129,6 +129,10 @@ export const measurementSourceEnum = z.enum([
// client write path). Part of this enum so the read/response shapes (and
// the iOS decoder) can decode the rows it surfaces.
"WHOOP",
// v1.12.0 — Fitbit/Pixel integration via the Google Health API. Server-owned
// (no client write path), exactly like WHOOP. Part of this enum so the
// read/response shapes (and the iOS decoder) can decode the rows it surfaces.
"FITBIT",
]);
/**
+23 -17
View File
@@ -214,29 +214,35 @@ export type DeviceTypePriority = z.infer<typeof deviceTypePrioritySchema>;
* receives the same reading via Withings' Health Mate iOS app).
*/
export const DEFAULT_SOURCE_PRIORITY: Required<MetricPriority> = {
steps: ["APPLE_HEALTH", "WITHINGS", "MANUAL"],
activeEnergy: ["APPLE_HEALTH", "WITHINGS", "MANUAL"],
walkingRunningDistance: ["APPLE_HEALTH", "WITHINGS", "MANUAL"],
flightsClimbed: ["APPLE_HEALTH", "WITHINGS", "MANUAL"],
// v1.12.0 — Fitbit/Pixel rides below Apple Health for cumulative metrics:
// HealthKit still aggregates the broadest device set, and a Fitbit-only
// self-hoster has no Apple stream to compete with anyway.
steps: ["APPLE_HEALTH", "WITHINGS", "FITBIT", "MANUAL"],
activeEnergy: ["APPLE_HEALTH", "WITHINGS", "FITBIT", "MANUAL"],
walkingRunningDistance: ["APPLE_HEALTH", "WITHINGS", "FITBIT", "MANUAL"],
flightsClimbed: ["APPLE_HEALTH", "WITHINGS", "FITBIT", "MANUAL"],
// v1.11.0 — WHOOP leads the recovery-input ladders (sleep / HRV / RHR):
// a worn-all-night strap has higher-resolution overnight sampling than
// the iPhone-relayed HealthKit summary or the Withings nightly summary.
sleep: ["WHOOP", "APPLE_HEALTH", "WITHINGS"],
hrv: ["WHOOP", "APPLE_HEALTH", "WITHINGS"],
restingHeartRate: ["WHOOP", "APPLE_HEALTH", "WITHINGS"],
// v1.12.0 — Fitbit is a wrist wearable in the same class as WHOOP but with
// a lower-fidelity nightly estimate, so it ranks just below it.
sleep: ["WHOOP", "FITBIT", "APPLE_HEALTH", "WITHINGS"],
hrv: ["WHOOP", "FITBIT", "APPLE_HEALTH", "WITHINGS"],
restingHeartRate: ["WHOOP", "FITBIT", "APPLE_HEALTH", "WITHINGS"],
// A real scale beats a strap's body-measurement estimate for weight.
weight: ["WITHINGS", "APPLE_HEALTH", "MANUAL", "WHOOP"],
weight: ["WITHINGS", "APPLE_HEALTH", "MANUAL", "WHOOP", "FITBIT"],
bloodPressure: ["WITHINGS", "APPLE_HEALTH", "MANUAL"],
pulse: ["WITHINGS", "APPLE_HEALTH", "MANUAL"],
bodyFat: ["WITHINGS", "APPLE_HEALTH", "MANUAL"],
bodyTemperature: ["WITHINGS", "APPLE_HEALTH", "MANUAL"],
// Withings ScanWatch pulse-ox is the primary SpO2 sensor; WHOOP second.
spo2: ["WITHINGS", "WHOOP", "APPLE_HEALTH", "MANUAL"],
vo2Max: ["WITHINGS", "APPLE_HEALTH", "MANUAL"],
pulse: ["WITHINGS", "APPLE_HEALTH", "MANUAL", "FITBIT"],
bodyFat: ["WITHINGS", "APPLE_HEALTH", "MANUAL", "FITBIT"],
bodyTemperature: ["WITHINGS", "APPLE_HEALTH", "MANUAL", "FITBIT"],
// Withings ScanWatch pulse-ox is the primary SpO2 sensor; WHOOP second,
// Fitbit third.
spo2: ["WITHINGS", "WHOOP", "FITBIT", "APPLE_HEALTH", "MANUAL"],
vo2Max: ["WITHINGS", "APPLE_HEALTH", "FITBIT", "MANUAL"],
// v1.11.0 — new WHOOP-overlapping keys. ScanWatch dermal reading is the
// primary skin-temperature sensor; WHOOP's strap is second.
skinTemperature: ["WITHINGS", "WHOOP", "APPLE_HEALTH"],
respiratoryRate: ["WHOOP", "APPLE_HEALTH", "WITHINGS"],
// primary skin-temperature sensor; WHOOP's strap is second, Fitbit third.
skinTemperature: ["WITHINGS", "WHOOP", "FITBIT", "APPLE_HEALTH"],
respiratoryRate: ["WHOOP", "FITBIT", "APPLE_HEALTH", "WITHINGS"],
// v1.11.0 — native-vs-derived recovery. WHOOP's device-native Recovery
// outranks HealthLog's COMPUTED proxy when both exist; the proxy is the
// fallback for users without a strap.