feat(realtime): monitoramento em tempo real via WebSocket multi-tenant

Fecha agente.md secao 54-55 (infraestrutura) e 161 (WebSocket multi-tenant).
Entrega o pipeline de push em tempo real completo — o consumo visual
("Monitoramento -> Filas/Ramais") fica pra fase Frontend.

Requisito central da secao 161 ("nao transmitir tudo e filtrar so no
browser"): RealtimeGateway tem um unico ponto de emissao,
broadcastToTenant(), sempre server.to(`tenant:<id>`), nunca broadcast
global. Cada socket entra na room do proprio tenant no handshake, nunca
escolhe a room.

Autenticacao na conexao (handshake.auth.token, nao Authorization header):
valida o JWT (mesmo verifyAccessToken do JwtAuthGuard), exige tenantId no
token e a permission monitoring.view (ja existia desde RBAC, sem
consumidor ate agora) — mesmo principio de nunca confiar em tenant_id do
client, so do JWT ja emitido por /auth/select-tenant.

Origem dos eventos: canal Redis unico b2bcall:events (o mesmo desde Event
Socket). Dois produtores: b2bcall-fs-events (eventos do FreeSWITCH,
resolvendo tenantId por fan-out quando nao ha channel variable, ver
tenant-resolve.ts) e apps/api (mudancas no nosso Agent.state via
agents-me.controller, tenantId direto do JWT, sem fan-out).

Bug real achado e corrigido ao construir esta fase: nenhum evento CUSTOM do
ESL (sofia::register, sofia::gateway_state, callcenter::info) jamais
chegava em b2bcall-fs-events nesta sessao inteira. Causa: event_json(...)
mandava "CUSTOM" como ultimo token do comando `event json`, sem subclass
depois — mod_event_socket exige os subclasses logo depois do token CUSTOM
no mesmo comando pra serem entregues. Corrigido separando PLAIN_EVENTS
(viram listener .on()) de CUSTOM_SUBCLASSES (so compoem o comando de
assinatura). Resolve as lacunas ja documentadas em docs/TRUNKS.md e
docs/AGENTS.md. De quebra, corrigido um bug de nome de campo
(CC-Agent-Status, que nao existe -> CC-Agent-State) e um segundo bug real
em trunk-sync.ts (rescan nunca descarregava gateway removido -> agora roda
`killgw` antes do rescan).

Novos tipos normalizados a partir de callcenter::info, com nomes de campo
confirmados contra uma fila real: AGENT_OFFERED_CALL, AGENT_BRIDGE_FAILED,
QUEUE_MEMBER_COUNT (chamadas esperando, secao 54), QUEUE_MEMBER_LEFT (com
cause/cancelReason e timestamps — base pra Service Level/Abandon Rate
quando CDR existir).

Verificado ponta a ponta com um client socket.io real: login/pause/resume/
logout emitindo AGENT_STATE_CHANGED; chamada de teste numa fila com agente
logado emitindo QUEUE_MEMBER_COUNT/LEFT, AGENT_OFFERED_CALL,
AGENT_BRIDGE_FAILED, AGENT_STATUS_CHANGED (CC-Agent-State correto); token
ausente/invalido desconectado na hora, sem vazar nenhum evento.

Achado sistemico durante o teste (documentado, nao corrigido nesta fase):
@@unique combinado com soft delete, sem excluir deletedAt, em
Agent/Extension/Trunk/Queue/PauseReason — nao da pra reusar numero/nome/
codigo depois de apagar. Precisa de indice unico parcial em cada um, fora
do escopo desta fase.

typecheck do workspace inteiro limpo. ~144MB de memoria total nos
containers (fs-events 44MB, fs-config 45MB, freeswitch 26MB, postgres
21MB, redis 8MB).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X1HxY46WGU4G1zmVDNKcWw
This commit is contained in:
2026-08-28 11:55:52 -03:00
parent b940ce3e63
commit f051fe3162
15 changed files with 661 additions and 12 deletions

48
TODO.md
View File

@@ -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 <nome>` 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:<id>`) — 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)
---

View File

@@ -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",

View File

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

View File

@@ -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 {}

View File

@@ -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}`);

View File

@@ -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<void> {
const event: NormalizedEvent = {
type: "AGENT_STATE_CHANGED",
occurredAt: new Date().toISOString(),
tenantId,
data: { agentId, state },
};
await getRedisClient().publish(EVENTS_CHANNEL, JSON.stringify(event));
}

View File

@@ -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<void> {
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<void> {
await this.subscriber?.quit();
}
}

View File

@@ -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<void> {
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);
}
}

View File

@@ -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 {}

View File

@@ -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<typeof normalizeEslEvent>): Promise<string | undefined> {
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) });
});

View File

@@ -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 (`<uuid>@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<string, string>();
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<unknown>,
): Promise<string | undefined> {
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<string | undefined> {
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<string | undefined> {
const id = extractId(queueFsName);
if (!id) return undefined;
return resolveViaFanOut(`queue:${id}`, (tx) => tx.queue.findUnique({ where: { id } }));
}

View File

@@ -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

129
docs/REALTIME.md Normal file
View File

@@ -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:<tenantId>`) 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 (`<uuid>@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:<id>`:
```
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).

View File

@@ -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"

233
pnpm-lock.yaml generated
View File

@@ -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: {}