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:
77
apps/api/src/agent-console/agent-console.controller.ts
Normal file
77
apps/api/src/agent-console/agent-console.controller.ts
Normal file
@@ -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'],
|
||||
});
|
||||
}
|
||||
}
|
||||
9
apps/api/src/agent-console/agent-console.module.ts
Normal file
9
apps/api/src/agent-console/agent-console.module.ts
Normal file
@@ -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 {}
|
||||
262
apps/api/src/agent-console/agent-console.service.ts
Normal file
262
apps/api/src/agent-console/agent-console.service.ts
Normal file
@@ -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 };
|
||||
}
|
||||
}
|
||||
7
apps/api/src/agent-console/dto/agent-login.dto.ts
Normal file
7
apps/api/src/agent-console/dto/agent-login.dto.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { IsString, Matches } from 'class-validator';
|
||||
|
||||
export class AgentLoginDto {
|
||||
@IsString()
|
||||
@Matches(/^\d{2,10}$/, { message: 'Ramal inválido.' })
|
||||
extension!: string;
|
||||
}
|
||||
6
apps/api/src/agent-console/dto/agent-pause.dto.ts
Normal file
6
apps/api/src/agent-console/dto/agent-pause.dto.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { IsUUID } from 'class-validator';
|
||||
|
||||
export class AgentPauseDto {
|
||||
@IsUUID('4')
|
||||
pauseReasonId!: string;
|
||||
}
|
||||
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),
|
||||
) {}
|
||||
@@ -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: [
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
36
apps/api/src/pause-reasons/dto/create-pause-reason.dto.ts
Normal file
36
apps/api/src/pause-reasons/dto/create-pause-reason.dto.ts
Normal file
@@ -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;
|
||||
}
|
||||
@@ -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),
|
||||
) {}
|
||||
71
apps/api/src/pause-reasons/pause-reasons.controller.ts
Normal file
71
apps/api/src/pause-reasons/pause-reasons.controller.ts
Normal file
@@ -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'],
|
||||
});
|
||||
}
|
||||
}
|
||||
10
apps/api/src/pause-reasons/pause-reasons.module.ts
Normal file
10
apps/api/src/pause-reasons/pause-reasons.module.ts
Normal file
@@ -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 {}
|
||||
96
apps/api/src/pause-reasons/pause-reasons.service.ts
Normal file
96
apps/api/src/pause-reasons/pause-reasons.service.ts
Normal file
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
12
apps/api/src/queues/dto/add-queue-member.dto.ts
Normal file
12
apps/api/src/queues/dto/add-queue-member.dto.ts
Normal file
@@ -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;
|
||||
}
|
||||
83
apps/api/src/queues/dto/create-queue.dto.ts
Normal file
83
apps/api/src/queues/dto/create-queue.dto.ts
Normal file
@@ -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;
|
||||
}
|
||||
6
apps/api/src/queues/dto/update-queue.dto.ts
Normal file
6
apps/api/src/queues/dto/update-queue.dto.ts
Normal file
@@ -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),
|
||||
) {}
|
||||
35
apps/api/src/queues/queue-generator.ts
Normal file
35
apps/api/src/queues/queue-generator.ts
Normal file
@@ -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');
|
||||
}
|
||||
104
apps/api/src/queues/queues.controller.ts
Normal file
104
apps/api/src/queues/queues.controller.ts
Normal file
@@ -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'],
|
||||
});
|
||||
}
|
||||
}
|
||||
9
apps/api/src/queues/queues.module.ts
Normal file
9
apps/api/src/queues/queues.module.ts
Normal file
@@ -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 {}
|
||||
178
apps/api/src/queues/queues.service.ts
Normal file
178
apps/api/src/queues/queues.service.ts
Normal file
@@ -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<void> {
|
||||
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,
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user