Files
HealthLog/prisma/migrations/0029_integration_status/migration.sql
T
Marc-André BombeckandClaude Opus 4.7 4db72a8c2e feat(integrations): connection-state + sync-error UI in Settings → Integrations
v1.4.15 Phase B2, criterion 1. Until this release the Settings →
Integrations card showed "Connected / Last sync 23 min ago" and
nothing else. A user whose Withings refresh token had revoked saw a
green badge; a user whose moodLog instance was down saw the same
green badge with a slightly older timestamp. The signal was useless.

This commit adds:

  - New `IntegrationStatus` table (`integration_statuses` migration
    `0029`). One row per (user, integration) capturing `state`
    (`connected | error_transient | error_reauth | disconnected`),
    `lastSuccessAt`, `lastAttemptAt`, encrypted `lastError`,
    `consecutiveFailures` counter, `alertedAt` window-guard.
  - `src/lib/integrations/status.ts` — the only writers
    (`recordSyncSuccess`, `recordSyncFailure`, `markReauthRequired`,
    `markReconnected`, `markDisconnected`) plus a read-only
    `getIntegrationStatus()` for the API. The error column is
    AES-256-GCM encrypted at rest because Withings/moodLog 401s can
    echo URL/apiKey fragments — we can't ship that into a future
    backup tarball.
  - `GET /api/integrations/status` — single roundtrip returns both
    Withings and moodLog snapshots plus the global threshold (so the
    UI's "{n}/{threshold}" string is server-sourced and rotates with
    the env var).
  - Settings → Integrations cards both gain an
    `IntegrationStatusBanner` row above the credentials form: state
    badge ("Connected" / "Reconnect required" / "Error" /
    "Disconnected"), `{n}/{threshold} consecutive failures` chip,
    last-success / last-attempt times, and a destructive-toned
    last-error line. The banner is suppressed entirely when the
    integration is fresh-connected with no history (no false alarms).

Tests: +20 unit (status helper threshold + alert-window state machine,
banner rendering across all four states) + 8 integration
(`tests/integration/integration-status.test.ts`) confirming
encrypted-at-rest, audit-log row shape, alert idempotency window,
and CASCADE-delete on User. 116 files / 883 unit pass, 11 integration
files / 39 pass.

Encryption uses the existing `src/lib/crypto.ts` AES-256-GCM helper —
no new key material. The `consecutive_failures` counter and
`alerted_at` window are wired to the dispatcher in the next commit
(`feat(integrations): admin Telegram alert on persistent sync
failure`).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 21:04:04 +02:00

53 lines
2.5 KiB
SQL

-- v1.4.15 Phase B2: per-(user, integration) sync status row.
--
-- The Settings → Integrations card needs more than a "connected yes/no"
-- bit to be useful: it must show last-success time, last-attempt time,
-- the most-recent error, and a "needs reconnect" state when the
-- refresh-token grant has revoked. A separate table is preferable to
-- piling more columns onto `users` / `withings_connections` because:
--
-- 1. moodLog credentials live on `users`, Withings tokens live on
-- `withings_connections` — neither is a good home for the OTHER
-- integration's status.
-- 2. Future integrations (HealthKit, Garmin, etc.) get a row here
-- without a schema migration per integration.
-- 3. The (userId, integration) UNIQUE constraint upserts cleanly
-- from the `recordSyncSuccess` / `recordSyncFailure` helpers in
-- `src/lib/integrations/status.ts`.
--
-- Encryption: `last_error` is AES-256-GCM encrypted at the application
-- layer (see `src/lib/crypto.ts`) because some upstream errors echo
-- back fragments of the user's URL or apiKey (moodLog 401 responses
-- can include the supplied Authorization header in the body). Keeping
-- this column ciphertext-only matches the Withings/moodLog credential
-- treatment elsewhere in the schema.
--
-- `consecutive_failures` resets to 0 on the next success and feeds the
-- admin-Telegram alert threshold (default 3, see PERSISTENT_FAILURE_*
-- constants in `src/lib/integrations/status.ts`). `alerted_at` is the
-- idempotency guard so the alert fires once per failure burst, not
-- once per failure.
CREATE TABLE "integration_statuses" (
"id" TEXT NOT NULL,
"user_id" TEXT NOT NULL,
"integration" TEXT NOT NULL,
"state" TEXT NOT NULL DEFAULT 'connected',
"last_success_at" TIMESTAMP(3),
"last_attempt_at" TIMESTAMP(3),
"last_error" TEXT,
"consecutive_failures" INTEGER NOT NULL DEFAULT 0,
"alerted_at" TIMESTAMP(3),
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "integration_statuses_pkey" PRIMARY KEY ("id")
);
CREATE INDEX "integration_statuses_user_id_idx" ON "integration_statuses"("user_id");
CREATE INDEX "integration_statuses_state_idx" ON "integration_statuses"("state");
CREATE UNIQUE INDEX "integration_statuses_user_id_integration_key" ON "integration_statuses"("user_id", "integration");
ALTER TABLE "integration_statuses" ADD CONSTRAINT "integration_statuses_user_id_fkey"
FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;