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:
@@ -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). */
|
||||
|
||||
6
apps/api/src/platform/dto/update-webrtc-proxy.dto.ts
Normal file
6
apps/api/src/platform/dto/update-webrtc-proxy.dto.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { IsUrl } from "class-validator";
|
||||
|
||||
export class UpdateWebrtcProxyDto {
|
||||
@IsUrl({ protocols: ["ws", "wss"], require_protocol: true, require_tld: false })
|
||||
url!: string;
|
||||
}
|
||||
57
apps/api/src/platform/platform-webrtc-proxy.controller.ts
Normal file
57
apps/api/src/platform/platform-webrtc-proxy.controller.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import { Body, Controller, ForbiddenException, Get, Put, UseGuards } from "@nestjs/common";
|
||||
import { getPrismaClient } from "@b2bcall/database";
|
||||
import { recordAuditEvent, isPlatformUser, type AccessTokenClaims } from "@b2bcall/auth";
|
||||
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 { UpdateWebrtcProxyDto } from "./dto/update-webrtc-proxy.dto";
|
||||
|
||||
export const WEBRTC_PROXY_SETTING_KEY = "webrtc_proxy_url";
|
||||
|
||||
/**
|
||||
* "Sistema > Softphone WebRTC" — endereço WSS do proxy OpenSIPS que faz a
|
||||
* ponte WebRTC↔SIP pro widget Handphone embutido no app do tenant (PHASE
|
||||
* 66, ver docs/SOFTPHONE.md). Config global (não por tenant): um único
|
||||
* OpenSIPS atende todos os tenants, cada ramal continua puro SIP — o
|
||||
* FreeSWITCH deste projeto nunca fala WebRTC diretamente.
|
||||
*/
|
||||
@UseGuards(JwtAuthGuard, PermissionGuard)
|
||||
@Controller("platform/webrtc-proxy")
|
||||
export class PlatformWebrtcProxyController {
|
||||
@RequirePermission("freeswitch.view")
|
||||
@Get()
|
||||
async get(@CurrentUser() user: AccessTokenClaims) {
|
||||
if (!(await isPlatformUser(user.sub))) {
|
||||
throw new ForbiddenException("So' um usuario com role de plataforma pode ver esta configuracao");
|
||||
}
|
||||
const prisma = getPrismaClient();
|
||||
const setting = await prisma.platformSetting.findUnique({ where: { key: WEBRTC_PROXY_SETTING_KEY } });
|
||||
return { url: setting?.value ?? null };
|
||||
}
|
||||
|
||||
@RequirePermission("freeswitch.configure")
|
||||
@Put()
|
||||
async update(@CurrentUser() user: AccessTokenClaims, @Body() dto: UpdateWebrtcProxyDto) {
|
||||
if (!(await isPlatformUser(user.sub))) {
|
||||
throw new ForbiddenException("So' um usuario com role de plataforma pode editar esta configuracao");
|
||||
}
|
||||
const prisma = getPrismaClient();
|
||||
const setting = await prisma.platformSetting.upsert({
|
||||
where: { key: WEBRTC_PROXY_SETTING_KEY },
|
||||
create: { key: WEBRTC_PROXY_SETTING_KEY, value: dto.url, updatedBy: user.sub },
|
||||
update: { value: dto.url, updatedBy: user.sub },
|
||||
});
|
||||
|
||||
await recordAuditEvent(prisma, {
|
||||
action: "PLATFORM_WEBRTC_PROXY_UPDATE",
|
||||
tenantId: null,
|
||||
userId: user.sub,
|
||||
entityType: "platform_setting",
|
||||
entityId: WEBRTC_PROXY_SETTING_KEY,
|
||||
after: { url: dto.url },
|
||||
});
|
||||
|
||||
return { url: setting.value };
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import { PlatformQuotasController } from "./platform-quotas.controller";
|
||||
import { PlatformFreeswitchController } from "./platform-freeswitch.controller";
|
||||
import { PlatformAiUsageController } from "./platform-ai-usage.controller";
|
||||
import { PlatformSystemConfigController } from "./platform-system-config.controller";
|
||||
import { PlatformWebrtcProxyController } from "./platform-webrtc-proxy.controller";
|
||||
|
||||
@Module({
|
||||
controllers: [
|
||||
@@ -20,6 +21,7 @@ import { PlatformSystemConfigController } from "./platform-system-config.control
|
||||
PlatformFreeswitchController,
|
||||
PlatformAiUsageController,
|
||||
PlatformSystemConfigController,
|
||||
PlatformWebrtcProxyController,
|
||||
],
|
||||
})
|
||||
export class PlatformModule {}
|
||||
|
||||
Reference in New Issue
Block a user