feat: add call center queues

- packages/database: Agent, AgentSession, AgentStateEvent, AgentPauseEvent,
  PauseReason, Queue, QueueMember (agente.md secao 53)
- apps/api/src/queues: CRUD de filas gerando queues.conf pelo mesmo padrao
  do dialplan (arquivo compartilhado + module reload app_queue.so), mas
  SEM membros estaticos no arquivo — membership eh 100% dinamica via AMI
  (evita duas fontes de verdade conflitantes)
- apps/api/src/pause-reasons: CRUD de motivos de pausa
- apps/api/src/agents: CRUD administrativo de agentes (1:1 com User)
- apps/api/src/agent-console: maquina de estados do agente
  (LOGGED_IN/AVAILABLE/PAUSED), 'tela do agente' via
  login/available/pause/unpause/logout, cada transicao aciona AMI
  QueueAdd/QueuePause/QueueRemove de verdade e registra
  agent_state_events/agent_pause_events com inicio/fim
- apps/api/src/monitoring: GET /api/monitoring/queues (chamadas
  aguardando, agentes logados/pausados/disponiveis via AMI QueueStatus ao
  vivo) — metricas historicas (TME/TMA/SLA) ficam para a Fase 7

Testado ponta a ponta contra o Asterisk real: fila criada aparece via
'queue show'; agente loga, fica disponivel (QueueAdd confirmado, membro
dinamico visivel), pausa com motivo (confirmado 'paused:Almoco' ao vivo),
despausa, desloga (QueueRemove confirmado, fila volta a 'No Members').

Disposicoes de chamada e Callback adiados para a Fase 6 (dependem de
haver chamadas de campanha reais para classificar).
This commit is contained in:
2026-08-27 13:38:19 -03:00
parent cf3fc4b3ef
commit 63103d4335
29 changed files with 1627 additions and 15 deletions

36
TODO.md
View File

@@ -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)

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

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

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

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

View File

@@ -0,0 +1,6 @@
import { IsUUID } from 'class-validator';
export class AgentPauseDto {
@IsUUID('4')
pauseReasonId!: string;
}

View File

@@ -0,0 +1,75 @@
import {
Body,
Controller,
Delete,
Get,
Param,
ParseUUIDPipe,
Patch,
Post,
Req,
} from '@nestjs/common';
import type { FastifyRequest } from 'fastify';
import { RequirePermissions } from '../common/decorators/permissions.decorator';
import { CurrentUser } from '../common/decorators/current-user.decorator';
import type { AuthenticatedUser } from '../common/guards/auth.guard';
import { AgentsService } from './agents.service';
import { CreateAgentDto } from './dto/create-agent.dto';
import { UpdateAgentDto } from './dto/update-agent.dto';
@Controller('agents')
export class AgentsController {
constructor(private readonly agentsService: AgentsService) {}
@Get()
@RequirePermissions('agents.view')
list() {
return this.agentsService.list();
}
@Get(':id')
@RequirePermissions('agents.view')
findOne(@Param('id', ParseUUIDPipe) id: string) {
return this.agentsService.findByIdOrThrow(id);
}
@Post()
@RequirePermissions('agents.create')
create(
@Body() dto: CreateAgentDto,
@CurrentUser() actor: AuthenticatedUser,
@Req() request: FastifyRequest,
) {
return this.agentsService.create(dto, actor, {
ip: request.ip,
userAgent: request.headers['user-agent'],
});
}
@Patch(':id')
@RequirePermissions('agents.update')
update(
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: UpdateAgentDto,
@CurrentUser() actor: AuthenticatedUser,
@Req() request: FastifyRequest,
) {
return this.agentsService.update(id, dto, actor, {
ip: request.ip,
userAgent: request.headers['user-agent'],
});
}
@Delete(':id')
@RequirePermissions('agents.delete')
remove(
@Param('id', ParseUUIDPipe) id: string,
@CurrentUser() actor: AuthenticatedUser,
@Req() request: FastifyRequest,
) {
return this.agentsService.delete(id, actor, {
ip: request.ip,
userAgent: request.headers['user-agent'],
});
}
}

View File

@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { AgentsController } from './agents.controller';
import { AgentsService } from './agents.service';
@Module({
controllers: [AgentsController],
providers: [AgentsService],
exports: [AgentsService],
})
export class AgentsModule {}

View File

@@ -0,0 +1,119 @@
import {
BadRequestException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { AuditService } from '../audit/audit.service';
import type { RequestContext } from '../auth/auth.service';
import { CreateAgentDto } from './dto/create-agent.dto';
import { UpdateAgentDto } from './dto/update-agent.dto';
@Injectable()
export class AgentsService {
constructor(
private readonly prisma: PrismaService,
private readonly audit: AuditService,
) {}
list() {
return this.prisma.agent.findMany({
orderBy: { code: 'asc' },
include: {
user: { select: { id: true, name: true, email: true } },
queues: { include: { queue: true } },
},
});
}
async findByIdOrThrow(id: string) {
const agent = await this.prisma.agent.findUnique({
where: { id },
include: {
user: { select: { id: true, name: true, email: true } },
queues: { include: { queue: true } },
},
});
if (!agent) throw new NotFoundException('Agente não encontrado.');
return agent;
}
async findByUserId(userId: string) {
return this.prisma.agent.findUnique({
where: { userId },
include: { queues: { include: { queue: true } } },
});
}
async create(
dto: CreateAgentDto,
actor: { id: string },
ctx: RequestContext,
) {
const [existingCode, existingUser, user] = await Promise.all([
this.prisma.agent.findUnique({ where: { code: dto.code } }),
this.prisma.agent.findUnique({ where: { userId: dto.userId } }),
this.prisma.user.findUnique({ where: { id: dto.userId } }),
]);
if (existingCode)
throw new BadRequestException('Já existe um agente com este código.');
if (existingUser)
throw new BadRequestException(
'Este usuário já possui um agente associado.',
);
if (!user) throw new BadRequestException('Usuário informado não existe.');
const agent = await this.prisma.agent.create({ data: dto });
await this.audit.log({
userId: actor.id,
action: 'agent_created',
entityType: 'agent',
entityId: agent.id,
after: { ...dto },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
});
return agent;
}
async update(
id: string,
dto: UpdateAgentDto,
actor: { id: string },
ctx: RequestContext,
) {
const before = await this.prisma.agent.findUnique({ where: { id } });
if (!before) throw new NotFoundException('Agente não encontrado.');
const agent = await this.prisma.agent.update({ where: { id }, data: dto });
await this.audit.log({
userId: actor.id,
action: 'agent_updated',
entityType: 'agent',
entityId: id,
before,
after: { ...dto },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
});
return agent;
}
async delete(id: string, actor: { id: string }, ctx: RequestContext) {
const agent = await this.prisma.agent.findUnique({ where: { id } });
if (!agent) throw new NotFoundException('Agente não encontrado.');
await this.prisma.agent.delete({ where: { id } });
await this.audit.log({
userId: actor.id,
action: 'agent_deleted',
entityType: 'agent',
entityId: id,
before: agent,
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
});
}
}

View File

@@ -0,0 +1,25 @@
import {
IsBoolean,
IsOptional,
IsString,
IsUUID,
Matches,
MinLength,
} from 'class-validator';
export class CreateAgentDto {
@IsString()
@Matches(/^[a-zA-Z0-9_-]{1,20}$/)
code!: string;
@IsString()
@MinLength(1)
name!: string;
@IsUUID('4')
userId!: string;
@IsOptional()
@IsBoolean()
active?: boolean;
}

View File

@@ -0,0 +1,6 @@
import { PartialType, OmitType } from '@nestjs/mapped-types';
import { CreateAgentDto } from './create-agent.dto';
export class UpdateAgentDto extends PartialType(
OmitType(CreateAgentDto, ['code', 'userId'] as const),
) {}

View File

@@ -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: [

View File

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

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

View File

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

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

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

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

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

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

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

View 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');
}

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

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

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

View File

@@ -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"

View File

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

View File

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

View File

@@ -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")
}