From 167776ff63f5d909526b3b07539c7de91e9cef9e Mon Sep 17 00:00:00 2001 From: B2BCall Bootstrap Date: Thu, 27 Aug 2026 14:48:46 -0300 Subject: [PATCH] feat: add campaign management MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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). --- TODO.md | 19 +- apps/api/package.json | 3 + apps/api/src/app.module.ts | 8 + .../api/src/campaigns/campaigns.controller.ts | 127 +++++++++ apps/api/src/campaigns/campaigns.module.ts | 10 + apps/api/src/campaigns/campaigns.service.ts | 239 ++++++++++++++++ .../src/campaigns/dto/create-campaign.dto.ts | 133 +++++++++ .../src/campaigns/dto/update-campaign.dto.ts | 4 + .../dispositions/dispositions.controller.ts | 68 +++++ .../src/dispositions/dispositions.module.ts | 9 + .../src/dispositions/dispositions.service.ts | 101 +++++++ .../dto/create-disposition.dto.ts | 31 ++ .../dto/update-disposition.dto.ts | 6 + apps/api/src/leads/dto/query-leads.dto.ts | 26 ++ apps/api/src/leads/leads.controller.ts | 77 +++++ apps/api/src/leads/leads.module.ts | 9 + apps/api/src/leads/leads.service.ts | 267 ++++++++++++++++++ .../api/src/leads/phone-normalization.spec.ts | 45 +++ apps/api/src/main.ts | 6 + .../dto/add-suppression-entry.dto.ts | 11 + .../suppression/dto/query-suppression.dto.ts | 21 ++ .../src/suppression/suppression.controller.ts | 84 ++++++ .../api/src/suppression/suppression.module.ts | 10 + .../src/suppression/suppression.service.ts | 148 ++++++++++ .../migration.sql | 206 ++++++++++++++ .../migration.sql | 5 + packages/database/prisma/schema.prisma | 243 +++++++++++++++- packages/shared/src/index.ts | 1 + packages/shared/src/phone-normalization.ts | 56 ++++ pnpm-lock.yaml | 40 +++ 30 files changed, 2008 insertions(+), 5 deletions(-) create mode 100644 apps/api/src/campaigns/campaigns.controller.ts create mode 100644 apps/api/src/campaigns/campaigns.module.ts create mode 100644 apps/api/src/campaigns/campaigns.service.ts create mode 100644 apps/api/src/campaigns/dto/create-campaign.dto.ts create mode 100644 apps/api/src/campaigns/dto/update-campaign.dto.ts create mode 100644 apps/api/src/dispositions/dispositions.controller.ts create mode 100644 apps/api/src/dispositions/dispositions.module.ts create mode 100644 apps/api/src/dispositions/dispositions.service.ts create mode 100644 apps/api/src/dispositions/dto/create-disposition.dto.ts create mode 100644 apps/api/src/dispositions/dto/update-disposition.dto.ts create mode 100644 apps/api/src/leads/dto/query-leads.dto.ts create mode 100644 apps/api/src/leads/leads.controller.ts create mode 100644 apps/api/src/leads/leads.module.ts create mode 100644 apps/api/src/leads/leads.service.ts create mode 100644 apps/api/src/leads/phone-normalization.spec.ts create mode 100644 apps/api/src/suppression/dto/add-suppression-entry.dto.ts create mode 100644 apps/api/src/suppression/dto/query-suppression.dto.ts create mode 100644 apps/api/src/suppression/suppression.controller.ts create mode 100644 apps/api/src/suppression/suppression.module.ts create mode 100644 apps/api/src/suppression/suppression.service.ts create mode 100644 packages/database/prisma/migrations/20260827172316_add_campaigns_predictive_dialer/migration.sql create mode 100644 packages/database/prisma/migrations/20260827173012_add_campaign_relations/migration.sql create mode 100644 packages/shared/src/phone-normalization.ts diff --git a/TODO.md b/TODO.md index 04138c4..c92c5ce 100644 --- a/TODO.md +++ b/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 ## Fase 6 — Campanhas e discador preditivo -- [ ] CRUD Campanhas (todos os campos da seção 24) -- [ ] Import CSV streaming (preview, mapeamento, validação, duplicados, rejeitados) -- [ ] Normalização de telefone (serviço dedicado, BR inicialmente) -- [ ] Lista de supressão (DNC) + checagem obrigatória pré-originação +- [x] CRUD Campanhas (todos os campos da seção 24) — testado: criação com + defaults corretos, máquina de estados (DRAFT/READY/RUNNING/PAUSED/ + DRAINING/STOPPED/COMPLETED) rejeitando transições inválidas (ex.: + 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) - [ ] Reserva concorrente de leads (FOR UPDATE SKIP LOCKED + timeout) - [ ] Idempotência de originação (attempt_id/call_id/uniqueid/linkedid, state machine) diff --git a/apps/api/package.json b/apps/api/package.json index 5ce5659..36780e3 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -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", diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index f3fb689..b406fc0 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -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: [ diff --git a/apps/api/src/campaigns/campaigns.controller.ts b/apps/api/src/campaigns/campaigns.controller.ts new file mode 100644 index 0000000..908fbd7 --- /dev/null +++ b/apps/api/src/campaigns/campaigns.controller.ts @@ -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'], + }); + } +} diff --git a/apps/api/src/campaigns/campaigns.module.ts b/apps/api/src/campaigns/campaigns.module.ts new file mode 100644 index 0000000..9828af5 --- /dev/null +++ b/apps/api/src/campaigns/campaigns.module.ts @@ -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 {} diff --git a/apps/api/src/campaigns/campaigns.service.ts b/apps/api/src/campaigns/campaigns.service.ts new file mode 100644 index 0000000..65f1c53 --- /dev/null +++ b/apps/api/src/campaigns/campaigns.service.ts @@ -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 = { + 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) { + 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', + ); + } +} diff --git a/apps/api/src/campaigns/dto/create-campaign.dto.ts b/apps/api/src/campaigns/dto/create-campaign.dto.ts new file mode 100644 index 0000000..23a6dd2 --- /dev/null +++ b/apps/api/src/campaigns/dto/create-campaign.dto.ts @@ -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; + + @IsOptional() + @IsBoolean() + amdEnabled?: boolean; + + @IsOptional() + @IsInt() + @Min(0) + wrapUpTimeSeconds?: number; +} diff --git a/apps/api/src/campaigns/dto/update-campaign.dto.ts b/apps/api/src/campaigns/dto/update-campaign.dto.ts new file mode 100644 index 0000000..42974e8 --- /dev/null +++ b/apps/api/src/campaigns/dto/update-campaign.dto.ts @@ -0,0 +1,4 @@ +import { PartialType } from '@nestjs/mapped-types'; +import { CreateCampaignDto } from './create-campaign.dto'; + +export class UpdateCampaignDto extends PartialType(CreateCampaignDto) {} diff --git a/apps/api/src/dispositions/dispositions.controller.ts b/apps/api/src/dispositions/dispositions.controller.ts new file mode 100644 index 0000000..3c0d84e --- /dev/null +++ b/apps/api/src/dispositions/dispositions.controller.ts @@ -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'], + }); + } +} diff --git a/apps/api/src/dispositions/dispositions.module.ts b/apps/api/src/dispositions/dispositions.module.ts new file mode 100644 index 0000000..3ca20a5 --- /dev/null +++ b/apps/api/src/dispositions/dispositions.module.ts @@ -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 {} diff --git a/apps/api/src/dispositions/dispositions.service.ts b/apps/api/src/dispositions/dispositions.service.ts new file mode 100644 index 0000000..5db7d7e --- /dev/null +++ b/apps/api/src/dispositions/dispositions.service.ts @@ -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, + }); + } +} diff --git a/apps/api/src/dispositions/dto/create-disposition.dto.ts b/apps/api/src/dispositions/dto/create-disposition.dto.ts new file mode 100644 index 0000000..c9b643a --- /dev/null +++ b/apps/api/src/dispositions/dto/create-disposition.dto.ts @@ -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; +} diff --git a/apps/api/src/dispositions/dto/update-disposition.dto.ts b/apps/api/src/dispositions/dto/update-disposition.dto.ts new file mode 100644 index 0000000..921ef15 --- /dev/null +++ b/apps/api/src/dispositions/dto/update-disposition.dto.ts @@ -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), +) {} diff --git a/apps/api/src/leads/dto/query-leads.dto.ts b/apps/api/src/leads/dto/query-leads.dto.ts new file mode 100644 index 0000000..24cc869 --- /dev/null +++ b/apps/api/src/leads/dto/query-leads.dto.ts @@ -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; +} diff --git a/apps/api/src/leads/leads.controller.ts b/apps/api/src/leads/leads.controller.ts new file mode 100644 index 0000000..eeb769d --- /dev/null +++ b/apps/api/src/leads/leads.controller.ts @@ -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'] }, + ); + } +} diff --git a/apps/api/src/leads/leads.module.ts b/apps/api/src/leads/leads.module.ts new file mode 100644 index 0000000..b2263e3 --- /dev/null +++ b/apps/api/src/leads/leads.module.ts @@ -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 {} diff --git a/apps/api/src/leads/leads.service.ts b/apps/api/src/leads/leads.service.ts new file mode 100644 index 0000000..564962c --- /dev/null +++ b/apps/api/src/leads/leads.service.ts @@ -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; + reason: string; +} + +export interface ImportSummary { + total: number; + valid: number; + invalid: number; + duplicate: number; + rejectedCsv: string | null; +} + +function pickField( + row: Record, + 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 { + 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 { + 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(); + 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>) { + 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 }; + } +} diff --git a/apps/api/src/leads/phone-normalization.spec.ts b/apps/api/src/leads/phone-normalization.spec.ts new file mode 100644 index 0000000..058d2a8 --- /dev/null +++ b/apps/api/src/leads/phone-normalization.spec.ts @@ -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); + }); +}); diff --git a/apps/api/src/main.ts b/apps/api/src/main.ts index 2230d94..670220d 100644 --- a/apps/api/src/main.ts +++ b/apps/api/src/main.ts @@ -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: diff --git a/apps/api/src/suppression/dto/add-suppression-entry.dto.ts b/apps/api/src/suppression/dto/add-suppression-entry.dto.ts new file mode 100644 index 0000000..83ad6e6 --- /dev/null +++ b/apps/api/src/suppression/dto/add-suppression-entry.dto.ts @@ -0,0 +1,11 @@ +import { IsOptional, IsString, MinLength } from 'class-validator'; + +export class AddSuppressionEntryDto { + @IsString() + @MinLength(8) + phone!: string; + + @IsOptional() + @IsString() + reason?: string; +} diff --git a/apps/api/src/suppression/dto/query-suppression.dto.ts b/apps/api/src/suppression/dto/query-suppression.dto.ts new file mode 100644 index 0000000..ad284b2 --- /dev/null +++ b/apps/api/src/suppression/dto/query-suppression.dto.ts @@ -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; +} diff --git a/apps/api/src/suppression/suppression.controller.ts b/apps/api/src/suppression/suppression.controller.ts new file mode 100644 index 0000000..c2a5e35 --- /dev/null +++ b/apps/api/src/suppression/suppression.controller.ts @@ -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) { + 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'], + }); + } +} diff --git a/apps/api/src/suppression/suppression.module.ts b/apps/api/src/suppression/suppression.module.ts new file mode 100644 index 0000000..34da5d8 --- /dev/null +++ b/apps/api/src/suppression/suppression.module.ts @@ -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 {} diff --git a/apps/api/src/suppression/suppression.service.ts b/apps/api/src/suppression/suppression.service.ts new file mode 100644 index 0000000..de56948 --- /dev/null +++ b/apps/api/src/suppression/suppression.service.ts @@ -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 { + 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, + }); + } +} diff --git a/packages/database/prisma/migrations/20260827172316_add_campaigns_predictive_dialer/migration.sql b/packages/database/prisma/migrations/20260827172316_add_campaigns_predictive_dialer/migration.sql new file mode 100644 index 0000000..59c5b38 --- /dev/null +++ b/packages/database/prisma/migrations/20260827172316_add_campaigns_predictive_dialer/migration.sql @@ -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; diff --git a/packages/database/prisma/migrations/20260827173012_add_campaign_relations/migration.sql b/packages/database/prisma/migrations/20260827173012_add_campaign_relations/migration.sql new file mode 100644 index 0000000..dc0b771 --- /dev/null +++ b/packages/database/prisma/migrations/20260827173012_add_campaign_relations/migration.sql @@ -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; diff --git a/packages/database/prisma/schema.prisma b/packages/database/prisma/schema.prisma index d02cde5..244f45e 100644 --- a/packages/database/prisma/schema.prisma +++ b/packages/database/prisma/schema.prisma @@ -180,6 +180,8 @@ model Trunk { createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") + campaigns Campaign[] + @@map("trunks") } @@ -289,7 +291,8 @@ model Queue { createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") - members QueueMember[] + members QueueMember[] + campaigns Campaign[] @@map("queues") } @@ -394,3 +397,241 @@ model AgentPauseEvent { @@index([agentId, endedAt]) @@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") +} diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 11e3df4..372e3b8 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -1,3 +1,4 @@ export * from './permissions'; export * from './generate-password'; export * from './secret-crypto'; +export * from './phone-normalization'; diff --git a/packages/shared/src/phone-normalization.ts b/packages/shared/src/phone-normalization.ts new file mode 100644 index 0000000..3a42bc2 --- /dev/null +++ b/packages/shared/src/phone-normalization.ts @@ -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); +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 956488d..2d20b5f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -25,6 +25,9 @@ importers: '@fastify/helmet': specifier: ^13.0.1 version: 13.1.1 + '@fastify/multipart': + specifier: ^9.0.3 + version: 9.4.0 '@fastify/static': specifier: ^8.0.4 version: 8.3.0 @@ -64,6 +67,12 @@ importers: class-validator: specifier: ^0.14.2 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: specifier: ^5.2.1 version: 5.11.3 @@ -489,12 +498,18 @@ packages: '@fastify/ajv-compiler@4.0.6': resolution: {integrity: sha512-NtuzM0SfaMJbGlnjr9LWQUN5LzgSrbB8tf/wRZNas+4E1O/Nmzl53e7ruT61HDZyRCJGC6FxIogmNZO1c5ETBA==} + '@fastify/busboy@3.2.2': + resolution: {integrity: sha512-yXSS27qPExaXeuLvMRMXOLtpipzfQYNjG3FkunDWKGfMYjKuhFXko9CVzqxm8jcF+lmtS9Fd89QNdh9XDjnbNg==} + '@fastify/cookie@11.1.2': resolution: {integrity: sha512-Dtrpk/YOGUsbRMvP/8ZqPpwnMRv0qSqodFdoQ2B589Obc7jw4s4Qla+cV72Bsm7WsZJnqlYFX/i7uSBq0xzg6g==} '@fastify/cors@11.3.0': resolution: {integrity: sha512-ggQGua+xHv1MvePbPr0v//xLYEsCXbWspquXCJS9Ot5YoRXq8J8ZWzHnxDBVnbtXosvistXo6LtNzOJswf64Fw==} + '@fastify/deepmerge@3.2.1': + resolution: {integrity: sha512-N5Oqvltoa2r9z1tbx4xjky0oRR60v+T47Ic4J1ukoVQcptLOrIdRnCSdTGmOmajZuHVKlTnfcmrjyqsGEW1ztA==} + '@fastify/error@4.2.0': resolution: {integrity: sha512-RSo3sVDXfHskiBZKBPRgnQTtIqpi/7zhJOEmAxCiBcM7d0uwdGdxLlsCaLzGs8v8NnxIRlfG0N51p5yFaOentQ==} @@ -513,6 +528,9 @@ packages: '@fastify/merge-json-schemas@0.2.1': resolution: {integrity: sha512-OA3KGBCy6KtIvLf8DINC5880o5iBlDX4SxzLQS8HorJAbqluzLRn80UXU0bxZn7UOFhFgpRJDasfwn9nG4FG4A==} + '@fastify/multipart@9.4.0': + resolution: {integrity: sha512-Z404bzZeLSXTBmp/trCBuoVFX28pM7rhv849Q5TsbTFZHuk1lc4QjQITTPK92DKVpXmNtJXeHSSc7GYvqFpxAQ==} + '@fastify/proxy-addr@5.1.0': resolution: {integrity: sha512-INS+6gh91cLUjB+PVHfu1UqcB76Sqtpyp7bnL+FYojhjygvOPA9ctiD/JDKsyD9Xgu4hUhCSJBPig/w7duNajw==} @@ -1813,6 +1831,12 @@ packages: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} 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: resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} engines: {node: '>=6.0'} @@ -3904,6 +3928,8 @@ snapshots: ajv-formats: 3.0.1(ajv@8.20.0) fast-uri: 4.1.3 + '@fastify/busboy@3.2.2': {} + '@fastify/cookie@11.1.2': dependencies: cookie: 2.0.1 @@ -3914,6 +3940,8 @@ snapshots: fastify-plugin: 6.0.0 toad-cache: 3.7.4 + '@fastify/deepmerge@3.2.1': {} + '@fastify/error@4.2.0': {} '@fastify/fast-json-stringify-compiler@5.1.0': @@ -3936,6 +3964,14 @@ snapshots: dependencies: 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': dependencies: '@fastify/forwarded': 3.0.2 @@ -5354,6 +5390,10 @@ snapshots: shebang-command: 2.0.0 which: 2.0.2 + csv-parse@5.6.0: {} + + csv-stringify@6.8.3: {} + debug@4.4.3(supports-color@8.1.1): dependencies: ms: 2.1.3