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:
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 { QueuesController } from "./queues.controller";
|
||||
import { QueueAgentsController } from "./queue-agents.controller";
|
||||
|
||||
@Module({
|
||||
controllers: [QueuesController],
|
||||
controllers: [QueuesController, QueueAgentsController],
|
||||
})
|
||||
export class QueuesModule {}
|
||||
|
||||
Reference in New Issue
Block a user