generator client { provider = "prisma-client-js" } datasource db { provider = "postgresql" } enum TenantStatus { TRIAL ACTIVE SUSPENDED PAST_DUE CANCELLED @@map("tenant_status") } // Privacidade de IA (agente.md secao 122) — resolvida em cascata // Campaign > Queue > Tenant (o mais específico que não for null vence). // Tenant é o único nível obrigatório (default OFF: preciso de opt-in // explícito antes de mandar áudio de cliente pra um provider externo). enum AIPrivacyLevel { AI_OFF TRANSCRIPTION_ONLY TRANSCRIPTION_AND_ANALYSIS @@map("ai_privacy_level") } model Tenant { id String @id @default(uuid()) @db.Uuid code String @unique slug String @unique legalName String @map("legal_name") tradeName String? @map("trade_name") taxId String? @map("tax_id") status TenantStatus @default(TRIAL) timezone String @default("America/Sao_Paulo") locale String @default("pt-BR") billingCurrency String @default("BRL") @map("billing_currency") // Domínio SIP deste tenant (secao 178, docs/EXTENSIONS.md) — precisa ser // único: é a chave que `b2bcall-fs-config` usa pra achar QUAL tenant é // dono de um REGISTER/directory lookup (`Tenant.findFirst({ // telephonyDomain: domain })`). Antes desta constraint, todo tenant // nascia com o mesmo valor fixo ("b2bcall.local") — achado real // reportado pelo usuário: isolamento de PABX por tenant (call groups, // filas, IVR) não funciona sem um domínio de verdade por tenant. telephonyDomain String? @unique @map("telephony_domain") planId String @map("plan_id") @db.Uuid aiPrivacyLevel AIPrivacyLevel @default(AI_OFF) @map("ai_privacy_level") // null = usa o PriceBook/RateDeck com isDefault=true (agente.md secao // 128-129) — mesma convenção de "campo null = default/sem override" já // usada em Queue.aiPrivacyLevel. priceBookId String? @map("price_book_id") @db.Uuid rateDeckId String? @map("rate_deck_id") @db.Uuid createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") deletedAt DateTime? @map("deleted_at") plan Plan @relation(fields: [planId], references: [id]) priceBook PriceBook? @relation(fields: [priceBookId], references: [id]) rateDeck RateDeck? @relation(fields: [rateDeckId], references: [id]) memberships TenantMembership[] userRoles UserRole[] extensions Extension[] trunks Trunk[] dialplanExtensions DialplanExtension[] dialplanVersions DialplanVersion[] queues Queue[] agents Agent[] pauseReasons PauseReason[] tiers Tier[] agentSessions AgentSession[] agentStateEvents AgentStateEvent[] agentPauseEvents AgentPauseEvent[] campaigns Campaign[] leads Lead[] suppressionEntries SuppressionEntry[] callAttempts CallAttempt[] campaignStats CampaignStats[] dispositions Disposition[] calls Call[] callLegs CallLeg[] callEvents CallEvent[] recordings Recording[] aiproviders AIProvider[] aimodels AIModel[] aipromptTemplates AIPromptTemplate[] aijobs AIJob[] callTranscriptions CallTranscription[] callAIAnalyses CallAIAnalysis[] qualityScorecards QualityScorecard[] qualityEvaluations QualityEvaluation[] aiusageRecords AIUsageRecord[] aipromptVersions AIPromptVersion[] callTranscriptSegments CallTranscriptSegment[] qualityScorecardItems QualityScorecardItem[] subscriptions TenantSubscription[] usageEvents UsageEvent[] ratedUsageItems RatedUsageItem[] billingPeriods BillingPeriod[] billingStatements BillingStatement[] billingStatementItems BillingStatementItem[] @@map("tenants") } // Entitlements por plano (agente.md secao 56) — "criar sistema genérico... // não espalhar `if plan == PRO` pelo código". Todo tenant tem exatamente um // Plan (nunca null: em vez de checar "tem plano?", o código só lê o limite // e trata null-no-campo como "sem limite", agente.md secao 57-61). model Plan { id String @id @default(uuid()) @db.Uuid key String @unique name String maxExtensions Int? @map("max_extensions") maxAgents Int? @map("max_agents") maxTrunks Int? @map("max_trunks") maxQueues Int? @map("max_queues") maxCampaigns Int? @map("max_campaigns") maxCps Int? @map("max_cps") maxConcurrentCalls Int? @map("max_concurrent_calls") maxDailyCalls Int? @map("max_daily_calls") maxMonthlyCalls Int? @map("max_monthly_calls") maxRecordingStorageGb Int? @map("max_recording_storage_gb") // agente.md secao 94 — null = sem retenção automática (nunca apagado). recordingRetentionDays Int? @map("recording_retention_days") transcriptionRetentionDays Int? @map("transcription_retention_days") recordingEnabled Boolean @default(true) @map("recording_enabled") aiEnabled Boolean @default(false) @map("ai_enabled") aiTranscriptionEnabled Boolean @default(false) @map("ai_transcription_enabled") aiAnalysisEnabled Boolean @default(false) @map("ai_analysis_enabled") apiAccessEnabled Boolean @default(true) @map("api_access_enabled") createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") tenants Tenant[] planVersions PlanVersion[] @@map("plans") } enum UserStatus { ACTIVE DISABLED @@map("user_status") } // Identidade global do usuário. NUNCA carrega tenant_id diretamente — o tenant // é sempre resolvido via TenantMembership (agente.md secao 31). model User { id String @id @default(uuid()) @db.Uuid email String @unique passwordHash String @map("password_hash") name String status UserStatus @default(ACTIVE) mustChangePassword Boolean @default(false) @map("must_change_password") createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") deletedAt DateTime? @map("deleted_at") memberships TenantMembership[] userRoles UserRole[] sessions Session[] agents Agent[] @@map("users") } enum RoleScope { PLATFORM TENANT @@map("role_scope") } // Definições de role. Roles de sistema (isSystem=true) são criadas pelo seed // (agente.md secao 142) e não podem ser removidas via API. model Role { id String @id @default(uuid()) @db.Uuid key String @unique name String scope RoleScope isSystem Boolean @default(false) @map("is_system") createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") rolePermissions RolePermission[] userRoles UserRole[] @@map("roles") } model Permission { id String @id @default(uuid()) @db.Uuid key String @unique description String? rolePermissions RolePermission[] @@map("permissions") } model RolePermission { roleId String @map("role_id") @db.Uuid permissionId String @map("permission_id") @db.Uuid role Role @relation(fields: [roleId], references: [id], onDelete: Cascade) permission Permission @relation(fields: [permissionId], references: [id], onDelete: Cascade) @@id([roleId, permissionId]) @@map("role_permissions") } // tenantId é obrigatório quando role.scope == TENANT e deve ser nulo quando // role.scope == PLATFORM (invariante aplicado em packages/auth, não no banco — // ver docs/AUTHENTICATION.md). model UserRole { id String @id @default(uuid()) @db.Uuid userId String @map("user_id") @db.Uuid roleId String @map("role_id") @db.Uuid tenantId String? @map("tenant_id") @db.Uuid createdAt DateTime @default(now()) @map("created_at") user User @relation(fields: [userId], references: [id], onDelete: Cascade) role Role @relation(fields: [roleId], references: [id], onDelete: Cascade) tenant Tenant? @relation(fields: [tenantId], references: [id], onDelete: Cascade) @@unique([userId, roleId, tenantId]) @@map("user_roles") } // Refresh-token de longa duração (rotacionado a cada uso). Nunca guardamos o // token em texto puro — só o hash (SHA-256) usado para lookup/comparação. model Session { id String @id @default(uuid()) @db.Uuid userId String @map("user_id") @db.Uuid refreshTokenHash String @unique @map("refresh_token_hash") activeTenantId String? @map("active_tenant_id") @db.Uuid userAgent String? @map("user_agent") ipAddress String? @map("ip_address") createdAt DateTime @default(now()) @map("created_at") expiresAt DateTime @map("expires_at") revokedAt DateTime? @map("revoked_at") user User @relation(fields: [userId], references: [id], onDelete: Cascade) @@index([userId]) @@map("sessions") } // tenantId nulo = evento de escopo plataforma (agente.md secao 150). model AuditLog { id String @id @default(uuid()) @db.Uuid tenantId String? @map("tenant_id") @db.Uuid userId String? @map("user_id") @db.Uuid 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") @@index([tenantId, createdAt]) @@index([userId]) @@map("audit_logs") } // Tabela tenant-scoped protegida por Row Level Security (ver migration // 'tenant_isolation' e docs/TENANT_ISOLATION.md). model TenantMembership { id String @id @default(uuid()) @db.Uuid tenantId String @map("tenant_id") @db.Uuid userId String @map("user_id") @db.Uuid createdAt DateTime @default(now()) @map("created_at") tenant Tenant @relation(fields: [tenantId], references: [id]) user User @relation(fields: [userId], references: [id]) @@unique([tenantId, userId]) @@index([tenantId]) @@map("tenant_memberships") } // Tabela tenant-scoped protegida por Row Level Security. sipPasswordEnc // guarda a senha SIP cifrada (AES-256-GCM, ver packages/shared/src/crypto.ts) // — nunca texto puro (agente.md secao 178). model Extension { id String @id @default(uuid()) @db.Uuid tenantId String @map("tenant_id") @db.Uuid number String name String domain String sipPasswordEnc String @map("sip_password_enc") callerIdName String? @map("caller_id_name") callerIdNumber String? @map("caller_id_number") context String @default("default") sofiaProfile String @default("internal") @map("sofia_profile") codecs String @default("PCMU,PCMA,OPUS") @map("codecs") // Grupo de captura (agente.md secao 178, achado real reportado pelo // usuário: sem isto, qualquer ramal consegue capturar a chamada de // qualquer outro — precisa isolar por grupo pra virar um PABX de // verdade). Vira a variável `call-group` no directory XML // (packages/telephony); o *8 de group pickup em si é uma extensão de // dialplan (Telefonia > Dialplan, já configurável por tenant), não // precisa de coluna própria pra isso. callGroup String? @map("call_group") maxRegistrations Int @default(1) @map("max_registrations") // Status de registro SIP em tempo real (PHASE 55, monitoramento — // achado real: usuário pediu "quantos ramais estão online e o status de // cada" antes de ir pro IVR). Preenchido pelo `apps/freeswitch-events` // a cada `sofia::register`/`sofia::unregister`/`sofia::expire` — null = // não registrado agora. Não dá pra consultar o ESL direto de // `apps/api` pra isso (roda no host, `freeswitch:8021` só existe na // rede interna do Docker, ver docs/FREESWITCH.md), mas fs-events já // tem conexão ESL permanente e já consome esses eventos pra outros // fins — mesmo padrão já usado em `Trunk.status`/`statusUpdatedAt`. registeredAt DateTime? @map("registered_at") enabled Boolean @default(true) createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") deletedAt DateTime? @map("deleted_at") tenant Tenant @relation(fields: [tenantId], references: [id]) agents Agent[] calls Call[] @@unique([tenantId, number]) @@index([tenantId]) @@map("extensions") } enum TrunkStatus { UP DOWN REGISTERED TRYING FAILED UNREGISTERED UNKNOWN @@map("trunk_status") } enum DtmfMode { RFC2833 INFO INBAND @@map("dtmf_mode") } enum SipTransport { UDP TCP TLS @@map("sip_transport") } // Tabela tenant-scoped protegida por Row Level Security. passwordEnc guarda // a senha do tronco cifrada (AES-256-GCM), como sipPasswordEnc em Extension // (agente.md secao 41, 178). model Trunk { id String @id @default(uuid()) @db.Uuid tenantId String @map("tenant_id") @db.Uuid name String description String? sofiaProfile String @default("external") @map("sofia_profile") host String proxy String? realm String? register Boolean @default(true) username String? passwordEnc String? @map("password_enc") fromUser String? @map("from_user") fromDomain String? @map("from_domain") registerProxy String? @map("register_proxy") outboundProxy String? @map("outbound_proxy") expireSeconds Int @default(3600) @map("expire_seconds") retrySeconds Int @default(30) @map("retry_seconds") callerIdName String? @map("caller_id_name") callerIdNumber String? @map("caller_id_number") codecs String @default("PCMU,PCMA,OPUS") dtmfMode DtmfMode @default(RFC2833) @map("dtmf_mode") ping Boolean @default(true) pingFrequency Int @default(30) @map("ping_frequency") transport SipTransport @default(UDP) inboundContext String @default("default") @map("inbound_context") maxCps Int? @map("max_cps") maxChannels Int? @map("max_channels") enabled Boolean @default(true) // Refletido pelos eventos sofia::gateway_state (agente.md secao 42) — // b2bcall-fs-events atualiza isso, nunca escrito manualmente pela API. status TrunkStatus @default(UNKNOWN) statusUpdatedAt DateTime? @map("status_updated_at") createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") deletedAt DateTime? @map("deleted_at") tenant Tenant @relation(fields: [tenantId], references: [id]) campaigns Campaign[] calls Call[] @@unique([tenantId, name]) @@index([tenantId]) @@map("trunks") } enum DialplanVersionStatus { DRAFT ACTIVE SUPERSEDED @@map("dialplan_version_status") } // Editor estruturado (agente.md secao 43) — fonte editável. As versões // publicadas (DialplanVersion) são um snapshot gerado a partir destas // linhas, não o que o FreeSWITCH consulta diretamente. model DialplanExtension { id String @id @default(uuid()) @db.Uuid tenantId String @map("tenant_id") @db.Uuid context String @default("default") name String conditionField String @map("condition_field") conditionExpr String @map("condition_expr") // Array de { application, data } — validado contra uma allowlist de // applications seguras na camada de API (agente.md secao 180: nunca // deixar input de usuário virar comando arbitrário no FreeSWITCH). actions Json antiActions Json? @map("anti_actions") continueOnFalse Boolean @default(false) @map("continue_on_false") order Int @default(0) enabled Boolean @default(true) createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") deletedAt DateTime? @map("deleted_at") tenant Tenant @relation(fields: [tenantId], references: [id]) @@index([tenantId, context]) @@map("dialplan_extensions") } // Versionamento (agente.md secao 44): gerar -> validar -> versionar -> // ativar -> reloadxml -> verificar -> rollback se necessário. "Ativar" uma // versão anterior é o próprio mecanismo de rollback — não existe endpoint // separado. model DialplanVersion { id String @id @default(uuid()) @db.Uuid tenantId String @map("tenant_id") @db.Uuid context String version Int generatedXml String @map("generated_xml") status DialplanVersionStatus @default(DRAFT) createdByUserId String? @map("created_by_user_id") @db.Uuid createdAt DateTime @default(now()) @map("created_at") activatedAt DateTime? @map("activated_at") tenant Tenant @relation(fields: [tenantId], references: [id]) @@unique([tenantId, context, version]) @@index([tenantId, context, status]) @@map("dialplan_versions") } enum QueueStrategy { LONGEST_IDLE_AGENT ROUND_ROBIN TOP_DOWN AGENT_WITH_LEAST_TALK_TIME AGENT_WITH_FEWEST_CALLS SEQUENTIALLY_BY_AGENT_ORDER RING_ALL RING_PROGRESSIVELY @@map("queue_strategy") } // Tabela tenant-scoped protegida por RLS (agente.md secao 50-51). O nome no // FreeSWITCH é `@` — mod_callcenter usa // um namespace unico compartilhado entre tenants (nao ha equivalente ao // diretorio por-arquivo do Sofia pra isolar por tenant, ver docs/QUEUES.md). model Queue { id String @id @default(uuid()) @db.Uuid tenantId String @map("tenant_id") @db.Uuid name String description String? strategy QueueStrategy @default(LONGEST_IDLE_AGENT) mohSound String? @map("moh_sound") announceSound String? @map("announce_sound") announceFrequency Int @default(0) @map("announce_frequency") maxWaitTime Int @default(0) @map("max_wait_time") maxWaitTimeWithNoAgent Int @default(0) @map("max_wait_time_with_no_agent") agentNoAnswerStatus String? @map("agent_no_answer_status") tierRulesApply Boolean @default(false) @map("tier_rules_apply") tierRuleWaitSecond Int @default(300) @map("tier_rule_wait_second") discardAbandonedAfter Int @default(60) @map("discard_abandoned_after") abandonedResumeAllowed Boolean @default(false) @map("abandoned_resume_allowed") skipAgentsWithExternalCalls Boolean @default(true) @map("skip_agents_with_external_calls") recordingEnabled Boolean @default(false) @map("recording_enabled") // null = herda do Tenant (secao 122). aiPrivacyLevel AIPrivacyLevel? @map("ai_privacy_level") enabled Boolean @default(true) createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") deletedAt DateTime? @map("deleted_at") tenant Tenant @relation(fields: [tenantId], references: [id]) tiers Tier[] campaigns Campaign[] calls Call[] @@unique([tenantId, name]) @@index([tenantId]) @@map("queues") } enum AgentState { OFFLINE LOGGED_IN AVAILABLE RESERVED RINGING IN_CALL WRAP_UP PAUSED @@map("agent_state") } // Tabela tenant-scoped protegida por RLS. Separa User (login) / Agent // (identidade de call center) / Extension (ramal SIP usado como contato) — // agente.md secao 45. O nome no FreeSWITCH é `@`, // mesma convenção UUID das outras entidades (agente.md secao 47). model Agent { id String @id @default(uuid()) @db.Uuid tenantId String @map("tenant_id") @db.Uuid userId String @map("user_id") @db.Uuid extensionId String? @map("extension_id") @db.Uuid name String maxNoAnswer Int @default(3) @map("max_no_answer") wrapUpTime Int @default(10) @map("wrap_up_time") rejectDelayTime Int @default(10) @map("reject_delay_time") busyDelayTime Int @default(60) @map("busy_delay_time") noAnswerDelayTime Int @default(10) @map("no_answer_delay_time") // Espelha o estado real (secao 46) — nunca escrito diretamente pela API, // só por login/logout/pause/resume ou por eventos do FreeSWITCH. state AgentState @default(OFFLINE) stateUpdatedAt DateTime? @map("state_updated_at") enabled Boolean @default(true) createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") deletedAt DateTime? @map("deleted_at") tenant Tenant @relation(fields: [tenantId], references: [id]) user User @relation(fields: [userId], references: [id]) extension Extension? @relation(fields: [extensionId], references: [id]) tiers Tier[] sessions AgentSession[] stateEvents AgentStateEvent[] pauseEvents AgentPauseEvent[] callAttempts CallAttempt[] calls Call[] @@unique([tenantId, userId]) @@index([tenantId]) @@map("agents") } // Queue <-> Agent (agente.md secao 52). O nome da fila/agente no FreeSWITCH // já é o "@" — level/position espelham 1:1 os mesmos conceitos // do mod_callcenter. model Tier { id String @id @default(uuid()) @db.Uuid tenantId String @map("tenant_id") @db.Uuid queueId String @map("queue_id") @db.Uuid agentId String @map("agent_id") @db.Uuid level Int @default(1) position Int @default(1) createdAt DateTime @default(now()) @map("created_at") tenant Tenant @relation(fields: [tenantId], references: [id]) queue Queue @relation(fields: [queueId], references: [id]) agent Agent @relation(fields: [agentId], references: [id]) @@unique([queueId, agentId]) @@index([tenantId]) @@map("tiers") } // Uma "sessão" = do login até o logout do agente (agente.md secao 45, 47). model AgentSession { id String @id @default(uuid()) @db.Uuid tenantId String @map("tenant_id") @db.Uuid agentId String @map("agent_id") @db.Uuid startedAt DateTime @default(now()) @map("started_at") endedAt DateTime? @map("ended_at") tenant Tenant @relation(fields: [tenantId], references: [id]) agent Agent @relation(fields: [agentId], references: [id]) @@index([tenantId, agentId]) @@map("agent_sessions") } // Histórico de transições de estado (agente.md secao 46) — nunca deletado, // serve de auditoria e insumo pra relatórios (secao 158). model AgentStateEvent { id String @id @default(uuid()) @db.Uuid tenantId String @map("tenant_id") @db.Uuid agentId String @map("agent_id") @db.Uuid state AgentState occurredAt DateTime @default(now()) @map("occurred_at") tenant Tenant @relation(fields: [tenantId], references: [id]) agent Agent @relation(fields: [agentId], references: [id]) @@index([tenantId, agentId, occurredAt]) @@map("agent_state_events") } // agente.md secao 48. model PauseReason { id String @id @default(uuid()) @db.Uuid tenantId String @map("tenant_id") @db.Uuid name String code String description String? maxDuration Int? @map("max_duration") paid Boolean @default(false) enabled Boolean @default(true) createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") tenant Tenant @relation(fields: [tenantId], references: [id]) pauseEvents AgentPauseEvent[] @@unique([tenantId, code]) @@index([tenantId]) @@map("pause_reasons") } model AgentPauseEvent { id String @id @default(uuid()) @db.Uuid tenantId String @map("tenant_id") @db.Uuid agentId String @map("agent_id") @db.Uuid pauseReasonId String @map("pause_reason_id") @db.Uuid startedAt DateTime @default(now()) @map("started_at") endedAt DateTime? @map("ended_at") tenant Tenant @relation(fields: [tenantId], references: [id]) agent Agent @relation(fields: [agentId], references: [id]) pauseReason PauseReason @relation(fields: [pauseReasonId], references: [id]) @@index([tenantId, agentId]) @@map("agent_pause_events") } enum CampaignStatus { DRAFT READY WAITING_SCHEDULE RUNNING PAUSED DRAINING STOPPED COMPLETED ERROR @@map("campaign_status") } // Tabela tenant-scoped protegida por RLS (agente.md secao 63-66). Só o // modelo/CRUD e as transições de status desta fase — o motor que de fato // origina chamadas (PredictiveDialerEngine, secao 72-73) é uma fase à parte. model Campaign { id String @id @default(uuid()) @db.Uuid tenantId String @map("tenant_id") @db.Uuid name String description String? queueId String @map("queue_id") @db.Uuid trunkId String @map("trunk_id") @db.Uuid callerIdName String? @map("caller_id_name") callerIdNumber String? @map("caller_id_number") timezone String @default("America/Sao_Paulo") startDate DateTime? @map("start_date") @db.Date endDate DateTime? @map("end_date") @db.Date // ISO-8601 (1=segunda ... 7=domingo). daysOfWeek Int[] @map("days_of_week") // "HH:MM", interpretado na timezone da campanha — sem tipo TIME nativo // pra manter o schema simples nesta fase (o scheduler de verdade é da // fase Predictive Engine). startTime String? @map("start_time") endTime String? @map("end_time") maxCps Int? @map("max_cps") maxConcurrentCalls Int? @map("max_concurrent_calls") pacingInitial Float @default(1.0) @map("pacing_initial") pacingMin Float @default(1.0) @map("pacing_min") pacingMax Float @default(3.0) @map("pacing_max") targetAbandonRate Float @default(0.03) @map("target_abandon_rate") ringTimeout Int @default(30) @map("ring_timeout") maxAttempts Int @default(3) @map("max_attempts") recordingEnabled Boolean @default(false) @map("recording_enabled") avmdEnabled Boolean @default(false) @map("avmd_enabled") aiTranscriptionEnabled Boolean @default(false) @map("ai_transcription_enabled") aiAnalysisEnabled Boolean @default(false) @map("ai_analysis_enabled") // Secao 116: sobrescreve o template padrão do tenant pra ANALYSIS // (null = usa o template ANALYSIS padrão resolvido normalmente). analysisPromptTemplateId String? @map("analysis_prompt_template_id") @db.Uuid status CampaignStatus @default(DRAFT) createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") deletedAt DateTime? @map("deleted_at") tenant Tenant @relation(fields: [tenantId], references: [id]) queue Queue @relation(fields: [queueId], references: [id]) trunk Trunk @relation(fields: [trunkId], references: [id]) analysisPromptTemplate AIPromptTemplate? @relation(fields: [analysisPromptTemplateId], references: [id]) leads Lead[] callAttempts CallAttempt[] campaignStats CampaignStats[] calls Call[] @@unique([tenantId, name]) @@index([tenantId]) @@map("campaigns") } enum LeadStatus { NEW READY RESERVED ORIGINATING RINGING ANSWERED QUEUEING CONNECTED_AGENT BUSY NO_ANSWER FAILED VOICEMAIL CALLBACK COMPLETED DO_NOT_CALL MAX_ATTEMPTS @@map("lead_status") } // Tabela tenant-scoped protegida por RLS (agente.md secao 67-68). A reserva // atômica (READY -> RESERVED, `FOR UPDATE SKIP LOCKED`, secao 78) é // implementada na fase CPS Limiter/Predictive Engine — aqui só o modelo e // o CRUD/import. model Lead { id String @id @default(uuid()) @db.Uuid tenantId String @map("tenant_id") @db.Uuid campaignId String @map("campaign_id") @db.Uuid name String? phoneOriginal String @map("phone_original") 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") customFields Json @default("{}") @map("custom_fields") createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") tenant Tenant @relation(fields: [tenantId], references: [id]) campaign Campaign @relation(fields: [campaignId], references: [id]) callAttempts CallAttempt[] calls Call[] @@unique([campaignId, phoneNormalized]) @@index([tenantId]) @@index([campaignId, status, nextAttemptAt]) @@map("leads") } // Lista de bloqueio (agente.md secao 71) — tenant-scoped, checada antes de // qualquer originate (checagem em si é da fase Predictive Engine/CPS // Limiter, aqui só o modelo/CRUD). model SuppressionEntry { id String @id @default(uuid()) @db.Uuid tenantId String @map("tenant_id") @db.Uuid phoneNormalized String @map("phone_normalized") reason String? createdAt DateTime @default(now()) @map("created_at") tenant Tenant @relation(fields: [tenantId], references: [id]) @@unique([tenantId, phoneNormalized]) @@index([tenantId]) @@map("suppression_entries") } enum CallAttemptStatus { CREATED RESERVED ORIGINATING ORIGINATED RINGING ANSWERED QUEUEING AGENT_CONNECTED COMPLETED BUSY NO_ANSWER FAILED ABANDONED @@map("call_attempt_status") } // Uma tentativa de discagem de um lead (agente.md secao 81-82). Os // identificadores daqui (id, campaignId, leadId, originationUuid) viram // channel variables b2bcall_* no originate (secao 81) — é assim que // b2bcall-fs-events consegue popular NormalizedEvent.tenantId/b2bcallCallId // pra chamadas do discador, diferente das chamadas internas (extension a // extension) que não carregam esses vars ainda. model CallAttempt { id String @id @default(uuid()) @db.Uuid tenantId String @map("tenant_id") @db.Uuid campaignId String @map("campaign_id") @db.Uuid leadId String @map("lead_id") @db.Uuid agentId String? @map("agent_id") @db.Uuid // UUID do channel no FreeSWITCH (real ou simulado) — null até o originate // de fato rodar (status ainda CREATED/RESERVED). originationUuid String? @unique @map("origination_uuid") @db.Uuid status CallAttemptStatus @default(CREATED) // agente.md secao 185: em modo simulação, nenhuma chamada PSTN real // acontece — answer/busy/no_answer/failed/ringing/delay/talk_time são // sorteados em software, nunca originados de verdade pro trunk. simulated Boolean @default(false) hangupCause String? @map("hangup_cause") talkTimeSeconds Int? @map("talk_time_seconds") createdAt DateTime @default(now()) @map("created_at") ringingAt DateTime? @map("ringing_at") answeredAt DateTime? @map("answered_at") bridgedAt DateTime? @map("bridged_at") endedAt DateTime? @map("ended_at") tenant Tenant @relation(fields: [tenantId], references: [id]) campaign Campaign @relation(fields: [campaignId], references: [id]) lead Lead @relation(fields: [leadId], references: [id]) agent Agent? @relation(fields: [agentId], references: [id]) calls Call[] @@index([tenantId]) @@index([campaignId, status]) @@map("call_attempts") } // Estatísticas EWMA por campanha (agente.md secao 73-76), consultadas e // atualizadas a cada tick do PredictiveDialerEngine — persistidas pra // sobreviver a reinícios do worker (não é cache descartável). Valores // iniciais são estimativas conservadoras (secao 74: "não precisa Machine // Learning, preferir algoritmo estatístico determinístico e explicável"), // convergem conforme tentativas reais completam. model CampaignStats { campaignId String @id @map("campaign_id") @db.Uuid tenantId String @map("tenant_id") @db.Uuid answerProbability Float @default(0.4) @map("answer_probability") averageAnswerDelay Float @default(5) @map("average_answer_delay") averageTalkTime Float @default(180) @map("average_talk_time") abandonRate Float @default(0) @map("abandon_rate") // Fator de pacing aplicado no cálculo de quantas chamadas originar por // tick (secao 76) — começa em Campaign.pacingInitial, ajustado pelo // controle de abandono (secao 84), sempre dentro de [pacingMin, pacingMax]. pacingFactor Float @default(1.0) @map("pacing_factor") updatedAt DateTime @updatedAt @map("updated_at") tenant Tenant @relation(fields: [tenantId], references: [id]) campaign Campaign @relation(fields: [campaignId], references: [id]) @@index([tenantId]) @@map("campaign_stats") } enum CallDirection { INBOUND OUTBOUND INTERNAL @@map("call_direction") } // Disposição escolhida pelo agente ao fim de uma chamada (agente.md secao // 89: "Call Center -> Disposições", personalizável por tenant — mesmo // padrão de PauseReason, não uma lista fixa hardcoded). model Disposition { id String @id @default(uuid()) @db.Uuid tenantId String @map("tenant_id") @db.Uuid name String code String enabled Boolean @default(true) createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") tenant Tenant @relation(fields: [tenantId], references: [id]) calls Call[] @@unique([tenantId, code]) @@index([tenantId]) @@map("dispositions") } // "calls" (agente.md secao 153-154) — um registro por chamada lógica // (`id` = freeswitch_uuid da perna principal; sem suporte a transferência // entre uuids nesta fase, então `call_id`/`freeswitch_uuid` coincidem por // enquanto, mantidos como campos separados pra já bater com o schema da // especificação quando isso mudar). Alimentado por // apps/freeswitch-events (nunca escrito manualmente pela API) — ver // docs/CDR.md. model Call { id String @id @db.Uuid tenantId String @map("tenant_id") @db.Uuid // agente.md secao 154: attempt_id só existe pra chamadas originadas pelo // PredictiveDialerEngine — CallAttempt já cobre o conceito de // "dial_attempts" da secao 153, sem tabela duplicada. attemptId String? @map("attempt_id") @db.Uuid freeswitchUuid String @map("freeswitch_uuid") @db.Uuid sipCallId String? @map("sip_call_id") direction CallDirection @default(INTERNAL) campaignId String? @map("campaign_id") @db.Uuid leadId String? @map("lead_id") @db.Uuid queueId String? @map("queue_id") @db.Uuid agentId String? @map("agent_id") @db.Uuid extensionId String? @map("extension_id") @db.Uuid trunkId String? @map("trunk_id") @db.Uuid callerNumber String? @map("caller_number") calledNumber String? @map("called_number") createdAt DateTime @default(now()) @map("created_at") progressAt DateTime? @map("progress_at") answerAt DateTime? @map("answer_at") queueEnterAt DateTime? @map("queue_enter_at") agentAnswerAt DateTime? @map("agent_answer_at") bridgeAt DateTime? @map("bridge_at") endAt DateTime? @map("end_at") // Segundos, calculados quando a chamada termina (secao 155-156): // ringTime = answerAt-createdAt, waitTime = agentAnswerAt-queueEnterAt // (TME de uma chamada individual), talkTime = endAt-bridgeAt, // durationSeconds = endAt-createdAt, billableSeconds = talkTime por // enquanto (sem regra de arredondamento/tarifação ainda, fase Billing). ringTime Int? @map("ring_time") waitTime Int? @map("wait_time") talkTime Int? @map("talk_time") durationSeconds Int? @map("duration_seconds") billableSeconds Int? @map("billable_seconds") // "Chamada faturável" (agente.md secao 133) — preenchidos pelo // RatingEngine (packages/billing) quando o UsageEvent CALL_SECONDS desta // chamada é avaliado (nunca no momento do CDR — billableSeconds já // existe desde a fase CDR, o resto só existe depois de rated). billingIncrementSeconds Int? @map("billing_increment_seconds") ratedMinutes Float? @map("rated_minutes") destinationRate Float? @map("destination_rate") ratedAmount Float? @map("rated_amount") hangupCause String? @map("hangup_cause") dispositionId String? @map("disposition_id") @db.Uuid tenant Tenant @relation(fields: [tenantId], references: [id]) attempt CallAttempt? @relation(fields: [attemptId], references: [id]) campaign Campaign? @relation(fields: [campaignId], references: [id]) lead Lead? @relation(fields: [leadId], references: [id]) queue Queue? @relation(fields: [queueId], references: [id]) agent Agent? @relation(fields: [agentId], references: [id]) extension Extension? @relation(fields: [extensionId], references: [id]) trunk Trunk? @relation(fields: [trunkId], references: [id]) disposition Disposition? @relation(fields: [dispositionId], references: [id]) legs CallLeg[] events CallEvent[] recording Recording? aijobs AIJob[] callTranscriptions CallTranscription[] callAIAnalyses CallAIAnalysis[] qualityEvaluations QualityEvaluation[] aiusageRecords AIUsageRecord[] usageEvents UsageEvent[] ratedUsageItems RatedUsageItem[] @@index([tenantId, createdAt]) @@index([tenantId, queueId]) @@index([tenantId, agentId]) @@index([tenantId, campaignId]) @@map("calls") } // "call_legs" (secao 153) — um por channel/uuid FreeSWITCH envolvido na // chamada (hoje sempre 1, a própria perna principal; ganha sentido quando // existir bridge de 2+ pernas rastreadas separadamente, ex.: transferência). model CallLeg { id String @id @default(uuid()) @db.Uuid tenantId String @map("tenant_id") @db.Uuid callId String @map("call_id") @db.Uuid freeswitchUuid String @map("freeswitch_uuid") @db.Uuid role String // "caller" | "callee" | "agent" — livre, sem enum fechado ainda createdAt DateTime @default(now()) @map("created_at") answeredAt DateTime? @map("answered_at") endedAt DateTime? @map("ended_at") hangupCause String? @map("hangup_cause") tenant Tenant @relation(fields: [tenantId], references: [id]) call Call @relation(fields: [callId], references: [id]) @@unique([freeswitchUuid]) @@index([tenantId, callId]) @@map("call_legs") } // "call_events" (secao 153) — trilha bruta dos NormalizedEvent que // alimentaram a chamada, persistida (o canal Redis b2bcall:events é // efêmero, pub/sub sem histórico). Nunca deletado — é o material bruto por // trás de qualquer relatório futuro mais granular que os campos agregados // de `Call` não cobrirem. model CallEvent { id String @id @default(uuid()) @db.Uuid tenantId String @map("tenant_id") @db.Uuid callId String @map("call_id") @db.Uuid type String occurredAt DateTime @map("occurred_at") data Json @default("{}") tenant Tenant @relation(fields: [tenantId], references: [id]) call Call @relation(fields: [callId], references: [id]) @@index([tenantId, callId, occurredAt]) @@map("call_events") } enum StorageProvider { LOCAL S3 @@map("storage_provider") } enum RecordingStatus { AVAILABLE DELETED FAILED @@map("recording_status") } // "recordings" (agente.md secao 90-94) — só chamadas originadas pelo // PredictiveDialerEngine com Campaign.recordingEnabled são gravadas nesta // fase (é o único caminho de originate que o sistema controla hoje; ver // docs/RECORDING.md). `objectKey` segue a estrutura da secao 93 // (tenants/{tenant_id}/recordings/YYYY/MM/DD/{call_id}.wav) — nunca // aceita um valor vindo do client, sempre construído no servidor. model Recording { id String @id @default(uuid()) @db.Uuid tenantId String @map("tenant_id") @db.Uuid callId String @unique @map("call_id") @db.Uuid storageProvider StorageProvider @map("storage_provider") objectKey String @map("object_key") format String @default("wav") durationSeconds Int? @map("duration_seconds") channels Int @default(2) sizeBytes BigInt? @map("size_bytes") checksum String? recordedAt DateTime @map("recorded_at") retentionUntil DateTime? @map("retention_until") status RecordingStatus @default(AVAILABLE) createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") tenant Tenant @relation(fields: [tenantId], references: [id]) call Call @relation(fields: [callId], references: [id]) @@index([tenantId, recordedAt]) @@index([retentionUntil]) @@map("recordings") } // ============================================================ // Módulo IA (agente.md secao 95-124) // ============================================================ enum AIProviderScope { GLOBAL TENANT @@map("ai_provider_scope") } // "ai_providers" (secao 96-101). `providerType` é String, não enum — a // especificação exige explicitamente "não hardcode OpenAI no domínio" e // "arquitetura deve permitir Google/Azure/Bedrock/modelos locais/outros // futuramente" (secao 96-97); um enum Postgres exigiria uma migration // pra cada provider novo, o oposto do espírito da abstração. A validação // de quais `providerType` têm adapter implementado de verdade acontece na // camada de serviço (packages/ai), não no banco. // // RLS híbrida: `scope=GLOBAL` (tenantId null, cadastrado pelo platform // admin) é visível de QUALQUER contexto de tenant — é a mesma técnica já // usada pra `tenant_memberships` na descoberta de tenant durante login // (USING com OR). `scope=TENANT` (BYOK, secao 100) só é visível no // próprio contexto do tenant dono. Quem pode ESCREVER num provider GLOBAL // é decidido na camada de serviço (só platform admin), não pela RLS. model AIProvider { id String @id @default(uuid()) @db.Uuid scope AIProviderScope tenantId String? @map("tenant_id") @db.Uuid providerType String @map("provider_type") name String baseUrl String? @map("base_url") // AES-256-GCM (secao 101, mesmo padrão de sipPasswordEnc/passwordEnc) — // a key inteira nunca é reexibida depois de salva. encryptedApiKey String @map("encrypted_api_key") organization String? project String? enabled Boolean @default(true) createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") tenant Tenant? @relation(fields: [tenantId], references: [id]) models AIModel[] callTranscriptions CallTranscription[] callAIAnalyses CallAIAnalysis[] aiusageRecords AIUsageRecord[] @@index([tenantId]) @@map("ai_providers") } enum AICapability { TRANSCRIPTION DIARIZATION TEXT_ANALYSIS STRUCTURED_OUTPUT EMBEDDINGS REALTIME_AUDIO @@map("ai_capability") } // "ai_models" (secao 102-103) — mesma RLS híbrida do provider dono // (tenantId copiado do AIProvider na criação, evita subquery de RLS // cruzando tabelas). model AIModel { id String @id @default(uuid()) @db.Uuid tenantId String? @map("tenant_id") @db.Uuid providerId String @map("provider_id") @db.Uuid externalModelId String @map("external_model_id") displayName String @map("display_name") capabilities AICapability[] inputCost Float? @map("input_cost") outputCost Float? @map("output_cost") audioCost Float? @map("audio_cost") enabled Boolean @default(true) createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") tenant Tenant? @relation(fields: [tenantId], references: [id]) provider AIProvider @relation(fields: [providerId], references: [id]) @@unique([providerId, externalModelId]) @@index([tenantId]) @@map("ai_models") } enum AIPromptPurpose { ANALYSIS SCORECARD @@map("ai_prompt_purpose") } // "ai_prompt_templates"/"ai_prompt_versions" (secao 114-116). `tenantId // null` = template padrão da plataforma; Campaign pode sobrescrever // apontando pro seu próprio template via `analysisPromptTemplateId` // (secao 116) — o template em si não carrega campaignId, evita um // template "pertencer" a uma campanha específica de forma rígida (a // mesma campanha de cobrança de vários tenants pode reusar o mesmo // template). Versões nunca são editadas in-place — cada mudança de // conteúdo cria uma nova `AIPromptVersion`, a versão ativa é apontada por // `activeVersionId` (histórico completo preservado, mesmo espírito de // "immutable usage ledger" da secao 233 aplicado a prompts). model AIPromptTemplate { id String @id @default(uuid()) @db.Uuid tenantId String? @map("tenant_id") @db.Uuid purpose AIPromptPurpose name String activeVersionId String? @unique @map("active_version_id") @db.Uuid createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") tenant Tenant? @relation(fields: [tenantId], references: [id]) activeVersion AIPromptVersion? @relation("ActiveVersion", fields: [activeVersionId], references: [id]) versions AIPromptVersion[] @relation("TemplateVersions") campaigns Campaign[] @@index([tenantId]) @@map("ai_prompt_templates") } model AIPromptVersion { id String @id @default(uuid()) @db.Uuid tenantId String? @map("tenant_id") @db.Uuid templateId String @map("template_id") @db.Uuid version Int content String createdAt DateTime @default(now()) @map("created_at") tenant Tenant? @relation(fields: [tenantId], references: [id]) template AIPromptTemplate @relation("TemplateVersions", fields: [templateId], references: [id]) activeFor AIPromptTemplate? @relation("ActiveVersion") @@unique([templateId, version]) @@index([tenantId]) @@map("ai_prompt_versions") } enum AIJobType { TRANSCRIPTION ANALYSIS REANALYSIS SCORECARD_EVALUATION @@map("ai_job_type") } enum AIJobStatus { PENDING PROCESSING COMPLETED FAILED RETRYING CANCELLED @@map("ai_job_status") } // "ai_jobs" (secao 106-108) — pipeline sempre assíncrono, nunca bloqueia // a chamada esperando IA. `scheduledAt` é quando o job pode rodar de novo // (exponential backoff no retry); `attemptCount >= maxAttempts` vira // FAILED terminal (dead-letter, secao 108: nunca retry infinito). model AIJob { id String @id @default(uuid()) @db.Uuid tenantId String @map("tenant_id") @db.Uuid callId String @map("call_id") @db.Uuid type AIJobType status AIJobStatus @default(PENDING) attemptCount Int @default(0) @map("attempt_count") maxAttempts Int @default(5) @map("max_attempts") lastError String? @map("last_error") scheduledAt DateTime @default(now()) @map("scheduled_at") createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") completedAt DateTime? @map("completed_at") tenant Tenant @relation(fields: [tenantId], references: [id]) call Call @relation(fields: [callId], references: [id]) @@index([tenantId, status, scheduledAt]) @@index([tenantId, callId]) @@map("ai_jobs") } enum TranscriptionStatus { PENDING COMPLETED FAILED @@map("transcription_status") } // "call_transcriptions" (secao 109). model CallTranscription { id String @id @default(uuid()) @db.Uuid tenantId String @map("tenant_id") @db.Uuid callId String @map("call_id") @db.Uuid providerId String? @map("provider_id") @db.Uuid model String? language String? text String? status TranscriptionStatus @default(PENDING) durationSeconds Int? @map("duration_seconds") providerRequestId String? @map("provider_request_id") inputUsage Int? @map("input_usage") outputUsage Int? @map("output_usage") providerCost Float? @map("provider_cost") createdAt DateTime @default(now()) @map("created_at") tenant Tenant @relation(fields: [tenantId], references: [id]) call Call @relation(fields: [callId], references: [id]) provider AIProvider? @relation(fields: [providerId], references: [id]) segments CallTranscriptSegment[] @@index([tenantId, callId]) @@map("call_transcriptions") } enum TranscriptSpeaker { AGENT CUSTOMER UNKNOWN @@map("transcript_speaker") } // "call_transcript_segments" (secao 110-111) — `speaker` prioriza o canal // estéreo (secao 111: "não confiar cegamente em diarização quando a // direção do áudio permite identificação melhor") sobre a diarização // probabilística do provider, quando a gravação for estéreo. model CallTranscriptSegment { id String @id @default(uuid()) @db.Uuid tenantId String @map("tenant_id") @db.Uuid transcriptionId String @map("transcription_id") @db.Uuid speaker TranscriptSpeaker @default(UNKNOWN) startMs Int @map("start_ms") endMs Int @map("end_ms") text String confidence Float? tenant Tenant @relation(fields: [tenantId], references: [id]) transcription CallTranscription @relation(fields: [transcriptionId], references: [id]) @@index([tenantId]) @@index([transcriptionId]) @@map("call_transcript_segments") } // "call_ai_analyses" (secao 112-113) — resultado estruturado, validado // contra um schema antes de persistir (secao 113: "não usar somente texto // livre"), nunca o texto cru da resposta do modelo. model CallAIAnalysis { id String @id @default(uuid()) @db.Uuid tenantId String @map("tenant_id") @db.Uuid callId String @map("call_id") @db.Uuid providerId String? @map("provider_id") @db.Uuid model String? summary String? customerIntent String? @map("customer_intent") outcome String? sentiment String? topics String[] keywords String[] objections String[] questions String[] actionItems String[] @map("action_items") complianceFlags String[] @map("compliance_flags") riskFlags String[] @map("risk_flags") qualityScore Int? @map("quality_score") agentScore Int? @map("agent_score") customerSentimentScore Float? @map("customer_sentiment_score") salesOpportunity Boolean? @map("sales_opportunity") nextBestAction String? @map("next_best_action") createdAt DateTime @default(now()) @map("created_at") tenant Tenant @relation(fields: [tenantId], references: [id]) call Call @relation(fields: [callId], references: [id]) provider AIProvider? @relation(fields: [providerId], references: [id]) @@index([tenantId, callId]) @@map("call_ai_analyses") } // "quality_scorecards"/"quality_scorecard_items"/"quality_evaluations" // (secao 117-118). model QualityScorecard { id String @id @default(uuid()) @db.Uuid tenantId String @map("tenant_id") @db.Uuid name String enabled Boolean @default(true) createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") tenant Tenant @relation(fields: [tenantId], references: [id]) items QualityScorecardItem[] evaluations QualityEvaluation[] @@index([tenantId]) @@map("quality_scorecards") } model QualityScorecardItem { id String @id @default(uuid()) @db.Uuid tenantId String @map("tenant_id") @db.Uuid scorecardId String @map("scorecard_id") @db.Uuid name String weight Float @default(1) description String? evaluationPrompt String? @map("evaluation_prompt") tenant Tenant @relation(fields: [tenantId], references: [id]) scorecard QualityScorecard @relation(fields: [scorecardId], references: [id]) @@index([tenantId]) @@index([scorecardId]) @@map("quality_scorecard_items") } // Secao 118: guarda score final + scores por critério + justificativa — // nunca o chain-of-thought do modelo. model QualityEvaluation { id String @id @default(uuid()) @db.Uuid tenantId String @map("tenant_id") @db.Uuid callId String @map("call_id") @db.Uuid scorecardId String @map("scorecard_id") @db.Uuid score Int criterionScores Json @map("criterion_scores") summaryJustification String? @map("summary_justification") createdAt DateTime @default(now()) @map("created_at") tenant Tenant @relation(fields: [tenantId], references: [id]) call Call @relation(fields: [callId], references: [id]) scorecard QualityScorecard @relation(fields: [scorecardId], references: [id]) @@index([tenantId, callId]) @@map("quality_evaluations") } enum AIUsageType { AI_TRANSCRIPTION_SECONDS AI_ANALYSIS_REQUEST AI_INPUT_TOKENS AI_OUTPUT_TOKENS @@map("ai_usage_type") } // "AI usage metering" (secao 124) — ledger imutável (secao 233: "immutable // usage ledger > reconstruir billing de forma improvisada"), só INSERT // pelo código da aplicação, nunca UPDATE/DELETE. Consumido pelo // RatingEngine (packages/billing) junto com UsageEvent — ver comentário // acima de UsageEvent sobre por que são 2 tabelas em vez de 1. model AIUsageRecord { id String @id @default(uuid()) @db.Uuid tenantId String @map("tenant_id") @db.Uuid callId String? @map("call_id") @db.Uuid type AIUsageType quantity Float providerId String? @map("provider_id") @db.Uuid model String? occurredAt DateTime @default(now()) @map("occurred_at") tenant Tenant @relation(fields: [tenantId], references: [id]) call Call? @relation(fields: [callId], references: [id]) provider AIProvider? @relation(fields: [providerId], references: [id]) ratedUsageItems RatedUsageItem[] @@index([tenantId, occurredAt]) @@index([tenantId, type]) @@map("ai_usage_records") } // ============================================================ // BILLING (agente.md secao 125-139) // // "Criar billing desde o início. Não tratar cobrança como relatório // calculado posteriormente de maneira improvisada" (secao 125). // // PriceBook/PriceBookItem/RateDeck/RateDeckEntry/PlanVersion são // catálogos GLOBAIS da plataforma (sem tenant_id, mesmo padrão já usado // por `Plan` — gerenciados só pelo platform admin, um Tenant escolhe qual // usar via `Tenant.priceBookId`/`rateDeckId`, null = o que tiver // `isDefault=true`). TenantSubscription/UsageEvent/RatedUsageItem/ // BillingPeriod/BillingStatement(Item) SÃO tenant-scoped, com RLS. // ============================================================ // "plan_versions" (secao 126: "Preços e limites devem ser versionados"). // Versiona só o PREÇO base da assinatura por enquanto — os limites // (max_extensions etc.) continuam em `Plan` direto, sem versionamento // próprio (mudam raramente nesta fase do produto; documentado como // simplificação conhecida em docs/BILLING.md). model PlanVersion { id String @id @default(uuid()) @db.Uuid planId String @map("plan_id") @db.Uuid version Int basePrice Float @map("base_price") currency String @default("BRL") effectiveFrom DateTime @map("effective_from") effectiveUntil DateTime? @map("effective_until") createdAt DateTime @default(now()) @map("created_at") plan Plan @relation(fields: [planId], references: [id]) subscriptions TenantSubscription[] @@unique([planId, version]) @@map("plan_versions") } enum TenantSubscriptionStatus { TRIALING ACTIVE PAST_DUE CANCELED @@map("tenant_subscription_status") } // "tenant_subscriptions" (secao 127). model TenantSubscription { id String @id @default(uuid()) @db.Uuid tenantId String @map("tenant_id") @db.Uuid planVersionId String @map("plan_version_id") @db.Uuid status TenantSubscriptionStatus @default(ACTIVE) startedAt DateTime @map("started_at") endsAt DateTime? @map("ends_at") // Dia do mês (1-28, nunca 29-31 pra evitar mês sem esse dia) em que o // período de billing do tenant fecha (secao 127). billingCycleAnchor Int @map("billing_cycle_anchor") currency String @default("BRL") createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") tenant Tenant @relation(fields: [tenantId], references: [id]) planVersion PlanVersion @relation(fields: [planVersionId], references: [id]) @@index([tenantId, status]) @@map("tenant_subscriptions") } enum PriceItemType { BASE_SUBSCRIPTION EXTENSION_MONTH AGENT_MONTH TRUNK_MONTH CALL CALL_MINUTE FIXED_MINUTE MOBILE_MINUTE INTERNATIONAL_MINUTE AI_TRANSCRIPTION_MINUTE AI_ANALYSIS_CALL AI_INPUT_TOKEN AI_OUTPUT_TOKEN RECORDING_GB_MONTH @@map("price_item_type") } // "price_books"/"price_book_items" (secao 128) — catálogo global, // `isDefault` marca qual usar quando `Tenant.priceBookId` é null. model PriceBook { id String @id @default(uuid()) @db.Uuid name String currency String @default("BRL") isDefault Boolean @default(false) @map("is_default") createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") items PriceBookItem[] tenants Tenant[] @@map("price_books") } // Preço vigente por tipo — `validFrom`/`validUntil` permitem reajuste sem // perder o preço histórico (o RatingEngine sempre busca o item vigente em // `usage_event.occurred_at`, nunca "o preço de hoje" pra uso passado). model PriceBookItem { id String @id @default(uuid()) @db.Uuid priceBookId String @map("price_book_id") @db.Uuid type PriceItemType unitPrice Float @map("unit_price") effectiveFrom DateTime @map("effective_from") effectiveUntil DateTime? @map("effective_until") createdAt DateTime @default(now()) @map("created_at") priceBook PriceBook @relation(fields: [priceBookId], references: [id]) ratedUsageItems RatedUsageItem[] @@index([priceBookId, type, effectiveFrom]) @@map("price_book_items") } // "rate_decks"/"rate_deck_entries" (secao 129) — precificação por destino // via longest prefix matching (packages/billing/src/rating-engine.ts), // separado dos PriceBookItem(type=CALL_MINUTE) que servem só de fallback // quando nenhum prefixo do rate deck bate com o número discado. model RateDeck { id String @id @default(uuid()) @db.Uuid name String isDefault Boolean @default(false) @map("is_default") createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") entries RateDeckEntry[] tenants Tenant[] @@map("rate_decks") } enum DestinationType { FIXED MOBILE INTERNATIONAL @@map("destination_type") } model RateDeckEntry { id String @id @default(uuid()) @db.Uuid rateDeckId String @map("rate_deck_id") @db.Uuid prefix String destinationName String @map("destination_name") destinationType DestinationType @map("destination_type") pricePerMinute Float @map("price_per_minute") billingIncrementSeconds Int @default(60) @map("billing_increment_seconds") minimumSeconds Int @default(0) @map("minimum_seconds") connectionFee Float @default(0) @map("connection_fee") validFrom DateTime @map("valid_from") validUntil DateTime? @map("valid_until") createdAt DateTime @default(now()) @map("created_at") rateDeck RateDeck @relation(fields: [rateDeckId], references: [id]) ratedUsageItems RatedUsageItem[] // Longest prefix matching precisa varrer todas as entries vigentes do // deck — sem índice em `prefix` sozinho (o match é por STARTS WITH, não // igualdade), o RatingEngine já traz tudo pra memória por rateDeckId. @@index([rateDeckId, validFrom]) @@map("rate_deck_entries") } enum UsageMeter { CALL_COUNT CALL_SECONDS EXTENSION_ACTIVE_DAY AGENT_ACTIVE_DAY TRUNK_ACTIVE_DAY RECORDING_BYTES // Os 4 meters de IA abaixo completam a lista da secao 131, mas quem // escreve esses eventos na prática é AIUsageRecord (ledger próprio, // já existia desde a PHASE 20, antes da fase Billing) — o RatingEngine // lê os dois ledgers, ver comentário em UsageEvent. Mantidos aqui só // pra o enum bater com a especificação, não usados pra escrita. AI_TRANSCRIPTION_SECONDS AI_ANALYSIS_REQUEST AI_INPUT_TOKENS AI_OUTPUT_TOKENS @@map("usage_meter") } // "usage_events" (secao 131) — ledger imutável, só INSERT pelo código da // aplicação (mesma convenção de AIUsageRecord, secao 233: "immutable // usage ledger"). Existem 2 ledgers (este + AIUsageRecord) em vez de 1 // porque AIUsageRecord já foi construído e testado ponta a ponta na fase // de IA, ANTES da fase Billing existir — migrar aquele código pra esta // tabela só pra unificar seria puro churn sem ganho funcional; o // RatingEngine simplesmente lê dos dois. Documentado em docs/BILLING.md. model UsageEvent { id String @id @default(uuid()) @db.Uuid tenantId String @map("tenant_id") @db.Uuid callId String? @map("call_id") @db.Uuid meter UsageMeter quantity Float unit String sourceType String @map("source_type") sourceId String? @map("source_id") occurredAt DateTime @map("occurred_at") metadata Json? createdAt DateTime @default(now()) @map("created_at") tenant Tenant @relation(fields: [tenantId], references: [id]) call Call? @relation(fields: [callId], references: [id]) ratedUsageItems RatedUsageItem[] @@index([tenantId, occurredAt]) @@index([tenantId, meter]) @@map("usage_events") } // "rated_usage_items" (secao 132) — resultado de aplicar o RatingEngine // num UsageEvent OU AIUsageRecord (exatamente um dos dois, checado na // camada de serviço — Postgres não tem um jeito limpo de expressar "XOR // de FK nullable" sem trigger, e um trigger seria over-engineering pra // isto). `pricingVersion` referencia o PriceBookItem/RateDeckEntry usado, // pra auditoria de qual preço vigia quando foi calculado. model RatedUsageItem { id String @id @default(uuid()) @db.Uuid tenantId String @map("tenant_id") @db.Uuid usageEventId String? @map("usage_event_id") @db.Uuid aiUsageRecordId String? @map("ai_usage_record_id") @db.Uuid callId String? @map("call_id") @db.Uuid priceBookItemId String? @map("price_book_item_id") @db.Uuid rateDeckEntryId String? @map("rate_deck_entry_id") @db.Uuid quantity Float unitPrice Float @map("unit_price") amount Float currency String @default("BRL") billingPeriodId String? @map("billing_period_id") @db.Uuid createdAt DateTime @default(now()) @map("created_at") tenant Tenant @relation(fields: [tenantId], references: [id]) usageEvent UsageEvent? @relation(fields: [usageEventId], references: [id]) aiUsageRecord AIUsageRecord? @relation(fields: [aiUsageRecordId], references: [id]) call Call? @relation(fields: [callId], references: [id]) priceBookItem PriceBookItem? @relation(fields: [priceBookItemId], references: [id]) rateDeckEntry RateDeckEntry? @relation(fields: [rateDeckEntryId], references: [id]) billingPeriod BillingPeriod? @relation(fields: [billingPeriodId], references: [id]) @@index([tenantId, billingPeriodId]) @@map("rated_usage_items") } enum BillingPeriodStatus { OPEN CALCULATING READY CLOSED REOPENED @@map("billing_period_status") } // "billing_periods" (secao 134). Fechamento imutável (secao 137): depois // de CLOSED, o service layer nunca recalcula silenciosamente — só via // REOPEN explícito, com audit trail (recordAuditEvent, user+reason), que // volta o status pra REOPENED (nunca direto pra OPEN, pra deixar visível // no histórico que este período já foi fechado antes). model BillingPeriod { id String @id @default(uuid()) @db.Uuid tenantId String @map("tenant_id") @db.Uuid periodStart DateTime @map("period_start") periodEnd DateTime @map("period_end") status BillingPeriodStatus @default(OPEN) closedAt DateTime? @map("closed_at") reopenedAt DateTime? @map("reopened_at") createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") tenant Tenant @relation(fields: [tenantId], references: [id]) ratedUsageItems RatedUsageItem[] statements BillingStatement[] @@unique([tenantId, periodStart, periodEnd]) @@index([tenantId, status]) @@map("billing_periods") } enum BillingStatementCategory { PLAN_BASE EXTENSIONS AGENTS TRUNKS CALLS MINUTES AI_TRANSCRIPTION AI_ANALYSIS AI_TOKENS STORAGE ADJUSTMENT @@map("billing_statement_category") } // "billing_statements"/"billing_statement_items" (secao 135). Secao 136: // NUNCA chamar isto de nota fiscal — só "Usage Statement"/"Billing // Statement"/"Relatório de Consumo" (aplicado na nomenclatura da API e // dos DTOs, não só em texto de UI que ainda não existe). model BillingStatement { id String @id @default(uuid()) @db.Uuid tenantId String @map("tenant_id") @db.Uuid billingPeriodId String @map("billing_period_id") @db.Uuid currency String @default("BRL") subtotal Float adjustments Float @default(0) total Float generatedAt DateTime @default(now()) @map("generated_at") tenant Tenant @relation(fields: [tenantId], references: [id]) billingPeriod BillingPeriod @relation(fields: [billingPeriodId], references: [id]) items BillingStatementItem[] @@index([tenantId, billingPeriodId]) @@map("billing_statements") } model BillingStatementItem { id String @id @default(uuid()) @db.Uuid tenantId String @map("tenant_id") @db.Uuid billingStatementId String @map("billing_statement_id") @db.Uuid category BillingStatementCategory description String quantity Float? unitPrice Float? @map("unit_price") amount Float tenant Tenant @relation(fields: [tenantId], references: [id]) billingStatement BillingStatement @relation(fields: [billingStatementId], references: [id]) @@index([billingStatementId]) @@map("billing_statement_items") }