feat: add call center queues

- 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).
This commit is contained in:
2026-08-27 13:38:19 -03:00
parent cf3fc4b3ef
commit 63103d4335
29 changed files with 1627 additions and 15 deletions

View File

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

View File

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

View File

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

View File

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

View File

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