feat(entitlements,campaigns): plans/quotas + campanhas, leads, lista de bloqueio

Fecha duas fases: Plans/Entitlements (agente.md secao 56-62), que tinha
ficado pra trás desde o inicio, e Campanhas/Leads/Lista de Bloqueio (secao
63-71).

## Plans/Entitlements

A ordem de implementacao da propria especificacao (secao 232) coloca
Plans/Entitlements logo depois de PostgreSQL RLS, bem antes de FreeSWITCH
— mas o build seguiu direto sem essa peca, e toda fase desde entao
documentou "quota depende de Plans/Entitlements" como pendencia
(EXTENSIONS.md, TRUNKS.md, AGENTS.md, QUEUES.md, agora todas atualizadas).
Fechado agora porque Campanhas precisa de max_campaigns e o proximo CPS
Limiter vai precisar de max_cps/max_concurrent_calls.

- plans: catalogo compartilhado entre tenants (sem RLS, nao e' tenant-
  scoped) com todos os campos de entitlement da secao 56. Campo de limite
  null = "sem limite", nunca "sem plano" — tenants.plan_id e' obrigatorio,
  nunca null (secao 56: nao espalhar `if plan == PRO` pelo codigo).
- Migration hand-escrita: cria plans, insere seed "trial", faz backfill de
  plan_id pros tenants ja existentes, so' depois torna NOT NULL (Postgres
  nao deixa NOT NULL sem default em tabela nao-vazia).
- packages/entitlements (pacote novo): assertQuota/assertFeatureEnabled,
  erros mapeados pra 403 no DomainExceptionFilter.
- Retrofit em Extensions/Trunks/Agents/Queues: contam linhas ativas e
  checam quota antes de criar.

## Campanhas, Leads, Lista de Bloqueio

Deliberadamente so' o modelo/CRUD/maquina de estados — o motor que de fato
origina chamadas (PredictiveDialerEngine, secao 72-86: dados em tempo
real, EWMA, CPS distribuido, reserva atomica de lead, lock de campanha,
bgapi originate, controle de abandono, retry) e' um sistema grande o
suficiente pra merecer fase propria (secao 72: "nao e' so' `for lead ->
originate`").

- campaigns/leads/suppression_entries (tenant-scoped, RLS).
- Maquina de estados da campanha (secao 64-66): start/pause/drain/stop com
  tabela de transicoes validas — transicao invalida retorna 400, nunca
  ignora silenciosamente. Apagar bloqueado enquanto RUNNING/DRAINING.
- packages/shared/src/phone.ts (secao 70): normalizacao dedicada,
  preparada pra E.164 completo, so' BR implementado.
- Importacao CSV em batches de 1000 (secao 69): detecta duplicado (dentro
  do CSV + contra leads existentes), checa lista de bloqueio (importa como
  DO_NOT_CALL, nao descarta), retorna {total, valid, invalid, duplicates,
  imported, suppressed}.
- Lista de bloqueio (secao 71): CRUD tenant-scoped.

Verificado ponta a ponta: campanha com queueId/trunkId invalido e
pacingMin > pacingMax rejeitados; CSV de 5 linhas (1 invalida, 1
duplicada, 1 bloqueada) importado corretamente; start->pause->drain->stop
e transicoes invalidas todas corretas; 3a campanha rejeitada por quota
(max_campaigns=2 do plano trial); 6a extensao rejeitada por quota
(max_extensions=5). Suites de teste existentes (tenant-isolation, auth)
atualizadas pro novo Tenant.planId obrigatorio e passando.

typecheck do workspace inteiro limpo.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X1HxY46WGU4G1zmVDNKcWw
This commit is contained in:
2026-08-28 12:26:47 -03:00
parent f051fe3162
commit 7b62ad3d82
35 changed files with 1674 additions and 13 deletions

View File

@@ -28,10 +28,12 @@ model Tenant {
locale String @default("pt-BR")
billingCurrency String @default("BRL") @map("billing_currency")
telephonyDomain String? @map("telephony_domain")
planId String @map("plan_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])
memberships TenantMembership[]
userRoles UserRole[]
extensions Extension[]
@@ -45,10 +47,47 @@ model Tenant {
agentSessions AgentSession[]
agentStateEvents AgentStateEvent[]
agentPauseEvents AgentPauseEvent[]
campaigns Campaign[]
leads Lead[]
suppressionEntries SuppressionEntry[]
@@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")
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[]
@@map("plans")
}
enum UserStatus {
ACTIVE
DISABLED
@@ -316,7 +355,8 @@ model Trunk {
updatedAt DateTime @updatedAt @map("updated_at")
deletedAt DateTime? @map("deleted_at")
tenant Tenant @relation(fields: [tenantId], references: [id])
tenant Tenant @relation(fields: [tenantId], references: [id])
campaigns Campaign[]
@@unique([tenantId, name])
@@index([tenantId])
@@ -440,8 +480,9 @@ model Queue {
updatedAt DateTime @updatedAt @map("updated_at")
deletedAt DateTime? @map("deleted_at")
tenant Tenant @relation(fields: [tenantId], references: [id])
tiers Tier[]
tenant Tenant @relation(fields: [tenantId], references: [id])
tiers Tier[]
campaigns Campaign[]
@@unique([tenantId, name])
@@index([tenantId])
@@ -602,3 +643,163 @@ model AgentPauseEvent {
@@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")
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])
leads Lead[]
@@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])
@@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")
}