diff --git a/.planning/ios-coord/v1.12.0-server-to-ios-fitbit-source-heads-up.md b/.planning/ios-coord/v1.12.0-server-to-ios-fitbit-source-heads-up.md new file mode 100644 index 000000000..04b6216b2 --- /dev/null +++ b/.planning/ios-coord/v1.12.0-server-to-ios-fitbit-source-heads-up.md @@ -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`). diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index eee70699e..201f73c0e 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -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 diff --git a/messages/de.json b/messages/de.json index 62f56f668..10302180e 100644 --- a/messages/de.json +++ b/messages/de.json @@ -3130,6 +3130,7 @@ "MANUAL": "Manuelle Eingabe", "IMPORT": "Import", "WHOOP": "WHOOP", + "FITBIT": "Fitbit", "COMPUTED": "Berechnet" }, "deviceLabels": { diff --git a/messages/en.json b/messages/en.json index 28e8ae249..d8fe8ad56 100644 --- a/messages/en.json +++ b/messages/en.json @@ -3130,6 +3130,7 @@ "MANUAL": "Manual entry", "IMPORT": "Import", "WHOOP": "WHOOP", + "FITBIT": "Fitbit", "COMPUTED": "Computed" }, "deviceLabels": { diff --git a/messages/es.json b/messages/es.json index fa606ac4b..0c78edc61 100644 --- a/messages/es.json +++ b/messages/es.json @@ -3130,6 +3130,7 @@ "MANUAL": "Entrada manual", "IMPORT": "Importar", "WHOOP": "WHOOP", + "FITBIT": "Fitbit", "COMPUTED": "Calculado" }, "deviceLabels": { diff --git a/messages/fr.json b/messages/fr.json index fb6ca414a..81d7b018b 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -3130,6 +3130,7 @@ "MANUAL": "Saisie manuelle", "IMPORT": "Importer", "WHOOP": "WHOOP", + "FITBIT": "Fitbit", "COMPUTED": "Calculé" }, "deviceLabels": { diff --git a/messages/it.json b/messages/it.json index d03d132d7..61a22b2af 100644 --- a/messages/it.json +++ b/messages/it.json @@ -3130,6 +3130,7 @@ "MANUAL": "Inserimento manuale", "IMPORT": "Importa", "WHOOP": "WHOOP", + "FITBIT": "Fitbit", "COMPUTED": "Calcolato" }, "deviceLabels": { diff --git a/messages/pl.json b/messages/pl.json index 8229ce793..1c83dc2f0 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -3130,6 +3130,7 @@ "MANUAL": "Wpis ręczny", "IMPORT": "Importuj", "WHOOP": "WHOOP", + "FITBIT": "Fitbit", "COMPUTED": "Obliczone" }, "deviceLabels": { diff --git a/prisma/migrations/0117_v1120_fitbit_integration/migration.sql b/prisma/migrations/0117_v1120_fitbit_integration/migration.sql new file mode 100644 index 000000000..d909b7466 --- /dev/null +++ b/prisma/migrations/0117_v1120_fitbit_integration/migration.sql @@ -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; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 358f0c0ad..8880649d1 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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 { diff --git a/scripts/rotate-encryption-key.ts b/scripts/rotate-encryption-key.ts index 92c730a50..9b14c2aab 100644 --- a/scripts/rotate-encryption-key.ts +++ b/scripts/rotate-encryption-key.ts @@ -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, + }); + }, + ); + results.push(r); + } + // ───── AppSettings table (singleton typically) ───── const settings = await prisma.appSettings.findMany({ select: { diff --git a/src/components/settings/sources-section.tsx b/src/components/settings/sources-section.tsx index 8b524a3bb..232162404 100644 --- a/src/components/settings/sources-section.tsx +++ b/src/components/settings/sources-section.tsx @@ -94,6 +94,8 @@ const SOURCE_LABEL_KEYS: Record = { // `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 = { diff --git a/src/lib/integrations/status.ts b/src/lib/integrations/status.ts index 40a0d8e57..126baab0c 100644 --- a/src/lib/integrations/status.ts +++ b/src/lib/integrations/status.ts @@ -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]; diff --git a/src/lib/query-keys.ts b/src/lib/query-keys.ts index 2dffcd2eb..eb723122b 100644 --- a/src/lib/query-keys.ts +++ b/src/lib/query-keys.ts @@ -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 + diff --git a/src/lib/sources/pick-canonical-workout.ts b/src/lib/sources/pick-canonical-workout.ts index 7ac8aa3ca..06de50099 100644 --- a/src/lib/sources/pick-canonical-workout.ts +++ b/src/lib/sources/pick-canonical-workout.ts @@ -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", diff --git a/src/lib/validations/__tests__/source-priority.test.ts b/src/lib/validations/__tests__/source-priority.test.ts index 9d36249f1..a8953d8ca 100644 --- a/src/lib/validations/__tests__/source-priority.test.ts +++ b/src/lib/validations/__tests__/source-priority.test.ts @@ -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); }); diff --git a/src/lib/validations/measurement.ts b/src/lib/validations/measurement.ts index be483d776..595826da8 100644 --- a/src/lib/validations/measurement.ts +++ b/src/lib/validations/measurement.ts @@ -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", ]); /** diff --git a/src/lib/validations/source-priority.ts b/src/lib/validations/source-priority.ts index d10b90a38..8011840ff 100644 --- a/src/lib/validations/source-priority.ts +++ b/src/lib/validations/source-priority.ts @@ -214,29 +214,35 @@ export type DeviceTypePriority = z.infer; * receives the same reading via Withings' Health Mate iOS app). */ export const DEFAULT_SOURCE_PRIORITY: Required = { - 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.