mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 13:52:20 +00:00
feat(google-health): add the connection data model and migration
Introduce the data model behind a Google Health provider that reads Fitbit, Pixel Watch, and Fitbit Air data through a user's Google account. - GoogleHealthConnection: 1:1 with the user, encrypted access/refresh tokens, token expiry, external Google user id, sync high-water-mark, and a needsReauth flag for the periodic re-consent that Google's Testing-mode clients require. - GoogleHealthOAuthState: the connect-flow nonce ledger with the PKCE verifier. - Two encrypted BYO-client columns on User for the operator's own Google OAuth client id and secret. - GOOGLE_HEALTH added to the measurement_source enum. Migration 0224 mirrors the existing integration shape: a standalone enum-add, guarded CREATE TABLE / ADD COLUMN, forward-only and idempotent.
This commit is contained in:
1 parent
3bceb61aed
commit
f56268adf6
2 files changed
+166
No files matched your search
@@ -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;
|
||||
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user