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 {