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

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

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

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

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

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

View File

@@ -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
View File

@@ -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

View File

@@ -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). */

View 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;
}

View 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 };
}
}

View File

@@ -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 {}

File diff suppressed because one or more lines are too long

View File

@@ -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 {

View File

@@ -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,7 +117,8 @@ export function RotasEntradaView({
</THead> </THead>
<TBody> <TBody>
{routes.map((r) => ( {routes.map((r) => (
<TR key={r.id}> <Fragment key={r.id}>
<TR>
<TD> <TD>
<span className="flex items-center gap-2 font-mono font-medium text-foreground"> <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 /> <PhoneIncoming className="h-3.5 w-3.5 text-muted-foreground" aria-hidden />
@@ -124,9 +132,38 @@ export function RotasEntradaView({
</TD> </TD>
<TD className="text-muted-foreground">{formatDate(r.createdAt)}</TD> <TD className="text-muted-foreground">{formatDate(r.createdAt)}</TD>
<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} /> <DeleteInboundRouteButton routeId={r.id} didNumber={r.didNumber} />
</div>
</TD> </TD>
</TR> </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,7 +237,14 @@ function NewInboundRouteForm({
} }
startTransition(async () => { startTransition(async () => {
const result = await createInboundRoute({ const result = isEditing
? await updateInboundRoute(route.id, {
description: description.trim() || undefined,
destinationType,
destinationNumber,
destinationContext,
})
: await createInboundRoute({
didNumber: didNumber.trim(), didNumber: didNumber.trim(),
description: description.trim() || undefined, description: description.trim() || undefined,
destinationType, destinationType,
@@ -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>

View File

@@ -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 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>

View File

@@ -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) };
}
}

View File

@@ -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} />;
}

View File

@@ -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 WebRTCSIP 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>
);
}

View File

@@ -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",
},
], ],
}, },
{ {

View File

@@ -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) };
}
}

View File

@@ -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;
}

View File

@@ -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} />
</>
}
/> />
); );
} }

View File

@@ -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;

View File

@@ -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"

View File

@@ -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)

View File

@@ -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
View 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``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.

View File

@@ -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")
);

View File

@@ -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")
}

View File

@@ -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> {