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:
36
TODO.md
36
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)
|
||||
|
||||
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,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user