diff --git a/apps/api/src/extensions/extensions.controller.ts b/apps/api/src/extensions/extensions.controller.ts index 94af730..ca2d711 100644 --- a/apps/api/src/extensions/extensions.controller.ts +++ b/apps/api/src/extensions/extensions.controller.ts @@ -35,6 +35,7 @@ function toPublicExtension(ext: { codecs: string; callGroup: string | null; maxRegistrations: number; + registeredAt: Date | null; enabled: boolean; createdAt: Date; }) { diff --git a/apps/freeswitch-events/src/extension-status.ts b/apps/freeswitch-events/src/extension-status.ts new file mode 100644 index 0000000..884a60b --- /dev/null +++ b/apps/freeswitch-events/src/extension-status.ts @@ -0,0 +1,87 @@ +import { getPrismaClient, withTenantContext } from "@b2bcall/database"; +import { createLogger } from "@b2bcall/shared"; +import type { FreeSwitchTelephonyProvider } from "@b2bcall/telephony"; + +const logger = createLogger("b2bcall-fs-events"); + +/** + * sofia::register/unregister/expire não carregam `b2bcall_tenant_id` (só + * existe como channel variable de CHAMADA, nunca de REGISTER) — resolve + * por domínio (from-host), único por tenant desde a PHASE 52/53. Mesmo + * padrão de fan-out do `updateTrunkStatusFromGatewayEvent`, mas aqui o + * domínio já identifica o tenant direto, sem precisar tentar cada um. + */ +export async function updateExtensionRegistrationStatus( + user: string | undefined, + host: string | undefined, + registered: boolean, +): Promise { + if (!user || !host) return; + const prisma = getPrismaClient(); + + const tenant = await prisma.tenant.findFirst({ where: { telephonyDomain: host }, select: { id: true } }); + if (!tenant) { + logger.debug("nenhum tenant encontrado pro dominio do REGISTER", { host, user }); + return; + } + + const result = await withTenantContext(prisma, tenant.id, (tx) => + tx.extension.updateMany({ + where: { number: user, tenantId: tenant.id, deletedAt: null }, + data: { registeredAt: registered ? new Date() : null }, + }), + ); + if (result.count > 0) { + logger.info("status de registro do ramal atualizado", { user, host, registered }); + } +} + +/** + * Reconciliação ao conectar/reconectar no ESL (secao 55): sem isto, um + * ramal que já estava registrado ANTES do fs-events subir (ou durante uma + * queda do serviço) ficaria com `registered_at` desatualizado até o + * próximo REGISTER natural do aparelho — minutos de atraso, dependendo do + * `registration-expires` configurado no softphone. `show registrations` + * é a fonte de verdade ao vivo: zera todo mundo do tenant primeiro, + * depois marca só quem está de fato na lista agora. + */ +export async function reconcileExtensionRegistrations(provider: FreeSwitchTelephonyProvider): Promise { + const prisma = getPrismaClient(); + let raw: unknown; + try { + raw = await provider.getRegistrations(); + } catch (err) { + logger.error("falha ao consultar registrations pra reconciliacao", { error: String(err) }); + return; + } + + const rows = (raw as { rows?: Array<{ reg_user?: string; realm?: string }> } | undefined)?.rows ?? []; + const registeredByDomain = new Map>(); + for (const row of rows) { + if (!row.reg_user || !row.realm) continue; + if (!registeredByDomain.has(row.realm)) registeredByDomain.set(row.realm, new Set()); + registeredByDomain.get(row.realm)!.add(row.reg_user); + } + + const tenants = await prisma.tenant.findMany({ + where: { status: "ACTIVE", telephonyDomain: { not: null } }, + select: { id: true, telephonyDomain: true }, + }); + + for (const tenant of tenants) { + const registeredNumbers = Array.from(registeredByDomain.get(tenant.telephonyDomain!) ?? []); + await withTenantContext(prisma, tenant.id, async (tx) => { + await tx.extension.updateMany({ + where: { tenantId: tenant.id, deletedAt: null, number: { notIn: registeredNumbers } }, + data: { registeredAt: null }, + }); + if (registeredNumbers.length > 0) { + await tx.extension.updateMany({ + where: { tenantId: tenant.id, deletedAt: null, number: { in: registeredNumbers } }, + data: { registeredAt: new Date() }, + }); + } + }); + } + logger.info("reconciliacao de registrations de ramais concluida", { tenantsChecked: tenants.length }); +} diff --git a/apps/freeswitch-events/src/main.ts b/apps/freeswitch-events/src/main.ts index 4d11ba6..9fc4841 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 { updateExtensionRegistrationStatus, reconcileExtensionRegistrations } from "./extension-status"; import { resolveTenantIdForAgent, resolveTenantIdForQueue } from "./tenant-resolve"; import { persistCallEvent } from "./cdr"; import { uploadRecordingIfPresent } from "./recording"; @@ -90,6 +91,10 @@ async function main() { call.on(eventName, (raw) => handleEvent(eventName, raw)); } call.on("CUSTOM", (raw) => handleEvent("CUSTOM", raw)); + + reconcileExtensionRegistrations(provider).catch((err) => { + logger.error("falha na reconciliacao de registrations de ramais", { error: String(err) }); + }); }); client.on("reconnecting", (retryMs) => { @@ -170,6 +175,14 @@ async function main() { logger.error("falha ao atualizar status do trunk", { error: String(err), gateway }); }); } + + if (normalized.type === "EXTENSION_REGISTERED" || normalized.type === "EXTENSION_UNREGISTERED") { + const user = normalized.data.user as string | undefined; + const host = normalized.data.host as string | undefined; + updateExtensionRegistrationStatus(user, host, normalized.type === "EXTENSION_REGISTERED").catch((err) => { + logger.error("falha ao atualizar status de registro do ramal", { error: String(err), user, host }); + }); + } } provider.connect(); diff --git a/apps/frontend/src/app/app/monitoramento/monitoramento-view.tsx b/apps/frontend/src/app/app/monitoramento/monitoramento-view.tsx index 40ad04b..1d377d3 100644 --- a/apps/frontend/src/app/app/monitoramento/monitoramento-view.tsx +++ b/apps/frontend/src/app/app/monitoramento/monitoramento-view.tsx @@ -6,6 +6,7 @@ import { Panel, PanelHeader } from "@/components/ui/panel"; import { InstrumentTile } from "@/components/ui/instrument-tile"; import { Pill } from "@/components/ui/pill"; import { AGENT_STATE_LABELS, type Agent, type Queue } from "@/lib/callcenter-types"; +import type { Extension } from "@/lib/extension-types"; import { EVENT_CATEGORY, stripDomain, type RealtimeEvent } from "@/lib/realtime-types"; type ConnectionStatus = "connecting" | "live" | "reconnecting" | "closed"; @@ -69,7 +70,15 @@ function describeEvent(event: RealtimeEvent, agentNames: Record, } } -export function MonitoramentoView({ initialAgents, queues }: { initialAgents: Agent[]; queues: Queue[] }) { +export function MonitoramentoView({ + initialAgents, + queues, + initialExtensions, +}: { + initialAgents: Agent[]; + queues: Queue[]; + initialExtensions: Extension[]; +}) { const [status, setStatus] = useState("connecting"); const [events, setEvents] = useState([]); const [agentState, setAgentState] = useState>(() => @@ -77,9 +86,21 @@ export function MonitoramentoView({ initialAgents, queues }: { initialAgents: Ag ); const [queueCounts, setQueueCounts] = useState>({}); const [tallies, setTallies] = useState({ created: 0, answered: 0, ended: 0 }); + // Ramal está online quando `registeredAt` não é null (secao 55 — + // apps/freeswitch-events mantém isso via sofia::register/unregister/ + // expire, com reconciliação ao conectar no ESL). Chave = número do + // ramal, que é o mesmo valor que `EXTENSION_REGISTERED`/ + // `EXTENSION_UNREGISTERED` trazem em `data.user` (sem domínio). + const [extensionRegisteredAt, setExtensionRegisteredAt] = useState>(() => + Object.fromEntries(initialExtensions.map((e) => [e.number, e.registeredAt])), + ); const agentNames = useMemo(() => Object.fromEntries(initialAgents.map((a) => [a.id, a.name])), [initialAgents]); const queueNames = useMemo(() => Object.fromEntries(queues.map((q) => [q.id, q.name])), [queues]); + const onlineExtensionCount = useMemo( + () => Object.values(extensionRegisteredAt).filter((v) => v != null).length, + [extensionRegisteredAt], + ); const eventsRef = useRef([]); @@ -115,6 +136,16 @@ export function MonitoramentoView({ initialAgents, queues }: { initialAgents: Ag setQueueCounts((prev) => ({ ...prev, [queueId]: event.data.count as number })); } } + + if (event.type === "EXTENSION_REGISTERED" || event.type === "EXTENSION_UNREGISTERED") { + const number = String(event.data.user ?? ""); + if (number) { + setExtensionRegisteredAt((prev) => ({ + ...prev, + [number]: event.type === "EXTENSION_REGISTERED" ? event.occurredAt : null, + })); + } + } }; source.onerror = () => { @@ -140,10 +171,16 @@ export function MonitoramentoView({ initialAgents, queues }: { initialAgents: Ag -
+
+
@@ -186,6 +223,41 @@ export function MonitoramentoView({ initialAgents, queues }: { initialAgents: Ag )} + + + {initialExtensions.length === 0 ? ( +

Nenhum ramal cadastrado ainda.

+ ) : ( +
    + {initialExtensions.map((ext) => { + const registeredAt = extensionRegisteredAt[ext.number] ?? null; + const online = registeredAt != null; + return ( +
  • + + + {ext.number} + {ext.name} + {ext.callGroup && grupo {ext.callGroup}} + + + {online && ( + + desde {new Date(registeredAt).toLocaleTimeString("pt-BR")} + + )} + {online ? "Online" : "Offline"} + +
  • + ); + })} +
+ )} +
+ {events.length === 0 ? ( diff --git a/apps/frontend/src/app/app/monitoramento/page.tsx b/apps/frontend/src/app/app/monitoramento/page.tsx index 425649a..545f423 100644 --- a/apps/frontend/src/app/app/monitoramento/page.tsx +++ b/apps/frontend/src/app/app/monitoramento/page.tsx @@ -1,15 +1,17 @@ import { requireSession } from "@/lib/session"; import { apiFetch } from "@/lib/api"; import type { Agent, Queue } from "@/lib/callcenter-types"; +import type { Extension } from "@/lib/extension-types"; import { MonitoramentoView } from "./monitoramento-view"; export default async function MonitoramentoPage() { const session = await requireSession(); - const [agents, queues] = await Promise.all([ + const [agents, queues, extensions] = await Promise.all([ apiFetch("/agents", session.accessToken), apiFetch("/queues", session.accessToken), + apiFetch("/extensions", session.accessToken), ]); - return ; + return ; } diff --git a/apps/frontend/src/lib/extension-types.ts b/apps/frontend/src/lib/extension-types.ts index 994f4b0..7bc630f 100644 --- a/apps/frontend/src/lib/extension-types.ts +++ b/apps/frontend/src/lib/extension-types.ts @@ -11,6 +11,7 @@ export interface Extension { codecs: string; callGroup: string | null; maxRegistrations: number; + registeredAt: string | null; enabled: boolean; createdAt: string; updatedAt: string; diff --git a/packages/database/prisma/migrations/20260830160000_extension_registered_at/migration.sql b/packages/database/prisma/migrations/20260830160000_extension_registered_at/migration.sql new file mode 100644 index 0000000..7c63b80 --- /dev/null +++ b/packages/database/prisma/migrations/20260830160000_extension_registered_at/migration.sql @@ -0,0 +1,2 @@ +-- PHASE 55: status de registro SIP em tempo real (monitoramento de ramais) +ALTER TABLE "extensions" ADD COLUMN "registered_at" TIMESTAMP(3); diff --git a/packages/database/prisma/schema.prisma b/packages/database/prisma/schema.prisma index 47c337b..c982be9 100644 --- a/packages/database/prisma/schema.prisma +++ b/packages/database/prisma/schema.prisma @@ -321,6 +321,17 @@ model Extension { maxRegistrations Int @default(1) @map("max_registrations") + // Status de registro SIP em tempo real (PHASE 55, monitoramento — + // achado real: usuário pediu "quantos ramais estão online e o status de + // cada" antes de ir pro IVR). Preenchido pelo `apps/freeswitch-events` + // a cada `sofia::register`/`sofia::unregister`/`sofia::expire` — null = + // não registrado agora. Não dá pra consultar o ESL direto de + // `apps/api` pra isso (roda no host, `freeswitch:8021` só existe na + // rede interna do Docker, ver docs/FREESWITCH.md), mas fs-events já + // tem conexão ESL permanente e já consome esses eventos pra outros + // fins — mesmo padrão já usado em `Trunk.status`/`statusUpdatedAt`. + registeredAt DateTime? @map("registered_at") + enabled Boolean @default(true) createdAt DateTime @default(now()) @map("created_at")