// Schema do domínio de aplicação do B2BCall. Vive no schema "public" do // Postgres — nunca misturado com as tabelas do Asterisk Realtime (schema // "asterisk", ver infrastructure/postgres/init/002-asterisk-realtime.sql). // // Modelado incrementalmente por fase (ver TODO.md): esta primeira migration // cobre apenas autenticação, RBAC e auditoria (Fase 3). Demais entidades // (agentes, troncos, filas, campanhas, leads, ...) chegam em migrations // subsequentes, nunca alteração manual de schema. generator client { provider = "prisma-client-js" } datasource db { provider = "postgresql" url = env("DATABASE_URL") } model User { id String @id @default(uuid()) name String email String @unique passwordHash String @map("password_hash") isActive Boolean @default(true) @map("is_active") mustChangePassword Boolean @default(false) @map("must_change_password") lastLoginAt DateTime? @map("last_login_at") createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") roles UserRole[] sessions Session[] passwordResetTokens PasswordResetToken[] auditLogs AuditLog[] agent Agent? @@map("users") } model Session { id String @id @default(uuid()) userId String @map("user_id") refreshTokenHash String @map("refresh_token_hash") userAgent String? @map("user_agent") ipAddress String? @map("ip_address") expiresAt DateTime @map("expires_at") revokedAt DateTime? @map("revoked_at") createdAt DateTime @default(now()) @map("created_at") user User @relation(fields: [userId], references: [id], onDelete: Cascade) @@index([userId]) @@index([expiresAt]) @@map("sessions") } model PasswordResetToken { id String @id @default(uuid()) userId String @map("user_id") tokenHash String @unique @map("token_hash") expiresAt DateTime @map("expires_at") usedAt DateTime? @map("used_at") createdAt DateTime @default(now()) @map("created_at") user User @relation(fields: [userId], references: [id], onDelete: Cascade) @@index([userId]) @@map("password_reset_tokens") } model Role { id String @id @default(uuid()) name String @unique description String? isSystem Boolean @default(false) @map("is_system") createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") users UserRole[] permissions RolePermission[] @@map("roles") } model Permission { id String @id @default(uuid()) key String @unique description String? roles RolePermission[] @@map("permissions") } model UserRole { userId String @map("user_id") roleId String @map("role_id") user User @relation(fields: [userId], references: [id], onDelete: Cascade) role Role @relation(fields: [roleId], references: [id], onDelete: Cascade) @@id([userId, roleId]) @@map("user_roles") } model RolePermission { roleId String @map("role_id") permissionId String @map("permission_id") role Role @relation(fields: [roleId], references: [id], onDelete: Cascade) permission Permission @relation(fields: [permissionId], references: [id], onDelete: Cascade) @@id([roleId, permissionId]) @@map("role_permissions") } model AuditLog { id BigInt @id @default(autoincrement()) userId String? @map("user_id") action String entityType String? @map("entity_type") entityId String? @map("entity_id") before Json? after Json? ipAddress String? @map("ip_address") userAgent String? @map("user_agent") createdAt DateTime @default(now()) @map("created_at") user User? @relation(fields: [userId], references: [id], onDelete: SetNull) @@index([userId]) @@index([entityType, entityId]) @@index([createdAt]) @@map("audit_logs") } // =========================================================================== // Fase 4 — Telefonia. CRUD da aplicação; os objetos PJSIP correspondentes // (ps_endpoints/ps_auths/ps_aors/ps_endpoint_id_ips/ps_registrations) são // provisionados no schema "asterisk" pelo TrunksService/ExtensionsService // (packages/telephony), nunca editados manualmente. // =========================================================================== enum TrunkType { IP AUTH REGISTRATION } enum DtmfMode { rfc4733 info inband auto } model Trunk { id String @id @default(uuid()) name String @unique type TrunkType host String port Int @default(5060) transport String @default("udp") username String? // Segredo cifrado em repouso (AES-256-GCM, master key fora do banco — // agente.md seção 55). Nunca retornado em claro pela API após salvar. secretEncrypted String? @map("secret_encrypted") fromUser String? @map("from_user") fromDomain String? @map("from_domain") contactUser String? @map("contact_user") outboundProxy String? @map("outbound_proxy") context String @default("outbound") callerId String? @map("caller_id") codecs String[] @default(["ulaw", "alaw"]) dtmfMode DtmfMode @default(rfc4733) @map("dtmf_mode") qualifyFrequency Int @default(60) @map("qualify_frequency") maxChannels Int? @map("max_channels") maxCps Int @default(5) @map("max_cps") allowedIps String[] @default([]) @map("allowed_ips") enabled Boolean @default(true) createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") campaigns Campaign[] @@map("trunks") } model Extension { id String @id @default(uuid()) number String @unique name String sipPasswordEncrypted String @map("sip_password_encrypted") callerId String? @map("caller_id") context String @default("b2bcall-agents") codecs String[] @default(["ulaw", "alaw"]) transport String @default("udp") maxContacts Int @default(1) @map("max_contacts") qualifyFrequency Int @default(60) @map("qualify_frequency") enabled Boolean @default(true) createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") @@map("extensions") } // Último estado conhecido de cada ramal — alimentado por // apps/asterisk-events a partir de DeviceStateChange/ContactStatus. // Fonte do painel de monitoramento (nunca polling do Asterisk no frontend). model ExtensionState { extension String @id deviceState String? @map("device_state") contactStatus String? @map("contact_status") contactUri String? @map("contact_uri") updatedAt DateTime @updatedAt @map("updated_at") @@map("extension_states") } // =========================================================================== // Dialplan estruturado e versionado (agente.md seção 21). O modo "Advanced" // citado na spec é apenas uma restrição de permissão no frontend sobre os // mesmos dados — não um formato de armazenamento diferente. // =========================================================================== model DialplanEntry { id String @id @default(uuid()) context String exten String priority Int application String argument String? enabled Boolean @default(true) order Int @default(0) createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") @@index([context]) @@map("dialplan_entries") } enum DialplanVersionStatus { APPLIED FAILED ROLLED_BACK } model DialplanVersion { id String @id @default(uuid()) generatedConfig String @map("generated_config") status DialplanVersionStatus reloadResult String? @map("reload_result") createdById String? @map("created_by_id") createdAt DateTime @default(now()) @map("created_at") @@map("dialplan_versions") } // =========================================================================== // Fase 5 — Call Center. Usuário da aplicação (User) e agente de Call Center // (Agent) são conceitos separados (agente.md seção 16): uma pessoa pode // possuir os dois associados 1:1. // =========================================================================== enum QueueStrategy { ringall leastrecent fewestcalls random rrmemory rrordered linear wrandom } model Queue { id String @id @default(uuid()) name String @unique number String @unique strategy QueueStrategy @default(ringall) timeout Int @default(15) retry Int @default(5) wrapUpTime Int @default(0) @map("wrap_up_time") maxLen Int @default(0) @map("max_len") musicOnHold String @default("default") @map("music_on_hold") announce String? serviceLevel Int @default(60) @map("service_level") autoFill Boolean @default(true) @map("auto_fill") ringInUse Boolean @default(true) @map("ring_in_use") weight Int @default(0) enabled Boolean @default(true) createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") members QueueMember[] campaigns Campaign[] @@map("queues") } model QueueMember { id String @id @default(uuid()) queueId String @map("queue_id") agentId String @map("agent_id") penalty Int @default(0) createdAt DateTime @default(now()) @map("created_at") queue Queue @relation(fields: [queueId], references: [id], onDelete: Cascade) agent Agent @relation(fields: [agentId], references: [id], onDelete: Cascade) @@unique([queueId, agentId]) @@map("queue_members") } model PauseReason { id String @id @default(uuid()) name String code String @unique description String? maxDurationSeconds Int? @map("max_duration_seconds") paid Boolean @default(false) active Boolean @default(true) createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") pauseEvents AgentPauseEvent[] @@map("pause_reasons") } model Agent { id String @id @default(uuid()) code String @unique name String userId String @unique @map("user_id") active Boolean @default(true) currentExtension String? @map("current_extension") createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") user User @relation(fields: [userId], references: [id], onDelete: Cascade) sessions AgentSession[] stateEvents AgentStateEvent[] pauseEvents AgentPauseEvent[] queues QueueMember[] @@map("agents") } model AgentSession { id String @id @default(uuid()) agentId String @map("agent_id") extension String startedAt DateTime @default(now()) @map("started_at") endedAt DateTime? @map("ended_at") agent Agent @relation(fields: [agentId], references: [id], onDelete: Cascade) @@index([agentId]) @@map("agent_sessions") } enum AgentState { OFFLINE LOGGED_IN AVAILABLE RINGING IN_CALL WRAP_UP PAUSED } // Trilha de auditoria da máquina de estados do agente (agente.md seção 48). // Sempre exatamente um registro "aberto" (endedAt = null) por agente. model AgentStateEvent { id String @id @default(uuid()) agentId String @map("agent_id") state AgentState startedAt DateTime @default(now()) @map("started_at") endedAt DateTime? @map("ended_at") agent Agent @relation(fields: [agentId], references: [id], onDelete: Cascade) @@index([agentId, endedAt]) @@map("agent_state_events") } model AgentPauseEvent { id String @id @default(uuid()) agentId String @map("agent_id") pauseReasonId String @map("pause_reason_id") startedAt DateTime @default(now()) @map("started_at") endedAt DateTime? @map("ended_at") agent Agent @relation(fields: [agentId], references: [id], onDelete: Cascade) pauseReason PauseReason @relation(fields: [pauseReasonId], references: [id], onDelete: Restrict) @@index([agentId, endedAt]) @@map("agent_pause_events") } // =========================================================================== // Fase 6 — Campanhas e Discador Preditivo. Núcleo mais crítico do sistema // (agente.md seção 30). O Asterisk nunca é fonte de verdade para este // domínio (seção 97) — todo o estado vive aqui. // =========================================================================== enum CampaignStatus { DRAFT READY RUNNING PAUSED DRAINING STOPPED COMPLETED } model Campaign { id String @id @default(uuid()) name String @unique description String? queueId String @map("queue_id") trunkId String @map("trunk_id") callerId String? @map("caller_id") context String @default("outbound") status CampaignStatus @default(DRAFT) startDate DateTime? @map("start_date") endDate DateTime? @map("end_date") // 0=domingo .. 6=sábado (ISO-like, mas com domingo em 0 por simplicidade // de exibição em pt-BR). Vazio = todos os dias. daysOfWeek Int[] @default([1, 2, 3, 4, 5]) @map("days_of_week") startTime String @default("08:00") @map("start_time") endTime String @default("20:00") @map("end_time") timezone String @default("America/Sao_Paulo") maxCps Int @default(2) @map("max_cps") maxConcurrentCalls Int @default(10) @map("max_concurrent_calls") pacingInitial Float @default(1.0) @map("pacing_initial") pacingMin Float @default(0.5) @map("pacing_min") pacingMax Float @default(3.0) @map("pacing_max") targetAbandonRate Float @default(0.03) @map("target_abandon_rate") maxWaitForAgentSeconds Int @default(30) @map("max_wait_for_agent_seconds") ringTimeoutSeconds Int @default(25) @map("ring_timeout_seconds") maxAttempts Int @default(5) @map("max_attempts") // Minutos de espera até nova tentativa por causa de encerramento. // Default reflete agente.md seção 79. retryRules Json @default("{\"BUSY\":15,\"NO_ANSWER\":60,\"CONGESTION\":5,\"FAILED\":30}") @map("retry_rules") amdEnabled Boolean @default(false) @map("amd_enabled") wrapUpTimeSeconds Int @default(0) @map("wrap_up_time_seconds") createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") queue Queue @relation(fields: [queueId], references: [id], onDelete: Restrict) trunk Trunk @relation(fields: [trunkId], references: [id], onDelete: Restrict) leads Lead[] imports LeadImport[] attempts DialAttempt[] callbacks Callback[] @@map("campaigns") } enum LeadStatus { NEW READY RESERVED DIALING RINGING ANSWERED CONNECTED_AGENT BUSY NO_ANSWER FAILED INVALID VOICEMAIL CALLBACK COMPLETED DO_NOT_CALL MAX_ATTEMPTS } enum LeadImportStatus { PROCESSING COMPLETED FAILED } model LeadImport { id String @id @default(uuid()) campaignId String @map("campaign_id") filename String status LeadImportStatus @default(PROCESSING) totalRows Int @default(0) @map("total_rows") validRows Int @default(0) @map("valid_rows") invalidRows Int @default(0) @map("invalid_rows") duplicateRows Int @default(0) @map("duplicate_rows") rejectedCsv String? @map("rejected_csv") createdById String? @map("created_by_id") createdAt DateTime @default(now()) @map("created_at") campaign Campaign @relation(fields: [campaignId], references: [id], onDelete: Cascade) leads Lead[] @@map("lead_imports") } model Lead { id String @id @default(uuid()) campaignId String @map("campaign_id") importId String? @map("import_id") name String? phone String phoneNormalized String @map("phone_normalized") status LeadStatus @default(NEW) attemptCount Int @default(0) @map("attempt_count") lastAttemptAt DateTime? @map("last_attempt_at") nextAttemptAt DateTime? @map("next_attempt_at") lastResult String? @map("last_result") reservedAt DateTime? @map("reserved_at") reservedBy String? @map("reserved_by") customFields Json? @map("custom_fields") createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") campaign Campaign @relation(fields: [campaignId], references: [id], onDelete: Cascade) import LeadImport? @relation(fields: [importId], references: [id], onDelete: SetNull) attempts DialAttempt[] callbacks Callback[] @@index([campaignId, status, nextAttemptAt]) @@index([phoneNormalized]) @@map("leads") } enum CallState { CREATED RESERVED ORIGINATING RINGING ANSWERED QUEUED AGENT_CONNECTED COMPLETED FAILED } enum AmdResult { HUMAN MACHINE NOT_SURE HANGUP } // Tentativa de discagem — também a "chamada" em si (agente.md seção 36: // "criar chamadas como state machine"). attempt_id é o próprio id, nunca o // UNIQUEID do Asterisk (seção 97: "não utilize UNIQUEID como PK de negócio"). model DialAttempt { id String @id @default(uuid()) leadId String @map("lead_id") campaignId String @map("campaign_id") state CallState @default(CREATED) asteriskUniqueId String? @unique @map("asterisk_unique_id") asteriskLinkedId String? @map("asterisk_linked_id") calledNumber String @map("called_number") callerIdUsed String? @map("caller_id_used") agentId String? @map("agent_id") dispositionId String? @map("disposition_id") dispositionNotes String? @map("disposition_notes") amdResult AmdResult? @map("amd_result") hangupCause String? @map("hangup_cause") startedAt DateTime @default(now()) @map("started_at") ringingAt DateTime? @map("ringing_at") answeredAt DateTime? @map("answered_at") queuedAt DateTime? @map("queued_at") agentConnectedAt DateTime? @map("agent_connected_at") endedAt DateTime? @map("ended_at") lead Lead @relation(fields: [leadId], references: [id], onDelete: Cascade) campaign Campaign @relation(fields: [campaignId], references: [id], onDelete: Cascade) disposition CallDisposition? @relation(fields: [dispositionId], references: [id], onDelete: SetNull) @@index([campaignId, state]) @@index([leadId]) @@map("dial_attempts") } enum DispositionAction { NONE CALLBACK DO_NOT_CALL } model CallDisposition { id String @id @default(uuid()) name String code String @unique description String? action DispositionAction @default(NONE) active Boolean @default(true) createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") attempts DialAttempt[] @@map("call_dispositions") } model Callback { id String @id @default(uuid()) leadId String @map("lead_id") campaignId String @map("campaign_id") preferredAgentId String? @map("preferred_agent_id") scheduledAt DateTime @map("scheduled_at") timezone String @default("America/Sao_Paulo") notes String? completed Boolean @default(false) createdAt DateTime @default(now()) @map("created_at") lead Lead @relation(fields: [leadId], references: [id], onDelete: Cascade) campaign Campaign @relation(fields: [campaignId], references: [id], onDelete: Cascade) @@index([scheduledAt, completed]) @@map("call_callbacks") } model SuppressionEntry { id String @id @default(uuid()) phoneNormalized String @unique @map("phone_normalized") reason String? addedById String? @map("added_by_id") createdAt DateTime @default(now()) @map("created_at") @@map("suppression_list") } // =========================================================================== // Fase 7 — Compliance (agente.md seção 34). Parâmetros configuráveis, nunca // hardcoded para uma legislação específica — o sistema AJUDA a cumprir // regras, nunca burla mecanismos antispam/autenticação de operadora. // =========================================================================== // Linha única (singleton lógico) de parâmetros de compliance. model ComplianceSettings { id String @id @default(uuid()) shortCallThresholdSeconds Int @default(3) @map("short_call_threshold_seconds") maxAttemptsPerNumberPerDay Int @default(3) @map("max_attempts_per_number_per_day") maxAttemptsPerNumberPerMonth Int @default(10) @map("max_attempts_per_number_per_month") highVolumeMonthlyThreshold Int @default(1000000) @map("high_volume_monthly_threshold") updatedAt DateTime @updatedAt @map("updated_at") @@map("compliance_settings") }