diff --git a/apps/api/src/inbound-routes/dto/create-inbound-route.dto.ts b/apps/api/src/inbound-routes/dto/create-inbound-route.dto.ts index 4ddef31..94511da 100644 --- a/apps/api/src/inbound-routes/dto/create-inbound-route.dto.ts +++ b/apps/api/src/inbound-routes/dto/create-inbound-route.dto.ts @@ -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 { // Numero como o provedor de troncos manda no INVITE (destination_number) — @@ -14,16 +16,24 @@ export class CreateInboundRouteDto { @MaxLength(255) 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 - // resolução — normalmente "default" (cai na discagem interna existente), - // ou um contexto de IVR dedicado. + // resolução — só usado quando destinationType é EXTENSION/IVR. @IsOptional() @IsString() @MaxLength(80) destinationContext?: string; - // destination_number sintético usado dentro desse contexto — numero de - // ramal real, ou um destino reservado do menu de IVR. + // Interpretação depende de destinationType: número de ramal (EXTENSION), + // IVR_ENTRY_DESTINATION fixo (IVR), Queue.id (QUEUE), ou + // Extension.callGroup (CALL_GROUP). @IsString() @Matches(/^[a-zA-Z0-9_-]{1,40}$/, { message: "destinationNumber deve ser alfanumérico (1 a 40 caracteres)" }) destinationNumber!: string; @@ -39,6 +49,10 @@ export class UpdateInboundRouteDto { @MaxLength(255) description?: string; + @IsOptional() + @IsIn(DESTINATION_TYPES) + destinationType?: (typeof DESTINATION_TYPES)[number]; + @IsOptional() @IsString() @MaxLength(80) diff --git a/apps/api/src/inbound-routes/inbound-routes.controller.ts b/apps/api/src/inbound-routes/inbound-routes.controller.ts index aef5e0e..e905420 100644 --- a/apps/api/src/inbound-routes/inbound-routes.controller.ts +++ b/apps/api/src/inbound-routes/inbound-routes.controller.ts @@ -47,6 +47,7 @@ export class InboundRoutesController { tenantId, didNumber: dto.didNumber, description: dto.description, + destinationType: dto.destinationType ?? "EXTENSION", destinationContext: dto.destinationContext ?? "default", destinationNumber: dto.destinationNumber, enabled: dto.enabled ?? true, @@ -105,6 +106,7 @@ export class InboundRoutesController { where: { id, tenantId, deletedAt: null }, data: { ...(dto.description !== undefined ? { description: dto.description } : {}), + ...(dto.destinationType !== undefined ? { destinationType: dto.destinationType } : {}), ...(dto.destinationContext !== undefined ? { destinationContext: dto.destinationContext } : {}), ...(dto.destinationNumber !== undefined ? { destinationNumber: dto.destinationNumber } : {}), ...(dto.enabled !== undefined ? { enabled: dto.enabled } : {}), diff --git a/apps/freeswitch-config/src/main.ts b/apps/freeswitch-config/src/main.ts index 980011d..c295cda 100644 --- a/apps/freeswitch-config/src/main.ts +++ b/apps/freeswitch-config/src/main.ts @@ -108,9 +108,34 @@ async function resolveInboundRouteXml(didNumber: string | undefined): Promise + tx.extension.findMany({ + where: { tenantId: tenant.id, callGroup: route.destinationNumber, enabled: true, deletedAt: null }, + select: { number: true }, + }), + ); + return buildInboundRouteXml({ + tenantId: tenant.id, + domain, + destinationType: "CALL_GROUP", + groupMembers: members.map((m) => m.number), + }); + } + return buildInboundRouteXml({ tenantId: tenant.id, - domain: tenant.telephonyDomain!, + domain, + destinationType: route.destinationType, destinationNumber: route.destinationNumber, destinationContext: route.destinationContext, }); diff --git a/apps/frontend/src/app/app/telefonia/rotas-entrada/actions.ts b/apps/frontend/src/app/app/telefonia/rotas-entrada/actions.ts index 219e58d..4ab9b8c 100644 --- a/apps/frontend/src/app/app/telefonia/rotas-entrada/actions.ts +++ b/apps/frontend/src/app/app/telefonia/rotas-entrada/actions.ts @@ -3,7 +3,7 @@ import { revalidatePath } from "next/cache"; import { requireSession } from "@/lib/session"; 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 { if (err instanceof ApiError) { @@ -22,6 +22,7 @@ function extractErrorMessage(err: unknown): string { export interface CreateInboundRouteInput { didNumber: string; description?: string; + destinationType: InboundRouteDestinationType; destinationContext?: string; destinationNumber: string; } diff --git a/apps/frontend/src/app/app/telefonia/rotas-entrada/page.tsx b/apps/frontend/src/app/app/telefonia/rotas-entrada/page.tsx index b094bef..8a53b76 100644 --- a/apps/frontend/src/app/app/telefonia/rotas-entrada/page.tsx +++ b/apps/frontend/src/app/app/telefonia/rotas-entrada/page.tsx @@ -1,10 +1,16 @@ import { requireSession } from "@/lib/session"; 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"; export default async function RotasEntradaPage() { const session = await requireSession(); - const routes = await apiFetch("/inbound-routes", session.accessToken); - return ; + const [routes, extensions, ivrMenus, queues] = await Promise.all([ + apiFetch("/inbound-routes", session.accessToken), + apiFetch("/extensions", session.accessToken), + apiFetch("/ivr-menus", session.accessToken), + apiFetch("/queues", session.accessToken), + ]); + return ; } diff --git a/apps/frontend/src/app/app/telefonia/rotas-entrada/rotas-entrada-view.tsx b/apps/frontend/src/app/app/telefonia/rotas-entrada/rotas-entrada-view.tsx index 729dc87..6037588 100644 --- a/apps/frontend/src/app/app/telefonia/rotas-entrada/rotas-entrada-view.tsx +++ b/apps/frontend/src/app/app/telefonia/rotas-entrada/rotas-entrada-view.tsx @@ -1,29 +1,83 @@ "use client"; -import { useState, useTransition } from "react"; +import { useMemo, useState, useTransition } from "react"; import { useRouter } from "next/navigation"; import { PhoneIncoming, Plus, Trash2, X } from "lucide-react"; import { Panel, PanelHeader } from "@/components/ui/panel"; 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 { EmptyState, TBody, TD, TH, THead, TR, Table } from "@/components/ui/table"; 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"; -export function RotasEntradaView({ routes }: { routes: InboundRoute[] }) { +function destinationLabel( + route: InboundRoute, + extensionsByNumber: Record, + ivrMenusByContext: Record, + queuesById: Record, +): 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 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 (

Rotas de entrada

- Cada número (DID) que um tronco recebe vira uma rota própria, apontando pra um ramal, fila ou IVR — um - tronco pode carregar vários números com destinos diferentes. O número (DID) é único entre todos os - tenants: é a única forma de saber de quem é uma chamada de entrada antes de identificar o tenant. + Cada número (DID) que um tronco recebe vira uma rota própria, apontando pra um ramal, IVR, fila ou grupo + de ramais — um tronco pode carregar vários números com destinos diferentes. O número (DID) é único entre + todos os tenants: é a única forma de saber de quem é uma chamada de entrada antes de identificar o + tenant.

- {showForm && setShowForm(false)} />} + {showForm && ( + setShowForm(false)} /> + )} @@ -62,9 +118,7 @@ export function RotasEntradaView({ routes }: { routes: InboundRoute[] }) { {r.description ?? "—"} - - {r.destinationNumber} ({r.destinationContext}) - + {destinationLabel(r, extensionsByNumber, ivrMenusByContext, queuesById)} {r.enabled ? "Ativa" : "Desativada"} @@ -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 [description, setDescription] = useState(""); - const [destinationNumber, setDestinationNumber] = useState(""); + const [destinationType, setDestinationType] = useState("EXTENSION"); + const [destinationValue, setDestinationValue] = useState(""); const [error, setError] = useState(null); const [pending, startTransition] = useTransition(); + function onTypeChange(next: InboundRouteDestinationType) { + setDestinationType(next); + setDestinationValue(""); + } + function onSubmit(e: React.FormEvent) { e.preventDefault(); setError(null); - if (!didNumber.trim() || !destinationNumber.trim()) { - setError("DID e destino são obrigatórios."); + if (!didNumber.trim()) { + setError("Informe o número (DID)."); 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 () => { const result = await createInboundRoute({ didNumber: didNumber.trim(), description: description.trim() || undefined, - destinationNumber: destinationNumber.trim(), + destinationType, + destinationNumber, + destinationContext, }); if (!result.ok) { setError(result.error); @@ -113,7 +204,7 @@ function NewInboundRouteForm({ onDone }: { onDone: () => void }) { return (
-
+
Número (DID) void }) { disabled={pending} />
-
- Ramal de destino - setDestinationNumber(e.target.value)} - placeholder="Ex.: 1001" - disabled={pending} - /> -
Descrição (opcional) void }) { />
+
+
+ Tipo de destino + +
+
+ Destino + + {destinationType === "EXTENSION" && extensions.length === 0 && ( +

Nenhum ramal cadastrado ainda.

+ )} + {destinationType === "IVR" && ivrMenus.length === 0 && ( +

Nenhum menu de IVR cadastrado ainda (Telefonia > IVR).

+ )} + {destinationType === "QUEUE" && queues.length === 0 && ( +

Nenhuma fila cadastrada ainda (Call Center > Filas).

+ )} + {destinationType === "CALL_GROUP" && callGroups.length === 0 && ( +

+ Nenhum ramal tem grupo de captura definido ainda (Telefonia > Ramais). +

+ )} +
+
{error && (

{error} diff --git a/docs/INBOUND_ROUTES.md b/docs/INBOUND_ROUTES.md index 990cf84..c14287d 100644 --- a/docs/INBOUND_ROUTES.md +++ b/docs/INBOUND_ROUTES.md @@ -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 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="@"` + 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 `@` 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 - 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 - destino "fila" — só ramal, dentro do contexto `default`. -- Tela de frontend "Rotas de Entrada" cobre só CRUD simples (DID → - ramal); não tem seletor dedicado de "IVR" como destino ainda (o - operador digita o contexto/`ivr_entry` manualmente, mostrados na - própria tela de IVR pra copiar). + destino "fila" — só ramal, dentro do contexto `default`. Rota de + entrada já suporta fila/grupo, mas um MENU de IVR ainda só bridge pra + ramal. +- Grupo de ramais só é alcançável por Rota de Entrada — não existe (e + 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 loop de chamada, etc.) — não replicadas no contexto `inbound` novo. Aceitável pra esta fase (sem trunks reais ainda), mas revisar antes de diff --git a/packages/database/prisma/migrations/20260830230000_inbound_route_destination_type/migration.sql b/packages/database/prisma/migrations/20260830230000_inbound_route_destination_type/migration.sql new file mode 100644 index 0000000..2d8a369 --- /dev/null +++ b/packages/database/prisma/migrations/20260830230000_inbound_route_destination_type/migration.sql @@ -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'; diff --git a/packages/database/prisma/schema.prisma b/packages/database/prisma/schema.prisma index a936c38..8b96808 100644 --- a/packages/database/prisma/schema.prisma +++ b/packages/database/prisma/schema.prisma @@ -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) diff --git a/packages/telephony/src/dialplan-xml.ts b/packages/telephony/src/dialplan-xml.ts index 7447e7b..ba6f2b0 100644 --- a/packages/telephony/src/dialplan-xml.ts +++ b/packages/telephony/src/dialplan-xml.ts @@ -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 `@` + // 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 = ` + `; + + let action: string; + if (params.destinationType === "QUEUE") { + action = ` + `; + } else if (params.destinationType === "CALL_GROUP") { + const legs = params.groupMembers.map((num) => `user/${xmlEscape(num)}@${xmlEscape(params.domain)}`).join(","); + action = ` + `; + } else { + action = ` `; + } + return `

- - - +${setup} +${action}