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

@@ -1,4 +1,6 @@
import { IsBoolean, IsOptional, IsString, Matches, MaxLength } from "class-validator"; import { IsBoolean, IsIn, IsOptional, IsString, Matches, MaxLength } from "class-validator";
const DESTINATION_TYPES = ["EXTENSION", "IVR", "QUEUE", "CALL_GROUP"] as const;
export class CreateInboundRouteDto { export class CreateInboundRouteDto {
// Numero como o provedor de troncos manda no INVITE (destination_number) — // Numero como o provedor de troncos manda no INVITE (destination_number) —
@@ -14,16 +16,24 @@ export class CreateInboundRouteDto {
@MaxLength(255) @MaxLength(255)
description?: string; description?: string;
// PHASE 62 — decide como `destinationNumber`/`destinationContext` são
// interpretados na resolução de entrada (ver dialplan-xml.ts):
// EXTENSION/IVR continuam discando pelo dialplan do tenant; QUEUE/
// CALL_GROUP são resolvidos direto, sem tocar no dialplan.
@IsOptional()
@IsIn(DESTINATION_TYPES)
destinationType?: (typeof DESTINATION_TYPES)[number];
// Contexto de dialplan do PRÓPRIO tenant que recebe a chamada depois da // Contexto de dialplan do PRÓPRIO tenant que recebe a chamada depois da
// resolução — normalmente "default" (cai na discagem interna existente), // resolução — só usado quando destinationType é EXTENSION/IVR.
// ou um contexto de IVR dedicado.
@IsOptional() @IsOptional()
@IsString() @IsString()
@MaxLength(80) @MaxLength(80)
destinationContext?: string; destinationContext?: string;
// destination_number sintético usado dentro desse contexto — numero de // Interpretação depende de destinationType: número de ramal (EXTENSION),
// ramal real, ou um destino reservado do menu de IVR. // IVR_ENTRY_DESTINATION fixo (IVR), Queue.id (QUEUE), ou
// Extension.callGroup (CALL_GROUP).
@IsString() @IsString()
@Matches(/^[a-zA-Z0-9_-]{1,40}$/, { message: "destinationNumber deve ser alfanumérico (1 a 40 caracteres)" }) @Matches(/^[a-zA-Z0-9_-]{1,40}$/, { message: "destinationNumber deve ser alfanumérico (1 a 40 caracteres)" })
destinationNumber!: string; destinationNumber!: string;
@@ -39,6 +49,10 @@ export class UpdateInboundRouteDto {
@MaxLength(255) @MaxLength(255)
description?: string; description?: string;
@IsOptional()
@IsIn(DESTINATION_TYPES)
destinationType?: (typeof DESTINATION_TYPES)[number];
@IsOptional() @IsOptional()
@IsString() @IsString()
@MaxLength(80) @MaxLength(80)

View File

@@ -47,6 +47,7 @@ export class InboundRoutesController {
tenantId, tenantId,
didNumber: dto.didNumber, didNumber: dto.didNumber,
description: dto.description, description: dto.description,
destinationType: dto.destinationType ?? "EXTENSION",
destinationContext: dto.destinationContext ?? "default", destinationContext: dto.destinationContext ?? "default",
destinationNumber: dto.destinationNumber, destinationNumber: dto.destinationNumber,
enabled: dto.enabled ?? true, enabled: dto.enabled ?? true,
@@ -105,6 +106,7 @@ export class InboundRoutesController {
where: { id, tenantId, deletedAt: null }, where: { id, tenantId, deletedAt: null },
data: { data: {
...(dto.description !== undefined ? { description: dto.description } : {}), ...(dto.description !== undefined ? { description: dto.description } : {}),
...(dto.destinationType !== undefined ? { destinationType: dto.destinationType } : {}),
...(dto.destinationContext !== undefined ? { destinationContext: dto.destinationContext } : {}), ...(dto.destinationContext !== undefined ? { destinationContext: dto.destinationContext } : {}),
...(dto.destinationNumber !== undefined ? { destinationNumber: dto.destinationNumber } : {}), ...(dto.destinationNumber !== undefined ? { destinationNumber: dto.destinationNumber } : {}),
...(dto.enabled !== undefined ? { enabled: dto.enabled } : {}), ...(dto.enabled !== undefined ? { enabled: dto.enabled } : {}),

View File

@@ -108,9 +108,34 @@ async function resolveInboundRouteXml(didNumber: string | undefined): Promise<st
tx.inboundRoute.findFirst({ where: { tenantId: tenant.id, didNumber, enabled: true, deletedAt: null } }), tx.inboundRoute.findFirst({ where: { tenantId: tenant.id, didNumber, enabled: true, deletedAt: null } }),
); );
if (route) { if (route) {
const domain = tenant.telephonyDomain!;
// PHASE 62: fila e grupo nunca "discam um número" no dialplan do
// tenant — a resolução de entrada já emite a action final.
if (route.destinationType === "QUEUE") {
return buildInboundRouteXml({ tenantId: tenant.id, domain, destinationType: "QUEUE", queueId: route.destinationNumber });
}
if (route.destinationType === "CALL_GROUP") {
// Resolvido AGORA, nunca um snapshot salvo — trocar quem está no
// grupo depois de criar a rota já vale na PRÓXIMA chamada.
const members = await withTenantContext(prisma, tenant.id, (tx) =>
tx.extension.findMany({
where: { tenantId: tenant.id, callGroup: route.destinationNumber, enabled: true, deletedAt: null },
select: { number: true },
}),
);
return buildInboundRouteXml({ return buildInboundRouteXml({
tenantId: tenant.id, tenantId: tenant.id,
domain: tenant.telephonyDomain!, domain,
destinationType: "CALL_GROUP",
groupMembers: members.map((m) => m.number),
});
}
return buildInboundRouteXml({
tenantId: tenant.id,
domain,
destinationType: route.destinationType,
destinationNumber: route.destinationNumber, destinationNumber: route.destinationNumber,
destinationContext: route.destinationContext, destinationContext: route.destinationContext,
}); });

View File

@@ -3,7 +3,7 @@
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
import { requireSession } from "@/lib/session"; import { requireSession } from "@/lib/session";
import { apiFetch, ApiError } from "@/lib/api"; import { apiFetch, ApiError } from "@/lib/api";
import type { InboundRoute } from "@/lib/callcenter-types"; import type { InboundRoute, InboundRouteDestinationType } from "@/lib/callcenter-types";
function extractErrorMessage(err: unknown): string { function extractErrorMessage(err: unknown): string {
if (err instanceof ApiError) { if (err instanceof ApiError) {
@@ -22,6 +22,7 @@ function extractErrorMessage(err: unknown): string {
export interface CreateInboundRouteInput { export interface CreateInboundRouteInput {
didNumber: string; didNumber: string;
description?: string; description?: string;
destinationType: InboundRouteDestinationType;
destinationContext?: string; destinationContext?: string;
destinationNumber: string; destinationNumber: string;
} }

View File

@@ -1,10 +1,16 @@
import { requireSession } from "@/lib/session"; import { requireSession } from "@/lib/session";
import { apiFetch } from "@/lib/api"; import { apiFetch } from "@/lib/api";
import type { InboundRoute } from "@/lib/callcenter-types"; import type { InboundRoute, IvrMenu, Queue } from "@/lib/callcenter-types";
import type { Extension } from "@/lib/extension-types";
import { RotasEntradaView } from "./rotas-entrada-view"; import { RotasEntradaView } from "./rotas-entrada-view";
export default async function RotasEntradaPage() { export default async function RotasEntradaPage() {
const session = await requireSession(); const session = await requireSession();
const routes = await apiFetch<InboundRoute[]>("/inbound-routes", session.accessToken); const [routes, extensions, ivrMenus, queues] = await Promise.all([
return <RotasEntradaView routes={routes} />; apiFetch<InboundRoute[]>("/inbound-routes", session.accessToken),
apiFetch<Extension[]>("/extensions", session.accessToken),
apiFetch<IvrMenu[]>("/ivr-menus", session.accessToken),
apiFetch<Queue[]>("/queues", session.accessToken),
]);
return <RotasEntradaView routes={routes} extensions={extensions} ivrMenus={ivrMenus} queues={queues} />;
} }

View File

@@ -1,29 +1,83 @@
"use client"; "use client";
import { useState, useTransition } from "react"; import { useMemo, useState, useTransition } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { PhoneIncoming, Plus, Trash2, X } from "lucide-react"; import { PhoneIncoming, Plus, Trash2, X } from "lucide-react";
import { Panel, PanelHeader } from "@/components/ui/panel"; import { Panel, PanelHeader } from "@/components/ui/panel";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input, FieldLabel } from "@/components/ui/input"; import { Input, Select, FieldLabel } from "@/components/ui/input";
import { Pill } from "@/components/ui/pill"; import { Pill } from "@/components/ui/pill";
import { EmptyState, TBody, TD, TH, THead, TR, Table } from "@/components/ui/table"; import { EmptyState, TBody, TD, TH, THead, TR, Table } from "@/components/ui/table";
import { formatDate } from "@/lib/format"; import { formatDate } from "@/lib/format";
import type { InboundRoute } from "@/lib/callcenter-types"; import {
INBOUND_ROUTE_DESTINATION_TYPES,
INBOUND_ROUTE_DESTINATION_TYPE_LABELS,
IVR_ENTRY_DESTINATION,
type InboundRoute,
type InboundRouteDestinationType,
type IvrMenu,
type Queue,
} from "@/lib/callcenter-types";
import type { Extension } from "@/lib/extension-types";
import { createInboundRoute, deleteInboundRoute } from "./actions"; import { createInboundRoute, deleteInboundRoute } from "./actions";
export function RotasEntradaView({ routes }: { routes: InboundRoute[] }) { function destinationLabel(
route: InboundRoute,
extensionsByNumber: Record<string, Extension>,
ivrMenusByContext: Record<string, IvrMenu>,
queuesById: Record<string, Queue>,
): string {
switch (route.destinationType) {
case "EXTENSION": {
const ext = extensionsByNumber[route.destinationNumber];
return ext ? `Ramal ${ext.number}${ext.name}` : `Ramal ${route.destinationNumber}`;
}
case "IVR": {
const menu = ivrMenusByContext[route.destinationContext];
return menu ? `IVR: ${menu.name}` : `IVR (${route.destinationContext})`;
}
case "QUEUE": {
const queue = queuesById[route.destinationNumber];
return queue ? `Fila: ${queue.name}` : `Fila (${route.destinationNumber})`;
}
case "CALL_GROUP":
return `Grupo: ${route.destinationNumber}`;
default:
return route.destinationNumber;
}
}
export function RotasEntradaView({
routes,
extensions,
ivrMenus,
queues,
}: {
routes: InboundRoute[];
extensions: Extension[];
ivrMenus: IvrMenu[];
queues: Queue[];
}) {
const [showForm, setShowForm] = useState(false); const [showForm, setShowForm] = useState(false);
const extensionsByNumber = useMemo(() => Object.fromEntries(extensions.map((e) => [e.number, e])), [extensions]);
const ivrMenusByContext = useMemo(() => Object.fromEntries(ivrMenus.map((m) => [m.context, m])), [ivrMenus]);
const queuesById = useMemo(() => Object.fromEntries(queues.map((q) => [q.id, q])), [queues]);
const callGroups = useMemo(
() => Array.from(new Set(extensions.map((e) => e.callGroup).filter((g): g is string => !!g))).sort(),
[extensions],
);
return ( return (
<div className="space-y-5"> <div className="space-y-5">
<div className="flex flex-wrap items-start justify-between gap-3"> <div className="flex flex-wrap items-start justify-between gap-3">
<div> <div>
<h1 className="text-lg font-semibold text-foreground">Rotas de entrada</h1> <h1 className="text-lg font-semibold text-foreground">Rotas de entrada</h1>
<p className="mt-1 max-w-2xl text-sm text-muted-foreground"> <p className="mt-1 max-w-2xl text-sm text-muted-foreground">
Cada número (DID) que um tronco recebe vira uma rota própria, apontando pra um ramal, fila ou IVR um Cada número (DID) que um tronco recebe vira uma rota própria, apontando pra um ramal, IVR, fila ou grupo
tronco pode carregar vários números com destinos diferentes. O número (DID) é único entre todos os de ramais um tronco pode carregar vários números com destinos diferentes. O número (DID) é único entre
tenants: é a única forma de saber de quem é uma chamada de entrada antes de identificar o tenant. todos os tenants: é a única forma de saber de quem é uma chamada de entrada antes de identificar o
tenant.
</p> </p>
</div> </div>
<Button type="button" onClick={() => setShowForm((s) => !s)}> <Button type="button" onClick={() => setShowForm((s) => !s)}>
@@ -32,7 +86,9 @@ export function RotasEntradaView({ routes }: { routes: InboundRoute[] }) {
</Button> </Button>
</div> </div>
{showForm && <NewInboundRouteForm onDone={() => setShowForm(false)} />} {showForm && (
<NewInboundRouteForm extensions={extensions} ivrMenus={ivrMenus} queues={queues} callGroups={callGroups} onDone={() => setShowForm(false)} />
)}
<Panel> <Panel>
<PanelHeader title="Rotas cadastradas" description={`${routes.length} rota(s) neste tenant`} /> <PanelHeader title="Rotas cadastradas" description={`${routes.length} rota(s) neste tenant`} />
@@ -62,9 +118,7 @@ export function RotasEntradaView({ routes }: { routes: InboundRoute[] }) {
</span> </span>
</TD> </TD>
<TD className="text-muted-foreground">{r.description ?? "—"}</TD> <TD className="text-muted-foreground">{r.description ?? "—"}</TD>
<TD className="font-mono text-muted-foreground"> <TD className="text-muted-foreground">{destinationLabel(r, extensionsByNumber, ivrMenusByContext, queuesById)}</TD>
{r.destinationNumber} <span className="text-xs">({r.destinationContext})</span>
</TD>
<TD> <TD>
<Pill tone={r.enabled ? "accent" : "neutral"}>{r.enabled ? "Ativa" : "Desativada"}</Pill> <Pill tone={r.enabled ? "accent" : "neutral"}>{r.enabled ? "Ativa" : "Desativada"}</Pill>
</TD> </TD>
@@ -82,25 +136,62 @@ export function RotasEntradaView({ routes }: { routes: InboundRoute[] }) {
); );
} }
function NewInboundRouteForm({ onDone }: { onDone: () => void }) { function NewInboundRouteForm({
extensions,
ivrMenus,
queues,
callGroups,
onDone,
}: {
extensions: Extension[];
ivrMenus: IvrMenu[];
queues: Queue[];
callGroups: string[];
onDone: () => void;
}) {
const [didNumber, setDidNumber] = useState(""); const [didNumber, setDidNumber] = useState("");
const [description, setDescription] = useState(""); const [description, setDescription] = useState("");
const [destinationNumber, setDestinationNumber] = useState(""); const [destinationType, setDestinationType] = useState<InboundRouteDestinationType>("EXTENSION");
const [destinationValue, setDestinationValue] = useState("");
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [pending, startTransition] = useTransition(); const [pending, startTransition] = useTransition();
function onTypeChange(next: InboundRouteDestinationType) {
setDestinationType(next);
setDestinationValue("");
}
function onSubmit(e: React.FormEvent) { function onSubmit(e: React.FormEvent) {
e.preventDefault(); e.preventDefault();
setError(null); setError(null);
if (!didNumber.trim() || !destinationNumber.trim()) { if (!didNumber.trim()) {
setError("DID e destino são obrigatórios."); setError("Informe o número (DID).");
return; return;
} }
if (!destinationValue) {
setError("Selecione um destino.");
return;
}
let destinationNumber = destinationValue;
let destinationContext: string | undefined;
if (destinationType === "IVR") {
const menu = ivrMenus.find((m) => m.id === destinationValue);
if (!menu) {
setError("Menu de IVR não encontrado.");
return;
}
destinationNumber = IVR_ENTRY_DESTINATION;
destinationContext = menu.context;
}
startTransition(async () => { startTransition(async () => {
const result = await createInboundRoute({ const result = await createInboundRoute({
didNumber: didNumber.trim(), didNumber: didNumber.trim(),
description: description.trim() || undefined, description: description.trim() || undefined,
destinationNumber: destinationNumber.trim(), destinationType,
destinationNumber,
destinationContext,
}); });
if (!result.ok) { if (!result.ok) {
setError(result.error); setError(result.error);
@@ -113,7 +204,7 @@ function NewInboundRouteForm({ onDone }: { onDone: () => void }) {
return ( return (
<Panel className="p-5"> <Panel className="p-5">
<form onSubmit={onSubmit} noValidate className="space-y-4"> <form onSubmit={onSubmit} noValidate className="space-y-4">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3"> <div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div> <div>
<FieldLabel htmlFor="ir-did">Número (DID)</FieldLabel> <FieldLabel htmlFor="ir-did">Número (DID)</FieldLabel>
<Input <Input
@@ -124,16 +215,6 @@ function NewInboundRouteForm({ onDone }: { onDone: () => void }) {
disabled={pending} disabled={pending}
/> />
</div> </div>
<div>
<FieldLabel htmlFor="ir-destination">Ramal de destino</FieldLabel>
<Input
id="ir-destination"
value={destinationNumber}
onChange={(e) => setDestinationNumber(e.target.value)}
placeholder="Ex.: 1001"
disabled={pending}
/>
</div>
<div> <div>
<FieldLabel htmlFor="ir-description">Descrição (opcional)</FieldLabel> <FieldLabel htmlFor="ir-description">Descrição (opcional)</FieldLabel>
<Input <Input
@@ -145,6 +226,67 @@ function NewInboundRouteForm({ onDone }: { onDone: () => void }) {
/> />
</div> </div>
</div> </div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div>
<FieldLabel htmlFor="ir-type">Tipo de destino</FieldLabel>
<Select
id="ir-type"
value={destinationType}
onChange={(e) => onTypeChange(e.target.value as InboundRouteDestinationType)}
disabled={pending}
>
{INBOUND_ROUTE_DESTINATION_TYPES.map((t) => (
<option key={t} value={t}>
{INBOUND_ROUTE_DESTINATION_TYPE_LABELS[t]}
</option>
))}
</Select>
</div>
<div>
<FieldLabel htmlFor="ir-destination">Destino</FieldLabel>
<Select id="ir-destination" value={destinationValue} onChange={(e) => setDestinationValue(e.target.value)} disabled={pending}>
<option value="">Selecione</option>
{destinationType === "EXTENSION" &&
extensions.map((ext) => (
<option key={ext.id} value={ext.number}>
{ext.number} {ext.name}
</option>
))}
{destinationType === "IVR" &&
ivrMenus.map((menu) => (
<option key={menu.id} value={menu.id}>
{menu.name}
</option>
))}
{destinationType === "QUEUE" &&
queues.map((q) => (
<option key={q.id} value={q.id}>
{q.name}
</option>
))}
{destinationType === "CALL_GROUP" &&
callGroups.map((g) => (
<option key={g} value={g}>
{g}
</option>
))}
</Select>
{destinationType === "EXTENSION" && extensions.length === 0 && (
<p className="mt-1 text-xs text-muted-foreground">Nenhum ramal cadastrado ainda.</p>
)}
{destinationType === "IVR" && ivrMenus.length === 0 && (
<p className="mt-1 text-xs text-muted-foreground">Nenhum menu de IVR cadastrado ainda (Telefonia &gt; IVR).</p>
)}
{destinationType === "QUEUE" && queues.length === 0 && (
<p className="mt-1 text-xs text-muted-foreground">Nenhuma fila cadastrada ainda (Call Center &gt; Filas).</p>
)}
{destinationType === "CALL_GROUP" && callGroups.length === 0 && (
<p className="mt-1 text-xs text-muted-foreground">
Nenhum ramal tem grupo de captura definido ainda (Telefonia &gt; Ramais).
</p>
)}
</div>
</div>
{error && ( {error && (
<p role="alert" className="rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive"> <p role="alert" className="rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
{error} {error}

View File

@@ -215,15 +215,56 @@ ponta: `PATCH` com coordenadas específicas, `GET` de volta confirma os
mesmos valores, e a página carregada de novo (SSR) já embute essas mesmos valores, e a página carregada de novo (SSR) já embute essas
coordenadas nos props iniciais do componente. coordenadas nos props iniciais do componente.
## Dropdown de destino: ramal, IVR, fila ou grupo (PHASE 62)
Achado real reportado pelo usuário: o destino de uma rota de entrada era
um campo de texto livre — sem dropdown, e (descoberto construindo o
dropdown) fila/grupo nunca tinham sido implementados de verdade como
destino possível, só ramal/IVR funcionavam.
`InboundRoute.destinationType` (novo enum: `EXTENSION`/`IVR`/`QUEUE`/
`CALL_GROUP`) decide como `buildInboundRouteXml` interpreta o destino:
- **EXTENSION/IVR**: comportamento inalterado — `transfer` pro contexto
do tenant, reaproveitando o dialplan já testado (PHASE 56/58).
- **QUEUE**: `destinationNumber` guarda o `Queue.id`. A resolução de
entrada emite `answer` + `callcenter data="<queueId>@<domain>"`
direto — nunca passa pelo dialplan "default" nem precisa de nenhuma
regra nova lá. `callcenter` entrou no allowlist de applications
(`packages/telephony/src/dialplan-xml.ts`) com o mesmo risco zero de
`pickup` — só recebe `<queueId>@<domain>` como texto.
- **CALL_GROUP**: `destinationNumber` guarda o valor de
`Extension.callGroup`. A resolução de entrada consulta AGORA (nunca um
snapshot salvo na criação da rota) todos os ramais do tenant com esse
`callGroup` e emite `bridge` com uma leg por ramal
(`user/A@domain,user/B@domain,...`) — toca todos ao mesmo tempo, quem
atender primeiro cancela os outros (ring group de verdade). Trocar
quem está no grupo depois de criar a rota já vale na PRÓXIMA chamada,
sem precisar re-salvar nada — a query roda a cada chamada de entrada,
não uma vez só.
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 registrados no mesmo `callGroup`, a chamada tocou nos DOIS
ao mesmo tempo (mesmo `call_uuid`, ambas as legs `RINGING`), atender em
um cancelou o outro automaticamente.
Tela "Rotas de Entrada": "Tipo de destino" (Ramal/IVR/Fila/Grupo de
ramais) + um segundo dropdown listando as opções reais do tenant pra
cada tipo (ramais cadastrados, menus de IVR, filas, ou os valores
distintos de `callGroup` já usados em algum ramal).
## O que falta ## O que falta
- Sem TTS (texto→voz) — só upload de arquivo WAV já gravado. - Sem TTS (texto→voz) — só upload de arquivo WAV já gravado.
- Menu de IVR não suporta sub-menus (uma opção levando a OUTRO IVR) nem - Menu de IVR não suporta sub-menus (uma opção levando a OUTRO IVR) nem
destino "fila" — só ramal, dentro do contexto `default`. destino "fila" — só ramal, dentro do contexto `default`. Rota de
- Tela de frontend "Rotas de Entrada" cobre só CRUD simples (DID → entrada já suporta fila/grupo, mas um MENU de IVR ainda só bridge pra
ramal); não tem seletor dedicado de "IVR" como destino ainda (o ramal.
operador digita o contexto/`ivr_entry` manualmente, mostrados na - Grupo de ramais só é alcançável por Rota de Entrada — não existe (e
própria tela de IVR pra copiar). não foi pedido) um jeito de discar um grupo de dentro do próprio
dialplan "default" via feature code.
- Perda das proteções de toll-fraud do `public.xml` vanilla (unroll de - Perda das proteções de toll-fraud do `public.xml` vanilla (unroll de
loop de chamada, etc.) — não replicadas no contexto `inbound` novo. loop de chamada, etc.) — não replicadas no contexto `inbound` novo.
Aceitável pra esta fase (sem trunks reais ainda), mas revisar antes de Aceitável pra esta fase (sem trunks reais ainda), mas revisar antes de

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") @@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 // Rota de entrada por DID (PHASE 56) — achado real: nenhuma chamada que
// chega por um tronco carrega `b2bcall_tenant_id` hoje (só ramal // chega por um tronco carrega `b2bcall_tenant_id` hoje (só ramal
// registrado e discagem de saída setam essa variable), então uma chamada // registrado e discagem de saída setam essa variable), então uma chamada
@@ -462,11 +471,23 @@ model InboundRoute {
didNumber String @unique @map("did_number") didNumber String @unique @map("did_number")
description String? description String?
// Contexto de dialplan do TENANT DONO que recebe a chamada depois da // PHASE 62 — achado real reportado pelo usuário: o destino de uma rota
// resolução (ex.: "default" pra cair direto na discagem interna já // de entrada era um campo de texto livre, sem dropdown de ramal/IVR/
// existente, ou um contexto de IVR dedicado) + o destination_number // fila/grupo. `destinationType` decide como `buildInboundRouteXml`
// sintético usado dentro dele (número de ramal real, ou um destino // (packages/telephony) interpreta `destinationNumber`:
// reservado do menu de IVR). // 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") destinationContext String @default("default") @map("destination_context")
destinationNumber String @map("destination_number") destinationNumber String @map("destination_number")

View File

@@ -35,6 +35,9 @@ export const ALLOWED_DIALPLAN_APPLICATIONS = [
// timeout terminators file invalid_file var_name regexp // timeout terminators file invalid_file var_name regexp
// digit_timeout`), nunca executa nada; mesmo padrão de risco zero. // digit_timeout`), nunca executa nada; mesmo padrão de risco zero.
"play_and_get_digits", "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; ] as const;
export type AllowedDialplanApplication = (typeof ALLOWED_DIALPLAN_APPLICATIONS)[number]; 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 * default (`$${domain}` do vars.xml, ex.: "b2bcall.local"), nunca pro
* domínio de verdade do tenant — a leg de entrada não é um ramal * domínio de verdade do tenant — a leg de entrada não é um ramal
* registrado, então nada preenche essa variable sozinho. * 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: { export function buildInboundRouteXml(
tenantId: string; params:
domain: string; | { tenantId: string; domain: string; destinationType: "EXTENSION" | "IVR"; destinationNumber: string; destinationContext: string }
destinationNumber: string; | { tenantId: string; domain: string; destinationType: "QUEUE"; queueId: string }
destinationContext: string; | { tenantId: string; domain: string; destinationType: "CALL_GROUP"; groupMembers: string[] },
}): 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"?> return `<?xml version="1.0" encoding="UTF-8"?>
<document type="freeswitch/xml"> <document type="freeswitch/xml">
<section name="dialplan"> <section name="dialplan">
<context name="inbound"> <context name="inbound">
<extension name="inbound-route"> <extension name="inbound-route">
<condition> <condition>
<action application="set" data="b2bcall_tenant_id=${xmlEscape(params.tenantId)}"/> ${setup}
<action application="set" data="domain_name=${xmlEscape(params.domain)}"/> ${action}
<action application="transfer" data="${xmlEscape(params.destinationNumber)} XML ${xmlEscape(params.destinationContext)}"/>
</condition> </condition>
</extension> </extension>
</context> </context>