feat: edição de rotas de entrada, fix de 2 bugs reais no ESL, diagnóstico de NAT/áudio e softphone WebRTC (PHASE 65/66)

Três achados reportados pelo usuário numa mensagem só: (1) Rotas de Entrada
não tinha edição depois de criada — implementada no mesmo padrão de Filas;
(2) telas de Platform > Infraestrutura sempre davam "Timeout no ESL" nesta
VM — não era limitação permanente como o comentário antigo dizia, e sim
ESL_HOST=freeswitch (nome DNS que só existe dentro da rede do Docker) mais
um segundo bug independente (`show gateways as json` não é comando válido
nesta versão do FreeSWITCH); (3) ramal externo registrava mas sem áudio —
diagnosticado com contadores de pacote do iptables: a VM está atrás de um
roteador sem port-forward pra faixa de RTP, achado de infraestrutura de
rede, não bug de código.

Também integra o softphone WebRTC (handphone.js/OpenSIPS, já em produção):
código-fonte encontrado em git.falehandix.com.br/Handix/handphone-2.0,
patch mínimo pra aceitar o endereço do proxy em runtime (era build-time),
nova config global (Platform > Infraestrutura > Softphone WebRTC) e widget
na topbar do tenant que pega usuário/domínio/senha do ramal vinculado ao
agente logado.

Adiciona docs/QA_SETUP.md — runbook completo pra subir o ambiente do zero
numa máquina nova (Docker, migrations, seed, systemd), e completa o
.env.example que estava faltando a maioria das variáveis reais.

Testado ponta a ponta com Playwright: edição de rota (criar/editar/F5),
as 3 telas de Infraestrutura com dado real, e um tenant/ramal/agente de
teste criados na hora confirmando que o script do softphone recebe as
credenciais certas.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BFaBaBSQGhyXGEgtTYZGV8
This commit is contained in:
2026-08-30 22:14:39 -03:00
parent a779a7e51f
commit 97ef8a6ba8
25 changed files with 1047 additions and 58 deletions

View File

@@ -10,11 +10,13 @@ import {
} from "@nestjs/common";
import { getPrismaClient, withTenantContext, type Prisma } from "@b2bcall/database";
import { recordAuditEvent, type AccessTokenClaims } from "@b2bcall/auth";
import { decryptSecret } from "@b2bcall/shared";
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";
import { WEBRTC_PROXY_SETTING_KEY } from "../platform/platform-webrtc-proxy.controller";
async function findMyAgent(tx: Prisma.TransactionClient, tenantId: string, userId: string) {
const agent = await tx.agent.findFirst({
@@ -58,6 +60,52 @@ export class AgentsMeController {
};
}
/**
* Credenciais SIP + endereço do proxy WebRTC pro softphone embutido
* (Handphone, PHASE 66) — sempre o ramal do PRÓPRIO agente, nunca um
* agentId/extensionId arbitrário do client (mesmo principio de
* `findMyAgent`). Diferente de `POST /extensions/:id/reveal-password`
* (que exige `extensions.manage`, permissão que um agente comum nunca
* tem): aqui não há permissão nenhuma além de "sou um agente logado
* com ramal vinculado" — é o próprio agente pegando a própria senha
* pra usar no softphone, não uma ação administrativa sobre o ramal de
* outra pessoa. Cada acesso fica no audit log (mesma lógica de
* `revealPassword`: decifrar de novo é sensível mesmo sem trocar nada).
*/
@Get("softphone-config")
async softphoneConfig(@CurrentUser() user: AccessTokenClaims) {
const prisma = getPrismaClient();
const tenantId = user.tenantId!;
const agent = await withTenantContext(prisma, tenantId, (tx) =>
tx.agent.findFirst({ where: { tenantId, userId: user.sub, deletedAt: null }, include: { extension: true } }),
);
if (!agent) {
throw new NotFoundException("Nenhum agente vinculado a este usuario neste tenant");
}
if (!agent.extension) {
return { hasExtension: false as const };
}
const setting = await prisma.platformSetting.findUnique({ where: { key: WEBRTC_PROXY_SETTING_KEY } });
await recordAuditEvent(prisma, {
action: "AGENT_SOFTPHONE_CONFIG_ACCESSED",
tenantId,
userId: user.sub,
entityType: "extension",
entityId: agent.extension.id,
});
return {
hasExtension: true as const,
username: agent.extension.number,
domain: agent.extension.domain,
password: decryptSecret(agent.extension.sipPasswordEnc),
displayName: agent.name,
proxyUrl: setting?.value ?? null,
};
}
/** Motivos de pausa pro próprio agente escolher — sem exigir
* `agents.view` (que listaria TODOS os agentes do tenant, permissão
* que o role "agent" nunca precisou ter até aqui). */