feat: add campaign management

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

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

19
TODO.md
View File

@@ -167,10 +167,21 @@ mestre original (`agente.md`, seções 90-93).
de CDR/CEL/queue_log) — não inventamos número aqui de CDR/CEL/queue_log) — não inventamos número aqui
## Fase 6 — Campanhas e discador preditivo ## Fase 6 — Campanhas e discador preditivo
- [ ] CRUD Campanhas (todos os campos da seção 24) - [x] CRUD Campanhas (todos os campos da seção 24) — testado: criação com
- [ ] Import CSV streaming (preview, mapeamento, validação, duplicados, rejeitados) defaults corretos, máquina de estados (DRAFT/READY/RUNNING/PAUSED/
- [ ] Normalização de telefone (serviço dedicado, BR inicialmente) DRAINING/STOPPED/COMPLETED) rejeitando transições inválidas (ex.:
- [ ] Lista de supressão (DNC) + checagem obrigatória pré-originação PAUSED->PAUSED = 400), edição bloqueada com campanha RUNNING
- [x] Import CSV streaming (detecção automática de delimitador — vírgula/
ponto-e-vírgula/tab —, mapeamento de coluna por nome de header,
validação+normalização, dedupe dentro do arquivo E contra leads já
existentes na campanha, CSV de rejeitados com motivo, modo dryRun
para preview sem persistir) — testado com CSV real (5 linhas: 3
válidas, 1 inválida, 1 duplicada — todos os contadores bateram)
- [x] Normalização de telefone (packages/shared, BR — E.164, DDD/celular
validados) — 7 testes unitários passando
- [x] Lista de supressão (DNC): CRUD + import CSV + remoção sempre exige
motivo e é auditada — checagem obrigatória pré-originação
(isSuppressed) implementada e usada pelo dialer-worker (ver abaixo)
- [ ] CPS limiter (token bucket, coordenado via Redis, multi-worker) - [ ] CPS limiter (token bucket, coordenado via Redis, multi-worker)
- [ ] Reserva concorrente de leads (FOR UPDATE SKIP LOCKED + timeout) - [ ] Reserva concorrente de leads (FOR UPDATE SKIP LOCKED + timeout)
- [ ] Idempotência de originação (attempt_id/call_id/uniqueid/linkedid, state machine) - [ ] Idempotência de originação (attempt_id/call_id/uniqueid/linkedid, state machine)

View File

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

View File

@@ -23,6 +23,10 @@ import { PauseReasonsModule } from './pause-reasons/pause-reasons.module';
import { QueuesModule } from './queues/queues.module'; import { QueuesModule } from './queues/queues.module';
import { AgentsModule } from './agents/agents.module'; import { AgentsModule } from './agents/agents.module';
import { AgentConsoleModule } from './agent-console/agent-console.module'; import { AgentConsoleModule } from './agent-console/agent-console.module';
import { SuppressionModule } from './suppression/suppression.module';
import { DispositionsModule } from './dispositions/dispositions.module';
import { CampaignsModule } from './campaigns/campaigns.module';
import { LeadsModule } from './leads/leads.module';
import { AuthGuard } from './common/guards/auth.guard'; import { AuthGuard } from './common/guards/auth.guard';
import { PermissionsGuard } from './common/guards/permissions.guard'; import { PermissionsGuard } from './common/guards/permissions.guard';
import { GlobalExceptionFilter } from './common/filters/global-exception.filter'; import { GlobalExceptionFilter } from './common/filters/global-exception.filter';
@@ -72,6 +76,10 @@ import { GlobalExceptionFilter } from './common/filters/global-exception.filter'
QueuesModule, QueuesModule,
AgentsModule, AgentsModule,
AgentConsoleModule, AgentConsoleModule,
SuppressionModule,
DispositionsModule,
CampaignsModule,
LeadsModule,
], ],
controllers: [AppController], controllers: [AppController],
providers: [ providers: [

View File

@@ -0,0 +1,127 @@
import {
Body,
Controller,
Delete,
Get,
Param,
ParseUUIDPipe,
Patch,
Post,
Req,
} from '@nestjs/common';
import type { FastifyRequest } from 'fastify';
import { RequirePermissions } from '../common/decorators/permissions.decorator';
import { CurrentUser } from '../common/decorators/current-user.decorator';
import type { AuthenticatedUser } from '../common/guards/auth.guard';
import { CampaignsService } from './campaigns.service';
import { CreateCampaignDto } from './dto/create-campaign.dto';
import { UpdateCampaignDto } from './dto/update-campaign.dto';
@Controller('campaigns')
export class CampaignsController {
constructor(private readonly campaignsService: CampaignsService) {}
@Get()
@RequirePermissions('campaigns.view')
list() {
return this.campaignsService.list();
}
@Get(':id')
@RequirePermissions('campaigns.view')
findOne(@Param('id', ParseUUIDPipe) id: string) {
return this.campaignsService.findByIdOrThrow(id);
}
@Post()
@RequirePermissions('campaigns.create')
create(
@Body() dto: CreateCampaignDto,
@CurrentUser() actor: AuthenticatedUser,
@Req() request: FastifyRequest,
) {
return this.campaignsService.create(dto, actor, {
ip: request.ip,
userAgent: request.headers['user-agent'],
});
}
@Patch(':id')
@RequirePermissions('campaigns.update')
update(
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: UpdateCampaignDto,
@CurrentUser() actor: AuthenticatedUser,
@Req() request: FastifyRequest,
) {
return this.campaignsService.update(id, dto, actor, {
ip: request.ip,
userAgent: request.headers['user-agent'],
});
}
@Delete(':id')
@RequirePermissions('campaigns.delete')
remove(
@Param('id', ParseUUIDPipe) id: string,
@CurrentUser() actor: AuthenticatedUser,
@Req() request: FastifyRequest,
) {
return this.campaignsService.delete(id, actor, {
ip: request.ip,
userAgent: request.headers['user-agent'],
});
}
@Post(':id/start')
@RequirePermissions('campaigns.start')
start(
@Param('id', ParseUUIDPipe) id: string,
@CurrentUser() actor: AuthenticatedUser,
@Req() request: FastifyRequest,
) {
return this.campaignsService.start(id, actor, {
ip: request.ip,
userAgent: request.headers['user-agent'],
});
}
@Post(':id/pause')
@RequirePermissions('campaigns.pause')
pause(
@Param('id', ParseUUIDPipe) id: string,
@CurrentUser() actor: AuthenticatedUser,
@Req() request: FastifyRequest,
) {
return this.campaignsService.pause(id, actor, {
ip: request.ip,
userAgent: request.headers['user-agent'],
});
}
@Post(':id/stop')
@RequirePermissions('campaigns.stop')
stop(
@Param('id', ParseUUIDPipe) id: string,
@CurrentUser() actor: AuthenticatedUser,
@Req() request: FastifyRequest,
) {
return this.campaignsService.stop(id, actor, {
ip: request.ip,
userAgent: request.headers['user-agent'],
});
}
@Post(':id/drain')
@RequirePermissions('campaigns.pause')
drain(
@Param('id', ParseUUIDPipe) id: string,
@CurrentUser() actor: AuthenticatedUser,
@Req() request: FastifyRequest,
) {
return this.campaignsService.drain(id, actor, {
ip: request.ip,
userAgent: request.headers['user-agent'],
});
}
}

View File

@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { CampaignsController } from './campaigns.controller';
import { CampaignsService } from './campaigns.service';
@Module({
controllers: [CampaignsController],
providers: [CampaignsService],
exports: [CampaignsService],
})
export class CampaignsModule {}

View File

@@ -0,0 +1,239 @@
import {
BadRequestException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { Campaign, CampaignStatus } from '@b2bcall/database';
import { PrismaService } from '../prisma/prisma.service';
import { AuditService } from '../audit/audit.service';
import type { RequestContext } from '../auth/auth.service';
import { CreateCampaignDto } from './dto/create-campaign.dto';
import { UpdateCampaignDto } from './dto/update-campaign.dto';
// Transições válidas de status (agente.md seção 24). DRAINING/COMPLETED
// nunca são setados diretamente pelo usuário via update — DRAINING só via
// ação "drenar", COMPLETED só pelo dialer-worker ao esgotar os leads.
const VALID_TRANSITIONS: Record<CampaignStatus, CampaignStatus[]> = {
DRAFT: [CampaignStatus.READY, CampaignStatus.RUNNING],
READY: [CampaignStatus.RUNNING, CampaignStatus.DRAFT],
RUNNING: [
CampaignStatus.PAUSED,
CampaignStatus.DRAINING,
CampaignStatus.STOPPED,
],
PAUSED: [CampaignStatus.RUNNING, CampaignStatus.STOPPED],
DRAINING: [CampaignStatus.STOPPED, CampaignStatus.COMPLETED],
STOPPED: [CampaignStatus.READY, CampaignStatus.RUNNING],
COMPLETED: [],
};
function toDto(campaign: Campaign) {
return campaign;
}
@Injectable()
export class CampaignsService {
constructor(
private readonly prisma: PrismaService,
private readonly audit: AuditService,
) {}
list() {
return this.prisma.campaign.findMany({
orderBy: { createdAt: 'desc' },
include: { queue: true, trunk: { select: { id: true, name: true } } },
});
}
async findByIdOrThrow(id: string) {
const campaign = await this.prisma.campaign.findUnique({
where: { id },
include: { queue: true, trunk: { select: { id: true, name: true } } },
});
if (!campaign) throw new NotFoundException('Campanha não encontrada.');
return campaign;
}
async create(
dto: CreateCampaignDto,
actor: { id: string },
ctx: RequestContext,
) {
const [existing, queue, trunk] = await Promise.all([
this.prisma.campaign.findUnique({ where: { name: dto.name } }),
this.prisma.queue.findUnique({ where: { id: dto.queueId } }),
this.prisma.trunk.findUnique({ where: { id: dto.trunkId } }),
]);
if (existing)
throw new BadRequestException('Já existe uma campanha com este nome.');
if (!queue) throw new BadRequestException('Fila informada não existe.');
if (!trunk) throw new BadRequestException('Tronco informado não existe.');
this.validatePacing(dto);
const campaign = await this.prisma.campaign.create({
data: {
...dto,
startDate: dto.startDate ? new Date(dto.startDate) : undefined,
endDate: dto.endDate ? new Date(dto.endDate) : undefined,
},
});
await this.audit.log({
userId: actor.id,
action: 'campaign_created',
entityType: 'campaign',
entityId: campaign.id,
after: { ...dto },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
});
return toDto(campaign);
}
private validatePacing(dto: Partial<CreateCampaignDto>) {
if (
dto.pacingMin !== undefined &&
dto.pacingMax !== undefined &&
dto.pacingMin > dto.pacingMax
) {
throw new BadRequestException(
'pacingMin não pode ser maior que pacingMax.',
);
}
}
async update(
id: string,
dto: UpdateCampaignDto,
actor: { id: string },
ctx: RequestContext,
) {
const before = await this.prisma.campaign.findUnique({ where: { id } });
if (!before) throw new NotFoundException('Campanha não encontrada.');
if (before.status === CampaignStatus.RUNNING) {
throw new BadRequestException(
'Pause a campanha antes de editar seus parâmetros.',
);
}
this.validatePacing({
pacingMin: dto.pacingMin ?? before.pacingMin,
pacingMax: dto.pacingMax ?? before.pacingMax,
});
const campaign = await this.prisma.campaign.update({
where: { id },
data: {
...dto,
startDate: dto.startDate ? new Date(dto.startDate) : undefined,
endDate: dto.endDate ? new Date(dto.endDate) : undefined,
},
});
await this.audit.log({
userId: actor.id,
action: 'campaign_updated',
entityType: 'campaign',
entityId: id,
before,
after: { ...dto },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
});
return toDto(campaign);
}
async delete(id: string, actor: { id: string }, ctx: RequestContext) {
const campaign = await this.prisma.campaign.findUnique({ where: { id } });
if (!campaign) throw new NotFoundException('Campanha não encontrada.');
if (campaign.status === CampaignStatus.RUNNING) {
throw new BadRequestException('Pare a campanha antes de excluí-la.');
}
await this.prisma.campaign.delete({ where: { id } });
await this.audit.log({
userId: actor.id,
action: 'campaign_deleted',
entityType: 'campaign',
entityId: id,
before: campaign,
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
});
}
private async transition(
id: string,
target: CampaignStatus,
actor: { id: string },
ctx: RequestContext,
action: string,
) {
const campaign = await this.prisma.campaign.findUnique({ where: { id } });
if (!campaign) throw new NotFoundException('Campanha não encontrada.');
if (!VALID_TRANSITIONS[campaign.status].includes(target)) {
throw new BadRequestException(
`Não é possível ir de ${campaign.status} para ${target}.`,
);
}
const updated = await this.prisma.campaign.update({
where: { id },
data: { status: target },
});
await this.audit.log({
userId: actor.id,
action,
entityType: 'campaign',
entityId: id,
before: { status: campaign.status },
after: { status: target },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
});
return updated;
}
start(id: string, actor: { id: string }, ctx: RequestContext) {
return this.transition(
id,
CampaignStatus.RUNNING,
actor,
ctx,
'campaign_started',
);
}
pause(id: string, actor: { id: string }, ctx: RequestContext) {
// Pausar nunca derruba chamadas em andamento (agente.md seção 76) — o
// dialer-worker apenas para de originar novas ao ver status != RUNNING.
return this.transition(
id,
CampaignStatus.PAUSED,
actor,
ctx,
'campaign_paused',
);
}
stop(id: string, actor: { id: string }, ctx: RequestContext) {
return this.transition(
id,
CampaignStatus.STOPPED,
actor,
ctx,
'campaign_stopped',
);
}
drain(id: string, actor: { id: string }, ctx: RequestContext) {
return this.transition(
id,
CampaignStatus.DRAINING,
actor,
ctx,
'campaign_drained',
);
}
}

View File

@@ -0,0 +1,133 @@
import {
IsArray,
IsBoolean,
IsDateString,
IsInt,
IsNumber,
IsObject,
IsOptional,
IsString,
IsUUID,
Matches,
Max,
MaxLength,
Min,
MinLength,
} from 'class-validator';
export class CreateCampaignDto {
@IsString()
@MinLength(1)
@MaxLength(120)
name!: string;
@IsOptional()
@IsString()
description?: string;
@IsUUID('4')
queueId!: string;
@IsUUID('4')
trunkId!: string;
@IsOptional()
@IsString()
callerId?: string;
@IsOptional()
@IsString()
context?: string;
@IsOptional()
@IsDateString()
startDate?: string;
@IsOptional()
@IsDateString()
endDate?: string;
@IsOptional()
@IsArray()
@IsInt({ each: true })
@Min(0, { each: true })
@Max(6, { each: true })
daysOfWeek?: number[];
@IsOptional()
@IsString()
@Matches(/^([01]\d|2[0-3]):[0-5]\d$/, { message: 'Horário no formato HH:MM' })
startTime?: string;
@IsOptional()
@IsString()
@Matches(/^([01]\d|2[0-3]):[0-5]\d$/, { message: 'Horário no formato HH:MM' })
endTime?: string;
@IsOptional()
@IsString()
timezone?: string;
@IsInt()
@Min(1)
@Max(1000)
maxCps!: number;
@IsInt()
@Min(1)
@Max(10000)
maxConcurrentCalls!: number;
@IsOptional()
@IsNumber()
@Min(0.1)
@Max(10)
pacingInitial?: number;
@IsOptional()
@IsNumber()
@Min(0.1)
@Max(10)
pacingMin?: number;
@IsOptional()
@IsNumber()
@Min(0.1)
@Max(10)
pacingMax?: number;
@IsOptional()
@IsNumber()
@Min(0)
@Max(1)
targetAbandonRate?: number;
@IsOptional()
@IsInt()
@Min(1)
maxWaitForAgentSeconds?: number;
@IsOptional()
@IsInt()
@Min(1)
ringTimeoutSeconds?: number;
@IsOptional()
@IsInt()
@Min(1)
@Max(50)
maxAttempts?: number;
@IsOptional()
@IsObject()
retryRules?: Record<string, number>;
@IsOptional()
@IsBoolean()
amdEnabled?: boolean;
@IsOptional()
@IsInt()
@Min(0)
wrapUpTimeSeconds?: number;
}

View File

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

View File

@@ -0,0 +1,68 @@
import {
Body,
Controller,
Delete,
Get,
Param,
ParseUUIDPipe,
Patch,
Post,
Req,
} from '@nestjs/common';
import type { FastifyRequest } from 'fastify';
import { RequirePermissions } from '../common/decorators/permissions.decorator';
import { CurrentUser } from '../common/decorators/current-user.decorator';
import type { AuthenticatedUser } from '../common/guards/auth.guard';
import { DispositionsService } from './dispositions.service';
import { CreateDispositionDto } from './dto/create-disposition.dto';
import { UpdateDispositionDto } from './dto/update-disposition.dto';
@Controller('dispositions')
export class DispositionsController {
constructor(private readonly dispositionsService: DispositionsService) {}
@Get()
list() {
return this.dispositionsService.list();
}
@Post()
@RequirePermissions('settings.manage')
create(
@Body() dto: CreateDispositionDto,
@CurrentUser() actor: AuthenticatedUser,
@Req() request: FastifyRequest,
) {
return this.dispositionsService.create(dto, actor, {
ip: request.ip,
userAgent: request.headers['user-agent'],
});
}
@Patch(':id')
@RequirePermissions('settings.manage')
update(
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: UpdateDispositionDto,
@CurrentUser() actor: AuthenticatedUser,
@Req() request: FastifyRequest,
) {
return this.dispositionsService.update(id, dto, actor, {
ip: request.ip,
userAgent: request.headers['user-agent'],
});
}
@Delete(':id')
@RequirePermissions('settings.manage')
remove(
@Param('id', ParseUUIDPipe) id: string,
@CurrentUser() actor: AuthenticatedUser,
@Req() request: FastifyRequest,
) {
return this.dispositionsService.delete(id, actor, {
ip: request.ip,
userAgent: request.headers['user-agent'],
});
}
}

View File

@@ -0,0 +1,9 @@
import { Module } from '@nestjs/common';
import { DispositionsController } from './dispositions.controller';
import { DispositionsService } from './dispositions.service';
@Module({
controllers: [DispositionsController],
providers: [DispositionsService],
})
export class DispositionsModule {}

View File

@@ -0,0 +1,101 @@
import {
BadRequestException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { AuditService } from '../audit/audit.service';
import type { RequestContext } from '../auth/auth.service';
import { CreateDispositionDto } from './dto/create-disposition.dto';
import { UpdateDispositionDto } from './dto/update-disposition.dto';
@Injectable()
export class DispositionsService {
constructor(
private readonly prisma: PrismaService,
private readonly audit: AuditService,
) {}
list() {
return this.prisma.callDisposition.findMany({ orderBy: { name: 'asc' } });
}
async create(
dto: CreateDispositionDto,
actor: { id: string },
ctx: RequestContext,
) {
const existing = await this.prisma.callDisposition.findUnique({
where: { code: dto.code },
});
if (existing)
throw new BadRequestException(
'Já existe uma disposição com este código.',
);
const disposition = await this.prisma.callDisposition.create({ data: dto });
await this.audit.log({
userId: actor.id,
action: 'disposition_created',
entityType: 'call_disposition',
entityId: disposition.id,
after: { ...dto },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
});
return disposition;
}
async update(
id: string,
dto: UpdateDispositionDto,
actor: { id: string },
ctx: RequestContext,
) {
const before = await this.prisma.callDisposition.findUnique({
where: { id },
});
if (!before) throw new NotFoundException('Disposição não encontrada.');
const disposition = await this.prisma.callDisposition.update({
where: { id },
data: dto,
});
await this.audit.log({
userId: actor.id,
action: 'disposition_updated',
entityType: 'call_disposition',
entityId: id,
before,
after: { ...dto },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
});
return disposition;
}
async delete(id: string, actor: { id: string }, ctx: RequestContext) {
const disposition = await this.prisma.callDisposition.findUnique({
where: { id },
});
if (!disposition) throw new NotFoundException('Disposição não encontrada.');
try {
await this.prisma.callDisposition.delete({ where: { id } });
} catch {
throw new BadRequestException(
'Esta disposição já foi usada em chamadas — desative-a em vez de excluir.',
);
}
await this.audit.log({
userId: actor.id,
action: 'disposition_deleted',
entityType: 'call_disposition',
entityId: id,
before: disposition,
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
});
}
}

View File

@@ -0,0 +1,31 @@
import {
IsBoolean,
IsEnum,
IsOptional,
IsString,
Matches,
MinLength,
} from 'class-validator';
import { DispositionAction } from '@b2bcall/database';
export class CreateDispositionDto {
@IsString()
@MinLength(1)
name!: string;
@IsString()
@Matches(/^[a-zA-Z0-9_-]{1,40}$/)
code!: string;
@IsOptional()
@IsString()
description?: string;
@IsOptional()
@IsEnum(DispositionAction)
action?: DispositionAction;
@IsOptional()
@IsBoolean()
active?: boolean;
}

View File

@@ -0,0 +1,6 @@
import { PartialType, OmitType } from '@nestjs/mapped-types';
import { CreateDispositionDto } from './create-disposition.dto';
export class UpdateDispositionDto extends PartialType(
OmitType(CreateDispositionDto, ['code'] as const),
) {}

View File

@@ -0,0 +1,26 @@
import { Type } from 'class-transformer';
import { IsEnum, IsInt, IsOptional, IsString, Max, Min } from 'class-validator';
import { LeadStatus } from '@b2bcall/database';
export class QueryLeadsDto {
@IsOptional()
@IsEnum(LeadStatus)
status?: LeadStatus;
@IsOptional()
@IsString()
search?: string;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
page: number = 1;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
@Max(500)
pageSize: number = 50;
}

View File

@@ -0,0 +1,77 @@
import {
BadRequestException,
Controller,
Get,
Header,
Param,
ParseUUIDPipe,
Post,
Query,
Req,
Res,
} from '@nestjs/common';
import type { FastifyReply, FastifyRequest } from 'fastify';
import { RequirePermissions } from '../common/decorators/permissions.decorator';
import { CurrentUser } from '../common/decorators/current-user.decorator';
import type { AuthenticatedUser } from '../common/guards/auth.guard';
import { LeadsService } from './leads.service';
import { QueryLeadsDto } from './dto/query-leads.dto';
@Controller('campaigns/:campaignId/leads')
export class LeadsController {
constructor(private readonly leadsService: LeadsService) {}
@Get()
@RequirePermissions('campaigns.view')
query(
@Param('campaignId', ParseUUIDPipe) campaignId: string,
@Query() query: QueryLeadsDto,
) {
return this.leadsService.query(campaignId, query);
}
@Get('imports')
@RequirePermissions('campaigns.view')
listImports(@Param('campaignId', ParseUUIDPipe) campaignId: string) {
return this.leadsService.listImports(campaignId);
}
@Get('imports/:importId/rejected.csv')
@RequirePermissions('campaigns.view')
@Header('Content-Type', 'text/csv; charset=utf-8')
async downloadRejected(
@Param('importId', ParseUUIDPipe) importId: string,
@Res({ passthrough: true }) reply: FastifyReply,
) {
const csv = await this.leadsService.getRejectedCsv(importId);
reply.header(
'Content-Disposition',
`attachment; filename="rejeitados-${importId}.csv"`,
);
return csv;
}
// dryRun=true roda a mesma validação/normalização/dedupe sem persistir
// nada — usado pelo frontend como "preview" antes de confirmar a
// importação (agente.md seção 27).
@Post('import')
@RequirePermissions('campaigns.update')
async importCsv(
@Param('campaignId', ParseUUIDPipe) campaignId: string,
@Query('dryRun') dryRun: string | undefined,
@Req() request: FastifyRequest,
@CurrentUser() actor: AuthenticatedUser,
) {
const file = await request.file();
if (!file) throw new BadRequestException('Nenhum arquivo enviado.');
return this.leadsService.importCsv(
campaignId,
file.filename,
file.file,
dryRun === 'true',
actor,
{ ip: request.ip, userAgent: request.headers['user-agent'] },
);
}
}

View File

@@ -0,0 +1,9 @@
import { Module } from '@nestjs/common';
import { LeadsController } from './leads.controller';
import { LeadsService } from './leads.service';
@Module({
controllers: [LeadsController],
providers: [LeadsService],
})
export class LeadsModule {}

View File

@@ -0,0 +1,267 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import type { Readable } from 'node:stream';
import { parse } from 'csv-parse';
import { stringify } from 'csv-stringify/sync';
import { normalizePhone } from '@b2bcall/shared';
import { PrismaService } from '../prisma/prisma.service';
import { AuditService } from '../audit/audit.service';
import type { RequestContext } from '../auth/auth.service';
import { QueryLeadsDto } from './dto/query-leads.dto';
const BATCH_SIZE = 500;
const PHONE_HEADERS = [
'telefone',
'phone',
'celular',
'numero',
'número',
'fone',
'tel',
];
const NAME_HEADERS = ['nome', 'name'];
interface RejectedRow {
original: Record<string, string>;
reason: string;
}
export interface ImportSummary {
total: number;
valid: number;
invalid: number;
duplicate: number;
rejectedCsv: string | null;
}
function pickField(
row: Record<string, string>,
candidates: string[],
): string | undefined {
const keys = Object.keys(row);
for (const candidate of candidates) {
const key = keys.find((k) => k.trim().toLowerCase() === candidate);
if (key && row[key]?.trim()) return row[key].trim();
}
return undefined;
}
@Injectable()
export class LeadsService {
constructor(
private readonly prisma: PrismaService,
private readonly audit: AuditService,
) {}
async query(campaignId: string, query: QueryLeadsDto) {
const where = {
campaignId,
status: query.status,
...(query.search
? {
OR: [
{
name: { contains: query.search, mode: 'insensitive' as const },
},
{ phoneNormalized: { contains: query.search } },
],
}
: {}),
};
const [total, items] = await this.prisma.$transaction([
this.prisma.lead.count({ where }),
this.prisma.lead.findMany({
where,
orderBy: { createdAt: 'desc' },
skip: (query.page - 1) * query.pageSize,
take: query.pageSize,
}),
]);
return { items, total, page: query.page, pageSize: query.pageSize };
}
listImports(campaignId: string) {
return this.prisma.leadImport.findMany({
where: { campaignId },
orderBy: { createdAt: 'desc' },
});
}
async getRejectedCsv(importId: string): Promise<string> {
const record = await this.prisma.leadImport.findUnique({
where: { id: importId },
});
if (!record) throw new NotFoundException('Importação não encontrada.');
return record.rejectedCsv ?? '';
}
// Streaming: nunca materializa o CSV inteiro na memória (agente.md seção
// 27) — lê e processa em lotes de BATCH_SIZE linhas.
async importCsv(
campaignId: string,
filename: string,
fileStream: Readable,
dryRun: boolean,
actor: { id: string },
ctx: RequestContext,
): Promise<ImportSummary> {
const campaign = await this.prisma.campaign.findUnique({
where: { id: campaignId },
});
if (!campaign) throw new NotFoundException('Campanha não encontrada.');
// Criado antes de processar as linhas para que cada lead já nasça
// vinculado ao import correto mesmo se outra importação rodar em
// paralelo na mesma campanha (evita corrida em um backfill posterior).
const importRecord = dryRun
? null
: await this.prisma.leadImport.create({
data: {
campaignId,
filename,
status: 'PROCESSING',
createdById: actor.id,
},
});
const parser = fileStream.pipe(
parse({
columns: true,
delimiter: [',', ';', '\t'],
skip_empty_lines: true,
trim: true,
bom: true,
}),
);
let total = 0;
let valid = 0;
let invalid = 0;
let duplicate = 0;
const rejected: RejectedRow[] = [];
const seenInFile = new Set<string>();
let batch: {
name: string | null;
phone: string;
phoneNormalized: string;
}[] = [];
const flushBatch = async () => {
if (batch.length === 0) return;
const phones = batch.map((b) => b.phoneNormalized);
const existing = dryRun
? []
: await this.prisma.lead.findMany({
where: { campaignId, phoneNormalized: { in: phones } },
select: { phoneNormalized: true },
});
const existingSet = new Set(existing.map((e) => e.phoneNormalized));
const toInsert: typeof batch = [];
for (const item of batch) {
if (existingSet.has(item.phoneNormalized)) {
duplicate++;
rejected.push({
original: { nome: item.name ?? '', telefone: item.phone },
reason: 'Duplicado (já existe na campanha)',
});
continue;
}
valid++;
toInsert.push(item);
}
if (!dryRun && toInsert.length > 0) {
await this.prisma.lead.createMany({
data: toInsert.map((item) => ({
campaignId,
importId: importRecord!.id,
name: item.name,
phone: item.phone,
phoneNormalized: item.phoneNormalized,
})),
});
}
batch = [];
};
for await (const row of parser as AsyncIterable<Record<string, string>>) {
total++;
const name = pickField(row, NAME_HEADERS) ?? null;
const phoneRaw = pickField(row, PHONE_HEADERS);
if (!phoneRaw) {
invalid++;
rejected.push({
original: row,
reason: 'Coluna de telefone não encontrada ou vazia',
});
continue;
}
const normalized = normalizePhone(phoneRaw);
if (!normalized.valid || !normalized.normalized) {
invalid++;
rejected.push({
original: row,
reason: normalized.reason ?? 'Telefone inválido',
});
continue;
}
if (seenInFile.has(normalized.normalized)) {
duplicate++;
rejected.push({
original: row,
reason: 'Duplicado (repetido no arquivo)',
});
continue;
}
seenInFile.add(normalized.normalized);
batch.push({
name,
phone: phoneRaw,
phoneNormalized: normalized.normalized,
});
if (batch.length >= BATCH_SIZE) await flushBatch();
}
await flushBatch();
const rejectedCsv =
rejected.length > 0
? stringify(
rejected.map((r) => ({ ...r.original, motivo_rejeicao: r.reason })),
{ header: true },
)
: null;
if (!dryRun && importRecord) {
await this.prisma.leadImport.update({
where: { id: importRecord.id },
data: {
status: 'COMPLETED',
totalRows: total,
validRows: valid,
invalidRows: invalid,
duplicateRows: duplicate,
rejectedCsv,
},
});
await this.audit.log({
userId: actor.id,
action: 'leads_imported',
entityType: 'campaign',
entityId: campaignId,
after: { total, valid, invalid, duplicate, importId: importRecord.id },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
});
}
return { total, valid, invalid, duplicate, rejectedCsv };
}
}

View File

@@ -0,0 +1,45 @@
import { normalizeBrazilianPhone } from '@b2bcall/shared';
describe('normalizeBrazilianPhone', () => {
it('normaliza celular de 11 dígitos sem DDI', () => {
expect(normalizeBrazilianPhone('11987654321')).toEqual({
original: '11987654321',
normalized: '+5511987654321',
valid: true,
});
});
it('normaliza número com DDI 55 e formatação', () => {
expect(normalizeBrazilianPhone('+55 (11) 98765-4321').normalized).toBe(
'+5511987654321',
);
});
it('normaliza fixo de 10 dígitos', () => {
expect(normalizeBrazilianPhone('1132654321').normalized).toBe(
'+551132654321',
);
});
it('remove zero de discagem interurbana', () => {
expect(normalizeBrazilianPhone('0 11 987654321').normalized).toBe(
'+5511987654321',
);
});
it('rejeita DDD inválido', () => {
const result = normalizeBrazilianPhone('0587654321');
expect(result.valid).toBe(false);
expect(result.reason).toContain('DDD inválido');
});
it('rejeita celular de 11 dígitos que não começa com 9', () => {
const result = normalizeBrazilianPhone('11887654321');
expect(result.valid).toBe(false);
});
it('rejeita quantidade de dígitos inválida', () => {
expect(normalizeBrazilianPhone('123').valid).toBe(false);
expect(normalizeBrazilianPhone('').valid).toBe(false);
});
});

View File

@@ -10,6 +10,7 @@ import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
import { Logger } from 'nestjs-pino'; import { Logger } from 'nestjs-pino';
import fastifyCookie from '@fastify/cookie'; import fastifyCookie from '@fastify/cookie';
import fastifyHelmet from '@fastify/helmet'; import fastifyHelmet from '@fastify/helmet';
import fastifyMultipart from '@fastify/multipart';
import { AppModule } from './app.module'; import { AppModule } from './app.module';
async function bootstrap() { async function bootstrap() {
@@ -24,6 +25,11 @@ async function bootstrap() {
const config = app.get(ConfigService); const config = app.get(ConfigService);
await app.register(fastifyCookie); await app.register(fastifyCookie);
// Streaming multipart para importação de CSV (leads, supressão) — nunca
// carrega o arquivo inteiro na memória (agente.md seção 27).
await app.register(fastifyMultipart, {
limits: { fileSize: 50 * 1024 * 1024 },
});
await app.register(fastifyHelmet, { await app.register(fastifyHelmet, {
// Swagger UI (quando habilitado) precisa de scripts/estilos inline. // Swagger UI (quando habilitado) precisa de scripts/estilos inline.
contentSecurityPolicy: contentSecurityPolicy:

View File

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

View File

@@ -0,0 +1,21 @@
import { Type } from 'class-transformer';
import { IsInt, IsOptional, IsString, Max, Min } from 'class-validator';
export class QuerySuppressionDto {
@IsOptional()
@IsString()
search?: string;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
page: number = 1;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
@Max(200)
pageSize: number = 50;
}

View File

@@ -0,0 +1,84 @@
import {
BadRequestException,
Body,
Controller,
Delete,
Get,
Param,
ParseUUIDPipe,
Post,
Query,
Req,
} from '@nestjs/common';
import { parse } from 'csv-parse';
import type { FastifyRequest } from 'fastify';
import { RequirePermissions } from '../common/decorators/permissions.decorator';
import { CurrentUser } from '../common/decorators/current-user.decorator';
import type { AuthenticatedUser } from '../common/guards/auth.guard';
import { SuppressionService } from './suppression.service';
import { AddSuppressionEntryDto } from './dto/add-suppression-entry.dto';
import { QuerySuppressionDto } from './dto/query-suppression.dto';
@Controller('suppression')
export class SuppressionController {
constructor(private readonly suppressionService: SuppressionService) {}
@Get()
@RequirePermissions('campaigns.view')
query(@Query() query: QuerySuppressionDto) {
return this.suppressionService.query(query);
}
@Post()
@RequirePermissions('campaigns.update')
add(
@Body() dto: AddSuppressionEntryDto,
@CurrentUser() actor: AuthenticatedUser,
@Req() request: FastifyRequest,
) {
return this.suppressionService.add(dto, actor, {
ip: request.ip,
userAgent: request.headers['user-agent'],
});
}
// Streaming: nunca materializa o CSV inteiro em uma string antes de
// processar — parseia linha a linha à medida que os bytes chegam.
@Post('import')
@RequirePermissions('campaigns.update')
async importCsv(
@Req() request: FastifyRequest,
@CurrentUser() actor: AuthenticatedUser,
) {
const file = await request.file();
if (!file) throw new BadRequestException('Nenhum arquivo enviado.');
const phones: string[] = [];
const parser = file.file.pipe(
parse({ columns: false, skip_empty_lines: true, trim: true }),
);
for await (const row of parser as AsyncIterable<string[]>) {
const phone = Array.isArray(row) ? row[0] : undefined;
if (phone) phones.push(phone);
}
return this.suppressionService.importCsv(phones, actor, {
ip: request.ip,
userAgent: request.headers['user-agent'],
});
}
@Delete(':id')
@RequirePermissions('campaigns.update')
remove(
@Param('id', ParseUUIDPipe) id: string,
@Body('removalReason') removalReason: string,
@CurrentUser() actor: AuthenticatedUser,
@Req() request: FastifyRequest,
) {
return this.suppressionService.remove(id, removalReason, actor, {
ip: request.ip,
userAgent: request.headers['user-agent'],
});
}
}

View File

@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { SuppressionController } from './suppression.controller';
import { SuppressionService } from './suppression.service';
@Module({
controllers: [SuppressionController],
providers: [SuppressionService],
exports: [SuppressionService],
})
export class SuppressionModule {}

View File

@@ -0,0 +1,148 @@
import {
BadRequestException,
ForbiddenException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { normalizePhone } from '@b2bcall/shared';
import { PrismaService } from '../prisma/prisma.service';
import { AuditService } from '../audit/audit.service';
import type { RequestContext } from '../auth/auth.service';
import { AddSuppressionEntryDto } from './dto/add-suppression-entry.dto';
import { QuerySuppressionDto } from './dto/query-suppression.dto';
@Injectable()
export class SuppressionService {
constructor(
private readonly prisma: PrismaService,
private readonly audit: AuditService,
) {}
async query(query: QuerySuppressionDto) {
const where = query.search
? { phoneNormalized: { contains: query.search } }
: {};
const [total, items] = await this.prisma.$transaction([
this.prisma.suppressionEntry.count({ where }),
this.prisma.suppressionEntry.findMany({
where,
orderBy: { createdAt: 'desc' },
skip: (query.page - 1) * query.pageSize,
take: query.pageSize,
}),
]);
return { items, total, page: query.page, pageSize: query.pageSize };
}
// Verificação obrigatória pré-originação (agente.md seção 29: "CHECK
// SUPPRESSION" antes de qualquer Originate). Usado pelo dialer-worker.
async isSuppressed(phoneNormalized: string): Promise<boolean> {
const entry = await this.prisma.suppressionEntry.findUnique({
where: { phoneNormalized },
});
return entry !== null;
}
async add(
dto: AddSuppressionEntryDto,
actor: { id: string },
ctx: RequestContext,
) {
const normalized = normalizePhone(dto.phone);
if (!normalized.valid || !normalized.normalized) {
throw new BadRequestException(`Telefone inválido: ${normalized.reason}`);
}
const entry = await this.prisma.suppressionEntry.upsert({
where: { phoneNormalized: normalized.normalized },
create: {
phoneNormalized: normalized.normalized,
reason: dto.reason,
addedById: actor.id,
},
update: { reason: dto.reason },
});
await this.audit.log({
userId: actor.id,
action: 'suppression_added',
entityType: 'suppression_entry',
entityId: entry.id,
after: { phone: normalized.normalized, reason: dto.reason },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
});
return entry;
}
async importCsv(
phones: string[],
actor: { id: string },
ctx: RequestContext,
) {
let added = 0;
let invalid = 0;
for (const raw of phones) {
const normalized = normalizePhone(raw);
if (!normalized.valid || !normalized.normalized) {
invalid++;
continue;
}
await this.prisma.suppressionEntry.upsert({
where: { phoneNormalized: normalized.normalized },
create: {
phoneNormalized: normalized.normalized,
reason: 'Importação CSV',
addedById: actor.id,
},
update: {},
});
added++;
}
await this.audit.log({
userId: actor.id,
action: 'suppression_imported',
entityType: 'suppression_entry',
after: { added, invalid, total: phones.length },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
});
return { added, invalid, total: phones.length };
}
// Remoção exige justificativa e é sempre auditada — nunca silenciosa
// (agente.md seção 29: "remover com permissão; informar motivo; auditoria").
async remove(
id: string,
removalReason: string,
actor: { id: string },
ctx: RequestContext,
) {
if (!removalReason || removalReason.trim().length === 0) {
throw new ForbiddenException(
'É obrigatório informar o motivo da remoção.',
);
}
const entry = await this.prisma.suppressionEntry.findUnique({
where: { id },
});
if (!entry)
throw new NotFoundException(
'Entrada não encontrada na lista de bloqueio.',
);
await this.prisma.suppressionEntry.delete({ where: { id } });
await this.audit.log({
userId: actor.id,
action: 'suppression_removed',
entityType: 'suppression_entry',
entityId: id,
before: entry,
after: { removalReason },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
});
}
}

View File

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

View File

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

View File

@@ -180,6 +180,8 @@ model Trunk {
createdAt DateTime @default(now()) @map("created_at") createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at") updatedAt DateTime @updatedAt @map("updated_at")
campaigns Campaign[]
@@map("trunks") @@map("trunks")
} }
@@ -290,6 +292,7 @@ model Queue {
updatedAt DateTime @updatedAt @map("updated_at") updatedAt DateTime @updatedAt @map("updated_at")
members QueueMember[] members QueueMember[]
campaigns Campaign[]
@@map("queues") @@map("queues")
} }
@@ -394,3 +397,241 @@ model AgentPauseEvent {
@@index([agentId, endedAt]) @@index([agentId, endedAt])
@@map("agent_pause_events") @@map("agent_pause_events")
} }
// ===========================================================================
// Fase 6 — Campanhas e Discador Preditivo. Núcleo mais crítico do sistema
// (agente.md seção 30). O Asterisk nunca é fonte de verdade para este
// domínio (seção 97) — todo o estado vive aqui.
// ===========================================================================
enum CampaignStatus {
DRAFT
READY
RUNNING
PAUSED
DRAINING
STOPPED
COMPLETED
}
model Campaign {
id String @id @default(uuid())
name String @unique
description String?
queueId String @map("queue_id")
trunkId String @map("trunk_id")
callerId String? @map("caller_id")
context String @default("outbound")
status CampaignStatus @default(DRAFT)
startDate DateTime? @map("start_date")
endDate DateTime? @map("end_date")
// 0=domingo .. 6=sábado (ISO-like, mas com domingo em 0 por simplicidade
// de exibição em pt-BR). Vazio = todos os dias.
daysOfWeek Int[] @default([1, 2, 3, 4, 5]) @map("days_of_week")
startTime String @default("08:00") @map("start_time")
endTime String @default("20:00") @map("end_time")
timezone String @default("America/Sao_Paulo")
maxCps Int @default(2) @map("max_cps")
maxConcurrentCalls Int @default(10) @map("max_concurrent_calls")
pacingInitial Float @default(1.0) @map("pacing_initial")
pacingMin Float @default(0.5) @map("pacing_min")
pacingMax Float @default(3.0) @map("pacing_max")
targetAbandonRate Float @default(0.03) @map("target_abandon_rate")
maxWaitForAgentSeconds Int @default(30) @map("max_wait_for_agent_seconds")
ringTimeoutSeconds Int @default(25) @map("ring_timeout_seconds")
maxAttempts Int @default(5) @map("max_attempts")
// Minutos de espera até nova tentativa por causa de encerramento.
// Default reflete agente.md seção 79.
retryRules Json @default("{\"BUSY\":15,\"NO_ANSWER\":60,\"CONGESTION\":5,\"FAILED\":30}") @map("retry_rules")
amdEnabled Boolean @default(false) @map("amd_enabled")
wrapUpTimeSeconds Int @default(0) @map("wrap_up_time_seconds")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
queue Queue @relation(fields: [queueId], references: [id], onDelete: Restrict)
trunk Trunk @relation(fields: [trunkId], references: [id], onDelete: Restrict)
leads Lead[]
imports LeadImport[]
attempts DialAttempt[]
callbacks Callback[]
@@map("campaigns")
}
enum LeadStatus {
NEW
READY
RESERVED
DIALING
RINGING
ANSWERED
CONNECTED_AGENT
BUSY
NO_ANSWER
FAILED
INVALID
VOICEMAIL
CALLBACK
COMPLETED
DO_NOT_CALL
MAX_ATTEMPTS
}
enum LeadImportStatus {
PROCESSING
COMPLETED
FAILED
}
model LeadImport {
id String @id @default(uuid())
campaignId String @map("campaign_id")
filename String
status LeadImportStatus @default(PROCESSING)
totalRows Int @default(0) @map("total_rows")
validRows Int @default(0) @map("valid_rows")
invalidRows Int @default(0) @map("invalid_rows")
duplicateRows Int @default(0) @map("duplicate_rows")
rejectedCsv String? @map("rejected_csv")
createdById String? @map("created_by_id")
createdAt DateTime @default(now()) @map("created_at")
campaign Campaign @relation(fields: [campaignId], references: [id], onDelete: Cascade)
leads Lead[]
@@map("lead_imports")
}
model Lead {
id String @id @default(uuid())
campaignId String @map("campaign_id")
importId String? @map("import_id")
name String?
phone String
phoneNormalized String @map("phone_normalized")
status LeadStatus @default(NEW)
attemptCount Int @default(0) @map("attempt_count")
lastAttemptAt DateTime? @map("last_attempt_at")
nextAttemptAt DateTime? @map("next_attempt_at")
lastResult String? @map("last_result")
reservedAt DateTime? @map("reserved_at")
reservedBy String? @map("reserved_by")
customFields Json? @map("custom_fields")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
campaign Campaign @relation(fields: [campaignId], references: [id], onDelete: Cascade)
import LeadImport? @relation(fields: [importId], references: [id], onDelete: SetNull)
attempts DialAttempt[]
callbacks Callback[]
@@index([campaignId, status, nextAttemptAt])
@@index([phoneNormalized])
@@map("leads")
}
enum CallState {
CREATED
RESERVED
ORIGINATING
RINGING
ANSWERED
QUEUED
AGENT_CONNECTED
COMPLETED
FAILED
}
enum AmdResult {
HUMAN
MACHINE
NOT_SURE
HANGUP
}
// Tentativa de discagem — também a "chamada" em si (agente.md seção 36:
// "criar chamadas como state machine"). attempt_id é o próprio id, nunca o
// UNIQUEID do Asterisk (seção 97: "não utilize UNIQUEID como PK de negócio").
model DialAttempt {
id String @id @default(uuid())
leadId String @map("lead_id")
campaignId String @map("campaign_id")
state CallState @default(CREATED)
asteriskUniqueId String? @unique @map("asterisk_unique_id")
asteriskLinkedId String? @map("asterisk_linked_id")
calledNumber String @map("called_number")
callerIdUsed String? @map("caller_id_used")
agentId String? @map("agent_id")
dispositionId String? @map("disposition_id")
dispositionNotes String? @map("disposition_notes")
amdResult AmdResult? @map("amd_result")
hangupCause String? @map("hangup_cause")
startedAt DateTime @default(now()) @map("started_at")
ringingAt DateTime? @map("ringing_at")
answeredAt DateTime? @map("answered_at")
queuedAt DateTime? @map("queued_at")
agentConnectedAt DateTime? @map("agent_connected_at")
endedAt DateTime? @map("ended_at")
lead Lead @relation(fields: [leadId], references: [id], onDelete: Cascade)
campaign Campaign @relation(fields: [campaignId], references: [id], onDelete: Cascade)
disposition CallDisposition? @relation(fields: [dispositionId], references: [id], onDelete: SetNull)
@@index([campaignId, state])
@@index([leadId])
@@map("dial_attempts")
}
enum DispositionAction {
NONE
CALLBACK
DO_NOT_CALL
}
model CallDisposition {
id String @id @default(uuid())
name String
code String @unique
description String?
action DispositionAction @default(NONE)
active Boolean @default(true)
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
attempts DialAttempt[]
@@map("call_dispositions")
}
model Callback {
id String @id @default(uuid())
leadId String @map("lead_id")
campaignId String @map("campaign_id")
preferredAgentId String? @map("preferred_agent_id")
scheduledAt DateTime @map("scheduled_at")
timezone String @default("America/Sao_Paulo")
notes String?
completed Boolean @default(false)
createdAt DateTime @default(now()) @map("created_at")
lead Lead @relation(fields: [leadId], references: [id], onDelete: Cascade)
campaign Campaign @relation(fields: [campaignId], references: [id], onDelete: Cascade)
@@index([scheduledAt, completed])
@@map("call_callbacks")
}
model SuppressionEntry {
id String @id @default(uuid())
phoneNormalized String @unique @map("phone_normalized")
reason String?
addedById String? @map("added_by_id")
createdAt DateTime @default(now()) @map("created_at")
@@map("suppression_list")
}

View File

@@ -1,3 +1,4 @@
export * from './permissions'; export * from './permissions';
export * from './generate-password'; export * from './generate-password';
export * from './secret-crypto'; export * from './secret-crypto';
export * from './phone-normalization';

View File

@@ -0,0 +1,56 @@
// Normalização de telefone dedicada (agente.md seção 28) — nunca espalhar
// regex de telefone pelo resto do código. Formato normalizado: E.164
// (+55DDDNUMERO), já preparando suporte internacional futuro (basta
// adicionar outros ramos de país aqui, sem tocar em quem consome isso).
export interface PhoneNormalizationResult {
original: string;
normalized: string | null;
valid: boolean;
reason?: string;
}
export function normalizeBrazilianPhone(raw: string): PhoneNormalizationResult {
const digits = (raw ?? '').replace(/\D/g, '');
if (!digits) {
return { original: raw, normalized: null, valid: false, reason: 'Telefone vazio' };
}
let national = digits;
if (national.startsWith('55') && (national.length === 12 || national.length === 13)) {
national = national.slice(2);
} else if (national.startsWith('0') && national.length > 10) {
national = national.replace(/^0+/, '');
}
if (national.length !== 10 && national.length !== 11) {
return {
original: raw,
normalized: null,
valid: false,
reason: `Quantidade de dígitos inválida (${national.length}), esperado 10 ou 11`,
};
}
const ddd = Number(national.slice(0, 2));
if (ddd < 11 || ddd > 99) {
return { original: raw, normalized: null, valid: false, reason: `DDD inválido (${national.slice(0, 2)})` };
}
const subscriberNumber = national.slice(2);
if (national.length === 11 && subscriberNumber[0] !== '9') {
return {
original: raw,
normalized: null,
valid: false,
reason: 'Número de celular com 11 dígitos deve começar com 9',
};
}
return { original: raw, normalized: `+55${national}`, valid: true };
}
// Ponto único de entrada — troca de país/estratégia acontece só aqui.
export function normalizePhone(raw: string): PhoneNormalizationResult {
return normalizeBrazilianPhone(raw);
}

40
pnpm-lock.yaml generated
View File

@@ -25,6 +25,9 @@ importers:
'@fastify/helmet': '@fastify/helmet':
specifier: ^13.0.1 specifier: ^13.0.1
version: 13.1.1 version: 13.1.1
'@fastify/multipart':
specifier: ^9.0.3
version: 9.4.0
'@fastify/static': '@fastify/static':
specifier: ^8.0.4 specifier: ^8.0.4
version: 8.3.0 version: 8.3.0
@@ -64,6 +67,12 @@ importers:
class-validator: class-validator:
specifier: ^0.14.2 specifier: ^0.14.2
version: 0.14.4 version: 0.14.4
csv-parse:
specifier: ^5.6.0
version: 5.6.0
csv-stringify:
specifier: ^6.5.2
version: 6.8.3
fastify: fastify:
specifier: ^5.2.1 specifier: ^5.2.1
version: 5.11.3 version: 5.11.3
@@ -489,12 +498,18 @@ packages:
'@fastify/ajv-compiler@4.0.6': '@fastify/ajv-compiler@4.0.6':
resolution: {integrity: sha512-NtuzM0SfaMJbGlnjr9LWQUN5LzgSrbB8tf/wRZNas+4E1O/Nmzl53e7ruT61HDZyRCJGC6FxIogmNZO1c5ETBA==} resolution: {integrity: sha512-NtuzM0SfaMJbGlnjr9LWQUN5LzgSrbB8tf/wRZNas+4E1O/Nmzl53e7ruT61HDZyRCJGC6FxIogmNZO1c5ETBA==}
'@fastify/busboy@3.2.2':
resolution: {integrity: sha512-yXSS27qPExaXeuLvMRMXOLtpipzfQYNjG3FkunDWKGfMYjKuhFXko9CVzqxm8jcF+lmtS9Fd89QNdh9XDjnbNg==}
'@fastify/cookie@11.1.2': '@fastify/cookie@11.1.2':
resolution: {integrity: sha512-Dtrpk/YOGUsbRMvP/8ZqPpwnMRv0qSqodFdoQ2B589Obc7jw4s4Qla+cV72Bsm7WsZJnqlYFX/i7uSBq0xzg6g==} resolution: {integrity: sha512-Dtrpk/YOGUsbRMvP/8ZqPpwnMRv0qSqodFdoQ2B589Obc7jw4s4Qla+cV72Bsm7WsZJnqlYFX/i7uSBq0xzg6g==}
'@fastify/cors@11.3.0': '@fastify/cors@11.3.0':
resolution: {integrity: sha512-ggQGua+xHv1MvePbPr0v//xLYEsCXbWspquXCJS9Ot5YoRXq8J8ZWzHnxDBVnbtXosvistXo6LtNzOJswf64Fw==} resolution: {integrity: sha512-ggQGua+xHv1MvePbPr0v//xLYEsCXbWspquXCJS9Ot5YoRXq8J8ZWzHnxDBVnbtXosvistXo6LtNzOJswf64Fw==}
'@fastify/deepmerge@3.2.1':
resolution: {integrity: sha512-N5Oqvltoa2r9z1tbx4xjky0oRR60v+T47Ic4J1ukoVQcptLOrIdRnCSdTGmOmajZuHVKlTnfcmrjyqsGEW1ztA==}
'@fastify/error@4.2.0': '@fastify/error@4.2.0':
resolution: {integrity: sha512-RSo3sVDXfHskiBZKBPRgnQTtIqpi/7zhJOEmAxCiBcM7d0uwdGdxLlsCaLzGs8v8NnxIRlfG0N51p5yFaOentQ==} resolution: {integrity: sha512-RSo3sVDXfHskiBZKBPRgnQTtIqpi/7zhJOEmAxCiBcM7d0uwdGdxLlsCaLzGs8v8NnxIRlfG0N51p5yFaOentQ==}
@@ -513,6 +528,9 @@ packages:
'@fastify/merge-json-schemas@0.2.1': '@fastify/merge-json-schemas@0.2.1':
resolution: {integrity: sha512-OA3KGBCy6KtIvLf8DINC5880o5iBlDX4SxzLQS8HorJAbqluzLRn80UXU0bxZn7UOFhFgpRJDasfwn9nG4FG4A==} resolution: {integrity: sha512-OA3KGBCy6KtIvLf8DINC5880o5iBlDX4SxzLQS8HorJAbqluzLRn80UXU0bxZn7UOFhFgpRJDasfwn9nG4FG4A==}
'@fastify/multipart@9.4.0':
resolution: {integrity: sha512-Z404bzZeLSXTBmp/trCBuoVFX28pM7rhv849Q5TsbTFZHuk1lc4QjQITTPK92DKVpXmNtJXeHSSc7GYvqFpxAQ==}
'@fastify/proxy-addr@5.1.0': '@fastify/proxy-addr@5.1.0':
resolution: {integrity: sha512-INS+6gh91cLUjB+PVHfu1UqcB76Sqtpyp7bnL+FYojhjygvOPA9ctiD/JDKsyD9Xgu4hUhCSJBPig/w7duNajw==} resolution: {integrity: sha512-INS+6gh91cLUjB+PVHfu1UqcB76Sqtpyp7bnL+FYojhjygvOPA9ctiD/JDKsyD9Xgu4hUhCSJBPig/w7duNajw==}
@@ -1813,6 +1831,12 @@ packages:
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
engines: {node: '>= 8'} engines: {node: '>= 8'}
csv-parse@5.6.0:
resolution: {integrity: sha512-l3nz3euub2QMg5ouu5U09Ew9Wf6/wQ8I++ch1loQ0ljmzhmfZYrH9fflS22i/PQEvsPvxCwxgz5q7UB8K1JO4Q==}
csv-stringify@6.8.3:
resolution: {integrity: sha512-gIeSCvq5F4VtXV3naV3VAewLhBkiZBz+PPhTOA8H3Y8h/ELa+R1ml0GZck/4/Nzo9ep2lvOluilJ6MJlbZsKMA==}
debug@4.4.3: debug@4.4.3:
resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==}
engines: {node: '>=6.0'} engines: {node: '>=6.0'}
@@ -3904,6 +3928,8 @@ snapshots:
ajv-formats: 3.0.1(ajv@8.20.0) ajv-formats: 3.0.1(ajv@8.20.0)
fast-uri: 4.1.3 fast-uri: 4.1.3
'@fastify/busboy@3.2.2': {}
'@fastify/cookie@11.1.2': '@fastify/cookie@11.1.2':
dependencies: dependencies:
cookie: 2.0.1 cookie: 2.0.1
@@ -3914,6 +3940,8 @@ snapshots:
fastify-plugin: 6.0.0 fastify-plugin: 6.0.0
toad-cache: 3.7.4 toad-cache: 3.7.4
'@fastify/deepmerge@3.2.1': {}
'@fastify/error@4.2.0': {} '@fastify/error@4.2.0': {}
'@fastify/fast-json-stringify-compiler@5.1.0': '@fastify/fast-json-stringify-compiler@5.1.0':
@@ -3936,6 +3964,14 @@ snapshots:
dependencies: dependencies:
dequal: 2.0.3 dequal: 2.0.3
'@fastify/multipart@9.4.0':
dependencies:
'@fastify/busboy': 3.2.2
'@fastify/deepmerge': 3.2.1
'@fastify/error': 4.2.0
fastify-plugin: 5.1.0
secure-json-parse: 4.1.0
'@fastify/proxy-addr@5.1.0': '@fastify/proxy-addr@5.1.0':
dependencies: dependencies:
'@fastify/forwarded': 3.0.2 '@fastify/forwarded': 3.0.2
@@ -5354,6 +5390,10 @@ snapshots:
shebang-command: 2.0.0 shebang-command: 2.0.0
which: 2.0.2 which: 2.0.2
csv-parse@5.6.0: {}
csv-stringify@6.8.3: {}
debug@4.4.3(supports-color@8.1.1): debug@4.4.3(supports-color@8.1.1):
dependencies: dependencies:
ms: 2.1.3 ms: 2.1.3