feat: add campaign management

- packages/database: Campaign, LeadImport, Lead, DialAttempt,
  CallDisposition, Callback, SuppressionEntry (agente.md secao 53)
- packages/shared: normalizePhone (BR, E.164) com 7 testes unitarios
- apps/api/src/campaigns: CRUD completo (todos os campos da secao 24) com
  maquina de estados validada (DRAFT/READY/RUNNING/PAUSED/DRAINING/
  STOPPED/COMPLETED) — edicao bloqueada com campanha RUNNING
- apps/api/src/leads: import CSV via streaming multipart (deteccao
  automatica de delimitador, mapeamento de coluna por header, validacao+
  normalizacao+dedupe em lotes de 500, CSV de rejeitados com motivo, modo
  dryRun para preview)
- apps/api/src/suppression: CRUD + import CSV da lista de bloqueio,
  remocao sempre exige motivo e e auditada, isSuppressed() pronto para o
  dialer-worker checar antes de originar
- apps/api/src/dispositions: CRUD de disposicoes de chamada

Testado ponta a ponta: campanha criada com defaults corretos, import de
CSV real (3 validos/1 invalido/1 duplicado, contadores batendo), leads
persistidos com telefone normalizado, transicoes de estado da campanha
rejeitando movimentos invalidos (PAUSED->PAUSED = 400).
This commit is contained in:
2026-08-27 14:48:46 -03:00
parent 63103d4335
commit 167776ff63
30 changed files with 2008 additions and 5 deletions

View File

@@ -180,6 +180,8 @@ model Trunk {
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
campaigns Campaign[]
@@map("trunks")
}
@@ -289,7 +291,8 @@ model Queue {
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
members QueueMember[]
members QueueMember[]
campaigns Campaign[]
@@map("queues")
}
@@ -394,3 +397,241 @@ model AgentPauseEvent {
@@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")
}