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:
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" };
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user