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:
@@ -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