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:
19
TODO.md
19
TODO.md
@@ -167,10 +167,21 @@ mestre original (`agente.md`, seções 90-93).
|
|||||||
de CDR/CEL/queue_log) — não inventamos número aqui
|
de CDR/CEL/queue_log) — não inventamos número aqui
|
||||||
|
|
||||||
## Fase 6 — Campanhas e discador preditivo
|
## Fase 6 — Campanhas e discador preditivo
|
||||||
- [ ] CRUD Campanhas (todos os campos da seção 24)
|
- [x] CRUD Campanhas (todos os campos da seção 24) — testado: criação com
|
||||||
- [ ] Import CSV streaming (preview, mapeamento, validação, duplicados, rejeitados)
|
defaults corretos, máquina de estados (DRAFT/READY/RUNNING/PAUSED/
|
||||||
- [ ] Normalização de telefone (serviço dedicado, BR inicialmente)
|
DRAINING/STOPPED/COMPLETED) rejeitando transições inválidas (ex.:
|
||||||
- [ ] Lista de supressão (DNC) + checagem obrigatória pré-originação
|
PAUSED->PAUSED = 400), edição bloqueada com campanha RUNNING
|
||||||
|
- [x] Import CSV streaming (detecção automática de delimitador — vírgula/
|
||||||
|
ponto-e-vírgula/tab —, mapeamento de coluna por nome de header,
|
||||||
|
validação+normalização, dedupe dentro do arquivo E contra leads já
|
||||||
|
existentes na campanha, CSV de rejeitados com motivo, modo dryRun
|
||||||
|
para preview sem persistir) — testado com CSV real (5 linhas: 3
|
||||||
|
válidas, 1 inválida, 1 duplicada — todos os contadores bateram)
|
||||||
|
- [x] Normalização de telefone (packages/shared, BR — E.164, DDD/celular
|
||||||
|
validados) — 7 testes unitários passando
|
||||||
|
- [x] Lista de supressão (DNC): CRUD + import CSV + remoção sempre exige
|
||||||
|
motivo e é auditada — checagem obrigatória pré-originação
|
||||||
|
(isSuppressed) implementada e usada pelo dialer-worker (ver abaixo)
|
||||||
- [ ] CPS limiter (token bucket, coordenado via Redis, multi-worker)
|
- [ ] CPS limiter (token bucket, coordenado via Redis, multi-worker)
|
||||||
- [ ] Reserva concorrente de leads (FOR UPDATE SKIP LOCKED + timeout)
|
- [ ] Reserva concorrente de leads (FOR UPDATE SKIP LOCKED + timeout)
|
||||||
- [ ] Idempotência de originação (attempt_id/call_id/uniqueid/linkedid, state machine)
|
- [ ] Idempotência de originação (attempt_id/call_id/uniqueid/linkedid, state machine)
|
||||||
|
|||||||
@@ -25,6 +25,7 @@
|
|||||||
"@b2bcall/telephony": "workspace:*",
|
"@b2bcall/telephony": "workspace:*",
|
||||||
"@fastify/cookie": "^11.0.2",
|
"@fastify/cookie": "^11.0.2",
|
||||||
"@fastify/helmet": "^13.0.1",
|
"@fastify/helmet": "^13.0.1",
|
||||||
|
"@fastify/multipart": "^9.0.3",
|
||||||
"@fastify/static": "^8.0.4",
|
"@fastify/static": "^8.0.4",
|
||||||
"@nestjs/common": "^11.0.1",
|
"@nestjs/common": "^11.0.1",
|
||||||
"@nestjs/config": "^4.0.2",
|
"@nestjs/config": "^4.0.2",
|
||||||
@@ -38,6 +39,8 @@
|
|||||||
"argon2": "^0.44.0",
|
"argon2": "^0.44.0",
|
||||||
"class-transformer": "^0.5.1",
|
"class-transformer": "^0.5.1",
|
||||||
"class-validator": "^0.14.2",
|
"class-validator": "^0.14.2",
|
||||||
|
"csv-parse": "^5.6.0",
|
||||||
|
"csv-stringify": "^6.5.2",
|
||||||
"fastify": "^5.2.1",
|
"fastify": "^5.2.1",
|
||||||
"ioredis": "^5.4.2",
|
"ioredis": "^5.4.2",
|
||||||
"ms": "^2.1.3",
|
"ms": "^2.1.3",
|
||||||
|
|||||||
@@ -23,6 +23,10 @@ import { PauseReasonsModule } from './pause-reasons/pause-reasons.module';
|
|||||||
import { QueuesModule } from './queues/queues.module';
|
import { QueuesModule } from './queues/queues.module';
|
||||||
import { AgentsModule } from './agents/agents.module';
|
import { AgentsModule } from './agents/agents.module';
|
||||||
import { AgentConsoleModule } from './agent-console/agent-console.module';
|
import { AgentConsoleModule } from './agent-console/agent-console.module';
|
||||||
|
import { SuppressionModule } from './suppression/suppression.module';
|
||||||
|
import { DispositionsModule } from './dispositions/dispositions.module';
|
||||||
|
import { CampaignsModule } from './campaigns/campaigns.module';
|
||||||
|
import { LeadsModule } from './leads/leads.module';
|
||||||
import { AuthGuard } from './common/guards/auth.guard';
|
import { AuthGuard } from './common/guards/auth.guard';
|
||||||
import { PermissionsGuard } from './common/guards/permissions.guard';
|
import { PermissionsGuard } from './common/guards/permissions.guard';
|
||||||
import { GlobalExceptionFilter } from './common/filters/global-exception.filter';
|
import { GlobalExceptionFilter } from './common/filters/global-exception.filter';
|
||||||
@@ -72,6 +76,10 @@ import { GlobalExceptionFilter } from './common/filters/global-exception.filter'
|
|||||||
QueuesModule,
|
QueuesModule,
|
||||||
AgentsModule,
|
AgentsModule,
|
||||||
AgentConsoleModule,
|
AgentConsoleModule,
|
||||||
|
SuppressionModule,
|
||||||
|
DispositionsModule,
|
||||||
|
CampaignsModule,
|
||||||
|
LeadsModule,
|
||||||
],
|
],
|
||||||
controllers: [AppController],
|
controllers: [AppController],
|
||||||
providers: [
|
providers: [
|
||||||
|
|||||||
127
apps/api/src/campaigns/campaigns.controller.ts
Normal file
127
apps/api/src/campaigns/campaigns.controller.ts
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Delete,
|
||||||
|
Get,
|
||||||
|
Param,
|
||||||
|
ParseUUIDPipe,
|
||||||
|
Patch,
|
||||||
|
Post,
|
||||||
|
Req,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import type { FastifyRequest } from 'fastify';
|
||||||
|
import { RequirePermissions } from '../common/decorators/permissions.decorator';
|
||||||
|
import { CurrentUser } from '../common/decorators/current-user.decorator';
|
||||||
|
import type { AuthenticatedUser } from '../common/guards/auth.guard';
|
||||||
|
import { CampaignsService } from './campaigns.service';
|
||||||
|
import { CreateCampaignDto } from './dto/create-campaign.dto';
|
||||||
|
import { UpdateCampaignDto } from './dto/update-campaign.dto';
|
||||||
|
|
||||||
|
@Controller('campaigns')
|
||||||
|
export class CampaignsController {
|
||||||
|
constructor(private readonly campaignsService: CampaignsService) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
@RequirePermissions('campaigns.view')
|
||||||
|
list() {
|
||||||
|
return this.campaignsService.list();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':id')
|
||||||
|
@RequirePermissions('campaigns.view')
|
||||||
|
findOne(@Param('id', ParseUUIDPipe) id: string) {
|
||||||
|
return this.campaignsService.findByIdOrThrow(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
@RequirePermissions('campaigns.create')
|
||||||
|
create(
|
||||||
|
@Body() dto: CreateCampaignDto,
|
||||||
|
@CurrentUser() actor: AuthenticatedUser,
|
||||||
|
@Req() request: FastifyRequest,
|
||||||
|
) {
|
||||||
|
return this.campaignsService.create(dto, actor, {
|
||||||
|
ip: request.ip,
|
||||||
|
userAgent: request.headers['user-agent'],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch(':id')
|
||||||
|
@RequirePermissions('campaigns.update')
|
||||||
|
update(
|
||||||
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
@Body() dto: UpdateCampaignDto,
|
||||||
|
@CurrentUser() actor: AuthenticatedUser,
|
||||||
|
@Req() request: FastifyRequest,
|
||||||
|
) {
|
||||||
|
return this.campaignsService.update(id, dto, actor, {
|
||||||
|
ip: request.ip,
|
||||||
|
userAgent: request.headers['user-agent'],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete(':id')
|
||||||
|
@RequirePermissions('campaigns.delete')
|
||||||
|
remove(
|
||||||
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
@CurrentUser() actor: AuthenticatedUser,
|
||||||
|
@Req() request: FastifyRequest,
|
||||||
|
) {
|
||||||
|
return this.campaignsService.delete(id, actor, {
|
||||||
|
ip: request.ip,
|
||||||
|
userAgent: request.headers['user-agent'],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(':id/start')
|
||||||
|
@RequirePermissions('campaigns.start')
|
||||||
|
start(
|
||||||
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
@CurrentUser() actor: AuthenticatedUser,
|
||||||
|
@Req() request: FastifyRequest,
|
||||||
|
) {
|
||||||
|
return this.campaignsService.start(id, actor, {
|
||||||
|
ip: request.ip,
|
||||||
|
userAgent: request.headers['user-agent'],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(':id/pause')
|
||||||
|
@RequirePermissions('campaigns.pause')
|
||||||
|
pause(
|
||||||
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
@CurrentUser() actor: AuthenticatedUser,
|
||||||
|
@Req() request: FastifyRequest,
|
||||||
|
) {
|
||||||
|
return this.campaignsService.pause(id, actor, {
|
||||||
|
ip: request.ip,
|
||||||
|
userAgent: request.headers['user-agent'],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(':id/stop')
|
||||||
|
@RequirePermissions('campaigns.stop')
|
||||||
|
stop(
|
||||||
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
@CurrentUser() actor: AuthenticatedUser,
|
||||||
|
@Req() request: FastifyRequest,
|
||||||
|
) {
|
||||||
|
return this.campaignsService.stop(id, actor, {
|
||||||
|
ip: request.ip,
|
||||||
|
userAgent: request.headers['user-agent'],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(':id/drain')
|
||||||
|
@RequirePermissions('campaigns.pause')
|
||||||
|
drain(
|
||||||
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
@CurrentUser() actor: AuthenticatedUser,
|
||||||
|
@Req() request: FastifyRequest,
|
||||||
|
) {
|
||||||
|
return this.campaignsService.drain(id, actor, {
|
||||||
|
ip: request.ip,
|
||||||
|
userAgent: request.headers['user-agent'],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
10
apps/api/src/campaigns/campaigns.module.ts
Normal file
10
apps/api/src/campaigns/campaigns.module.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { CampaignsController } from './campaigns.controller';
|
||||||
|
import { CampaignsService } from './campaigns.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
controllers: [CampaignsController],
|
||||||
|
providers: [CampaignsService],
|
||||||
|
exports: [CampaignsService],
|
||||||
|
})
|
||||||
|
export class CampaignsModule {}
|
||||||
239
apps/api/src/campaigns/campaigns.service.ts
Normal file
239
apps/api/src/campaigns/campaigns.service.ts
Normal file
@@ -0,0 +1,239 @@
|
|||||||
|
import {
|
||||||
|
BadRequestException,
|
||||||
|
Injectable,
|
||||||
|
NotFoundException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { Campaign, CampaignStatus } from '@b2bcall/database';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import { AuditService } from '../audit/audit.service';
|
||||||
|
import type { RequestContext } from '../auth/auth.service';
|
||||||
|
import { CreateCampaignDto } from './dto/create-campaign.dto';
|
||||||
|
import { UpdateCampaignDto } from './dto/update-campaign.dto';
|
||||||
|
|
||||||
|
// Transições válidas de status (agente.md seção 24). DRAINING/COMPLETED
|
||||||
|
// nunca são setados diretamente pelo usuário via update — DRAINING só via
|
||||||
|
// ação "drenar", COMPLETED só pelo dialer-worker ao esgotar os leads.
|
||||||
|
const VALID_TRANSITIONS: Record<CampaignStatus, CampaignStatus[]> = {
|
||||||
|
DRAFT: [CampaignStatus.READY, CampaignStatus.RUNNING],
|
||||||
|
READY: [CampaignStatus.RUNNING, CampaignStatus.DRAFT],
|
||||||
|
RUNNING: [
|
||||||
|
CampaignStatus.PAUSED,
|
||||||
|
CampaignStatus.DRAINING,
|
||||||
|
CampaignStatus.STOPPED,
|
||||||
|
],
|
||||||
|
PAUSED: [CampaignStatus.RUNNING, CampaignStatus.STOPPED],
|
||||||
|
DRAINING: [CampaignStatus.STOPPED, CampaignStatus.COMPLETED],
|
||||||
|
STOPPED: [CampaignStatus.READY, CampaignStatus.RUNNING],
|
||||||
|
COMPLETED: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
function toDto(campaign: Campaign) {
|
||||||
|
return campaign;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class CampaignsService {
|
||||||
|
constructor(
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
private readonly audit: AuditService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
list() {
|
||||||
|
return this.prisma.campaign.findMany({
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
include: { queue: true, trunk: { select: { id: true, name: true } } },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async findByIdOrThrow(id: string) {
|
||||||
|
const campaign = await this.prisma.campaign.findUnique({
|
||||||
|
where: { id },
|
||||||
|
include: { queue: true, trunk: { select: { id: true, name: true } } },
|
||||||
|
});
|
||||||
|
if (!campaign) throw new NotFoundException('Campanha não encontrada.');
|
||||||
|
return campaign;
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(
|
||||||
|
dto: CreateCampaignDto,
|
||||||
|
actor: { id: string },
|
||||||
|
ctx: RequestContext,
|
||||||
|
) {
|
||||||
|
const [existing, queue, trunk] = await Promise.all([
|
||||||
|
this.prisma.campaign.findUnique({ where: { name: dto.name } }),
|
||||||
|
this.prisma.queue.findUnique({ where: { id: dto.queueId } }),
|
||||||
|
this.prisma.trunk.findUnique({ where: { id: dto.trunkId } }),
|
||||||
|
]);
|
||||||
|
if (existing)
|
||||||
|
throw new BadRequestException('Já existe uma campanha com este nome.');
|
||||||
|
if (!queue) throw new BadRequestException('Fila informada não existe.');
|
||||||
|
if (!trunk) throw new BadRequestException('Tronco informado não existe.');
|
||||||
|
this.validatePacing(dto);
|
||||||
|
|
||||||
|
const campaign = await this.prisma.campaign.create({
|
||||||
|
data: {
|
||||||
|
...dto,
|
||||||
|
startDate: dto.startDate ? new Date(dto.startDate) : undefined,
|
||||||
|
endDate: dto.endDate ? new Date(dto.endDate) : undefined,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.audit.log({
|
||||||
|
userId: actor.id,
|
||||||
|
action: 'campaign_created',
|
||||||
|
entityType: 'campaign',
|
||||||
|
entityId: campaign.id,
|
||||||
|
after: { ...dto },
|
||||||
|
ipAddress: ctx.ip,
|
||||||
|
userAgent: ctx.userAgent,
|
||||||
|
});
|
||||||
|
return toDto(campaign);
|
||||||
|
}
|
||||||
|
|
||||||
|
private validatePacing(dto: Partial<CreateCampaignDto>) {
|
||||||
|
if (
|
||||||
|
dto.pacingMin !== undefined &&
|
||||||
|
dto.pacingMax !== undefined &&
|
||||||
|
dto.pacingMin > dto.pacingMax
|
||||||
|
) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'pacingMin não pode ser maior que pacingMax.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(
|
||||||
|
id: string,
|
||||||
|
dto: UpdateCampaignDto,
|
||||||
|
actor: { id: string },
|
||||||
|
ctx: RequestContext,
|
||||||
|
) {
|
||||||
|
const before = await this.prisma.campaign.findUnique({ where: { id } });
|
||||||
|
if (!before) throw new NotFoundException('Campanha não encontrada.');
|
||||||
|
if (before.status === CampaignStatus.RUNNING) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'Pause a campanha antes de editar seus parâmetros.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
this.validatePacing({
|
||||||
|
pacingMin: dto.pacingMin ?? before.pacingMin,
|
||||||
|
pacingMax: dto.pacingMax ?? before.pacingMax,
|
||||||
|
});
|
||||||
|
|
||||||
|
const campaign = await this.prisma.campaign.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
...dto,
|
||||||
|
startDate: dto.startDate ? new Date(dto.startDate) : undefined,
|
||||||
|
endDate: dto.endDate ? new Date(dto.endDate) : undefined,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.audit.log({
|
||||||
|
userId: actor.id,
|
||||||
|
action: 'campaign_updated',
|
||||||
|
entityType: 'campaign',
|
||||||
|
entityId: id,
|
||||||
|
before,
|
||||||
|
after: { ...dto },
|
||||||
|
ipAddress: ctx.ip,
|
||||||
|
userAgent: ctx.userAgent,
|
||||||
|
});
|
||||||
|
return toDto(campaign);
|
||||||
|
}
|
||||||
|
|
||||||
|
async delete(id: string, actor: { id: string }, ctx: RequestContext) {
|
||||||
|
const campaign = await this.prisma.campaign.findUnique({ where: { id } });
|
||||||
|
if (!campaign) throw new NotFoundException('Campanha não encontrada.');
|
||||||
|
if (campaign.status === CampaignStatus.RUNNING) {
|
||||||
|
throw new BadRequestException('Pare a campanha antes de excluí-la.');
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.prisma.campaign.delete({ where: { id } });
|
||||||
|
await this.audit.log({
|
||||||
|
userId: actor.id,
|
||||||
|
action: 'campaign_deleted',
|
||||||
|
entityType: 'campaign',
|
||||||
|
entityId: id,
|
||||||
|
before: campaign,
|
||||||
|
ipAddress: ctx.ip,
|
||||||
|
userAgent: ctx.userAgent,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async transition(
|
||||||
|
id: string,
|
||||||
|
target: CampaignStatus,
|
||||||
|
actor: { id: string },
|
||||||
|
ctx: RequestContext,
|
||||||
|
action: string,
|
||||||
|
) {
|
||||||
|
const campaign = await this.prisma.campaign.findUnique({ where: { id } });
|
||||||
|
if (!campaign) throw new NotFoundException('Campanha não encontrada.');
|
||||||
|
|
||||||
|
if (!VALID_TRANSITIONS[campaign.status].includes(target)) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`Não é possível ir de ${campaign.status} para ${target}.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = await this.prisma.campaign.update({
|
||||||
|
where: { id },
|
||||||
|
data: { status: target },
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.audit.log({
|
||||||
|
userId: actor.id,
|
||||||
|
action,
|
||||||
|
entityType: 'campaign',
|
||||||
|
entityId: id,
|
||||||
|
before: { status: campaign.status },
|
||||||
|
after: { status: target },
|
||||||
|
ipAddress: ctx.ip,
|
||||||
|
userAgent: ctx.userAgent,
|
||||||
|
});
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
|
||||||
|
start(id: string, actor: { id: string }, ctx: RequestContext) {
|
||||||
|
return this.transition(
|
||||||
|
id,
|
||||||
|
CampaignStatus.RUNNING,
|
||||||
|
actor,
|
||||||
|
ctx,
|
||||||
|
'campaign_started',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
pause(id: string, actor: { id: string }, ctx: RequestContext) {
|
||||||
|
// Pausar nunca derruba chamadas em andamento (agente.md seção 76) — o
|
||||||
|
// dialer-worker apenas para de originar novas ao ver status != RUNNING.
|
||||||
|
return this.transition(
|
||||||
|
id,
|
||||||
|
CampaignStatus.PAUSED,
|
||||||
|
actor,
|
||||||
|
ctx,
|
||||||
|
'campaign_paused',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
stop(id: string, actor: { id: string }, ctx: RequestContext) {
|
||||||
|
return this.transition(
|
||||||
|
id,
|
||||||
|
CampaignStatus.STOPPED,
|
||||||
|
actor,
|
||||||
|
ctx,
|
||||||
|
'campaign_stopped',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
drain(id: string, actor: { id: string }, ctx: RequestContext) {
|
||||||
|
return this.transition(
|
||||||
|
id,
|
||||||
|
CampaignStatus.DRAINING,
|
||||||
|
actor,
|
||||||
|
ctx,
|
||||||
|
'campaign_drained',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
133
apps/api/src/campaigns/dto/create-campaign.dto.ts
Normal file
133
apps/api/src/campaigns/dto/create-campaign.dto.ts
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
import {
|
||||||
|
IsArray,
|
||||||
|
IsBoolean,
|
||||||
|
IsDateString,
|
||||||
|
IsInt,
|
||||||
|
IsNumber,
|
||||||
|
IsObject,
|
||||||
|
IsOptional,
|
||||||
|
IsString,
|
||||||
|
IsUUID,
|
||||||
|
Matches,
|
||||||
|
Max,
|
||||||
|
MaxLength,
|
||||||
|
Min,
|
||||||
|
MinLength,
|
||||||
|
} from 'class-validator';
|
||||||
|
|
||||||
|
export class CreateCampaignDto {
|
||||||
|
@IsString()
|
||||||
|
@MinLength(1)
|
||||||
|
@MaxLength(120)
|
||||||
|
name!: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
description?: string;
|
||||||
|
|
||||||
|
@IsUUID('4')
|
||||||
|
queueId!: string;
|
||||||
|
|
||||||
|
@IsUUID('4')
|
||||||
|
trunkId!: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
callerId?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
context?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsDateString()
|
||||||
|
startDate?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsDateString()
|
||||||
|
endDate?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray()
|
||||||
|
@IsInt({ each: true })
|
||||||
|
@Min(0, { each: true })
|
||||||
|
@Max(6, { each: true })
|
||||||
|
daysOfWeek?: number[];
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@Matches(/^([01]\d|2[0-3]):[0-5]\d$/, { message: 'Horário no formato HH:MM' })
|
||||||
|
startTime?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@Matches(/^([01]\d|2[0-3]):[0-5]\d$/, { message: 'Horário no formato HH:MM' })
|
||||||
|
endTime?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
timezone?: string;
|
||||||
|
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
@Max(1000)
|
||||||
|
maxCps!: number;
|
||||||
|
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
@Max(10000)
|
||||||
|
maxConcurrentCalls!: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
@Min(0.1)
|
||||||
|
@Max(10)
|
||||||
|
pacingInitial?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
@Min(0.1)
|
||||||
|
@Max(10)
|
||||||
|
pacingMin?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
@Min(0.1)
|
||||||
|
@Max(10)
|
||||||
|
pacingMax?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
@Min(0)
|
||||||
|
@Max(1)
|
||||||
|
targetAbandonRate?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
maxWaitForAgentSeconds?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
ringTimeoutSeconds?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
@Max(50)
|
||||||
|
maxAttempts?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsObject()
|
||||||
|
retryRules?: Record<string, number>;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
amdEnabled?: boolean;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsInt()
|
||||||
|
@Min(0)
|
||||||
|
wrapUpTimeSeconds?: number;
|
||||||
|
}
|
||||||
4
apps/api/src/campaigns/dto/update-campaign.dto.ts
Normal file
4
apps/api/src/campaigns/dto/update-campaign.dto.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
import { PartialType } from '@nestjs/mapped-types';
|
||||||
|
import { CreateCampaignDto } from './create-campaign.dto';
|
||||||
|
|
||||||
|
export class UpdateCampaignDto extends PartialType(CreateCampaignDto) {}
|
||||||
68
apps/api/src/dispositions/dispositions.controller.ts
Normal file
68
apps/api/src/dispositions/dispositions.controller.ts
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Delete,
|
||||||
|
Get,
|
||||||
|
Param,
|
||||||
|
ParseUUIDPipe,
|
||||||
|
Patch,
|
||||||
|
Post,
|
||||||
|
Req,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import type { FastifyRequest } from 'fastify';
|
||||||
|
import { RequirePermissions } from '../common/decorators/permissions.decorator';
|
||||||
|
import { CurrentUser } from '../common/decorators/current-user.decorator';
|
||||||
|
import type { AuthenticatedUser } from '../common/guards/auth.guard';
|
||||||
|
import { DispositionsService } from './dispositions.service';
|
||||||
|
import { CreateDispositionDto } from './dto/create-disposition.dto';
|
||||||
|
import { UpdateDispositionDto } from './dto/update-disposition.dto';
|
||||||
|
|
||||||
|
@Controller('dispositions')
|
||||||
|
export class DispositionsController {
|
||||||
|
constructor(private readonly dispositionsService: DispositionsService) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
list() {
|
||||||
|
return this.dispositionsService.list();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
@RequirePermissions('settings.manage')
|
||||||
|
create(
|
||||||
|
@Body() dto: CreateDispositionDto,
|
||||||
|
@CurrentUser() actor: AuthenticatedUser,
|
||||||
|
@Req() request: FastifyRequest,
|
||||||
|
) {
|
||||||
|
return this.dispositionsService.create(dto, actor, {
|
||||||
|
ip: request.ip,
|
||||||
|
userAgent: request.headers['user-agent'],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch(':id')
|
||||||
|
@RequirePermissions('settings.manage')
|
||||||
|
update(
|
||||||
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
@Body() dto: UpdateDispositionDto,
|
||||||
|
@CurrentUser() actor: AuthenticatedUser,
|
||||||
|
@Req() request: FastifyRequest,
|
||||||
|
) {
|
||||||
|
return this.dispositionsService.update(id, dto, actor, {
|
||||||
|
ip: request.ip,
|
||||||
|
userAgent: request.headers['user-agent'],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete(':id')
|
||||||
|
@RequirePermissions('settings.manage')
|
||||||
|
remove(
|
||||||
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
@CurrentUser() actor: AuthenticatedUser,
|
||||||
|
@Req() request: FastifyRequest,
|
||||||
|
) {
|
||||||
|
return this.dispositionsService.delete(id, actor, {
|
||||||
|
ip: request.ip,
|
||||||
|
userAgent: request.headers['user-agent'],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
9
apps/api/src/dispositions/dispositions.module.ts
Normal file
9
apps/api/src/dispositions/dispositions.module.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { DispositionsController } from './dispositions.controller';
|
||||||
|
import { DispositionsService } from './dispositions.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
controllers: [DispositionsController],
|
||||||
|
providers: [DispositionsService],
|
||||||
|
})
|
||||||
|
export class DispositionsModule {}
|
||||||
101
apps/api/src/dispositions/dispositions.service.ts
Normal file
101
apps/api/src/dispositions/dispositions.service.ts
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
import {
|
||||||
|
BadRequestException,
|
||||||
|
Injectable,
|
||||||
|
NotFoundException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import { AuditService } from '../audit/audit.service';
|
||||||
|
import type { RequestContext } from '../auth/auth.service';
|
||||||
|
import { CreateDispositionDto } from './dto/create-disposition.dto';
|
||||||
|
import { UpdateDispositionDto } from './dto/update-disposition.dto';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class DispositionsService {
|
||||||
|
constructor(
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
private readonly audit: AuditService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
list() {
|
||||||
|
return this.prisma.callDisposition.findMany({ orderBy: { name: 'asc' } });
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(
|
||||||
|
dto: CreateDispositionDto,
|
||||||
|
actor: { id: string },
|
||||||
|
ctx: RequestContext,
|
||||||
|
) {
|
||||||
|
const existing = await this.prisma.callDisposition.findUnique({
|
||||||
|
where: { code: dto.code },
|
||||||
|
});
|
||||||
|
if (existing)
|
||||||
|
throw new BadRequestException(
|
||||||
|
'Já existe uma disposição com este código.',
|
||||||
|
);
|
||||||
|
|
||||||
|
const disposition = await this.prisma.callDisposition.create({ data: dto });
|
||||||
|
await this.audit.log({
|
||||||
|
userId: actor.id,
|
||||||
|
action: 'disposition_created',
|
||||||
|
entityType: 'call_disposition',
|
||||||
|
entityId: disposition.id,
|
||||||
|
after: { ...dto },
|
||||||
|
ipAddress: ctx.ip,
|
||||||
|
userAgent: ctx.userAgent,
|
||||||
|
});
|
||||||
|
return disposition;
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(
|
||||||
|
id: string,
|
||||||
|
dto: UpdateDispositionDto,
|
||||||
|
actor: { id: string },
|
||||||
|
ctx: RequestContext,
|
||||||
|
) {
|
||||||
|
const before = await this.prisma.callDisposition.findUnique({
|
||||||
|
where: { id },
|
||||||
|
});
|
||||||
|
if (!before) throw new NotFoundException('Disposição não encontrada.');
|
||||||
|
|
||||||
|
const disposition = await this.prisma.callDisposition.update({
|
||||||
|
where: { id },
|
||||||
|
data: dto,
|
||||||
|
});
|
||||||
|
await this.audit.log({
|
||||||
|
userId: actor.id,
|
||||||
|
action: 'disposition_updated',
|
||||||
|
entityType: 'call_disposition',
|
||||||
|
entityId: id,
|
||||||
|
before,
|
||||||
|
after: { ...dto },
|
||||||
|
ipAddress: ctx.ip,
|
||||||
|
userAgent: ctx.userAgent,
|
||||||
|
});
|
||||||
|
return disposition;
|
||||||
|
}
|
||||||
|
|
||||||
|
async delete(id: string, actor: { id: string }, ctx: RequestContext) {
|
||||||
|
const disposition = await this.prisma.callDisposition.findUnique({
|
||||||
|
where: { id },
|
||||||
|
});
|
||||||
|
if (!disposition) throw new NotFoundException('Disposição não encontrada.');
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.prisma.callDisposition.delete({ where: { id } });
|
||||||
|
} catch {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'Esta disposição já foi usada em chamadas — desative-a em vez de excluir.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.audit.log({
|
||||||
|
userId: actor.id,
|
||||||
|
action: 'disposition_deleted',
|
||||||
|
entityType: 'call_disposition',
|
||||||
|
entityId: id,
|
||||||
|
before: disposition,
|
||||||
|
ipAddress: ctx.ip,
|
||||||
|
userAgent: ctx.userAgent,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
31
apps/api/src/dispositions/dto/create-disposition.dto.ts
Normal file
31
apps/api/src/dispositions/dto/create-disposition.dto.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import {
|
||||||
|
IsBoolean,
|
||||||
|
IsEnum,
|
||||||
|
IsOptional,
|
||||||
|
IsString,
|
||||||
|
Matches,
|
||||||
|
MinLength,
|
||||||
|
} from 'class-validator';
|
||||||
|
import { DispositionAction } from '@b2bcall/database';
|
||||||
|
|
||||||
|
export class CreateDispositionDto {
|
||||||
|
@IsString()
|
||||||
|
@MinLength(1)
|
||||||
|
name!: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@Matches(/^[a-zA-Z0-9_-]{1,40}$/)
|
||||||
|
code!: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
description?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsEnum(DispositionAction)
|
||||||
|
action?: DispositionAction;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
active?: boolean;
|
||||||
|
}
|
||||||
6
apps/api/src/dispositions/dto/update-disposition.dto.ts
Normal file
6
apps/api/src/dispositions/dto/update-disposition.dto.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import { PartialType, OmitType } from '@nestjs/mapped-types';
|
||||||
|
import { CreateDispositionDto } from './create-disposition.dto';
|
||||||
|
|
||||||
|
export class UpdateDispositionDto extends PartialType(
|
||||||
|
OmitType(CreateDispositionDto, ['code'] as const),
|
||||||
|
) {}
|
||||||
26
apps/api/src/leads/dto/query-leads.dto.ts
Normal file
26
apps/api/src/leads/dto/query-leads.dto.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import { Type } from 'class-transformer';
|
||||||
|
import { IsEnum, IsInt, IsOptional, IsString, Max, Min } from 'class-validator';
|
||||||
|
import { LeadStatus } from '@b2bcall/database';
|
||||||
|
|
||||||
|
export class QueryLeadsDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsEnum(LeadStatus)
|
||||||
|
status?: LeadStatus;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
search?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
page: number = 1;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
@Max(500)
|
||||||
|
pageSize: number = 50;
|
||||||
|
}
|
||||||
77
apps/api/src/leads/leads.controller.ts
Normal file
77
apps/api/src/leads/leads.controller.ts
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
import {
|
||||||
|
BadRequestException,
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
Header,
|
||||||
|
Param,
|
||||||
|
ParseUUIDPipe,
|
||||||
|
Post,
|
||||||
|
Query,
|
||||||
|
Req,
|
||||||
|
Res,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import type { FastifyReply, FastifyRequest } from 'fastify';
|
||||||
|
import { RequirePermissions } from '../common/decorators/permissions.decorator';
|
||||||
|
import { CurrentUser } from '../common/decorators/current-user.decorator';
|
||||||
|
import type { AuthenticatedUser } from '../common/guards/auth.guard';
|
||||||
|
import { LeadsService } from './leads.service';
|
||||||
|
import { QueryLeadsDto } from './dto/query-leads.dto';
|
||||||
|
|
||||||
|
@Controller('campaigns/:campaignId/leads')
|
||||||
|
export class LeadsController {
|
||||||
|
constructor(private readonly leadsService: LeadsService) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
@RequirePermissions('campaigns.view')
|
||||||
|
query(
|
||||||
|
@Param('campaignId', ParseUUIDPipe) campaignId: string,
|
||||||
|
@Query() query: QueryLeadsDto,
|
||||||
|
) {
|
||||||
|
return this.leadsService.query(campaignId, query);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('imports')
|
||||||
|
@RequirePermissions('campaigns.view')
|
||||||
|
listImports(@Param('campaignId', ParseUUIDPipe) campaignId: string) {
|
||||||
|
return this.leadsService.listImports(campaignId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('imports/:importId/rejected.csv')
|
||||||
|
@RequirePermissions('campaigns.view')
|
||||||
|
@Header('Content-Type', 'text/csv; charset=utf-8')
|
||||||
|
async downloadRejected(
|
||||||
|
@Param('importId', ParseUUIDPipe) importId: string,
|
||||||
|
@Res({ passthrough: true }) reply: FastifyReply,
|
||||||
|
) {
|
||||||
|
const csv = await this.leadsService.getRejectedCsv(importId);
|
||||||
|
reply.header(
|
||||||
|
'Content-Disposition',
|
||||||
|
`attachment; filename="rejeitados-${importId}.csv"`,
|
||||||
|
);
|
||||||
|
return csv;
|
||||||
|
}
|
||||||
|
|
||||||
|
// dryRun=true roda a mesma validação/normalização/dedupe sem persistir
|
||||||
|
// nada — usado pelo frontend como "preview" antes de confirmar a
|
||||||
|
// importação (agente.md seção 27).
|
||||||
|
@Post('import')
|
||||||
|
@RequirePermissions('campaigns.update')
|
||||||
|
async importCsv(
|
||||||
|
@Param('campaignId', ParseUUIDPipe) campaignId: string,
|
||||||
|
@Query('dryRun') dryRun: string | undefined,
|
||||||
|
@Req() request: FastifyRequest,
|
||||||
|
@CurrentUser() actor: AuthenticatedUser,
|
||||||
|
) {
|
||||||
|
const file = await request.file();
|
||||||
|
if (!file) throw new BadRequestException('Nenhum arquivo enviado.');
|
||||||
|
|
||||||
|
return this.leadsService.importCsv(
|
||||||
|
campaignId,
|
||||||
|
file.filename,
|
||||||
|
file.file,
|
||||||
|
dryRun === 'true',
|
||||||
|
actor,
|
||||||
|
{ ip: request.ip, userAgent: request.headers['user-agent'] },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
9
apps/api/src/leads/leads.module.ts
Normal file
9
apps/api/src/leads/leads.module.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { LeadsController } from './leads.controller';
|
||||||
|
import { LeadsService } from './leads.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
controllers: [LeadsController],
|
||||||
|
providers: [LeadsService],
|
||||||
|
})
|
||||||
|
export class LeadsModule {}
|
||||||
267
apps/api/src/leads/leads.service.ts
Normal file
267
apps/api/src/leads/leads.service.ts
Normal file
@@ -0,0 +1,267 @@
|
|||||||
|
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||||
|
import type { Readable } from 'node:stream';
|
||||||
|
import { parse } from 'csv-parse';
|
||||||
|
import { stringify } from 'csv-stringify/sync';
|
||||||
|
import { normalizePhone } from '@b2bcall/shared';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import { AuditService } from '../audit/audit.service';
|
||||||
|
import type { RequestContext } from '../auth/auth.service';
|
||||||
|
import { QueryLeadsDto } from './dto/query-leads.dto';
|
||||||
|
|
||||||
|
const BATCH_SIZE = 500;
|
||||||
|
const PHONE_HEADERS = [
|
||||||
|
'telefone',
|
||||||
|
'phone',
|
||||||
|
'celular',
|
||||||
|
'numero',
|
||||||
|
'número',
|
||||||
|
'fone',
|
||||||
|
'tel',
|
||||||
|
];
|
||||||
|
const NAME_HEADERS = ['nome', 'name'];
|
||||||
|
|
||||||
|
interface RejectedRow {
|
||||||
|
original: Record<string, string>;
|
||||||
|
reason: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ImportSummary {
|
||||||
|
total: number;
|
||||||
|
valid: number;
|
||||||
|
invalid: number;
|
||||||
|
duplicate: number;
|
||||||
|
rejectedCsv: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickField(
|
||||||
|
row: Record<string, string>,
|
||||||
|
candidates: string[],
|
||||||
|
): string | undefined {
|
||||||
|
const keys = Object.keys(row);
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
const key = keys.find((k) => k.trim().toLowerCase() === candidate);
|
||||||
|
if (key && row[key]?.trim()) return row[key].trim();
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class LeadsService {
|
||||||
|
constructor(
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
private readonly audit: AuditService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async query(campaignId: string, query: QueryLeadsDto) {
|
||||||
|
const where = {
|
||||||
|
campaignId,
|
||||||
|
status: query.status,
|
||||||
|
...(query.search
|
||||||
|
? {
|
||||||
|
OR: [
|
||||||
|
{
|
||||||
|
name: { contains: query.search, mode: 'insensitive' as const },
|
||||||
|
},
|
||||||
|
{ phoneNormalized: { contains: query.search } },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
};
|
||||||
|
|
||||||
|
const [total, items] = await this.prisma.$transaction([
|
||||||
|
this.prisma.lead.count({ where }),
|
||||||
|
this.prisma.lead.findMany({
|
||||||
|
where,
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
skip: (query.page - 1) * query.pageSize,
|
||||||
|
take: query.pageSize,
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
return { items, total, page: query.page, pageSize: query.pageSize };
|
||||||
|
}
|
||||||
|
|
||||||
|
listImports(campaignId: string) {
|
||||||
|
return this.prisma.leadImport.findMany({
|
||||||
|
where: { campaignId },
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async getRejectedCsv(importId: string): Promise<string> {
|
||||||
|
const record = await this.prisma.leadImport.findUnique({
|
||||||
|
where: { id: importId },
|
||||||
|
});
|
||||||
|
if (!record) throw new NotFoundException('Importação não encontrada.');
|
||||||
|
return record.rejectedCsv ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Streaming: nunca materializa o CSV inteiro na memória (agente.md seção
|
||||||
|
// 27) — lê e processa em lotes de BATCH_SIZE linhas.
|
||||||
|
async importCsv(
|
||||||
|
campaignId: string,
|
||||||
|
filename: string,
|
||||||
|
fileStream: Readable,
|
||||||
|
dryRun: boolean,
|
||||||
|
actor: { id: string },
|
||||||
|
ctx: RequestContext,
|
||||||
|
): Promise<ImportSummary> {
|
||||||
|
const campaign = await this.prisma.campaign.findUnique({
|
||||||
|
where: { id: campaignId },
|
||||||
|
});
|
||||||
|
if (!campaign) throw new NotFoundException('Campanha não encontrada.');
|
||||||
|
|
||||||
|
// Criado antes de processar as linhas para que cada lead já nasça
|
||||||
|
// vinculado ao import correto mesmo se outra importação rodar em
|
||||||
|
// paralelo na mesma campanha (evita corrida em um backfill posterior).
|
||||||
|
const importRecord = dryRun
|
||||||
|
? null
|
||||||
|
: await this.prisma.leadImport.create({
|
||||||
|
data: {
|
||||||
|
campaignId,
|
||||||
|
filename,
|
||||||
|
status: 'PROCESSING',
|
||||||
|
createdById: actor.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const parser = fileStream.pipe(
|
||||||
|
parse({
|
||||||
|
columns: true,
|
||||||
|
delimiter: [',', ';', '\t'],
|
||||||
|
skip_empty_lines: true,
|
||||||
|
trim: true,
|
||||||
|
bom: true,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
let total = 0;
|
||||||
|
let valid = 0;
|
||||||
|
let invalid = 0;
|
||||||
|
let duplicate = 0;
|
||||||
|
const rejected: RejectedRow[] = [];
|
||||||
|
const seenInFile = new Set<string>();
|
||||||
|
let batch: {
|
||||||
|
name: string | null;
|
||||||
|
phone: string;
|
||||||
|
phoneNormalized: string;
|
||||||
|
}[] = [];
|
||||||
|
|
||||||
|
const flushBatch = async () => {
|
||||||
|
if (batch.length === 0) return;
|
||||||
|
|
||||||
|
const phones = batch.map((b) => b.phoneNormalized);
|
||||||
|
const existing = dryRun
|
||||||
|
? []
|
||||||
|
: await this.prisma.lead.findMany({
|
||||||
|
where: { campaignId, phoneNormalized: { in: phones } },
|
||||||
|
select: { phoneNormalized: true },
|
||||||
|
});
|
||||||
|
const existingSet = new Set(existing.map((e) => e.phoneNormalized));
|
||||||
|
|
||||||
|
const toInsert: typeof batch = [];
|
||||||
|
for (const item of batch) {
|
||||||
|
if (existingSet.has(item.phoneNormalized)) {
|
||||||
|
duplicate++;
|
||||||
|
rejected.push({
|
||||||
|
original: { nome: item.name ?? '', telefone: item.phone },
|
||||||
|
reason: 'Duplicado (já existe na campanha)',
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
valid++;
|
||||||
|
toInsert.push(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!dryRun && toInsert.length > 0) {
|
||||||
|
await this.prisma.lead.createMany({
|
||||||
|
data: toInsert.map((item) => ({
|
||||||
|
campaignId,
|
||||||
|
importId: importRecord!.id,
|
||||||
|
name: item.name,
|
||||||
|
phone: item.phone,
|
||||||
|
phoneNormalized: item.phoneNormalized,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
batch = [];
|
||||||
|
};
|
||||||
|
|
||||||
|
for await (const row of parser as AsyncIterable<Record<string, string>>) {
|
||||||
|
total++;
|
||||||
|
const name = pickField(row, NAME_HEADERS) ?? null;
|
||||||
|
const phoneRaw = pickField(row, PHONE_HEADERS);
|
||||||
|
|
||||||
|
if (!phoneRaw) {
|
||||||
|
invalid++;
|
||||||
|
rejected.push({
|
||||||
|
original: row,
|
||||||
|
reason: 'Coluna de telefone não encontrada ou vazia',
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalized = normalizePhone(phoneRaw);
|
||||||
|
if (!normalized.valid || !normalized.normalized) {
|
||||||
|
invalid++;
|
||||||
|
rejected.push({
|
||||||
|
original: row,
|
||||||
|
reason: normalized.reason ?? 'Telefone inválido',
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (seenInFile.has(normalized.normalized)) {
|
||||||
|
duplicate++;
|
||||||
|
rejected.push({
|
||||||
|
original: row,
|
||||||
|
reason: 'Duplicado (repetido no arquivo)',
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
seenInFile.add(normalized.normalized);
|
||||||
|
|
||||||
|
batch.push({
|
||||||
|
name,
|
||||||
|
phone: phoneRaw,
|
||||||
|
phoneNormalized: normalized.normalized,
|
||||||
|
});
|
||||||
|
if (batch.length >= BATCH_SIZE) await flushBatch();
|
||||||
|
}
|
||||||
|
await flushBatch();
|
||||||
|
|
||||||
|
const rejectedCsv =
|
||||||
|
rejected.length > 0
|
||||||
|
? stringify(
|
||||||
|
rejected.map((r) => ({ ...r.original, motivo_rejeicao: r.reason })),
|
||||||
|
{ header: true },
|
||||||
|
)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if (!dryRun && importRecord) {
|
||||||
|
await this.prisma.leadImport.update({
|
||||||
|
where: { id: importRecord.id },
|
||||||
|
data: {
|
||||||
|
status: 'COMPLETED',
|
||||||
|
totalRows: total,
|
||||||
|
validRows: valid,
|
||||||
|
invalidRows: invalid,
|
||||||
|
duplicateRows: duplicate,
|
||||||
|
rejectedCsv,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.audit.log({
|
||||||
|
userId: actor.id,
|
||||||
|
action: 'leads_imported',
|
||||||
|
entityType: 'campaign',
|
||||||
|
entityId: campaignId,
|
||||||
|
after: { total, valid, invalid, duplicate, importId: importRecord.id },
|
||||||
|
ipAddress: ctx.ip,
|
||||||
|
userAgent: ctx.userAgent,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return { total, valid, invalid, duplicate, rejectedCsv };
|
||||||
|
}
|
||||||
|
}
|
||||||
45
apps/api/src/leads/phone-normalization.spec.ts
Normal file
45
apps/api/src/leads/phone-normalization.spec.ts
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import { normalizeBrazilianPhone } from '@b2bcall/shared';
|
||||||
|
|
||||||
|
describe('normalizeBrazilianPhone', () => {
|
||||||
|
it('normaliza celular de 11 dígitos sem DDI', () => {
|
||||||
|
expect(normalizeBrazilianPhone('11987654321')).toEqual({
|
||||||
|
original: '11987654321',
|
||||||
|
normalized: '+5511987654321',
|
||||||
|
valid: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('normaliza número com DDI 55 e formatação', () => {
|
||||||
|
expect(normalizeBrazilianPhone('+55 (11) 98765-4321').normalized).toBe(
|
||||||
|
'+5511987654321',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('normaliza fixo de 10 dígitos', () => {
|
||||||
|
expect(normalizeBrazilianPhone('1132654321').normalized).toBe(
|
||||||
|
'+551132654321',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('remove zero de discagem interurbana', () => {
|
||||||
|
expect(normalizeBrazilianPhone('0 11 987654321').normalized).toBe(
|
||||||
|
'+5511987654321',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejeita DDD inválido', () => {
|
||||||
|
const result = normalizeBrazilianPhone('0587654321');
|
||||||
|
expect(result.valid).toBe(false);
|
||||||
|
expect(result.reason).toContain('DDD inválido');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejeita celular de 11 dígitos que não começa com 9', () => {
|
||||||
|
const result = normalizeBrazilianPhone('11887654321');
|
||||||
|
expect(result.valid).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejeita quantidade de dígitos inválida', () => {
|
||||||
|
expect(normalizeBrazilianPhone('123').valid).toBe(false);
|
||||||
|
expect(normalizeBrazilianPhone('').valid).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -10,6 +10,7 @@ import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
|
|||||||
import { Logger } from 'nestjs-pino';
|
import { Logger } from 'nestjs-pino';
|
||||||
import fastifyCookie from '@fastify/cookie';
|
import fastifyCookie from '@fastify/cookie';
|
||||||
import fastifyHelmet from '@fastify/helmet';
|
import fastifyHelmet from '@fastify/helmet';
|
||||||
|
import fastifyMultipart from '@fastify/multipart';
|
||||||
import { AppModule } from './app.module';
|
import { AppModule } from './app.module';
|
||||||
|
|
||||||
async function bootstrap() {
|
async function bootstrap() {
|
||||||
@@ -24,6 +25,11 @@ async function bootstrap() {
|
|||||||
const config = app.get(ConfigService);
|
const config = app.get(ConfigService);
|
||||||
|
|
||||||
await app.register(fastifyCookie);
|
await app.register(fastifyCookie);
|
||||||
|
// Streaming multipart para importação de CSV (leads, supressão) — nunca
|
||||||
|
// carrega o arquivo inteiro na memória (agente.md seção 27).
|
||||||
|
await app.register(fastifyMultipart, {
|
||||||
|
limits: { fileSize: 50 * 1024 * 1024 },
|
||||||
|
});
|
||||||
await app.register(fastifyHelmet, {
|
await app.register(fastifyHelmet, {
|
||||||
// Swagger UI (quando habilitado) precisa de scripts/estilos inline.
|
// Swagger UI (quando habilitado) precisa de scripts/estilos inline.
|
||||||
contentSecurityPolicy:
|
contentSecurityPolicy:
|
||||||
|
|||||||
11
apps/api/src/suppression/dto/add-suppression-entry.dto.ts
Normal file
11
apps/api/src/suppression/dto/add-suppression-entry.dto.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import { IsOptional, IsString, MinLength } from 'class-validator';
|
||||||
|
|
||||||
|
export class AddSuppressionEntryDto {
|
||||||
|
@IsString()
|
||||||
|
@MinLength(8)
|
||||||
|
phone!: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
reason?: string;
|
||||||
|
}
|
||||||
21
apps/api/src/suppression/dto/query-suppression.dto.ts
Normal file
21
apps/api/src/suppression/dto/query-suppression.dto.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import { Type } from 'class-transformer';
|
||||||
|
import { IsInt, IsOptional, IsString, Max, Min } from 'class-validator';
|
||||||
|
|
||||||
|
export class QuerySuppressionDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
search?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
page: number = 1;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
@Max(200)
|
||||||
|
pageSize: number = 50;
|
||||||
|
}
|
||||||
84
apps/api/src/suppression/suppression.controller.ts
Normal file
84
apps/api/src/suppression/suppression.controller.ts
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
import {
|
||||||
|
BadRequestException,
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Delete,
|
||||||
|
Get,
|
||||||
|
Param,
|
||||||
|
ParseUUIDPipe,
|
||||||
|
Post,
|
||||||
|
Query,
|
||||||
|
Req,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { parse } from 'csv-parse';
|
||||||
|
import type { FastifyRequest } from 'fastify';
|
||||||
|
import { RequirePermissions } from '../common/decorators/permissions.decorator';
|
||||||
|
import { CurrentUser } from '../common/decorators/current-user.decorator';
|
||||||
|
import type { AuthenticatedUser } from '../common/guards/auth.guard';
|
||||||
|
import { SuppressionService } from './suppression.service';
|
||||||
|
import { AddSuppressionEntryDto } from './dto/add-suppression-entry.dto';
|
||||||
|
import { QuerySuppressionDto } from './dto/query-suppression.dto';
|
||||||
|
|
||||||
|
@Controller('suppression')
|
||||||
|
export class SuppressionController {
|
||||||
|
constructor(private readonly suppressionService: SuppressionService) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
@RequirePermissions('campaigns.view')
|
||||||
|
query(@Query() query: QuerySuppressionDto) {
|
||||||
|
return this.suppressionService.query(query);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
@RequirePermissions('campaigns.update')
|
||||||
|
add(
|
||||||
|
@Body() dto: AddSuppressionEntryDto,
|
||||||
|
@CurrentUser() actor: AuthenticatedUser,
|
||||||
|
@Req() request: FastifyRequest,
|
||||||
|
) {
|
||||||
|
return this.suppressionService.add(dto, actor, {
|
||||||
|
ip: request.ip,
|
||||||
|
userAgent: request.headers['user-agent'],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Streaming: nunca materializa o CSV inteiro em uma string antes de
|
||||||
|
// processar — parseia linha a linha à medida que os bytes chegam.
|
||||||
|
@Post('import')
|
||||||
|
@RequirePermissions('campaigns.update')
|
||||||
|
async importCsv(
|
||||||
|
@Req() request: FastifyRequest,
|
||||||
|
@CurrentUser() actor: AuthenticatedUser,
|
||||||
|
) {
|
||||||
|
const file = await request.file();
|
||||||
|
if (!file) throw new BadRequestException('Nenhum arquivo enviado.');
|
||||||
|
|
||||||
|
const phones: string[] = [];
|
||||||
|
const parser = file.file.pipe(
|
||||||
|
parse({ columns: false, skip_empty_lines: true, trim: true }),
|
||||||
|
);
|
||||||
|
for await (const row of parser as AsyncIterable<string[]>) {
|
||||||
|
const phone = Array.isArray(row) ? row[0] : undefined;
|
||||||
|
if (phone) phones.push(phone);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.suppressionService.importCsv(phones, actor, {
|
||||||
|
ip: request.ip,
|
||||||
|
userAgent: request.headers['user-agent'],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete(':id')
|
||||||
|
@RequirePermissions('campaigns.update')
|
||||||
|
remove(
|
||||||
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
@Body('removalReason') removalReason: string,
|
||||||
|
@CurrentUser() actor: AuthenticatedUser,
|
||||||
|
@Req() request: FastifyRequest,
|
||||||
|
) {
|
||||||
|
return this.suppressionService.remove(id, removalReason, actor, {
|
||||||
|
ip: request.ip,
|
||||||
|
userAgent: request.headers['user-agent'],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
10
apps/api/src/suppression/suppression.module.ts
Normal file
10
apps/api/src/suppression/suppression.module.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { SuppressionController } from './suppression.controller';
|
||||||
|
import { SuppressionService } from './suppression.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
controllers: [SuppressionController],
|
||||||
|
providers: [SuppressionService],
|
||||||
|
exports: [SuppressionService],
|
||||||
|
})
|
||||||
|
export class SuppressionModule {}
|
||||||
148
apps/api/src/suppression/suppression.service.ts
Normal file
148
apps/api/src/suppression/suppression.service.ts
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
import {
|
||||||
|
BadRequestException,
|
||||||
|
ForbiddenException,
|
||||||
|
Injectable,
|
||||||
|
NotFoundException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { normalizePhone } from '@b2bcall/shared';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import { AuditService } from '../audit/audit.service';
|
||||||
|
import type { RequestContext } from '../auth/auth.service';
|
||||||
|
import { AddSuppressionEntryDto } from './dto/add-suppression-entry.dto';
|
||||||
|
import { QuerySuppressionDto } from './dto/query-suppression.dto';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class SuppressionService {
|
||||||
|
constructor(
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
private readonly audit: AuditService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async query(query: QuerySuppressionDto) {
|
||||||
|
const where = query.search
|
||||||
|
? { phoneNormalized: { contains: query.search } }
|
||||||
|
: {};
|
||||||
|
const [total, items] = await this.prisma.$transaction([
|
||||||
|
this.prisma.suppressionEntry.count({ where }),
|
||||||
|
this.prisma.suppressionEntry.findMany({
|
||||||
|
where,
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
skip: (query.page - 1) * query.pageSize,
|
||||||
|
take: query.pageSize,
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
return { items, total, page: query.page, pageSize: query.pageSize };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verificação obrigatória pré-originação (agente.md seção 29: "CHECK
|
||||||
|
// SUPPRESSION" antes de qualquer Originate). Usado pelo dialer-worker.
|
||||||
|
async isSuppressed(phoneNormalized: string): Promise<boolean> {
|
||||||
|
const entry = await this.prisma.suppressionEntry.findUnique({
|
||||||
|
where: { phoneNormalized },
|
||||||
|
});
|
||||||
|
return entry !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async add(
|
||||||
|
dto: AddSuppressionEntryDto,
|
||||||
|
actor: { id: string },
|
||||||
|
ctx: RequestContext,
|
||||||
|
) {
|
||||||
|
const normalized = normalizePhone(dto.phone);
|
||||||
|
if (!normalized.valid || !normalized.normalized) {
|
||||||
|
throw new BadRequestException(`Telefone inválido: ${normalized.reason}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const entry = await this.prisma.suppressionEntry.upsert({
|
||||||
|
where: { phoneNormalized: normalized.normalized },
|
||||||
|
create: {
|
||||||
|
phoneNormalized: normalized.normalized,
|
||||||
|
reason: dto.reason,
|
||||||
|
addedById: actor.id,
|
||||||
|
},
|
||||||
|
update: { reason: dto.reason },
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.audit.log({
|
||||||
|
userId: actor.id,
|
||||||
|
action: 'suppression_added',
|
||||||
|
entityType: 'suppression_entry',
|
||||||
|
entityId: entry.id,
|
||||||
|
after: { phone: normalized.normalized, reason: dto.reason },
|
||||||
|
ipAddress: ctx.ip,
|
||||||
|
userAgent: ctx.userAgent,
|
||||||
|
});
|
||||||
|
return entry;
|
||||||
|
}
|
||||||
|
|
||||||
|
async importCsv(
|
||||||
|
phones: string[],
|
||||||
|
actor: { id: string },
|
||||||
|
ctx: RequestContext,
|
||||||
|
) {
|
||||||
|
let added = 0;
|
||||||
|
let invalid = 0;
|
||||||
|
for (const raw of phones) {
|
||||||
|
const normalized = normalizePhone(raw);
|
||||||
|
if (!normalized.valid || !normalized.normalized) {
|
||||||
|
invalid++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
await this.prisma.suppressionEntry.upsert({
|
||||||
|
where: { phoneNormalized: normalized.normalized },
|
||||||
|
create: {
|
||||||
|
phoneNormalized: normalized.normalized,
|
||||||
|
reason: 'Importação CSV',
|
||||||
|
addedById: actor.id,
|
||||||
|
},
|
||||||
|
update: {},
|
||||||
|
});
|
||||||
|
added++;
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.audit.log({
|
||||||
|
userId: actor.id,
|
||||||
|
action: 'suppression_imported',
|
||||||
|
entityType: 'suppression_entry',
|
||||||
|
after: { added, invalid, total: phones.length },
|
||||||
|
ipAddress: ctx.ip,
|
||||||
|
userAgent: ctx.userAgent,
|
||||||
|
});
|
||||||
|
|
||||||
|
return { added, invalid, total: phones.length };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remoção exige justificativa e é sempre auditada — nunca silenciosa
|
||||||
|
// (agente.md seção 29: "remover com permissão; informar motivo; auditoria").
|
||||||
|
async remove(
|
||||||
|
id: string,
|
||||||
|
removalReason: string,
|
||||||
|
actor: { id: string },
|
||||||
|
ctx: RequestContext,
|
||||||
|
) {
|
||||||
|
if (!removalReason || removalReason.trim().length === 0) {
|
||||||
|
throw new ForbiddenException(
|
||||||
|
'É obrigatório informar o motivo da remoção.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const entry = await this.prisma.suppressionEntry.findUnique({
|
||||||
|
where: { id },
|
||||||
|
});
|
||||||
|
if (!entry)
|
||||||
|
throw new NotFoundException(
|
||||||
|
'Entrada não encontrada na lista de bloqueio.',
|
||||||
|
);
|
||||||
|
|
||||||
|
await this.prisma.suppressionEntry.delete({ where: { id } });
|
||||||
|
await this.audit.log({
|
||||||
|
userId: actor.id,
|
||||||
|
action: 'suppression_removed',
|
||||||
|
entityType: 'suppression_entry',
|
||||||
|
entityId: id,
|
||||||
|
before: entry,
|
||||||
|
after: { removalReason },
|
||||||
|
ipAddress: ctx.ip,
|
||||||
|
userAgent: ctx.userAgent,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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")
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
updatedAt DateTime @updatedAt @map("updated_at")
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
|
||||||
|
campaigns Campaign[]
|
||||||
|
|
||||||
@@map("trunks")
|
@@map("trunks")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -289,7 +291,8 @@ model Queue {
|
|||||||
createdAt DateTime @default(now()) @map("created_at")
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
updatedAt DateTime @updatedAt @map("updated_at")
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
|
||||||
members QueueMember[]
|
members QueueMember[]
|
||||||
|
campaigns Campaign[]
|
||||||
|
|
||||||
@@map("queues")
|
@@map("queues")
|
||||||
}
|
}
|
||||||
@@ -394,3 +397,241 @@ model AgentPauseEvent {
|
|||||||
@@index([agentId, endedAt])
|
@@index([agentId, endedAt])
|
||||||
@@map("agent_pause_events")
|
@@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 './permissions';
|
||||||
export * from './generate-password';
|
export * from './generate-password';
|
||||||
export * from './secret-crypto';
|
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);
|
||||||
|
}
|
||||||
40
pnpm-lock.yaml
generated
40
pnpm-lock.yaml
generated
@@ -25,6 +25,9 @@ importers:
|
|||||||
'@fastify/helmet':
|
'@fastify/helmet':
|
||||||
specifier: ^13.0.1
|
specifier: ^13.0.1
|
||||||
version: 13.1.1
|
version: 13.1.1
|
||||||
|
'@fastify/multipart':
|
||||||
|
specifier: ^9.0.3
|
||||||
|
version: 9.4.0
|
||||||
'@fastify/static':
|
'@fastify/static':
|
||||||
specifier: ^8.0.4
|
specifier: ^8.0.4
|
||||||
version: 8.3.0
|
version: 8.3.0
|
||||||
@@ -64,6 +67,12 @@ importers:
|
|||||||
class-validator:
|
class-validator:
|
||||||
specifier: ^0.14.2
|
specifier: ^0.14.2
|
||||||
version: 0.14.4
|
version: 0.14.4
|
||||||
|
csv-parse:
|
||||||
|
specifier: ^5.6.0
|
||||||
|
version: 5.6.0
|
||||||
|
csv-stringify:
|
||||||
|
specifier: ^6.5.2
|
||||||
|
version: 6.8.3
|
||||||
fastify:
|
fastify:
|
||||||
specifier: ^5.2.1
|
specifier: ^5.2.1
|
||||||
version: 5.11.3
|
version: 5.11.3
|
||||||
@@ -489,12 +498,18 @@ packages:
|
|||||||
'@fastify/ajv-compiler@4.0.6':
|
'@fastify/ajv-compiler@4.0.6':
|
||||||
resolution: {integrity: sha512-NtuzM0SfaMJbGlnjr9LWQUN5LzgSrbB8tf/wRZNas+4E1O/Nmzl53e7ruT61HDZyRCJGC6FxIogmNZO1c5ETBA==}
|
resolution: {integrity: sha512-NtuzM0SfaMJbGlnjr9LWQUN5LzgSrbB8tf/wRZNas+4E1O/Nmzl53e7ruT61HDZyRCJGC6FxIogmNZO1c5ETBA==}
|
||||||
|
|
||||||
|
'@fastify/busboy@3.2.2':
|
||||||
|
resolution: {integrity: sha512-yXSS27qPExaXeuLvMRMXOLtpipzfQYNjG3FkunDWKGfMYjKuhFXko9CVzqxm8jcF+lmtS9Fd89QNdh9XDjnbNg==}
|
||||||
|
|
||||||
'@fastify/cookie@11.1.2':
|
'@fastify/cookie@11.1.2':
|
||||||
resolution: {integrity: sha512-Dtrpk/YOGUsbRMvP/8ZqPpwnMRv0qSqodFdoQ2B589Obc7jw4s4Qla+cV72Bsm7WsZJnqlYFX/i7uSBq0xzg6g==}
|
resolution: {integrity: sha512-Dtrpk/YOGUsbRMvP/8ZqPpwnMRv0qSqodFdoQ2B589Obc7jw4s4Qla+cV72Bsm7WsZJnqlYFX/i7uSBq0xzg6g==}
|
||||||
|
|
||||||
'@fastify/cors@11.3.0':
|
'@fastify/cors@11.3.0':
|
||||||
resolution: {integrity: sha512-ggQGua+xHv1MvePbPr0v//xLYEsCXbWspquXCJS9Ot5YoRXq8J8ZWzHnxDBVnbtXosvistXo6LtNzOJswf64Fw==}
|
resolution: {integrity: sha512-ggQGua+xHv1MvePbPr0v//xLYEsCXbWspquXCJS9Ot5YoRXq8J8ZWzHnxDBVnbtXosvistXo6LtNzOJswf64Fw==}
|
||||||
|
|
||||||
|
'@fastify/deepmerge@3.2.1':
|
||||||
|
resolution: {integrity: sha512-N5Oqvltoa2r9z1tbx4xjky0oRR60v+T47Ic4J1ukoVQcptLOrIdRnCSdTGmOmajZuHVKlTnfcmrjyqsGEW1ztA==}
|
||||||
|
|
||||||
'@fastify/error@4.2.0':
|
'@fastify/error@4.2.0':
|
||||||
resolution: {integrity: sha512-RSo3sVDXfHskiBZKBPRgnQTtIqpi/7zhJOEmAxCiBcM7d0uwdGdxLlsCaLzGs8v8NnxIRlfG0N51p5yFaOentQ==}
|
resolution: {integrity: sha512-RSo3sVDXfHskiBZKBPRgnQTtIqpi/7zhJOEmAxCiBcM7d0uwdGdxLlsCaLzGs8v8NnxIRlfG0N51p5yFaOentQ==}
|
||||||
|
|
||||||
@@ -513,6 +528,9 @@ packages:
|
|||||||
'@fastify/merge-json-schemas@0.2.1':
|
'@fastify/merge-json-schemas@0.2.1':
|
||||||
resolution: {integrity: sha512-OA3KGBCy6KtIvLf8DINC5880o5iBlDX4SxzLQS8HorJAbqluzLRn80UXU0bxZn7UOFhFgpRJDasfwn9nG4FG4A==}
|
resolution: {integrity: sha512-OA3KGBCy6KtIvLf8DINC5880o5iBlDX4SxzLQS8HorJAbqluzLRn80UXU0bxZn7UOFhFgpRJDasfwn9nG4FG4A==}
|
||||||
|
|
||||||
|
'@fastify/multipart@9.4.0':
|
||||||
|
resolution: {integrity: sha512-Z404bzZeLSXTBmp/trCBuoVFX28pM7rhv849Q5TsbTFZHuk1lc4QjQITTPK92DKVpXmNtJXeHSSc7GYvqFpxAQ==}
|
||||||
|
|
||||||
'@fastify/proxy-addr@5.1.0':
|
'@fastify/proxy-addr@5.1.0':
|
||||||
resolution: {integrity: sha512-INS+6gh91cLUjB+PVHfu1UqcB76Sqtpyp7bnL+FYojhjygvOPA9ctiD/JDKsyD9Xgu4hUhCSJBPig/w7duNajw==}
|
resolution: {integrity: sha512-INS+6gh91cLUjB+PVHfu1UqcB76Sqtpyp7bnL+FYojhjygvOPA9ctiD/JDKsyD9Xgu4hUhCSJBPig/w7duNajw==}
|
||||||
|
|
||||||
@@ -1813,6 +1831,12 @@ packages:
|
|||||||
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
|
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
|
||||||
engines: {node: '>= 8'}
|
engines: {node: '>= 8'}
|
||||||
|
|
||||||
|
csv-parse@5.6.0:
|
||||||
|
resolution: {integrity: sha512-l3nz3euub2QMg5ouu5U09Ew9Wf6/wQ8I++ch1loQ0ljmzhmfZYrH9fflS22i/PQEvsPvxCwxgz5q7UB8K1JO4Q==}
|
||||||
|
|
||||||
|
csv-stringify@6.8.3:
|
||||||
|
resolution: {integrity: sha512-gIeSCvq5F4VtXV3naV3VAewLhBkiZBz+PPhTOA8H3Y8h/ELa+R1ml0GZck/4/Nzo9ep2lvOluilJ6MJlbZsKMA==}
|
||||||
|
|
||||||
debug@4.4.3:
|
debug@4.4.3:
|
||||||
resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==}
|
resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==}
|
||||||
engines: {node: '>=6.0'}
|
engines: {node: '>=6.0'}
|
||||||
@@ -3904,6 +3928,8 @@ snapshots:
|
|||||||
ajv-formats: 3.0.1(ajv@8.20.0)
|
ajv-formats: 3.0.1(ajv@8.20.0)
|
||||||
fast-uri: 4.1.3
|
fast-uri: 4.1.3
|
||||||
|
|
||||||
|
'@fastify/busboy@3.2.2': {}
|
||||||
|
|
||||||
'@fastify/cookie@11.1.2':
|
'@fastify/cookie@11.1.2':
|
||||||
dependencies:
|
dependencies:
|
||||||
cookie: 2.0.1
|
cookie: 2.0.1
|
||||||
@@ -3914,6 +3940,8 @@ snapshots:
|
|||||||
fastify-plugin: 6.0.0
|
fastify-plugin: 6.0.0
|
||||||
toad-cache: 3.7.4
|
toad-cache: 3.7.4
|
||||||
|
|
||||||
|
'@fastify/deepmerge@3.2.1': {}
|
||||||
|
|
||||||
'@fastify/error@4.2.0': {}
|
'@fastify/error@4.2.0': {}
|
||||||
|
|
||||||
'@fastify/fast-json-stringify-compiler@5.1.0':
|
'@fastify/fast-json-stringify-compiler@5.1.0':
|
||||||
@@ -3936,6 +3964,14 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
dequal: 2.0.3
|
dequal: 2.0.3
|
||||||
|
|
||||||
|
'@fastify/multipart@9.4.0':
|
||||||
|
dependencies:
|
||||||
|
'@fastify/busboy': 3.2.2
|
||||||
|
'@fastify/deepmerge': 3.2.1
|
||||||
|
'@fastify/error': 4.2.0
|
||||||
|
fastify-plugin: 5.1.0
|
||||||
|
secure-json-parse: 4.1.0
|
||||||
|
|
||||||
'@fastify/proxy-addr@5.1.0':
|
'@fastify/proxy-addr@5.1.0':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@fastify/forwarded': 3.0.2
|
'@fastify/forwarded': 3.0.2
|
||||||
@@ -5354,6 +5390,10 @@ snapshots:
|
|||||||
shebang-command: 2.0.0
|
shebang-command: 2.0.0
|
||||||
which: 2.0.2
|
which: 2.0.2
|
||||||
|
|
||||||
|
csv-parse@5.6.0: {}
|
||||||
|
|
||||||
|
csv-stringify@6.8.3: {}
|
||||||
|
|
||||||
debug@4.4.3(supports-color@8.1.1):
|
debug@4.4.3(supports-color@8.1.1):
|
||||||
dependencies:
|
dependencies:
|
||||||
ms: 2.1.3
|
ms: 2.1.3
|
||||||
|
|||||||
Reference in New Issue
Block a user