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