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;