diff --git a/TODO.md b/TODO.md index abf15a3..2ccba44 100644 --- a/TODO.md +++ b/TODO.md @@ -228,14 +228,52 @@ 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) +- [x] Estados derivados de chamada (RINGING/IN_CALL/WRAP_UP/RESERVED) — + mecanismo de entrega do `callcenter::info` corrigido e confirmado + (ver PHASE 13); persistir em `Agent.state` ainda não implementado - [ ] `PauseReason.maxDuration` não é aplicado automaticamente - [ ] Quota de agentes — depende de Plans/Entitlements +- [ ] Achado sistêmico: `@@unique` combinado com soft delete (sem excluir + `deletedAt`) em `Agent`/`Extension`/`Trunk`/`Queue`/`PauseReason` — + não dá pra reusar número/nome/código depois de apagar. Precisa de + índice único parcial em cada um, migration própria (ver docs/AGENTS.md) -## PHASE 13+ — ver `agente.md` seções 54 em diante (Monitoramento em tempo real, -Predictive Dialer, Recordings, AI, Billing, Frontend, Reports, Security, Tests) +## PHASE 13 — Realtime Monitoring / WebSocket multi-tenant (agente.md secao 54-55, 161) +- [x] **Bug real, achado nesta fase**: nenhum evento CUSTOM do ESL + (`sofia::register`, `sofia::gateway_state`, `callcenter::info`) jamais + chegava em `b2bcall-fs-events` — `event_json(...)` mandava `"CUSTOM"` + como último token do comando `event json`, sem subclass depois + (mod_event_socket exige os subclasses logo depois do token CUSTOM no + mesmo comando). Corrigido separando `PLAIN_EVENTS`/`CUSTOM_SUBCLASSES`. + Resolve as lacunas documentadas em PHASE 09/12 e docs/TRUNKS.md/AGENTS.md. +- [x] Corrigido de quebra: `CC-Agent-Status` (não existe) → `CC-Agent-State` + (campo real); novos tipos normalizados `AGENT_OFFERED_CALL`, + `AGENT_BRIDGE_FAILED`, `QUEUE_MEMBER_COUNT`, `QUEUE_MEMBER_LEFT` +- [x] Corrigido de quebra: `sofia profile external rescan` nunca descarregava + um gateway cujo arquivo foi apagado (fantasma na memória do Sofia) — + `trunk-sync.ts` agora roda `killgw ` pra cada gateway removido +- [x] `tenant-resolve.ts` (fs-events): resolve tenantId por fan-out + (agente/fila não carregam `b2bcall_tenant_id` — só existe a partir do + Predictive Engine), cacheado por id +- [x] `apps/api`: `RealtimeGateway` (socket.io sobre o Fastify HTTP server), + auth via JWT no handshake (`monitoring.view`), uma room por tenant + (`tenant:`) — nunca broadcast global, sempre `server.to(room)` + (secao 161: tenant-scoped no servidor, nunca filtrar só no browser) +- [x] `RealtimeRedisBridge`: assina `b2bcall:events` (canal único, mesmo + usado desde Event Socket), reencaminha pro tenant certo +- [x] `AGENT_STATE_CHANGED` publicado direto de `agents-me.controller.ts` + (login/logout/pause/resume) — tenantId já vem do JWT, sem fan-out +- [x] Testado ponta a ponta: login/pause/resume/logout via WS, chamada de + teste numa fila real (QUEUE_MEMBER_COUNT/LEFT, AGENT_OFFERED_CALL, + AGENT_BRIDGE_FAILED, AGENT_STATUS_CHANGED com CC-Agent-State correto), + token inválido desconectado na hora +- [ ] Ramais/extensões (secao 55 completa: busy/registro) — precisa de SIP + real pra testar, e extrair ramal dos headers de canal (não feito) +- [ ] TME/TMA/Service Level/Abandon Rate — dependem de CDR (fase futura) +- [ ] Snapshot/reconciliação ao reconectar o WebSocket + +## PHASE 14+ — ver `agente.md` seções 56 em diante (Predictive Dialer, +Recordings, AI, Billing, Frontend, Reports, Security, Tests) --- diff --git a/apps/api/package.json b/apps/api/package.json index 3b1ed7c..ff74fa9 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -19,13 +19,16 @@ "@nestjs/common": "^12.0.1", "@nestjs/core": "^12.0.1", "@nestjs/platform-fastify": "^12.0.1", + "@nestjs/platform-socket.io": "12.0.1", + "@nestjs/websockets": "12.0.1", "class-transformer": "^0.5.1", "class-validator": "^0.15.1", "fast-xml-parser": "5.11.1", "fastify": "5.12.1", "ioredis": "^6.0.0", "reflect-metadata": "^0.2.2", - "rxjs": "^7.8.2" + "rxjs": "^7.8.2", + "socket.io": "4.8.3" }, "devDependencies": { "@types/node": "^22.0.0", diff --git a/apps/api/src/agents/agents-me.controller.ts b/apps/api/src/agents/agents-me.controller.ts index 0a24152..c06e310 100644 --- a/apps/api/src/agents/agents-me.controller.ts +++ b/apps/api/src/agents/agents-me.controller.ts @@ -13,6 +13,7 @@ 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"; +import { publishAgentStateChanged } from "../realtime/realtime-publish.helper"; async function findMyAgent(tx: Prisma.TransactionClient, tenantId: string, userId: string) { const agent = await tx.agent.findFirst({ @@ -69,6 +70,7 @@ export class AgentsMeController { for (const tier of agent.tiers) { await notifyTierChanged(tenantId, tier.queueId, agent.id, "upsert", tier.level, tier.position); } + await publishAgentStateChanged(tenantId, agent.id, "AVAILABLE"); return { state: "AVAILABLE" }; } @@ -99,6 +101,7 @@ export class AgentsMeController { await recordAuditEvent(prisma, { action: "AGENT_LOGOUT", tenantId, userId: user.sub, entityType: "agent", entityId: agent.id }); await notifyAgentChanged(tenantId, agent.id, "upsert"); + await publishAgentStateChanged(tenantId, agent.id, "OFFLINE"); return { state: "OFFLINE" }; } @@ -141,6 +144,7 @@ export class AgentsMeController { }); await notifyAgentChanged(tenantId, agent.id, "upsert"); + await publishAgentStateChanged(tenantId, agent.id, "PAUSED"); return { state: "PAUSED" }; } @@ -167,6 +171,7 @@ export class AgentsMeController { await recordAuditEvent(prisma, { action: "AGENT_RESUME", tenantId, userId: user.sub, entityType: "agent", entityId: agent.id }); await notifyAgentChanged(tenantId, agent.id, "upsert"); + await publishAgentStateChanged(tenantId, agent.id, "AVAILABLE"); return { state: "AVAILABLE" }; } diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index cc52112..dd03619 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -7,6 +7,7 @@ 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"; +import { RealtimeModule } from "./realtime/realtime.module"; @Module({ imports: [ @@ -18,6 +19,7 @@ import { PauseReasonsModule } from "./pause-reasons/pause-reasons.module"; QueuesModule, AgentsModule, PauseReasonsModule, + RealtimeModule, ], }) export class AppModule {} diff --git a/apps/api/src/main.ts b/apps/api/src/main.ts index a1542fd..ecf1aae 100644 --- a/apps/api/src/main.ts +++ b/apps/api/src/main.ts @@ -1,6 +1,7 @@ import "reflect-metadata"; import { NestFactory } from "@nestjs/core"; import { FastifyAdapter, type NestFastifyApplication } from "@nestjs/platform-fastify"; +import { IoAdapter } from "@nestjs/platform-socket.io"; import { ValidationPipe } from "@nestjs/common"; import helmet from "@fastify/helmet"; import cors from "@fastify/cors"; @@ -42,6 +43,11 @@ async function bootstrap() { app.useGlobalFilters(new DomainExceptionFilter()); + // Monitoramento em tempo real (agente.md secao 54-55, 161) — socket.io + // sobre o mesmo servidor HTTP do Fastify, path proprio pra nao colidir + // com as rotas REST. + app.useWebSocketAdapter(new IoAdapter(app)); + const port = Number(process.env.API_PORT ?? 3000); await app.listen(port, "127.0.0.1"); console.log(`b2bcall-api ouvindo em http://127.0.0.1:${port}`); diff --git a/apps/api/src/realtime/realtime-publish.helper.ts b/apps/api/src/realtime/realtime-publish.helper.ts new file mode 100644 index 0000000..dc7a811 --- /dev/null +++ b/apps/api/src/realtime/realtime-publish.helper.ts @@ -0,0 +1,25 @@ +import type { NormalizedEvent } from "@b2bcall/telephony"; +import { getRedisClient } from "../common/redis"; + +const EVENTS_CHANNEL = "b2bcall:events"; + +/** + * Publica no mesmo canal que b2bcall-fs-events usa pros eventos vindos do + * FreeSWITCH — o RealtimeRedisBridge (apps/api) não distingue a origem, só + * reencaminha pro tenant certo via WebSocket (docs/REALTIME.md). Diferente + * dos eventos vindos do ESL, aqui o tenantId já é conhecido de cara (vem do + * JWT da requisição HTTP) — não precisa do fan-out de tenant-resolve.ts. + */ +export async function publishAgentStateChanged( + tenantId: string, + agentId: string, + state: string, +): Promise { + const event: NormalizedEvent = { + type: "AGENT_STATE_CHANGED", + occurredAt: new Date().toISOString(), + tenantId, + data: { agentId, state }, + }; + await getRedisClient().publish(EVENTS_CHANNEL, JSON.stringify(event)); +} diff --git a/apps/api/src/realtime/realtime-redis-bridge.service.ts b/apps/api/src/realtime/realtime-redis-bridge.service.ts new file mode 100644 index 0000000..70b4a32 --- /dev/null +++ b/apps/api/src/realtime/realtime-redis-bridge.service.ts @@ -0,0 +1,52 @@ +import { Injectable, Logger, OnModuleDestroy, OnModuleInit } from "@nestjs/common"; +import Redis from "ioredis"; +import type { NormalizedEvent } from "@b2bcall/telephony"; +import { RealtimeGateway } from "./realtime.gateway"; + +const EVENTS_CHANNEL = "b2bcall:events"; + +/** + * Pub/sub exige uma conexão Redis dedicada em modo subscriber (não pode + * compartilhar com o client usado pra comandos normais) — mesmo padrão já + * usado em fs-config/fs-events. + */ +@Injectable() +export class RealtimeRedisBridge implements OnModuleInit, OnModuleDestroy { + private readonly logger = new Logger(RealtimeRedisBridge.name); + private subscriber?: Redis; + + constructor(private readonly gateway: RealtimeGateway) {} + + async onModuleInit(): Promise { + const url = process.env.REDIS_URL; + if (!url) { + throw new Error("REDIS_URL nao configurado"); + } + + this.subscriber = new Redis(url); + this.subscriber.on("error", (err) => this.logger.error(`erro na conexao Redis (subscriber): ${String(err)}`)); + await this.subscriber.subscribe(EVENTS_CHANNEL); + + this.subscriber.on("message", (_channel, raw) => { + let event: NormalizedEvent; + try { + event = JSON.parse(raw); + } catch (err) { + this.logger.error(`evento invalido no canal ${EVENTS_CHANNEL}: ${String(err)}`); + return; + } + if (!event.tenantId) { + // Sem tenant resolvido (ex.: chamada sintética sem agente/fila real + // por trás) — nada pra emitir, nenhuma room saberia receber. + return; + } + this.gateway.broadcastToTenant(event.tenantId, event); + }); + + this.logger.log("bridge Redis -> WebSocket conectado"); + } + + async onModuleDestroy(): Promise { + await this.subscriber?.quit(); + } +} diff --git a/apps/api/src/realtime/realtime.gateway.ts b/apps/api/src/realtime/realtime.gateway.ts new file mode 100644 index 0000000..f664b0a --- /dev/null +++ b/apps/api/src/realtime/realtime.gateway.ts @@ -0,0 +1,64 @@ +import { Injectable, Logger } from "@nestjs/common"; +import { OnGatewayConnection, OnGatewayDisconnect, WebSocketGateway, WebSocketServer } from "@nestjs/websockets"; +import type { Server, Socket } from "socket.io"; +import { verifyAccessToken, userHasPermission } from "@b2bcall/auth"; +import type { NormalizedEvent } from "@b2bcall/telephony"; + +function tenantRoom(tenantId: string): string { + return `tenant:${tenantId}`; +} + +/** + * Monitoramento em tempo real (agente.md secao 54-55, 161: "eventos + * WebSocket devem ser tenant-scoped no servidor, nunca transmitir tudo e + * filtrar só no browser"). `broadcastToTenant` é o único ponto de emissão — + * sempre `server.to(room)`, nunca `server.emit()` global. + */ +@Injectable() +@WebSocketGateway({ + cors: { + origin: process.env.CORS_ORIGIN ? process.env.CORS_ORIGIN.split(",") : false, + credentials: true, + }, + path: "/realtime", +}) +export class RealtimeGateway implements OnGatewayConnection, OnGatewayDisconnect { + private readonly logger = new Logger(RealtimeGateway.name); + + @WebSocketServer() + server!: Server; + + async handleConnection(client: Socket): Promise { + const token = client.handshake.auth?.token as string | undefined; + if (!token) { + client.disconnect(true); + return; + } + + try { + const claims = await verifyAccessToken(token); + if (!claims.tenantId) { + client.disconnect(true); + return; + } + const allowed = await userHasPermission(claims.sub, "monitoring.view", claims.tenantId); + if (!allowed) { + client.disconnect(true); + return; + } + + await client.join(tenantRoom(claims.tenantId)); + this.logger.log(`cliente conectado ao monitoramento (tenant ${claims.tenantId})`); + } catch { + client.disconnect(true); + } + } + + handleDisconnect(): void { + // socket.io já limpa as rooms sozinho na desconexão — nada a fazer aqui. + } + + broadcastToTenant(tenantId: string, event: NormalizedEvent): void { + this.server.to(tenantRoom(tenantId)).emit("event", event); + } +} diff --git a/apps/api/src/realtime/realtime.module.ts b/apps/api/src/realtime/realtime.module.ts new file mode 100644 index 0000000..f9de692 --- /dev/null +++ b/apps/api/src/realtime/realtime.module.ts @@ -0,0 +1,8 @@ +import { Module } from "@nestjs/common"; +import { RealtimeGateway } from "./realtime.gateway"; +import { RealtimeRedisBridge } from "./realtime-redis-bridge.service"; + +@Module({ + providers: [RealtimeGateway, RealtimeRedisBridge], +}) +export class RealtimeModule {} diff --git a/apps/freeswitch-events/src/main.ts b/apps/freeswitch-events/src/main.ts index 409101b..8bddf38 100644 --- a/apps/freeswitch-events/src/main.ts +++ b/apps/freeswitch-events/src/main.ts @@ -3,6 +3,7 @@ import type { FreeSwitchEventData } from "esl"; import { FreeSwitchTelephonyProvider, normalizeEslEvent } from "@b2bcall/telephony"; import { createLogger } from "@b2bcall/shared"; import { updateTrunkStatusFromGatewayEvent } from "./trunk-status"; +import { resolveTenantIdForAgent, resolveTenantIdForQueue } from "./tenant-resolve"; const logger = createLogger("b2bcall-fs-events"); @@ -97,7 +98,27 @@ async function main() { logger.error("erro no client ESL", { error: String(err) }); }); - function handleEvent(eventName: string, raw: FreeSwitchEventData) { + // callcenter::info/sofia::gateway_state não carregam b2bcall_tenant_id + // (só existe como channel variable a partir do Predictive Engine) — pra + // esses tipos, o tenant é resolvido pelo id do agente/fila embutido no + // nome FreeSWITCH (tenant-resolve.ts). Sem isso, o WebSocket multi-tenant + // (docs/REALTIME.md) não teria como saber pra qual tenant emitir. + async function resolveTenantId(normalized: ReturnType): Promise { + if (!normalized || normalized.tenantId) return normalized?.tenantId; + switch (normalized.type) { + case "AGENT_STATUS_CHANGED": + case "AGENT_OFFERED_CALL": + case "AGENT_BRIDGE_FAILED": + return resolveTenantIdForAgent(normalized.data.agent as string | undefined); + case "QUEUE_MEMBER_COUNT": + case "QUEUE_MEMBER_LEFT": + return resolveTenantIdForQueue(normalized.data.queue as string | undefined); + default: + return undefined; + } + } + + async function handleEvent(eventName: string, raw: FreeSwitchEventData) { // Para eventos JSON (event_json), os campos reais do evento FreeSWITCH // (Event-Name, Unique-ID, Event-Subclass, variable_*, ...) vem em // `raw.body`; `raw.headers` são só os headers do protocolo ESL. @@ -107,6 +128,12 @@ async function main() { return; } + try { + normalized.tenantId = await resolveTenantId(normalized); + } catch (err) { + logger.error("falha ao resolver tenant do evento", { error: String(err), type: normalized.type }); + } + redis.publish(REDIS_CHANNEL, JSON.stringify(normalized)).catch((err) => { logger.error("falha ao publicar evento normalizado no Redis", { error: String(err) }); }); diff --git a/apps/freeswitch-events/src/tenant-resolve.ts b/apps/freeswitch-events/src/tenant-resolve.ts new file mode 100644 index 0000000..343329f --- /dev/null +++ b/apps/freeswitch-events/src/tenant-resolve.ts @@ -0,0 +1,49 @@ +import { getPrismaClient, withTenantContext, type Prisma } from "@b2bcall/database"; + +/** + * Eventos de callcenter/gateway não carregam tenant_id (só existe como + * channel variable a partir do Predictive Engine, ver normalize-event.ts) — + * mas o id do FreeSWITCH (`@dominio`) É o id primário da nossa linha + * (Agent.id/Queue.id), então dá pra achar o tenant dono procurando em cada + * tenant ativo (mesmo padrão de fan-out de trunk-status.ts). Cacheado por id + * — um agente/fila nunca muda de tenant, então uma entrada no cache nunca + * fica desatualizada (só inútil, se o registro for apagado depois). + */ +const cache = new Map(); + +function extractId(fsName: string | undefined): string | undefined { + if (!fsName) return undefined; + return fsName.split("@")[0]; +} + +async function resolveViaFanOut( + cacheKey: string, + lookup: (tx: Prisma.TransactionClient, tenantId: string) => Promise, +): Promise { + const cached = cache.get(cacheKey); + if (cached) return cached; + + const prisma = getPrismaClient(); + const tenants = await prisma.tenant.findMany({ where: { status: "ACTIVE" }, select: { id: true } }); + + for (const tenant of tenants) { + const found = await withTenantContext(prisma, tenant.id, (tx) => lookup(tx, tenant.id)); + if (found) { + cache.set(cacheKey, tenant.id); + return tenant.id; + } + } + return undefined; +} + +export async function resolveTenantIdForAgent(agentFsName: string | undefined): Promise { + const id = extractId(agentFsName); + if (!id) return undefined; + return resolveViaFanOut(`agent:${id}`, (tx) => tx.agent.findUnique({ where: { id } })); +} + +export async function resolveTenantIdForQueue(queueFsName: string | undefined): Promise { + const id = extractId(queueFsName); + if (!id) return undefined; + return resolveViaFanOut(`queue:${id}`, (tx) => tx.queue.findUnique({ where: { id } })); +} diff --git a/docs/AGENTS.md b/docs/AGENTS.md index 8eabc52..c31e84a 100644 --- a/docs/AGENTS.md +++ b/docs/AGENTS.md @@ -108,6 +108,20 @@ FreeSWITCH (`agent list`). - 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). +- **Achado ao testar a fase Realtime Monitoring, sistêmico (não é só + Agent)**: `@@unique([tenantId, userId])` em `Agent` não exclui + `deletedAt` — apagar um agente (soft delete) e tentar criar outro pro + mesmo usuário no mesmo tenant falha com `Unique constraint failed`, + porque a linha apagada continua ocupando o slot único indefinidamente. + Confirmado ao vivo durante o teste desta fase. O mesmo padrão + (`@@unique` combinado com soft delete, sem excluir `deletedAt`) existe + em pelo menos mais 4 models: `Extension` (`tenantId, number`), `Trunk` + (`tenantId, name`), `Queue` (`tenantId, name`), `PauseReason` + (`tenantId, code`) — todos vão ter o mesmo problema (não dá pra reusar + um número/nome/código depois de apagar). Precisa de um índice único + parcial (`WHERE deleted_at IS NULL`) em cada um — não corrigido nesta + fase (é uma migration própria tocando 5 tabelas, fora do escopo de + Realtime Monitoring); fica registrado aqui pra não se perder. ## Correção: eventos CUSTOM (`callcenter::info`) nunca chegavam diff --git a/docs/REALTIME.md b/docs/REALTIME.md new file mode 100644 index 0000000..31287a2 --- /dev/null +++ b/docs/REALTIME.md @@ -0,0 +1,129 @@ +# Monitoramento em tempo real (WebSocket) + +Agente.md secao 54-55 (filas/ramais) e 161 (WebSocket multi-tenant). Esta +fase entrega a infraestrutura de push em tempo real — o consumo visual +("Monitoramento → Filas/Ramais", cards coloridos) fica pra fase Frontend. + +## Requisito central: tenant-scoped no servidor + +Secao 161: "Não transmitir tudo e filtrar somente no browser." O gateway +(`apps/api/src/realtime/realtime.gateway.ts`) só tem **um** ponto de +emissão, `broadcastToTenant()`, e ele sempre usa `server.to(room)` — nunca +`server.emit()` global. Cada socket só entra na room do próprio tenant +(`tenant:`) na conexão, nunca escolhe a room ele mesmo. + +## Autenticação na conexão + +Socket.io não usa headers HTTP como uma request REST — o client manda o +access token em `socket.handshake.auth.token` (não em `Authorization`). +`RealtimeGateway.handleConnection`: + +1. Rejeita (`disconnect(true)`) se não tiver token. +2. Valida o JWT (`verifyAccessToken`, mesmo helper usado no `JwtAuthGuard`). +3. Exige `tenantId` no token (mesmo princípio do `PermissionGuard`: nunca + um tenant vindo do client, só do JWT já emitido por `/auth/select-tenant`). +4. Exige a permission `monitoring.view` (`userHasPermission`, já existia + desde a fase RBAC — só não tinha nenhum consumidor ainda). +5. Só então entra na room do tenant. + +Testado: token ausente/inválido → desconectado na hora, nenhum evento +vaza. Token válido → entra na room certa, recebe eventos do próprio tenant. + +## Origem dos eventos + +Um único canal Redis, `b2bcall:events` (o mesmo que já existia desde a fase +Event Socket) — o `RealtimeRedisBridge` (`apps/api`) assina esse canal com +uma conexão dedicada (pub/sub exige conexão própria, não pode compartilhar +com a usada pra comandos) e reencaminha pra `broadcastToTenant()` quando o +evento já tem `tenantId` resolvido; sem tenant resolvido, o evento é +descartado (nenhuma room saberia receber). + +Dois produtores publicam nesse canal: + +1. **`b2bcall-fs-events`** (eventos do FreeSWITCH, normalizados — ver + `packages/telephony/src/normalize-event.ts`). A maioria desses eventos + (`callcenter::info`, `sofia::gateway_state`) não carrega + `b2bcall_tenant_id` como channel variable (só existe a partir do + Predictive Engine) — o tenant é resolvido pelo id do agente/fila embutido + no nome FreeSWITCH (`@dominio`), com fan-out sobre os tenants + ativos (mesmo padrão de `trunk-status.ts`), cacheado por id em + `tenant-resolve.ts` (agente/fila nunca troca de tenant, cache nunca fica + desatualizado). Isso só passou a funcionar depois da correção do bug de + subscrição de eventos CUSTOM — ver docs/AGENTS.md e docs/EVENT_SOCKET.md. +2. **`apps/api`** (mudanças no nosso próprio `Agent.state`, via + `realtime-publish.helper.ts`, chamado de dentro de + `agents-me.controller.ts` em login/logout/pause/resume). Aqui o + `tenantId` já vem direto do JWT da requisição HTTP — sem fan-out. + +## Tipos de evento emitidos + +- `AGENT_STATE_CHANGED`: nosso enum próprio (`OFFLINE`/`AVAILABLE`/ + `PAUSED`/...), só muda via login/logout/pause/resume. +- `AGENT_STATUS_CHANGED`: `CC-Agent-State` bruto do mod_callcenter + (`Waiting`/`Receiving`/...) — vocabulário diferente do de cima, não dá + pra misturar (mesmo agente pode estar "AVAILABLE" no nosso enum e + "Receiving" no mod_callcenter simultaneamente, description de momentos + diferentes do ciclo de uma chamada). +- `QUEUE_MEMBER_COUNT`: contagem ao vivo de chamadas esperando por fila + (`CC-Count`) — a peça central da secao 54 ("Chamadas esperando"). +- `QUEUE_MEMBER_LEFT`: uma chamada saiu da fila, com `cause`/`cancelReason` + e timestamps de entrada/saída — atendida vs. abandonada, base pro cálculo + futuro de Service Level/Abandon Rate. +- `AGENT_OFFERED_CALL` / `AGENT_BRIDGE_FAILED`: uma chamada foi ofertada a + um agente / falhou ao bridgear (ex.: `USER_NOT_REGISTERED`). +- `CALL_CREATED`/`CALL_ANSWERED`/`CALL_ENDED`: já existiam desde a fase + Event Socket, mas só carregam `tenantId` quando o `b2bcall_tenant_id` + channel variable existir (chamadas puramente sintéticas de teste, como as + usadas pra verificar esta fase, não têm — não chegam no WebSocket). + +## Verificado ponta a ponta + +Client de teste com `socket.io-client`, autenticado com o JWT de um tenant +de teste, na room `tenant:`: + +``` +login do agente → AGENT_STATE_CHANGED {state: "AVAILABLE"} +pause → AGENT_STATE_CHANGED {state: "PAUSED"} +resume → AGENT_STATE_CHANGED {state: "AVAILABLE"} +logout → AGENT_STATE_CHANGED {state: "OFFLINE"} + +originate null/dummy &callcenter(fila@dominio), com agente logado numa fila: + → QUEUE_MEMBER_COUNT {count: 3} + → AGENT_STATUS_CHANGED {state: "Receiving"} + → AGENT_OFFERED_CALL + → AGENT_BRIDGE_FAILED {hangupCause: "USER_NOT_REGISTERED"} (ramal sem SIP real registrado) + → QUEUE_MEMBER_LEFT {cause: "Cancel", cancelReason: "TIMEOUT"} + → QUEUE_MEMBER_COUNT {count: 2} + +token ausente/inválido → socket desconectado na hora, nenhum evento recebido. +``` + +Todos chegaram só na room do tenant certo, com o `tenantId` batendo. + +## O que falta + +- **Ramais/extensões (secao 55 completa)**: as cores dependem de "ocupado" + (busy — CHANNEL_ANSWER/HANGUP tied a um ramal específico) e "offline" + (registro SIP — `sofia::register`/`unregister`, cujo mecanismo de entrega + agora funciona, mas nunca foi exercitado com um client SIP real nesta + sessão). Além disso, resolver **qual ramal** um evento de canal pertence + exige extrair o número de discagem dos headers (`Caller-Destination-Number`/ + `Channel-Name`), não implementado ainda — e o mesmo limite de domínio + compartilhado entre tenants já documentado em `docs/EXTENSIONS.md` afeta + esse mapeamento. Não implementado; fica pra quando houver um client SIP de + verdade pra testar. +- **Persistir `AGENT_STATUS_CHANGED`/`QUEUE_MEMBER_COUNT` em `Agent.state` + ou numa tabela de snapshot de fila**: hoje só passam pelo WebSocket, não + gravam nada — suficiente pro requisito "tempo real", mas sem histórico + consultável fora da tabela `agent_state_events` (que só reflete + login/logout/pause/resume, não os estados derivados de chamada). +- TME/TMA/Service Level/Abandon Rate (secao 54): dependem de CDR + (fase futura, depois do Predictive Engine na ordem do agente.md secao + 232) — `QUEUE_MEMBER_LEFT` já traz os dados brutos (`cause`, + `cancelReason`, timestamps) que vão alimentar esse cálculo quando CDR + existir. +- Dashboard Tenant/Platform (secao 162-163) — fase Frontend. +- Reconexão/reconciliação de estado ao reconectar o WebSocket (perder um + evento por queda de rede momentânea não é recuperável hoje — o client + precisa buscar o snapshot atual via REST depois de reconectar; não existe + endpoint de snapshot ainda). diff --git a/packages/telephony/src/types.ts b/packages/telephony/src/types.ts index 86ed22d..99cbcef 100644 --- a/packages/telephony/src/types.ts +++ b/packages/telephony/src/types.ts @@ -53,6 +53,10 @@ export type NormalizedEventType = | "EXTENSION_REGISTERED" | "EXTENSION_UNREGISTERED" | "AGENT_STATUS_CHANGED" + /** Mudança no nosso próprio enum AgentState (login/logout/pause/resume) — + * diferente de AGENT_STATUS_CHANGED, que é o `CC-Agent-State` bruto do + * mod_callcenter (Waiting/Receiving/...), vocabulário diferente. */ + | "AGENT_STATE_CHANGED" | "AGENT_OFFERED_CALL" | "AGENT_BRIDGE_FAILED" | "QUEUE_MEMBER_COUNT" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 002ffa5..a0a5274 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -40,10 +40,16 @@ importers: version: 12.0.1(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2) '@nestjs/core': specifier: ^12.0.1 - version: 12.0.1(@nestjs/common@12.0.1(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(reflect-metadata@0.2.2)(rxjs@7.8.2) + version: 12.0.1(@nestjs/common@12.0.1(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/websockets@12.0.1)(reflect-metadata@0.2.2)(rxjs@7.8.2) '@nestjs/platform-fastify': specifier: ^12.0.1 - version: 12.0.1(@nestjs/common@12.0.1(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@12.0.1(@nestjs/common@12.0.1(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(reflect-metadata@0.2.2)(rxjs@7.8.2)) + version: 12.0.1(@nestjs/common@12.0.1(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@12.0.1) + '@nestjs/platform-socket.io': + specifier: 12.0.1 + version: 12.0.1(@nestjs/common@12.0.1(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/websockets@12.0.1)(rxjs@7.8.2) + '@nestjs/websockets': + specifier: 12.0.1 + version: 12.0.1(@nestjs/common@12.0.1(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@12.0.1)(@nestjs/platform-socket.io@12.0.1)(reflect-metadata@0.2.2)(rxjs@7.8.2) class-transformer: specifier: ^0.5.1 version: 0.5.1 @@ -65,6 +71,9 @@ importers: rxjs: specifier: ^7.8.2 version: 7.8.2 + socket.io: + specifier: 4.8.3 + version: 4.8.3 devDependencies: '@types/node': specifier: ^22.0.0 @@ -485,6 +494,25 @@ packages: '@fastify/view': optional: true + '@nestjs/platform-socket.io@12.0.1': + resolution: {integrity: sha512-1lNTp0tjlRp+5rMX5b3G0l9bt1CH4ejH4ZMn7Qi7dJc536UpweNVWmL5zm8EHFN71Q3CxP1PbqL6aw+QrtS+8w==} + peerDependencies: + '@nestjs/common': ^12.0.0 + '@nestjs/websockets': ^12.0.0 + rxjs: ^7.1.0 + + '@nestjs/websockets@12.0.1': + resolution: {integrity: sha512-TxyfPg726saaaRihjBc5izQjQ7bRV1VJrZiEH9KO1LztJQyFbMDVztUI8ZXRgMYDuv8ZY0ENUaDb2JkpCkny0Q==} + peerDependencies: + '@nestjs/common': ^12.0.0 + '@nestjs/core': ^12.0.0 + '@nestjs/platform-socket.io': ^12.0.0 + reflect-metadata: ^0.1.12 || ^0.2.0 + rxjs: ^7.1.0 + peerDependenciesMeta: + '@nestjs/platform-socket.io': + optional: true + '@nodable/entities@3.0.0': resolution: {integrity: sha512-8L9xFeTYKhm49xfIypoe2W5wV1m/3Z58kT+7kR9A8OyFxcPduI4VmxaUMQyKYrRjUoLLSXv6EKKID5Tvj9cUVw==} @@ -714,6 +742,9 @@ packages: '@types/react': optional: true + '@socket.io/component-emitter@3.1.2': + resolution: {integrity: sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==} + '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} @@ -724,6 +755,9 @@ packages: '@tokenizer/token@0.3.0': resolution: {integrity: sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==} + '@types/cors@2.8.19': + resolution: {integrity: sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==} + '@types/d3-array@3.0.3': resolution: {integrity: sha512-Reoy+pKnvsksN0lQUlcH6dOGjRZ/3WRwXR//m+/8lt1BXeI4xyaUZoqULNjyXXRuh0Mj4LNpkCvhUpQlY3X5xQ==} @@ -775,6 +809,9 @@ packages: '@types/validator@13.15.10': resolution: {integrity: sha512-T8L6i7wCuyoK8A/ZeLYt1+q0ty3Zb9+qbSSvrIVitzT3YjZqkTZ40IbRsPanlB4h1QB3JVL1SYCdR6ngtFYcuA==} + '@types/ws@8.18.1': + resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==} + '@visx/curve@4.0.1-alpha.0': resolution: {integrity: sha512-jRu61Uz274pV1zyioXmboyrLutYbnKsgjj4njSGCnhdXj5GkZvZbg+ThDb6oOzoAnJOBRLz4rzPlWvNJOzuVMg==} @@ -813,6 +850,10 @@ packages: abstract-logging@2.0.1: resolution: {integrity: sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==} + accepts@1.3.8: + resolution: {integrity: sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==} + engines: {node: '>= 0.6'} + ajv-formats@3.0.1: resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==} peerDependencies: @@ -838,6 +879,10 @@ packages: resolution: {integrity: sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g==} engines: {node: '>= 6.0.0'} + base64id@2.0.0: + resolution: {integrity: sha512-lGe34o6EHj9y3Kts9R4ZYs/Gr+6N7MCaMlIFA3F1R2O5/m7K06AxfSeO5530PEERE6/WyEg3lsuyw4GHlPZHog==} + engines: {node: ^4.5.0 || >= 5.9} + better-result@2.10.0: resolution: {integrity: sha512-oQhh0y1qo2/ZKdAAEvHZAqKKiHOFU5k/bW96fE2ScgQOVkJRiHwB+nOS1SgFsYqRlxMDWvefXi9Q3px7QvgNDw==} @@ -869,10 +914,18 @@ packages: confbox@0.2.4: resolution: {integrity: sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==} + cookie@0.7.2: + resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==} + engines: {node: '>= 0.6'} + cookie@1.1.1: resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==} engines: {node: '>=18'} + cors@2.8.6: + resolution: {integrity: sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==} + engines: {node: '>= 0.10'} + cross-spawn@7.0.6: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} @@ -972,6 +1025,14 @@ packages: resolution: {integrity: sha512-i6UzDscO/XfAcNYD75CfICkmfLedpyPDdozrLMmQc5ORaQcdMoc21OnlEylMIqI7U8eniKrPMxxtj8k0vhmJhA==} engines: {node: '>=14'} + engine.io-parser@5.2.3: + resolution: {integrity: sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==} + engines: {node: '>=10.0.0'} + + engine.io@6.6.9: + resolution: {integrity: sha512-clKkw4C7nJ22mGgoVcCg6V/W/TxdNyIOTr89k2ONZu81qqkddPFDF0LXcbAwhzPD8DjkiRCjzuiO6Y+fkpD4vg==} + engines: {node: '>=10.2.0'} + env-paths@3.0.0: resolution: {integrity: sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} @@ -1147,6 +1208,14 @@ packages: magicast@0.5.4: resolution: {integrity: sha512-llBEhWm1SacoRwgHUoQJYtwp4PBLF4faQi5TCpIGyGs9n4y5+juI0tDgyKIfpqxckRHaHzouUEph3THklWh03w==} + mime-db@1.52.0: + resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} + engines: {node: '>= 0.6'} + + mime-types@2.1.35: + resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} + engines: {node: '>= 0.6'} + ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} @@ -1158,6 +1227,18 @@ packages: resolution: {integrity: sha512-Tz09sEL2EEuv5fFowm419c1+a/jSMiBjI9gHxVLrVdbUkkNUUfjsVYs9pVZu5oCon/kmRh9TfLEObFtkVxmY0w==} engines: {node: '>=8.0.0'} + negotiator@0.6.3: + resolution: {integrity: sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==} + engines: {node: '>= 0.6'} + + object-assign@4.1.1: + resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} + engines: {node: '>=0.10.0'} + + object-hash@3.0.0: + resolution: {integrity: sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==} + engines: {node: '>= 6'} + ohash@2.0.12: resolution: {integrity: sha512-65S/5gk9YSsaRjcyf7Nfa6h/d3E8/1gslpXfI4W7Dxn/oap8IKRuNT5VXkLQ1YFKIEg4apRY4Pj6aiwFzrDdmw==} @@ -1382,6 +1463,17 @@ packages: resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} engines: {node: '>=14'} + socket.io-adapter@2.5.8: + resolution: {integrity: sha512-6Oy52pbg+kvdCVvjcN+FnY7BvxZ7cIHNScbvztT/It5d0vbwoJoVZmF2gjJmnV0/4WlXRfG15zc45ySk9Ah8bw==} + + socket.io-parser@4.2.7: + resolution: {integrity: sha512-IH/iSeO9T6gz1KkFleGDWkG9N3dl4jXVYUtMhIqH10Md0ttMer8nUNWiP1DKuNrybD2xBrixLJdCC9J6ECoYkg==} + engines: {node: '>=10.0.0'} + + socket.io@4.8.3: + resolution: {integrity: sha512-2Dd78bqzzjE6KPkD5fHZmDAKRNe3J15q+YHDrIsy9WEkqttc7GY+kT9OBLSMaPbQaEd0x1BjcmtMtXkfpc+T5A==} + engines: {node: '>=10.2.0'} + sonic-boom@4.2.1: resolution: {integrity: sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==} @@ -1458,11 +1550,27 @@ packages: resolution: {integrity: sha512-TQ5pAGhd5whStmqWvYF4OjQROlmv9SMFVt37qoCBdqRffuuklWYQlCNnEs2ZaIBD1kZRNnikiZOS1eqgkar0iw==} engines: {node: '>= 0.10'} + vary@1.1.2: + resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} + engines: {node: '>= 0.8'} + which@2.0.2: resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} engines: {node: '>= 8'} hasBin: true + ws@8.21.3: + resolution: {integrity: sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + xml-naming@0.3.0: resolution: {integrity: sha512-ghig2TBE/H11aOVgmahA3MhimvkBr6JIYknH/Dhdk10nXwdbIqBJsbfMxpvFPG8bAw77gN29aQWvKpmVoPlvPQ==} engines: {node: '>=16.0.0'} @@ -1655,7 +1763,7 @@ snapshots: transitivePeerDependencies: - supports-color - '@nestjs/core@12.0.1(@nestjs/common@12.0.1(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(reflect-metadata@0.2.2)(rxjs@7.8.2)': + '@nestjs/core@12.0.1(@nestjs/common@12.0.1(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/websockets@12.0.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)': dependencies: '@nestjs/common': 12.0.1(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2) fast-safe-stringify: 2.1.1 @@ -1665,13 +1773,15 @@ snapshots: rxjs: 7.8.2 tslib: 2.8.1 uid: 2.0.2 + optionalDependencies: + '@nestjs/websockets': 12.0.1(@nestjs/common@12.0.1(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@12.0.1)(@nestjs/platform-socket.io@12.0.1)(reflect-metadata@0.2.2)(rxjs@7.8.2) - '@nestjs/platform-fastify@12.0.1(@nestjs/common@12.0.1(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@12.0.1(@nestjs/common@12.0.1(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(reflect-metadata@0.2.2)(rxjs@7.8.2))': + '@nestjs/platform-fastify@12.0.1(@nestjs/common@12.0.1(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@12.0.1)': dependencies: '@fastify/cors': 11.3.0 '@fastify/formbody': 9.0.0 '@nestjs/common': 12.0.1(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2) - '@nestjs/core': 12.0.1(@nestjs/common@12.0.1(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 12.0.1(@nestjs/common@12.0.1(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/websockets@12.0.1)(reflect-metadata@0.2.2)(rxjs@7.8.2) fast-querystring: 1.1.2 fastify: 5.12.1 fastify-plugin: 6.0.0 @@ -1681,6 +1791,30 @@ snapshots: reusify: 1.1.0 tslib: 2.8.1 + '@nestjs/platform-socket.io@12.0.1(@nestjs/common@12.0.1(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/websockets@12.0.1)(rxjs@7.8.2)': + dependencies: + '@nestjs/common': 12.0.1(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/websockets': 12.0.1(@nestjs/common@12.0.1(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@12.0.1)(@nestjs/platform-socket.io@12.0.1)(reflect-metadata@0.2.2)(rxjs@7.8.2) + rxjs: 7.8.2 + socket.io: 4.8.3 + tslib: 2.8.1 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + + '@nestjs/websockets@12.0.1(@nestjs/common@12.0.1(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@12.0.1)(@nestjs/platform-socket.io@12.0.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)': + dependencies: + '@nestjs/common': 12.0.1(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 12.0.1(@nestjs/common@12.0.1(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/websockets@12.0.1)(reflect-metadata@0.2.2)(rxjs@7.8.2) + iterare: 1.2.1 + object-hash: 3.0.0 + reflect-metadata: 0.2.2 + rxjs: 7.8.2 + tslib: 2.8.1 + optionalDependencies: + '@nestjs/platform-socket.io': 12.0.1(@nestjs/common@12.0.1(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/websockets@12.0.1)(rxjs@7.8.2) + '@nodable/entities@3.0.0': {} '@node-rs/argon2-android-arm-eabi@2.1.0': @@ -1900,6 +2034,8 @@ snapshots: optionalDependencies: '@types/react': 19.2.18 + '@socket.io/component-emitter@3.1.2': {} + '@standard-schema/spec@1.1.0': {} '@tokenizer/inflate@0.4.1': @@ -1911,6 +2047,10 @@ snapshots: '@tokenizer/token@0.3.0': {} + '@types/cors@2.8.19': + dependencies: + '@types/node': 22.20.1 + '@types/d3-array@3.0.3': {} '@types/d3-color@3.1.0': {} @@ -1961,6 +2101,10 @@ snapshots: '@types/validator@13.15.10': {} + '@types/ws@8.18.1': + dependencies: + '@types/node': 22.20.1 + '@visx/curve@4.0.1-alpha.0': dependencies: '@visx/vendor': 4.0.0-alpha.0 @@ -2040,6 +2184,11 @@ snapshots: abstract-logging@2.0.1: {} + accepts@1.3.8: + dependencies: + mime-types: 2.1.35 + negotiator: 0.6.3 + ajv-formats@3.0.1(ajv@8.20.0): optionalDependencies: ajv: 8.20.0 @@ -2062,6 +2211,8 @@ snapshots: aws-ssl-profiles@1.1.2: {} + base64id@2.0.0: {} + better-result@2.10.0: {} c12@3.3.4(magicast@0.5.4): @@ -2099,8 +2250,15 @@ snapshots: confbox@0.2.4: {} + cookie@0.7.2: {} + cookie@1.1.1: {} + cors@2.8.6: + dependencies: + object-assign: 4.1.1 + vary: 1.1.2 + cross-spawn@7.0.6: dependencies: path-key: 3.1.1 @@ -2184,6 +2342,25 @@ snapshots: empathic@2.0.0: {} + engine.io-parser@5.2.3: {} + + engine.io@6.6.9: + dependencies: + '@types/cors': 2.8.19 + '@types/node': 22.20.1 + '@types/ws': 8.18.1 + accepts: 1.3.8 + base64id: 2.0.0 + cookie: 0.7.2 + cors: 2.8.6 + debug: 4.4.3 + engine.io-parser: 5.2.3 + ws: 8.21.3 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + env-paths@3.0.0: {} esbuild@0.28.2: @@ -2395,6 +2572,12 @@ snapshots: source-map-js: 1.2.1 optional: true + mime-db@1.52.0: {} + + mime-types@2.1.35: + dependencies: + mime-db: 1.52.0 + ms@2.1.3: {} mysql2@3.15.3: @@ -2413,6 +2596,12 @@ snapshots: dependencies: lru.min: 1.1.4 + negotiator@0.6.3: {} + + object-assign@4.1.1: {} + + object-hash@3.0.0: {} + ohash@2.0.12: {} on-exit-leak-free@2.1.2: {} @@ -2601,6 +2790,36 @@ snapshots: signal-exit@4.1.0: {} + socket.io-adapter@2.5.8: + dependencies: + debug: 4.4.3 + ws: 8.21.3 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + + socket.io-parser@4.2.7: + dependencies: + '@socket.io/component-emitter': 3.1.2 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + socket.io@4.8.3: + dependencies: + accepts: 1.3.8 + base64id: 2.0.0 + cors: 2.8.6 + debug: 4.4.3 + engine.io: 6.6.9 + socket.io-adapter: 2.5.8 + socket.io-parser: 4.2.7 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + sonic-boom@4.2.1: dependencies: atomic-sleep: 1.0.0 @@ -2660,10 +2879,14 @@ snapshots: validator@13.15.35: {} + vary@1.1.2: {} + which@2.0.2: dependencies: isexe: 2.0.0 + ws@8.21.3: {} + xml-naming@0.3.0: {} xtend@4.0.2: {}