feat(schema): APNs device columns + dispatcher channel-type extension

Adds the per-device APNs identity pair (`apnsToken` + `apnsEnvironment`)
to the Device model so the dispatcher can fan a notification out to a
user's iPhone via Apple's push gateway. The iOS client decides whether
the token belongs to the sandbox or production gateway at registration
time — the server never auto-detects, mirroring the W1 stream-2
recommendation.

Migration 0037 is strictly additive: both columns nullable, a CHECK
constraint pairs them so neither can be set without the other, and a
single-column index on `apns_token` backs the dispatcher's per-user
lookup plus the cross-user-hijack guard already enforced in
`POST /api/devices`. The `NotificationChannel.type` column stays a
free-form string — adding "APNS" as the fourth channel-type is a
TypeScript-side change in the next commit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Marc-André BombeckandClaude Opus 4.7 committed 2026-05-11 00:35:58 +02:00
1 parent bb76ea4f98
commit 70789170cf
2 files changed
+55 -13

No files matched your search

@@ -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");
+25 -13
View File
@@ -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")
}