From 63103d43355569d288f5df9681e50744431f077d Mon Sep 17 00:00:00 2001 From: B2BCall Bootstrap Date: Thu, 27 Aug 2026 13:38:19 -0300 Subject: [PATCH] feat: add call center queues MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - packages/database: Agent, AgentSession, AgentStateEvent, AgentPauseEvent, PauseReason, Queue, QueueMember (agente.md secao 53) - apps/api/src/queues: CRUD de filas gerando queues.conf pelo mesmo padrao do dialplan (arquivo compartilhado + module reload app_queue.so), mas SEM membros estaticos no arquivo — membership eh 100% dinamica via AMI (evita duas fontes de verdade conflitantes) - apps/api/src/pause-reasons: CRUD de motivos de pausa - apps/api/src/agents: CRUD administrativo de agentes (1:1 com User) - apps/api/src/agent-console: maquina de estados do agente (LOGGED_IN/AVAILABLE/PAUSED), 'tela do agente' via login/available/pause/unpause/logout, cada transicao aciona AMI QueueAdd/QueuePause/QueueRemove de verdade e registra agent_state_events/agent_pause_events com inicio/fim - apps/api/src/monitoring: GET /api/monitoring/queues (chamadas aguardando, agentes logados/pausados/disponiveis via AMI QueueStatus ao vivo) — metricas historicas (TME/TMA/SLA) ficam para a Fase 7 Testado ponta a ponta contra o Asterisk real: fila criada aparece via 'queue show'; agente loga, fica disponivel (QueueAdd confirmado, membro dinamico visivel), pausa com motivo (confirmado 'paused:Almoco' ao vivo), despausa, desloga (QueueRemove confirmado, fila volta a 'No Members'). Disposicoes de chamada e Callback adiados para a Fase 6 (dependem de haver chamadas de campanha reais para classificar). --- TODO.md | 36 ++- .../agent-console/agent-console.controller.ts | 77 +++++ .../src/agent-console/agent-console.module.ts | 9 + .../agent-console/agent-console.service.ts | 262 ++++++++++++++++++ .../src/agent-console/dto/agent-login.dto.ts | 7 + .../src/agent-console/dto/agent-pause.dto.ts | 6 + apps/api/src/agents/agents.controller.ts | 75 +++++ apps/api/src/agents/agents.module.ts | 10 + apps/api/src/agents/agents.service.ts | 119 ++++++++ apps/api/src/agents/dto/create-agent.dto.ts | 25 ++ apps/api/src/agents/dto/update-agent.dto.ts | 6 + apps/api/src/app.module.ts | 8 + .../src/monitoring/monitoring.controller.ts | 47 +++- .../dto/create-pause-reason.dto.ts | 36 +++ .../dto/update-pause-reason.dto.ts | 6 + .../pause-reasons/pause-reasons.controller.ts | 71 +++++ .../src/pause-reasons/pause-reasons.module.ts | 10 + .../pause-reasons/pause-reasons.service.ts | 96 +++++++ .../src/queues/dto/add-queue-member.dto.ts | 12 + apps/api/src/queues/dto/create-queue.dto.ts | 83 ++++++ apps/api/src/queues/dto/update-queue.dto.ts | 6 + apps/api/src/queues/queue-generator.ts | 35 +++ apps/api/src/queues/queues.controller.ts | 104 +++++++ apps/api/src/queues/queues.module.ts | 9 + apps/api/src/queues/queues.service.ts | 178 ++++++++++++ infrastructure/asterisk/config/queues.conf | 8 +- infrastructure/docker/asterisk-entrypoint.sh | 9 +- .../migration.sql | 149 ++++++++++ packages/database/prisma/schema.prisma | 143 ++++++++++ 29 files changed, 1627 insertions(+), 15 deletions(-) create mode 100644 apps/api/src/agent-console/agent-console.controller.ts create mode 100644 apps/api/src/agent-console/agent-console.module.ts create mode 100644 apps/api/src/agent-console/agent-console.service.ts create mode 100644 apps/api/src/agent-console/dto/agent-login.dto.ts create mode 100644 apps/api/src/agent-console/dto/agent-pause.dto.ts create mode 100644 apps/api/src/agents/agents.controller.ts create mode 100644 apps/api/src/agents/agents.module.ts create mode 100644 apps/api/src/agents/agents.service.ts create mode 100644 apps/api/src/agents/dto/create-agent.dto.ts create mode 100644 apps/api/src/agents/dto/update-agent.dto.ts create mode 100644 apps/api/src/pause-reasons/dto/create-pause-reason.dto.ts create mode 100644 apps/api/src/pause-reasons/dto/update-pause-reason.dto.ts create mode 100644 apps/api/src/pause-reasons/pause-reasons.controller.ts create mode 100644 apps/api/src/pause-reasons/pause-reasons.module.ts create mode 100644 apps/api/src/pause-reasons/pause-reasons.service.ts create mode 100644 apps/api/src/queues/dto/add-queue-member.dto.ts create mode 100644 apps/api/src/queues/dto/create-queue.dto.ts create mode 100644 apps/api/src/queues/dto/update-queue.dto.ts create mode 100644 apps/api/src/queues/queue-generator.ts create mode 100644 apps/api/src/queues/queues.controller.ts create mode 100644 apps/api/src/queues/queues.module.ts create mode 100644 apps/api/src/queues/queues.service.ts create mode 100644 packages/database/prisma/migrations/20260827161848_add_call_center/migration.sql diff --git a/TODO.md b/TODO.md index f9ce0c8..04138c4 100644 --- a/TODO.md +++ b/TODO.md @@ -137,14 +137,34 @@ mestre original (`agente.md`, seções 90-93). status/diagnóstico, não tem tela de edição de asterisk.conf/rtp.conf ## Fase 5 — Call Center -- [ ] Agentes (agents, agent_sessions) separados de users -- [ ] Máquina de estados do agente (OFFLINE..PAUSED) -- [ ] Motivos de pausa (CRUD) -- [ ] Filas (CRUD + estratégias documentadas) -- [ ] Tela do agente (login dinâmico, pausa/retomada, disposição) -- [ ] Disposições de chamada (CRUD + ações: callback, DNC) -- [ ] Callback (agendamento + scheduler) -- [ ] Monitoramento de Filas (tempo real) +- [x] Agentes (agents, separado de users 1:1) + agent_sessions + + agent_state_events + agent_pause_events — schema Prisma completo +- [x] Máquina de estados do agente (LOGGED_IN→AVAILABLE→PAUSED→AVAILABLE, + logout) com histórico início/fim por estado — testado ponta a ponta + contra o Asterisk real: login cria sessão, "disponível" faz AMI + QueueAdd (confirmado via `queue show`: membro dinâmico aparece), + "pausar" faz QueuePause com motivo (confirmado: "paused:Almoco" + aparece ao vivo), "despausar" reverte, "logout" faz QueueRemove + (confirmado: fila volta a "No Members") +- [x] Motivos de pausa (CRUD) — sem permissão dedicada no catálogo da + seção 11, tratado sob settings.manage +- [x] Filas (CRUD + 8 estratégias do enum QueueStrategy) — gera + queues.conf via o mesmo padrão do dialplan (arquivo compartilhado + + reload), mas SEM membros estáticos: membership é 100% dinâmica via + AMI (login/logout do agente), evitando dessincronia entre duas + fontes de verdade +- [x] Tela do agente (backend completo: login/available/pause/unpause/ + logout via /api/agent-console/*) — disposição de chamada fica para + quando existir uma chamada de verdade para dispor (Fase 6) +- [ ] Disposições de chamada (CRUD + ações: callback, DNC) — adiado para + Fase 6 junto com Campanhas/Leads, já que disposição só faz sentido + quando há chamadas de campanha reais para classificar +- [ ] Callback (agendamento + scheduler) — idem, depende de leads/campanhas +- [x] Monitoramento de Filas (tempo real): GET /api/monitoring/queues + (chamadas aguardando, maior espera, agentes logados/pausados/ + disponíveis) via AMI QueueStatus ao vivo. TME/TMA/SLA/taxa de + abandono HISTÓRICOS ficam para a Fase 7 (dependem de consolidação + 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) diff --git a/apps/api/src/agent-console/agent-console.controller.ts b/apps/api/src/agent-console/agent-console.controller.ts new file mode 100644 index 0000000..aa6436a --- /dev/null +++ b/apps/api/src/agent-console/agent-console.controller.ts @@ -0,0 +1,77 @@ +import { Body, Controller, Get, Post, Req } from '@nestjs/common'; +import type { FastifyRequest } from 'fastify'; +import { CurrentUser } from '../common/decorators/current-user.decorator'; +import type { AuthenticatedUser } from '../common/guards/auth.guard'; +import { AgentConsoleService } from './agent-console.service'; +import { AgentLoginDto } from './dto/agent-login.dto'; +import { AgentPauseDto } from './dto/agent-pause.dto'; + +// Sem @RequirePermissions dedicada: qualquer usuário autenticado com um +// Agent associado pode operar sua própria tela de agente (agente.md seção +// 17). O acesso real é reforçado dentro do service (getAgentForUserOrThrow). +@Controller('agent-console') +export class AgentConsoleController { + constructor(private readonly agentConsoleService: AgentConsoleService) {} + + @Get('me') + me(@CurrentUser() user: AuthenticatedUser) { + return this.agentConsoleService.me(user.id); + } + + @Post('login') + login( + @Body() dto: AgentLoginDto, + @CurrentUser() user: AuthenticatedUser, + @Req() request: FastifyRequest, + ) { + return this.agentConsoleService.login(user.id, dto, { + ip: request.ip, + userAgent: request.headers['user-agent'], + }); + } + + @Post('available') + available( + @CurrentUser() user: AuthenticatedUser, + @Req() request: FastifyRequest, + ) { + return this.agentConsoleService.setAvailable(user.id, { + ip: request.ip, + userAgent: request.headers['user-agent'], + }); + } + + @Post('pause') + pause( + @Body() dto: AgentPauseDto, + @CurrentUser() user: AuthenticatedUser, + @Req() request: FastifyRequest, + ) { + return this.agentConsoleService.pause(user.id, dto, { + ip: request.ip, + userAgent: request.headers['user-agent'], + }); + } + + @Post('unpause') + unpause( + @CurrentUser() user: AuthenticatedUser, + @Req() request: FastifyRequest, + ) { + return this.agentConsoleService.unpause(user.id, { + ip: request.ip, + userAgent: request.headers['user-agent'], + }); + } + + @Post('logout') + logout( + @CurrentUser() user: AuthenticatedUser, + @Req() request: FastifyRequest, + ) { + return this.agentConsoleService.logout(user.id, { + ip: request.ip, + userAgent: request.headers['user-agent'], + }); + } +} diff --git a/apps/api/src/agent-console/agent-console.module.ts b/apps/api/src/agent-console/agent-console.module.ts new file mode 100644 index 0000000..88d39fb --- /dev/null +++ b/apps/api/src/agent-console/agent-console.module.ts @@ -0,0 +1,9 @@ +import { Module } from '@nestjs/common'; +import { AgentConsoleController } from './agent-console.controller'; +import { AgentConsoleService } from './agent-console.service'; + +@Module({ + controllers: [AgentConsoleController], + providers: [AgentConsoleService], +}) +export class AgentConsoleModule {} diff --git a/apps/api/src/agent-console/agent-console.service.ts b/apps/api/src/agent-console/agent-console.service.ts new file mode 100644 index 0000000..c00b9a3 --- /dev/null +++ b/apps/api/src/agent-console/agent-console.service.ts @@ -0,0 +1,262 @@ +import { + BadRequestException, + ForbiddenException, + Inject, + Injectable, +} from '@nestjs/common'; +import { AgentState } from '@b2bcall/database'; +import type { TelephonyProvider } from '@b2bcall/telephony'; +import { PrismaService } from '../prisma/prisma.service'; +import { AuditService } from '../audit/audit.service'; +import type { RequestContext } from '../auth/auth.service'; +import { TELEPHONY_PROVIDER } from '../telephony/telephony.module'; +import { AgentLoginDto } from './dto/agent-login.dto'; +import { AgentPauseDto } from './dto/agent-pause.dto'; + +function interfaceFor(extension: string): string { + return `PJSIP/${extension}`; +} + +@Injectable() +export class AgentConsoleService { + constructor( + private readonly prisma: PrismaService, + private readonly audit: AuditService, + @Inject(TELEPHONY_PROVIDER) private readonly telephony: TelephonyProvider, + ) {} + + private async getAgentForUserOrThrow(userId: string) { + const agent = await this.prisma.agent.findUnique({ + where: { userId }, + include: { queues: { include: { queue: true } } }, + }); + if (!agent) + throw new ForbiddenException( + 'Seu usuário não possui um agente de Call Center associado.', + ); + if (!agent.active) throw new ForbiddenException('Agente inativo.'); + return agent; + } + + async me(userId: string) { + const agent = await this.getAgentForUserOrThrow(userId); + const currentState = await this.prisma.agentStateEvent.findFirst({ + where: { agentId: agent.id, endedAt: null }, + orderBy: { startedAt: 'desc' }, + }); + const currentPause = await this.prisma.agentPauseEvent.findFirst({ + where: { agentId: agent.id, endedAt: null }, + include: { pauseReason: true }, + orderBy: { startedAt: 'desc' }, + }); + return { + agent, + state: currentState?.state ?? AgentState.OFFLINE, + stateSince: currentState?.startedAt ?? null, + currentPause, + }; + } + + // Fecha o estado aberto (se houver) e abre um novo — nunca há dois + // registros abertos simultaneamente para o mesmo agente (agente.md + // seção 48: "registrar início/fim de cada estado"). + private async transition(agentId: string, newState: AgentState) { + await this.prisma.$transaction([ + this.prisma.agentStateEvent.updateMany({ + where: { agentId, endedAt: null }, + data: { endedAt: new Date() }, + }), + this.prisma.agentStateEvent.create({ + data: { agentId, state: newState }, + }), + ]); + } + + async login(userId: string, dto: AgentLoginDto, ctx: RequestContext) { + const agent = await this.getAgentForUserOrThrow(userId); + + const extension = await this.prisma.extension.findUnique({ + where: { number: dto.extension }, + }); + if (!extension || !extension.enabled) + throw new BadRequestException('Ramal inválido ou desativado.'); + + await this.prisma.agent.update({ + where: { id: agent.id }, + data: { currentExtension: dto.extension }, + }); + await this.prisma.agentSession.create({ + data: { agentId: agent.id, extension: dto.extension }, + }); + await this.transition(agent.id, AgentState.LOGGED_IN); + + await this.audit.log({ + userId, + action: 'agent_login', + entityType: 'agent', + entityId: agent.id, + after: { extension: dto.extension }, + ipAddress: ctx.ip, + userAgent: ctx.userAgent, + }); + + return this.me(userId); + } + + async setAvailable(userId: string, ctx: RequestContext) { + const agent = await this.getAgentForUserOrThrow(userId); + if (!agent.currentExtension) + throw new BadRequestException( + 'Faça login em um ramal antes de ficar disponível.', + ); + + const iface = interfaceFor(agent.currentExtension); + for (const membership of agent.queues) { + try { + await this.telephony.queueAdd(membership.queue.name, iface, { + penalty: membership.penalty, + memberName: agent.name, + }); + } catch { + // Já pode ser membro (ex.: reconexão) — QueueAdd falha nesse caso, + // o que é inofensivo para o fluxo de disponibilidade. + } + } + + await this.transition(agent.id, AgentState.AVAILABLE); + + await this.audit.log({ + userId, + action: 'agent_available', + entityType: 'agent', + entityId: agent.id, + ipAddress: ctx.ip, + userAgent: ctx.userAgent, + }); + + return this.me(userId); + } + + async pause(userId: string, dto: AgentPauseDto, ctx: RequestContext) { + const agent = await this.getAgentForUserOrThrow(userId); + if (!agent.currentExtension) + throw new BadRequestException('Agente não está logado em um ramal.'); + + const reason = await this.prisma.pauseReason.findUnique({ + where: { id: dto.pauseReasonId }, + }); + if (!reason || !reason.active) + throw new BadRequestException('Motivo de pausa inválido.'); + + const iface = interfaceFor(agent.currentExtension); + await this.telephony.queuePause({ + interface: iface, + paused: true, + reason: reason.name, + }); + + await this.prisma.$transaction([ + this.prisma.agentStateEvent.updateMany({ + where: { agentId: agent.id, endedAt: null }, + data: { endedAt: new Date() }, + }), + this.prisma.agentStateEvent.create({ + data: { agentId: agent.id, state: AgentState.PAUSED }, + }), + this.prisma.agentPauseEvent.create({ + data: { agentId: agent.id, pauseReasonId: reason.id }, + }), + ]); + + await this.audit.log({ + userId, + action: 'agent_paused', + entityType: 'agent', + entityId: agent.id, + after: { reason: reason.name }, + ipAddress: ctx.ip, + userAgent: ctx.userAgent, + }); + + return this.me(userId); + } + + async unpause(userId: string, ctx: RequestContext) { + const agent = await this.getAgentForUserOrThrow(userId); + if (!agent.currentExtension) + throw new BadRequestException('Agente não está logado em um ramal.'); + + const iface = interfaceFor(agent.currentExtension); + await this.telephony.queuePause({ interface: iface, paused: false }); + + await this.prisma.$transaction([ + this.prisma.agentStateEvent.updateMany({ + where: { agentId: agent.id, endedAt: null }, + data: { endedAt: new Date() }, + }), + this.prisma.agentStateEvent.create({ + data: { agentId: agent.id, state: AgentState.AVAILABLE }, + }), + this.prisma.agentPauseEvent.updateMany({ + where: { agentId: agent.id, endedAt: null }, + data: { endedAt: new Date() }, + }), + ]); + + await this.audit.log({ + userId, + action: 'agent_unpaused', + entityType: 'agent', + entityId: agent.id, + ipAddress: ctx.ip, + userAgent: ctx.userAgent, + }); + + return this.me(userId); + } + + async logout(userId: string, ctx: RequestContext) { + const agent = await this.getAgentForUserOrThrow(userId); + + if (agent.currentExtension) { + const iface = interfaceFor(agent.currentExtension); + for (const membership of agent.queues) { + try { + await this.telephony.queueRemove(membership.queue.name, iface); + } catch { + // Pode já não ser membro — inofensivo. + } + } + } + + await this.prisma.$transaction([ + this.prisma.agentSession.updateMany({ + where: { agentId: agent.id, endedAt: null }, + data: { endedAt: new Date() }, + }), + this.prisma.agentStateEvent.updateMany({ + where: { agentId: agent.id, endedAt: null }, + data: { endedAt: new Date() }, + }), + this.prisma.agentPauseEvent.updateMany({ + where: { agentId: agent.id, endedAt: null }, + data: { endedAt: new Date() }, + }), + ]); + await this.prisma.agent.update({ + where: { id: agent.id }, + data: { currentExtension: null }, + }); + + await this.audit.log({ + userId, + action: 'agent_logout', + entityType: 'agent', + entityId: agent.id, + ipAddress: ctx.ip, + userAgent: ctx.userAgent, + }); + + return { ok: true }; + } +} diff --git a/apps/api/src/agent-console/dto/agent-login.dto.ts b/apps/api/src/agent-console/dto/agent-login.dto.ts new file mode 100644 index 0000000..49db310 --- /dev/null +++ b/apps/api/src/agent-console/dto/agent-login.dto.ts @@ -0,0 +1,7 @@ +import { IsString, Matches } from 'class-validator'; + +export class AgentLoginDto { + @IsString() + @Matches(/^\d{2,10}$/, { message: 'Ramal inválido.' }) + extension!: string; +} diff --git a/apps/api/src/agent-console/dto/agent-pause.dto.ts b/apps/api/src/agent-console/dto/agent-pause.dto.ts new file mode 100644 index 0000000..97fda99 --- /dev/null +++ b/apps/api/src/agent-console/dto/agent-pause.dto.ts @@ -0,0 +1,6 @@ +import { IsUUID } from 'class-validator'; + +export class AgentPauseDto { + @IsUUID('4') + pauseReasonId!: string; +} diff --git a/apps/api/src/agents/agents.controller.ts b/apps/api/src/agents/agents.controller.ts new file mode 100644 index 0000000..9cc266f --- /dev/null +++ b/apps/api/src/agents/agents.controller.ts @@ -0,0 +1,75 @@ +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 { AgentsService } from './agents.service'; +import { CreateAgentDto } from './dto/create-agent.dto'; +import { UpdateAgentDto } from './dto/update-agent.dto'; + +@Controller('agents') +export class AgentsController { + constructor(private readonly agentsService: AgentsService) {} + + @Get() + @RequirePermissions('agents.view') + list() { + return this.agentsService.list(); + } + + @Get(':id') + @RequirePermissions('agents.view') + findOne(@Param('id', ParseUUIDPipe) id: string) { + return this.agentsService.findByIdOrThrow(id); + } + + @Post() + @RequirePermissions('agents.create') + create( + @Body() dto: CreateAgentDto, + @CurrentUser() actor: AuthenticatedUser, + @Req() request: FastifyRequest, + ) { + return this.agentsService.create(dto, actor, { + ip: request.ip, + userAgent: request.headers['user-agent'], + }); + } + + @Patch(':id') + @RequirePermissions('agents.update') + update( + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: UpdateAgentDto, + @CurrentUser() actor: AuthenticatedUser, + @Req() request: FastifyRequest, + ) { + return this.agentsService.update(id, dto, actor, { + ip: request.ip, + userAgent: request.headers['user-agent'], + }); + } + + @Delete(':id') + @RequirePermissions('agents.delete') + remove( + @Param('id', ParseUUIDPipe) id: string, + @CurrentUser() actor: AuthenticatedUser, + @Req() request: FastifyRequest, + ) { + return this.agentsService.delete(id, actor, { + ip: request.ip, + userAgent: request.headers['user-agent'], + }); + } +} diff --git a/apps/api/src/agents/agents.module.ts b/apps/api/src/agents/agents.module.ts new file mode 100644 index 0000000..b67a869 --- /dev/null +++ b/apps/api/src/agents/agents.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { AgentsController } from './agents.controller'; +import { AgentsService } from './agents.service'; + +@Module({ + controllers: [AgentsController], + providers: [AgentsService], + exports: [AgentsService], +}) +export class AgentsModule {} diff --git a/apps/api/src/agents/agents.service.ts b/apps/api/src/agents/agents.service.ts new file mode 100644 index 0000000..7f94db3 --- /dev/null +++ b/apps/api/src/agents/agents.service.ts @@ -0,0 +1,119 @@ +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 { CreateAgentDto } from './dto/create-agent.dto'; +import { UpdateAgentDto } from './dto/update-agent.dto'; + +@Injectable() +export class AgentsService { + constructor( + private readonly prisma: PrismaService, + private readonly audit: AuditService, + ) {} + + list() { + return this.prisma.agent.findMany({ + orderBy: { code: 'asc' }, + include: { + user: { select: { id: true, name: true, email: true } }, + queues: { include: { queue: true } }, + }, + }); + } + + async findByIdOrThrow(id: string) { + const agent = await this.prisma.agent.findUnique({ + where: { id }, + include: { + user: { select: { id: true, name: true, email: true } }, + queues: { include: { queue: true } }, + }, + }); + if (!agent) throw new NotFoundException('Agente não encontrado.'); + return agent; + } + + async findByUserId(userId: string) { + return this.prisma.agent.findUnique({ + where: { userId }, + include: { queues: { include: { queue: true } } }, + }); + } + + async create( + dto: CreateAgentDto, + actor: { id: string }, + ctx: RequestContext, + ) { + const [existingCode, existingUser, user] = await Promise.all([ + this.prisma.agent.findUnique({ where: { code: dto.code } }), + this.prisma.agent.findUnique({ where: { userId: dto.userId } }), + this.prisma.user.findUnique({ where: { id: dto.userId } }), + ]); + if (existingCode) + throw new BadRequestException('Já existe um agente com este código.'); + if (existingUser) + throw new BadRequestException( + 'Este usuário já possui um agente associado.', + ); + if (!user) throw new BadRequestException('Usuário informado não existe.'); + + const agent = await this.prisma.agent.create({ data: dto }); + + await this.audit.log({ + userId: actor.id, + action: 'agent_created', + entityType: 'agent', + entityId: agent.id, + after: { ...dto }, + ipAddress: ctx.ip, + userAgent: ctx.userAgent, + }); + return agent; + } + + async update( + id: string, + dto: UpdateAgentDto, + actor: { id: string }, + ctx: RequestContext, + ) { + const before = await this.prisma.agent.findUnique({ where: { id } }); + if (!before) throw new NotFoundException('Agente não encontrado.'); + + const agent = await this.prisma.agent.update({ where: { id }, data: dto }); + + await this.audit.log({ + userId: actor.id, + action: 'agent_updated', + entityType: 'agent', + entityId: id, + before, + after: { ...dto }, + ipAddress: ctx.ip, + userAgent: ctx.userAgent, + }); + return agent; + } + + async delete(id: string, actor: { id: string }, ctx: RequestContext) { + const agent = await this.prisma.agent.findUnique({ where: { id } }); + if (!agent) throw new NotFoundException('Agente não encontrado.'); + + await this.prisma.agent.delete({ where: { id } }); + await this.audit.log({ + userId: actor.id, + action: 'agent_deleted', + entityType: 'agent', + entityId: id, + before: agent, + ipAddress: ctx.ip, + userAgent: ctx.userAgent, + }); + } +} diff --git a/apps/api/src/agents/dto/create-agent.dto.ts b/apps/api/src/agents/dto/create-agent.dto.ts new file mode 100644 index 0000000..d817cf3 --- /dev/null +++ b/apps/api/src/agents/dto/create-agent.dto.ts @@ -0,0 +1,25 @@ +import { + IsBoolean, + IsOptional, + IsString, + IsUUID, + Matches, + MinLength, +} from 'class-validator'; + +export class CreateAgentDto { + @IsString() + @Matches(/^[a-zA-Z0-9_-]{1,20}$/) + code!: string; + + @IsString() + @MinLength(1) + name!: string; + + @IsUUID('4') + userId!: string; + + @IsOptional() + @IsBoolean() + active?: boolean; +} diff --git a/apps/api/src/agents/dto/update-agent.dto.ts b/apps/api/src/agents/dto/update-agent.dto.ts new file mode 100644 index 0000000..658f993 --- /dev/null +++ b/apps/api/src/agents/dto/update-agent.dto.ts @@ -0,0 +1,6 @@ +import { PartialType, OmitType } from '@nestjs/mapped-types'; +import { CreateAgentDto } from './create-agent.dto'; + +export class UpdateAgentDto extends PartialType( + OmitType(CreateAgentDto, ['code', 'userId'] as const), +) {} diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index 126a6f1..f3fb689 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -19,6 +19,10 @@ import { ExtensionsModule } from './extensions/extensions.module'; import { MonitoringModule } from './monitoring/monitoring.module'; import { DialplanModule } from './dialplan/dialplan.module'; import { AsteriskAdminModule } from './asterisk-admin/asterisk-admin.module'; +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 { AuthGuard } from './common/guards/auth.guard'; import { PermissionsGuard } from './common/guards/permissions.guard'; import { GlobalExceptionFilter } from './common/filters/global-exception.filter'; @@ -64,6 +68,10 @@ import { GlobalExceptionFilter } from './common/filters/global-exception.filter' MonitoringModule, DialplanModule, AsteriskAdminModule, + PauseReasonsModule, + QueuesModule, + AgentsModule, + AgentConsoleModule, ], controllers: [AppController], providers: [ diff --git a/apps/api/src/monitoring/monitoring.controller.ts b/apps/api/src/monitoring/monitoring.controller.ts index 0bae96e..889662e 100644 --- a/apps/api/src/monitoring/monitoring.controller.ts +++ b/apps/api/src/monitoring/monitoring.controller.ts @@ -1,11 +1,16 @@ -import { Controller, Get } from '@nestjs/common'; +import { Controller, Get, Inject } from '@nestjs/common'; +import type { TelephonyProvider } from '@b2bcall/telephony'; import { RequirePermissions } from '../common/decorators/permissions.decorator'; import { PrismaService } from '../prisma/prisma.service'; +import { TELEPHONY_PROVIDER } from '../telephony/telephony.module'; import { computeExtensionVisualStatus } from './extension-status.util'; @Controller('monitoring') export class MonitoringController { - constructor(private readonly prisma: PrismaService) {} + constructor( + private readonly prisma: PrismaService, + @Inject(TELEPHONY_PROVIDER) private readonly telephony: TelephonyProvider, + ) {} @Get('extensions') @RequirePermissions('monitoring.view') @@ -34,4 +39,42 @@ export class MonitoringController { }; }); } + + // Métricas em tempo real (chamadas aguardando, agentes logados/pausados). + // TME/TMA/taxa de abandono históricos exigem consolidação de CDR/CEL/ + // queue_log (agente.md seção 20) — chegam na Fase 7, quando esse pipeline + // existir. Não inventamos número aqui (seção 72: nunca dado fake). + @Get('queues') + @RequirePermissions('monitoring.view') + async queues() { + const queues = await this.prisma.queue.findMany({ + where: { enabled: true }, + orderBy: { name: 'asc' }, + }); + const liveStatuses = this.telephony.isConnected() + ? await this.telephony.queueStatus() + : []; + const liveByName = new Map(liveStatuses.map((s) => [s.queue, s])); + + return queues.map((queue) => { + const live = liveByName.get(queue.name); + const members = live?.members ?? []; + const entries = live?.entries ?? []; + const waitTimes = entries + .map((e) => Number(e.Wait ?? 0)) + .filter((n) => !Number.isNaN(n)); + + return { + id: queue.id, + name: queue.name, + number: queue.number, + strategy: queue.strategy, + callsWaiting: entries.length, + longestWaitSeconds: waitTimes.length > 0 ? Math.max(...waitTimes) : 0, + agentsLoggedIn: members.length, + agentsPaused: members.filter((m) => m.Paused === '1').length, + agentsAvailable: members.filter((m) => m.Paused === '0').length, + }; + }); + } } diff --git a/apps/api/src/pause-reasons/dto/create-pause-reason.dto.ts b/apps/api/src/pause-reasons/dto/create-pause-reason.dto.ts new file mode 100644 index 0000000..8f02f2a --- /dev/null +++ b/apps/api/src/pause-reasons/dto/create-pause-reason.dto.ts @@ -0,0 +1,36 @@ +import { + IsBoolean, + IsInt, + IsOptional, + IsString, + Matches, + Min, + MinLength, +} from 'class-validator'; + +export class CreatePauseReasonDto { + @IsString() + @MinLength(1) + name!: string; + + @IsString() + @Matches(/^[a-zA-Z0-9_-]{1,40}$/) + code!: string; + + @IsOptional() + @IsString() + description?: string; + + @IsOptional() + @IsInt() + @Min(1) + maxDurationSeconds?: number; + + @IsOptional() + @IsBoolean() + paid?: boolean; + + @IsOptional() + @IsBoolean() + active?: boolean; +} diff --git a/apps/api/src/pause-reasons/dto/update-pause-reason.dto.ts b/apps/api/src/pause-reasons/dto/update-pause-reason.dto.ts new file mode 100644 index 0000000..6a38860 --- /dev/null +++ b/apps/api/src/pause-reasons/dto/update-pause-reason.dto.ts @@ -0,0 +1,6 @@ +import { PartialType, OmitType } from '@nestjs/mapped-types'; +import { CreatePauseReasonDto } from './create-pause-reason.dto'; + +export class UpdatePauseReasonDto extends PartialType( + OmitType(CreatePauseReasonDto, ['code'] as const), +) {} diff --git a/apps/api/src/pause-reasons/pause-reasons.controller.ts b/apps/api/src/pause-reasons/pause-reasons.controller.ts new file mode 100644 index 0000000..7a3832f --- /dev/null +++ b/apps/api/src/pause-reasons/pause-reasons.controller.ts @@ -0,0 +1,71 @@ +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 { PauseReasonsService } from './pause-reasons.service'; +import { CreatePauseReasonDto } from './dto/create-pause-reason.dto'; +import { UpdatePauseReasonDto } from './dto/update-pause-reason.dto'; + +// Não há permissão dedicada a motivos de pausa no catálogo (agente.md +// seção 11) — tratado como configuração geral do sistema (settings.manage). +@Controller('pause-reasons') +export class PauseReasonsController { + constructor(private readonly pauseReasonsService: PauseReasonsService) {} + + @Get() + @RequirePermissions('agents.view') + list() { + return this.pauseReasonsService.list(); + } + + @Post() + @RequirePermissions('settings.manage') + create( + @Body() dto: CreatePauseReasonDto, + @CurrentUser() actor: AuthenticatedUser, + @Req() request: FastifyRequest, + ) { + return this.pauseReasonsService.create(dto, actor, { + ip: request.ip, + userAgent: request.headers['user-agent'], + }); + } + + @Patch(':id') + @RequirePermissions('settings.manage') + update( + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: UpdatePauseReasonDto, + @CurrentUser() actor: AuthenticatedUser, + @Req() request: FastifyRequest, + ) { + return this.pauseReasonsService.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.pauseReasonsService.delete(id, actor, { + ip: request.ip, + userAgent: request.headers['user-agent'], + }); + } +} diff --git a/apps/api/src/pause-reasons/pause-reasons.module.ts b/apps/api/src/pause-reasons/pause-reasons.module.ts new file mode 100644 index 0000000..923c409 --- /dev/null +++ b/apps/api/src/pause-reasons/pause-reasons.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { PauseReasonsController } from './pause-reasons.controller'; +import { PauseReasonsService } from './pause-reasons.service'; + +@Module({ + controllers: [PauseReasonsController], + providers: [PauseReasonsService], + exports: [PauseReasonsService], +}) +export class PauseReasonsModule {} diff --git a/apps/api/src/pause-reasons/pause-reasons.service.ts b/apps/api/src/pause-reasons/pause-reasons.service.ts new file mode 100644 index 0000000..31fa047 --- /dev/null +++ b/apps/api/src/pause-reasons/pause-reasons.service.ts @@ -0,0 +1,96 @@ +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 { CreatePauseReasonDto } from './dto/create-pause-reason.dto'; +import { UpdatePauseReasonDto } from './dto/update-pause-reason.dto'; + +@Injectable() +export class PauseReasonsService { + constructor( + private readonly prisma: PrismaService, + private readonly audit: AuditService, + ) {} + + list() { + return this.prisma.pauseReason.findMany({ orderBy: { name: 'asc' } }); + } + + async create( + dto: CreatePauseReasonDto, + actor: { id: string }, + ctx: RequestContext, + ) { + const existing = await this.prisma.pauseReason.findUnique({ + where: { code: dto.code }, + }); + if (existing) + throw new BadRequestException( + 'Já existe um motivo de pausa com este código.', + ); + + const reason = await this.prisma.pauseReason.create({ data: dto }); + await this.audit.log({ + userId: actor.id, + action: 'pause_reason_created', + entityType: 'pause_reason', + entityId: reason.id, + after: { ...dto }, + ipAddress: ctx.ip, + userAgent: ctx.userAgent, + }); + return reason; + } + + async update( + id: string, + dto: UpdatePauseReasonDto, + actor: { id: string }, + ctx: RequestContext, + ) { + const before = await this.prisma.pauseReason.findUnique({ where: { id } }); + if (!before) throw new NotFoundException('Motivo de pausa não encontrado.'); + + const reason = await this.prisma.pauseReason.update({ + where: { id }, + data: dto, + }); + await this.audit.log({ + userId: actor.id, + action: 'pause_reason_updated', + entityType: 'pause_reason', + entityId: id, + before, + after: { ...dto }, + ipAddress: ctx.ip, + userAgent: ctx.userAgent, + }); + return reason; + } + + async delete(id: string, actor: { id: string }, ctx: RequestContext) { + const reason = await this.prisma.pauseReason.findUnique({ where: { id } }); + if (!reason) throw new NotFoundException('Motivo de pausa não encontrado.'); + + try { + await this.prisma.pauseReason.delete({ where: { id } }); + } catch { + throw new BadRequestException( + 'Este motivo já foi usado em pausas registradas — desative-o em vez de excluir.', + ); + } + await this.audit.log({ + userId: actor.id, + action: 'pause_reason_deleted', + entityType: 'pause_reason', + entityId: id, + before: reason, + ipAddress: ctx.ip, + userAgent: ctx.userAgent, + }); + } +} diff --git a/apps/api/src/queues/dto/add-queue-member.dto.ts b/apps/api/src/queues/dto/add-queue-member.dto.ts new file mode 100644 index 0000000..f3e9090 --- /dev/null +++ b/apps/api/src/queues/dto/add-queue-member.dto.ts @@ -0,0 +1,12 @@ +import { IsInt, IsOptional, IsUUID, Max, Min } from 'class-validator'; + +export class AddQueueMemberDto { + @IsUUID('4') + agentId!: string; + + @IsOptional() + @IsInt() + @Min(0) + @Max(10) + penalty?: number; +} diff --git a/apps/api/src/queues/dto/create-queue.dto.ts b/apps/api/src/queues/dto/create-queue.dto.ts new file mode 100644 index 0000000..012ce21 --- /dev/null +++ b/apps/api/src/queues/dto/create-queue.dto.ts @@ -0,0 +1,83 @@ +import { + IsBoolean, + IsEnum, + IsInt, + IsOptional, + IsString, + Matches, + Max, + MaxLength, + Min, + MinLength, +} from 'class-validator'; +import { QueueStrategy } from '@b2bcall/database'; + +export class CreateQueueDto { + @IsString() + @MinLength(1) + @MaxLength(80) + @Matches(/^[a-zA-Z0-9_-]+$/, { + message: 'Use apenas letras, números, hífen e underscore.', + }) + name!: string; + + @IsString() + @Matches(/^\d{2,10}$/) + number!: string; + + @IsOptional() + @IsEnum(QueueStrategy) + strategy?: QueueStrategy; + + @IsOptional() + @IsInt() + @Min(1) + @Max(600) + timeout?: number; + + @IsOptional() + @IsInt() + @Min(1) + retry?: number; + + @IsOptional() + @IsInt() + @Min(0) + wrapUpTime?: number; + + @IsOptional() + @IsInt() + @Min(0) + maxLen?: number; + + @IsOptional() + @IsString() + musicOnHold?: string; + + @IsOptional() + @IsString() + announce?: string; + + @IsOptional() + @IsInt() + @Min(1) + serviceLevel?: number; + + @IsOptional() + @IsBoolean() + autoFill?: boolean; + + @IsOptional() + @IsBoolean() + ringInUse?: boolean; + + @IsOptional() + @IsInt() + @Min(0) + @Max(10) + weight?: number; + + @IsOptional() + @IsBoolean() + enabled?: boolean; +} diff --git a/apps/api/src/queues/dto/update-queue.dto.ts b/apps/api/src/queues/dto/update-queue.dto.ts new file mode 100644 index 0000000..4713cdb --- /dev/null +++ b/apps/api/src/queues/dto/update-queue.dto.ts @@ -0,0 +1,6 @@ +import { PartialType, OmitType } from '@nestjs/mapped-types'; +import { CreateQueueDto } from './create-queue.dto'; + +export class UpdateQueueDto extends PartialType( + OmitType(CreateQueueDto, ['name', 'number'] as const), +) {} diff --git a/apps/api/src/queues/queue-generator.ts b/apps/api/src/queues/queue-generator.ts new file mode 100644 index 0000000..9e01588 --- /dev/null +++ b/apps/api/src/queues/queue-generator.ts @@ -0,0 +1,35 @@ +import type { Queue } from '@b2bcall/database'; + +/** + * Gera o texto de queues.conf para as filas cadastradas. Membros nunca + * entram aqui — são geridos dinamicamente via AMI (login/logout do agente), + * não fazem parte deste arquivo estático (ver docs/ARCHITECTURE.md). + */ +export function generateQueuesConfig(queues: Queue[]): string { + const enabled = queues + .filter((q) => q.enabled) + .sort((a, b) => a.name.localeCompare(b.name)); + + const lines: string[] = [ + '; Arquivo gerado automaticamente por QueuesService — NÃO EDITAR À MÃO.', + '', + ]; + + for (const q of enabled) { + lines.push(`[${q.name}]`); + lines.push(`musicclass=${q.musicOnHold}`); + lines.push(`strategy=${q.strategy}`); + lines.push(`timeout=${q.timeout}`); + lines.push(`retry=${q.retry}`); + lines.push(`wrapuptime=${q.wrapUpTime}`); + lines.push(`maxlen=${q.maxLen}`); + lines.push(`servicelevel=${q.serviceLevel}`); + lines.push(`autofill=${q.autoFill ? 'yes' : 'no'}`); + lines.push(`ringinuse=${q.ringInUse ? 'yes' : 'no'}`); + lines.push(`weight=${q.weight}`); + if (q.announce) lines.push(`announce=${q.announce}`); + lines.push(''); + } + + return lines.join('\n'); +} diff --git a/apps/api/src/queues/queues.controller.ts b/apps/api/src/queues/queues.controller.ts new file mode 100644 index 0000000..8aa066b --- /dev/null +++ b/apps/api/src/queues/queues.controller.ts @@ -0,0 +1,104 @@ +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 { QueuesService } from './queues.service'; +import { CreateQueueDto } from './dto/create-queue.dto'; +import { UpdateQueueDto } from './dto/update-queue.dto'; +import { AddQueueMemberDto } from './dto/add-queue-member.dto'; + +@Controller('queues') +export class QueuesController { + constructor(private readonly queuesService: QueuesService) {} + + @Get() + @RequirePermissions('queues.view') + list() { + return this.queuesService.list(); + } + + @Get(':id') + @RequirePermissions('queues.view') + findOne(@Param('id', ParseUUIDPipe) id: string) { + return this.queuesService.findByIdOrThrow(id); + } + + @Post() + @RequirePermissions('queues.create') + create( + @Body() dto: CreateQueueDto, + @CurrentUser() actor: AuthenticatedUser, + @Req() request: FastifyRequest, + ) { + return this.queuesService.create(dto, actor, { + ip: request.ip, + userAgent: request.headers['user-agent'], + }); + } + + @Patch(':id') + @RequirePermissions('queues.update') + update( + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: UpdateQueueDto, + @CurrentUser() actor: AuthenticatedUser, + @Req() request: FastifyRequest, + ) { + return this.queuesService.update(id, dto, actor, { + ip: request.ip, + userAgent: request.headers['user-agent'], + }); + } + + @Delete(':id') + @RequirePermissions('queues.delete') + remove( + @Param('id', ParseUUIDPipe) id: string, + @CurrentUser() actor: AuthenticatedUser, + @Req() request: FastifyRequest, + ) { + return this.queuesService.delete(id, actor, { + ip: request.ip, + userAgent: request.headers['user-agent'], + }); + } + + @Post(':id/members') + @RequirePermissions('queues.update') + addMember( + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: AddQueueMemberDto, + @CurrentUser() actor: AuthenticatedUser, + @Req() request: FastifyRequest, + ) { + return this.queuesService.addMember(id, dto.agentId, dto.penalty, actor, { + ip: request.ip, + userAgent: request.headers['user-agent'], + }); + } + + @Delete(':id/members/:agentId') + @RequirePermissions('queues.update') + removeMember( + @Param('id', ParseUUIDPipe) id: string, + @Param('agentId', ParseUUIDPipe) agentId: string, + @CurrentUser() actor: AuthenticatedUser, + @Req() request: FastifyRequest, + ) { + return this.queuesService.removeMember(id, agentId, actor, { + ip: request.ip, + userAgent: request.headers['user-agent'], + }); + } +} diff --git a/apps/api/src/queues/queues.module.ts b/apps/api/src/queues/queues.module.ts new file mode 100644 index 0000000..6d6edb6 --- /dev/null +++ b/apps/api/src/queues/queues.module.ts @@ -0,0 +1,9 @@ +import { Module } from '@nestjs/common'; +import { QueuesController } from './queues.controller'; +import { QueuesService } from './queues.service'; + +@Module({ + controllers: [QueuesController], + providers: [QueuesService], +}) +export class QueuesModule {} diff --git a/apps/api/src/queues/queues.service.ts b/apps/api/src/queues/queues.service.ts new file mode 100644 index 0000000..c1036be --- /dev/null +++ b/apps/api/src/queues/queues.service.ts @@ -0,0 +1,178 @@ +import { + BadRequestException, + Inject, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import { writeFile } from 'node:fs/promises'; +import type { TelephonyProvider } from '@b2bcall/telephony'; +import { PrismaService } from '../prisma/prisma.service'; +import { AuditService } from '../audit/audit.service'; +import type { RequestContext } from '../auth/auth.service'; +import { TELEPHONY_PROVIDER } from '../telephony/telephony.module'; +import { CreateQueueDto } from './dto/create-queue.dto'; +import { UpdateQueueDto } from './dto/update-queue.dto'; +import { generateQueuesConfig } from './queue-generator'; + +const QUEUES_FILE_PATH = '/etc/asterisk-generated/b2bcall-queues.conf'; + +@Injectable() +export class QueuesService { + constructor( + private readonly prisma: PrismaService, + private readonly audit: AuditService, + @Inject(TELEPHONY_PROVIDER) private readonly telephony: TelephonyProvider, + ) {} + + list() { + return this.prisma.queue.findMany({ + orderBy: { name: 'asc' }, + include: { members: { include: { agent: true } } }, + }); + } + + async findByIdOrThrow(id: string) { + const queue = await this.prisma.queue.findUnique({ + where: { id }, + include: { members: { include: { agent: true } } }, + }); + if (!queue) throw new NotFoundException('Fila não encontrada.'); + return queue; + } + + // Regenera queues.conf com todas as filas ativas e recarrega só o + // app_queue (não um core reload completo). + private async republish(): Promise { + const queues = await this.prisma.queue.findMany(); + const config = generateQueuesConfig(queues); + await writeFile(QUEUES_FILE_PATH, config, 'utf8'); + try { + await this.telephony.reload('app_queue.so'); + } catch (err) { + throw new BadRequestException( + `Falha ao recarregar filas no Asterisk: ${(err as Error).message}`, + ); + } + } + + async create( + dto: CreateQueueDto, + actor: { id: string }, + ctx: RequestContext, + ) { + const existing = await this.prisma.queue.findFirst({ + where: { OR: [{ name: dto.name }, { number: dto.number }] }, + }); + if (existing) + throw new BadRequestException( + 'Já existe uma fila com este nome ou número.', + ); + + const queue = await this.prisma.queue.create({ data: dto }); + await this.republish(); + + await this.audit.log({ + userId: actor.id, + action: 'queue_created', + entityType: 'queue', + entityId: queue.id, + after: { ...dto }, + ipAddress: ctx.ip, + userAgent: ctx.userAgent, + }); + return queue; + } + + async update( + id: string, + dto: UpdateQueueDto, + actor: { id: string }, + ctx: RequestContext, + ) { + const before = await this.prisma.queue.findUnique({ where: { id } }); + if (!before) throw new NotFoundException('Fila não encontrada.'); + + const queue = await this.prisma.queue.update({ where: { id }, data: dto }); + await this.republish(); + + await this.audit.log({ + userId: actor.id, + action: 'queue_updated', + entityType: 'queue', + entityId: id, + before, + after: { ...dto }, + ipAddress: ctx.ip, + userAgent: ctx.userAgent, + }); + return queue; + } + + async delete(id: string, actor: { id: string }, ctx: RequestContext) { + const queue = await this.prisma.queue.findUnique({ where: { id } }); + if (!queue) throw new NotFoundException('Fila não encontrada.'); + + await this.prisma.queue.delete({ where: { id } }); + await this.republish(); + + await this.audit.log({ + userId: actor.id, + action: 'queue_deleted', + entityType: 'queue', + entityId: id, + before: queue, + ipAddress: ctx.ip, + userAgent: ctx.userAgent, + }); + } + + async addMember( + queueId: string, + agentId: string, + penalty: number | undefined, + actor: { id: string }, + ctx: RequestContext, + ) { + const [queue, agent] = await Promise.all([ + this.prisma.queue.findUnique({ where: { id: queueId } }), + this.prisma.agent.findUnique({ where: { id: agentId } }), + ]); + if (!queue) throw new NotFoundException('Fila não encontrada.'); + if (!agent) throw new NotFoundException('Agente não encontrado.'); + + const member = await this.prisma.queueMember.upsert({ + where: { queueId_agentId: { queueId, agentId } }, + create: { queueId, agentId, penalty: penalty ?? 0 }, + update: { penalty: penalty ?? 0 }, + }); + + await this.audit.log({ + userId: actor.id, + action: 'queue_member_added', + entityType: 'queue', + entityId: queueId, + after: { agentId, penalty }, + ipAddress: ctx.ip, + userAgent: ctx.userAgent, + }); + return member; + } + + async removeMember( + queueId: string, + agentId: string, + actor: { id: string }, + ctx: RequestContext, + ) { + await this.prisma.queueMember.deleteMany({ where: { queueId, agentId } }); + await this.audit.log({ + userId: actor.id, + action: 'queue_member_removed', + entityType: 'queue', + entityId: queueId, + before: { agentId }, + ipAddress: ctx.ip, + userAgent: ctx.userAgent, + }); + } +} diff --git a/infrastructure/asterisk/config/queues.conf b/infrastructure/asterisk/config/queues.conf index bf630e6..734ce92 100644 --- a/infrastructure/asterisk/config/queues.conf +++ b/infrastructure/asterisk/config/queues.conf @@ -1,8 +1,12 @@ -; Configuração global de filas. As filas em si (Fase 5 — Call Center) serão -; gerenciadas pela aplicação. queue_log é usado por app_queue por padrão +; Configuração global de filas. queue_log é usado por app_queue por padrão ; (grava em /var/log/asterisk/queue_log) e será consumido pelo ; apps/asterisk-events para reconciliação (ver docs/ARCHITECTURE.md). [general] persistentmembers = yes autofill = yes shared_lastcall = yes + +; Filas gerenciadas pela aplicação (Call Center → Filas). Membros NÃO ficam +; aqui — são adicionados/removidos dinamicamente via AMI QueueAdd/QueueRemove +; no login/logout do agente (agente.md seção 17), nunca via reload. +#include "/etc/asterisk-generated/b2bcall-queues.conf" diff --git a/infrastructure/docker/asterisk-entrypoint.sh b/infrastructure/docker/asterisk-entrypoint.sh index 94fb04c..d18cef5 100644 --- a/infrastructure/docker/asterisk-entrypoint.sh +++ b/infrastructure/docker/asterisk-entrypoint.sh @@ -28,13 +28,16 @@ for f in "$CONFIG_SRC"/*.conf; do cp "$f" "$CONFIG_DST/$name" done -# extensions.conf inclui este arquivo (dialplan gerado pela aplicação) — -# precisa existir mesmo vazio no primeiro boot, antes de a API publicar -# a primeira versão (ver DialplanService). +# extensions.conf/queues.conf incluem estes arquivos gerados pela aplicação +# — precisam existir mesmo vazios no primeiro boot, antes da primeira +# publicação (ver DialplanService / QueuesService). mkdir -p /etc/asterisk-generated if [ ! -e /etc/asterisk-generated/b2bcall-dialplan.conf ]; then echo "; vazio até a primeira publicação em Telefonia -> Dialplan" > /etc/asterisk-generated/b2bcall-dialplan.conf fi +if [ ! -e /etc/asterisk-generated/b2bcall-queues.conf ]; then + echo "; vazio até a primeira publicação em Call Center -> Filas" > /etc/asterisk-generated/b2bcall-queues.conf +fi chown -R asterisk:asterisk "$CONFIG_DST" /var/lib/asterisk /var/log/asterisk /var/spool/asterisk /var/run/asterisk /etc/asterisk-generated diff --git a/packages/database/prisma/migrations/20260827161848_add_call_center/migration.sql b/packages/database/prisma/migrations/20260827161848_add_call_center/migration.sql new file mode 100644 index 0000000..7663379 --- /dev/null +++ b/packages/database/prisma/migrations/20260827161848_add_call_center/migration.sql @@ -0,0 +1,149 @@ +-- CreateEnum +CREATE TYPE "QueueStrategy" AS ENUM ('ringall', 'leastrecent', 'fewestcalls', 'random', 'rrmemory', 'rrordered', 'linear', 'wrandom'); + +-- CreateEnum +CREATE TYPE "AgentState" AS ENUM ('OFFLINE', 'LOGGED_IN', 'AVAILABLE', 'RINGING', 'IN_CALL', 'WRAP_UP', 'PAUSED'); + +-- CreateTable +CREATE TABLE "queues" ( + "id" TEXT NOT NULL, + "name" TEXT NOT NULL, + "number" TEXT NOT NULL, + "strategy" "QueueStrategy" NOT NULL DEFAULT 'ringall', + "timeout" INTEGER NOT NULL DEFAULT 15, + "retry" INTEGER NOT NULL DEFAULT 5, + "wrap_up_time" INTEGER NOT NULL DEFAULT 0, + "max_len" INTEGER NOT NULL DEFAULT 0, + "music_on_hold" TEXT NOT NULL DEFAULT 'default', + "announce" TEXT, + "service_level" INTEGER NOT NULL DEFAULT 60, + "auto_fill" BOOLEAN NOT NULL DEFAULT true, + "ring_in_use" BOOLEAN NOT NULL DEFAULT true, + "weight" INTEGER NOT NULL DEFAULT 0, + "enabled" BOOLEAN NOT NULL DEFAULT true, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "queues_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "queue_members" ( + "id" TEXT NOT NULL, + "queue_id" TEXT NOT NULL, + "agent_id" TEXT NOT NULL, + "penalty" INTEGER NOT NULL DEFAULT 0, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "queue_members_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "pause_reasons" ( + "id" TEXT NOT NULL, + "name" TEXT NOT NULL, + "code" TEXT NOT NULL, + "description" TEXT, + "max_duration_seconds" INTEGER, + "paid" BOOLEAN NOT NULL DEFAULT false, + "active" BOOLEAN NOT NULL DEFAULT true, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "pause_reasons_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "agents" ( + "id" TEXT NOT NULL, + "code" TEXT NOT NULL, + "name" TEXT NOT NULL, + "user_id" TEXT NOT NULL, + "active" BOOLEAN NOT NULL DEFAULT true, + "current_extension" TEXT, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "agents_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "agent_sessions" ( + "id" TEXT NOT NULL, + "agent_id" TEXT NOT NULL, + "extension" TEXT NOT NULL, + "started_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "ended_at" TIMESTAMP(3), + + CONSTRAINT "agent_sessions_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "agent_state_events" ( + "id" TEXT NOT NULL, + "agent_id" TEXT NOT NULL, + "state" "AgentState" NOT NULL, + "started_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "ended_at" TIMESTAMP(3), + + CONSTRAINT "agent_state_events_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "agent_pause_events" ( + "id" TEXT NOT NULL, + "agent_id" TEXT NOT NULL, + "pause_reason_id" TEXT NOT NULL, + "started_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "ended_at" TIMESTAMP(3), + + CONSTRAINT "agent_pause_events_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "queues_name_key" ON "queues"("name"); + +-- CreateIndex +CREATE UNIQUE INDEX "queues_number_key" ON "queues"("number"); + +-- CreateIndex +CREATE UNIQUE INDEX "queue_members_queue_id_agent_id_key" ON "queue_members"("queue_id", "agent_id"); + +-- CreateIndex +CREATE UNIQUE INDEX "pause_reasons_code_key" ON "pause_reasons"("code"); + +-- CreateIndex +CREATE UNIQUE INDEX "agents_code_key" ON "agents"("code"); + +-- CreateIndex +CREATE UNIQUE INDEX "agents_user_id_key" ON "agents"("user_id"); + +-- CreateIndex +CREATE INDEX "agent_sessions_agent_id_idx" ON "agent_sessions"("agent_id"); + +-- CreateIndex +CREATE INDEX "agent_state_events_agent_id_ended_at_idx" ON "agent_state_events"("agent_id", "ended_at"); + +-- CreateIndex +CREATE INDEX "agent_pause_events_agent_id_ended_at_idx" ON "agent_pause_events"("agent_id", "ended_at"); + +-- AddForeignKey +ALTER TABLE "queue_members" ADD CONSTRAINT "queue_members_queue_id_fkey" FOREIGN KEY ("queue_id") REFERENCES "queues"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "queue_members" ADD CONSTRAINT "queue_members_agent_id_fkey" FOREIGN KEY ("agent_id") REFERENCES "agents"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "agents" ADD CONSTRAINT "agents_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "agent_sessions" ADD CONSTRAINT "agent_sessions_agent_id_fkey" FOREIGN KEY ("agent_id") REFERENCES "agents"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "agent_state_events" ADD CONSTRAINT "agent_state_events_agent_id_fkey" FOREIGN KEY ("agent_id") REFERENCES "agents"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "agent_pause_events" ADD CONSTRAINT "agent_pause_events_agent_id_fkey" FOREIGN KEY ("agent_id") REFERENCES "agents"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "agent_pause_events" ADD CONSTRAINT "agent_pause_events_pause_reason_id_fkey" FOREIGN KEY ("pause_reason_id") REFERENCES "pause_reasons"("id") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/packages/database/prisma/schema.prisma b/packages/database/prisma/schema.prisma index c4450f5..d02cde5 100644 --- a/packages/database/prisma/schema.prisma +++ b/packages/database/prisma/schema.prisma @@ -31,6 +31,7 @@ model User { sessions Session[] passwordResetTokens PasswordResetToken[] auditLogs AuditLog[] + agent Agent? @@map("users") } @@ -251,3 +252,145 @@ model DialplanVersion { @@map("dialplan_versions") } + +// =========================================================================== +// Fase 5 — Call Center. Usuário da aplicação (User) e agente de Call Center +// (Agent) são conceitos separados (agente.md seção 16): uma pessoa pode +// possuir os dois associados 1:1. +// =========================================================================== + +enum QueueStrategy { + ringall + leastrecent + fewestcalls + random + rrmemory + rrordered + linear + wrandom +} + +model Queue { + id String @id @default(uuid()) + name String @unique + number String @unique + strategy QueueStrategy @default(ringall) + timeout Int @default(15) + retry Int @default(5) + wrapUpTime Int @default(0) @map("wrap_up_time") + maxLen Int @default(0) @map("max_len") + musicOnHold String @default("default") @map("music_on_hold") + announce String? + serviceLevel Int @default(60) @map("service_level") + autoFill Boolean @default(true) @map("auto_fill") + ringInUse Boolean @default(true) @map("ring_in_use") + weight Int @default(0) + enabled Boolean @default(true) + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + members QueueMember[] + + @@map("queues") +} + +model QueueMember { + id String @id @default(uuid()) + queueId String @map("queue_id") + agentId String @map("agent_id") + penalty Int @default(0) + createdAt DateTime @default(now()) @map("created_at") + + queue Queue @relation(fields: [queueId], references: [id], onDelete: Cascade) + agent Agent @relation(fields: [agentId], references: [id], onDelete: Cascade) + + @@unique([queueId, agentId]) + @@map("queue_members") +} + +model PauseReason { + id String @id @default(uuid()) + name String + code String @unique + description String? + maxDurationSeconds Int? @map("max_duration_seconds") + paid Boolean @default(false) + active Boolean @default(true) + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + pauseEvents AgentPauseEvent[] + + @@map("pause_reasons") +} + +model Agent { + id String @id @default(uuid()) + code String @unique + name String + userId String @unique @map("user_id") + active Boolean @default(true) + currentExtension String? @map("current_extension") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + sessions AgentSession[] + stateEvents AgentStateEvent[] + pauseEvents AgentPauseEvent[] + queues QueueMember[] + + @@map("agents") +} + +model AgentSession { + id String @id @default(uuid()) + agentId String @map("agent_id") + extension String + startedAt DateTime @default(now()) @map("started_at") + endedAt DateTime? @map("ended_at") + + agent Agent @relation(fields: [agentId], references: [id], onDelete: Cascade) + + @@index([agentId]) + @@map("agent_sessions") +} + +enum AgentState { + OFFLINE + LOGGED_IN + AVAILABLE + RINGING + IN_CALL + WRAP_UP + PAUSED +} + +// Trilha de auditoria da máquina de estados do agente (agente.md seção 48). +// Sempre exatamente um registro "aberto" (endedAt = null) por agente. +model AgentStateEvent { + id String @id @default(uuid()) + agentId String @map("agent_id") + state AgentState + startedAt DateTime @default(now()) @map("started_at") + endedAt DateTime? @map("ended_at") + + agent Agent @relation(fields: [agentId], references: [id], onDelete: Cascade) + + @@index([agentId, endedAt]) + @@map("agent_state_events") +} + +model AgentPauseEvent { + id String @id @default(uuid()) + agentId String @map("agent_id") + pauseReasonId String @map("pause_reason_id") + startedAt DateTime @default(now()) @map("started_at") + endedAt DateTime? @map("ended_at") + + agent Agent @relation(fields: [agentId], references: [id], onDelete: Cascade) + pauseReason PauseReason @relation(fields: [pauseReasonId], references: [id], onDelete: Restrict) + + @@index([agentId, endedAt]) + @@map("agent_pause_events") +}