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:
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
export * from './permissions';
|
||||
export * from './generate-password';
|
||||
export * from './secret-crypto';
|
||||
export * from './phone-normalization';
|
||||
|
||||
56
packages/shared/src/phone-normalization.ts
Normal file
56
packages/shared/src/phone-normalization.ts
Normal 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);
|
||||
}
|
||||
Reference in New Issue
Block a user