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:
2026-08-28 09:58:45 -03:00
parent 6628578c42
commit a05104a05f
21 changed files with 1402 additions and 140 deletions

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