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:
@@ -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")
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user