feat(rotas-entrada): dropdown de destino real (ramal/IVR/fila/grupo)

Pedido do usuário: "em vez de o campo de destino ser aberto tem que ter
um dropdown listando todos os ramais do tennant e tb todas ivr e filas
e grupos de ramais do tennant" — até aqui o destino era texto livre.

Construir o dropdown revelou que fila e grupo NUNCA tinham sido
implementados de verdade como destino possível — só ramal/IVR
funcionavam. Oferecer as duas opções sem o mecanismo por trás seria
mostrar um dropdown mentiroso, então:

InboundRoute.destinationType (novo enum EXTENSION/IVR/QUEUE/CALL_GROUP)
decide como buildInboundRouteXml interpreta o destino:
- EXTENSION/IVR: inalterado, o mesmo transfer já testado (PHASE 56/58).
- QUEUE: destinationNumber guarda o Queue.id; a resolução de entrada
  emite `answer` + `callcenter data="<queueId>@<domain>"` direto, sem
  tocar no dialplan "default". `callcenter` entrou no allowlist de
  applications com o mesmo risco zero de `pickup`.
- CALL_GROUP: destinationNumber guarda o Extension.callGroup; a
  resolução consulta AGORA (nunca um snapshot salvo) todos os ramais
  com esse callGroup e emite um `bridge` multi-leg — toca todos ao
  mesmo tempo, quem atender primeiro cancela os outros. Trocar quem
  está no grupo depois de criar a rota já vale na próxima chamada.

Testado ponta a ponta com chamadas reais nos 2 mecanismos novos: fila —
softphone externo discou o DID, show channels confirmou a chamada
dentro da application callcenter com o nome certo da fila; grupo — 2
ramais reais no mesmo callGroup, a chamada tocou nos DOIS ao mesmo
tempo (mesmo call_uuid, ambas RINGING), atender em um cancelou o outro
automaticamente — ring group de verdade.

Tela: "Tipo de destino" + um segundo dropdown com as opções reais do
tenant pra cada tipo (ramais, menus de IVR, filas, ou os valores
distintos de callGroup já usados em algum ramal). Testado com
Playwright: os 4 tipos aparecem, e trocar o tipo atualiza as opções do
segundo dropdown corretamente.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BFaBaBSQGhyXGEgtTYZGV8
This commit is contained in:
2026-08-30 20:13:55 -03:00
parent 1b05d93c48
commit 738bfa4f36
10 changed files with 342 additions and 58 deletions

View File

@@ -0,0 +1,4 @@
-- PHASE 62: dropdown de destino (ramal/IVR/fila/grupo) na rota de entrada
CREATE TYPE "inbound_route_destination_type" AS ENUM ('EXTENSION', 'IVR', 'QUEUE', 'CALL_GROUP');
ALTER TABLE "inbound_routes" ADD COLUMN "destination_type" "inbound_route_destination_type" NOT NULL DEFAULT 'EXTENSION';

View File

@@ -445,6 +445,15 @@ model Trunk {
@@map("trunks")
}
enum InboundRouteDestinationType {
EXTENSION
IVR
QUEUE
CALL_GROUP
@@map("inbound_route_destination_type")
}
// Rota de entrada por DID (PHASE 56) — achado real: nenhuma chamada que
// chega por um tronco carrega `b2bcall_tenant_id` hoje (só ramal
// registrado e discagem de saída setam essa variable), então uma chamada
@@ -462,13 +471,25 @@ model InboundRoute {
didNumber String @unique @map("did_number")
description String?
// Contexto de dialplan do TENANT DONO que recebe a chamada depois da
// resolução (ex.: "default" pra cair direto na discagem interna já
// existente, ou um contexto de IVR dedicado) + o destination_number
// sintético usado dentro dele (número de ramal real, ou um destino
// reservado do menu de IVR).
destinationContext String @default("default") @map("destination_context")
destinationNumber String @map("destination_number")
// PHASE 62 — achado real reportado pelo usuário: o destino de uma rota
// de entrada era um campo de texto livre, sem dropdown de ramal/IVR/
// fila/grupo. `destinationType` decide como `buildInboundRouteXml`
// (packages/telephony) interpreta `destinationNumber`:
// EXTENSION -> número do ramal (transfer pro contexto default, já
// testado ponta a ponta na PHASE 56)
// IVR -> sempre IVR_ENTRY_DESTINATION; `destinationContext`
// é o `IvrMenu.context`
// QUEUE -> `Queue.id` (o inbound route XML já emite `answer` +
// `callcenter`, sem precisar de nenhuma regra nova em
// "default" nem tocar no editor de dialplan)
// CALL_GROUP -> o valor de `Extension.callGroup` — resolvido pra
// lista de ramais TODA VEZ que a chamada entra (nunca
// um snapshot: `apps/freeswitch-config` faz a query na
// hora, então trocar quem está no grupo depois de criar
// a rota já vale na PRÓXIMA chamada, sem re-salvar nada)
destinationType InboundRouteDestinationType @default(EXTENSION) @map("destination_type")
destinationContext String @default("default") @map("destination_context")
destinationNumber String @map("destination_number")
enabled Boolean @default(true)

View File

@@ -35,6 +35,9 @@ export const ALLOWED_DIALPLAN_APPLICATIONS = [
// timeout terminators file invalid_file var_name regexp
// digit_timeout`), nunca executa nada; mesmo padrão de risco zero.
"play_and_get_digits",
// Rota de entrada pra fila (PHASE 62) — só recebe `<queueId>@<domain>`
// como texto, nunca um comando; mesmo padrão de risco zero.
"callcenter",
] as const;
export type AllowedDialplanApplication = (typeof ALLOWED_DIALPLAN_APPLICATIONS)[number];
@@ -161,22 +164,47 @@ function actionsXml(tag: "action" | "anti-action", actions: DialplanAction[] | u
* default (`$${domain}` do vars.xml, ex.: "b2bcall.local"), nunca pro
* domínio de verdade do tenant — a leg de entrada não é um ramal
* registrado, então nada preenche essa variable sozinho.
*
* PHASE 62 (dropdown de destino real: ramal/IVR/fila/grupo) — EXTENSION
* e IVR continuam usando exatamente o `transfer` acima (reaproveitam o
* dialplan do tenant sem mudança nenhuma). QUEUE e CALL_GROUP nunca
* passam pelo "default": a própria resolução de entrada já emite a
* action final, porque nenhum dos dois é "discar um número" — fila é
* `callcenter` de verdade, grupo é `bridge` simultâneo pra vários ramais.
* `groupMembers` é resolvido pelo CHAMADOR (apps/freeswitch-config, com
* acesso ao banco) toda vez que uma chamada de entrada chega — nunca um
* snapshot salvo, então trocar quem está no grupo já vale na próxima
* chamada, sem precisar re-salvar a rota.
*/
export function buildInboundRouteXml(params: {
tenantId: string;
domain: string;
destinationNumber: string;
destinationContext: string;
}): string {
export function buildInboundRouteXml(
params:
| { tenantId: string; domain: string; destinationType: "EXTENSION" | "IVR"; destinationNumber: string; destinationContext: string }
| { tenantId: string; domain: string; destinationType: "QUEUE"; queueId: string }
| { tenantId: string; domain: string; destinationType: "CALL_GROUP"; groupMembers: string[] },
): string {
const setup = ` <action application="set" data="b2bcall_tenant_id=${xmlEscape(params.tenantId)}"/>
<action application="set" data="domain_name=${xmlEscape(params.domain)}"/>`;
let action: string;
if (params.destinationType === "QUEUE") {
action = ` <action application="answer"/>
<action application="callcenter" data="${xmlEscape(params.queueId)}@${xmlEscape(params.domain)}"/>`;
} else if (params.destinationType === "CALL_GROUP") {
const legs = params.groupMembers.map((num) => `user/${xmlEscape(num)}@${xmlEscape(params.domain)}`).join(",");
action = ` <action application="answer"/>
<action application="bridge" data="${legs}"/>`;
} else {
action = ` <action application="transfer" data="${xmlEscape(params.destinationNumber)} XML ${xmlEscape(params.destinationContext)}"/>`;
}
return `<?xml version="1.0" encoding="UTF-8"?>
<document type="freeswitch/xml">
<section name="dialplan">
<context name="inbound">
<extension name="inbound-route">
<condition>
<action application="set" data="b2bcall_tenant_id=${xmlEscape(params.tenantId)}"/>
<action application="set" data="domain_name=${xmlEscape(params.domain)}"/>
<action application="transfer" data="${xmlEscape(params.destinationNumber)} XML ${xmlEscape(params.destinationContext)}"/>
${setup}
${action}
</condition>
</extension>
</context>