From c8387d4f2bd288c6ea15c25086a7d12e7f0eeb10 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 3 May 2026 21:41:52 +0200 Subject: [PATCH] =?UTF-8?q?chore(db):=20migration=200023=20=E2=80=94=20ide?= =?UTF-8?q?mpotency=5Fkeys,=20devices,=20healthkit=20user=20columns?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Purely additive migration: • users.display_name, users.healthkit_config_json, users.healthkit_last_synced_at — all nullable, no default backfill needed. • New table idempotency_keys for the per-user POST-replay cache, unique on (user_id, key, method, path), plus expires_at index for the future cleanup sweep. • New table devices for native push-token registration. Token has a unique index; re-registering moves ownership. No existing column was altered; no data backfill; deploy is safe under concurrent traffic. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../migration.sql | 55 ++++++++++++++++++ prisma/schema.prisma | 56 +++++++++++++++++++ 2 files changed, 111 insertions(+) create mode 100644 prisma/migrations/0023_ios_adapters_idempotency_devices/migration.sql diff --git a/prisma/migrations/0023_ios_adapters_idempotency_devices/migration.sql b/prisma/migrations/0023_ios_adapters_idempotency_devices/migration.sql new file mode 100644 index 000000000..a3c1c9abf --- /dev/null +++ b/prisma/migrations/0023_ios_adapters_idempotency_devices/migration.sql @@ -0,0 +1,55 @@ +-- v1.3: iOS adapter endpoints + idempotency cache + device registration +-- +-- Purely additive: +-- • users.display_name, users.healthkit_config_json, users.healthkit_last_synced_at +-- • new tables: idempotency_keys, devices + +-- ── users new columns ───────────────────────────────────────────────── + +ALTER TABLE "users" + ADD COLUMN "display_name" TEXT, + ADD COLUMN "healthkit_config_json" JSONB, + ADD COLUMN "healthkit_last_synced_at" TIMESTAMP(3); + +-- ── idempotency_keys ────────────────────────────────────────────────── + +CREATE TABLE "idempotency_keys" ( + "id" TEXT PRIMARY KEY, + "user_id" TEXT NOT NULL, + "key" TEXT NOT NULL, + "method" TEXT NOT NULL, + "path" TEXT NOT NULL, + "response_status" INTEGER NOT NULL, + "response_body" TEXT NOT NULL, + "expires_at" TIMESTAMP(3) NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "idempotency_keys_user_id_fkey" + FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE +); + +CREATE UNIQUE INDEX "idempotency_keys_user_id_key_method_path_key" + ON "idempotency_keys" ("user_id", "key", "method", "path"); + +CREATE INDEX "idempotency_keys_expires_at_idx" + ON "idempotency_keys" ("expires_at"); + +-- ── devices ─────────────────────────────────────────────────────────── + +CREATE TABLE "devices" ( + "id" TEXT PRIMARY KEY, + "user_id" TEXT NOT NULL, + "platform" TEXT NOT NULL, + "token" TEXT NOT NULL, + "bundle_id" TEXT NOT NULL, + "locale" TEXT, + "app_version" TEXT, + "model" TEXT, + "last_seen" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + CONSTRAINT "devices_user_id_fkey" + FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE +); + +CREATE UNIQUE INDEX "devices_token_key" ON "devices" ("token"); +CREATE INDEX "devices_user_id_idx" ON "devices" ("user_id"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 8768cf22d..52213ee66 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -74,6 +74,14 @@ model User { // Onboarding onboardingCompletedAt DateTime? @map("onboarding_completed_at") + // HealthKit (iOS) integration config — list of per-metric direction toggles. + // Shape: { entries: [{ id, kind, direction, enabled }], lastSyncedAt } + healthKitConfigJson Json? @map("healthkit_config_json") + healthKitLastSyncedAt DateTime? @map("healthkit_last_synced_at") + + // Display name (optional override of username for greetings). + displayName String? @map("display_name") + // Relations passkeys Passkey[] sessions Session[] @@ -90,6 +98,8 @@ model User { dataBackups DataBackup[] achievements UserAchievement[] feedback Feedback[] + idempotencyKeys IdempotencyKey[] + devices Device[] @@map("users") } @@ -559,6 +569,52 @@ model RateLimit { @@map("rate_limits") } +// ─── Idempotency Keys (mobile-client replay protection) ── + +/// Caches the response of a POST/PUT/PATCH/DELETE request keyed by +/// (userId, key, method, path) so retries within the TTL replay the +/// original outcome instead of producing a duplicate side-effect. +model IdempotencyKey { + id String @id @default(cuid()) + userId String @map("user_id") + key String + method String + path String + responseStatus Int @map("response_status") + responseBody String @map("response_body") // serialized JSON envelope + expiresAt DateTime @map("expires_at") + createdAt DateTime @default(now()) @map("created_at") + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@unique([userId, key, method, path]) + @@index([expiresAt]) + @@map("idempotency_keys") +} + +// ─── Devices (APNs / mobile push targets) ──────────────── + +/// Registered native-client device for push delivery. Token is unique +/// across users; re-registering on a different account moves ownership. +model Device { + id String @id @default(cuid()) + userId String @map("user_id") + platform String // "ios" + token String @unique + bundleId String @map("bundle_id") + locale String? + appVersion String? @map("app_version") + model String? + lastSeen DateTime @default(now()) @map("last_seen") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@index([userId]) + @@map("devices") +} + // ─── User Achievements ─────────────────────────────────────── model UserAchievement {