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 (
+
+ 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 ? `` : ""}