generator client { provider = "prisma-client-js" } datasource db { provider = "postgresql" url = env("DATABASE_URL") } // ============================================================================ // Tenancy // ============================================================================ model Tenant { id String @id @default(cuid()) slug String @unique name String isActive Boolean @default(true) isForwardingPaused Boolean @default(false) settings Json @default("{}") createdAt DateTime @default(now()) updatedAt DateTime @updatedAt admins Admin[] tenantBots TenantBot[] groups Group[] messages Message[] approvals Approval[] syncRoutes SyncRoute[] consentRecords ConsentRecord[] memberOptOuts MemberOptOut[] towerUsers TowerUser[] auditEvents AuditEvent[] rules TenantRule[] groupAccesses GroupAccess[] } enum AdminRole { OWNER ADMIN VIEWER } model Admin { id String @id @default(cuid()) tenantId String tenant Tenant @relation(fields: [tenantId], references: [id]) email String passwordHash String role AdminRole @default(ADMIN) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt claimedGroups Group[] @relation("claimer") @@unique([tenantId, email]) @@index([tenantId]) } model SuperAdmin { id String @id @default(cuid()) email String @unique passwordHash String name String? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt } enum ActorType { ADMIN SYSTEM ADAPTER MEMBER } model AuditEvent { id String @id @default(cuid()) tenantId String tenant Tenant @relation(fields: [tenantId], references: [id]) actorType ActorType actorId String? action String resourceType String resourceId String payload Json @default("{}") traceId String? createdAt DateTime @default(now()) @@index([tenantId, createdAt]) @@index([resourceType, resourceId]) } // ============================================================================ // WhatsApp accounts (Phase 2B: bots only, tenant-less, accessed via TenantBot) // ============================================================================ enum AccountStatus { ACTIVE DISCONNECTED BANNED PAIRING } model Account { id String @id @default(cuid()) // tenantId REMOVED in Phase 2B — bots are global, access scoped via TenantBot platform String jid String sessionPath String displayName String? status AccountStatus @default(ACTIVE) qrCode String? isBot Boolean @default(true) pairingToken String? @unique pairingExpiresAt DateTime? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt tenants TenantBot[] groups Group[] @@unique([platform, jid]) @@index([isBot]) @@index([status]) } // Many-to-many: which tenants may claim groups from which bot. // Phase 2B ships with implicit "all tenants" (UI auto-grants on first claim), // but the table is wired so multi-bot + restricted sharing works in later phases. model TenantBot { id String @id @default(cuid()) tenantId String tenant Tenant @relation(fields: [tenantId], references: [id]) accountId String account Account @relation(fields: [accountId], references: [id]) isActive Boolean @default(true) createdAt DateTime @default(now()) @@unique([tenantId, accountId]) @@index([accountId]) } // ============================================================================ // Groups + claim lifecycle // ============================================================================ enum GroupClaimStatus { PENDING_CLAIM CLAIMED RELEASED EXPIRED } model Group { id String @id @default(cuid()) // tenantId nullable: null while PENDING_CLAIM/EXPIRED, set once CLAIMED tenantId String? tenant Tenant? @relation(fields: [tenantId], references: [id]) platform String platformId String name String description String? isActive Boolean @default(true) accountId String? account Account? @relation(fields: [accountId], references: [id]) claimStatus GroupClaimStatus @default(PENDING_CLAIM) claimedAt DateTime? claimedByAdminId String? claimedByAdmin Admin? @relation("claimer", fields: [claimedByAdminId], references: [id]) claimExpiresAt DateTime? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt messages Message[] syncRoutesFrom SyncRoute[] @relation("sourceGroup") syncRoutesTo SyncRoute[] @relation("targetGroup") consents ConsentRecord[] claimTokens GroupClaimToken[] groupAccesses GroupAccess[] @@unique([platform, platformId]) @@index([accountId]) @@index([tenantId]) @@index([claimStatus]) } // ============================================================================ // Message ingest + approval // ============================================================================ model Message { id String @id @default(cuid()) tenantId String tenant Tenant @relation(fields: [tenantId], references: [id]) platform String platformMsgId String sourceGroupId String sourceGroup Group @relation(fields: [sourceGroupId], references: [id]) senderJid String senderName String? senderTowerUserId String? senderTowerUser TowerUser? @relation("senderTowerUser", fields: [senderTowerUserId], references: [id]) content String mediaUrl String? tags String[] status MessageStatus @default(PENDING) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt approval Approval? @@unique([platform, platformMsgId]) @@index([tenantId]) @@index([senderTowerUserId]) } enum MessageStatus { PENDING APPROVED REJECTED DISTRIBUTED ARCHIVED } model Approval { id String @id @default(cuid()) tenantId String tenant Tenant @relation(fields: [tenantId], references: [id]) messageId String @unique message Message @relation(fields: [messageId], references: [id]) adminId String decision ApprovalDecision notes String? decidedAt DateTime @default(now()) @@index([tenantId]) } enum ApprovalDecision { APPROVED REJECTED } model SyncRoute { id String @id @default(cuid()) tenantId String tenant Tenant @relation(fields: [tenantId], references: [id]) sourceGroupId String sourceGroup Group @relation("sourceGroup", fields: [sourceGroupId], references: [id]) targetGroupId String targetGroup Group @relation("targetGroup", fields: [targetGroupId], references: [id]) isActive Boolean @default(true) createdAt DateTime @default(now()) @@unique([sourceGroupId, targetGroupId]) @@index([tenantId]) } // ============================================================================ // Group claiming + sharing // ============================================================================ model GroupClaimToken { id String @id @default(cuid()) groupId String group Group @relation(fields: [groupId], references: [id]) token String @unique creatorJid String expiresAt DateTime consumedAt DateTime? createdAt DateTime @default(now()) @@index([expiresAt]) } model GroupAccess { id String @id @default(cuid()) groupId String group Group @relation(fields: [groupId], references: [id]) tenantId String tenant Tenant @relation(fields: [tenantId], references: [id]) grantedBy String createdAt DateTime @default(now()) @@unique([groupId, tenantId]) @@index([tenantId]) } // ============================================================================ // Member onboarding (Phase 2B) // ============================================================================ enum ConsentScope { INGEST ARCHIVE REPLICATE DISPLAY } enum ConsentStatus { GRANTED REVOKED } enum MemberOptOutReason { STOP_KEYWORD SELF_PORTAL ADMIN_ACTION } // Hashed identity: SHA-256 of E.164 phone number (pepper via JWT_SECRET). model TowerUser { id String @id @default(cuid()) tenantId String tenant Tenant @relation(fields: [tenantId], references: [id]) phoneHash String jid String displayName String? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt consents ConsentRecord[] optOuts MemberOptOut[] sessions TowerSession[] messages Message[] @relation("senderTowerUser") @@unique([tenantId, phoneHash]) @@index([phoneHash]) @@index([tenantId]) @@index([jid]) } model TowerSession { id String @id @default(cuid()) userId String user TowerUser @relation(fields: [userId], references: [id]) tokenHash String @unique expiresAt DateTime createdAt DateTime @default(now()) @@index([userId]) @@index([expiresAt]) } model ConsentRecord { id String @id @default(cuid()) tenantId String tenant Tenant @relation(fields: [tenantId], references: [id]) groupId String group Group @relation(fields: [groupId], references: [id]) userId String user TowerUser @relation(fields: [userId], references: [id]) scopes ConsentScope[] retentionDays Int @default(90) policyVersion String status ConsentStatus @default(GRANTED) proofEventId String effectiveAt DateTime @default(now()) revokedAt DateTime? createdAt DateTime @default(now()) @@index([tenantId, groupId, userId]) @@index([status]) @@index([userId]) } model MemberOptOut { id String @id @default(cuid()) tenantId String tenant Tenant @relation(fields: [tenantId], references: [id]) userId String user TowerUser @relation(fields: [userId], references: [id]) groupId String? reason MemberOptOutReason notes String? createdAt DateTime @default(now()) @@index([tenantId, userId]) @@index([groupId]) @@index([userId]) } model OtpChallenge { id String @id @default(cuid()) tenantId String jid String phoneHash String code String scopes ConsentScope[] retentionDays Int @default(90) policyVersion String groupId String expiresAt DateTime consumedAt DateTime? sentAt DateTime? createdAt DateTime @default(now()) @@index([tenantId, jid]) @@index([expiresAt]) @@index([sentAt]) } // ============================================================================ // Tenant Rules Engine // ============================================================================ enum RuleMatchType { HASHTAG PREFIX REACTION_EMOJI } enum RuleAction { FLAG AUTO_APPROVE SKIP REJECT } model TenantRule { id String @id @default(cuid()) tenantId String tenant Tenant @relation(fields: [tenantId], references: [id]) matchType RuleMatchType matchValue String action RuleAction priority Int @default(0) isActive Boolean @default(true) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@unique([tenantId, matchType, matchValue]) @@index([tenantId, isActive]) @@index([tenantId, matchType]) }