From a05104a05fffc39ff99c660f5f30b9a55701340b Mon Sep 17 00:00:00 2001 From: Matheus Date: Fri, 28 Aug 2026 09:58:45 -0300 Subject: [PATCH] =?UTF-8?q?feat(agents):=20agentes,=20tiers=20e=20pausas?= =?UTF-8?q?=20=E2=80=94=20call=20center=20completo?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01X1HxY46WGU4G1zmVDNKcWw --- TODO.md | 36 +- apps/api/src/agents/agent-sync.helper.ts | 26 ++ apps/api/src/agents/agents-me.controller.ts | 173 +++++++ apps/api/src/agents/agents.controller.ts | 109 +++++ apps/api/src/agents/agents.module.ts | 8 + apps/api/src/agents/dto/create-agent.dto.ts | 26 ++ apps/api/src/agents/dto/pause.dto.ts | 6 + apps/api/src/app.module.ts | 13 +- .../dto/create-pause-reason.dto.ts | 26 ++ .../pause-reasons/pause-reasons.controller.ts | 88 ++++ .../src/pause-reasons/pause-reasons.module.ts | 7 + .../api/src/queues/dto/add-queue-agent.dto.ts | 18 + .../api/src/queues/queue-agents.controller.ts | 97 ++++ apps/api/src/queues/queues.module.ts | 3 +- apps/freeswitch-config/src/agent-sync.ts | 135 ++++++ apps/freeswitch-config/src/main.ts | 21 +- docs/AGENTS.md | 109 +++++ docs/QUEUES.md | 4 +- .../20260828124245_agents/migration.sql | 187 ++++++++ packages/database/prisma/schema.prisma | 424 ++++++++++++------ packages/telephony/src/freeswitch-provider.ts | 26 +- 21 files changed, 1402 insertions(+), 140 deletions(-) create mode 100644 apps/api/src/agents/agent-sync.helper.ts create mode 100644 apps/api/src/agents/agents-me.controller.ts create mode 100644 apps/api/src/agents/agents.controller.ts create mode 100644 apps/api/src/agents/agents.module.ts create mode 100644 apps/api/src/agents/dto/create-agent.dto.ts create mode 100644 apps/api/src/agents/dto/pause.dto.ts create mode 100644 apps/api/src/pause-reasons/dto/create-pause-reason.dto.ts create mode 100644 apps/api/src/pause-reasons/pause-reasons.controller.ts create mode 100644 apps/api/src/pause-reasons/pause-reasons.module.ts create mode 100644 apps/api/src/queues/dto/add-queue-agent.dto.ts create mode 100644 apps/api/src/queues/queue-agents.controller.ts create mode 100644 apps/freeswitch-config/src/agent-sync.ts create mode 100644 docs/AGENTS.md create mode 100644 packages/database/prisma/migrations/20260828124245_agents/migration.sql diff --git a/TODO.md b/TODO.md index 653bd5e..abf15a3 100644 --- a/TODO.md +++ b/TODO.md @@ -200,7 +200,41 @@ - [ ] Monitoramento em tempo real (secao 54) — depende de WebSocket - [ ] 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) --- diff --git a/apps/api/src/agents/agent-sync.helper.ts b/apps/api/src/agents/agent-sync.helper.ts new file mode 100644 index 0000000..acb33fb --- /dev/null +++ b/apps/api/src/agents/agent-sync.helper.ts @@ -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 { + 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 { + await getRedisClient().publish( + TIERS_SYNC_CHANNEL, + JSON.stringify({ tenantId, queueId, agentId, action, level, position }), + ); +} diff --git a/apps/api/src/agents/agents-me.controller.ts b/apps/api/src/agents/agents-me.controller.ts new file mode 100644 index 0000000..0a24152 --- /dev/null +++ b/apps/api/src/agents/agents-me.controller.ts @@ -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" }; + } +} diff --git a/apps/api/src/agents/agents.controller.ts b/apps/api/src/agents/agents.controller.ts new file mode 100644 index 0000000..6b4738f --- /dev/null +++ b/apps/api/src/agents/agents.controller.ts @@ -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"); + } +} diff --git a/apps/api/src/agents/agents.module.ts b/apps/api/src/agents/agents.module.ts new file mode 100644 index 0000000..9b70e9c --- /dev/null +++ b/apps/api/src/agents/agents.module.ts @@ -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 {} diff --git a/apps/api/src/agents/dto/create-agent.dto.ts b/apps/api/src/agents/dto/create-agent.dto.ts new file mode 100644 index 0000000..35648ec --- /dev/null +++ b/apps/api/src/agents/dto/create-agent.dto.ts @@ -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; +} diff --git a/apps/api/src/agents/dto/pause.dto.ts b/apps/api/src/agents/dto/pause.dto.ts new file mode 100644 index 0000000..ac8697b --- /dev/null +++ b/apps/api/src/agents/dto/pause.dto.ts @@ -0,0 +1,6 @@ +import { IsUUID } from "class-validator"; + +export class PauseDto { + @IsUUID() + pauseReasonId!: string; +} diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index 63a3412..cc52112 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -5,8 +5,19 @@ import { ExtensionsModule } from "./extensions/extensions.module"; import { TrunksModule } from "./trunks/trunks.module"; import { DialplanModule } from "./dialplan/dialplan.module"; import { QueuesModule } from "./queues/queues.module"; +import { AgentsModule } from "./agents/agents.module"; +import { PauseReasonsModule } from "./pause-reasons/pause-reasons.module"; @Module({ - imports: [HealthModule, AuthModule, ExtensionsModule, TrunksModule, DialplanModule, QueuesModule], + imports: [ + HealthModule, + AuthModule, + ExtensionsModule, + TrunksModule, + DialplanModule, + QueuesModule, + AgentsModule, + PauseReasonsModule, + ], }) export class AppModule {} diff --git a/apps/api/src/pause-reasons/dto/create-pause-reason.dto.ts b/apps/api/src/pause-reasons/dto/create-pause-reason.dto.ts new file mode 100644 index 0000000..58be7c4 --- /dev/null +++ b/apps/api/src/pause-reasons/dto/create-pause-reason.dto.ts @@ -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; +} diff --git a/apps/api/src/pause-reasons/pause-reasons.controller.ts b/apps/api/src/pause-reasons/pause-reasons.controller.ts new file mode 100644 index 0000000..8cbc8da --- /dev/null +++ b/apps/api/src/pause-reasons/pause-reasons.controller.ts @@ -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, + }); + } +} diff --git a/apps/api/src/pause-reasons/pause-reasons.module.ts b/apps/api/src/pause-reasons/pause-reasons.module.ts new file mode 100644 index 0000000..d1749d6 --- /dev/null +++ b/apps/api/src/pause-reasons/pause-reasons.module.ts @@ -0,0 +1,7 @@ +import { Module } from "@nestjs/common"; +import { PauseReasonsController } from "./pause-reasons.controller"; + +@Module({ + controllers: [PauseReasonsController], +}) +export class PauseReasonsModule {} diff --git a/apps/api/src/queues/dto/add-queue-agent.dto.ts b/apps/api/src/queues/dto/add-queue-agent.dto.ts new file mode 100644 index 0000000..1b360d8 --- /dev/null +++ b/apps/api/src/queues/dto/add-queue-agent.dto.ts @@ -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; +} diff --git a/apps/api/src/queues/queue-agents.controller.ts b/apps/api/src/queues/queue-agents.controller.ts new file mode 100644 index 0000000..346ce03 --- /dev/null +++ b/apps/api/src/queues/queue-agents.controller.ts @@ -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"); + } +} diff --git a/apps/api/src/queues/queues.module.ts b/apps/api/src/queues/queues.module.ts index 28cec6b..376642a 100644 --- a/apps/api/src/queues/queues.module.ts +++ b/apps/api/src/queues/queues.module.ts @@ -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 {} diff --git a/apps/freeswitch-config/src/agent-sync.ts b/apps/freeswitch-config/src/agent-sync.ts new file mode 100644 index 0000000..0239c4d --- /dev/null +++ b/apps/freeswitch-config/src/agent-sync.ts @@ -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> = { + 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 { + 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 { + 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 }); +} diff --git a/apps/freeswitch-config/src/main.ts b/apps/freeswitch-config/src/main.ts index 543000a..3a7f2c8 100644 --- a/apps/freeswitch-config/src/main.ts +++ b/apps/freeswitch-config/src/main.ts @@ -8,10 +8,13 @@ import { buildDirectoryUserXml, NOT_FOUND_XML } from "@b2bcall/telephony"; import { createLogger } from "@b2bcall/shared"; import { syncTrunks } from "./trunk-sync"; import { syncQueues } from "./queue-sync"; +import { syncAgent, syncTier, type AgentSyncMessage, type TierSyncMessage } from "./agent-sync"; const logger = createLogger("b2bcall-fs-config"); 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"; function requireEnv(name: string): string { @@ -174,12 +177,26 @@ async function main() { // criados enquanto este servico estava fora do ar. const subscriber = new Redis(process.env.REDIS_URL!); subscriber.on("error", (err) => logger.error("erro na conexao Redis (subscriber)", { error: String(err) })); - await subscriber.subscribe(TRUNKS_SYNC_CHANNEL, QUEUES_SYNC_CHANNEL); - subscriber.on("message", (channel, _msg) => { + await subscriber.subscribe(TRUNKS_SYNC_CHANNEL, QUEUES_SYNC_CHANNEL, AGENTS_SYNC_CHANNEL, TIERS_SYNC_CHANNEL); + subscriber.on("message", (channel, msg) => { if (channel === TRUNKS_SYNC_CHANNEL) { syncTrunks().catch((err) => logger.error("falha ao sincronizar trunks", { error: String(err) })); } else if (channel === QUEUES_SYNC_CHANNEL) { 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) }); + } } }); diff --git a/docs/AGENTS.md b/docs/AGENTS.md new file mode 100644 index 0000000..0d9c587 --- /dev/null +++ b/docs/AGENTS.md @@ -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). diff --git a/docs/QUEUES.md b/docs/QUEUES.md index 7ceeb8b..3d1ec47 100644 --- a/docs/QUEUES.md +++ b/docs/QUEUES.md @@ -72,8 +72,8 @@ DELETE /queues/:id ## O que falta -- Agentes, Tiers, Pausas (secao 45-49) — fase separada, mecanismo é - puramente via comando ESL (`agent add`, `tier add`), sem arquivo. +- Agentes, Tiers, Pausas (secao 45-49) — implementados na fase seguinte, ver + docs/AGENTS.md. - Monitoramento em tempo real das filas (secao 54) — depende de WebSocket multi-tenant, que ainda não existe. - `tier-rule-wait-multiply-level` e `tier-rule-no-agent-no-wait` (vistos no diff --git a/packages/database/prisma/migrations/20260828124245_agents/migration.sql b/packages/database/prisma/migrations/20260828124245_agents/migration.sql new file mode 100644 index 0000000..f29bf77 --- /dev/null +++ b/packages/database/prisma/migrations/20260828124245_agents/migration.sql @@ -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); diff --git a/packages/database/prisma/schema.prisma b/packages/database/prisma/schema.prisma index f6f0498..bae3c5c 100644 --- a/packages/database/prisma/schema.prisma +++ b/packages/database/prisma/schema.prisma @@ -17,28 +17,34 @@ enum TenantStatus { } model Tenant { - id String @id @default(uuid()) @db.Uuid - code String @unique - slug String @unique - legalName String @map("legal_name") - tradeName String? @map("trade_name") - taxId String? @map("tax_id") - status TenantStatus @default(TRIAL) - timezone String @default("America/Sao_Paulo") - locale String @default("pt-BR") - billingCurrency String @map("billing_currency") @default("BRL") - telephonyDomain String? @map("telephony_domain") - createdAt DateTime @default(now()) @map("created_at") - updatedAt DateTime @updatedAt @map("updated_at") - deletedAt DateTime? @map("deleted_at") + id String @id @default(uuid()) @db.Uuid + code String @unique + slug String @unique + legalName String @map("legal_name") + tradeName String? @map("trade_name") + taxId String? @map("tax_id") + status TenantStatus @default(TRIAL) + timezone String @default("America/Sao_Paulo") + locale String @default("pt-BR") + billingCurrency String @default("BRL") @map("billing_currency") + telephonyDomain String? @map("telephony_domain") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + deletedAt DateTime? @map("deleted_at") - memberships TenantMembership[] - userRoles UserRole[] - extensions Extension[] - trunks Trunk[] + memberships TenantMembership[] + userRoles UserRole[] + extensions Extension[] + trunks Trunk[] dialplanExtensions DialplanExtension[] dialplanVersions DialplanVersion[] queues Queue[] + agents Agent[] + pauseReasons PauseReason[] + tiers Tier[] + agentSessions AgentSession[] + agentStateEvents AgentStateEvent[] + agentPauseEvents AgentPauseEvent[] @@map("tenants") } @@ -53,19 +59,20 @@ enum UserStatus { // Identidade global do usuário. NUNCA carrega tenant_id diretamente — o tenant // é sempre resolvido via TenantMembership (agente.md secao 31). model User { - id String @id @default(uuid()) @db.Uuid - email String @unique - passwordHash String @map("password_hash") - name String - status UserStatus @default(ACTIVE) - mustChangePassword Boolean @default(false) @map("must_change_password") - createdAt DateTime @default(now()) @map("created_at") - updatedAt DateTime @updatedAt @map("updated_at") - deletedAt DateTime? @map("deleted_at") + id String @id @default(uuid()) @db.Uuid + email String @unique + passwordHash String @map("password_hash") + name String + status UserStatus @default(ACTIVE) + mustChangePassword Boolean @default(false) @map("must_change_password") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + deletedAt DateTime? @map("deleted_at") - memberships TenantMembership[] - userRoles UserRole[] - sessions Session[] + memberships TenantMembership[] + userRoles UserRole[] + sessions Session[] + agents Agent[] @@map("users") } @@ -179,8 +186,8 @@ model TenantMembership { userId String @map("user_id") @db.Uuid createdAt DateTime @default(now()) @map("created_at") - tenant Tenant @relation(fields: [tenantId], references: [id]) - user User @relation(fields: [userId], references: [id]) + tenant Tenant @relation(fields: [tenantId], references: [id]) + user User @relation(fields: [userId], references: [id]) @@unique([tenantId, userId]) @@index([tenantId]) @@ -191,31 +198,32 @@ model TenantMembership { // guarda a senha SIP cifrada (AES-256-GCM, ver packages/shared/src/crypto.ts) // — nunca texto puro (agente.md secao 178). model Extension { - id String @id @default(uuid()) @db.Uuid - tenantId String @map("tenant_id") @db.Uuid + id String @id @default(uuid()) @db.Uuid + tenantId String @map("tenant_id") @db.Uuid - number String - name String - domain String + number String + name String + domain String - sipPasswordEnc String @map("sip_password_enc") + sipPasswordEnc String @map("sip_password_enc") - callerIdName String? @map("caller_id_name") - callerIdNumber String? @map("caller_id_number") + callerIdName String? @map("caller_id_name") + callerIdNumber String? @map("caller_id_number") - context String @default("default") - sofiaProfile String @default("internal") @map("sofia_profile") - codecs String @default("PCMU,PCMA,OPUS") @map("codecs") + context String @default("default") + sofiaProfile String @default("internal") @map("sofia_profile") + codecs String @default("PCMU,PCMA,OPUS") @map("codecs") - maxRegistrations Int @default(1) @map("max_registrations") + maxRegistrations Int @default(1) @map("max_registrations") - enabled Boolean @default(true) + enabled Boolean @default(true) - createdAt DateTime @default(now()) @map("created_at") - updatedAt DateTime @updatedAt @map("updated_at") - deletedAt DateTime? @map("deleted_at") + 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]) + tenant Tenant @relation(fields: [tenantId], references: [id]) + agents Agent[] @@unique([tenantId, number]) @@index([tenantId]) @@ -254,61 +262,61 @@ enum SipTransport { // a senha do tronco cifrada (AES-256-GCM), como sipPasswordEnc em Extension // (agente.md secao 41, 178). model Trunk { - id String @id @default(uuid()) @db.Uuid - tenantId String @map("tenant_id") @db.Uuid + id String @id @default(uuid()) @db.Uuid + tenantId String @map("tenant_id") @db.Uuid - name String - description String? + name String + description String? - sofiaProfile String @default("external") @map("sofia_profile") + sofiaProfile String @default("external") @map("sofia_profile") - host String - proxy String? - realm String? + host String + proxy String? + realm String? - register Boolean @default(true) + register Boolean @default(true) - username String? - passwordEnc String? @map("password_enc") + username String? + passwordEnc String? @map("password_enc") - fromUser String? @map("from_user") - fromDomain String? @map("from_domain") + fromUser String? @map("from_user") + fromDomain String? @map("from_domain") - registerProxy String? @map("register_proxy") - outboundProxy String? @map("outbound_proxy") + registerProxy String? @map("register_proxy") + outboundProxy String? @map("outbound_proxy") - expireSeconds Int @default(3600) @map("expire_seconds") - retrySeconds Int @default(30) @map("retry_seconds") + expireSeconds Int @default(3600) @map("expire_seconds") + retrySeconds Int @default(30) @map("retry_seconds") - callerIdName String? @map("caller_id_name") - callerIdNumber String? @map("caller_id_number") + callerIdName String? @map("caller_id_name") + callerIdNumber String? @map("caller_id_number") - codecs String @default("PCMU,PCMA,OPUS") + codecs String @default("PCMU,PCMA,OPUS") - dtmfMode DtmfMode @default(RFC2833) @map("dtmf_mode") + dtmfMode DtmfMode @default(RFC2833) @map("dtmf_mode") - ping Boolean @default(true) - pingFrequency Int @default(30) @map("ping_frequency") + ping Boolean @default(true) + pingFrequency Int @default(30) @map("ping_frequency") - transport SipTransport @default(UDP) + transport SipTransport @default(UDP) - inboundContext String @default("default") @map("inbound_context") + inboundContext String @default("default") @map("inbound_context") - maxCps Int? @map("max_cps") - maxChannels Int? @map("max_channels") + maxCps Int? @map("max_cps") + maxChannels Int? @map("max_channels") - enabled Boolean @default(true) + enabled Boolean @default(true) // Refletido pelos eventos sofia::gateway_state (agente.md secao 42) — // b2bcall-fs-events atualiza isso, nunca escrito manualmente pela API. - status TrunkStatus @default(UNKNOWN) - statusUpdatedAt DateTime? @map("status_updated_at") + status TrunkStatus @default(UNKNOWN) + statusUpdatedAt DateTime? @map("status_updated_at") - createdAt DateTime @default(now()) @map("created_at") - updatedAt DateTime @updatedAt @map("updated_at") - deletedAt DateTime? @map("deleted_at") + 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]) + tenant Tenant @relation(fields: [tenantId], references: [id]) @@unique([tenantId, name]) @@index([tenantId]) @@ -327,31 +335,31 @@ enum DialplanVersionStatus { // publicadas (DialplanVersion) são um snapshot gerado a partir destas // linhas, não o que o FreeSWITCH consulta diretamente. model DialplanExtension { - id String @id @default(uuid()) @db.Uuid - tenantId String @map("tenant_id") @db.Uuid + id String @id @default(uuid()) @db.Uuid + tenantId String @map("tenant_id") @db.Uuid - context String @default("default") - name String + context String @default("default") + name String - conditionField String @map("condition_field") - conditionExpr String @map("condition_expr") + conditionField String @map("condition_field") + conditionExpr String @map("condition_expr") // Array de { application, data } — validado contra uma allowlist de // applications seguras na camada de API (agente.md secao 180: nunca // deixar input de usuário virar comando arbitrário no FreeSWITCH). - actions Json - antiActions Json? @map("anti_actions") + actions Json + antiActions Json? @map("anti_actions") - continueOnFalse Boolean @default(false) @map("continue_on_false") - order Int @default(0) + continueOnFalse Boolean @default(false) @map("continue_on_false") + order Int @default(0) - enabled Boolean @default(true) + enabled Boolean @default(true) - createdAt DateTime @default(now()) @map("created_at") - updatedAt DateTime @updatedAt @map("updated_at") - deletedAt DateTime? @map("deleted_at") + 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]) + tenant Tenant @relation(fields: [tenantId], references: [id]) @@index([tenantId, context]) @@map("dialplan_extensions") @@ -362,19 +370,19 @@ model DialplanExtension { // versão anterior é o próprio mecanismo de rollback — não existe endpoint // separado. model DialplanVersion { - id String @id @default(uuid()) @db.Uuid - tenantId String @map("tenant_id") @db.Uuid + id String @id @default(uuid()) @db.Uuid + tenantId String @map("tenant_id") @db.Uuid - context String - version Int - generatedXml String @map("generated_xml") - status DialplanVersionStatus @default(DRAFT) + context String + version Int + generatedXml String @map("generated_xml") + status DialplanVersionStatus @default(DRAFT) - createdByUserId String? @map("created_by_user_id") @db.Uuid - createdAt DateTime @default(now()) @map("created_at") - activatedAt DateTime? @map("activated_at") + createdByUserId String? @map("created_by_user_id") @db.Uuid + createdAt DateTime @default(now()) @map("created_at") + activatedAt DateTime? @map("activated_at") - tenant Tenant @relation(fields: [tenantId], references: [id]) + tenant Tenant @relation(fields: [tenantId], references: [id]) @@unique([tenantId, context, version]) @@index([tenantId, context, status]) @@ -399,42 +407,198 @@ enum QueueStrategy { // um namespace unico compartilhado entre tenants (nao ha equivalente ao // diretorio por-arquivo do Sofia pra isolar por tenant, ver docs/QUEUES.md). model Queue { - id String @id @default(uuid()) @db.Uuid - tenantId String @map("tenant_id") @db.Uuid + id String @id @default(uuid()) @db.Uuid + tenantId String @map("tenant_id") @db.Uuid - name String - description String? + name String + description String? - strategy QueueStrategy @default(LONGEST_IDLE_AGENT) + strategy QueueStrategy @default(LONGEST_IDLE_AGENT) - mohSound String? @map("moh_sound") - announceSound String? @map("announce_sound") - announceFrequency Int @default(0) @map("announce_frequency") + mohSound String? @map("moh_sound") + announceSound String? @map("announce_sound") + announceFrequency Int @default(0) @map("announce_frequency") - maxWaitTime Int @default(0) @map("max_wait_time") - maxWaitTimeWithNoAgent Int @default(0) @map("max_wait_time_with_no_agent") + maxWaitTime Int @default(0) @map("max_wait_time") + maxWaitTimeWithNoAgent Int @default(0) @map("max_wait_time_with_no_agent") - agentNoAnswerStatus String? @map("agent_no_answer_status") + agentNoAnswerStatus String? @map("agent_no_answer_status") - tierRulesApply Boolean @default(false) @map("tier_rules_apply") - tierRuleWaitSecond Int @default(300) @map("tier_rule_wait_second") + tierRulesApply Boolean @default(false) @map("tier_rules_apply") + tierRuleWaitSecond Int @default(300) @map("tier_rule_wait_second") - discardAbandonedAfter Int @default(60) @map("discard_abandoned_after") - abandonedResumeAllowed Boolean @default(false) @map("abandoned_resume_allowed") + discardAbandonedAfter Int @default(60) @map("discard_abandoned_after") + abandonedResumeAllowed Boolean @default(false) @map("abandoned_resume_allowed") - skipAgentsWithExternalCalls Boolean @default(true) @map("skip_agents_with_external_calls") + skipAgentsWithExternalCalls Boolean @default(true) @map("skip_agents_with_external_calls") - recordingEnabled Boolean @default(false) @map("recording_enabled") + recordingEnabled Boolean @default(false) @map("recording_enabled") - enabled Boolean @default(true) + enabled Boolean @default(true) - createdAt DateTime @default(now()) @map("created_at") - updatedAt DateTime @updatedAt @map("updated_at") - deletedAt DateTime? @map("deleted_at") + 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]) + tenant Tenant @relation(fields: [tenantId], references: [id]) + tiers Tier[] @@unique([tenantId, name]) @@index([tenantId]) @@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 é `@`, +// 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 "@" — 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") +} diff --git a/packages/telephony/src/freeswitch-provider.ts b/packages/telephony/src/freeswitch-provider.ts index 1f02d03..14ffac8 100644 --- a/packages/telephony/src/freeswitch-provider.ts +++ b/packages/telephony/src/freeswitch-provider.ts @@ -149,12 +149,32 @@ export class FreeSwitchTelephonyProvider implements TelephonyProvider { await this.call().api(`callcenter_config agent set contact '${agentId}' '${contact}'`); } - async addAgentToQueue(queueName: string, agentId: string): Promise { - 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 { + await this.call().api(`callcenter_config tier add ${queueName} ${agentId} ${level} ${position}`); } async removeAgentFromQueue(queueName: string, agentId: string): Promise { - 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 { + await this.call().api(`callcenter_config agent add '${agentId}' '${type}'`); + } + + async removeAgent(agentId: string): Promise { + await this.call().api(`callcenter_config agent del '${agentId}'`); } async reloadXml(): Promise {