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:
@@ -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)
|
||||||
|
|||||||
@@ -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 } : {}),
|
||||||
|
|||||||
@@ -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,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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} />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 > IVR).</p>
|
||||||
|
)}
|
||||||
|
{destinationType === "QUEUE" && queues.length === 0 && (
|
||||||
|
<p className="mt-1 text-xs text-muted-foreground">Nenhuma fila cadastrada ainda (Call Center > 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 > 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}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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';
|
||||||
@@ -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")
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user