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