diff --git a/prisma/migrations/0037_apns_device_columns/migration.sql b/prisma/migrations/0037_apns_device_columns/migration.sql new file mode 100644 index 000000000..536db4eb5 --- /dev/null +++ b/prisma/migrations/0037_apns_device_columns/migration.sql @@ -0,0 +1,30 @@ +-- v1.4.23 — APNs send-side scaffolding (Wave 3 / F4) +-- +-- Adds the per-device APNs identity pair so the dispatcher can fan a +-- notification out to a user's iPhone via Apple's push gateway. Strictly +-- additive: +-- * Both columns nullable — pre-iOS-app rows on disk stay valid. +-- * `apnsToken` indexed for the dispatcher's per-user lookup + +-- cross-user-hijack guard in `POST /api/devices`. +-- * CHECK constraint enforces "if you store an APNs token, you must +-- also tell us which gateway it belongs to". The iOS client decides +-- `sandbox` vs `production` at registration time — the server never +-- auto-detects (per W1 stream-2 recommendation). +-- +-- The `NotificationChannel.type` column is a free-form String, not a +-- Postgres enum, so adding the new "APNS" channel-type value is a +-- TypeScript-side change only — no DDL needed for the channel cascade. + +ALTER TABLE "devices" + ADD COLUMN "apns_token" TEXT, + ADD COLUMN "apns_environment" TEXT; + +ALTER TABLE "devices" + ADD CONSTRAINT "devices_apns_environment_required_with_token" + CHECK ( + ("apns_token" IS NULL AND "apns_environment" IS NULL) + OR + ("apns_token" IS NOT NULL AND "apns_environment" IS NOT NULL) + ); + +CREATE INDEX "devices_apns_token_idx" ON "devices" ("apns_token"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 5ab916862..9823265b0 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -678,7 +678,7 @@ model AuditLog { model NotificationChannel { id String @id @default(cuid()) userId String @map("user_id") - type String // "TELEGRAM" | "NTFY" | "WEB_PUSH" + type String // "TELEGRAM" | "NTFY" | "WEB_PUSH" | "APNS" enabled Boolean @default(true) config String // AES-256-GCM encrypted JSON (channel-specific config) createdAt DateTime @default(now()) @map("created_at") @@ -790,23 +790,35 @@ model IdempotencyKey { // ─── 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. +/// across users; re-registering on a different account is rejected with +/// 409 by `POST /api/devices` (cross-user-hijack guard). +/// +/// `apnsToken` and `apnsEnvironment` are nullable because today's iOS +/// builds register through a generic `token` first; a follow-up call +/// from the iOS app populates the APNs-specific pair once the device +/// receives its first push token from `application:didRegisterForRemoteNotificationsWithDeviceToken:`. +/// Loose-typed `apnsEnvironment` (sandbox|production) leaves headroom +/// for whatever new gateway labels Apple ships next without a forced +/// migration. 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") + 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? + apnsToken String? @map("apns_token") + apnsEnvironment String? @map("apns_environment") // "sandbox" | "production" + 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]) + @@index([apnsToken]) @@map("devices") }