Files
B2BCall-dialer/apps/api/src/extensions/extensions.controller.ts
Matheus fc4f5db13a feat(monitoramento): status de registro dos ramais (online/offline) em tempo real
Pedido do usuário: "antes de ir para IVR no monitoramento tem que mostrar
quantos ramais estão online e o status de cada ramal criado".

apps/api roda no host e não alcança o ESL do FreeSWITCH diretamente
(freeswitch:8021 só existe na rede interna do Docker) — mesmo padrão já
documentado em platform-freeswitch.controller.ts. Por isso, seguido o
mesmo caminho já usado por Trunk.status: apps/freeswitch-events (conexão
ESL permanente) já consumia sofia::register/unregister/expire pra outros
fins, então virou a fonte de verdade — grava `Extension.registeredAt` a
cada evento, mais uma reconciliação completa (`show registrations`) ao
conectar/reconectar no ESL pra não ficar com dado desatualizado se o
serviço caiu no meio de uma sessão de registro.

GET /extensions agora devolve `registeredAt`; a página /app/monitoramento
ganhou um painel "Ramais" com status ao vivo (online/offline, desde
quando, grupo de captura) e um instrumento "Ramais online: X de Y" — a
mesma conexão SSE que já existia só precisou aprender a atualizar esse
novo estado a partir dos eventos EXTENSION_REGISTERED/UNREGISTERED, que
já estavam sendo publicados e só apareciam no log de eventos.

Testado: reconciliação confirmada rodando no container real (2 ramais já
registrados antes do restart do fs-events foram marcados online
corretamente); GET /extensions confirmado devolvendo registeredAt (null
num ramal recém-criado, sem registro).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BFaBaBSQGhyXGEgtTYZGV8
2026-08-30 16:02:14 -03:00

261 lines
8.7 KiB
TypeScript

import {
BadRequestException,
Body,
Controller,
Delete,
Get,
HttpCode,
HttpStatus,
NotFoundException,
Param,
Patch,
Post,
UseGuards,
} from "@nestjs/common";
import { getPrismaClient, withTenantContext } from "@b2bcall/database";
import { generateStrongPassword, encryptSecret, decryptSecret } from "@b2bcall/shared";
import { recordAuditEvent, type AccessTokenClaims } from "@b2bcall/auth";
import { assertQuota } from "@b2bcall/entitlements";
import { JwtAuthGuard } from "../common/guards/jwt-auth.guard";
import { PermissionGuard } from "../common/guards/permission.guard";
import { RequirePermission } from "../common/decorators/require-permission.decorator";
import { CurrentUser } from "../common/decorators/current-user.decorator";
import { CreateExtensionDto, UpdateExtensionDto } from "./dto/create-extension.dto";
function toPublicExtension(ext: {
id: string;
number: string;
name: string;
domain: string;
sipPasswordEnc: string;
callerIdName: string | null;
callerIdNumber: string | null;
context: string;
sofiaProfile: string;
codecs: string;
callGroup: string | null;
maxRegistrations: number;
registeredAt: Date | null;
enabled: boolean;
createdAt: Date;
}) {
// sipPasswordEnc NUNCA sai daqui — agente.md secao 39: "Nunca mostrar
// novamente a senha inteira". Destructuring explícito (não spread) pra
// garantir que o campo é de fato removido, não só "esquecido" no tipo.
const { sipPasswordEnc: _sipPasswordEnc, ...rest } = ext;
return rest;
}
@UseGuards(JwtAuthGuard, PermissionGuard)
@Controller("extensions")
export class ExtensionsController {
@RequirePermission("extensions.manage")
@Post()
async create(@CurrentUser() user: AccessTokenClaims, @Body() dto: CreateExtensionDto) {
const prisma = getPrismaClient();
const tenantId = user.tenantId!;
const tenant = await withTenantContext(prisma, tenantId, (tx) =>
tx.tenant.findUniqueOrThrow({ where: { id: tenantId } }),
);
if (!tenant.telephonyDomain) {
throw new BadRequestException(
"Tenant ainda nao tem telephony_domain configurado — necessario antes de criar ramais",
);
}
const activeCount = await withTenantContext(prisma, tenantId, (tx) =>
tx.extension.count({ where: { tenantId, deletedAt: null } }),
);
await assertQuota(tenantId, "maxExtensions", activeCount);
const plainPassword = generateStrongPassword();
const extension = await withTenantContext(prisma, tenantId, (tx) =>
tx.extension.create({
data: {
tenantId,
number: dto.number,
name: dto.name,
domain: tenant.telephonyDomain!,
sipPasswordEnc: encryptSecret(plainPassword),
callerIdName: dto.callerIdName,
callerIdNumber: dto.callerIdNumber,
context: dto.context ?? "default",
sofiaProfile: dto.sofiaProfile ?? "internal",
maxRegistrations: dto.maxRegistrations ?? 1,
callGroup: dto.callGroup,
},
}),
);
await recordAuditEvent(prisma, {
action: "EXTENSION_CREATE",
tenantId,
userId: user.sub,
entityType: "extension",
entityId: extension.id,
after: { number: extension.number, name: extension.name },
});
return {
...toPublicExtension(extension),
// Só aqui, uma unica vez, na resposta da criacao.
sipPassword: plainPassword,
};
}
@RequirePermission("extensions.view")
@Get()
async list(@CurrentUser() user: AccessTokenClaims) {
const prisma = getPrismaClient();
const tenantId = user.tenantId!;
const extensions = await withTenantContext(prisma, tenantId, (tx) =>
tx.extension.findMany({ where: { deletedAt: null }, orderBy: { number: "asc" } }),
);
return extensions.map(toPublicExtension);
}
@RequirePermission("extensions.view")
@Get(":id")
async get(@CurrentUser() user: AccessTokenClaims, @Param("id") id: string) {
const prisma = getPrismaClient();
const tenantId = user.tenantId!;
const extension = await withTenantContext(prisma, tenantId, (tx) =>
tx.extension.findFirst({ where: { id, deletedAt: null } }),
);
if (!extension) {
throw new NotFoundException();
}
return toPublicExtension(extension);
}
/**
* Redefine a senha SIP (agente.md secao 39: nunca reexpor a senha
* existente — a única forma de "editar" é gerar uma nova e mostrar
* ela UMA vez, mesmo caminho da criação). `b2bcall-fs-config` resolve
* o directory ao vivo por request (sem arquivo/sync intermediário,
* diferente de trunks/queues) — um `UPDATE` aqui já é o suficiente,
* o próximo REGISTER do ramal usa a senha nova automaticamente.
*/
@RequirePermission("extensions.manage")
@Post(":id/reset-password")
async resetPassword(@CurrentUser() user: AccessTokenClaims, @Param("id") id: string) {
const prisma = getPrismaClient();
const tenantId = user.tenantId!;
const plainPassword = generateStrongPassword();
const result = await withTenantContext(prisma, tenantId, (tx) =>
tx.extension.updateMany({
where: { id, tenantId, deletedAt: null },
data: { sipPasswordEnc: encryptSecret(plainPassword) },
}),
);
if (result.count === 0) {
throw new NotFoundException();
}
await recordAuditEvent(prisma, {
action: "EXTENSION_RESET_PASSWORD",
tenantId,
userId: user.sub,
entityType: "extension",
entityId: id,
});
return { sipPassword: plainPassword };
}
/**
* Revela a senha SIP atual (achado real reportado pelo usuário: "show
* once" puro não funciona no dia a dia — reconfigurar um telefone físico
* ou um softphone precisa da senha de novo, e forçar reset toda vez
* derruba o registro de qualquer aparelho já configurado com a senha
* antiga). Diferente de `resetPassword`: não gera senha nova, só
* decifra a que já existe (`sipPasswordEnc` é criptografia reversível
* AES-256-GCM, não hash — sempre foi possível decifrar, só não estava
* exposto). Cada chamada fica no audit log — ver a senha de novo é uma
* ação sensível, mesmo sem trocar nada.
*/
@RequirePermission("extensions.manage")
@Post(":id/reveal-password")
async revealPassword(@CurrentUser() user: AccessTokenClaims, @Param("id") id: string) {
const prisma = getPrismaClient();
const tenantId = user.tenantId!;
const extension = await withTenantContext(prisma, tenantId, (tx) =>
tx.extension.findFirst({ where: { id, tenantId, deletedAt: null } }),
);
if (!extension) throw new NotFoundException();
await recordAuditEvent(prisma, {
action: "EXTENSION_PASSWORD_REVEALED",
tenantId,
userId: user.sub,
entityType: "extension",
entityId: id,
});
return { sipPassword: decryptSecret(extension.sipPasswordEnc) };
}
@RequirePermission("extensions.manage")
@Patch(":id")
async update(@CurrentUser() user: AccessTokenClaims, @Param("id") id: string, @Body() dto: UpdateExtensionDto) {
const prisma = getPrismaClient();
const tenantId = user.tenantId!;
const result = await withTenantContext(prisma, tenantId, (tx) =>
tx.extension.updateMany({
where: { id, tenantId, deletedAt: null },
data: {
...(dto.callerIdName !== undefined ? { callerIdName: dto.callerIdName } : {}),
...(dto.callerIdNumber !== undefined ? { callerIdNumber: dto.callerIdNumber } : {}),
...(dto.maxRegistrations !== undefined ? { maxRegistrations: dto.maxRegistrations } : {}),
...(dto.callGroup !== undefined ? { callGroup: dto.callGroup } : {}),
},
}),
);
if (result.count === 0) throw new NotFoundException();
const updated = await withTenantContext(prisma, tenantId, (tx) => tx.extension.findFirstOrThrow({ where: { id } }));
await recordAuditEvent(prisma, {
action: "EXTENSION_UPDATE",
tenantId,
userId: user.sub,
entityType: "extension",
entityId: id,
after: { ...dto },
});
return toPublicExtension(updated);
}
@RequirePermission("extensions.manage")
@Delete(":id")
@HttpCode(HttpStatus.NO_CONTENT)
async remove(@CurrentUser() user: AccessTokenClaims, @Param("id") id: string) {
const prisma = getPrismaClient();
const tenantId = user.tenantId!;
const result = await withTenantContext(prisma, tenantId, (tx) =>
tx.extension.updateMany({
where: { id, deletedAt: null },
data: { deletedAt: new Date(), enabled: false },
}),
);
if (result.count === 0) {
throw new NotFoundException();
}
await recordAuditEvent(prisma, {
action: "EXTENSION_DELETE",
tenantId,
userId: user.sub,
entityType: "extension",
entityId: id,
});
}
}