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

@@ -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);
}