feat(agents): agentes, tiers e pausas — call center completo
Fecha agente.md secao 45-49/52. Depois desta fase, um usuario autenticado consegue logar como agente, entrar numa fila real, se pausar e voltar, tudo refletido de verdade no FreeSWITCH. Schema (migration 20260828124245_agents): - agents (tenant-scoped, RLS): User -> Extension -> identidade de agente, state (enum AgentState de 8 valores) espelhando o estado real, so alterado via login/logout/pause/resume, nunca escrito direto pela API. - tiers: Queue<->Agent (level/position 1:1 com mod_callcenter). - agent_sessions: um ciclo login->logout por linha. - agent_state_events: historico de transicoes de estado. - pause_reasons / agent_pause_events (secao 48). Dois bugs reais corrigidos em FreeSwitchTelephonyProvider, presentes desde a fase de Event Socket original: - "queue add/del member" nao existe no mod_callcenter — membership de fila usa tier add/tier del. So foi pego agora ao confirmar de novo a sintaxe via `help callcenter_config` antes de codar esta fase. - addAgent/removeAgent nao existiam ainda (agent add/del). Mecanismo de sync: agentes e tiers nao tem representacao em XML, so comando ESL direto — diferente do padrao "regenera todos os arquivos" usado em Trunks/Queues. apps/api publica uma mensagem por acao com payload (b2bcall:agents:sync, b2bcall:tiers:sync); b2bcall-fs-config aplica o comando correspondente (agent-sync.ts). Achados confirmados manualmente contra o FreeSWITCH real antes de codar: - `agent add`/`tier add` nao sao idempotentes (erro em duplicata) — sync ignora esse erro (.catch), condicao esperada em resync. - `agent del`/`tier del` em algo inexistente nao da erro — seguro chamar sem checar existencia antes. - `agent set status` so aceita 3 valores exatos (Available/On Break/ Logged Out) — testado deliberadamente com valor invalido. - Corrida real: atribuir tier antes do primeiro login do agente falha silenciosamente do lado do FreeSWITCH (agente so existe la a partir do `agent add` no login). Login sempre re-sincroniza todos os tiers do agente depois de garantir que ele existe — auto-correcao confirmada no teste ponta a ponta. apps/api: AgentsController (CRUD), AgentsMeController (login/logout/ pause/resume — sempre resolve o agente via JWT, nunca um agentId arbitrario do client), PauseReasonsController (CRUD), QueueAgentsController (POST/DELETE de tier em /queues/:id/agents). Verificado ponta a ponta via curl + fs_cli contra o FreeSWITCH real: login -> Available, pause -> On Break, resume -> Available, logout -> Logged Out, todos batendo entre Agent.state (banco) e `agent list` (FreeSWITCH). typecheck do workspace inteiro limpo. ~350MB de memoria total (docker stats). Documentado em docs/AGENTS.md, incluindo lacuna conhecida: estados derivados de chamada (RINGING/IN_CALL/WRAP_UP/RESERVED) dependem do evento CUSTOM callcenter::info, ainda nao comprovado chegando em fs-events nesta sessao (mesma lacuna de sofia::gateway_state ja documentada em docs/TRUNKS.md) — precisa de uma chamada real passando pela fila pra investigar. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01X1HxY46WGU4G1zmVDNKcWw
This commit is contained in:
36
TODO.md
36
TODO.md
@@ -200,7 +200,41 @@
|
|||||||
- [ ] Monitoramento em tempo real (secao 54) — depende de WebSocket
|
- [ ] Monitoramento em tempo real (secao 54) — depende de WebSocket
|
||||||
- [ ] Quota de filas — depende de Plans/Entitlements
|
- [ ] Quota de filas — depende de Plans/Entitlements
|
||||||
|
|
||||||
## PHASE 12+ — ver `agente.md` seções 45 em diante (Agentes, Tiers, Pausas,
|
## PHASE 12 — Agentes, Tiers, Pausas (agente.md secao 45-49, 52)
|
||||||
|
- [x] `agents`/`tiers`/`agent_sessions`/`agent_state_events`/
|
||||||
|
`pause_reasons`/`agent_pause_events` (tenant-scoped, RLS) — separa
|
||||||
|
User/Agent/Extension (secao 45)
|
||||||
|
- [x] Corrigidos bugs reais em `FreeSwitchTelephonyProvider` (nunca
|
||||||
|
testados antes): `addAgentToQueue`/`removeAgentFromQueue` usavam
|
||||||
|
"queue add/del member", que **não existe** — comando certo é
|
||||||
|
`tier add`/`tier del`. Adicionados `addAgent`/`removeAgent`
|
||||||
|
(`agent add`/`agent del`), que faltavam por completo.
|
||||||
|
- [x] Confirmado manualmente antes de codar: `agent add`/`tier add`
|
||||||
|
duplicado dá erro ("already exist", capturado e ignorado no sync);
|
||||||
|
`agent del`/`tier del` em algo inexistente não dá erro; `agent set
|
||||||
|
status` só aceita `Available`/`On Break`/`Logged Out`
|
||||||
|
- [x] Sync 100% dinâmico via ESL (sem arquivo, diferente de Trunks/Queues):
|
||||||
|
`b2bcall:agents:sync`/`b2bcall:tiers:sync` (Redis pub/sub com payload
|
||||||
|
por ação, não um resync geral)
|
||||||
|
- [x] `apps/api`: `/agents` (CRUD provisionamento), `/agents/me/login|
|
||||||
|
logout|pause|resume` (sempre sobre o agente do usuário autenticado,
|
||||||
|
nunca um id arbitrário do client), `/pause-reasons`,
|
||||||
|
`/queues/:id/agents` (tier assignment)
|
||||||
|
- [x] Achado de corrida real, visto no teste ponta a ponta: atribuir tier
|
||||||
|
antes do primeiro login falha (agente ainda não existe no
|
||||||
|
FreeSWITCH) — login sempre re-sincroniza todos os tiers do agente,
|
||||||
|
autocorrigindo. Confirmado acontecendo exatamente assim no teste.
|
||||||
|
- [x] Testado ponta a ponta os 4 estados: login→Available, pause→On Break,
|
||||||
|
resume→Available, logout→Logged Out — todos confirmados batendo
|
||||||
|
entre `Agent.state` (banco) e `callcenter_config agent list`
|
||||||
|
(FreeSWITCH)
|
||||||
|
- [ ] Estados derivados de chamada (RINGING/IN_CALL/WRAP_UP/RESERVED) —
|
||||||
|
dependem de `callcenter::info` (CUSTOM event), ainda não provado
|
||||||
|
funcionando (mesma lacuna do `sofia::gateway_state`, ver docs/TRUNKS.md)
|
||||||
|
- [ ] `PauseReason.maxDuration` não é aplicado automaticamente
|
||||||
|
- [ ] Quota de agentes — depende de Plans/Entitlements
|
||||||
|
|
||||||
|
## PHASE 13+ — ver `agente.md` seções 54 em diante (Monitoramento em tempo real,
|
||||||
Predictive Dialer, Recordings, AI, Billing, Frontend, Reports, Security, Tests)
|
Predictive Dialer, Recordings, AI, Billing, Frontend, Reports, Security, Tests)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
26
apps/api/src/agents/agent-sync.helper.ts
Normal file
26
apps/api/src/agents/agent-sync.helper.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import { getRedisClient } from "../common/redis";
|
||||||
|
|
||||||
|
const AGENTS_SYNC_CHANNEL = "b2bcall:agents:sync";
|
||||||
|
const TIERS_SYNC_CHANNEL = "b2bcall:tiers:sync";
|
||||||
|
|
||||||
|
export async function notifyAgentChanged(
|
||||||
|
tenantId: string,
|
||||||
|
agentId: string,
|
||||||
|
action: "upsert" | "delete" = "upsert",
|
||||||
|
): Promise<void> {
|
||||||
|
await getRedisClient().publish(AGENTS_SYNC_CHANNEL, JSON.stringify({ tenantId, agentId, action }));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function notifyTierChanged(
|
||||||
|
tenantId: string,
|
||||||
|
queueId: string,
|
||||||
|
agentId: string,
|
||||||
|
action: "upsert" | "delete",
|
||||||
|
level?: number,
|
||||||
|
position?: number,
|
||||||
|
): Promise<void> {
|
||||||
|
await getRedisClient().publish(
|
||||||
|
TIERS_SYNC_CHANNEL,
|
||||||
|
JSON.stringify({ tenantId, queueId, agentId, action, level, position }),
|
||||||
|
);
|
||||||
|
}
|
||||||
173
apps/api/src/agents/agents-me.controller.ts
Normal file
173
apps/api/src/agents/agents-me.controller.ts
Normal file
@@ -0,0 +1,173 @@
|
|||||||
|
import {
|
||||||
|
BadRequestException,
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
ForbiddenException,
|
||||||
|
NotFoundException,
|
||||||
|
Post,
|
||||||
|
UseGuards,
|
||||||
|
} from "@nestjs/common";
|
||||||
|
import { getPrismaClient, withTenantContext, type Prisma } from "@b2bcall/database";
|
||||||
|
import { recordAuditEvent, type AccessTokenClaims } from "@b2bcall/auth";
|
||||||
|
import { JwtAuthGuard } from "../common/guards/jwt-auth.guard";
|
||||||
|
import { CurrentUser } from "../common/decorators/current-user.decorator";
|
||||||
|
import { PauseDto } from "./dto/pause.dto";
|
||||||
|
import { notifyAgentChanged, notifyTierChanged } from "./agent-sync.helper";
|
||||||
|
|
||||||
|
async function findMyAgent(tx: Prisma.TransactionClient, tenantId: string, userId: string) {
|
||||||
|
const agent = await tx.agent.findFirst({
|
||||||
|
where: { tenantId, userId, deletedAt: null },
|
||||||
|
include: { extension: true, tiers: true },
|
||||||
|
});
|
||||||
|
if (!agent) {
|
||||||
|
throw new NotFoundException("Nenhum agente vinculado a este usuario neste tenant");
|
||||||
|
}
|
||||||
|
return agent;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "Tela do Agente" (agente.md secao 49): DISPONÍVEL / PAUSA / FINALIZAR
|
||||||
|
* PAUSA / LOGOUT. Opera sempre sobre o agente do PRÓPRIO usuário
|
||||||
|
* autenticado — nunca aceita um agentId arbitrário do client (mesmo
|
||||||
|
* principio de nunca confiar em tenant_id do frontend, secao 31).
|
||||||
|
*/
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
@Controller("agents/me")
|
||||||
|
export class AgentsMeController {
|
||||||
|
/** Fluxo de login (agente.md secao 47): valida usuário (JWT) e ramal,
|
||||||
|
* cria sessão, configura contact/tiers no FreeSWITCH, fica AVAILABLE. */
|
||||||
|
@Post("login")
|
||||||
|
async login(@CurrentUser() user: AccessTokenClaims) {
|
||||||
|
const prisma = getPrismaClient();
|
||||||
|
const tenantId = user.tenantId!;
|
||||||
|
|
||||||
|
const agent = await withTenantContext(prisma, tenantId, (tx) => findMyAgent(tx, tenantId, user.sub));
|
||||||
|
if (!agent.extension) {
|
||||||
|
throw new BadRequestException("Agente sem ramal configurado — nao e' possivel logar");
|
||||||
|
}
|
||||||
|
if (!agent.enabled) {
|
||||||
|
throw new ForbiddenException("Agente desabilitado");
|
||||||
|
}
|
||||||
|
|
||||||
|
await withTenantContext(prisma, tenantId, async (tx) => {
|
||||||
|
await tx.agentSession.create({ data: { tenantId, agentId: agent.id } });
|
||||||
|
await tx.agentStateEvent.createMany({
|
||||||
|
data: [
|
||||||
|
{ tenantId, agentId: agent.id, state: "LOGGED_IN" },
|
||||||
|
{ tenantId, agentId: agent.id, state: "AVAILABLE" },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
await tx.agent.update({
|
||||||
|
where: { id: agent.id },
|
||||||
|
data: { state: "AVAILABLE", stateUpdatedAt: new Date() },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await recordAuditEvent(prisma, { action: "AGENT_LOGIN", tenantId, userId: user.sub, entityType: "agent", entityId: agent.id });
|
||||||
|
|
||||||
|
await notifyAgentChanged(tenantId, agent.id, "upsert");
|
||||||
|
for (const tier of agent.tiers) {
|
||||||
|
await notifyTierChanged(tenantId, tier.queueId, agent.id, "upsert", tier.level, tier.position);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { state: "AVAILABLE" };
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post("logout")
|
||||||
|
async logout(@CurrentUser() user: AccessTokenClaims) {
|
||||||
|
const prisma = getPrismaClient();
|
||||||
|
const tenantId = user.tenantId!;
|
||||||
|
|
||||||
|
const agent = await withTenantContext(prisma, tenantId, (tx) => findMyAgent(tx, tenantId, user.sub));
|
||||||
|
|
||||||
|
await withTenantContext(prisma, tenantId, async (tx) => {
|
||||||
|
await tx.agentSession.updateMany({
|
||||||
|
where: { tenantId, agentId: agent.id, endedAt: null },
|
||||||
|
data: { endedAt: new Date() },
|
||||||
|
});
|
||||||
|
await tx.agentPauseEvent.updateMany({
|
||||||
|
where: { tenantId, agentId: agent.id, endedAt: null },
|
||||||
|
data: { endedAt: new Date() },
|
||||||
|
});
|
||||||
|
await tx.agentStateEvent.create({ data: { tenantId, agentId: agent.id, state: "OFFLINE" } });
|
||||||
|
await tx.agent.update({
|
||||||
|
where: { id: agent.id },
|
||||||
|
data: { state: "OFFLINE", stateUpdatedAt: new Date() },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await recordAuditEvent(prisma, { action: "AGENT_LOGOUT", tenantId, userId: user.sub, entityType: "agent", entityId: agent.id });
|
||||||
|
|
||||||
|
await notifyAgentChanged(tenantId, agent.id, "upsert");
|
||||||
|
|
||||||
|
return { state: "OFFLINE" };
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post("pause")
|
||||||
|
async pause(@CurrentUser() user: AccessTokenClaims, @Body() dto: PauseDto) {
|
||||||
|
const prisma = getPrismaClient();
|
||||||
|
const tenantId = user.tenantId!;
|
||||||
|
|
||||||
|
const agent = await withTenantContext(prisma, tenantId, (tx) => findMyAgent(tx, tenantId, user.sub));
|
||||||
|
if (agent.state === "OFFLINE") {
|
||||||
|
throw new BadRequestException("Agente precisa estar logado pra entrar em pausa");
|
||||||
|
}
|
||||||
|
|
||||||
|
const pauseReason = await withTenantContext(prisma, tenantId, (tx) =>
|
||||||
|
tx.pauseReason.findFirst({ where: { id: dto.pauseReasonId, tenantId, enabled: true } }),
|
||||||
|
);
|
||||||
|
if (!pauseReason) {
|
||||||
|
throw new NotFoundException("Motivo de pausa nao encontrado");
|
||||||
|
}
|
||||||
|
|
||||||
|
await withTenantContext(prisma, tenantId, async (tx) => {
|
||||||
|
await tx.agentPauseEvent.create({
|
||||||
|
data: { tenantId, agentId: agent.id, pauseReasonId: pauseReason.id },
|
||||||
|
});
|
||||||
|
await tx.agentStateEvent.create({ data: { tenantId, agentId: agent.id, state: "PAUSED" } });
|
||||||
|
await tx.agent.update({
|
||||||
|
where: { id: agent.id },
|
||||||
|
data: { state: "PAUSED", stateUpdatedAt: new Date() },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await recordAuditEvent(prisma, {
|
||||||
|
action: "AGENT_PAUSE",
|
||||||
|
tenantId,
|
||||||
|
userId: user.sub,
|
||||||
|
entityType: "agent",
|
||||||
|
entityId: agent.id,
|
||||||
|
after: { pauseReason: pauseReason.name },
|
||||||
|
});
|
||||||
|
|
||||||
|
await notifyAgentChanged(tenantId, agent.id, "upsert");
|
||||||
|
|
||||||
|
return { state: "PAUSED" };
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post("resume")
|
||||||
|
async resume(@CurrentUser() user: AccessTokenClaims) {
|
||||||
|
const prisma = getPrismaClient();
|
||||||
|
const tenantId = user.tenantId!;
|
||||||
|
|
||||||
|
const agent = await withTenantContext(prisma, tenantId, (tx) => findMyAgent(tx, tenantId, user.sub));
|
||||||
|
|
||||||
|
await withTenantContext(prisma, tenantId, async (tx) => {
|
||||||
|
await tx.agentPauseEvent.updateMany({
|
||||||
|
where: { tenantId, agentId: agent.id, endedAt: null },
|
||||||
|
data: { endedAt: new Date() },
|
||||||
|
});
|
||||||
|
await tx.agentStateEvent.create({ data: { tenantId, agentId: agent.id, state: "AVAILABLE" } });
|
||||||
|
await tx.agent.update({
|
||||||
|
where: { id: agent.id },
|
||||||
|
data: { state: "AVAILABLE", stateUpdatedAt: new Date() },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await recordAuditEvent(prisma, { action: "AGENT_RESUME", tenantId, userId: user.sub, entityType: "agent", entityId: agent.id });
|
||||||
|
|
||||||
|
await notifyAgentChanged(tenantId, agent.id, "upsert");
|
||||||
|
|
||||||
|
return { state: "AVAILABLE" };
|
||||||
|
}
|
||||||
|
}
|
||||||
109
apps/api/src/agents/agents.controller.ts
Normal file
109
apps/api/src/agents/agents.controller.ts
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Delete,
|
||||||
|
Get,
|
||||||
|
HttpCode,
|
||||||
|
HttpStatus,
|
||||||
|
NotFoundException,
|
||||||
|
Param,
|
||||||
|
Post,
|
||||||
|
UseGuards,
|
||||||
|
} from "@nestjs/common";
|
||||||
|
import { getPrismaClient, withTenantContext } from "@b2bcall/database";
|
||||||
|
import { recordAuditEvent, type AccessTokenClaims } from "@b2bcall/auth";
|
||||||
|
import { JwtAuthGuard } from "../common/guards/jwt-auth.guard";
|
||||||
|
import { PermissionGuard } from "../common/guards/permission.guard";
|
||||||
|
import { RequirePermission } from "../common/decorators/require-permission.decorator";
|
||||||
|
import { CurrentUser } from "../common/decorators/current-user.decorator";
|
||||||
|
import { CreateAgentDto } from "./dto/create-agent.dto";
|
||||||
|
import { notifyAgentChanged } from "./agent-sync.helper";
|
||||||
|
|
||||||
|
@UseGuards(JwtAuthGuard, PermissionGuard)
|
||||||
|
@Controller("agents")
|
||||||
|
export class AgentsController {
|
||||||
|
/** Provisionamento: liga um usuário (login) a uma identidade de agente e,
|
||||||
|
* opcionalmente, a um ramal (agente.md secao 45). */
|
||||||
|
@RequirePermission("agents.manage")
|
||||||
|
@Post()
|
||||||
|
async create(@CurrentUser() user: AccessTokenClaims, @Body() dto: CreateAgentDto) {
|
||||||
|
const prisma = getPrismaClient();
|
||||||
|
const tenantId = user.tenantId!;
|
||||||
|
|
||||||
|
const agent = await withTenantContext(prisma, tenantId, (tx) =>
|
||||||
|
tx.agent.create({
|
||||||
|
data: {
|
||||||
|
tenantId,
|
||||||
|
userId: dto.userId,
|
||||||
|
extensionId: dto.extensionId,
|
||||||
|
name: dto.name,
|
||||||
|
maxNoAnswer: dto.maxNoAnswer ?? 3,
|
||||||
|
wrapUpTime: dto.wrapUpTime ?? 10,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await recordAuditEvent(prisma, {
|
||||||
|
action: "AGENT_CREATE",
|
||||||
|
tenantId,
|
||||||
|
userId: user.sub,
|
||||||
|
entityType: "agent",
|
||||||
|
entityId: agent.id,
|
||||||
|
after: { name: agent.name, userId: agent.userId },
|
||||||
|
});
|
||||||
|
|
||||||
|
return agent;
|
||||||
|
}
|
||||||
|
|
||||||
|
@RequirePermission("agents.view")
|
||||||
|
@Get()
|
||||||
|
async list(@CurrentUser() user: AccessTokenClaims) {
|
||||||
|
const prisma = getPrismaClient();
|
||||||
|
const tenantId = user.tenantId!;
|
||||||
|
return withTenantContext(prisma, tenantId, (tx) =>
|
||||||
|
tx.agent.findMany({ where: { deletedAt: null }, orderBy: { name: "asc" } }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@RequirePermission("agents.view")
|
||||||
|
@Get(":id")
|
||||||
|
async get(@CurrentUser() user: AccessTokenClaims, @Param("id") id: string) {
|
||||||
|
const prisma = getPrismaClient();
|
||||||
|
const tenantId = user.tenantId!;
|
||||||
|
const agent = await withTenantContext(prisma, tenantId, (tx) =>
|
||||||
|
tx.agent.findFirst({ where: { id, deletedAt: null } }),
|
||||||
|
);
|
||||||
|
if (!agent) {
|
||||||
|
throw new NotFoundException();
|
||||||
|
}
|
||||||
|
return agent;
|
||||||
|
}
|
||||||
|
|
||||||
|
@RequirePermission("agents.manage")
|
||||||
|
@Delete(":id")
|
||||||
|
@HttpCode(HttpStatus.NO_CONTENT)
|
||||||
|
async remove(@CurrentUser() user: AccessTokenClaims, @Param("id") id: string) {
|
||||||
|
const prisma = getPrismaClient();
|
||||||
|
const tenantId = user.tenantId!;
|
||||||
|
|
||||||
|
const result = await withTenantContext(prisma, tenantId, (tx) =>
|
||||||
|
tx.agent.updateMany({
|
||||||
|
where: { id, deletedAt: null },
|
||||||
|
data: { deletedAt: new Date(), enabled: false, state: "OFFLINE" },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
if (result.count === 0) {
|
||||||
|
throw new NotFoundException();
|
||||||
|
}
|
||||||
|
|
||||||
|
await recordAuditEvent(prisma, {
|
||||||
|
action: "AGENT_DELETE",
|
||||||
|
tenantId,
|
||||||
|
userId: user.sub,
|
||||||
|
entityType: "agent",
|
||||||
|
entityId: id,
|
||||||
|
});
|
||||||
|
|
||||||
|
await notifyAgentChanged(tenantId, id, "delete");
|
||||||
|
}
|
||||||
|
}
|
||||||
8
apps/api/src/agents/agents.module.ts
Normal file
8
apps/api/src/agents/agents.module.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
import { Module } from "@nestjs/common";
|
||||||
|
import { AgentsController } from "./agents.controller";
|
||||||
|
import { AgentsMeController } from "./agents-me.controller";
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
controllers: [AgentsController, AgentsMeController],
|
||||||
|
})
|
||||||
|
export class AgentsModule {}
|
||||||
26
apps/api/src/agents/dto/create-agent.dto.ts
Normal file
26
apps/api/src/agents/dto/create-agent.dto.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import { IsInt, IsOptional, IsString, IsUUID, Max, MaxLength, Min } from "class-validator";
|
||||||
|
|
||||||
|
export class CreateAgentDto {
|
||||||
|
@IsUUID()
|
||||||
|
userId!: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID()
|
||||||
|
extensionId?: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(120)
|
||||||
|
name!: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsInt()
|
||||||
|
@Min(0)
|
||||||
|
@Max(20)
|
||||||
|
maxNoAnswer?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsInt()
|
||||||
|
@Min(0)
|
||||||
|
@Max(3600)
|
||||||
|
wrapUpTime?: number;
|
||||||
|
}
|
||||||
6
apps/api/src/agents/dto/pause.dto.ts
Normal file
6
apps/api/src/agents/dto/pause.dto.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import { IsUUID } from "class-validator";
|
||||||
|
|
||||||
|
export class PauseDto {
|
||||||
|
@IsUUID()
|
||||||
|
pauseReasonId!: string;
|
||||||
|
}
|
||||||
@@ -5,8 +5,19 @@ import { ExtensionsModule } from "./extensions/extensions.module";
|
|||||||
import { TrunksModule } from "./trunks/trunks.module";
|
import { TrunksModule } from "./trunks/trunks.module";
|
||||||
import { DialplanModule } from "./dialplan/dialplan.module";
|
import { DialplanModule } from "./dialplan/dialplan.module";
|
||||||
import { QueuesModule } from "./queues/queues.module";
|
import { QueuesModule } from "./queues/queues.module";
|
||||||
|
import { AgentsModule } from "./agents/agents.module";
|
||||||
|
import { PauseReasonsModule } from "./pause-reasons/pause-reasons.module";
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [HealthModule, AuthModule, ExtensionsModule, TrunksModule, DialplanModule, QueuesModule],
|
imports: [
|
||||||
|
HealthModule,
|
||||||
|
AuthModule,
|
||||||
|
ExtensionsModule,
|
||||||
|
TrunksModule,
|
||||||
|
DialplanModule,
|
||||||
|
QueuesModule,
|
||||||
|
AgentsModule,
|
||||||
|
PauseReasonsModule,
|
||||||
|
],
|
||||||
})
|
})
|
||||||
export class AppModule {}
|
export class AppModule {}
|
||||||
|
|||||||
26
apps/api/src/pause-reasons/dto/create-pause-reason.dto.ts
Normal file
26
apps/api/src/pause-reasons/dto/create-pause-reason.dto.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import { IsBoolean, IsInt, IsOptional, IsString, Max, MaxLength, Min } from "class-validator";
|
||||||
|
|
||||||
|
export class CreatePauseReasonDto {
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(80)
|
||||||
|
name!: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(40)
|
||||||
|
code!: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(255)
|
||||||
|
description?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsInt()
|
||||||
|
@Min(0)
|
||||||
|
@Max(86400)
|
||||||
|
maxDuration?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
paid?: boolean;
|
||||||
|
}
|
||||||
88
apps/api/src/pause-reasons/pause-reasons.controller.ts
Normal file
88
apps/api/src/pause-reasons/pause-reasons.controller.ts
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Delete,
|
||||||
|
Get,
|
||||||
|
HttpCode,
|
||||||
|
HttpStatus,
|
||||||
|
NotFoundException,
|
||||||
|
Param,
|
||||||
|
Post,
|
||||||
|
UseGuards,
|
||||||
|
} from "@nestjs/common";
|
||||||
|
import { getPrismaClient, withTenantContext } from "@b2bcall/database";
|
||||||
|
import { recordAuditEvent, type AccessTokenClaims } from "@b2bcall/auth";
|
||||||
|
import { JwtAuthGuard } from "../common/guards/jwt-auth.guard";
|
||||||
|
import { PermissionGuard } from "../common/guards/permission.guard";
|
||||||
|
import { RequirePermission } from "../common/decorators/require-permission.decorator";
|
||||||
|
import { CurrentUser } from "../common/decorators/current-user.decorator";
|
||||||
|
import { CreatePauseReasonDto } from "./dto/create-pause-reason.dto";
|
||||||
|
|
||||||
|
// Sem permission dedicada pra pausas (nao existe na secao 145 do
|
||||||
|
// agente.md) — reusa agents.* (pausas sao parte da gestao de agentes),
|
||||||
|
// mesmo criterio usado em Dialplan pra freeswitch.*.
|
||||||
|
@UseGuards(JwtAuthGuard, PermissionGuard)
|
||||||
|
@Controller("pause-reasons")
|
||||||
|
export class PauseReasonsController {
|
||||||
|
@RequirePermission("agents.manage")
|
||||||
|
@Post()
|
||||||
|
async create(@CurrentUser() user: AccessTokenClaims, @Body() dto: CreatePauseReasonDto) {
|
||||||
|
const prisma = getPrismaClient();
|
||||||
|
const tenantId = user.tenantId!;
|
||||||
|
|
||||||
|
const reason = await withTenantContext(prisma, tenantId, (tx) =>
|
||||||
|
tx.pauseReason.create({
|
||||||
|
data: {
|
||||||
|
tenantId,
|
||||||
|
name: dto.name,
|
||||||
|
code: dto.code,
|
||||||
|
description: dto.description,
|
||||||
|
maxDuration: dto.maxDuration,
|
||||||
|
paid: dto.paid ?? false,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await recordAuditEvent(prisma, {
|
||||||
|
action: "PAUSE_REASON_CREATE",
|
||||||
|
tenantId,
|
||||||
|
userId: user.sub,
|
||||||
|
entityType: "pause_reason",
|
||||||
|
entityId: reason.id,
|
||||||
|
after: { name: reason.name, code: reason.code },
|
||||||
|
});
|
||||||
|
|
||||||
|
return reason;
|
||||||
|
}
|
||||||
|
|
||||||
|
@RequirePermission("agents.view")
|
||||||
|
@Get()
|
||||||
|
async list(@CurrentUser() user: AccessTokenClaims) {
|
||||||
|
const prisma = getPrismaClient();
|
||||||
|
const tenantId = user.tenantId!;
|
||||||
|
return withTenantContext(prisma, tenantId, (tx) => tx.pauseReason.findMany({ orderBy: { name: "asc" } }));
|
||||||
|
}
|
||||||
|
|
||||||
|
@RequirePermission("agents.manage")
|
||||||
|
@Delete(":id")
|
||||||
|
@HttpCode(HttpStatus.NO_CONTENT)
|
||||||
|
async remove(@CurrentUser() user: AccessTokenClaims, @Param("id") id: string) {
|
||||||
|
const prisma = getPrismaClient();
|
||||||
|
const tenantId = user.tenantId!;
|
||||||
|
|
||||||
|
const result = await withTenantContext(prisma, tenantId, (tx) =>
|
||||||
|
tx.pauseReason.updateMany({ where: { id, tenantId }, data: { enabled: false } }),
|
||||||
|
);
|
||||||
|
if (result.count === 0) {
|
||||||
|
throw new NotFoundException();
|
||||||
|
}
|
||||||
|
|
||||||
|
await recordAuditEvent(prisma, {
|
||||||
|
action: "PAUSE_REASON_DELETE",
|
||||||
|
tenantId,
|
||||||
|
userId: user.sub,
|
||||||
|
entityType: "pause_reason",
|
||||||
|
entityId: id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
7
apps/api/src/pause-reasons/pause-reasons.module.ts
Normal file
7
apps/api/src/pause-reasons/pause-reasons.module.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { Module } from "@nestjs/common";
|
||||||
|
import { PauseReasonsController } from "./pause-reasons.controller";
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
controllers: [PauseReasonsController],
|
||||||
|
})
|
||||||
|
export class PauseReasonsModule {}
|
||||||
18
apps/api/src/queues/dto/add-queue-agent.dto.ts
Normal file
18
apps/api/src/queues/dto/add-queue-agent.dto.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import { IsInt, IsOptional, IsUUID, Max, Min } from "class-validator";
|
||||||
|
|
||||||
|
export class AddQueueAgentDto {
|
||||||
|
@IsUUID()
|
||||||
|
agentId!: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
@Max(100)
|
||||||
|
level?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
@Max(100)
|
||||||
|
position?: number;
|
||||||
|
}
|
||||||
97
apps/api/src/queues/queue-agents.controller.ts
Normal file
97
apps/api/src/queues/queue-agents.controller.ts
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Delete,
|
||||||
|
HttpCode,
|
||||||
|
HttpStatus,
|
||||||
|
NotFoundException,
|
||||||
|
Param,
|
||||||
|
Post,
|
||||||
|
UseGuards,
|
||||||
|
} from "@nestjs/common";
|
||||||
|
import { getPrismaClient, withTenantContext } from "@b2bcall/database";
|
||||||
|
import { recordAuditEvent, type AccessTokenClaims } from "@b2bcall/auth";
|
||||||
|
import { JwtAuthGuard } from "../common/guards/jwt-auth.guard";
|
||||||
|
import { PermissionGuard } from "../common/guards/permission.guard";
|
||||||
|
import { RequirePermission } from "../common/decorators/require-permission.decorator";
|
||||||
|
import { CurrentUser } from "../common/decorators/current-user.decorator";
|
||||||
|
import { AddQueueAgentDto } from "./dto/add-queue-agent.dto";
|
||||||
|
import { notifyTierChanged } from "../agents/agent-sync.helper";
|
||||||
|
|
||||||
|
@UseGuards(JwtAuthGuard, PermissionGuard)
|
||||||
|
@Controller("queues/:queueId/agents")
|
||||||
|
export class QueueAgentsController {
|
||||||
|
@RequirePermission("queues.manage")
|
||||||
|
@Post()
|
||||||
|
async add(
|
||||||
|
@CurrentUser() user: AccessTokenClaims,
|
||||||
|
@Param("queueId") queueId: string,
|
||||||
|
@Body() dto: AddQueueAgentDto,
|
||||||
|
) {
|
||||||
|
const prisma = getPrismaClient();
|
||||||
|
const tenantId = user.tenantId!;
|
||||||
|
|
||||||
|
const [queue, agent] = await withTenantContext(prisma, tenantId, (tx) =>
|
||||||
|
Promise.all([
|
||||||
|
tx.queue.findFirst({ where: { id: queueId, tenantId, deletedAt: null } }),
|
||||||
|
tx.agent.findFirst({ where: { id: dto.agentId, tenantId, deletedAt: null } }),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
if (!queue || !agent) {
|
||||||
|
throw new NotFoundException("Fila ou agente nao encontrado");
|
||||||
|
}
|
||||||
|
|
||||||
|
const level = dto.level ?? 1;
|
||||||
|
const position = dto.position ?? 1;
|
||||||
|
|
||||||
|
const tier = await withTenantContext(prisma, tenantId, (tx) =>
|
||||||
|
tx.tier.upsert({
|
||||||
|
where: { queueId_agentId: { queueId, agentId: dto.agentId } },
|
||||||
|
update: { level, position },
|
||||||
|
create: { tenantId, queueId, agentId: dto.agentId, level, position },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await recordAuditEvent(prisma, {
|
||||||
|
action: "TIER_ADD",
|
||||||
|
tenantId,
|
||||||
|
userId: user.sub,
|
||||||
|
entityType: "tier",
|
||||||
|
entityId: tier.id,
|
||||||
|
after: { queueId, agentId: dto.agentId, level, position },
|
||||||
|
});
|
||||||
|
|
||||||
|
await notifyTierChanged(tenantId, queueId, dto.agentId, "upsert", level, position);
|
||||||
|
|
||||||
|
return tier;
|
||||||
|
}
|
||||||
|
|
||||||
|
@RequirePermission("queues.manage")
|
||||||
|
@Delete(":agentId")
|
||||||
|
@HttpCode(HttpStatus.NO_CONTENT)
|
||||||
|
async remove(
|
||||||
|
@CurrentUser() user: AccessTokenClaims,
|
||||||
|
@Param("queueId") queueId: string,
|
||||||
|
@Param("agentId") agentId: string,
|
||||||
|
) {
|
||||||
|
const prisma = getPrismaClient();
|
||||||
|
const tenantId = user.tenantId!;
|
||||||
|
|
||||||
|
const result = await withTenantContext(prisma, tenantId, (tx) =>
|
||||||
|
tx.tier.deleteMany({ where: { tenantId, queueId, agentId } }),
|
||||||
|
);
|
||||||
|
if (result.count === 0) {
|
||||||
|
throw new NotFoundException();
|
||||||
|
}
|
||||||
|
|
||||||
|
await recordAuditEvent(prisma, {
|
||||||
|
action: "TIER_REMOVE",
|
||||||
|
tenantId,
|
||||||
|
userId: user.sub,
|
||||||
|
entityType: "tier",
|
||||||
|
entityId: `${queueId}:${agentId}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
await notifyTierChanged(tenantId, queueId, agentId, "delete");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
import { Module } from "@nestjs/common";
|
import { Module } from "@nestjs/common";
|
||||||
import { QueuesController } from "./queues.controller";
|
import { QueuesController } from "./queues.controller";
|
||||||
|
import { QueueAgentsController } from "./queue-agents.controller";
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
controllers: [QueuesController],
|
controllers: [QueuesController, QueueAgentsController],
|
||||||
})
|
})
|
||||||
export class QueuesModule {}
|
export class QueuesModule {}
|
||||||
|
|||||||
135
apps/freeswitch-config/src/agent-sync.ts
Normal file
135
apps/freeswitch-config/src/agent-sync.ts
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
import { getPrismaClient, withTenantContext, type AgentState } from "@b2bcall/database";
|
||||||
|
import { FreeSwitchTelephonyProvider } from "@b2bcall/telephony";
|
||||||
|
import { createLogger } from "@b2bcall/shared";
|
||||||
|
|
||||||
|
const logger = createLogger("b2bcall-fs-config");
|
||||||
|
|
||||||
|
let provider: FreeSwitchTelephonyProvider | undefined;
|
||||||
|
|
||||||
|
function getProvider(): FreeSwitchTelephonyProvider {
|
||||||
|
if (!provider) {
|
||||||
|
provider = new FreeSwitchTelephonyProvider({
|
||||||
|
host: process.env.ESL_HOST ?? "freeswitch",
|
||||||
|
port: Number(process.env.ESL_PORT ?? 8021),
|
||||||
|
password: process.env.ESL_PASSWORD ?? "",
|
||||||
|
logger: {
|
||||||
|
debug: () => {},
|
||||||
|
info: (msg) => logger.debug(msg),
|
||||||
|
error: (msg, data) => logger.error(msg, { detail: data }),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
provider.connect();
|
||||||
|
}
|
||||||
|
return provider;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mapeamento pro par status/state real do mod_callcenter (confirmado
|
||||||
|
// manualmente: só "Available"/"On Break"/"Logged Out" são aceitos —
|
||||||
|
// qualquer outro valor dá "-ERR Invalid Agent Status!"). Os estados
|
||||||
|
// derivados de chamada (RINGING/IN_CALL/WRAP_UP/RESERVED) não têm status
|
||||||
|
// próprio no mod_callcenter — ficam em "Available" com o `state` (não
|
||||||
|
// `status`) mudando sozinho conforme a chamada progride; não escrevemos
|
||||||
|
// esses na FreeSWITCH, só refletimos o que ela reportar (ver docs/AGENTS.md).
|
||||||
|
const STATUS_MAP: Partial<Record<AgentState, string>> = {
|
||||||
|
AVAILABLE: "Available",
|
||||||
|
LOGGED_IN: "Available",
|
||||||
|
RESERVED: "Available",
|
||||||
|
RINGING: "Available",
|
||||||
|
IN_CALL: "Available",
|
||||||
|
WRAP_UP: "Available",
|
||||||
|
PAUSED: "On Break",
|
||||||
|
OFFLINE: "Logged Out",
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface AgentSyncMessage {
|
||||||
|
tenantId: string;
|
||||||
|
agentId: string;
|
||||||
|
action: "upsert" | "delete";
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TierSyncMessage {
|
||||||
|
tenantId: string;
|
||||||
|
queueId: string;
|
||||||
|
agentId: string;
|
||||||
|
level?: number;
|
||||||
|
position?: number;
|
||||||
|
action: "upsert" | "delete";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Agentes são 100% dinâmicos no mod_callcenter (sem arquivo, ao contrário
|
||||||
|
* de filas/troncos) — `agent add` só roda uma vez (erro "already exist" se
|
||||||
|
* repetido, capturado e ignorado); status/contact podem ser reenviados sem
|
||||||
|
* problema (agente.md secao 45-47).
|
||||||
|
*/
|
||||||
|
export async function syncAgent(msg: AgentSyncMessage): Promise<void> {
|
||||||
|
const providerInstance = getProvider();
|
||||||
|
const connected = await providerInstance.waitUntilConnected(5000);
|
||||||
|
if (!connected) {
|
||||||
|
logger.warn("ESL nao conectado, sync de agente adiado", { agentId: msg.agentId });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const prisma = getPrismaClient();
|
||||||
|
const tenant = await prisma.tenant.findUnique({ where: { id: msg.tenantId } });
|
||||||
|
if (!tenant?.telephonyDomain) return;
|
||||||
|
|
||||||
|
const agentName = `${msg.agentId}@${tenant.telephonyDomain}`;
|
||||||
|
|
||||||
|
if (msg.action === "delete") {
|
||||||
|
await providerInstance.removeAgent(agentName).catch(() => undefined);
|
||||||
|
logger.info("agente removido do FreeSWITCH", { agentName });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const agent = await withTenantContext(prisma, msg.tenantId, (tx) =>
|
||||||
|
tx.agent.findFirst({
|
||||||
|
where: { id: msg.agentId, tenantId: msg.tenantId, deletedAt: null },
|
||||||
|
include: { extension: true },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
if (!agent || !agent.enabled) {
|
||||||
|
await providerInstance.removeAgent(agentName).catch(() => undefined);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await providerInstance.addAgent(agentName).catch(() => undefined); // "already exist" e' esperado/ok
|
||||||
|
|
||||||
|
if (agent.extension) {
|
||||||
|
const contact = `{ignore_early_media=true}user/${agent.extension.number}@${agent.extension.domain}`;
|
||||||
|
await providerInstance.setAgentContact(agentName, contact);
|
||||||
|
}
|
||||||
|
|
||||||
|
const status = STATUS_MAP[agent.state] ?? "Logged Out";
|
||||||
|
await providerInstance.setAgentStatus(agentName, status);
|
||||||
|
|
||||||
|
logger.info("agente sincronizado", { agentName, status });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function syncTier(msg: TierSyncMessage): Promise<void> {
|
||||||
|
const providerInstance = getProvider();
|
||||||
|
const connected = await providerInstance.waitUntilConnected(5000);
|
||||||
|
if (!connected) {
|
||||||
|
logger.warn("ESL nao conectado, sync de tier adiado", { agentId: msg.agentId, queueId: msg.queueId });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const prisma = getPrismaClient();
|
||||||
|
const tenant = await prisma.tenant.findUnique({ where: { id: msg.tenantId } });
|
||||||
|
if (!tenant?.telephonyDomain) return;
|
||||||
|
|
||||||
|
const agentName = `${msg.agentId}@${tenant.telephonyDomain}`;
|
||||||
|
const queueName = `${msg.queueId}@${tenant.telephonyDomain}`;
|
||||||
|
|
||||||
|
if (msg.action === "delete") {
|
||||||
|
await providerInstance.removeAgentFromQueue(queueName, agentName).catch(() => undefined);
|
||||||
|
logger.info("tier removido", { queueName, agentName });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await providerInstance
|
||||||
|
.addAgentToQueue(queueName, agentName, msg.level ?? 1, msg.position ?? 1)
|
||||||
|
.catch(() => undefined); // "already exist" e' esperado/ok
|
||||||
|
|
||||||
|
logger.info("tier sincronizado", { queueName, agentName });
|
||||||
|
}
|
||||||
@@ -8,10 +8,13 @@ import { buildDirectoryUserXml, NOT_FOUND_XML } from "@b2bcall/telephony";
|
|||||||
import { createLogger } from "@b2bcall/shared";
|
import { createLogger } from "@b2bcall/shared";
|
||||||
import { syncTrunks } from "./trunk-sync";
|
import { syncTrunks } from "./trunk-sync";
|
||||||
import { syncQueues } from "./queue-sync";
|
import { syncQueues } from "./queue-sync";
|
||||||
|
import { syncAgent, syncTier, type AgentSyncMessage, type TierSyncMessage } from "./agent-sync";
|
||||||
|
|
||||||
const logger = createLogger("b2bcall-fs-config");
|
const logger = createLogger("b2bcall-fs-config");
|
||||||
|
|
||||||
const TRUNKS_SYNC_CHANNEL = "b2bcall:trunks:sync";
|
const TRUNKS_SYNC_CHANNEL = "b2bcall:trunks:sync";
|
||||||
|
const AGENTS_SYNC_CHANNEL = "b2bcall:agents:sync";
|
||||||
|
const TIERS_SYNC_CHANNEL = "b2bcall:tiers:sync";
|
||||||
const QUEUES_SYNC_CHANNEL = "b2bcall:queues:sync";
|
const QUEUES_SYNC_CHANNEL = "b2bcall:queues:sync";
|
||||||
|
|
||||||
function requireEnv(name: string): string {
|
function requireEnv(name: string): string {
|
||||||
@@ -174,12 +177,26 @@ async function main() {
|
|||||||
// criados enquanto este servico estava fora do ar.
|
// criados enquanto este servico estava fora do ar.
|
||||||
const subscriber = new Redis(process.env.REDIS_URL!);
|
const subscriber = new Redis(process.env.REDIS_URL!);
|
||||||
subscriber.on("error", (err) => logger.error("erro na conexao Redis (subscriber)", { error: String(err) }));
|
subscriber.on("error", (err) => logger.error("erro na conexao Redis (subscriber)", { error: String(err) }));
|
||||||
await subscriber.subscribe(TRUNKS_SYNC_CHANNEL, QUEUES_SYNC_CHANNEL);
|
await subscriber.subscribe(TRUNKS_SYNC_CHANNEL, QUEUES_SYNC_CHANNEL, AGENTS_SYNC_CHANNEL, TIERS_SYNC_CHANNEL);
|
||||||
subscriber.on("message", (channel, _msg) => {
|
subscriber.on("message", (channel, msg) => {
|
||||||
if (channel === TRUNKS_SYNC_CHANNEL) {
|
if (channel === TRUNKS_SYNC_CHANNEL) {
|
||||||
syncTrunks().catch((err) => logger.error("falha ao sincronizar trunks", { error: String(err) }));
|
syncTrunks().catch((err) => logger.error("falha ao sincronizar trunks", { error: String(err) }));
|
||||||
} else if (channel === QUEUES_SYNC_CHANNEL) {
|
} else if (channel === QUEUES_SYNC_CHANNEL) {
|
||||||
syncQueues().catch((err) => logger.error("falha ao sincronizar filas", { error: String(err) }));
|
syncQueues().catch((err) => logger.error("falha ao sincronizar filas", { error: String(err) }));
|
||||||
|
} else if (channel === AGENTS_SYNC_CHANNEL) {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(msg) as AgentSyncMessage;
|
||||||
|
syncAgent(parsed).catch((err) => logger.error("falha ao sincronizar agente", { error: String(err) }));
|
||||||
|
} catch (err) {
|
||||||
|
logger.error("mensagem invalida no canal de agentes", { error: String(err) });
|
||||||
|
}
|
||||||
|
} else if (channel === TIERS_SYNC_CHANNEL) {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(msg) as TierSyncMessage;
|
||||||
|
syncTier(parsed).catch((err) => logger.error("falha ao sincronizar tier", { error: String(err) }));
|
||||||
|
} catch (err) {
|
||||||
|
logger.error("mensagem invalida no canal de tiers", { error: String(err) });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
109
docs/AGENTS.md
Normal file
109
docs/AGENTS.md
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
# Agentes, Tiers e Pausas
|
||||||
|
|
||||||
|
Agente.md secao 45-49 e 52. Completa o Call Center: depois desta fase, um
|
||||||
|
usuário autenticado consegue logar como agente, entrar numa fila real, se
|
||||||
|
pausar e voltar — tudo refletido de verdade no FreeSWITCH.
|
||||||
|
|
||||||
|
## Modelo
|
||||||
|
|
||||||
|
- `agents` (tenant-scoped, RLS): liga `User` (login) → `Extension` (ramal
|
||||||
|
SIP) → identidade de agente (`User`/`Agent`/`Extension` separados,
|
||||||
|
agente.md secao 45). `state` espelha o estado real (secao 46), nunca
|
||||||
|
escrito direto pela API — só via login/logout/pause/resume.
|
||||||
|
- `tiers`: Queue↔Agent (secao 52) — level/position espelham 1:1 o
|
||||||
|
mod_callcenter.
|
||||||
|
- `agent_sessions`: uma linha por ciclo login→logout.
|
||||||
|
- `agent_state_events`: histórico de transições de estado (auditoria).
|
||||||
|
- `pause_reasons`/`agent_pause_events` (secao 48).
|
||||||
|
|
||||||
|
## Mecanismo: 100% dinâmico, sem arquivo
|
||||||
|
|
||||||
|
Confirmado na fase anterior (Queues) via `help callcenter_config`: agentes e
|
||||||
|
tiers não têm representação em XML — só comandos ESL diretos
|
||||||
|
(`agent add/del/set status/set contact`, `tier add/del`). Isso muda o padrão
|
||||||
|
de sincronização: em vez de "regenerar todos os arquivos" (Trunks/Queues),
|
||||||
|
`apps/api` publica uma mensagem **por ação, com payload** (`{tenantId,
|
||||||
|
agentId, action}` em `b2bcall:agents:sync`; `{tenantId, queueId, agentId,
|
||||||
|
level, position, action}` em `b2bcall:tiers:sync`) — `b2bcall-fs-config`
|
||||||
|
recebe e aplica o comando correspondente.
|
||||||
|
|
||||||
|
## Achados reais confirmados manualmente antes de codar
|
||||||
|
|
||||||
|
Rodei cada comando contra o FreeSWITCH real antes de escrever qualquer
|
||||||
|
lógica de sync (mesmo método usado nas fases anteriores):
|
||||||
|
|
||||||
|
- `agent add` em cima de um agente que já existe: **erro**
|
||||||
|
("Agent already exist!") — não é idempotente. `tier add` duplicado: mesmo
|
||||||
|
erro ("Tier already exist!"). Ambos capturados e ignorados no sync
|
||||||
|
(`.catch(() => undefined)`), já que essa condição é esperada em qualquer
|
||||||
|
resync.
|
||||||
|
- `tier del`/`agent del` em algo que não existe: **não dá erro** (+OK) —
|
||||||
|
seguro chamar sem checar existência antes.
|
||||||
|
- `agent set status` só aceita 3 valores exatos: `Available`, `On Break`,
|
||||||
|
`Logged Out` — qualquer outro string dá `-ERR Invalid Agent Status!`
|
||||||
|
(testado deliberadamente). Os estados derivados de chamada do nosso enum
|
||||||
|
(RESERVED/RINGING/IN_CALL/WRAP_UP) não têm status próprio no
|
||||||
|
mod_callcenter — mapeiam pra "Available" (só o `state`, campo separado,
|
||||||
|
muda sozinho conforme a chamada progride — não escrevemos isso).
|
||||||
|
- **Achado de corrida real**, visto ao testar o fluxo completo: como um
|
||||||
|
agente só passa a existir no FreeSWITCH no login (`agent add` roda ali,
|
||||||
|
não na criação do registro `Agent`), atribuir um tier (`POST
|
||||||
|
/queues/:id/agents`) **antes** do primeiro login do agente falha
|
||||||
|
silenciosamente do lado do FreeSWITCH (`-ERR Agent not found!`, logado
|
||||||
|
mas não propagado como erro HTTP). Por isso o login **sempre
|
||||||
|
re-sincroniza todos os tiers do agente** depois de garantir que ele
|
||||||
|
existe — a auto-correção aconteceu exatamente assim no teste real.
|
||||||
|
|
||||||
|
## Fluxo de login (agente.md secao 47)
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /agents/me/login
|
||||||
|
→ valida usuário (JWT) e ramal (Agent.extensionId precisa existir)
|
||||||
|
→ cria agent_session
|
||||||
|
→ agent add (idempotente via catch) + set contact (dial-string do ramal)
|
||||||
|
→ set status Available
|
||||||
|
→ re-sincroniza todos os tiers do agente
|
||||||
|
→ Agent.state = AVAILABLE
|
||||||
|
```
|
||||||
|
|
||||||
|
`POST /agents/me/logout|pause|resume` seguem o mesmo padrão — sempre sobre
|
||||||
|
o agente do **próprio usuário autenticado** (nunca um `agentId` arbitrário
|
||||||
|
do client, mesmo princípio de nunca confiar em tenant_id/ids sensíveis vindo
|
||||||
|
do frontend sem checar contra o JWT).
|
||||||
|
|
||||||
|
## Verificado ponta a ponta
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /extensions {"number":"2000",...}
|
||||||
|
POST /agents {userId, extensionId, name}
|
||||||
|
POST /queues {"name":"Suporte"}
|
||||||
|
POST /queues/:queueId/agents {agentId} → tier add falha (agente ainda nao existe)
|
||||||
|
POST /agents/me/login → agent add + contact + status Available
|
||||||
|
→ tier re-sync (dessa vez funciona)
|
||||||
|
|
||||||
|
callcenter_config agent list
|
||||||
|
→ status=Available, contact={ignore_early_media=true}user/2000@b2bcall.local ✓
|
||||||
|
callcenter_config tier list
|
||||||
|
→ queue|agent com state=Ready ✓
|
||||||
|
|
||||||
|
POST /agents/me/pause {pauseReasonId} → status=On Break ✓
|
||||||
|
POST /agents/me/resume → status=Available ✓
|
||||||
|
POST /agents/me/logout → status=Logged Out ✓
|
||||||
|
```
|
||||||
|
|
||||||
|
Todos os 4 estados confirmados batendo entre o banco (`Agent.state`) e o
|
||||||
|
FreeSWITCH (`agent list`).
|
||||||
|
|
||||||
|
## O que falta
|
||||||
|
|
||||||
|
- Estados derivados de chamada (RINGING, IN_CALL, WRAP_UP, RESERVED) —
|
||||||
|
dependem de `callcenter::info` (CUSTOM event), que **ainda não foi
|
||||||
|
provado funcionando** nesta sessão (mesma lacuna de `sofia::gateway_state`
|
||||||
|
documentada em docs/TRUNKS.md). Não implementado; precisa de uma chamada
|
||||||
|
real passando pela fila pra testar.
|
||||||
|
- Tela do agente (secao 49) — fase Frontend.
|
||||||
|
- Monitoramento de filas/ramais em tempo real (secao 54-55) — depende de
|
||||||
|
WebSocket multi-tenant.
|
||||||
|
- Quota de agentes (`max_agents`) — depende de Plans/Entitlements.
|
||||||
|
- `PauseReason.maxDuration` existe no modelo mas não é aplicado
|
||||||
|
automaticamente ainda (ninguém força o fim da pausa ao expirar).
|
||||||
@@ -72,8 +72,8 @@ DELETE /queues/:id
|
|||||||
|
|
||||||
## O que falta
|
## O que falta
|
||||||
|
|
||||||
- Agentes, Tiers, Pausas (secao 45-49) — fase separada, mecanismo é
|
- Agentes, Tiers, Pausas (secao 45-49) — implementados na fase seguinte, ver
|
||||||
puramente via comando ESL (`agent add`, `tier add`), sem arquivo.
|
docs/AGENTS.md.
|
||||||
- Monitoramento em tempo real das filas (secao 54) — depende de WebSocket
|
- Monitoramento em tempo real das filas (secao 54) — depende de WebSocket
|
||||||
multi-tenant, que ainda não existe.
|
multi-tenant, que ainda não existe.
|
||||||
- `tier-rule-wait-multiply-level` e `tier-rule-no-agent-no-wait` (vistos no
|
- `tier-rule-wait-multiply-level` e `tier-rule-no-agent-no-wait` (vistos no
|
||||||
|
|||||||
@@ -0,0 +1,187 @@
|
|||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "agent_state" AS ENUM ('OFFLINE', 'LOGGED_IN', 'AVAILABLE', 'RESERVED', 'RINGING', 'IN_CALL', 'WRAP_UP', 'PAUSED');
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "agents" (
|
||||||
|
"id" UUID NOT NULL,
|
||||||
|
"tenant_id" UUID NOT NULL,
|
||||||
|
"user_id" UUID NOT NULL,
|
||||||
|
"extension_id" UUID,
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
"max_no_answer" INTEGER NOT NULL DEFAULT 3,
|
||||||
|
"wrap_up_time" INTEGER NOT NULL DEFAULT 10,
|
||||||
|
"reject_delay_time" INTEGER NOT NULL DEFAULT 10,
|
||||||
|
"busy_delay_time" INTEGER NOT NULL DEFAULT 60,
|
||||||
|
"no_answer_delay_time" INTEGER NOT NULL DEFAULT 10,
|
||||||
|
"state" "agent_state" NOT NULL DEFAULT 'OFFLINE',
|
||||||
|
"state_updated_at" TIMESTAMP(3),
|
||||||
|
"enabled" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||||
|
"deleted_at" TIMESTAMP(3),
|
||||||
|
|
||||||
|
CONSTRAINT "agents_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "tiers" (
|
||||||
|
"id" UUID NOT NULL,
|
||||||
|
"tenant_id" UUID NOT NULL,
|
||||||
|
"queue_id" UUID NOT NULL,
|
||||||
|
"agent_id" UUID NOT NULL,
|
||||||
|
"level" INTEGER NOT NULL DEFAULT 1,
|
||||||
|
"position" INTEGER NOT NULL DEFAULT 1,
|
||||||
|
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "tiers_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "agent_sessions" (
|
||||||
|
"id" UUID NOT NULL,
|
||||||
|
"tenant_id" UUID NOT NULL,
|
||||||
|
"agent_id" UUID 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" UUID NOT NULL,
|
||||||
|
"tenant_id" UUID NOT NULL,
|
||||||
|
"agent_id" UUID NOT NULL,
|
||||||
|
"state" "agent_state" NOT NULL,
|
||||||
|
"occurred_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "agent_state_events_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "pause_reasons" (
|
||||||
|
"id" UUID NOT NULL,
|
||||||
|
"tenant_id" UUID NOT NULL,
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
"code" TEXT NOT NULL,
|
||||||
|
"description" TEXT,
|
||||||
|
"max_duration" INTEGER,
|
||||||
|
"paid" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"enabled" 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 "agent_pause_events" (
|
||||||
|
"id" UUID NOT NULL,
|
||||||
|
"tenant_id" UUID NOT NULL,
|
||||||
|
"agent_id" UUID NOT NULL,
|
||||||
|
"pause_reason_id" UUID 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 INDEX "agents_tenant_id_idx" ON "agents"("tenant_id");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "agents_tenant_id_user_id_key" ON "agents"("tenant_id", "user_id");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "tiers_tenant_id_idx" ON "tiers"("tenant_id");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "tiers_queue_id_agent_id_key" ON "tiers"("queue_id", "agent_id");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "agent_sessions_tenant_id_agent_id_idx" ON "agent_sessions"("tenant_id", "agent_id");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "agent_state_events_tenant_id_agent_id_occurred_at_idx" ON "agent_state_events"("tenant_id", "agent_id", "occurred_at");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "pause_reasons_tenant_id_idx" ON "pause_reasons"("tenant_id");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "pause_reasons_tenant_id_code_key" ON "pause_reasons"("tenant_id", "code");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "agent_pause_events_tenant_id_agent_id_idx" ON "agent_pause_events"("tenant_id", "agent_id");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "agents" ADD CONSTRAINT "agents_tenant_id_fkey" FOREIGN KEY ("tenant_id") REFERENCES "tenants"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "agents" ADD CONSTRAINT "agents_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "agents" ADD CONSTRAINT "agents_extension_id_fkey" FOREIGN KEY ("extension_id") REFERENCES "extensions"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "tiers" ADD CONSTRAINT "tiers_tenant_id_fkey" FOREIGN KEY ("tenant_id") REFERENCES "tenants"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "tiers" ADD CONSTRAINT "tiers_queue_id_fkey" FOREIGN KEY ("queue_id") REFERENCES "queues"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "tiers" ADD CONSTRAINT "tiers_agent_id_fkey" FOREIGN KEY ("agent_id") REFERENCES "agents"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "agent_sessions" ADD CONSTRAINT "agent_sessions_tenant_id_fkey" FOREIGN KEY ("tenant_id") REFERENCES "tenants"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "agent_sessions" ADD CONSTRAINT "agent_sessions_agent_id_fkey" FOREIGN KEY ("agent_id") REFERENCES "agents"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "agent_state_events" ADD CONSTRAINT "agent_state_events_tenant_id_fkey" FOREIGN KEY ("tenant_id") REFERENCES "tenants"("id") ON DELETE RESTRICT 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 RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "pause_reasons" ADD CONSTRAINT "pause_reasons_tenant_id_fkey" FOREIGN KEY ("tenant_id") REFERENCES "tenants"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "agent_pause_events" ADD CONSTRAINT "agent_pause_events_tenant_id_fkey" FOREIGN KEY ("tenant_id") REFERENCES "tenants"("id") ON DELETE RESTRICT 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 RESTRICT 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;
|
||||||
|
|
||||||
|
-- Tabelas de negocio tenant-scoped: RLS obrigatorio em todas (ver docs/TENANT_ISOLATION.md).
|
||||||
|
ALTER TABLE "agents" ENABLE ROW LEVEL SECURITY;
|
||||||
|
ALTER TABLE "agents" FORCE ROW LEVEL SECURITY;
|
||||||
|
CREATE POLICY "tenant_isolation" ON "agents"
|
||||||
|
USING (tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::uuid);
|
||||||
|
|
||||||
|
ALTER TABLE "tiers" ENABLE ROW LEVEL SECURITY;
|
||||||
|
ALTER TABLE "tiers" FORCE ROW LEVEL SECURITY;
|
||||||
|
CREATE POLICY "tenant_isolation" ON "tiers"
|
||||||
|
USING (tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::uuid);
|
||||||
|
|
||||||
|
ALTER TABLE "agent_sessions" ENABLE ROW LEVEL SECURITY;
|
||||||
|
ALTER TABLE "agent_sessions" FORCE ROW LEVEL SECURITY;
|
||||||
|
CREATE POLICY "tenant_isolation" ON "agent_sessions"
|
||||||
|
USING (tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::uuid);
|
||||||
|
|
||||||
|
ALTER TABLE "agent_state_events" ENABLE ROW LEVEL SECURITY;
|
||||||
|
ALTER TABLE "agent_state_events" FORCE ROW LEVEL SECURITY;
|
||||||
|
CREATE POLICY "tenant_isolation" ON "agent_state_events"
|
||||||
|
USING (tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::uuid);
|
||||||
|
|
||||||
|
ALTER TABLE "pause_reasons" ENABLE ROW LEVEL SECURITY;
|
||||||
|
ALTER TABLE "pause_reasons" FORCE ROW LEVEL SECURITY;
|
||||||
|
CREATE POLICY "tenant_isolation" ON "pause_reasons"
|
||||||
|
USING (tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::uuid);
|
||||||
|
|
||||||
|
ALTER TABLE "agent_pause_events" ENABLE ROW LEVEL SECURITY;
|
||||||
|
ALTER TABLE "agent_pause_events" FORCE ROW LEVEL SECURITY;
|
||||||
|
CREATE POLICY "tenant_isolation" ON "agent_pause_events"
|
||||||
|
USING (tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::uuid);
|
||||||
@@ -26,7 +26,7 @@ model Tenant {
|
|||||||
status TenantStatus @default(TRIAL)
|
status TenantStatus @default(TRIAL)
|
||||||
timezone String @default("America/Sao_Paulo")
|
timezone String @default("America/Sao_Paulo")
|
||||||
locale String @default("pt-BR")
|
locale String @default("pt-BR")
|
||||||
billingCurrency String @map("billing_currency") @default("BRL")
|
billingCurrency String @default("BRL") @map("billing_currency")
|
||||||
telephonyDomain String? @map("telephony_domain")
|
telephonyDomain String? @map("telephony_domain")
|
||||||
createdAt DateTime @default(now()) @map("created_at")
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
updatedAt DateTime @updatedAt @map("updated_at")
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
@@ -39,6 +39,12 @@ model Tenant {
|
|||||||
dialplanExtensions DialplanExtension[]
|
dialplanExtensions DialplanExtension[]
|
||||||
dialplanVersions DialplanVersion[]
|
dialplanVersions DialplanVersion[]
|
||||||
queues Queue[]
|
queues Queue[]
|
||||||
|
agents Agent[]
|
||||||
|
pauseReasons PauseReason[]
|
||||||
|
tiers Tier[]
|
||||||
|
agentSessions AgentSession[]
|
||||||
|
agentStateEvents AgentStateEvent[]
|
||||||
|
agentPauseEvents AgentPauseEvent[]
|
||||||
|
|
||||||
@@map("tenants")
|
@@map("tenants")
|
||||||
}
|
}
|
||||||
@@ -66,6 +72,7 @@ model User {
|
|||||||
memberships TenantMembership[]
|
memberships TenantMembership[]
|
||||||
userRoles UserRole[]
|
userRoles UserRole[]
|
||||||
sessions Session[]
|
sessions Session[]
|
||||||
|
agents Agent[]
|
||||||
|
|
||||||
@@map("users")
|
@@map("users")
|
||||||
}
|
}
|
||||||
@@ -216,6 +223,7 @@ model Extension {
|
|||||||
deletedAt DateTime? @map("deleted_at")
|
deletedAt DateTime? @map("deleted_at")
|
||||||
|
|
||||||
tenant Tenant @relation(fields: [tenantId], references: [id])
|
tenant Tenant @relation(fields: [tenantId], references: [id])
|
||||||
|
agents Agent[]
|
||||||
|
|
||||||
@@unique([tenantId, number])
|
@@unique([tenantId, number])
|
||||||
@@index([tenantId])
|
@@index([tenantId])
|
||||||
@@ -433,8 +441,164 @@ model Queue {
|
|||||||
deletedAt DateTime? @map("deleted_at")
|
deletedAt DateTime? @map("deleted_at")
|
||||||
|
|
||||||
tenant Tenant @relation(fields: [tenantId], references: [id])
|
tenant Tenant @relation(fields: [tenantId], references: [id])
|
||||||
|
tiers Tier[]
|
||||||
|
|
||||||
@@unique([tenantId, name])
|
@@unique([tenantId, name])
|
||||||
@@index([tenantId])
|
@@index([tenantId])
|
||||||
@@map("queues")
|
@@map("queues")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum AgentState {
|
||||||
|
OFFLINE
|
||||||
|
LOGGED_IN
|
||||||
|
AVAILABLE
|
||||||
|
RESERVED
|
||||||
|
RINGING
|
||||||
|
IN_CALL
|
||||||
|
WRAP_UP
|
||||||
|
PAUSED
|
||||||
|
|
||||||
|
@@map("agent_state")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tabela tenant-scoped protegida por RLS. Separa User (login) / Agent
|
||||||
|
// (identidade de call center) / Extension (ramal SIP usado como contato) —
|
||||||
|
// agente.md secao 45. O nome no FreeSWITCH é `<agent.id>@<tenant.telephonyDomain>`,
|
||||||
|
// mesma convenção UUID das outras entidades (agente.md secao 47).
|
||||||
|
model Agent {
|
||||||
|
id String @id @default(uuid()) @db.Uuid
|
||||||
|
tenantId String @map("tenant_id") @db.Uuid
|
||||||
|
|
||||||
|
userId String @map("user_id") @db.Uuid
|
||||||
|
extensionId String? @map("extension_id") @db.Uuid
|
||||||
|
|
||||||
|
name String
|
||||||
|
|
||||||
|
maxNoAnswer Int @default(3) @map("max_no_answer")
|
||||||
|
wrapUpTime Int @default(10) @map("wrap_up_time")
|
||||||
|
rejectDelayTime Int @default(10) @map("reject_delay_time")
|
||||||
|
busyDelayTime Int @default(60) @map("busy_delay_time")
|
||||||
|
noAnswerDelayTime Int @default(10) @map("no_answer_delay_time")
|
||||||
|
|
||||||
|
// Espelha o estado real (secao 46) — nunca escrito diretamente pela API,
|
||||||
|
// só por login/logout/pause/resume ou por eventos do FreeSWITCH.
|
||||||
|
state AgentState @default(OFFLINE)
|
||||||
|
stateUpdatedAt DateTime? @map("state_updated_at")
|
||||||
|
|
||||||
|
enabled Boolean @default(true)
|
||||||
|
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
deletedAt DateTime? @map("deleted_at")
|
||||||
|
|
||||||
|
tenant Tenant @relation(fields: [tenantId], references: [id])
|
||||||
|
user User @relation(fields: [userId], references: [id])
|
||||||
|
extension Extension? @relation(fields: [extensionId], references: [id])
|
||||||
|
tiers Tier[]
|
||||||
|
sessions AgentSession[]
|
||||||
|
stateEvents AgentStateEvent[]
|
||||||
|
pauseEvents AgentPauseEvent[]
|
||||||
|
|
||||||
|
@@unique([tenantId, userId])
|
||||||
|
@@index([tenantId])
|
||||||
|
@@map("agents")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Queue <-> Agent (agente.md secao 52). O nome da fila/agente no FreeSWITCH
|
||||||
|
// já é o "<id>@<domain>" — level/position espelham 1:1 os mesmos conceitos
|
||||||
|
// do mod_callcenter.
|
||||||
|
model Tier {
|
||||||
|
id String @id @default(uuid()) @db.Uuid
|
||||||
|
tenantId String @map("tenant_id") @db.Uuid
|
||||||
|
|
||||||
|
queueId String @map("queue_id") @db.Uuid
|
||||||
|
agentId String @map("agent_id") @db.Uuid
|
||||||
|
|
||||||
|
level Int @default(1)
|
||||||
|
position Int @default(1)
|
||||||
|
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
|
||||||
|
tenant Tenant @relation(fields: [tenantId], references: [id])
|
||||||
|
queue Queue @relation(fields: [queueId], references: [id])
|
||||||
|
agent Agent @relation(fields: [agentId], references: [id])
|
||||||
|
|
||||||
|
@@unique([queueId, agentId])
|
||||||
|
@@index([tenantId])
|
||||||
|
@@map("tiers")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Uma "sessão" = do login até o logout do agente (agente.md secao 45, 47).
|
||||||
|
model AgentSession {
|
||||||
|
id String @id @default(uuid()) @db.Uuid
|
||||||
|
tenantId String @map("tenant_id") @db.Uuid
|
||||||
|
agentId String @map("agent_id") @db.Uuid
|
||||||
|
|
||||||
|
startedAt DateTime @default(now()) @map("started_at")
|
||||||
|
endedAt DateTime? @map("ended_at")
|
||||||
|
|
||||||
|
tenant Tenant @relation(fields: [tenantId], references: [id])
|
||||||
|
agent Agent @relation(fields: [agentId], references: [id])
|
||||||
|
|
||||||
|
@@index([tenantId, agentId])
|
||||||
|
@@map("agent_sessions")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Histórico de transições de estado (agente.md secao 46) — nunca deletado,
|
||||||
|
// serve de auditoria e insumo pra relatórios (secao 158).
|
||||||
|
model AgentStateEvent {
|
||||||
|
id String @id @default(uuid()) @db.Uuid
|
||||||
|
tenantId String @map("tenant_id") @db.Uuid
|
||||||
|
agentId String @map("agent_id") @db.Uuid
|
||||||
|
|
||||||
|
state AgentState
|
||||||
|
occurredAt DateTime @default(now()) @map("occurred_at")
|
||||||
|
|
||||||
|
tenant Tenant @relation(fields: [tenantId], references: [id])
|
||||||
|
agent Agent @relation(fields: [agentId], references: [id])
|
||||||
|
|
||||||
|
@@index([tenantId, agentId, occurredAt])
|
||||||
|
@@map("agent_state_events")
|
||||||
|
}
|
||||||
|
|
||||||
|
// agente.md secao 48.
|
||||||
|
model PauseReason {
|
||||||
|
id String @id @default(uuid()) @db.Uuid
|
||||||
|
tenantId String @map("tenant_id") @db.Uuid
|
||||||
|
|
||||||
|
name String
|
||||||
|
code String
|
||||||
|
description String?
|
||||||
|
|
||||||
|
maxDuration Int? @map("max_duration")
|
||||||
|
paid Boolean @default(false)
|
||||||
|
|
||||||
|
enabled Boolean @default(true)
|
||||||
|
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
|
||||||
|
tenant Tenant @relation(fields: [tenantId], references: [id])
|
||||||
|
pauseEvents AgentPauseEvent[]
|
||||||
|
|
||||||
|
@@unique([tenantId, code])
|
||||||
|
@@index([tenantId])
|
||||||
|
@@map("pause_reasons")
|
||||||
|
}
|
||||||
|
|
||||||
|
model AgentPauseEvent {
|
||||||
|
id String @id @default(uuid()) @db.Uuid
|
||||||
|
tenantId String @map("tenant_id") @db.Uuid
|
||||||
|
agentId String @map("agent_id") @db.Uuid
|
||||||
|
pauseReasonId String @map("pause_reason_id") @db.Uuid
|
||||||
|
|
||||||
|
startedAt DateTime @default(now()) @map("started_at")
|
||||||
|
endedAt DateTime? @map("ended_at")
|
||||||
|
|
||||||
|
tenant Tenant @relation(fields: [tenantId], references: [id])
|
||||||
|
agent Agent @relation(fields: [agentId], references: [id])
|
||||||
|
pauseReason PauseReason @relation(fields: [pauseReasonId], references: [id])
|
||||||
|
|
||||||
|
@@index([tenantId, agentId])
|
||||||
|
@@map("agent_pause_events")
|
||||||
|
}
|
||||||
|
|||||||
@@ -149,12 +149,32 @@ export class FreeSwitchTelephonyProvider implements TelephonyProvider {
|
|||||||
await this.call().api(`callcenter_config agent set contact '${agentId}' '${contact}'`);
|
await this.call().api(`callcenter_config agent set contact '${agentId}' '${contact}'`);
|
||||||
}
|
}
|
||||||
|
|
||||||
async addAgentToQueue(queueName: string, agentId: string): Promise<void> {
|
/**
|
||||||
await this.call().api(`callcenter_config queue add member ${queueName} ${agentId}`);
|
* `callcenter_config` NÃO tem "queue add member"/"queue del member" —
|
||||||
|
* comando inexistente, confirmado com `help callcenter_config` contra o
|
||||||
|
* FreeSWITCH real (fase Agents/Tiers). O jeito certo de associar um
|
||||||
|
* agente a uma fila é `tier add`/`tier del`.
|
||||||
|
*/
|
||||||
|
async addAgentToQueue(queueName: string, agentId: string, level = 1, position = 1): Promise<void> {
|
||||||
|
await this.call().api(`callcenter_config tier add ${queueName} ${agentId} ${level} ${position}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
async removeAgentFromQueue(queueName: string, agentId: string): Promise<void> {
|
async removeAgentFromQueue(queueName: string, agentId: string): Promise<void> {
|
||||||
await this.call().api(`callcenter_config queue del member ${queueName} ${agentId}`);
|
await this.call().api(`callcenter_config tier del ${queueName} ${agentId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `callcenter_config agent add` — precisa existir antes de
|
||||||
|
* setAgentStatus/setAgentContact/addAgentToQueue funcionarem pra um
|
||||||
|
* agente novo. `type` normalmente é "callback" (disca pro `contact`
|
||||||
|
* quando uma chamada é oferecida).
|
||||||
|
*/
|
||||||
|
async addAgent(agentId: string, type: "callback" | "uuid-standby" = "callback"): Promise<void> {
|
||||||
|
await this.call().api(`callcenter_config agent add '${agentId}' '${type}'`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async removeAgent(agentId: string): Promise<void> {
|
||||||
|
await this.call().api(`callcenter_config agent del '${agentId}'`);
|
||||||
}
|
}
|
||||||
|
|
||||||
async reloadXml(): Promise<void> {
|
async reloadXml(): Promise<void> {
|
||||||
|
|||||||
Reference in New Issue
Block a user