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:
75
apps/api/src/agents/agents.controller.ts
Normal file
75
apps/api/src/agents/agents.controller.ts
Normal 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'],
|
||||
});
|
||||
}
|
||||
}
|
||||
10
apps/api/src/agents/agents.module.ts
Normal file
10
apps/api/src/agents/agents.module.ts
Normal 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 {}
|
||||
119
apps/api/src/agents/agents.service.ts
Normal file
119
apps/api/src/agents/agents.service.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
}
|
||||
25
apps/api/src/agents/dto/create-agent.dto.ts
Normal file
25
apps/api/src/agents/dto/create-agent.dto.ts
Normal 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;
|
||||
}
|
||||
6
apps/api/src/agents/dto/update-agent.dto.ts
Normal file
6
apps/api/src/agents/dto/update-agent.dto.ts
Normal 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),
|
||||
) {}
|
||||
Reference in New Issue
Block a user