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:
82
.env.example
82
.env.example
@@ -1,8 +1,82 @@
|
||||
# Copie pra `.env` (gitignored) e preencha. Ver docs/QA_SETUP.md pro passo a
|
||||
# passo completo de como subir o ambiente do zero numa máquina nova.
|
||||
|
||||
# Token de acesso ao repositório de pacotes .deb do FreeSWITCH
|
||||
# (freeswitch.signalwire.com) — só existe durante o build da imagem Docker
|
||||
# (BuildKit secret, nunca vira camada da imagem nem chega no container em
|
||||
# runtime). Peça este valor a quem já tem uma conta SignalWire; não é gerável
|
||||
# localmente.
|
||||
FREESWITCH_PAT=
|
||||
DATABASE_URL=postgresql://user:password@localhost:5432/b2bcall?schema=public
|
||||
POSTGRES_APP_USER=
|
||||
|
||||
# apps/api roda direto no host (fora do Docker) — fixado aqui pra nunca
|
||||
# depender de quem inicia primeiro disputando a porta padrão 3000 com o
|
||||
# apps/frontend.
|
||||
API_PORT=3000
|
||||
|
||||
# --- Postgres (container b2bcall-postgres, gerar senhas com `openssl rand -base64 24`) ---
|
||||
POSTGRES_USER=b2bcall
|
||||
POSTGRES_PASSWORD=
|
||||
POSTGRES_DB=b2bcall
|
||||
POSTGRES_HOST=postgres
|
||||
POSTGRES_PORT=5432
|
||||
|
||||
# --- Redis (container b2bcall-redis) ---
|
||||
REDIS_PASSWORD=
|
||||
REDIS_HOST=redis
|
||||
REDIS_PORT=6379
|
||||
|
||||
# --- Segredos da aplicação (gerar cada um com `openssl rand -hex 32`) ---
|
||||
JWT_SECRET=
|
||||
JWT_REFRESH_SECRET=
|
||||
ENCRYPTION_KEY=
|
||||
|
||||
# Event Socket do FreeSWITCH — só apps/api (systemd, no host, fora do Docker)
|
||||
# lê isto. "freeswitch" é um nome DNS que só existe na rede interna do
|
||||
# Docker; os serviços que RODAM dentro do Docker (fs-events, fs-config,
|
||||
# predictive-dialer) têm ESL_HOST=freeswitch fixo no próprio
|
||||
# docker-compose.yml, nunca leem esta variável. Precisa ser 127.0.0.1 (a
|
||||
# porta 8021 é publicada só em loopback, ver docker-compose.yml) — nunca
|
||||
# "freeswitch" aqui.
|
||||
ESL_HOST=127.0.0.1
|
||||
ESL_PORT=8021
|
||||
ESL_PASSWORD=
|
||||
|
||||
# Segurança de chamada real (agente.md secao 186) — as DUAS precisam estar
|
||||
# explicitamente na posição de risco pra originar uma chamada PSTN de
|
||||
# verdade. Deixe como está pra um ambiente de QA/laboratório.
|
||||
DIALER_SIMULATION=true
|
||||
ALLOW_REAL_OUTBOUND_CALLS=false
|
||||
|
||||
# --- Gravações (agente.md secao 90-93) ---
|
||||
STORAGE_PROVIDER=LOCAL
|
||||
LOCAL_STORAGE_ROOT=/opt/b2bcall/data/object-storage-local
|
||||
|
||||
# --- Prompts de áudio do IVR (PHASE 59) ---
|
||||
IVR_PROMPTS_HOST_ROOT=/opt/b2bcall/data/ivr-prompts
|
||||
|
||||
# --- S3/MinIO (só usado se STORAGE_PROVIDER=s3; deixe em branco pro padrão LOCAL) ---
|
||||
S3_ENDPOINT=
|
||||
S3_REGION=
|
||||
S3_ACCESS_KEY_ID=
|
||||
S3_SECRET_ACCESS_KEY=
|
||||
S3_BUCKET=
|
||||
S3_FORCE_PATH_STYLE=true
|
||||
|
||||
# --- Connection strings derivadas dos valores acima (preencher com os
|
||||
# mesmos POSTGRES_PASSWORD/REDIS_PASSWORD já definidos) ---
|
||||
DATABASE_URL=postgresql://b2bcall:@localhost:5432/b2bcall?schema=public
|
||||
REDIS_URL=redis://:@localhost:6379
|
||||
|
||||
# Role restrito (não-superuser) que a aplicação usa em runtime pra RLS
|
||||
# funcionar de verdade (agente.md secao 32) — a role em si é criada por uma
|
||||
# migration do Prisma, mas a senha nunca fica num arquivo de migration
|
||||
# commitado (ver scripts/db-setup-app-role.sh).
|
||||
POSTGRES_APP_USER=b2bcall_app
|
||||
POSTGRES_APP_PASSWORD=
|
||||
APP_DATABASE_URL=postgresql://user:password@localhost:5432/b2bcall?schema=public
|
||||
REDIS_URL=redis://:password@localhost:6379
|
||||
APP_DATABASE_URL=postgresql://b2bcall_app:@localhost:5432/b2bcall?schema=public
|
||||
|
||||
# Credenciais que b2bcall-fs-config usa pra autenticar no Event Socket
|
||||
# quando o PRÓPRIO FreeSWITCH chama de volta (mod_xml_curl) — role de app
|
||||
# dedicado, nada a ver com FS_CONFIG_USER de um tenant.
|
||||
FS_CONFIG_USER=
|
||||
FS_CONFIG_PASSWORD=
|
||||
|
||||
80
TODO.md
80
TODO.md
@@ -2337,6 +2337,86 @@ a funcionalidade de verdade)
|
||||
sobreviveu ao F5. Confirmado ainda que corrigir a Hydration
|
||||
eliminou o erro do console sem quebrar nenhum dos dois cenários
|
||||
|
||||
## PHASE 65 — 3 achados reais reportados pelo usuário numa mensagem só:
|
||||
edição de rota de entrada faltando, telas de Infraestrutura sempre com
|
||||
erro de ESL, e ramal externo registrando sem áudio
|
||||
- [x] Rotas de Entrada não tinha edição depois de criada (só criar/
|
||||
remover) — mesmo padrão de Filas (form inline por linha,
|
||||
`Fragment`-wrapped). DID não pode ser trocado depois de criado
|
||||
(é a chave de roteamento — trocar quebraria o vínculo com o
|
||||
tronco/operadora). Testado ponta a ponta com Playwright: criar,
|
||||
editar descrição, F5, confirma que persistiu.
|
||||
- [x] Telas de `Platform > Infraestrutura` sempre mostravam "Timeout
|
||||
conectando no ESL" nesta VM — suposição registrada até então
|
||||
(incorreta) era que isso seria permanente (`apps/api` fora do
|
||||
Docker, porta nunca publicada). Causa real: `ESL_HOST=freeswitch`
|
||||
no `.env` é um nome DNS que só existe dentro da rede do Docker,
|
||||
nunca resolve a partir do host. O host SEMPRE alcança o IP de
|
||||
qualquer container na rede bridge diretamente, com ou sem porta
|
||||
publicada — só outras máquinas são bloqueadas sem `ports:`. Fix:
|
||||
publicar 8021 só em loopback (`127.0.0.1:8021:8021`) + trocar
|
||||
`ESL_HOST` pra `127.0.0.1`. Ver docs/FREESWITCH.md ("Achados na
|
||||
sessão de PHASE 65") pro segundo bug achado na mesma investigação
|
||||
(`show gateways as json` não é um comando válido nesta versão do
|
||||
FreeSWITCH). Verificado com curl + Playwright nas 3 telas.
|
||||
- [x] "Registrei o ramal e não passou áudio" — diagnosticado com
|
||||
contadores de pacotes do `iptables` (não suposição): SIP chegava,
|
||||
RTP nunca chegava. Causa: esta VM está atrás de um roteador (NAT)
|
||||
sem port-forward configurado pra faixa de RTP — infraestrutura de
|
||||
rede fora do controle desta aplicação, não um bug de código (a
|
||||
configuração de NAT-detection do próprio FreeSWITCH já estava
|
||||
correta, testado direto via `fs_cli -x "acl <ip> nat.auto"`). Ver
|
||||
docs/NETWORK_ARCHITECTURE.md pro diagnóstico completo e o
|
||||
checklist de portas pra encaminhar no roteador.
|
||||
|
||||
## PHASE 66 — Softphone WebRTC embutido (pedido do usuário: "integre o
|
||||
widget handphone.js, é um webrtc que vai conectar em outro proxy [OpenSIPS,
|
||||
já em produção] que depois vai vir via sip comum ao b2bcall")
|
||||
- [x] O FreeSWITCH deste projeto nunca fala WebRTC — quem faz a ponte
|
||||
WebRTC↔SIP é um OpenSIPS externo já em produção. Cada ramal
|
||||
continua um registro SIP puro; só o NAVEGADOR do agente conecta
|
||||
via WebRTC no OpenSIPS.
|
||||
- [x] Código-fonte do widget (`handphone.js`, já commitado sem histórico
|
||||
neste repo) achado em `git.falehandix.com.br/Handix/handphone-2.0`
|
||||
(branch `main`, a mais atualizada — a branch default `handphone-
|
||||
2.0-alpha` está desatualizada). Nesse código, o endereço do proxy
|
||||
(`server`) vinha de `VITE_SIP_SERVER`, uma env var de BUILD-TIME —
|
||||
incompatível com "Super Admin configura em runtime, sem redeploy".
|
||||
Patch mínimo de 2 linhas em `widgetStorage.mergeConfig`/
|
||||
`widgetConfig.parseWidgetConfig` (só nesta cópia local, nunca
|
||||
enviado pro repo externo do usuário): `data-sip-server`/
|
||||
`window.HandphoneConfig.server` agora tem prioridade sobre o env
|
||||
var de build, mantendo o comportamento antigo como fallback.
|
||||
Rebuildado (`npm run build:widget`) e vendorizado em
|
||||
`apps/frontend/public/handphone.js` (era um arquivo solto na raiz
|
||||
do repo antes desta fase).
|
||||
- [x] `PlatformSetting` (tabela chave-valor genérica, sem RLS — mesmo
|
||||
padrão de Role/Permission) pra guardar o endereço WSS do proxy
|
||||
OpenSIPS, configurável em `Platform > Infraestrutura > Softphone
|
||||
WebRTC` (permissão `freeswitch.configure`, mesma família das
|
||||
outras telas de infra).
|
||||
- [x] `GET /agents/me/softphone-config` (novo, `agents-me.controller.ts`)
|
||||
— credenciais SIP do PRÓPRIO ramal vinculado ao agente logado
|
||||
(nunca um `extensionId` arbitrário) + o endereço do proxy. Sem
|
||||
`@RequirePermission` (mesmo padrão dos outros endpoints de
|
||||
`agents/me`) — é o próprio agente pegando a própria senha, não uma
|
||||
ação administrativa tipo `POST /extensions/:id/reveal-password`
|
||||
(que exige `extensions.manage`, permissão que um agente comum
|
||||
nunca tem). Cada acesso fica no audit log.
|
||||
- [x] `SoftphoneWidget` (novo componente client, ao lado do
|
||||
`AgentStatusWidget` na topbar do tenant) injeta um `<script
|
||||
src="/handphone.js" data-sip-*>` sob demanda (só quando
|
||||
`hasExtension`, uma vez por carregamento de página — nunca no
|
||||
layout server-side, pra não decifrar a senha nem gravar audit log
|
||||
a cada navegação). O widget se autoconecta sozinho via WebRTC
|
||||
independente do estado de ACD (Disponível/Pausa/Offline) — mesmo
|
||||
tipo de registro SIP que um telefone físico faria.
|
||||
- [x] Testado ponta a ponta com Playwright, tenant/agente/ramal reais
|
||||
criados na hora: tela de config do Super Admin salva e recarrega
|
||||
certo; script do softphone injetado na topbar com
|
||||
`sip-user`/`sip-domain`/`sip-server` corretos e a senha presente
|
||||
(não vazia) assim que o agente com ramal vinculado abre `/app`.
|
||||
|
||||
---
|
||||
|
||||
## Riscos conhecidos
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
97
apps/frontend/public/handphone.js
Normal file
97
apps/frontend/public/handphone.js
Normal file
File diff suppressed because one or more lines are too long
@@ -43,6 +43,30 @@ export async function createInboundRoute(
|
||||
}
|
||||
}
|
||||
|
||||
export interface UpdateInboundRouteInput {
|
||||
description?: string;
|
||||
destinationType: InboundRouteDestinationType;
|
||||
destinationContext?: string;
|
||||
destinationNumber: string;
|
||||
}
|
||||
|
||||
export async function updateInboundRoute(
|
||||
id: string,
|
||||
input: UpdateInboundRouteInput,
|
||||
): Promise<{ ok: true; route: InboundRoute } | { ok: false; error: string }> {
|
||||
const session = await requireSession();
|
||||
try {
|
||||
const route = await apiFetch<InboundRoute>(`/inbound-routes/${id}`, session.accessToken, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
revalidatePath("/app/telefonia/rotas-entrada");
|
||||
return { ok: true, route };
|
||||
} catch (err) {
|
||||
return { ok: false, error: extractErrorMessage(err) };
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteInboundRoute(id: string): Promise<{ ok: true } | { ok: false; error: string }> {
|
||||
const session = await requireSession();
|
||||
try {
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState, useTransition } from "react";
|
||||
import { Fragment, useMemo, useState, useTransition } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { PhoneIncoming, Plus, Trash2, X } from "lucide-react";
|
||||
import { Pencil, PhoneIncoming, Plus, Trash2, X } from "lucide-react";
|
||||
import { Panel, PanelHeader } from "@/components/ui/panel";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input, Select, FieldLabel } from "@/components/ui/input";
|
||||
@@ -19,7 +19,7 @@ import {
|
||||
type Queue,
|
||||
} from "@/lib/callcenter-types";
|
||||
import type { Extension } from "@/lib/extension-types";
|
||||
import { createInboundRoute, deleteInboundRoute } from "./actions";
|
||||
import { createInboundRoute, deleteInboundRoute, updateInboundRoute } from "./actions";
|
||||
|
||||
function destinationLabel(
|
||||
route: InboundRoute,
|
||||
@@ -59,6 +59,7 @@ export function RotasEntradaView({
|
||||
queues: Queue[];
|
||||
}) {
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
|
||||
const extensionsByNumber = useMemo(() => Object.fromEntries(extensions.map((e) => [e.number, e])), [extensions]);
|
||||
const ivrMenusByContext = useMemo(() => Object.fromEntries(ivrMenus.map((m) => [m.context, m])), [ivrMenus]);
|
||||
@@ -80,14 +81,20 @@ export function RotasEntradaView({
|
||||
tenant.
|
||||
</p>
|
||||
</div>
|
||||
<Button type="button" onClick={() => setShowForm((s) => !s)}>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setEditingId(null);
|
||||
setShowForm((s) => !s);
|
||||
}}
|
||||
>
|
||||
{showForm ? <X className="h-4 w-4" aria-hidden /> : <Plus className="h-4 w-4" aria-hidden />}
|
||||
{showForm ? "Cancelar" : "Nova rota"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{showForm && (
|
||||
<NewInboundRouteForm extensions={extensions} ivrMenus={ivrMenus} queues={queues} callGroups={callGroups} onDone={() => setShowForm(false)} />
|
||||
<InboundRouteForm extensions={extensions} ivrMenus={ivrMenus} queues={queues} callGroups={callGroups} onDone={() => setShowForm(false)} />
|
||||
)}
|
||||
|
||||
<Panel>
|
||||
@@ -110,23 +117,53 @@ export function RotasEntradaView({
|
||||
</THead>
|
||||
<TBody>
|
||||
{routes.map((r) => (
|
||||
<TR key={r.id}>
|
||||
<TD>
|
||||
<span className="flex items-center gap-2 font-mono font-medium text-foreground">
|
||||
<PhoneIncoming className="h-3.5 w-3.5 text-muted-foreground" aria-hidden />
|
||||
{r.didNumber}
|
||||
</span>
|
||||
</TD>
|
||||
<TD className="text-muted-foreground">{r.description ?? "—"}</TD>
|
||||
<TD className="text-muted-foreground">{destinationLabel(r, extensionsByNumber, ivrMenusByContext, queuesById)}</TD>
|
||||
<TD>
|
||||
<Pill tone={r.enabled ? "accent" : "neutral"}>{r.enabled ? "Ativa" : "Desativada"}</Pill>
|
||||
</TD>
|
||||
<TD className="text-muted-foreground">{formatDate(r.createdAt)}</TD>
|
||||
<TD>
|
||||
<DeleteInboundRouteButton routeId={r.id} didNumber={r.didNumber} />
|
||||
</TD>
|
||||
</TR>
|
||||
<Fragment key={r.id}>
|
||||
<TR>
|
||||
<TD>
|
||||
<span className="flex items-center gap-2 font-mono font-medium text-foreground">
|
||||
<PhoneIncoming className="h-3.5 w-3.5 text-muted-foreground" aria-hidden />
|
||||
{r.didNumber}
|
||||
</span>
|
||||
</TD>
|
||||
<TD className="text-muted-foreground">{r.description ?? "—"}</TD>
|
||||
<TD className="text-muted-foreground">{destinationLabel(r, extensionsByNumber, ivrMenusByContext, queuesById)}</TD>
|
||||
<TD>
|
||||
<Pill tone={r.enabled ? "accent" : "neutral"}>{r.enabled ? "Ativa" : "Desativada"}</Pill>
|
||||
</TD>
|
||||
<TD className="text-muted-foreground">{formatDate(r.createdAt)}</TD>
|
||||
<TD>
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setShowForm(false);
|
||||
setEditingId((id) => (id === r.id ? null : r.id));
|
||||
}}
|
||||
aria-label={`Editar rota ${r.didNumber}`}
|
||||
>
|
||||
<Pencil className="h-3.5 w-3.5" aria-hidden />
|
||||
</Button>
|
||||
<DeleteInboundRouteButton routeId={r.id} didNumber={r.didNumber} />
|
||||
</div>
|
||||
</TD>
|
||||
</TR>
|
||||
{editingId === r.id && (
|
||||
<tr>
|
||||
<td colSpan={6} className="bg-muted/30 p-4">
|
||||
<InboundRouteForm
|
||||
route={r}
|
||||
extensions={extensions}
|
||||
ivrMenus={ivrMenus}
|
||||
queues={queues}
|
||||
callGroups={callGroups}
|
||||
onDone={() => setEditingId(null)}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</Fragment>
|
||||
))}
|
||||
</TBody>
|
||||
</Table>
|
||||
@@ -136,23 +173,37 @@ export function RotasEntradaView({
|
||||
);
|
||||
}
|
||||
|
||||
function NewInboundRouteForm({
|
||||
/** Deriva o valor inicial do dropdown "Destino" a partir de uma rota já
|
||||
* salva — o único caso que precisa de tradução é IVR: o banco guarda
|
||||
* `destinationContext` (o context do menu), mas o `<select>` usa o id
|
||||
* do menu como valor. */
|
||||
function initialDestinationValue(route: InboundRoute, ivrMenus: IvrMenu[]): string {
|
||||
if (route.destinationType === "IVR") {
|
||||
return ivrMenus.find((m) => m.context === route.destinationContext)?.id ?? "";
|
||||
}
|
||||
return route.destinationNumber;
|
||||
}
|
||||
|
||||
function InboundRouteForm({
|
||||
route,
|
||||
extensions,
|
||||
ivrMenus,
|
||||
queues,
|
||||
callGroups,
|
||||
onDone,
|
||||
}: {
|
||||
route?: InboundRoute;
|
||||
extensions: Extension[];
|
||||
ivrMenus: IvrMenu[];
|
||||
queues: Queue[];
|
||||
callGroups: string[];
|
||||
onDone: () => void;
|
||||
}) {
|
||||
const [didNumber, setDidNumber] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [destinationType, setDestinationType] = useState<InboundRouteDestinationType>("EXTENSION");
|
||||
const [destinationValue, setDestinationValue] = useState("");
|
||||
const isEditing = !!route;
|
||||
const [didNumber, setDidNumber] = useState(route?.didNumber ?? "");
|
||||
const [description, setDescription] = useState(route?.description ?? "");
|
||||
const [destinationType, setDestinationType] = useState<InboundRouteDestinationType>(route?.destinationType ?? "EXTENSION");
|
||||
const [destinationValue, setDestinationValue] = useState(route ? initialDestinationValue(route, ivrMenus) : "");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [pending, startTransition] = useTransition();
|
||||
|
||||
@@ -186,13 +237,20 @@ function NewInboundRouteForm({
|
||||
}
|
||||
|
||||
startTransition(async () => {
|
||||
const result = await createInboundRoute({
|
||||
didNumber: didNumber.trim(),
|
||||
description: description.trim() || undefined,
|
||||
destinationType,
|
||||
destinationNumber,
|
||||
destinationContext,
|
||||
});
|
||||
const result = isEditing
|
||||
? await updateInboundRoute(route.id, {
|
||||
description: description.trim() || undefined,
|
||||
destinationType,
|
||||
destinationNumber,
|
||||
destinationContext,
|
||||
})
|
||||
: await createInboundRoute({
|
||||
didNumber: didNumber.trim(),
|
||||
description: description.trim() || undefined,
|
||||
destinationType,
|
||||
destinationNumber,
|
||||
destinationContext,
|
||||
});
|
||||
if (!result.ok) {
|
||||
setError(result.error);
|
||||
return;
|
||||
@@ -212,8 +270,9 @@ function NewInboundRouteForm({
|
||||
value={didNumber}
|
||||
onChange={(e) => setDidNumber(e.target.value)}
|
||||
placeholder="Ex.: 551140028922"
|
||||
disabled={pending}
|
||||
disabled={pending || isEditing}
|
||||
/>
|
||||
{isEditing && <p className="mt-1 text-xs text-muted-foreground">O número (DID) não pode ser trocado depois de criado.</p>}
|
||||
</div>
|
||||
<div>
|
||||
<FieldLabel htmlFor="ir-description">Descrição (opcional)</FieldLabel>
|
||||
@@ -292,9 +351,14 @@ function NewInboundRouteForm({
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex justify-end">
|
||||
<div className="flex justify-end gap-2">
|
||||
{isEditing && (
|
||||
<Button type="button" variant="ghost" onClick={onDone} disabled={pending}>
|
||||
Cancelar
|
||||
</Button>
|
||||
)}
|
||||
<Button type="submit" disabled={pending}>
|
||||
{pending ? "Criando…" : "Criar rota"}
|
||||
{pending ? "Salvando…" : isEditing ? "Salvar alterações" : "Criar rota"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -9,12 +9,11 @@ export function EslUnavailableNotice({ error }: { error: string }) {
|
||||
Erro retornado: <span className="font-mono text-xs text-destructive">{error}</span>
|
||||
</p>
|
||||
<p>
|
||||
<code className="font-mono text-xs">apps/api</code> roda direto no host desta VM, fora do Docker; a porta
|
||||
do Event Socket (8021) do FreeSWITCH é deliberadamente <strong>não publicada no host</strong> (agente.md
|
||||
secao 184: porta sensível, nunca exposta). Por isso este endpoint sempre falha aqui, mesmo com o
|
||||
FreeSWITCH saudável — o container está isolado do jeito certo. Em produção, onde{" "}
|
||||
<code className="font-mono text-xs">apps/api</code> roda na mesma rede Docker do FreeSWITCH, esta tela
|
||||
mostra os dados reais.
|
||||
<code className="font-mono text-xs">apps/api</code> roda direto no host desta VM, fora do Docker, e fala
|
||||
com o Event Socket (8021) do FreeSWITCH via <code className="font-mono text-xs">127.0.0.1</code> — a porta
|
||||
é publicada só em loopback (agente.md secao 184: nunca pra rede), então normalmente esta tela mostra dados
|
||||
reais. Se este erro aparecer, o mais provável é o container do FreeSWITCH estar fora do ar ou reiniciando
|
||||
— confira <code className="font-mono text-xs">docker ps</code>/<code className="font-mono text-xs">docker logs b2bcall-freeswitch</code> antes de mais nada.
|
||||
</p>
|
||||
</div>
|
||||
</Panel>
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { requireSession } from "@/lib/session";
|
||||
import { apiFetch, ApiError } from "@/lib/api";
|
||||
import type { WebrtcProxyConfig } from "@/lib/platform-types";
|
||||
|
||||
function extractErrorMessage(err: unknown): string {
|
||||
if (err instanceof ApiError) {
|
||||
try {
|
||||
const parsed = JSON.parse(err.message);
|
||||
if (Array.isArray(parsed.message)) return parsed.message.join(" ");
|
||||
if (typeof parsed.message === "string") return parsed.message;
|
||||
} catch {
|
||||
// corpo não era JSON
|
||||
}
|
||||
return err.message || "Falha inesperada na API.";
|
||||
}
|
||||
return "Falha inesperada. Tente novamente.";
|
||||
}
|
||||
|
||||
export async function updateWebrtcProxy(
|
||||
url: string,
|
||||
): Promise<{ ok: true; config: WebrtcProxyConfig } | { ok: false; error: string }> {
|
||||
const session = await requireSession();
|
||||
try {
|
||||
const config = await apiFetch<WebrtcProxyConfig>("/platform/webrtc-proxy", session.accessToken, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ url }),
|
||||
});
|
||||
revalidatePath("/platform/infraestrutura/softphone");
|
||||
return { ok: true, config };
|
||||
} catch (err) {
|
||||
return { ok: false, error: extractErrorMessage(err) };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { requireSession } from "@/lib/session";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import type { WebrtcProxyConfig } from "@/lib/platform-types";
|
||||
import { SoftphoneSettingsView } from "./softphone-settings-view";
|
||||
|
||||
export default async function InfraestruturaSoftphonePage() {
|
||||
const session = await requireSession();
|
||||
const config = await apiFetch<WebrtcProxyConfig>("/platform/webrtc-proxy", session.accessToken);
|
||||
return <SoftphoneSettingsView config={config} />;
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useTransition } from "react";
|
||||
import { Panel, PanelHeader } from "@/components/ui/panel";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import type { WebrtcProxyConfig } from "@/lib/platform-types";
|
||||
import { updateWebrtcProxy } from "./actions";
|
||||
|
||||
export function SoftphoneSettingsView({ config }: { config: WebrtcProxyConfig }) {
|
||||
const [url, setUrl] = useState(config.url ?? "");
|
||||
const [saved, setSaved] = useState(config.url);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [pending, startTransition] = useTransition();
|
||||
|
||||
function onSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
startTransition(async () => {
|
||||
const result = await updateWebrtcProxy(url.trim());
|
||||
if (!result.ok) {
|
||||
setError(result.error);
|
||||
return;
|
||||
}
|
||||
setSaved(result.config.url);
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<div>
|
||||
<h1 className="text-lg font-semibold text-foreground">Softphone WebRTC</h1>
|
||||
<p className="mt-1 max-w-2xl text-sm text-muted-foreground">
|
||||
Endereço do proxy OpenSIPS que faz a ponte WebRTC↔SIP pro softphone embutido no app do tenant. O FreeSWITCH
|
||||
desta instalação nunca fala WebRTC — cada ramal continua um registro SIP puro, o navegador do agente é quem
|
||||
conecta via WebRTC no OpenSIPS, que repassa como SIP comum de volta pra este servidor.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Panel className="p-5">
|
||||
<PanelHeader title="Endereço do proxy (WSS)" description="Ex.: wss://wss.proxysip.exemplo.com.br:4443" />
|
||||
<form onSubmit={onSubmit} className="mt-3 flex items-end gap-3">
|
||||
<div className="flex-1">
|
||||
<Input
|
||||
value={url}
|
||||
onChange={(e) => setUrl(e.target.value)}
|
||||
placeholder="wss://proxy.exemplo.com:4443"
|
||||
disabled={pending}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<Button type="submit" disabled={pending}>
|
||||
{pending ? "Salvando…" : "Salvar"}
|
||||
</Button>
|
||||
</form>
|
||||
{error && <p className="mt-2 text-xs text-destructive">{error}</p>}
|
||||
{!error && (
|
||||
<p className="mt-2 text-xs text-muted-foreground">
|
||||
{saved ? `Configurado: ${saved}` : "Ainda não configurado — o widget do softphone não conecta até um endereço ser salvo aqui."}
|
||||
</p>
|
||||
)}
|
||||
</Panel>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -104,6 +104,12 @@ export const PLATFORM_NAV: NavSection[] = [
|
||||
description: "Postgres, Redis e FreeSWITCH — verificação ao vivo",
|
||||
permission: "freeswitch.view",
|
||||
},
|
||||
{
|
||||
label: "Softphone WebRTC",
|
||||
href: "/platform/infraestrutura/softphone",
|
||||
description: "Endereço do proxy OpenSIPS pro softphone embutido no app do tenant",
|
||||
permission: "freeswitch.configure",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -49,3 +49,23 @@ export async function agentResume(): Promise<ActionResult> {
|
||||
export async function agentPause(pauseReasonId: string): Promise<ActionResult> {
|
||||
return callAgentMe("/pause", { pauseReasonId });
|
||||
}
|
||||
|
||||
export type SoftphoneConfig =
|
||||
| { hasExtension: false }
|
||||
| { hasExtension: true; username: string; domain: string; password: string; displayName: string; proxyUrl: string | null };
|
||||
|
||||
/**
|
||||
* Sob demanda, não no layout (agente.md secao 39/178: decifrar senha é
|
||||
* sensível e fica no audit log — buscar isto em toda navegação encheria o
|
||||
* log à toa). Chamado uma vez pelo próprio widget do softphone quando ele
|
||||
* monta, só pra quem já tem um Agent com ramal vinculado.
|
||||
*/
|
||||
export async function getSoftphoneConfig(): Promise<{ ok: true; config: SoftphoneConfig } | { ok: false; error: string }> {
|
||||
const session = await requireSession();
|
||||
try {
|
||||
const config = await apiFetch<SoftphoneConfig>("/agents/me/softphone-config", session.accessToken);
|
||||
return { ok: true, config };
|
||||
} catch (err) {
|
||||
return { ok: false, error: extractErrorMessage(err) };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
import { getSoftphoneConfig } from "./agent-me-actions";
|
||||
|
||||
const SCRIPT_ID = "handphone-widget-script";
|
||||
|
||||
/**
|
||||
* Softphone WebRTC embutido (Handphone, PHASE 66) — o FreeSWITCH deste
|
||||
* projeto nunca fala WebRTC: quem faz a ponte WebRTC↔SIP é um OpenSIPS
|
||||
* externo já em produção, configurado pelo Platform Super Admin em
|
||||
* Sistema > Softphone WebRTC. O ramal continua puro SIP; o widget só
|
||||
* pega username/domain/senha do ramal vinculado ao agente e o endereço
|
||||
* do proxy, e se autoconecta via `data-sip-*` (ver dist/handphone.js e
|
||||
* docs/SOFTPHONE.md) — nenhum estado de ACD (Disponível/Pausa/Offline)
|
||||
* interfere aqui, é o mesmo tipo de registro SIP que um telefone físico
|
||||
* faria.
|
||||
*/
|
||||
export function SoftphoneWidget({ hasExtension }: { hasExtension: boolean }) {
|
||||
const loadedRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasExtension || loadedRef.current) return;
|
||||
if (document.getElementById(SCRIPT_ID)) return;
|
||||
loadedRef.current = true;
|
||||
|
||||
getSoftphoneConfig().then((result) => {
|
||||
if (!result.ok || !result.config.hasExtension || !result.config.proxyUrl) {
|
||||
if (result.ok && result.config.hasExtension && !result.config.proxyUrl) {
|
||||
console.warn("[Softphone] proxy WebRTC nao configurado (Platform > Sistema > Softphone WebRTC)");
|
||||
}
|
||||
return;
|
||||
}
|
||||
const { username, domain, password, proxyUrl } = result.config;
|
||||
const script = document.createElement("script");
|
||||
script.id = SCRIPT_ID;
|
||||
script.src = "/handphone.js";
|
||||
script.dataset.sipUser = username;
|
||||
script.dataset.sipDomain = domain;
|
||||
script.dataset.sipPassword = password;
|
||||
script.dataset.sipServer = proxyUrl;
|
||||
script.dataset.position = "bottom-right";
|
||||
document.body.appendChild(script);
|
||||
});
|
||||
}, [hasExtension]);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { filterNavByPermissions } from "@/components/shell/nav-types";
|
||||
import type { MyAgent, PauseReason } from "@/lib/callcenter-types";
|
||||
import { TENANT_NAV } from "./nav-data";
|
||||
import { AgentStatusWidget } from "./agent-status-widget";
|
||||
import { SoftphoneWidget } from "./softphone-widget";
|
||||
|
||||
/** Ver comentário em `platform-shell/platform-sidebar.tsx` — mesma razão. */
|
||||
export function TenantTopbar({
|
||||
@@ -25,7 +26,12 @@ export function TenantTopbar({
|
||||
items={filterNavByPermissions(TENANT_NAV, permissionKeys)}
|
||||
fallbackTitle={fallbackTitle}
|
||||
userLabel={userLabel}
|
||||
rightExtra={<AgentStatusWidget initialAgent={myAgent} pauseReasons={pauseReasons} />}
|
||||
rightExtra={
|
||||
<>
|
||||
<AgentStatusWidget initialAgent={myAgent} pauseReasons={pauseReasons} />
|
||||
<SoftphoneWidget hasExtension={myAgent?.hasExtension ?? false} />
|
||||
</>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -112,6 +112,10 @@ export interface SystemConfig {
|
||||
corsOrigin: string | null;
|
||||
}
|
||||
|
||||
export interface WebrtcProxyConfig {
|
||||
url: string | null;
|
||||
}
|
||||
|
||||
export interface TenantAiUsage {
|
||||
tenantId: string;
|
||||
legalName: string;
|
||||
|
||||
@@ -99,12 +99,25 @@ services:
|
||||
# uma chamada ativa seria latência/confiabilidade desnecessárias
|
||||
# pra um prompt de poucos segundos.
|
||||
- ./data/ivr-prompts:/ivr-prompts
|
||||
# Event Socket (8021) NUNCA publicado — só alcançável por outros
|
||||
# containers na rede interna do compose (agente.md secao 22).
|
||||
# Event Socket (8021) NUNCA publicado pra REDE — só em loopback do
|
||||
# próprio host (agente.md secao 22: "NÃO pública... acesso somente
|
||||
# pela aplicação autorizada"). Achado real testando as telas de
|
||||
# Infraestrutura (platform): o comentário original aqui dizia que
|
||||
# elas SEMPRE falhariam nesta VM porque "apps/api roda fora do
|
||||
# Docker" — isso estava incompleto. `apps/api` (systemd, no host)
|
||||
# JÁ conseguia alcançar o IP do container na rede bridge sem nenhuma
|
||||
# porta publicada (`docker inspect` + `/dev/tcp` confirmam: o HOST
|
||||
# sempre alcança a rede bridge do Docker, só outras MÁQUINAS é que
|
||||
# não). O bloqueio real era `ESL_HOST=freeswitch` — um nome DNS que
|
||||
# só existe dentro da rede interna do Docker, nunca no resolver do
|
||||
# host. `127.0.0.1:8021:8021` resolve isso sem abrir a porta pra
|
||||
# ninguém além do próprio host — mesma garantia de segurança de
|
||||
# antes, só que a aplicação autorizada agora alcança de verdade.
|
||||
# SIP (5060) e RTP (16384-16584, range fixo no Dockerfile) publicados
|
||||
# a partir da PHASE 54 — necessário pra registrar um softphone/telefone
|
||||
# de fora da rede Docker (achado testando registro de verdade).
|
||||
# pra rede a partir da PHASE 54 — necessário pra registrar um
|
||||
# softphone/telefone de fora da rede Docker.
|
||||
ports:
|
||||
- "127.0.0.1:8021:8021"
|
||||
- "5060:5060/udp"
|
||||
- "5060:5060/tcp"
|
||||
- "16384-16584:16384-16584/udp"
|
||||
|
||||
@@ -65,11 +65,48 @@ imagem, só injetada via variável de ambiente do container. O entrypoint falha
|
||||
alto (`set -eu` + `${ESL_PASSWORD:?...}`) se a variável não estiver definida —
|
||||
nunca sobe com a senha padrão por engano.
|
||||
|
||||
Porta 8021 **não é publicada no host** (`ports:` ausente no compose) — só
|
||||
alcançável por outros containers na rede interna do Docker Compose
|
||||
(`b2bcall_default`), pelo nome de serviço `freeswitch`. Isso satisfaz
|
||||
"NÃO pública... acesso somente pela aplicação autorizada" sem precisar de ACL
|
||||
adicional por enquanto (ACL fica como hardening futuro, ver TODO).
|
||||
Porta 8021 **nunca publicada pra rede** — só em loopback do próprio host
|
||||
(`"127.0.0.1:8021:8021"` no `docker-compose.yml`, desde a PHASE 65, ver seção
|
||||
abaixo). Isso satisfaz "NÃO pública... acesso somente pela aplicação
|
||||
autorizada" (agente.md secao 22) sem abrir a porta pra ninguém além do próprio
|
||||
host.
|
||||
|
||||
## Achados na sessão de PHASE 65 (Platform > Infraestrutura)
|
||||
|
||||
Duas telas de `platform/infraestrutura` (FreeSWITCH, SIP Profiles, Nodes)
|
||||
sempre mostravam "Timeout conectando no ESL do FreeSWITCH" nesta VM. A
|
||||
suposição registrada até então (agora corrigida) era que isso seria uma
|
||||
limitação permanente: "`apps/api` roda fora do Docker, a porta nunca é
|
||||
publicada, então este endpoint sempre falha aqui". Isso estava incompleto.
|
||||
|
||||
**Causa real**: `ESL_HOST=freeswitch` no `.env` — um nome DNS que só resolve
|
||||
dentro da rede interna do Docker Compose (embedded DNS), nunca a partir do
|
||||
resolver do próprio host (confirmado: `getent hosts freeswitch` falha no host
|
||||
com exit code 2). Não era uma questão de porta publicada — testado e
|
||||
confirmado que o **host sempre alcança o IP de qualquer container na rede
|
||||
bridge do Docker diretamente** (`docker inspect` + `/dev/tcp` bateram), mesmo
|
||||
sem nenhuma porta publicada. Só outras *máquinas* são bloqueadas sem
|
||||
`ports:` — o host nunca foi.
|
||||
|
||||
**Fix**: publicar 8021 só em loopback (`127.0.0.1:8021:8021`) e trocar
|
||||
`ESL_HOST` pra `127.0.0.1` no `.env` — resolve o DNS sem abrir a porta pra
|
||||
rede nenhuma (confirmado via `ss -tlnp`: `docker-proxy` escutando em
|
||||
`127.0.0.1`, não `0.0.0.0`). Os serviços que rodam DENTRO do Docker
|
||||
(`fs-events`, `fs-config`, `predictive-dialer`) continuam com
|
||||
`ESL_HOST=freeswitch` fixo no próprio `docker-compose.yml` — não dependem do
|
||||
`.env` pra isso, então a troca não afeta eles.
|
||||
|
||||
**Segundo bug, achado na mesma investigação**: a tela "Nodes" continuava
|
||||
mostrando `error: ""` (string vazia) mesmo depois do fix acima.
|
||||
`getGateways()` (`packages/telephony/src/freeswitch-provider.ts`) rodava
|
||||
`show gateways as json` — **não é um sub-comando válido** de `show` nesta
|
||||
versão do FreeSWITCH (`fs_cli -x "show gateways as json"` devolve
|
||||
`-USAGE: codec|endpoint|application|...`). O pacote `esl` trata qualquer
|
||||
reply começando com `-` como erro de protocolo e rejeita a promise com um
|
||||
`FreeSwitchError` cujo `.message` pode vir vazio — daí o `""` na tela em vez
|
||||
de uma mensagem útil. Fix: `getGateways()` agora roda `sofia status` (mesmo
|
||||
comando já usado pela tela de SIP Profiles) — gateways aparecem como linhas
|
||||
`type=gateway` no texto puro, sem variante JSON dedicada nesta versão.
|
||||
|
||||
## O que NÃO foi feito nesta fase (fica para as próximas, por design)
|
||||
|
||||
|
||||
@@ -27,6 +27,34 @@ topologia esperada é `Internet → OpenSIPS → rede SIP privada → FreeSWITCH
|
||||
então o FreeSWITCH em si tende a ficar em rede privada mesmo, o que favorece
|
||||
manter bridge/macvlan em vez de host).
|
||||
|
||||
## Achado real (PHASE 65): VM atrás de roteador, ramal externo sem áudio
|
||||
|
||||
Esta VM de laboratório está atrás de um roteador (NAT) — o IP da interface de
|
||||
rede (`ens18`) é privado (`10.10.32.x`), o roteador é quem faz NAT pro IP
|
||||
público real. Reportado pelo usuário: um softphone externo registrava com
|
||||
sucesso, mas sem áudio.
|
||||
|
||||
Diagnóstico (contadores de pacotes do `iptables`, não suposição): 10 pacotes
|
||||
chegaram em `5060/udp` (SIP), **zero** pacotes chegaram em qualquer porta da
|
||||
faixa de RTP (`16384-16584/udp`). O REGISTER funciona porque a resposta
|
||||
trafega de volta pelo mesmo "buraco" NAT que o próprio pacote do cliente abriu
|
||||
— mas RTP usa portas completamente diferentes, como um fluxo NOVO. Sem uma
|
||||
regra de **port forward no roteador** pra essa faixa (apontando pro IP
|
||||
privado desta VM), esses pacotes nunca chegam até o Docker/FreeSWITCH.
|
||||
|
||||
Do lado desta VM estava tudo certo: `docker-compose.yml` publica a faixa de
|
||||
RTP pra rede (não só loopback), `Ext-RTP-IP`/`Ext-SIP-IP` resolvem certo via
|
||||
STUN (`sofia status profile internal` mostra o IP público real), e a
|
||||
detecção de NAT do próprio FreeSWITCH (`apply-nat-acl value="nat.auto"`,
|
||||
`nat.auto` é uma ACL auto-gerada pelo core do FreeSWITCH — RFC1918 exceto a
|
||||
própria rede local do container, não precisa existir em `acl.conf.xml`)
|
||||
funciona corretamente (testado direto via `fs_cli -x "acl <ip> nat.auto"`).
|
||||
|
||||
**Não tem fix de código pra isso** — é infraestrutura de rede fora do
|
||||
controle desta aplicação. Checklist pra quem for expor telefonia real atrás
|
||||
de um roteador: encaminhar UDP `5060` e toda a faixa RTP (`16384-16584`,
|
||||
Dockerfile do FreeSWITCH) pro IP privado da VM, sem tradução de porta.
|
||||
|
||||
## Quando `apps/api` virar container
|
||||
|
||||
Hoje ela roda no host por conveniência de desenvolvimento. Quando virar o
|
||||
|
||||
227
docs/QA_SETUP.md
Normal file
227
docs/QA_SETUP.md
Normal file
@@ -0,0 +1,227 @@
|
||||
# Subindo o ambiente do zero numa máquina nova (QA)
|
||||
|
||||
Runbook pra clonar este repositório numa máquina nova e deixar tudo rodando
|
||||
— Postgres/Redis/FreeSWITCH em Docker, `apps/api`/`apps/frontend` como
|
||||
services systemd, banco migrado e semeado. Escrito pra outra sessão do
|
||||
Claude Code seguir sozinha, na ordem, verificando cada passo antes do
|
||||
próximo. Nada aqui precisa de intervenção manual além do que está marcado
|
||||
explicitamente como "precisa de um humano".
|
||||
|
||||
Este documento descreve como levantar um ambiente **igual ao de
|
||||
desenvolvimento/laboratório** (`ACESSO_TESTE.md`), não produção: sem HTTPS,
|
||||
sem domínio próprio, `DIALER_SIMULATION=true` (nenhuma chamada PSTN real
|
||||
sai). Rodar em produção de verdade é fora de escopo deste guia.
|
||||
|
||||
## 0. Pré-requisitos da máquina
|
||||
|
||||
- Linux com `systemd` (as units em `infrastructure/systemd/` assumem isso)
|
||||
- Docker + Docker Compose plugin (`docker compose version` funcionando)
|
||||
- Node.js >= 22 (`node -v`) e `pnpm` (`corepack enable` costuma bastar —
|
||||
o `packageManager` no `package.json` da raiz fixa a versão exata)
|
||||
- Pelo menos ~2GB de RAM livres pro Postgres+Redis+FreeSWITCH juntos, mais o
|
||||
que o Next.js dev server e o `tsc` do `apps/api` consumirem
|
||||
- Este repositório **precisa ficar em `/opt/b2bcall`** — as units systemd em
|
||||
`infrastructure/systemd/*.service` têm esse caminho fixo
|
||||
(`WorkingDirectory=`, `EnvironmentFile=`). Cloná-lo em outro lugar exige
|
||||
editar essas duas linhas nas duas units antes de instalar.
|
||||
|
||||
Verifique antes de prosseguir:
|
||||
```bash
|
||||
docker compose version
|
||||
node -v # >= 22
|
||||
corepack enable && corepack prepare pnpm@$(node -e "console.log(require('/opt/b2bcall/package.json').packageManager.split('@')[1])") --activate
|
||||
pnpm -v
|
||||
```
|
||||
|
||||
## 1. Clonar e instalar dependências
|
||||
|
||||
```bash
|
||||
cd /opt
|
||||
git clone <url-do-repositorio> b2bcall # ou `git pull` se já existe
|
||||
cd /opt/b2bcall
|
||||
pnpm install
|
||||
```
|
||||
|
||||
## 2. Criar o `.env`
|
||||
|
||||
`.env` nunca é commitado (segredos reais). Copie o template e preencha:
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
Abra `.env.example` pra ver o comentário de cada variável — ele já explica
|
||||
o que cada uma faz e como gerar as que são segredos aleatórios
|
||||
(`openssl rand -hex 32` / `openssl rand -base64 24`). Resumo do que precisa
|
||||
de valor manual:
|
||||
|
||||
| Variável | Como preencher |
|
||||
|---|---|
|
||||
| `FREESWITCH_PAT` | **Precisa de um humano** — token de acesso ao repositório de pacotes do FreeSWITCH (`freeswitch.signalwire.com`), não é gerável localmente. Peça ao dono do projeto. |
|
||||
| `POSTGRES_PASSWORD`, `REDIS_PASSWORD`, `POSTGRES_APP_PASSWORD` | `openssl rand -base64 24` (gerar um valor diferente pra cada) |
|
||||
| `JWT_SECRET`, `JWT_REFRESH_SECRET`, `ENCRYPTION_KEY` | `openssl rand -hex 32` (gerar um valor diferente pra cada — `ENCRYPTION_KEY` cifra segredos reversíveis como senha SIP de ramal, nunca reaproveitar entre ambientes) |
|
||||
| `ESL_PASSWORD` | `openssl rand -base64 24` |
|
||||
| `FS_CONFIG_PASSWORD` | `openssl rand -base64 24` |
|
||||
| `DATABASE_URL` | `postgresql://b2bcall:<POSTGRES_PASSWORD>@localhost:5432/b2bcall?schema=public` |
|
||||
| `REDIS_URL` | `redis://:<REDIS_PASSWORD>@localhost:6379` |
|
||||
| `APP_DATABASE_URL` | `postgresql://b2bcall_app:<POSTGRES_APP_PASSWORD>@localhost:5432/b2bcall?schema=public` |
|
||||
| `FS_CONFIG_USER` | Qualquer string curta sem espaço (ex.: `fsconfig`) |
|
||||
|
||||
Todas as outras variáveis do `.env.example` já têm um valor padrão
|
||||
sensato pra este tipo de ambiente (deixe como está).
|
||||
|
||||
Crie também `apps/frontend/.env.local` (não existe ainda num clone novo,
|
||||
também gitignored):
|
||||
```bash
|
||||
echo "B2BCALL_API_URL=http://localhost:3000" > apps/frontend/.env.local
|
||||
```
|
||||
|
||||
## 3. Subir os containers Docker
|
||||
|
||||
```bash
|
||||
cd /opt/b2bcall
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
O `docker compose` lê `FREESWITCH_PAT` automaticamente do `.env` na raiz
|
||||
pro build da imagem do FreeSWITCH (BuildKit secret — não fica em nenhuma
|
||||
camada da imagem final). O build do FreeSWITCH baixa pacotes `.deb`
|
||||
externos, pode levar alguns minutos na primeira vez.
|
||||
|
||||
Verifique que todos os 7 containers subiram saudáveis:
|
||||
```bash
|
||||
docker compose ps
|
||||
```
|
||||
Espera-se `b2bcall-postgres`, `b2bcall-redis`, `b2bcall-freeswitch`,
|
||||
`b2bcall-fs-config`, `b2bcall-fs-events`, `b2bcall-predictive-dialer`,
|
||||
`b2bcall-ai-worker` todos `Up` (os 3 primeiros com healthcheck `healthy`).
|
||||
Se algum não subir, `docker compose logs <serviço>` quase sempre mostra a
|
||||
causa na hora (comum: `.env` com uma variável faltando/typo).
|
||||
|
||||
## 4. Migrar e semear o banco
|
||||
|
||||
```bash
|
||||
cd /opt/b2bcall/packages/database
|
||||
set -a && source /opt/b2bcall/.env && set +a
|
||||
npx prisma migrate deploy
|
||||
npx prisma generate
|
||||
```
|
||||
|
||||
Isso cria todas as tabelas, incluindo a role restrita `b2bcall_app` (não
|
||||
tem senha ainda — a migration não pode embutir um segredo). Defina a senha
|
||||
dela agora:
|
||||
|
||||
```bash
|
||||
cd /opt/b2bcall
|
||||
bash scripts/db-setup-app-role.sh
|
||||
```
|
||||
|
||||
Depois, rode o seed (idempotente — cria o catálogo de permissions/roles do
|
||||
sistema e o usuário Platform Super Admin):
|
||||
|
||||
```bash
|
||||
pnpm --filter @b2bcall/auth run seed
|
||||
```
|
||||
|
||||
A senha temporária do Platform Super Admin (`admin@b2bcall.local`) é
|
||||
gravada em `/opt/b2bcall/FIRST_LOGIN.txt` (permissão 600, gitignored) —
|
||||
**leia esse arquivo pra saber a senha do primeiro login**, ele não aparece
|
||||
no terminal.
|
||||
|
||||
## 5. Instalar os services systemd
|
||||
|
||||
```bash
|
||||
cd /opt/b2bcall
|
||||
sudo cp infrastructure/systemd/b2bcall-api.service /etc/systemd/system/
|
||||
sudo cp infrastructure/systemd/b2bcall-frontend.service /etc/systemd/system/
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable --now b2bcall-api.service
|
||||
sudo systemctl enable --now b2bcall-frontend.service
|
||||
```
|
||||
|
||||
Verifique:
|
||||
```bash
|
||||
systemctl status b2bcall-api.service b2bcall-frontend.service
|
||||
journalctl -u b2bcall-api.service -n 50 # esperado: "Nest application successfully started" (ou similar)
|
||||
```
|
||||
|
||||
`apps/api` recompila (`tsc`) toda vez que a unit sobe — leva uns 15-20s.
|
||||
Detalhes/troubleshooting em `infrastructure/systemd/README.md`.
|
||||
|
||||
## 6. Verificação de ponta a ponta
|
||||
|
||||
```bash
|
||||
curl -s http://localhost:3000/health/ready # apps/api + Postgres + Redis, tudo respondendo
|
||||
curl -s -o /dev/null -w "%{http_code}\n" http://localhost:3001/login # apps/frontend respondendo (espera 200)
|
||||
```
|
||||
|
||||
Depois, pelo navegador (ou um túnel SSH se a máquina não for acessível
|
||||
direto: `ssh -L 3001:localhost:3001 <usuario>@<ip-da-maquina>`):
|
||||
|
||||
1. Acesse `http://<ip-da-maquina>:3001/login`
|
||||
2. Entre com `admin@b2bcall.local` e a senha de `FIRST_LOGIN.txt`
|
||||
3. A tela "Trocar senha" aparece automaticamente (senha temporária,
|
||||
`mustChangePassword=true`) — troque por uma senha sua
|
||||
4. Depois de trocar, você cai em `/platform` (Platform Super Admin)
|
||||
5. Crie um tenant de teste em **Clientes > Tenants** pra ter algo pra
|
||||
testar (a senha do admin desse tenant também é mostrada uma única vez
|
||||
na tela — anote ou copie na hora)
|
||||
|
||||
## 7. Se for testar telefonia real (SIP/RTP de fora da rede Docker)
|
||||
|
||||
Só necessário se for registrar um softphone/telefone físico de verdade
|
||||
contra este ambiente — não é preciso pra testar só a aplicação web.
|
||||
|
||||
`docker-compose.yml` já publica `5060/udp`, `5060/tcp` e a faixa de RTP
|
||||
`16384-16584/udp` pra rede (não só loopback). **Se esta máquina estiver
|
||||
atrás de um roteador/NAT** (IP da interface de rede é privado, tipo
|
||||
`10.x.x.x`/`192.168.x.x`), é preciso configurar **port forward no
|
||||
roteador** pra essas mesmas portas apontando pro IP privado desta máquina
|
||||
— sem isso, o registro SIP até funciona (a resposta volta pelo mesmo
|
||||
"buraco" que o cliente abriu), mas o **áudio não passa** (RTP é um fluxo
|
||||
novo, em portas diferentes, e chega bloqueado). Isto foi um achado real
|
||||
diagnosticado numa sessão anterior — detalhes completos e como confirmar
|
||||
com contadores de pacote do `iptables` em `docs/NETWORK_ARCHITECTURE.md`
|
||||
("Achado real (PHASE 65)").
|
||||
|
||||
## 8. Softphone WebRTC (widget embutido, PHASE 66)
|
||||
|
||||
O app do tenant tem um softphone WebRTC embutido (`apps/frontend/public/
|
||||
handphone.js`) que conecta via WebRTC num proxy OpenSIPS externo (já em
|
||||
produção, fora deste repositório) — o FreeSWITCH desta instalação nunca
|
||||
fala WebRTC diretamente, cada ramal continua um registro SIP puro.
|
||||
|
||||
Pra funcionar, um Platform Super Admin precisa configurar o endereço WSS
|
||||
desse proxy em **Platform > Infraestrutura > Softphone WebRTC**
|
||||
(`PUT /platform/webrtc-proxy`, guardado na tabela `platform_settings` —
|
||||
não tem nada a configurar via `.env` ou variável de ambiente pra isso).
|
||||
Sem essa configuração, o widget simplesmente não conecta (fica sem
|
||||
aparecer/logar um aviso no console do navegador) — não é um erro de
|
||||
deploy, só falta esse passo manual pós-subida.
|
||||
|
||||
## Referência rápida — o que roda onde
|
||||
|
||||
| Componente | Como sobe | Restart depois de mudar código |
|
||||
|---|---|---|
|
||||
| Postgres, Redis, FreeSWITCH, fs-config, fs-events, predictive-dialer, ai-worker | Docker (`restart: unless-stopped`) | `docker compose build <serviço> && docker compose up -d <serviço>` |
|
||||
| `apps/api` | systemd (`b2bcall-api.service`), `pnpm dev` (modo dev, não build de produção — decisão deliberada, ver `infrastructure/systemd/README.md`) | `systemctl restart b2bcall-api.service` |
|
||||
| `apps/frontend` | systemd (`b2bcall-frontend.service`), `pnpm dev` | Hot-reload sozinho (Next.js dev server); `systemctl restart` só depois de mudar dependências |
|
||||
|
||||
## Problemas comuns
|
||||
|
||||
- **`EADDRINUSE` na porta 3000/3001**: confira que `API_PORT=3000` está no
|
||||
`.env` e que a unit do frontend tem `Environment=PORT=3001` — sem os
|
||||
dois fixos, os dois processos competem pela mesma porta.
|
||||
- **Timeout conectando no ESL do FreeSWITCH** (telas de
|
||||
`Platform > Infraestrutura`): confira `ESL_HOST=127.0.0.1` no `.env`
|
||||
(nunca `freeswitch` — esse nome só resolve dentro da rede Docker) e que
|
||||
a porta 8021 está publicada em loopback (`docker compose ps` deve
|
||||
mostrar `127.0.0.1:8021->8021/tcp` no FreeSWITCH).
|
||||
- **RLS bloqueando tudo silenciosamente** (contagens sempre zero, listas
|
||||
sempre vazias mesmo com dado no banco): confirme que
|
||||
`scripts/db-setup-app-role.sh` rodou depois da migration — sem a senha
|
||||
do role `b2bcall_app` setada, `APP_DATABASE_URL` não autentica e
|
||||
`apps/api` provavelmente nem sobe.
|
||||
- **Container do FreeSWITCH não builda**: quase sempre `FREESWITCH_PAT`
|
||||
errado/expirado no `.env` — `docker compose logs freeswitch` mostra o
|
||||
erro de autenticação do `apt` contra o repositório do SignalWire.
|
||||
@@ -0,0 +1,11 @@
|
||||
-- PHASE 66: configuração global de plataforma, chave-valor. Sem tenant_id
|
||||
-- (não é escopo de tenant, sem RLS, mesmo padrão de roles/permissions).
|
||||
-- Primeiro uso: endereço WSS do proxy OpenSIPS pro softphone (Handphone).
|
||||
CREATE TABLE "platform_settings" (
|
||||
"key" TEXT NOT NULL,
|
||||
"value" TEXT NOT NULL,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||
"updated_by" UUID,
|
||||
|
||||
CONSTRAINT "platform_settings_pkey" PRIMARY KEY ("key")
|
||||
);
|
||||
@@ -2131,3 +2131,18 @@ model BillingStatementItem {
|
||||
@@index([billingStatementId])
|
||||
@@map("billing_statement_items")
|
||||
}
|
||||
|
||||
// Configuração global de plataforma, chave-valor — sem tenant_id (não é
|
||||
// escopo de tenant, não leva RLS, mesmo padrão de Role/Permission). Tabela
|
||||
// genérica pra não precisar de migration nova a cada config global futura.
|
||||
// Primeiro uso (PHASE 66): endereço WSS do proxy OpenSIPS que faz a ponte
|
||||
// WebRTC↔SIP pro softphone embutido (Handphone) — o FreeSWITCH deste
|
||||
// projeto nunca fala WebRTC diretamente, só SIP puro (ver docs/SOFTPHONE.md).
|
||||
model PlatformSetting {
|
||||
key String @id
|
||||
value String
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
updatedBy String? @map("updated_by") @db.Uuid
|
||||
|
||||
@@map("platform_settings")
|
||||
}
|
||||
|
||||
@@ -132,8 +132,20 @@ export class FreeSwitchTelephonyProvider implements TelephonyProvider {
|
||||
return this.apiJson("show registrations as json");
|
||||
}
|
||||
|
||||
/**
|
||||
* Achado real testando a tela "Infraestrutura > Nodes" (secao
|
||||
* "Achados na sessão de PHASE 65" em docs/FREESWITCH.md): `show
|
||||
* gateways as json` NÃO é um sub-comando válido de `show` nesta
|
||||
* versão do FreeSWITCH (`-USAGE: codec|endpoint|application|...`) —
|
||||
* o próprio pacote `esl` trata qualquer reply começando com `-` como
|
||||
* erro e rejeita a promise (com `.message` vazio, por isso o erro
|
||||
* aparecia como `""` na tela). Gateways aparecem como linhas
|
||||
* `type=gateway` dentro de `sofia status` (já usado por
|
||||
* `runApi("sofia status")` no endpoint de profiles) — não tem
|
||||
* variante JSON, mas o texto cru já é suficiente pra esta tela.
|
||||
*/
|
||||
getGateways(): Promise<unknown> {
|
||||
return this.apiJson("show gateways as json");
|
||||
return this.runApi("sofia status");
|
||||
}
|
||||
|
||||
async getQueues(): Promise<unknown> {
|
||||
|
||||
Reference in New Issue
Block a user