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
|
status/diagnóstico, não tem tela de edição de asterisk.conf/rtp.conf
|
||||||
|
|
||||||
## Fase 5 — Call Center
|
## Fase 5 — Call Center
|
||||||
- [ ] Agentes (agents, agent_sessions) separados de users
|
- [x] Agentes (agents, separado de users 1:1) + agent_sessions +
|
||||||
- [ ] Máquina de estados do agente (OFFLINE..PAUSED)
|
agent_state_events + agent_pause_events — schema Prisma completo
|
||||||
- [ ] Motivos de pausa (CRUD)
|
- [x] Máquina de estados do agente (LOGGED_IN→AVAILABLE→PAUSED→AVAILABLE,
|
||||||
- [ ] Filas (CRUD + estratégias documentadas)
|
logout) com histórico início/fim por estado — testado ponta a ponta
|
||||||
- [ ] Tela do agente (login dinâmico, pausa/retomada, disposição)
|
contra o Asterisk real: login cria sessão, "disponível" faz AMI
|
||||||
- [ ] Disposições de chamada (CRUD + ações: callback, DNC)
|
QueueAdd (confirmado via `queue show`: membro dinâmico aparece),
|
||||||
- [ ] Callback (agendamento + scheduler)
|
"pausar" faz QueuePause com motivo (confirmado: "paused:Almoco"
|
||||||
- [ ] Monitoramento de Filas (tempo real)
|
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
|
## Fase 6 — Campanhas e discador preditivo
|
||||||
- [ ] CRUD Campanhas (todos os campos da seção 24)
|
- [ ] 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 { MonitoringModule } from './monitoring/monitoring.module';
|
||||||
import { DialplanModule } from './dialplan/dialplan.module';
|
import { DialplanModule } from './dialplan/dialplan.module';
|
||||||
import { AsteriskAdminModule } from './asterisk-admin/asterisk-admin.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 { AuthGuard } from './common/guards/auth.guard';
|
||||||
import { PermissionsGuard } from './common/guards/permissions.guard';
|
import { PermissionsGuard } from './common/guards/permissions.guard';
|
||||||
import { GlobalExceptionFilter } from './common/filters/global-exception.filter';
|
import { GlobalExceptionFilter } from './common/filters/global-exception.filter';
|
||||||
@@ -64,6 +68,10 @@ import { GlobalExceptionFilter } from './common/filters/global-exception.filter'
|
|||||||
MonitoringModule,
|
MonitoringModule,
|
||||||
DialplanModule,
|
DialplanModule,
|
||||||
AsteriskAdminModule,
|
AsteriskAdminModule,
|
||||||
|
PauseReasonsModule,
|
||||||
|
QueuesModule,
|
||||||
|
AgentsModule,
|
||||||
|
AgentConsoleModule,
|
||||||
],
|
],
|
||||||
controllers: [AppController],
|
controllers: [AppController],
|
||||||
providers: [
|
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 { RequirePermissions } from '../common/decorators/permissions.decorator';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import { TELEPHONY_PROVIDER } from '../telephony/telephony.module';
|
||||||
import { computeExtensionVisualStatus } from './extension-status.util';
|
import { computeExtensionVisualStatus } from './extension-status.util';
|
||||||
|
|
||||||
@Controller('monitoring')
|
@Controller('monitoring')
|
||||||
export class MonitoringController {
|
export class MonitoringController {
|
||||||
constructor(private readonly prisma: PrismaService) {}
|
constructor(
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
@Inject(TELEPHONY_PROVIDER) private readonly telephony: TelephonyProvider,
|
||||||
|
) {}
|
||||||
|
|
||||||
@Get('extensions')
|
@Get('extensions')
|
||||||
@RequirePermissions('monitoring.view')
|
@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
|
; Configuração global de filas. queue_log é usado por app_queue por padrão
|
||||||
; gerenciadas pela aplicação. queue_log é usado por app_queue por padrão
|
|
||||||
; (grava em /var/log/asterisk/queue_log) e será consumido pelo
|
; (grava em /var/log/asterisk/queue_log) e será consumido pelo
|
||||||
; apps/asterisk-events para reconciliação (ver docs/ARCHITECTURE.md).
|
; apps/asterisk-events para reconciliação (ver docs/ARCHITECTURE.md).
|
||||||
[general]
|
[general]
|
||||||
persistentmembers = yes
|
persistentmembers = yes
|
||||||
autofill = yes
|
autofill = yes
|
||||||
shared_lastcall = 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"
|
cp "$f" "$CONFIG_DST/$name"
|
||||||
done
|
done
|
||||||
|
|
||||||
# extensions.conf inclui este arquivo (dialplan gerado pela aplicação) —
|
# extensions.conf/queues.conf incluem estes arquivos gerados pela aplicação
|
||||||
# precisa existir mesmo vazio no primeiro boot, antes de a API publicar
|
# — precisam existir mesmo vazios no primeiro boot, antes da primeira
|
||||||
# a primeira versão (ver DialplanService).
|
# publicação (ver DialplanService / QueuesService).
|
||||||
mkdir -p /etc/asterisk-generated
|
mkdir -p /etc/asterisk-generated
|
||||||
if [ ! -e /etc/asterisk-generated/b2bcall-dialplan.conf ]; then
|
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
|
echo "; vazio até a primeira publicação em Telefonia -> Dialplan" > /etc/asterisk-generated/b2bcall-dialplan.conf
|
||||||
fi
|
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
|
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[]
|
sessions Session[]
|
||||||
passwordResetTokens PasswordResetToken[]
|
passwordResetTokens PasswordResetToken[]
|
||||||
auditLogs AuditLog[]
|
auditLogs AuditLog[]
|
||||||
|
agent Agent?
|
||||||
|
|
||||||
@@map("users")
|
@@map("users")
|
||||||
}
|
}
|
||||||
@@ -251,3 +252,145 @@ model DialplanVersion {
|
|||||||
|
|
||||||
@@map("dialplan_versions")
|
@@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