enum NotificationDeliveryStatus { PENDING SENT FAILED SKIPPED READ } enum NotificationPreferenceChannel { IN_APP EMAIL } model NotificationDelivery { id String @id @default(cuid()) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt tenantId String? userId String? channel NotificationPreferenceChannel category String messageKey String sourceKey String? status NotificationDeliveryStatus @default(PENDING) attemptsMade Int @default(0) providerMessageId String? lastError String? payloadMetadata Json? sentAt DateTime? readAt DateTime? tenant Tenant? @relation(fields: [tenantId], references: [id], onDelete: SetNull) user User? @relation(fields: [userId], references: [id], onDelete: SetNull) @@index([tenantId, userId, status], name: "notification_delivery_tenant_user_status_idx") @@index([tenantId, channel, category], name: "notification_delivery_tenant_channel_category_idx") @@index([status, updatedAt], name: "notification_delivery_status_updated_at_idx") @@index([providerMessageId], name: "notification_delivery_provider_message_id_idx") @@unique([tenantId, userId, channel, sourceKey], name: "notification_delivery_source_key") } model UserNotificationPreference { id String @id @default(cuid()) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt tenantId String userId String channel NotificationPreferenceChannel category String enabled Boolean @default(true) tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade) user User @relation(fields: [userId], references: [id], onDelete: Cascade) @@unique([tenantId, userId, channel, category], name: "user_notification_preference_scope_key") @@index([tenantId, userId, category], name: "user_notification_preference_tenant_user_category_idx") @@index([tenantId, category], name: "user_notification_preference_tenant_category_idx") } // DeviceToken (Phase 08 #65 — Capacitor mobile wrapper). // // Tracks active APNs / FCM / WebPush device tokens per user. The // register-device API endpoint upserts by `token` (globally unique). // `lastSeenAt` is bumped on every successful send so an out-of-band // cron can prune tokens older than 90d without disrupting active users. // `revokedAt` is set when the provider returns `Unregistered` or // `InvalidRegistration`; the worker job filters on `revokedAt IS NULL`. model DeviceToken { id String @id @default(cuid()) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt userId String tenantId String? platform String // 'ios' | 'android' | 'web' token String @unique pushProvider String // 'fcm' | 'apns' | 'webpush' appVersion String? lastSeenAt DateTime @default(now()) revokedAt DateTime? user User @relation(fields: [userId], references: [id], onDelete: Cascade) tenant Tenant? @relation(fields: [tenantId], references: [id], onDelete: SetNull) @@index([userId, tenantId], name: "device_token_user_tenant_idx") @@index([tenantId, platform], name: "device_token_tenant_platform_idx") @@index([revokedAt], name: "device_token_revoked_at_idx") }