feat: add campaign management

- packages/database: Campaign, LeadImport, Lead, DialAttempt,
  CallDisposition, Callback, SuppressionEntry (agente.md secao 53)
- packages/shared: normalizePhone (BR, E.164) com 7 testes unitarios
- apps/api/src/campaigns: CRUD completo (todos os campos da secao 24) com
  maquina de estados validada (DRAFT/READY/RUNNING/PAUSED/DRAINING/
  STOPPED/COMPLETED) — edicao bloqueada com campanha RUNNING
- apps/api/src/leads: import CSV via streaming multipart (deteccao
  automatica de delimitador, mapeamento de coluna por header, validacao+
  normalizacao+dedupe em lotes de 500, CSV de rejeitados com motivo, modo
  dryRun para preview)
- apps/api/src/suppression: CRUD + import CSV da lista de bloqueio,
  remocao sempre exige motivo e e auditada, isSuppressed() pronto para o
  dialer-worker checar antes de originar
- apps/api/src/dispositions: CRUD de disposicoes de chamada

Testado ponta a ponta: campanha criada com defaults corretos, import de
CSV real (3 validos/1 invalido/1 duplicado, contadores batendo), leads
persistidos com telefone normalizado, transicoes de estado da campanha
rejeitando movimentos invalidos (PAUSED->PAUSED = 400).
This commit is contained in:
2026-08-27 14:48:46 -03:00
parent 63103d4335
commit 167776ff63
30 changed files with 2008 additions and 5 deletions

View File

@@ -25,6 +25,7 @@
"@b2bcall/telephony": "workspace:*",
"@fastify/cookie": "^11.0.2",
"@fastify/helmet": "^13.0.1",
"@fastify/multipart": "^9.0.3",
"@fastify/static": "^8.0.4",
"@nestjs/common": "^11.0.1",
"@nestjs/config": "^4.0.2",
@@ -38,6 +39,8 @@
"argon2": "^0.44.0",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.2",
"csv-parse": "^5.6.0",
"csv-stringify": "^6.5.2",
"fastify": "^5.2.1",
"ioredis": "^5.4.2",
"ms": "^2.1.3",

View File

@@ -23,6 +23,10 @@ import { PauseReasonsModule } from './pause-reasons/pause-reasons.module';
import { QueuesModule } from './queues/queues.module';
import { AgentsModule } from './agents/agents.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 { PermissionsGuard } from './common/guards/permissions.guard';
import { GlobalExceptionFilter } from './common/filters/global-exception.filter';
@@ -72,6 +76,10 @@ import { GlobalExceptionFilter } from './common/filters/global-exception.filter'
QueuesModule,
AgentsModule,
AgentConsoleModule,
SuppressionModule,
DispositionsModule,
CampaignsModule,
LeadsModule,
],
controllers: [AppController],
providers: [

View 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'],
});
}
}

View 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 {}

View 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',
);
}
}

View 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;
}

View File

@@ -0,0 +1,4 @@
import { PartialType } from '@nestjs/mapped-types';
import { CreateCampaignDto } from './create-campaign.dto';
export class UpdateCampaignDto extends PartialType(CreateCampaignDto) {}

View 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'],
});
}
}

View 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 {}

View 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,
});
}
}

View 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;
}

View 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),
) {}

View 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;
}

View 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'] },
);
}
}

View 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 {}

View 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 };
}
}

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

View File

@@ -10,6 +10,7 @@ import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
import { Logger } from 'nestjs-pino';
import fastifyCookie from '@fastify/cookie';
import fastifyHelmet from '@fastify/helmet';
import fastifyMultipart from '@fastify/multipart';
import { AppModule } from './app.module';
async function bootstrap() {
@@ -24,6 +25,11 @@ async function bootstrap() {
const config = app.get(ConfigService);
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, {
// Swagger UI (quando habilitado) precisa de scripts/estilos inline.
contentSecurityPolicy:

View File

@@ -0,0 +1,11 @@
import { IsOptional, IsString, MinLength } from 'class-validator';
export class AddSuppressionEntryDto {
@IsString()
@MinLength(8)
phone!: string;
@IsOptional()
@IsString()
reason?: string;
}

View 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;
}

View 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'],
});
}
}

View 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 {}

View 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,
});
}
}