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

@@ -0,0 +1,206 @@
-- CreateEnum
CREATE TYPE "CampaignStatus" AS ENUM ('DRAFT', 'READY', 'RUNNING', 'PAUSED', 'DRAINING', 'STOPPED', 'COMPLETED');
-- CreateEnum
CREATE TYPE "LeadStatus" AS ENUM ('NEW', 'READY', 'RESERVED', 'DIALING', 'RINGING', 'ANSWERED', 'CONNECTED_AGENT', 'BUSY', 'NO_ANSWER', 'FAILED', 'INVALID', 'VOICEMAIL', 'CALLBACK', 'COMPLETED', 'DO_NOT_CALL', 'MAX_ATTEMPTS');
-- CreateEnum
CREATE TYPE "LeadImportStatus" AS ENUM ('PROCESSING', 'COMPLETED', 'FAILED');
-- CreateEnum
CREATE TYPE "CallState" AS ENUM ('CREATED', 'RESERVED', 'ORIGINATING', 'RINGING', 'ANSWERED', 'QUEUED', 'AGENT_CONNECTED', 'COMPLETED', 'FAILED');
-- CreateEnum
CREATE TYPE "AmdResult" AS ENUM ('HUMAN', 'MACHINE', 'NOT_SURE', 'HANGUP');
-- CreateEnum
CREATE TYPE "DispositionAction" AS ENUM ('NONE', 'CALLBACK', 'DO_NOT_CALL');
-- CreateTable
CREATE TABLE "campaigns" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"description" TEXT,
"queue_id" TEXT NOT NULL,
"trunk_id" TEXT NOT NULL,
"caller_id" TEXT,
"context" TEXT NOT NULL DEFAULT 'outbound',
"status" "CampaignStatus" NOT NULL DEFAULT 'DRAFT',
"start_date" TIMESTAMP(3),
"end_date" TIMESTAMP(3),
"days_of_week" INTEGER[] DEFAULT ARRAY[1, 2, 3, 4, 5]::INTEGER[],
"start_time" TEXT NOT NULL DEFAULT '08:00',
"end_time" TEXT NOT NULL DEFAULT '20:00',
"timezone" TEXT NOT NULL DEFAULT 'America/Sao_Paulo',
"max_cps" INTEGER NOT NULL DEFAULT 2,
"max_concurrent_calls" INTEGER NOT NULL DEFAULT 10,
"pacing_initial" DOUBLE PRECISION NOT NULL DEFAULT 1.0,
"pacing_min" DOUBLE PRECISION NOT NULL DEFAULT 0.5,
"pacing_max" DOUBLE PRECISION NOT NULL DEFAULT 3.0,
"target_abandon_rate" DOUBLE PRECISION NOT NULL DEFAULT 0.03,
"max_wait_for_agent_seconds" INTEGER NOT NULL DEFAULT 30,
"ring_timeout_seconds" INTEGER NOT NULL DEFAULT 25,
"max_attempts" INTEGER NOT NULL DEFAULT 5,
"retry_rules" JSONB NOT NULL DEFAULT '{"BUSY":15,"NO_ANSWER":60,"CONGESTION":5,"FAILED":30}',
"amd_enabled" BOOLEAN NOT NULL DEFAULT false,
"wrap_up_time_seconds" INTEGER NOT NULL DEFAULT 0,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "campaigns_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "lead_imports" (
"id" TEXT NOT NULL,
"campaign_id" TEXT NOT NULL,
"filename" TEXT NOT NULL,
"status" "LeadImportStatus" NOT NULL DEFAULT 'PROCESSING',
"total_rows" INTEGER NOT NULL DEFAULT 0,
"valid_rows" INTEGER NOT NULL DEFAULT 0,
"invalid_rows" INTEGER NOT NULL DEFAULT 0,
"duplicate_rows" INTEGER NOT NULL DEFAULT 0,
"rejected_csv" TEXT,
"created_by_id" TEXT,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "lead_imports_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "leads" (
"id" TEXT NOT NULL,
"campaign_id" TEXT NOT NULL,
"import_id" TEXT,
"name" TEXT,
"phone" TEXT NOT NULL,
"phone_normalized" TEXT NOT NULL,
"status" "LeadStatus" NOT NULL DEFAULT 'NEW',
"attempt_count" INTEGER NOT NULL DEFAULT 0,
"last_attempt_at" TIMESTAMP(3),
"next_attempt_at" TIMESTAMP(3),
"last_result" TEXT,
"reserved_at" TIMESTAMP(3),
"reserved_by" TEXT,
"custom_fields" JSONB,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "leads_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "dial_attempts" (
"id" TEXT NOT NULL,
"lead_id" TEXT NOT NULL,
"campaign_id" TEXT NOT NULL,
"state" "CallState" NOT NULL DEFAULT 'CREATED',
"asterisk_unique_id" TEXT,
"asterisk_linked_id" TEXT,
"called_number" TEXT NOT NULL,
"caller_id_used" TEXT,
"agent_id" TEXT,
"disposition_id" TEXT,
"disposition_notes" TEXT,
"amd_result" "AmdResult",
"hangup_cause" TEXT,
"started_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"ringing_at" TIMESTAMP(3),
"answered_at" TIMESTAMP(3),
"queued_at" TIMESTAMP(3),
"agent_connected_at" TIMESTAMP(3),
"ended_at" TIMESTAMP(3),
CONSTRAINT "dial_attempts_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "call_dispositions" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"code" TEXT NOT NULL,
"description" TEXT,
"action" "DispositionAction" NOT NULL DEFAULT 'NONE',
"active" BOOLEAN NOT NULL DEFAULT true,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "call_dispositions_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "call_callbacks" (
"id" TEXT NOT NULL,
"lead_id" TEXT NOT NULL,
"campaign_id" TEXT NOT NULL,
"preferred_agent_id" TEXT,
"scheduled_at" TIMESTAMP(3) NOT NULL,
"timezone" TEXT NOT NULL DEFAULT 'America/Sao_Paulo',
"notes" TEXT,
"completed" BOOLEAN NOT NULL DEFAULT false,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "call_callbacks_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "suppression_list" (
"id" TEXT NOT NULL,
"phone_normalized" TEXT NOT NULL,
"reason" TEXT,
"added_by_id" TEXT,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "suppression_list_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "campaigns_name_key" ON "campaigns"("name");
-- CreateIndex
CREATE INDEX "leads_campaign_id_status_next_attempt_at_idx" ON "leads"("campaign_id", "status", "next_attempt_at");
-- CreateIndex
CREATE INDEX "leads_phone_normalized_idx" ON "leads"("phone_normalized");
-- CreateIndex
CREATE UNIQUE INDEX "dial_attempts_asterisk_unique_id_key" ON "dial_attempts"("asterisk_unique_id");
-- CreateIndex
CREATE INDEX "dial_attempts_campaign_id_state_idx" ON "dial_attempts"("campaign_id", "state");
-- CreateIndex
CREATE INDEX "dial_attempts_lead_id_idx" ON "dial_attempts"("lead_id");
-- CreateIndex
CREATE UNIQUE INDEX "call_dispositions_code_key" ON "call_dispositions"("code");
-- CreateIndex
CREATE INDEX "call_callbacks_scheduled_at_completed_idx" ON "call_callbacks"("scheduled_at", "completed");
-- CreateIndex
CREATE UNIQUE INDEX "suppression_list_phone_normalized_key" ON "suppression_list"("phone_normalized");
-- AddForeignKey
ALTER TABLE "lead_imports" ADD CONSTRAINT "lead_imports_campaign_id_fkey" FOREIGN KEY ("campaign_id") REFERENCES "campaigns"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "leads" ADD CONSTRAINT "leads_campaign_id_fkey" FOREIGN KEY ("campaign_id") REFERENCES "campaigns"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "leads" ADD CONSTRAINT "leads_import_id_fkey" FOREIGN KEY ("import_id") REFERENCES "lead_imports"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "dial_attempts" ADD CONSTRAINT "dial_attempts_lead_id_fkey" FOREIGN KEY ("lead_id") REFERENCES "leads"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "dial_attempts" ADD CONSTRAINT "dial_attempts_campaign_id_fkey" FOREIGN KEY ("campaign_id") REFERENCES "campaigns"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "dial_attempts" ADD CONSTRAINT "dial_attempts_disposition_id_fkey" FOREIGN KEY ("disposition_id") REFERENCES "call_dispositions"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "call_callbacks" ADD CONSTRAINT "call_callbacks_lead_id_fkey" FOREIGN KEY ("lead_id") REFERENCES "leads"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "call_callbacks" ADD CONSTRAINT "call_callbacks_campaign_id_fkey" FOREIGN KEY ("campaign_id") REFERENCES "campaigns"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -0,0 +1,5 @@
-- AddForeignKey
ALTER TABLE "campaigns" ADD CONSTRAINT "campaigns_queue_id_fkey" FOREIGN KEY ("queue_id") REFERENCES "queues"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "campaigns" ADD CONSTRAINT "campaigns_trunk_id_fkey" FOREIGN KEY ("trunk_id") REFERENCES "trunks"("id") ON DELETE RESTRICT ON UPDATE CASCADE;

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")
}

View File

@@ -1,3 +1,4 @@
export * from './permissions';
export * from './generate-password';
export * from './secret-crypto';
export * from './phone-normalization';

View File

@@ -0,0 +1,56 @@
// Normalização de telefone dedicada (agente.md seção 28) — nunca espalhar
// regex de telefone pelo resto do código. Formato normalizado: E.164
// (+55DDDNUMERO), já preparando suporte internacional futuro (basta
// adicionar outros ramos de país aqui, sem tocar em quem consome isso).
export interface PhoneNormalizationResult {
original: string;
normalized: string | null;
valid: boolean;
reason?: string;
}
export function normalizeBrazilianPhone(raw: string): PhoneNormalizationResult {
const digits = (raw ?? '').replace(/\D/g, '');
if (!digits) {
return { original: raw, normalized: null, valid: false, reason: 'Telefone vazio' };
}
let national = digits;
if (national.startsWith('55') && (national.length === 12 || national.length === 13)) {
national = national.slice(2);
} else if (national.startsWith('0') && national.length > 10) {
national = national.replace(/^0+/, '');
}
if (national.length !== 10 && national.length !== 11) {
return {
original: raw,
normalized: null,
valid: false,
reason: `Quantidade de dígitos inválida (${national.length}), esperado 10 ou 11`,
};
}
const ddd = Number(national.slice(0, 2));
if (ddd < 11 || ddd > 99) {
return { original: raw, normalized: null, valid: false, reason: `DDD inválido (${national.slice(0, 2)})` };
}
const subscriberNumber = national.slice(2);
if (national.length === 11 && subscriberNumber[0] !== '9') {
return {
original: raw,
normalized: null,
valid: false,
reason: 'Número de celular com 11 dígitos deve começar com 9',
};
}
return { original: raw, normalized: `+55${national}`, valid: true };
}
// Ponto único de entrada — troca de país/estratégia acontece só aqui.
export function normalizePhone(raw: string): PhoneNormalizationResult {
return normalizeBrazilianPhone(raw);
}