diff --git a/TODO.md b/TODO.md index 13f5af6..85e29e7 100644 --- a/TODO.md +++ b/TODO.md @@ -1828,6 +1828,55 @@ usuário: `Ctrl+F5` depois de um tempo logado quebrava com 401 cru) mas o error boundary do client detecta, desloga e redireciona pro login sozinho — sem crash na tela pro usuário +## PHASE 52 — Domínio SIP por tenant + grupo de captura + revelar senha +(achado real detalhado pelo usuário, testando o PABX de verdade) +- [x] achado real (o mais sério dos três): `TenantsController.create()` + gravava `telephonyDomain: "b2bcall.local"` fixo pra TODO tenant + novo — `b2bcall-fs-config` decide qual tenant é dono de um REGISTER + só pelo domínio (`Tenant.findFirst({ telephonyDomain: domain })`), + então com todo tenant no mesmo domínio o isolamento de PABX (ramais, + call groups, filas, IVR) não tinha como funcionar de verdade. + Investigado direto no container: o sofia profile `internal` + (vanilla, nunca sobrescrito) já vem com + `` — o FreeSWITCH + SEMPRE aceitou domínio dinâmico por REGISTER, o bug era só a + aplicação gravando o mesmo valor pra todo mundo. Nenhuma mudança de + infra (Dockerfile/sofia profile) foi necessária +- [x] `Tenant.telephonyDomain` agora é obrigatório e `@unique` (migration + `20260830140000_tenant_domain_and_call_group`, com backfill: + tenants existentes ganharam `{code}.b2bcall.net` automaticamente, + e os `Extension.domain` já criados foram atualizados junto — sem + isso ficariam com um `` desatualizado no XML). Tela de + criação de tenant sugere `{code}.b2bcall.net` ao digitar o código, + editável antes de criar. Duplicado vira 403 claro +- [x] `Extension.callGroup` (novo, nullable) — grupo de captura (secao + 178): ramais no mesmo grupo podem atender a chamada um do outro + (`*8`/group pickup), ramais fora do grupo não. Vira a variable + `call-group` no directory XML (`packages/telephony`); a extensão de + dialplan do `*8` em si é uma regra a configurar em Telefonia > + Dialplan (já existe o editor), não precisava de código novo — só do + dado. Editável na criação e depois (`PATCH /extensions/:id`, novo) +- [x] `POST /extensions/:id/reveal-password` (novo) — achado real: "show + once" puro não funciona no dia a dia de um PABX (reconfigurar um + telefone físico ou softphone precisa da senha de novo; forçar reset + toda vez derruba o registro de qualquer aparelho já configurado + com a senha antiga). `sipPasswordEnc` sempre foi criptografia + reversível (AES-256-GCM), nunca hash — só não estava exposto. + Diferente de `reset-password`: nunca troca nada, só decifra a + atual. Auditado (`EXTENSION_PASSWORD_REVEALED`) por ser sensível + mesmo sem escrita +- [x] Tela de criação de ramal ganhou um bloco "Configuração do telefone/ + softphone" (domínio, ramal, proxy=servidor, senha) — os 4 campos + exatos que o usuário descreveu precisar pra configurar um aparelho +- [x] `apps/freeswitch-config` reconstruído e reiniciado (mudança de + schema + lógica) — testado ponta a ponta contra o container REAL: + criado tenant com domínio próprio + ramal com callGroup, `docker + exec` no FreeSWITCH chamando `fs-config` direto (mesma rede + Docker) confirmou (1) senha revelada bate exatamente com a gerada + na criação, (2) `call-group` aparece no XML, (3) o mesmo número de + ramal em domínios diferentes NUNCA se confunde (isolamento + cross-tenant confirmado de verdade, não só por leitura de código) + --- ## Riscos conhecidos diff --git a/apps/api/src/extensions/dto/create-extension.dto.ts b/apps/api/src/extensions/dto/create-extension.dto.ts index 91b513f..e777ab3 100644 --- a/apps/api/src/extensions/dto/create-extension.dto.ts +++ b/apps/api/src/extensions/dto/create-extension.dto.ts @@ -33,4 +33,37 @@ export class CreateExtensionDto { @Min(1) @Max(10) maxRegistrations?: number; + + /** Grupo de captura (secao 178) — ramais no mesmo grupo podem capturar + * a chamada um do outro (*8 no dialplan); ramais fora do grupo, não. + * Sem grupo (null) = ninguém mais captura essa chamada. */ + @IsOptional() + @IsString() + @MaxLength(60) + @Matches(/^[a-zA-Z0-9_-]+$/, { message: "callGroup só pode ter letras, números, hífen e underscore" }) + callGroup?: string; +} + +export class UpdateExtensionDto { + @IsOptional() + @IsString() + @MaxLength(80) + callerIdName?: string; + + @IsOptional() + @IsString() + @Matches(/^[0-9]{2,20}$/) + callerIdNumber?: string; + + @IsOptional() + @IsInt() + @Min(1) + @Max(10) + maxRegistrations?: number; + + @IsOptional() + @IsString() + @MaxLength(60) + @Matches(/^[a-zA-Z0-9_-]+$/, { message: "callGroup só pode ter letras, números, hífen e underscore" }) + callGroup?: string; } diff --git a/apps/api/src/extensions/extensions.controller.ts b/apps/api/src/extensions/extensions.controller.ts index 45e8c0e..94af730 100644 --- a/apps/api/src/extensions/extensions.controller.ts +++ b/apps/api/src/extensions/extensions.controller.ts @@ -8,18 +8,19 @@ import { HttpStatus, NotFoundException, Param, + Patch, Post, UseGuards, } from "@nestjs/common"; import { getPrismaClient, withTenantContext } from "@b2bcall/database"; -import { generateStrongPassword, encryptSecret } from "@b2bcall/shared"; +import { generateStrongPassword, encryptSecret, decryptSecret } from "@b2bcall/shared"; import { recordAuditEvent, type AccessTokenClaims } from "@b2bcall/auth"; import { assertQuota } from "@b2bcall/entitlements"; 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 { CreateExtensionDto } from "./dto/create-extension.dto"; +import { CreateExtensionDto, UpdateExtensionDto } from "./dto/create-extension.dto"; function toPublicExtension(ext: { id: string; @@ -32,6 +33,7 @@ function toPublicExtension(ext: { context: string; sofiaProfile: string; codecs: string; + callGroup: string | null; maxRegistrations: number; enabled: boolean; createdAt: Date; @@ -81,6 +83,7 @@ export class ExtensionsController { context: dto.context ?? "default", sofiaProfile: dto.sofiaProfile ?? "internal", maxRegistrations: dto.maxRegistrations ?? 1, + callGroup: dto.callGroup, }, }), ); @@ -162,6 +165,72 @@ export class ExtensionsController { return { sipPassword: plainPassword }; } + /** + * Revela a senha SIP atual (achado real reportado pelo usuário: "show + * once" puro não funciona no dia a dia — reconfigurar um telefone físico + * ou um softphone precisa da senha de novo, e forçar reset toda vez + * derruba o registro de qualquer aparelho já configurado com a senha + * antiga). Diferente de `resetPassword`: não gera senha nova, só + * decifra a que já existe (`sipPasswordEnc` é criptografia reversível + * AES-256-GCM, não hash — sempre foi possível decifrar, só não estava + * exposto). Cada chamada fica no audit log — ver a senha de novo é uma + * ação sensível, mesmo sem trocar nada. + */ + @RequirePermission("extensions.manage") + @Post(":id/reveal-password") + async revealPassword(@CurrentUser() user: AccessTokenClaims, @Param("id") id: string) { + const prisma = getPrismaClient(); + const tenantId = user.tenantId!; + + const extension = await withTenantContext(prisma, tenantId, (tx) => + tx.extension.findFirst({ where: { id, tenantId, deletedAt: null } }), + ); + if (!extension) throw new NotFoundException(); + + await recordAuditEvent(prisma, { + action: "EXTENSION_PASSWORD_REVEALED", + tenantId, + userId: user.sub, + entityType: "extension", + entityId: id, + }); + + return { sipPassword: decryptSecret(extension.sipPasswordEnc) }; + } + + @RequirePermission("extensions.manage") + @Patch(":id") + async update(@CurrentUser() user: AccessTokenClaims, @Param("id") id: string, @Body() dto: UpdateExtensionDto) { + const prisma = getPrismaClient(); + const tenantId = user.tenantId!; + + const result = await withTenantContext(prisma, tenantId, (tx) => + tx.extension.updateMany({ + where: { id, tenantId, deletedAt: null }, + data: { + ...(dto.callerIdName !== undefined ? { callerIdName: dto.callerIdName } : {}), + ...(dto.callerIdNumber !== undefined ? { callerIdNumber: dto.callerIdNumber } : {}), + ...(dto.maxRegistrations !== undefined ? { maxRegistrations: dto.maxRegistrations } : {}), + ...(dto.callGroup !== undefined ? { callGroup: dto.callGroup } : {}), + }, + }), + ); + if (result.count === 0) throw new NotFoundException(); + + const updated = await withTenantContext(prisma, tenantId, (tx) => tx.extension.findFirstOrThrow({ where: { id } })); + + await recordAuditEvent(prisma, { + action: "EXTENSION_UPDATE", + tenantId, + userId: user.sub, + entityType: "extension", + entityId: id, + after: { ...dto }, + }); + + return toPublicExtension(updated); + } + @RequirePermission("extensions.manage") @Delete(":id") @HttpCode(HttpStatus.NO_CONTENT) diff --git a/apps/api/src/tenants/dto/create-tenant.dto.ts b/apps/api/src/tenants/dto/create-tenant.dto.ts index c9daa48..58f2b90 100644 --- a/apps/api/src/tenants/dto/create-tenant.dto.ts +++ b/apps/api/src/tenants/dto/create-tenant.dto.ts @@ -25,6 +25,22 @@ export class CreateTenantDto { @IsUUID() planId!: string; + /** Domínio SIP deste tenant (secao 178, docs/EXTENSIONS.md) — achado + * real reportado pelo usuário: antes disto todo tenant nascia com o + * mesmo domínio fixo ("b2bcall.local"), quebrando isolamento de PABX + * (call groups, filas, IVR não conseguem distinguir um tenant do + * outro). A tela sugere `{code}.b2bcall.net`, mas quem cria pode + * digitar outro — só precisa ser único (constraint no banco) e ter + * cara de domínio (não precisa resolver de verdade por DNS: o + * telefone/softphone aponta pro IP do servidor via `proxy=`, este + * campo só serve pra identificar o tenant no REGISTER). */ + @IsString() + @MaxLength(120) + @Matches(/^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]*[a-z0-9])?)+$/, { + message: "telephonyDomain precisa ter formato de domínio (ex.: minhaempresa.b2bcall.net)", + }) + telephonyDomain!: string; + /** Cria o primeiro usuário (tenant_admin) na mesma transação — sem essa * conta o tenant fica inacessível (secao 141: "Tenant Admin é criado * junto com o tenant"). Senha gerada e devolvida uma única vez, mesmo diff --git a/apps/api/src/tenants/tenants.controller.ts b/apps/api/src/tenants/tenants.controller.ts index 86b6a72..17bfcf4 100644 --- a/apps/api/src/tenants/tenants.controller.ts +++ b/apps/api/src/tenants/tenants.controller.ts @@ -14,8 +14,14 @@ import { CreateTenantDto, UpdateTenantDto } from "./dto/create-tenant.dto"; * direto. `tenants` não tem RLS (tabela raiz, ver docs/TENANT_ISOLATION.md), * então lida direto com `getPrismaClient()`, nunca `withTenantContext`. * - * `telephonyDomain` fixo em "b2bcall.local" pra todo tenant novo — decisão - * já tomada na PHASE 08 (multi-domínio real por tenant não existe ainda). + * `telephonyDomain` (secao 178, docs/EXTENSIONS.md) vem do `dto` agora, + * único por tenant (constraint no banco) — achado real reportado pelo + * usuário testando: até a PHASE 50 todo tenant novo nascia com o mesmo + * domínio fixo ("b2bcall.local"), quebrando isolamento de PABX de verdade + * (call groups, filas, IVR não tinham como distinguir um tenant do outro + * no REGISTER). O FreeSWITCH em si já aceita qualquer domínio dinamicamente + * (`` no sofia profile vanilla, + * confirmado lendo o container) — o bug era só este valor fixo aqui. */ @UseGuards(JwtAuthGuard, PermissionGuard) @Controller("tenants") @@ -28,12 +34,14 @@ export class TenantsController { } const prisma = getPrismaClient(); - const [existingCode, existingEmail, plan] = await Promise.all([ + const [existingCode, existingDomain, existingEmail, plan] = await Promise.all([ prisma.tenant.findFirst({ where: { code: dto.code } }), + prisma.tenant.findFirst({ where: { telephonyDomain: dto.telephonyDomain } }), prisma.user.findUnique({ where: { email: dto.adminEmail } }), prisma.plan.findUnique({ where: { id: dto.planId } }), ]); if (existingCode) throw new ForbiddenException(`Ja existe um tenant com code "${dto.code}"`); + if (existingDomain) throw new ForbiddenException(`Ja existe um tenant usando o dominio "${dto.telephonyDomain}"`); if (existingEmail) throw new ForbiddenException(`Ja existe um usuario com o e-mail "${dto.adminEmail}"`); if (!plan) throw new ForbiddenException("Plano nao encontrado"); @@ -49,7 +57,7 @@ export class TenantsController { tradeName: dto.tradeName, taxId: dto.taxId, planId: dto.planId, - telephonyDomain: "b2bcall.local", + telephonyDomain: dto.telephonyDomain, }, }); diff --git a/apps/freeswitch-config/src/main.ts b/apps/freeswitch-config/src/main.ts index 3a7f2c8..174274f 100644 --- a/apps/freeswitch-config/src/main.ts +++ b/apps/freeswitch-config/src/main.ts @@ -52,9 +52,11 @@ async function resolveDirectoryXml(user: string | undefined, domain: string | un // tenants nao e' tenant-scoped (e' o proprio registro de tenants — sem // RLS, ver docs/TENANT_ISOLATION.md), mas so devolvemos dados de UM - // tenant especifico depois de achar o dono do dominio. - const tenant = await prisma.tenant.findFirst({ where: { telephonyDomain: domain, status: "ACTIVE" } }); - if (!tenant) { + // tenant especifico depois de achar o dono do dominio. `telephonyDomain` + // e' @unique desde a PHASE 50 (achado real: antes disso todo tenant + // tinha o mesmo dominio fixo) — `findUnique` reflete essa garantia. + const tenant = await prisma.tenant.findUnique({ where: { telephonyDomain: domain } }); + if (!tenant || tenant.status !== "ACTIVE") { return NOT_FOUND_XML; } @@ -75,16 +77,17 @@ async function resolveDirectoryXml(user: string | undefined, domain: string | un callerIdNumber: extension.callerIdNumber ?? undefined, tenantId: extension.tenantId, extensionId: extension.id, + callGroup: extension.callGroup, }); } /** * Resolve tenant pelo channel variable `b2bcall_tenant_id` — injetado em * toda chamada originada de um ramal nosso (ver buildDirectoryUserXml). - * Ao contrário do directory (resolvido por domain, que hoje é o mesmo pra - * todos os tenants — limitação conhecida, ver docs/EXTENSIONS.md), o - * dialplan já tem essa variável disponível na própria chamada, então nem - * sofre da mesma ambiguidade. + * Ao contrário do directory (resolvido por domain — cada tenant tem o seu + * agora, único no banco, PHASE 50/docs/EXTENSIONS.md), o dialplan já tem + * essa variável disponível na própria chamada, então nem depende de + * domain nenhum. * * Serve o XML JÁ GERADO da versão ACTIVE (dialplan_versions.generated_xml) * — nunca reconstrói ao vivo a partir de dialplan_extensions. Editar as diff --git a/apps/frontend/.impeccable/review/pabx-extension-detail-desktop.png b/apps/frontend/.impeccable/review/pabx-extension-detail-desktop.png new file mode 100644 index 0000000..1f1be1f Binary files /dev/null and b/apps/frontend/.impeccable/review/pabx-extension-detail-desktop.png differ diff --git a/apps/frontend/.impeccable/review/pabx-extension-reveal-password-desktop.png b/apps/frontend/.impeccable/review/pabx-extension-reveal-password-desktop.png new file mode 100644 index 0000000..e31eb4f Binary files /dev/null and b/apps/frontend/.impeccable/review/pabx-extension-reveal-password-desktop.png differ diff --git a/apps/frontend/.impeccable/review/pabx-new-extension-created-desktop.png b/apps/frontend/.impeccable/review/pabx-new-extension-created-desktop.png new file mode 100644 index 0000000..47526dc Binary files /dev/null and b/apps/frontend/.impeccable/review/pabx-new-extension-created-desktop.png differ diff --git a/apps/frontend/.impeccable/review/pabx-new-extension-form-desktop.png b/apps/frontend/.impeccable/review/pabx-new-extension-form-desktop.png new file mode 100644 index 0000000..b404d7d Binary files /dev/null and b/apps/frontend/.impeccable/review/pabx-new-extension-form-desktop.png differ diff --git a/apps/frontend/.impeccable/review/pabx-new-tenant-domain-desktop.png b/apps/frontend/.impeccable/review/pabx-new-tenant-domain-desktop.png new file mode 100644 index 0000000..5a015cd Binary files /dev/null and b/apps/frontend/.impeccable/review/pabx-new-tenant-domain-desktop.png differ diff --git a/apps/frontend/src/app/app/telefonia/ramais/[id]/actions-panel.tsx b/apps/frontend/src/app/app/telefonia/ramais/[id]/actions-panel.tsx index f32b085..ab62cbb 100644 --- a/apps/frontend/src/app/app/telefonia/ramais/[id]/actions-panel.tsx +++ b/apps/frontend/src/app/app/telefonia/ramais/[id]/actions-panel.tsx @@ -2,10 +2,96 @@ import { useState, useTransition } from "react"; import { useRouter } from "next/navigation"; -import { KeyRound, Trash2 } from "lucide-react"; +import { Eye, KeyRound, Trash2 } from "lucide-react"; import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; import { SecretReveal } from "@/components/ui/secret-reveal"; -import { resetExtensionPassword, deleteExtension } from "../actions"; +import { resetExtensionPassword, revealExtensionPassword, deleteExtension, updateExtension } from "../actions"; + +/** Diferente de `ResetPasswordAction`: nunca troca a senha, só mostra a + * atual de novo — pra reconfigurar um telefone/softphone sem invalidar + * outro aparelho já usando a mesma credencial (achado real reportado pelo + * usuário: "show once" puro não funciona no dia a dia de um PABX). */ +export function RevealPasswordAction({ extensionId }: { extensionId: string }) { + const [pending, startTransition] = useTransition(); + const [error, setError] = useState(null); + const [password, setPassword] = useState(null); + + function onClick() { + setError(null); + startTransition(async () => { + const result = await revealExtensionPassword(extensionId); + if (!result.ok) { + setError(result.error); + return; + } + setPassword(result.sipPassword); + }); + } + + if (password) { + return ; + } + + return ( +
+ + {error && ( +

+ {error} +

+ )} +
+ ); +} + +export function CallGroupEditAction({ extensionId, callGroup }: { extensionId: string; callGroup: string | null }) { + const router = useRouter(); + const [editing, setEditing] = useState(false); + const [value, setValue] = useState(callGroup ?? ""); + const [pending, startTransition] = useTransition(); + const [error, setError] = useState(null); + + function onSave() { + setError(null); + startTransition(async () => { + const result = await updateExtension(extensionId, { callGroup: value.trim() || null }); + if (!result.ok) { + setError(result.error); + return; + } + setEditing(false); + router.refresh(); + }); + } + + if (!editing) { + return ( +
+ {callGroup ?? "— (nenhum)"} + +
+ ); + } + + return ( +
+ setValue(e.target.value)} placeholder="Ex.: recepcao" className="h-8 w-48 font-mono" disabled={pending} /> + + + {error && {error}} +
+ ); +} export function ResetPasswordAction({ extensionId }: { extensionId: string }) { const [pending, startTransition] = useTransition(); diff --git a/apps/frontend/src/app/app/telefonia/ramais/[id]/page.tsx b/apps/frontend/src/app/app/telefonia/ramais/[id]/page.tsx index 539a356..a545434 100644 --- a/apps/frontend/src/app/app/telefonia/ramais/[id]/page.tsx +++ b/apps/frontend/src/app/app/telefonia/ramais/[id]/page.tsx @@ -7,7 +7,7 @@ import { Panel, PanelHeader } from "@/components/ui/panel"; import { Pill } from "@/components/ui/pill"; import { formatDate } from "@/lib/format"; import type { Extension } from "@/lib/extension-types"; -import { ResetPasswordAction, DeleteExtensionAction } from "./actions-panel"; +import { ResetPasswordAction, RevealPasswordAction, CallGroupEditAction, DeleteExtensionAction } from "./actions-panel"; interface Me { tenant: { name: string; code: string } | null; @@ -73,6 +73,12 @@ export default async function ExtensionDetailPage({ params }: { params: Promise<
Registros simultâneos
{extension.maxRegistrations}
+
+
Grupo de captura
+
+ +
+
Criado em
{formatDate(extension.createdAt)}
@@ -83,9 +89,10 @@ export default async function ExtensionDetailPage({ params }: { params: Promise< -
+
+
diff --git a/apps/frontend/src/app/app/telefonia/ramais/actions.ts b/apps/frontend/src/app/app/telefonia/ramais/actions.ts index a2ccdd2..89a4ef9 100644 --- a/apps/frontend/src/app/app/telefonia/ramais/actions.ts +++ b/apps/frontend/src/app/app/telefonia/ramais/actions.ts @@ -26,6 +26,7 @@ export interface CreateExtensionInput { callerIdNumber?: string; context?: string; maxRegistrations?: number; + callGroup?: string; } export async function createExtension( @@ -57,6 +58,43 @@ export async function resetExtensionPassword(id: string): Promise<{ ok: true; si } } +/** Diferente de `resetExtensionPassword`: não troca nada, só mostra a + * senha atual de novo — pra reconfigurar um telefone/softphone sem + * derrubar o registro de outro aparelho já usando essa senha. */ +export async function revealExtensionPassword(id: string): Promise<{ ok: true; sipPassword: string } | { ok: false; error: string }> { + const session = await requireSession(); + try { + const { sipPassword } = await apiFetch<{ sipPassword: string }>(`/extensions/${id}/reveal-password`, session.accessToken, { + method: "POST", + }); + return { ok: true, sipPassword }; + } catch (err) { + return { ok: false, error: extractErrorMessage(err) }; + } +} + +export interface UpdateExtensionInput { + callerIdName?: string; + callerIdNumber?: string; + maxRegistrations?: number; + callGroup?: string | null; +} + +export async function updateExtension(id: string, input: UpdateExtensionInput): Promise<{ ok: true; extension: Extension } | { ok: false; error: string }> { + const session = await requireSession(); + try { + const extension = await apiFetch(`/extensions/${id}`, session.accessToken, { + method: "PATCH", + body: JSON.stringify(input), + }); + revalidatePath(`/app/telefonia/ramais/${id}`); + revalidatePath("/app/telefonia/ramais"); + return { ok: true, extension }; + } catch (err) { + return { ok: false, error: extractErrorMessage(err) }; + } +} + export async function deleteExtension(id: string): Promise<{ ok: true } | { ok: false; error: string }> { const session = await requireSession(); try { diff --git a/apps/frontend/src/app/app/telefonia/ramais/new/form.tsx b/apps/frontend/src/app/app/telefonia/ramais/new/form.tsx index 908d3ac..0a56c67 100644 --- a/apps/frontend/src/app/app/telefonia/ramais/new/form.tsx +++ b/apps/frontend/src/app/app/telefonia/ramais/new/form.tsx @@ -15,6 +15,7 @@ export function NewExtensionForm() { const [name, setName] = useState(""); const [callerIdName, setCallerIdName] = useState(""); const [callerIdNumber, setCallerIdNumber] = useState(""); + const [callGroup, setCallGroup] = useState(""); const [error, setError] = useState(null); const [pending, startTransition] = useTransition(); const [created, setCreated] = useState<{ extension: Extension; sipPassword: string } | null>(null); @@ -38,6 +39,7 @@ export function NewExtensionForm() { name: name.trim(), callerIdName: callerIdName.trim() || undefined, callerIdNumber: callerIdNumber.trim() || undefined, + callGroup: callGroup.trim() || undefined, }); if (!result.ok) { setError(result.error); @@ -64,6 +66,28 @@ export function NewExtensionForm() { + +

Configuração do telefone/softphone

+
+
+
Domínio
+
{created.extension.domain}
+
+
+
Ramal (usuário)
+
{created.extension.number}
+
+
+
Proxy/servidor
+
IP ou host do FreeSWITCH desta implantação
+
+
+
Senha
+
a de cima ↑
+
+
+
+
+ +
+ Grupo de captura (opcional) + setCallGroup(e.target.value)} + placeholder="Ex.: recepcao, vendas" + className="font-mono" + disabled={pending} + /> +

+ Ramais no mesmo grupo podem capturar a chamada um do outro (*8). Sem grupo, nenhum outro ramal captura + esta chamada. +

+
{error && ( diff --git a/apps/frontend/src/app/platform/clientes/tenants/[id]/tenant-detail-view.tsx b/apps/frontend/src/app/platform/clientes/tenants/[id]/tenant-detail-view.tsx index 20fc8be..5b42e0c 100644 --- a/apps/frontend/src/app/platform/clientes/tenants/[id]/tenant-detail-view.tsx +++ b/apps/frontend/src/app/platform/clientes/tenants/[id]/tenant-detail-view.tsx @@ -50,6 +50,10 @@ export function TenantDetailView({ tenant, plans }: { tenant: Tenant & { plan: P Código {tenant.code} — criado em {formatDate(tenant.createdAt)},{" "} {tenant.memberCount} usuário(s)

+

+ Domínio SIP {tenant.telephonyDomain ?? "—"} — usado pra + isolar ramais/filas/IVR deste tenant no PABX, fixo desde a criação +

diff --git a/apps/frontend/src/app/platform/clientes/tenants/actions.ts b/apps/frontend/src/app/platform/clientes/tenants/actions.ts index c52118f..422a0aa 100644 --- a/apps/frontend/src/app/platform/clientes/tenants/actions.ts +++ b/apps/frontend/src/app/platform/clientes/tenants/actions.ts @@ -25,6 +25,7 @@ export interface CreateTenantInput { tradeName?: string; taxId?: string; planId: string; + telephonyDomain: string; adminEmail: string; adminName: string; } diff --git a/apps/frontend/src/app/platform/clientes/tenants/new/form.tsx b/apps/frontend/src/app/platform/clientes/tenants/new/form.tsx index 8aa2c7b..bfbd6e6 100644 --- a/apps/frontend/src/app/platform/clientes/tenants/new/form.tsx +++ b/apps/frontend/src/app/platform/clientes/tenants/new/form.tsx @@ -16,12 +16,20 @@ export function NewTenantForm({ plans }: { plans: Plan[] }) { const [tradeName, setTradeName] = useState(""); const [taxId, setTaxId] = useState(""); const [planId, setPlanId] = useState(plans[0]?.id ?? ""); + const [telephonyDomain, setTelephonyDomain] = useState(""); + const [domainTouched, setDomainTouched] = useState(false); const [adminEmail, setAdminEmail] = useState(""); const [adminName, setAdminName] = useState(""); const [error, setError] = useState(null); const [pending, startTransition] = useTransition(); const [created, setCreated] = useState(null); + function onCodeChange(value: string) { + const next = value.toLowerCase(); + setCode(next); + if (!domainTouched) setTelephonyDomain(next ? `${next}.b2bcall.net` : ""); + } + function onSubmit(e: React.FormEvent) { e.preventDefault(); setError(null); @@ -34,6 +42,10 @@ export function NewTenantForm({ plans }: { plans: Plan[] }) { setError("Razão social, plano, e-mail e nome do admin são obrigatórios."); return; } + if (!telephonyDomain.trim()) { + setError("Domínio SIP é obrigatório — isola o PABX deste tenant dos outros."); + return; + } startTransition(async () => { const result = await createTenant({ @@ -42,6 +54,7 @@ export function NewTenantForm({ plans }: { plans: Plan[] }) { tradeName: tradeName.trim() || undefined, taxId: taxId.trim() || undefined, planId, + telephonyDomain: telephonyDomain.trim(), adminEmail: adminEmail.trim(), adminName: adminName.trim(), }); @@ -102,7 +115,7 @@ export function NewTenantForm({ plans }: { plans: Plan[] }) { setCode(e.target.value.toLowerCase())} + onChange={(e) => onCodeChange(e.target.value)} placeholder="ex.: acme" className="font-mono" disabled={pending} @@ -129,9 +142,30 @@ export function NewTenantForm({ plans }: { plans: Plan[] }) { setTradeName(e.target.value)} placeholder="Ex.: Acme Call Center" disabled={pending} /> -
- CNPJ/CPF (opcional) - setTaxId(e.target.value)} className="font-mono" disabled={pending} /> +
+
+ CNPJ/CPF (opcional) + setTaxId(e.target.value)} className="font-mono" disabled={pending} /> +
+
+ Domínio SIP (telefonia) + { + setDomainTouched(true); + setTelephonyDomain(e.target.value.toLowerCase()); + }} + placeholder="empresa.b2bcall.net" + className="font-mono" + disabled={pending} + /> +

+ Isola o PABX deste tenant dos outros (ramais, filas, IVR) — sugerido a partir do código, mas pode + trocar. Não precisa resolver por DNS de verdade: o telefone aponta pro IP do servidor, este campo só + identifica o tenant. +

+
diff --git a/apps/frontend/src/lib/extension-types.ts b/apps/frontend/src/lib/extension-types.ts index c61e8c9..994f4b0 100644 --- a/apps/frontend/src/lib/extension-types.ts +++ b/apps/frontend/src/lib/extension-types.ts @@ -9,6 +9,7 @@ export interface Extension { context: string; sofiaProfile: string; codecs: string; + callGroup: string | null; maxRegistrations: number; enabled: boolean; createdAt: string; diff --git a/apps/frontend/src/lib/platform-types.ts b/apps/frontend/src/lib/platform-types.ts index a2101a2..c22225a 100644 --- a/apps/frontend/src/lib/platform-types.ts +++ b/apps/frontend/src/lib/platform-types.ts @@ -33,6 +33,7 @@ export interface Tenant { timezone: string; locale: string; billingCurrency: string; + telephonyDomain: string | null; planId: string; plan: { id: string; key: string; name: string }; memberCount: number; diff --git a/docs/EXTENSIONS.md b/docs/EXTENSIONS.md index a81d0ad..0941b11 100644 --- a/docs/EXTENSIONS.md +++ b/docs/EXTENSIONS.md @@ -71,18 +71,29 @@ user/1500 &park()` falhava com `MANDATORY_IE_MISSING` em vez do pro endpoint `user/`. Corrigido copiando o mesmo template de `dial-string` da config vanilla. -## `$${domain}` fixo +## `$${domain}` fixo (só o valor default do container, não uma restrição) `Tenant.telephonyDomain` precisa bater com o que o FreeSWITCH manda como `domain` no POST do xml_curl. Por padrão, a config vanilla usa `domain=$${local_ip_v4}` — o IP do container, que muda a cada restart e nunca seria estável o suficiente pra configurar em um tenant. Corrigido no `Dockerfile` do FreeSWITCH com um `sed` fixando `$${domain}` pra -`b2bcall.local` (configurável via `ARG DEFAULT_SIP_DOMAIN`). Multi-domínio -real por tenant (múltiplos domínios simultâneos, um por tenant) ainda não -está resolvido — hoje só suporta um domínio fixo pra todos; isso é uma -limitação genuína a resolver quando existir gestão de domínio por tenant de -verdade (fora do escopo desta fase). +`b2bcall.local` (configurável via `ARG DEFAULT_SIP_DOMAIN`) — isso é só o +valor usado como REFERÊNCIA/fallback em `vars.xml`, não uma restrição de +quais domínios o profile aceita. + +**Multi-domínio real por tenant — resolvido na PHASE 50** (achado real +reportado pelo usuário testando): o profile `internal` (vanilla, não +sobrescrito) já vem com `` +— aceita REGISTER de qualquer domínio dinamicamente, sempre dependeu só do +`section=directory` do `mod_xml_curl` resolver o domínio certo (que já +funciona por request, olhando o `domain` que o UA manda). O bug de verdade +era só a camada de aplicação: `TenantsController.create()` gravava o mesmo +`telephonyDomain` fixo ("b2bcall.local") pra todo tenant novo. Corrigido: +`telephonyDomain` agora é obrigatório e único na criação do tenant +(constraint no banco), sugerido como `{code}.b2bcall.net` na tela mas +editável. Nenhuma mudança de infra (Dockerfile/sofia profile) foi +necessária — só era preciso um domínio de verdade por tenant. ## Verificado ponta a ponta @@ -113,9 +124,34 @@ reais, deixou de ser opcional): HTTP Basic, credenciais em `FS_CONFIG_USER`/ sem credenciais recebe 401; o FreeSWITCH (com `gateway-credentials` configurado) continua funcionando normalmente. +## Senha SIP: "ver de novo" além do "show once" (PHASE 51) + +Achado real reportado pelo usuário: "show once" puro não funciona no dia a +dia — reconfigurar um telefone físico ou softphone precisa da senha de +novo, e forçar reset toda vez (`POST /:id/reset-password`) derruba o +registro de qualquer aparelho já configurado com a senha antiga. +`sipPasswordEnc` sempre foi criptografia reversível (AES-256-GCM), nunca +hash — só não estava exposto. `POST /extensions/:id/reveal-password` (novo) +decifra e devolve a senha ATUAL sem trocar nada, auditado +(`EXTENSION_PASSWORD_REVEALED`) por ser uma ação sensível mesmo sem +escrita nenhuma. + +## Grupo de captura (call group, PHASE 51) + +Achado real: sem isso, qualquer ramal conseguia capturar a chamada de +qualquer outro (o PBX não tinha noção de "grupo"). `Extension.callGroup` +(nullable) vira a variable `call-group` no directory XML — o FreeSWITCH já +resolve `*8` (group pickup) comparando essa variable entre canais. A +extensão de dialplan pro `*8` em si fica pra configurar em Telefonia > +Dialplan (já é um editor de regras por tenant), não precisa de código +novo — só precisava existir o dado. + ## O que falta - ~~Quota de ramais~~ — implementada na fase Plans/Entitlements (ver docs/ENTITLEMENTS.md), `assertQuota` chamado antes de criar. - Tela "Telefonia → Ramais" (frontend) — fase Frontend, bem mais adiante. -- Multi-domínio real por tenant (ver acima). +- ~~Multi-domínio real por tenant~~ — resolvido na PHASE 50 (ver acima). +- Extensão de dialplan padrão pro `*8` de group pickup — o dado + (`callGroup`) já existe, falta só alguém configurar a regra em + Telefonia > Dialplan (ou decidir seedar uma default por tenant). diff --git a/packages/database/prisma/migrations/20260830140000_tenant_domain_and_call_group/migration.sql b/packages/database/prisma/migrations/20260830140000_tenant_domain_and_call_group/migration.sql new file mode 100644 index 0000000..ec2fec2 --- /dev/null +++ b/packages/database/prisma/migrations/20260830140000_tenant_domain_and_call_group/migration.sql @@ -0,0 +1,19 @@ +-- AlterTable +ALTER TABLE "extensions" ADD COLUMN "call_group" TEXT; + +-- Backfill: todo tenant existente nascia com o mesmo telephony_domain fixo +-- ("b2bcall.local") — achado real reportado pelo usuário (isolamento de +-- PABX por tenant nao funciona sem dominio unico por tenant, secao 178). +-- Da' um valor distinto e' pra cada um antes de criar a constraint UNIQUE +-- abaixo, seguindo a mesma convencao que passa a ser sugerida na criacao +-- de tenant novo (`{code}.b2bcall.net`). +UPDATE "tenants" SET "telephony_domain" = "code" || '.b2bcall.net' WHERE "telephony_domain" IS NOT NULL; + +-- Ramais já existentes guardaram o domain antigo no momento da criação +-- (Extension.domain é uma cópia, não uma FK) — sem isto ficariam com um +-- desatualizado no XML do directory depois do backfill acima. +UPDATE "extensions" e SET "domain" = t."telephony_domain" + FROM "tenants" t WHERE e."tenant_id" = t."id"; + +-- CreateIndex +CREATE UNIQUE INDEX "tenants_telephony_domain_key" ON "tenants"("telephony_domain"); diff --git a/packages/database/prisma/schema.prisma b/packages/database/prisma/schema.prisma index 1d97047..47c337b 100644 --- a/packages/database/prisma/schema.prisma +++ b/packages/database/prisma/schema.prisma @@ -39,7 +39,14 @@ model Tenant { timezone String @default("America/Sao_Paulo") locale String @default("pt-BR") billingCurrency String @default("BRL") @map("billing_currency") - telephonyDomain String? @map("telephony_domain") + // Domínio SIP deste tenant (secao 178, docs/EXTENSIONS.md) — precisa ser + // único: é a chave que `b2bcall-fs-config` usa pra achar QUAL tenant é + // dono de um REGISTER/directory lookup (`Tenant.findFirst({ + // telephonyDomain: domain })`). Antes desta constraint, todo tenant + // nascia com o mesmo valor fixo ("b2bcall.local") — achado real + // reportado pelo usuário: isolamento de PABX por tenant (call groups, + // filas, IVR) não funciona sem um domínio de verdade por tenant. + telephonyDomain String? @unique @map("telephony_domain") planId String @map("plan_id") @db.Uuid aiPrivacyLevel AIPrivacyLevel @default(AI_OFF) @map("ai_privacy_level") // null = usa o PriceBook/RateDeck com isDefault=true (agente.md secao @@ -303,6 +310,15 @@ model Extension { sofiaProfile String @default("internal") @map("sofia_profile") codecs String @default("PCMU,PCMA,OPUS") @map("codecs") + // Grupo de captura (agente.md secao 178, achado real reportado pelo + // usuário: sem isto, qualquer ramal consegue capturar a chamada de + // qualquer outro — precisa isolar por grupo pra virar um PABX de + // verdade). Vira a variável `call-group` no directory XML + // (packages/telephony); o *8 de group pickup em si é uma extensão de + // dialplan (Telefonia > Dialplan, já configurável por tenant), não + // precisa de coluna própria pra isso. + callGroup String? @map("call_group") + maxRegistrations Int @default(1) @map("max_registrations") enabled Boolean @default(true) diff --git a/packages/telephony/src/directory-xml.ts b/packages/telephony/src/directory-xml.ts index 40e8eaf..c97895b 100644 --- a/packages/telephony/src/directory-xml.ts +++ b/packages/telephony/src/directory-xml.ts @@ -18,6 +18,12 @@ export interface DirectoryUserParams { callerIdNumber?: string; tenantId: string; extensionId: string; + /** Grupo de captura (secao 178) — vira a variable `call-group` no + * directory; o *8 de group pickup no dialplan usa essa mesma variable + * pra achar um canal tocando no mesmo grupo (`pickup` da FreeSWITCH + * exige `call-group` batendo, senão qualquer ramal capturaria a + * chamada de qualquer outro — achado real reportado pelo usuário). */ + callGroup?: string | null; } /** @@ -54,6 +60,7 @@ export function buildDirectoryUserXml(params: DirectoryUserParams): string { + ${params.callGroup ? `` : ""}