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:
@@ -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",
|
||||
|
||||
@@ -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: [
|
||||
|
||||
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 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:
|
||||
|
||||
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,
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user