diff --git a/prisma/migrations/0224_v127_google_health/migration.sql b/prisma/migrations/0224_v127_google_health/migration.sql new file mode 100644 index 000000000..e345d3232 --- /dev/null +++ b/prisma/migrations/0224_v127_google_health/migration.sql @@ -0,0 +1,86 @@ +-- v1.27.0 — Google Health API integration (Fitbit + Pixel Watch + Fitbit Air) +-- over `health.googleapis.com/v4` (schema + data layer). +-- +-- A separate, coexisting provider alongside the classic Fitbit Web API +-- integration (0117) — its own connection table, `GOOGLE_HEALTH` source enum +-- value, and OAuth client. Purely-additive: one enum-extension, two new tables, +-- and two new nullable columns on `users`. No backfill, no existing row touched. +-- +-- 1. `measurement_source` += `GOOGLE_HEALTH` for the server-side native ingest. +-- 2. `google_health_connections` — per-user encrypted token row (1:1 with +-- users), mirroring `fitbit_connections` plus a `needs_reauth` flag for the +-- Testing-mode 7-day refresh-expiry re-consent state. +-- 3. `google_health_oauth_states` — short-lived `(nonce → userId)` CSRF +-- ledger with the PKCE `code_verifier`, identical in shape to +-- `fitbit_oauth_states`. +-- 4. `users` += `google_health_client_id_encrypted` / +-- `google_health_client_secret_encrypted` — per-user BYO-keys (the CASA-free +-- "Testing" consent path caps a single OAuth client at ≤100 test users). +-- +-- Idempotent guards (`IF NOT EXISTS`) make reruns safe. Forward-only. +-- +-- Reversibility: Postgres cannot remove an enum value, so `GOOGLE_HEALTH` 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 Google Health server-owned source ── +ALTER TYPE "measurement_source" ADD VALUE IF NOT EXISTS 'GOOGLE_HEALTH'; + +-- ── 2. google_health_connections ────────────────────────────────────── +CREATE TABLE IF NOT EXISTS "google_health_connections" ( + "id" TEXT NOT NULL, + "user_id" TEXT NOT NULL, + "google_user_id" TEXT NOT NULL, + "access_token" TEXT NOT NULL, + "refresh_token" TEXT NOT NULL, + "token_expires_at" TIMESTAMP(3) NOT NULL, + "scope" TEXT, + "needs_reauth" BOOLEAN NOT NULL DEFAULT false, + "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 "google_health_connections_pkey" PRIMARY KEY ("id") +); + +CREATE UNIQUE INDEX IF NOT EXISTS "google_health_connections_user_id_key" + ON "google_health_connections" ("user_id"); + +DO $$ BEGIN + ALTER TABLE "google_health_connections" + ADD CONSTRAINT "google_health_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. google_health_oauth_states ───────────────────────────────────── +CREATE TABLE IF NOT EXISTS "google_health_oauth_states" ( + "nonce" TEXT NOT NULL, + "user_id" TEXT NOT NULL, + "code_verifier" TEXT, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "expires_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "google_health_oauth_states_pkey" PRIMARY KEY ("nonce") +); + +CREATE INDEX IF NOT EXISTS "google_health_oauth_states_expires_at_idx" + ON "google_health_oauth_states" ("expires_at"); + +DO $$ BEGIN + ALTER TABLE "google_health_oauth_states" + ADD CONSTRAINT "google_health_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 Google Health BYO-key columns (encrypted at app level) ─ +ALTER TABLE "users" + ADD COLUMN IF NOT EXISTS "google_health_client_id_encrypted" TEXT; +ALTER TABLE "users" + ADD COLUMN IF NOT EXISTS "google_health_client_secret_encrypted" TEXT; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index eb2ff9e3d..e2b483a3b 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -140,6 +140,14 @@ model User { fitbitClientIdEncrypted String? @map("fitbit_client_id_encrypted") fitbitClientSecretEncrypted String? @map("fitbit_client_secret_encrypted") + // v1.27.0 — Google Health API OAuth credentials (per-user BYO-keys, encrypted + // at app level). A separate provider from the classic Fitbit Web API columns + // above: the CASA-free "Testing" consent path caps a single OAuth client at + // ≤100 test users, so each self-hoster registers their own Google Cloud OAuth + // client and pastes the client id/secret into Settings. + googleHealthClientIdEncrypted String? @map("google_health_client_id_encrypted") + googleHealthClientSecretEncrypted String? @map("google_health_client_secret_encrypted") + // v1.17.0 — Nightscout glucose integration (F1, per-user, encrypted at app // level). A self-hoster points HealthLog at their own Nightscout instance: // the base URL + the API token / secret are stored encrypted. By default @@ -626,6 +634,11 @@ model User { // OAuth-state ledger (1:N) relations. fitbitConnection FitbitConnection? fitbitOAuthStates FitbitOAuthState[] + // v1.27.0 — Google Health API integration (separate provider from the classic + // Fitbit Web API above). Mirrors the connection (1:1) + OAuth-state ledger + // (1:N) relations. + googleHealthConnection GoogleHealthConnection? + googleHealthOAuthStates GoogleHealthOAuthState[] moodEntries MoodEntry[] mentalHealthAssessments MentalHealthAssessment[] moodTagsCustom MoodTag[] @relation("UserMoodTags") @@ -1453,6 +1466,13 @@ enum MeasurementSource { // surfaces still reject it (server-resolved only); iOS DTO mirrors this // spelling so the /api/sync/changes delta feed decodes the rows. MCP + // v1.27.0 — Google Health API integration (Fitbit + Pixel Watch + Fitbit + // Air) over `health.googleapis.com/v4`. A separate, coexisting provider + // alongside the classic Fitbit Web API integration (source `FITBIT`) with + // its own connection table and OAuth client. Server-owned (no client write + // path); rejected on batch + single POST like WITHINGS/WHOOP/FITBIT. iOS DTO + // mirrors this exact spelling so /api/sync/changes decodes the rows. + GOOGLE_HEALTH @@map("measurement_source") } @@ -3218,6 +3238,66 @@ model FitbitOAuthState { @@map("fitbit_oauth_states") } +/// v1.27.0 — Google Health connection (Fitbit + Pixel Watch + Fitbit Air) over +/// the Google Health API (`health.googleapis.com/v4`). A separate, coexisting +/// provider alongside the classic Fitbit Web API integration (`FitbitConnection`) +/// — its own token row, `GOOGLE_HEALTH` source enum value, and OAuth client. +/// Tokens are encrypted at the app level before persistence. Google refresh +/// tokens do NOT rotate per use, so `refreshToken` is overwritten only when a +/// token response carries a fresh one. +/// +/// `needsReauth` flags the CASA-free "Testing" consent path, where refresh +/// tokens expire after 7 days: the sync loop sets it on an `invalid_grant` +/// refresh failure so the card can surface a soft "reconnect" CTA rather than a +/// hard error, and the callback clears it on a fresh consent. +model GoogleHealthConnection { + id String @id @default(cuid()) + userId String @unique @map("user_id") + googleUserId String @map("google_user_id") // Google external account 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") + /// Set true when a refresh fails with `invalid_grant` (7-day Testing-mode + /// refresh expiry) so the connection reads as a soft disconnect awaiting + /// re-consent; cleared on a successful reconnect. + needsReauth Boolean @default(false) @map("needs_reauth") + /// Single high-water mark on interval `start_time` — Google exposes no sync + /// cursor, so incremental sync walks forward from here. + lastSyncedAt DateTime? @map("last_synced_at") + /// Set once the self-converging history backfill walks every data type 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("google_health_connections") +} + +/// v1.27.0 — short-lived `(nonce → userId)` ledger backing the Google Health +/// OAuth state check, identical in shape + lifecycle to `FitbitOAuthState`. +/// Google's OAuth is Authorization Code + PKCE (S256), so the connect route +/// mints a `code_verifier` alongside the nonce and stashes it here; the callback +/// reads it back to present on the token exchange. +model GoogleHealthOAuthState { + nonce String @id + userId String @map("user_id") + /// PKCE code_verifier carried from connect to callback. Null when no PKCE + /// handshake is in flight (legacy rows). + codeVerifier String? @map("code_verifier") + createdAt DateTime @default(now()) @map("created_at") + expiresAt DateTime @map("expires_at") + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@index([expiresAt]) + @@map("google_health_oauth_states") +} + // ─── Mood Entries (moodLog) ─────────────────────────────────── model MoodEntry {