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=
|
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=
|
POSTGRES_APP_PASSWORD=
|
||||||
APP_DATABASE_URL=postgresql://user:password@localhost:5432/b2bcall?schema=public
|
APP_DATABASE_URL=postgresql://b2bcall_app:@localhost:5432/b2bcall?schema=public
|
||||||
REDIS_URL=redis://:password@localhost:6379
|
|
||||||
|
# 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_USER=
|
||||||
FS_CONFIG_PASSWORD=
|
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
|
sobreviveu ao F5. Confirmado ainda que corrigir a Hydration
|
||||||
eliminou o erro do console sem quebrar nenhum dos dois cenários
|
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
|
## Riscos conhecidos
|
||||||
|
|||||||
@@ -10,11 +10,13 @@ import {
|
|||||||
} from "@nestjs/common";
|
} from "@nestjs/common";
|
||||||
import { getPrismaClient, withTenantContext, type Prisma } from "@b2bcall/database";
|
import { getPrismaClient, withTenantContext, type Prisma } from "@b2bcall/database";
|
||||||
import { recordAuditEvent, type AccessTokenClaims } from "@b2bcall/auth";
|
import { recordAuditEvent, type AccessTokenClaims } from "@b2bcall/auth";
|
||||||
|
import { decryptSecret } from "@b2bcall/shared";
|
||||||
import { JwtAuthGuard } from "../common/guards/jwt-auth.guard";
|
import { JwtAuthGuard } from "../common/guards/jwt-auth.guard";
|
||||||
import { CurrentUser } from "../common/decorators/current-user.decorator";
|
import { CurrentUser } from "../common/decorators/current-user.decorator";
|
||||||
import { PauseDto } from "./dto/pause.dto";
|
import { PauseDto } from "./dto/pause.dto";
|
||||||
import { notifyAgentChanged, notifyTierChanged } from "./agent-sync.helper";
|
import { notifyAgentChanged, notifyTierChanged } from "./agent-sync.helper";
|
||||||
import { publishAgentStateChanged } from "../realtime/realtime-publish.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) {
|
async function findMyAgent(tx: Prisma.TransactionClient, tenantId: string, userId: string) {
|
||||||
const agent = await tx.agent.findFirst({
|
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
|
/** Motivos de pausa pro próprio agente escolher — sem exigir
|
||||||
* `agents.view` (que listaria TODOS os agentes do tenant, permissão
|
* `agents.view` (que listaria TODOS os agentes do tenant, permissão
|
||||||
* que o role "agent" nunca precisou ter até aqui). */
|
* 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 { PlatformFreeswitchController } from "./platform-freeswitch.controller";
|
||||||
import { PlatformAiUsageController } from "./platform-ai-usage.controller";
|
import { PlatformAiUsageController } from "./platform-ai-usage.controller";
|
||||||
import { PlatformSystemConfigController } from "./platform-system-config.controller";
|
import { PlatformSystemConfigController } from "./platform-system-config.controller";
|
||||||
|
import { PlatformWebrtcProxyController } from "./platform-webrtc-proxy.controller";
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
controllers: [
|
controllers: [
|
||||||
@@ -20,6 +21,7 @@ import { PlatformSystemConfigController } from "./platform-system-config.control
|
|||||||
PlatformFreeswitchController,
|
PlatformFreeswitchController,
|
||||||
PlatformAiUsageController,
|
PlatformAiUsageController,
|
||||||
PlatformSystemConfigController,
|
PlatformSystemConfigController,
|
||||||
|
PlatformWebrtcProxyController,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class PlatformModule {}
|
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 }> {
|
export async function deleteInboundRoute(id: string): Promise<{ ok: true } | { ok: false; error: string }> {
|
||||||
const session = await requireSession();
|
const session = await requireSession();
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useMemo, useState, useTransition } from "react";
|
import { Fragment, useMemo, useState, useTransition } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
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 { Panel, PanelHeader } from "@/components/ui/panel";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input, Select, FieldLabel } from "@/components/ui/input";
|
import { Input, Select, FieldLabel } from "@/components/ui/input";
|
||||||
@@ -19,7 +19,7 @@ import {
|
|||||||
type Queue,
|
type Queue,
|
||||||
} from "@/lib/callcenter-types";
|
} from "@/lib/callcenter-types";
|
||||||
import type { Extension } from "@/lib/extension-types";
|
import type { Extension } from "@/lib/extension-types";
|
||||||
import { createInboundRoute, deleteInboundRoute } from "./actions";
|
import { createInboundRoute, deleteInboundRoute, updateInboundRoute } from "./actions";
|
||||||
|
|
||||||
function destinationLabel(
|
function destinationLabel(
|
||||||
route: InboundRoute,
|
route: InboundRoute,
|
||||||
@@ -59,6 +59,7 @@ export function RotasEntradaView({
|
|||||||
queues: Queue[];
|
queues: Queue[];
|
||||||
}) {
|
}) {
|
||||||
const [showForm, setShowForm] = useState(false);
|
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 extensionsByNumber = useMemo(() => Object.fromEntries(extensions.map((e) => [e.number, e])), [extensions]);
|
||||||
const ivrMenusByContext = useMemo(() => Object.fromEntries(ivrMenus.map((m) => [m.context, m])), [ivrMenus]);
|
const ivrMenusByContext = useMemo(() => Object.fromEntries(ivrMenus.map((m) => [m.context, m])), [ivrMenus]);
|
||||||
@@ -80,14 +81,20 @@ export function RotasEntradaView({
|
|||||||
tenant.
|
tenant.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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 ? <X className="h-4 w-4" aria-hidden /> : <Plus className="h-4 w-4" aria-hidden />}
|
||||||
{showForm ? "Cancelar" : "Nova rota"}
|
{showForm ? "Cancelar" : "Nova rota"}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{showForm && (
|
{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>
|
<Panel>
|
||||||
@@ -110,23 +117,53 @@ export function RotasEntradaView({
|
|||||||
</THead>
|
</THead>
|
||||||
<TBody>
|
<TBody>
|
||||||
{routes.map((r) => (
|
{routes.map((r) => (
|
||||||
<TR key={r.id}>
|
<Fragment key={r.id}>
|
||||||
<TD>
|
<TR>
|
||||||
<span className="flex items-center gap-2 font-mono font-medium text-foreground">
|
<TD>
|
||||||
<PhoneIncoming className="h-3.5 w-3.5 text-muted-foreground" aria-hidden />
|
<span className="flex items-center gap-2 font-mono font-medium text-foreground">
|
||||||
{r.didNumber}
|
<PhoneIncoming className="h-3.5 w-3.5 text-muted-foreground" aria-hidden />
|
||||||
</span>
|
{r.didNumber}
|
||||||
</TD>
|
</span>
|
||||||
<TD className="text-muted-foreground">{r.description ?? "—"}</TD>
|
</TD>
|
||||||
<TD className="text-muted-foreground">{destinationLabel(r, extensionsByNumber, ivrMenusByContext, queuesById)}</TD>
|
<TD className="text-muted-foreground">{r.description ?? "—"}</TD>
|
||||||
<TD>
|
<TD className="text-muted-foreground">{destinationLabel(r, extensionsByNumber, ivrMenusByContext, queuesById)}</TD>
|
||||||
<Pill tone={r.enabled ? "accent" : "neutral"}>{r.enabled ? "Ativa" : "Desativada"}</Pill>
|
<TD>
|
||||||
</TD>
|
<Pill tone={r.enabled ? "accent" : "neutral"}>{r.enabled ? "Ativa" : "Desativada"}</Pill>
|
||||||
<TD className="text-muted-foreground">{formatDate(r.createdAt)}</TD>
|
</TD>
|
||||||
<TD>
|
<TD className="text-muted-foreground">{formatDate(r.createdAt)}</TD>
|
||||||
<DeleteInboundRouteButton routeId={r.id} didNumber={r.didNumber} />
|
<TD>
|
||||||
</TD>
|
<div className="flex items-center justify-end gap-1">
|
||||||
</TR>
|
<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>
|
</TBody>
|
||||||
</Table>
|
</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,
|
extensions,
|
||||||
ivrMenus,
|
ivrMenus,
|
||||||
queues,
|
queues,
|
||||||
callGroups,
|
callGroups,
|
||||||
onDone,
|
onDone,
|
||||||
}: {
|
}: {
|
||||||
|
route?: InboundRoute;
|
||||||
extensions: Extension[];
|
extensions: Extension[];
|
||||||
ivrMenus: IvrMenu[];
|
ivrMenus: IvrMenu[];
|
||||||
queues: Queue[];
|
queues: Queue[];
|
||||||
callGroups: string[];
|
callGroups: string[];
|
||||||
onDone: () => void;
|
onDone: () => void;
|
||||||
}) {
|
}) {
|
||||||
const [didNumber, setDidNumber] = useState("");
|
const isEditing = !!route;
|
||||||
const [description, setDescription] = useState("");
|
const [didNumber, setDidNumber] = useState(route?.didNumber ?? "");
|
||||||
const [destinationType, setDestinationType] = useState<InboundRouteDestinationType>("EXTENSION");
|
const [description, setDescription] = useState(route?.description ?? "");
|
||||||
const [destinationValue, setDestinationValue] = useState("");
|
const [destinationType, setDestinationType] = useState<InboundRouteDestinationType>(route?.destinationType ?? "EXTENSION");
|
||||||
|
const [destinationValue, setDestinationValue] = useState(route ? initialDestinationValue(route, ivrMenus) : "");
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [pending, startTransition] = useTransition();
|
const [pending, startTransition] = useTransition();
|
||||||
|
|
||||||
@@ -186,13 +237,20 @@ function NewInboundRouteForm({
|
|||||||
}
|
}
|
||||||
|
|
||||||
startTransition(async () => {
|
startTransition(async () => {
|
||||||
const result = await createInboundRoute({
|
const result = isEditing
|
||||||
didNumber: didNumber.trim(),
|
? await updateInboundRoute(route.id, {
|
||||||
description: description.trim() || undefined,
|
description: description.trim() || undefined,
|
||||||
destinationType,
|
destinationType,
|
||||||
destinationNumber,
|
destinationNumber,
|
||||||
destinationContext,
|
destinationContext,
|
||||||
});
|
})
|
||||||
|
: await createInboundRoute({
|
||||||
|
didNumber: didNumber.trim(),
|
||||||
|
description: description.trim() || undefined,
|
||||||
|
destinationType,
|
||||||
|
destinationNumber,
|
||||||
|
destinationContext,
|
||||||
|
});
|
||||||
if (!result.ok) {
|
if (!result.ok) {
|
||||||
setError(result.error);
|
setError(result.error);
|
||||||
return;
|
return;
|
||||||
@@ -212,8 +270,9 @@ function NewInboundRouteForm({
|
|||||||
value={didNumber}
|
value={didNumber}
|
||||||
onChange={(e) => setDidNumber(e.target.value)}
|
onChange={(e) => setDidNumber(e.target.value)}
|
||||||
placeholder="Ex.: 551140028922"
|
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>
|
||||||
<div>
|
<div>
|
||||||
<FieldLabel htmlFor="ir-description">Descrição (opcional)</FieldLabel>
|
<FieldLabel htmlFor="ir-description">Descrição (opcional)</FieldLabel>
|
||||||
@@ -292,9 +351,14 @@ function NewInboundRouteForm({
|
|||||||
{error}
|
{error}
|
||||||
</p>
|
</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}>
|
<Button type="submit" disabled={pending}>
|
||||||
{pending ? "Criando…" : "Criar rota"}
|
{pending ? "Salvando…" : isEditing ? "Salvar alterações" : "Criar rota"}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -9,12 +9,11 @@ export function EslUnavailableNotice({ error }: { error: string }) {
|
|||||||
Erro retornado: <span className="font-mono text-xs text-destructive">{error}</span>
|
Erro retornado: <span className="font-mono text-xs text-destructive">{error}</span>
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
<code className="font-mono text-xs">apps/api</code> roda direto no host desta VM, fora do Docker; a porta
|
<code className="font-mono text-xs">apps/api</code> roda direto no host desta VM, fora do Docker, e fala
|
||||||
do Event Socket (8021) do FreeSWITCH é deliberadamente <strong>não publicada no host</strong> (agente.md
|
com o Event Socket (8021) do FreeSWITCH via <code className="font-mono text-xs">127.0.0.1</code> — a porta
|
||||||
secao 184: porta sensível, nunca exposta). Por isso este endpoint sempre falha aqui, mesmo com o
|
é publicada só em loopback (agente.md secao 184: nunca pra rede), então normalmente esta tela mostra dados
|
||||||
FreeSWITCH saudável — o container está isolado do jeito certo. Em produção, onde{" "}
|
reais. Se este erro aparecer, o mais provável é o container do FreeSWITCH estar fora do ar ou reiniciando
|
||||||
<code className="font-mono text-xs">apps/api</code> roda na mesma rede Docker do FreeSWITCH, esta tela
|
— 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.
|
||||||
mostra os dados reais.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</Panel>
|
</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",
|
description: "Postgres, Redis e FreeSWITCH — verificação ao vivo",
|
||||||
permission: "freeswitch.view",
|
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> {
|
export async function agentPause(pauseReasonId: string): Promise<ActionResult> {
|
||||||
return callAgentMe("/pause", { pauseReasonId });
|
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 type { MyAgent, PauseReason } from "@/lib/callcenter-types";
|
||||||
import { TENANT_NAV } from "./nav-data";
|
import { TENANT_NAV } from "./nav-data";
|
||||||
import { AgentStatusWidget } from "./agent-status-widget";
|
import { AgentStatusWidget } from "./agent-status-widget";
|
||||||
|
import { SoftphoneWidget } from "./softphone-widget";
|
||||||
|
|
||||||
/** Ver comentário em `platform-shell/platform-sidebar.tsx` — mesma razão. */
|
/** Ver comentário em `platform-shell/platform-sidebar.tsx` — mesma razão. */
|
||||||
export function TenantTopbar({
|
export function TenantTopbar({
|
||||||
@@ -25,7 +26,12 @@ export function TenantTopbar({
|
|||||||
items={filterNavByPermissions(TENANT_NAV, permissionKeys)}
|
items={filterNavByPermissions(TENANT_NAV, permissionKeys)}
|
||||||
fallbackTitle={fallbackTitle}
|
fallbackTitle={fallbackTitle}
|
||||||
userLabel={userLabel}
|
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;
|
corsOrigin: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface WebrtcProxyConfig {
|
||||||
|
url: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
export interface TenantAiUsage {
|
export interface TenantAiUsage {
|
||||||
tenantId: string;
|
tenantId: string;
|
||||||
legalName: string;
|
legalName: string;
|
||||||
|
|||||||
@@ -99,12 +99,25 @@ services:
|
|||||||
# uma chamada ativa seria latência/confiabilidade desnecessárias
|
# uma chamada ativa seria latência/confiabilidade desnecessárias
|
||||||
# pra um prompt de poucos segundos.
|
# pra um prompt de poucos segundos.
|
||||||
- ./data/ivr-prompts:/ivr-prompts
|
- ./data/ivr-prompts:/ivr-prompts
|
||||||
# Event Socket (8021) NUNCA publicado — só alcançável por outros
|
# Event Socket (8021) NUNCA publicado pra REDE — só em loopback do
|
||||||
# containers na rede interna do compose (agente.md secao 22).
|
# 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
|
# SIP (5060) e RTP (16384-16584, range fixo no Dockerfile) publicados
|
||||||
# a partir da PHASE 54 — necessário pra registrar um softphone/telefone
|
# pra rede a partir da PHASE 54 — necessário pra registrar um
|
||||||
# de fora da rede Docker (achado testando registro de verdade).
|
# softphone/telefone de fora da rede Docker.
|
||||||
ports:
|
ports:
|
||||||
|
- "127.0.0.1:8021:8021"
|
||||||
- "5060:5060/udp"
|
- "5060:5060/udp"
|
||||||
- "5060:5060/tcp"
|
- "5060:5060/tcp"
|
||||||
- "16384-16584:16384-16584/udp"
|
- "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 —
|
alto (`set -eu` + `${ESL_PASSWORD:?...}`) se a variável não estiver definida —
|
||||||
nunca sobe com a senha padrão por engano.
|
nunca sobe com a senha padrão por engano.
|
||||||
|
|
||||||
Porta 8021 **não é publicada no host** (`ports:` ausente no compose) — só
|
Porta 8021 **nunca publicada pra rede** — só em loopback do próprio host
|
||||||
alcançável por outros containers na rede interna do Docker Compose
|
(`"127.0.0.1:8021:8021"` no `docker-compose.yml`, desde a PHASE 65, ver seção
|
||||||
(`b2bcall_default`), pelo nome de serviço `freeswitch`. Isso satisfaz
|
abaixo). Isso satisfaz "NÃO pública... acesso somente pela aplicação
|
||||||
"NÃO pública... acesso somente pela aplicação autorizada" sem precisar de ACL
|
autorizada" (agente.md secao 22) sem abrir a porta pra ninguém além do próprio
|
||||||
adicional por enquanto (ACL fica como hardening futuro, ver TODO).
|
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)
|
## 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
|
então o FreeSWITCH em si tende a ficar em rede privada mesmo, o que favorece
|
||||||
manter bridge/macvlan em vez de host).
|
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
|
## Quando `apps/api` virar container
|
||||||
|
|
||||||
Hoje ela roda no host por conveniência de desenvolvimento. Quando virar o
|
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])
|
@@index([billingStatementId])
|
||||||
@@map("billing_statement_items")
|
@@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");
|
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> {
|
getGateways(): Promise<unknown> {
|
||||||
return this.apiJson("show gateways as json");
|
return this.runApi("sofia status");
|
||||||
}
|
}
|
||||||
|
|
||||||
async getQueues(): Promise<unknown> {
|
async getQueues(): Promise<unknown> {
|
||||||
|
|||||||
Reference in New Issue
Block a user