From 3fe571bfb00661ef85a68615b84892a564c655d9 Mon Sep 17 00:00:00 2001 From: Matheus Date: Mon, 31 Aug 2026 14:01:03 -0300 Subject: [PATCH] =?UTF-8?q?feat(telefonia):=20edi=C3=A7=C3=A3o=20de=20rama?= =?UTF-8?q?l=20(nome/caller=20ID/contexto/codecs/grupo)=20junto=20da=20sen?= =?UTF-8?q?ha=20SIP?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pedido do usuário: abrir a edição completa do ramal no mesmo lugar de ver/redefinir a senha, em vez de só um campo (grupo de captura) editável isoladamente. UpdateExtensionDto ganha name/context/codecs (só callGroup e caller ID eram editáveis antes); codecs valida contra uma lista fixa e agora realmente vira absolute_codec_string no directory XML — antes a coluna existia no schema mas não tinha efeito nenhum no FreeSWITCH. Grupo de captura vira um input com datalist alimentado pelos grupos já usados em outros ramais do tenant, em vez de digitar de novo do zero. Testado ponta a ponta com Playwright (tenant/ramais descartáveis): grupo reaproveitado de outro ramal, edição de nome/caller ID/contexto, remoção de um codec — tudo persistido e refletido na tela depois de salvar. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01BFaBaBSQGhyXGEgtTYZGV8 --- TODO.md | 48 ++++ .../extensions/dto/create-extension.dto.ts | 23 ++ .../src/extensions/extensions.controller.ts | 3 + apps/freeswitch-config/src/main.ts | 1 + .../telefonia/ramais/[id]/actions-panel.tsx | 235 ++++++++++++++---- .../app/app/telefonia/ramais/[id]/page.tsx | 65 ++--- .../src/app/app/telefonia/ramais/actions.ts | 7 +- apps/frontend/src/lib/extension-types.ts | 3 + packages/telephony/src/directory-xml.ts | 6 + 9 files changed, 296 insertions(+), 95 deletions(-) diff --git a/TODO.md b/TODO.md index f6637a3..db6042f 100644 --- a/TODO.md +++ b/TODO.md @@ -2636,6 +2636,54 @@ do usuário: "o widget não conecta mais fica só cinza") em 4 execuções seguidas) e o status evolui `connecting → connected` sem nunca voltar pra cinza. +## PHASE 73 — Editar ramal (nome/caller ID/contexto/codecs/grupo) no +mesmo lugar da senha (pedido do usuário: "quando clicar no ramal pra +editar a senha ou ver a senha atual já abra pra alterar o nome, o +callerid, contexto, a lista de codec disponível e grupo de captura com +uma lista dos que já foram criados, e um botão de salvar") +- [x] `UpdateExtensionDto` só aceitava `callerIdName`/`callerIdNumber`/ + `maxRegistrations`/`callGroup` — `name`, `context` e `codecs` + existiam no schema (`codecs` com default `"PCMU,PCMA,OPUS"`) mas + não tinham caminho de update nenhum. Adicionados os três, `codecs` + validado contra a lista fixa `AVAILABLE_CODECS` (PCMU/PCMA/OPUS/ + G722/G729/GSM, mesma constante espelhada no frontend). +- [x] **Achado real construindo isto**: a coluna `codecs` nunca tinha + sido ligada em nada — `buildDirectoryUserXml` (directory XML que o + FreeSWITCH consulta via mod_xml_curl) não emitia variável nenhuma + pra ela, então mudar o campo não teria efeito real nenhum na + negociação SDP do ramal. Adicionada a variável `absolute_codec_string` + (nome padrão do FreeSWITCH pra restringir/ordenar os codecs de um + usuário específico) em `packages/telephony/src/directory-xml.ts`, + e `apps/freeswitch-config/src/main.ts` passa `extension.codecs` + pra ela — agora o campo realmente controla o que o FreeSWITCH + oferece nesse ramal, não só um valor decorativo no banco. +- [x] Frontend: painel único "Configuração e senha SIP" em + `ramais/[id]/page.tsx` — a antiga `dl` só-leitura virou um + formulário (`ExtensionEditForm` em `actions-panel.tsx`) com nome, + caller ID (nome+número), contexto, checkboxes dos codecs + disponíveis e grupo de captura, tudo num "Editar configuração" + + Salvar, bem ao lado de "Ver senha atual"/"Redefinir senha" (mesmo + painel, não mais escondido em telas separadas). Grupo de captura + usa um `` com `` alimentado pelos grupos já + cadastrados nos OUTROS ramais do tenant (busca `GET /extensions` + na própria server component da página) — escolhe um já existente + ou digita um novo, sem re-digitar do zero. Substituiu o antigo + `CallGroupEditAction` (isolado, só esse campo). +- [x] `callerIdName`/`callerIdNumber` agora aceitam `null` explícito pra + limpar o valor (antes só dava pra trocar por outro texto, nunca + voltar a vazio, porque o form mandava `undefined` — que o backend + trata como "não mudar nada" — em vez de `null`). Mesmo padrão que + `callGroup` já usava. +- [x] Testado ponta a ponta com Playwright: tenant descartável com dois + ramais, um com grupo de captura "vendas"; abriu o segundo ramal, + "Editar configuração" mostrou "vendas" na lista suspensa (vindo do + OUTRO ramal), editou nome/caller ID/contexto, desmarcou OPUS — + salvou e a tela voltou mostrando exatamente os valores novos + (`codecs: "PCMU,PCMA"`, grupo "vendas" reaproveitado). Precisou + `systemctl restart b2bcall-api.service` pra pegar o DTO novo — + `apps/api` roda `tsc` uma vez no start (não é watch mode), então + mudança de `src/` só entra em produção depois do restart. + --- ## 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 e777ab3..d714d01 100644 --- a/apps/api/src/extensions/dto/create-extension.dto.ts +++ b/apps/api/src/extensions/dto/create-extension.dto.ts @@ -1,5 +1,13 @@ import { IsIn, IsInt, IsOptional, IsString, Matches, Max, Min, MaxLength } from "class-validator"; +/** Codecs de áudio suportados pelo perfil Sofia `internal` (mesma lista + * usada em `packages/telephony/src/directory-xml.ts::absolute_codec_string` + * — manter as duas em sincronia). */ +export const AVAILABLE_CODECS = ["PCMU", "PCMA", "OPUS", "G722", "G729", "GSM"] as const; + +const CODEC_ALTERNATION = AVAILABLE_CODECS.join("|"); +const CODECS_REGEX = new RegExp(`^(${CODEC_ALTERNATION})(,(${CODEC_ALTERNATION}))*$`); + export class CreateExtensionDto { @IsString() @Matches(/^[0-9]{2,10}$/, { message: "number deve ter só dígitos (2 a 10)" }) @@ -45,6 +53,11 @@ export class CreateExtensionDto { } export class UpdateExtensionDto { + @IsOptional() + @IsString() + @MaxLength(120) + name?: string; + @IsOptional() @IsString() @MaxLength(80) @@ -55,6 +68,16 @@ export class UpdateExtensionDto { @Matches(/^[0-9]{2,20}$/) callerIdNumber?: string; + @IsOptional() + @IsString() + @MaxLength(80) + context?: string; + + @IsOptional() + @IsString() + @Matches(CODECS_REGEX, { message: `codecs deve ser uma lista separada por vírgula entre: ${AVAILABLE_CODECS.join(", ")}` }) + codecs?: string; + @IsOptional() @IsInt() @Min(1) diff --git a/apps/api/src/extensions/extensions.controller.ts b/apps/api/src/extensions/extensions.controller.ts index ca2d711..5f15775 100644 --- a/apps/api/src/extensions/extensions.controller.ts +++ b/apps/api/src/extensions/extensions.controller.ts @@ -209,8 +209,11 @@ export class ExtensionsController { tx.extension.updateMany({ where: { id, tenantId, deletedAt: null }, data: { + ...(dto.name !== undefined ? { name: dto.name } : {}), ...(dto.callerIdName !== undefined ? { callerIdName: dto.callerIdName } : {}), ...(dto.callerIdNumber !== undefined ? { callerIdNumber: dto.callerIdNumber } : {}), + ...(dto.context !== undefined ? { context: dto.context } : {}), + ...(dto.codecs !== undefined ? { codecs: dto.codecs } : {}), ...(dto.maxRegistrations !== undefined ? { maxRegistrations: dto.maxRegistrations } : {}), ...(dto.callGroup !== undefined ? { callGroup: dto.callGroup } : {}), }, diff --git a/apps/freeswitch-config/src/main.ts b/apps/freeswitch-config/src/main.ts index c295cda..48bd355 100644 --- a/apps/freeswitch-config/src/main.ts +++ b/apps/freeswitch-config/src/main.ts @@ -78,6 +78,7 @@ async function resolveDirectoryXml(user: string | undefined, domain: string | un tenantId: extension.tenantId, extensionId: extension.id, callGroup: extension.callGroup, + codecs: extension.codecs, }); } 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 ab62cbb..ed5b0c3 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,12 +2,198 @@ import { useState, useTransition } from "react"; import { useRouter } from "next/navigation"; -import { Eye, KeyRound, Trash2 } from "lucide-react"; +import { Eye, KeyRound, Pencil, Trash2 } from "lucide-react"; import { Button } from "@/components/ui/button"; -import { Input } from "@/components/ui/input"; +import { Input, FieldLabel } from "@/components/ui/input"; import { SecretReveal } from "@/components/ui/secret-reveal"; +import { AVAILABLE_CODECS, type Extension } from "@/lib/extension-types"; import { resetExtensionPassword, revealExtensionPassword, deleteExtension, updateExtension } from "../actions"; +/** Formulário único de edição (nome, caller ID, contexto, codecs, grupo + * de captura) — pedido do usuário: "quando clicar no ramal para editar a + * senha ou ver a senha atual já abra pra alterar" o resto da configuração + * junto, num só lugar com um botão de salvar. */ +export function ExtensionEditForm({ extension, existingCallGroups }: { extension: Extension; existingCallGroups: string[] }) { + const router = useRouter(); + const [editing, setEditing] = useState(false); + const [name, setName] = useState(extension.name); + const [callerIdName, setCallerIdName] = useState(extension.callerIdName ?? ""); + const [callerIdNumber, setCallerIdNumber] = useState(extension.callerIdNumber ?? ""); + const [context, setContext] = useState(extension.context); + const [codecs, setCodecs] = useState(extension.codecs.split(",").map((c) => c.trim()).filter(Boolean)); + const [callGroup, setCallGroup] = useState(extension.callGroup ?? ""); + const [pending, startTransition] = useTransition(); + const [error, setError] = useState(null); + + function toggleCodec(codec: string) { + setCodecs((prev) => (prev.includes(codec) ? prev.filter((c) => c !== codec) : [...prev, codec])); + } + + function onSave() { + setError(null); + if (!name.trim()) { + setError("Dê um nome ao ramal."); + return; + } + if (codecs.length === 0) { + setError("Selecione ao menos um codec."); + return; + } + startTransition(async () => { + const result = await updateExtension(extension.id, { + name: name.trim(), + callerIdName: callerIdName.trim() || null, + callerIdNumber: callerIdNumber.trim() || null, + context: context.trim() || undefined, + codecs: codecs.join(","), + callGroup: callGroup.trim() || null, + }); + if (!result.ok) { + setError(result.error); + return; + } + setEditing(false); + router.refresh(); + }); + } + + function onCancel() { + setName(extension.name); + setCallerIdName(extension.callerIdName ?? ""); + setCallerIdNumber(extension.callerIdNumber ?? ""); + setContext(extension.context); + setCodecs(extension.codecs.split(",").map((c) => c.trim()).filter(Boolean)); + setCallGroup(extension.callGroup ?? ""); + setError(null); + setEditing(false); + } + + if (!editing) { + return ( +
+
+
Nome
+
{extension.name}
+
+
+
Caller ID
+
+ {extension.callerIdName || extension.callerIdNumber + ? `${extension.callerIdName ?? ""} ${extension.callerIdNumber ? `<${extension.callerIdNumber}>` : ""}`.trim() + : "—"} +
+
+
+
Contexto
+
{extension.context}
+
+
+
Perfil Sofia
+
{extension.sofiaProfile}
+
+
+
Codecs
+
{extension.codecs}
+
+
+
Grupo de captura
+
{extension.callGroup ?? "— (nenhum)"}
+
+
+ +
+
+ ); + } + + return ( +
+
+
+ Nome + setName(e.target.value)} disabled={pending} /> +
+
+ Contexto + setContext(e.target.value)} className="font-mono" disabled={pending} /> +
+
+ Caller ID — nome + setCallerIdName(e.target.value)} disabled={pending} /> +
+
+ Caller ID — número + setCallerIdNumber(e.target.value.replace(/[^0-9]/g, ""))} + className="font-mono" + disabled={pending} + /> +
+
+ +
+ Codecs disponíveis +
+ {AVAILABLE_CODECS.map((codec) => ( + + ))} +
+
+ +
+ Grupo de captura + setCallGroup(e.target.value)} + placeholder="Sem grupo" + className="font-mono" + disabled={pending} + /> + + {existingCallGroups.map((group) => ( + +

+ Escolha um grupo já existente na lista ou digite um novo. Ramais no mesmo grupo podem capturar a chamada um + do outro (*8). +

+
+ + {error && ( +

+ {error} +

+ )} + +
+ + +
+
+ ); +} + /** 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 @@ -48,51 +234,6 @@ export function RevealPasswordAction({ extensionId }: { extensionId: string }) { ); } -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(); const [error, setError] = useState(null); 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 a545434..2da9d58 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, RevealPasswordAction, CallGroupEditAction, DeleteExtensionAction } from "./actions-panel"; +import { ResetPasswordAction, RevealPasswordAction, ExtensionEditForm, DeleteExtensionAction } from "./actions-panel"; interface Me { tenant: { name: string; code: string } | null; @@ -25,6 +25,8 @@ export default async function ExtensionDetailPage({ params }: { params: Promise< throw err; } const me = await apiFetch("/auth/me", session.accessToken); + const allExtensions = await apiFetch("/extensions", session.accessToken); + const existingCallGroups = [...new Set(allExtensions.map((e) => e.callGroup).filter((g): g is string => !!g))].sort(); return (
@@ -46,54 +48,25 @@ export default async function ExtensionDetailPage({ params }: { params: Promise< {extension.domain}

- - -
-
-
Caller ID
-
- {extension.callerIdName || extension.callerIdNumber - ? `${extension.callerIdName ?? ""} ${extension.callerIdNumber ? `<${extension.callerIdNumber}>` : ""}`.trim() - : "—"} -
-
-
-
Contexto
-
{extension.context}
-
-
-
Perfil Sofia
-
{extension.sofiaProfile}
-
-
-
Codecs
-
{extension.codecs}
-
-
-
Registros simultâneos
-
{extension.maxRegistrations}
-
-
-
Grupo de captura
-
- -
-
-
-
Criado em
-
{formatDate(extension.createdAt)}
-
-
-
- -
- - +
+ +
+ + Registros simultâneos: {extension.maxRegistrations} + + Criado em {formatDate(extension.createdAt)} +
+
+
+ + +
+
diff --git a/apps/frontend/src/app/app/telefonia/ramais/actions.ts b/apps/frontend/src/app/app/telefonia/ramais/actions.ts index 89a4ef9..1e22e76 100644 --- a/apps/frontend/src/app/app/telefonia/ramais/actions.ts +++ b/apps/frontend/src/app/app/telefonia/ramais/actions.ts @@ -74,8 +74,11 @@ export async function revealExtensionPassword(id: string): Promise<{ ok: true; s } export interface UpdateExtensionInput { - callerIdName?: string; - callerIdNumber?: string; + name?: string; + callerIdName?: string | null; + callerIdNumber?: string | null; + context?: string; + codecs?: string; maxRegistrations?: number; callGroup?: string | null; } diff --git a/apps/frontend/src/lib/extension-types.ts b/apps/frontend/src/lib/extension-types.ts index 7bc630f..19cb073 100644 --- a/apps/frontend/src/lib/extension-types.ts +++ b/apps/frontend/src/lib/extension-types.ts @@ -1,3 +1,6 @@ +/** Mesma lista de `apps/api/src/extensions/dto/create-extension.dto.ts::AVAILABLE_CODECS` — manter em sincronia. */ +export const AVAILABLE_CODECS = ["PCMU", "PCMA", "OPUS", "G722", "G729", "GSM"] as const; + export interface Extension { id: string; tenantId: string; diff --git a/packages/telephony/src/directory-xml.ts b/packages/telephony/src/directory-xml.ts index 36f20b3..32cef7c 100644 --- a/packages/telephony/src/directory-xml.ts +++ b/packages/telephony/src/directory-xml.ts @@ -30,6 +30,11 @@ export interface DirectoryUserParams { * contra a documentação do FreeSWITCH antes de close (a primeira versão * usava `call-group`, que não é lido por nada). */ callGroup?: string | null; + /** Lista de codecs de áudio permitidos pro ramal, separados por vírgula + * (ex.: "PCMU,PCMA,OPUS") — vira `absolute_codec_string`, a variável + * que o FreeSWITCH usa pra restringir/ordenar os codecs oferecidos na + * negociação SDP pra este usuário especificamente. */ + codecs?: string; } /** @@ -67,6 +72,7 @@ export function buildDirectoryUserXml(params: DirectoryUserParams): string { ${params.callGroup ? `` : ""} + ${params.codecs ? `` : ""}