diff --git a/TODO.md b/TODO.md index 762afe3..f525cce 100644 --- a/TODO.md +++ b/TODO.md @@ -2417,6 +2417,45 @@ já em produção] que depois vai vir via sip comum ao b2bcall") `sip-user`/`sip-domain`/`sip-server` corretos e a senha presente (não vazia) assim que o agente com ramal vinculado abre `/app`. +## PHASE 67 — Dropdown de destino (fila/outro IVR) numa opção de IVR +(pedido do usuário: "dentro do IVR tem que dar a opção de eu colocar como +destino não apenas os ramais mas tb as filas e outra IVR, como feito na +rota de entrada") +- [x] Mesmo gap que existia em Rotas de Entrada até a PHASE 62: + `IvrMenuOption` só tinha `destinationNumber` (sempre interpretado + como ramal), sem tipo nenhum. Novo enum próprio + `IvrOptionDestinationType` (EXTENSION/QUEUE/IVR) — SEM + `CALL_GROUP` de propósito: diferente de `InboundRoute` (resolvido + por chamada, sempre fresco), o dialplan de um IVR é compilado UMA + VEZ ao salvar o menu — "quem está no grupo agora" ficaria + desatualizado até a próxima edição, comportamento silenciosamente + stale que ninguém pediu. +- [x] `buildIvrDialplanExtensions` (packages/telephony) — cada branch de + dígito decide a action pelo `destinationType`, mesmo padrão de + `buildInboundRouteXml`: EXTENSION continua `bridge` direto; + QUEUE vira `answer` + `callcenter`; IVR vira `transfer` pro + `IVR_ENTRY_DESTINATION` do contexto do menu alvo — reaproveita a + MESMA entrada que uma `InboundRoute` usa pra entrar naquele menu. +- [x] "Outro IVR" cria um grafo entre menus que nunca existia antes (uma + `InboundRoute` não é ela mesma um menu, nunca podia formar ciclo). + `assertNoIvrCycle` (novo, `ivr-menus.controller.ts`) monta o grafo + com TODOS os menus do tenant antes de compilar e rejeita + (409) qualquer combinação que criaria um ciclo — A→B→A ou até + A→A — checado tanto em `create` quanto `update`. +- [x] Frontend: os dois editores de IVR (form clássico `ivr-view.tsx` e + o editor visual `ivr-flow-editor.tsx`, PHASE 60/61) ganharam o + mesmo dropdown "Tipo" + "Destino" condicional já usado em Rotas de + Entrada — precisou buscar `queues` na `page.tsx` do IVR (só + `extensions` era buscado antes) e passar a lista de OUTROS menus + (excluindo o próprio, de propósito, pra reduzir a chance de + self-reference direto na UI — o backend segue sendo quem + garante isso de verdade). +- [x] Testado ponta a ponta com Playwright, tenant/fila/2 menus de IVR + reais criados na hora: Menu A com opção tipo Fila (aponta pra + "Fila QA"), Menu B com opção tipo Outro IVR (aponta pro Menu A) — + os dois persistiram com o tipo certo, sobrevivendo a um reload + completo da página. + --- ## Riscos conhecidos diff --git a/apps/api/src/ivr/dto/create-ivr-menu.dto.ts b/apps/api/src/ivr/dto/create-ivr-menu.dto.ts index abcf794..ff75328 100644 --- a/apps/api/src/ivr/dto/create-ivr-menu.dto.ts +++ b/apps/api/src/ivr/dto/create-ivr-menu.dto.ts @@ -3,10 +3,18 @@ import { ArrayMaxSize, ArrayMinSize, IsArray, IsIn, IsNumber, IsOptional, IsStri import { ALLOWED_IVR_DIGITS } from "@b2bcall/telephony"; import { IsSafeDialplanData } from "../../dialplan/dto/safe-dialplan-data.validator"; +const IVR_OPTION_DESTINATION_TYPES = ["EXTENSION", "QUEUE", "IVR"] as const; + export class IvrMenuOptionDto { @IsIn(ALLOWED_IVR_DIGITS) digit!: string; + // PHASE 67 — decide como destinationNumber/destinationContext são + // interpretados (ver comentário em IvrMenuOption no schema). + @IsOptional() + @IsIn(IVR_OPTION_DESTINATION_TYPES) + destinationType?: (typeof IVR_OPTION_DESTINATION_TYPES)[number]; + @IsString() @Matches(/^[a-zA-Z0-9_-]{1,40}$/, { message: "destinationNumber deve ser alfanumérico (1 a 40 caracteres)" }) destinationNumber!: string; diff --git a/apps/api/src/ivr/ivr-menus.controller.ts b/apps/api/src/ivr/ivr-menus.controller.ts index 80253e5..fc4bd44 100644 --- a/apps/api/src/ivr/ivr-menus.controller.ts +++ b/apps/api/src/ivr/ivr-menus.controller.ts @@ -49,6 +49,53 @@ function promptHostPath(tenantId: string, menuId: string): string { return join(IVR_PROMPTS_HOST_ROOT, tenantId, `${menuId}.wav`); } +/** + * PHASE 67 — destino "Outro IVR" cria um grafo entre menus (algo que + * InboundRoute->IVR nunca tinha, já que uma rota de entrada não é ela + * mesma um menu). Sem essa checagem, dois menus apontando um pro outro + * (ou um menu apontando pra si mesmo) compilam sem erro nenhum e só + * travam a experiência do CHAMADOR depois (fica indo de um prompt pro + * outro pra sempre) — pega isso antes de compilar, não depois de alguém + * reclamar. Monta o grafo com TODOS os menus do tenant (não só os que já + * existiam antes desta operação), substituindo as arestas do menu sendo + * salvo pelas opções novas — cobre tanto create quanto update. + */ +async function assertNoIvrCycle( + tx: Prisma.TransactionClient, + tenantId: string, + menuContext: string, + newOptions: IvrMenuOptionInput[], +): Promise { + const menus = await tx.ivrMenu.findMany({ + where: { tenantId, deletedAt: null }, + select: { context: true, options: { select: { destinationType: true, destinationContext: true } } }, + }); + + const edges = new Map(); + for (const m of menus) { + edges.set( + m.context, + m.options.filter((o) => o.destinationType === "IVR").map((o) => o.destinationContext), + ); + } + edges.set( + menuContext, + newOptions.filter((o) => o.destinationType === "IVR").map((o) => o.destinationContext), + ); + + const visited = new Set(); + const stack = [...(edges.get(menuContext) ?? [])]; + while (stack.length > 0) { + const next = stack.pop()!; + if (next === menuContext) { + throw new ConflictException(`Esse destino criaria um ciclo entre menus de IVR (via "${next}")`); + } + if (visited.has(next)) continue; + visited.add(next); + stack.push(...(edges.get(next) ?? [])); + } +} + /** * Tela de autoria de IVR (PHASE 58, docs/INBOUND_ROUTES.md) — por cima do * editor genérico de dialplan (PHASE 56/57): criar/editar um `IvrMenu` @@ -133,22 +180,31 @@ export class IvrMenusController { const prisma = getPrismaClient(); const tenantId = user.tenantId!; + const createOptions: IvrMenuOptionInput[] = dto.options.map((o) => ({ + digit: o.digit, + destinationType: o.destinationType ?? "EXTENSION", + destinationNumber: o.destinationNumber, + destinationContext: o.destinationContext ?? "default", + })); + let menu; try { menu = await withTenantContext(prisma, tenantId, async (tx) => { + await assertNoIvrCycle(tx, tenantId, dto.context, createOptions); const created = await tx.ivrMenu.create({ data: { tenantId, name: dto.name, context: dto.context, greeting: dto.greeting }, }); await tx.ivrMenuOption.createMany({ - data: dto.options.map((opt) => ({ + data: createOptions.map((opt, i) => ({ tenantId, ivrMenuId: created.id, digit: opt.digit, + destinationType: opt.destinationType, destinationNumber: opt.destinationNumber, - destinationContext: opt.destinationContext ?? "default", - label: opt.label, - positionX: opt.positionX, - positionY: opt.positionY, + destinationContext: opt.destinationContext, + label: dto.options[i].label, + positionX: dto.options[i].positionX, + positionY: dto.options[i].positionY, })), }); return created; @@ -160,11 +216,6 @@ export class IvrMenusController { throw err; } - const createOptions: IvrMenuOptionInput[] = dto.options.map((o) => ({ - digit: o.digit, - destinationNumber: o.destinationNumber, - destinationContext: o.destinationContext ?? "default", - })); await compileAndActivateIvrDialplan(prisma, tenantId, user.sub, menu, createOptions); await recordAuditEvent(prisma, { @@ -219,7 +270,24 @@ export class IvrMenusController { ); if (!existing) throw new NotFoundException(); + const options: IvrMenuOptionInput[] = dto.options + ? dto.options.map((o) => ({ + digit: o.digit, + destinationType: o.destinationType ?? "EXTENSION", + destinationNumber: o.destinationNumber, + destinationContext: o.destinationContext ?? "default", + })) + : existing.options.map((o) => ({ + digit: o.digit, + destinationType: o.destinationType, + destinationNumber: o.destinationNumber, + destinationContext: o.destinationContext, + })); + const menu = await withTenantContext(prisma, tenantId, async (tx) => { + if (dto.options) { + await assertNoIvrCycle(tx, tenantId, existing.context, options); + } const updated = await tx.ivrMenu.update({ where: { id }, data: { @@ -232,12 +300,13 @@ export class IvrMenusController { if (dto.options) { await tx.ivrMenuOption.deleteMany({ where: { ivrMenuId: id } }); await tx.ivrMenuOption.createMany({ - data: dto.options.map((opt) => ({ + data: dto.options.map((opt, i) => ({ tenantId, ivrMenuId: id, digit: opt.digit, + destinationType: options[i].destinationType, destinationNumber: opt.destinationNumber, - destinationContext: opt.destinationContext ?? "default", + destinationContext: options[i].destinationContext, label: opt.label, positionX: opt.positionX, positionY: opt.positionY, @@ -247,10 +316,6 @@ export class IvrMenusController { return updated; }); - const options: IvrMenuOptionInput[] = dto.options - ? dto.options.map((o) => ({ digit: o.digit, destinationNumber: o.destinationNumber, destinationContext: o.destinationContext ?? "default" })) - : existing.options.map((o) => ({ digit: o.digit, destinationNumber: o.destinationNumber, destinationContext: o.destinationContext })); - await compileAndActivateIvrDialplan(prisma, tenantId, user.sub, menu, options); await recordAuditEvent(prisma, { @@ -342,6 +407,7 @@ export class IvrMenusController { const options: IvrMenuOptionInput[] = menu.options.map((o) => ({ digit: o.digit, + destinationType: o.destinationType, destinationNumber: o.destinationNumber, destinationContext: o.destinationContext, })); @@ -380,6 +446,7 @@ export class IvrMenusController { const options: IvrMenuOptionInput[] = menu.options.map((o) => ({ digit: o.digit, + destinationType: o.destinationType, destinationNumber: o.destinationNumber, destinationContext: o.destinationContext, })); diff --git a/apps/frontend/src/app/app/telefonia/ivr/actions.ts b/apps/frontend/src/app/app/telefonia/ivr/actions.ts index 2f00c66..57ad898 100644 --- a/apps/frontend/src/app/app/telefonia/ivr/actions.ts +++ b/apps/frontend/src/app/app/telefonia/ivr/actions.ts @@ -3,7 +3,7 @@ import { revalidatePath } from "next/cache"; import { requireSession } from "@/lib/session"; import { apiFetch, ApiError, API_BASE_URL } from "@/lib/api"; -import type { IvrMenu } from "@/lib/callcenter-types"; +import type { IvrMenu, IvrOptionDestinationType } from "@/lib/callcenter-types"; function extractErrorMessage(err: unknown): string { if (err instanceof ApiError) { @@ -21,7 +21,9 @@ function extractErrorMessage(err: unknown): string { export interface IvrMenuOptionInput { digit: string; + destinationType?: IvrOptionDestinationType; destinationNumber: string; + destinationContext?: string; label?: string; positionX?: number; positionY?: number; diff --git a/apps/frontend/src/app/app/telefonia/ivr/ivr-flow-editor.tsx b/apps/frontend/src/app/app/telefonia/ivr/ivr-flow-editor.tsx index a12b1bc..167f656 100644 --- a/apps/frontend/src/app/app/telefonia/ivr/ivr-flow-editor.tsx +++ b/apps/frontend/src/app/app/telefonia/ivr/ivr-flow-editor.tsx @@ -19,14 +19,23 @@ import "@xyflow/react/dist/style.css"; import { PhoneIncoming, Plus, Save, Trash2 } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Input, Select } from "@/components/ui/input"; -import { ALLOWED_IVR_DIGITS, type IvrMenu } from "@/lib/callcenter-types"; +import { + ALLOWED_IVR_DIGITS, + IVR_ENTRY_DESTINATION, + IVR_OPTION_DESTINATION_TYPES, + IVR_OPTION_DESTINATION_TYPE_LABELS, + type IvrMenu, + type IvrOptionDestinationType, + type Queue, +} from "@/lib/callcenter-types"; import type { Extension } from "@/lib/extension-types"; import { updateIvrMenuOptions, type IvrMenuOptionInput } from "./actions"; interface OptionState { key: string; digit: string; - destinationNumber: string; + destinationType: IvrOptionDestinationType; + destinationValue: string; label: string; positionX: number | null; positionY: number | null; @@ -37,6 +46,8 @@ interface EntryNodeData extends Record {} interface OptionNodeData extends Record { option: OptionState; extensions: Extension[]; + queues: Queue[]; + menus: IvrMenu[]; usedDigits: string[]; onChange: (key: string, patch: Partial) => void; onRemove: (key: string) => void; @@ -56,7 +67,7 @@ function EntryNode(_props: NodeProps>) { } function OptionNode({ data }: NodeProps>) { - const { option, extensions, usedDigits, onChange, onRemove } = data; + const { option, extensions, queues, menus, usedDigits, onChange, onRemove } = data; return (
@@ -85,16 +96,40 @@ function OptionNode({ data }: NodeProps>) {
+ ({ @@ -155,18 +192,29 @@ function nextKey(): string { * direto os campos (dígito/ramal/descrição); salvar recompila o * dialplan no backend exatamente como o formulário antigo fazia. */ -export function IvrFlowEditor({ menu, extensions }: { menu: IvrMenu; extensions: Extension[] }) { +export function IvrFlowEditor({ + menu, + extensions, + queues, + menus, +}: { + menu: IvrMenu; + extensions: Extension[]; + queues: Queue[]; + menus: IvrMenu[]; +}) { const initialOptions = useMemo( () => menu.options.map((o) => ({ key: o.id, digit: o.digit, - destinationNumber: o.destinationNumber, + destinationType: o.destinationType, + destinationValue: o.destinationType === "IVR" ? (menus.find((m) => m.context === o.destinationContext)?.id ?? "") : o.destinationNumber, label: o.label ?? "", positionX: o.positionX, positionY: o.positionY, })), - [menu.options], + [menu.options, menus], ); const initialEntryPosition = useMemo( () => (menu.entryPositionX != null && menu.entryPositionY != null ? { x: menu.entryPositionX, y: menu.entryPositionY } : null), @@ -188,9 +236,9 @@ export function IvrFlowEditor({ menu, extensions }: { menu: IvrMenu; extensions: }, []); const { nodes: initialNodes, edges: initialEdges } = useMemo( - () => layoutNodes(options, initialEntryPosition, extensions, onChange, onRemove), + () => layoutNodes(options, initialEntryPosition, extensions, queues, menus, onChange, onRemove), // eslint-disable-next-line react-hooks/exhaustive-deps -- initialEntryPosition só serve pro layout inicial (posição arrastada depois vive em `nodes`, nunca recomputada daqui) - [options, extensions, onChange, onRemove], + [options, extensions, queues, menus, onChange, onRemove], ); const [nodes, setNodes, onNodesChange] = useNodesState(initialNodes); const [edges, setEdges, onEdgesChange] = useEdgesState(initialEdges); @@ -212,7 +260,10 @@ export function IvrFlowEditor({ menu, extensions }: { menu: IvrMenu; extensions: setSaved(false); const usedDigits = new Set(options.map((o) => o.digit)); const nextDigit = ALLOWED_IVR_DIGITS.find((d) => !usedDigits.has(d)) ?? "1"; - setOptions((prev) => [...prev, { key: nextKey(), digit: nextDigit, destinationNumber: "", label: "", positionX: null, positionY: null }]); + setOptions((prev) => [ + ...prev, + { key: nextKey(), digit: nextDigit, destinationType: "EXTENSION", destinationValue: "", label: "", positionX: null, positionY: null }, + ]); } function onSave() { @@ -221,8 +272,8 @@ export function IvrFlowEditor({ menu, extensions }: { menu: IvrMenu; extensions: setError("O menu precisa de pelo menos uma opção."); return; } - if (options.some((o) => !o.destinationNumber.trim())) { - setError("Toda opção precisa de um ramal de destino selecionado."); + if (options.some((o) => !o.destinationValue.trim())) { + setError("Toda opção precisa de um destino selecionado."); return; } const digits = options.map((o) => o.digit); @@ -236,12 +287,24 @@ export function IvrFlowEditor({ menu, extensions }: { menu: IvrMenu; extensions: // pelo react-flow é o estado de nós, nunca `data.option` de volta. const payload: IvrMenuOptionInput[] = options.map((o) => { const node = nodes.find((n) => n.id === o.key); + const position = { positionX: node?.position.x, positionY: node?.position.y }; + if (o.destinationType === "IVR") { + const target = menus.find((m) => m.id === o.destinationValue); + return { + digit: o.digit, + destinationType: "IVR", + destinationNumber: IVR_ENTRY_DESTINATION, + destinationContext: target?.context ?? "default", + label: o.label.trim() || undefined, + ...position, + }; + } return { digit: o.digit, - destinationNumber: o.destinationNumber.trim(), + destinationType: o.destinationType, + destinationNumber: o.destinationValue.trim(), label: o.label.trim() || undefined, - positionX: node?.position.x, - positionY: node?.position.y, + ...position, }; }); const entryNode = nodes.find((n) => n.id === ENTRY_NODE_ID); diff --git a/apps/frontend/src/app/app/telefonia/ivr/ivr-view.tsx b/apps/frontend/src/app/app/telefonia/ivr/ivr-view.tsx index 394d421..628c7b0 100644 --- a/apps/frontend/src/app/app/telefonia/ivr/ivr-view.tsx +++ b/apps/frontend/src/app/app/telefonia/ivr/ivr-view.tsx @@ -8,7 +8,15 @@ import { Button } from "@/components/ui/button"; import { Input, Select, FieldLabel } from "@/components/ui/input"; import { Pill } from "@/components/ui/pill"; import { EmptyState } from "@/components/ui/table"; -import { ALLOWED_IVR_DIGITS, IVR_ENTRY_DESTINATION, type IvrMenu } from "@/lib/callcenter-types"; +import { + ALLOWED_IVR_DIGITS, + IVR_ENTRY_DESTINATION, + IVR_OPTION_DESTINATION_TYPES, + IVR_OPTION_DESTINATION_TYPE_LABELS, + type IvrMenu, + type IvrOptionDestinationType, + type Queue, +} from "@/lib/callcenter-types"; import type { Extension } from "@/lib/extension-types"; import { createIvrMenu, deleteIvrMenu, deleteIvrMenuPrompt, uploadIvrMenuPrompt, type IvrMenuOptionInput } from "./actions"; import { IvrFlowEditor } from "./ivr-flow-editor"; @@ -27,7 +35,7 @@ function slugifyContext(name: string): string { ); } -export function IvrView({ menus, extensions }: { menus: IvrMenu[]; extensions: Extension[] }) { +export function IvrView({ menus, extensions, queues }: { menus: IvrMenu[]; extensions: Extension[]; queues: Queue[] }) { const [showForm, setShowForm] = useState(false); return ( @@ -47,7 +55,15 @@ export function IvrView({ menus, extensions }: { menus: IvrMenu[]; extensions: E - {showForm && m.context)} onDone={() => setShowForm(false)} />} + {showForm && ( + m.context)} + onDone={() => setShowForm(false)} + /> + )} @@ -70,7 +86,12 @@ export function IvrView({ menus, extensions }: { menus: IvrMenu[]; extensions: E {IVR_ENTRY_DESTINATION}

- + m.id !== menu.id)} + /> ))} @@ -143,23 +164,28 @@ function PromptControl({ menu }: { menu: IvrMenu }) { interface OptionRow { digit: string; - destinationNumber: string; + destinationType: IvrOptionDestinationType; + destinationValue: string; label: string; } function NewIvrMenuForm({ extensions, + queues, + menus, existingContexts, onDone, }: { extensions: Extension[]; + queues: Queue[]; + menus: IvrMenu[]; existingContexts: string[]; onDone: () => void; }) { const [name, setName] = useState(""); const [context, setContext] = useState(""); const [contextTouched, setContextTouched] = useState(false); - const [options, setOptions] = useState([{ digit: "1", destinationNumber: "", label: "" }]); + const [options, setOptions] = useState([{ digit: "1", destinationType: "EXTENSION", destinationValue: "", label: "" }]); const [error, setError] = useState(null); const [pending, startTransition] = useTransition(); @@ -172,7 +198,7 @@ function NewIvrMenuForm({ function addOption() { const nextDigit = ALLOWED_IVR_DIGITS.find((d) => !usedDigits.has(d)) ?? "1"; - setOptions((prev) => [...prev, { digit: nextDigit, destinationNumber: "", label: "" }]); + setOptions((prev) => [...prev, { digit: nextDigit, destinationType: "EXTENSION", destinationValue: "", label: "" }]); } function removeOption(index: number) { @@ -190,8 +216,8 @@ function NewIvrMenuForm({ setError(`Já existe um menu com o contexto "${effectiveContext}" — escolha outro nome.`); return; } - if (options.length === 0 || options.some((o) => !o.destinationNumber.trim())) { - setError("Toda opção precisa de um dígito e um ramal de destino."); + if (options.length === 0 || options.some((o) => !o.destinationValue.trim())) { + setError("Toda opção precisa de um dígito e um destino selecionado."); return; } const digits = options.map((o) => o.digit); @@ -200,11 +226,24 @@ function NewIvrMenuForm({ return; } - const payloadOptions: IvrMenuOptionInput[] = options.map((o) => ({ - digit: o.digit, - destinationNumber: o.destinationNumber.trim(), - label: o.label.trim() || undefined, - })); + const payloadOptions: IvrMenuOptionInput[] = options.map((o) => { + if (o.destinationType === "IVR") { + const target = menus.find((m) => m.id === o.destinationValue); + return { + digit: o.digit, + destinationType: "IVR", + destinationNumber: IVR_ENTRY_DESTINATION, + destinationContext: target?.context ?? "default", + label: o.label.trim() || undefined, + }; + } + return { + digit: o.digit, + destinationType: o.destinationType, + destinationNumber: o.destinationValue.trim(), + label: o.label.trim() || undefined, + }; + }); startTransition(async () => { const result = await createIvrMenu({ name: name.trim(), context: effectiveContext, options: payloadOptions }); @@ -247,7 +286,7 @@ function NewIvrMenuForm({ {options.map((opt, i) => ( -
+
Dígito updateOption(i, { destinationNumber: e.target.value })} + id={`ivr-opt-${i}-type`} + value={opt.destinationType} + onChange={(e) => updateOption(i, { destinationType: e.target.value as IvrOptionDestinationType, destinationValue: "" })} disabled={pending} > - - {extensions.map((ext) => ( - ))}
+
+ Destino + + {opt.destinationType === "IVR" && menus.length === 0 && ( +

Crie outro menu de IVR primeiro pra poder apontar pra ele.

+ )} +
Descrição (opcional) ("/ivr-menus", session.accessToken), apiFetch("/extensions", session.accessToken), + apiFetch("/queues", session.accessToken), ]); - return ; + return ; } diff --git a/apps/frontend/src/lib/callcenter-types.ts b/apps/frontend/src/lib/callcenter-types.ts index fd76606..d816afe 100644 --- a/apps/frontend/src/lib/callcenter-types.ts +++ b/apps/frontend/src/lib/callcenter-types.ts @@ -74,9 +74,19 @@ export const ALLOWED_IVR_DIGITS = ["1", "2", "3", "4", "5", "6", "7", "8", "9", * aponta pra um IvrMenu usa isso como destinationNumber (packages/telephony). */ export const IVR_ENTRY_DESTINATION = "ivr_entry"; +export const IVR_OPTION_DESTINATION_TYPES = ["EXTENSION", "QUEUE", "IVR"] as const; +export type IvrOptionDestinationType = (typeof IVR_OPTION_DESTINATION_TYPES)[number]; + +export const IVR_OPTION_DESTINATION_TYPE_LABELS: Record = { + EXTENSION: "Ramal", + QUEUE: "Fila", + IVR: "Outro IVR", +}; + export interface IvrMenuOption { id: string; digit: string; + destinationType: IvrOptionDestinationType; destinationNumber: string; destinationContext: string; label: string | null; diff --git a/docs/INBOUND_ROUTES.md b/docs/INBOUND_ROUTES.md index c14287d..9bbc884 100644 --- a/docs/INBOUND_ROUTES.md +++ b/docs/INBOUND_ROUTES.md @@ -255,13 +255,48 @@ 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). +## Dropdown de destino numa opção de IVR: ramal, fila ou outro IVR (PHASE 67) + +Mesmo gap da seção acima, um nível abaixo: até aqui, uma OPÇÃO dentro de +um menu de IVR só sabia apontar pra ramal (`bridge` fixo). Rota de +entrada já suportava fila/grupo pra ENTRAR num menu, mas depois de +entrar, cada dígito só discava um ramal. + +`IvrMenuOption.destinationType` (novo enum próprio, +`IvrOptionDestinationType`: `EXTENSION`/`QUEUE`/`IVR`) decide a action de +cada branch em `buildIvrDialplanExtensions` (packages/telephony), mesmo +padrão do `InboundRoute` acima: + +- **EXTENSION**: inalterado — `bridge` direto pro ramal. +- **QUEUE**: `answer` + `callcenter data="@"`. +- **IVR**: `transfer` pro `IVR_ENTRY_DESTINATION` do contexto do MENU + ALVO — reaproveita a mesma entrada que uma `InboundRoute` usa pra + entrar num IVR, então um menu vira efetivamente um sub-menu de outro. + +**Sem `CALL_GROUP` aqui, de propósito** (diferente de `InboundRoute`, +que tem os 4 tipos): o dialplan de um IVR é compilado UMA VEZ, ao salvar +o menu (`compileAndActivateIvrDialplan`) — não resolvido a cada chamada +como a resolução de `InboundRoute`. Um destino `CALL_GROUP` ficaria +"quem estava no grupo no momento em que o menu foi salvo por último", +uma defasagem silenciosa que ninguém pediu — melhor não oferecer do que +oferecer errado. + +**Ciclos**: "Outro IVR" cria a primeira forma de um menu apontar pra +outro (uma `InboundRoute` nunca é ela mesma um menu, então nunca podia +formar ciclo). `assertNoIvrCycle` (`ivr-menus.controller.ts`) monta o +grafo com todos os menus do tenant antes de compilar e rejeita (409) +qualquer save que criaria um ciclo (A→B→A, ou até A apontando pra si +mesmo) — checado tanto em `create` quanto `update`. + +Os dois editores de IVR (form clássico e o editor visual de nós, PHASE +60/61) ganharam o mesmo par de dropdowns "Tipo"/"Destino" já usado em +Rotas de Entrada. Testado ponta a ponta: menu A com opção tipo Fila, +menu B com opção tipo Outro IVR apontando pro menu A — os dois +persistiram certo depois de um reload completo da página. + ## 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`. 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. diff --git a/packages/database/prisma/migrations/20260831120000_ivr_option_destination_type/migration.sql b/packages/database/prisma/migrations/20260831120000_ivr_option_destination_type/migration.sql new file mode 100644 index 0000000..4aae318 --- /dev/null +++ b/packages/database/prisma/migrations/20260831120000_ivr_option_destination_type/migration.sql @@ -0,0 +1,5 @@ +-- PHASE 67: dropdown de destino (ramal/fila/outro IVR) numa opção de IVR, +-- mesmo gap que existia em Rotas de Entrada até a PHASE 62. +CREATE TYPE "ivr_option_destination_type" AS ENUM ('EXTENSION', 'QUEUE', 'IVR'); + +ALTER TABLE "ivr_menu_options" ADD COLUMN "destination_type" "ivr_option_destination_type" NOT NULL DEFAULT 'EXTENSION'; diff --git a/packages/database/prisma/schema.prisma b/packages/database/prisma/schema.prisma index 6d8ed31..f9b3620 100644 --- a/packages/database/prisma/schema.prisma +++ b/packages/database/prisma/schema.prisma @@ -545,6 +545,14 @@ model IvrMenu { @@map("ivr_menus") } +enum IvrOptionDestinationType { + EXTENSION + QUEUE + IVR + + @@map("ivr_option_destination_type") +} + model IvrMenuOption { id String @id @default(uuid()) @db.Uuid tenantId String @map("tenant_id") @db.Uuid @@ -552,8 +560,27 @@ model IvrMenuOption { digit String // "0".."9", "*" ou "#" — validado na API, é o que vira o regex da extension de branching - destinationNumber String @map("destination_number") // ramal real dentro do contexto abaixo - destinationContext String @default("default") @map("destination_context") + // PHASE 67 — achado real reportado pelo usuário: o destino de uma opção + // de IVR só podia ser ramal, sem dropdown de fila/outro menu, igual o + // gap que existia em Rotas de Entrada até a PHASE 62. Mesmo `destination + // Type` decide como `buildIvrDialplanExtensions` (packages/telephony) + // interpreta os 2 campos abaixo: + // EXTENSION -> destinationNumber é o número do ramal (bridge, igual + // sempre foi); destinationContext não usado + // QUEUE -> destinationNumber é o Queue.id (answer + callcenter, + // mesmo padrão de InboundRoute); destinationContext não usado + // IVR -> destinationNumber vira sempre IVR_ENTRY_DESTINATION; + // destinationContext é o IvrMenu.context do menu alvo + // (transfer pro próprio contexto, igual InboundRoute + // encaminha pra um IVR). Sem CALL_GROUP aqui de propósito: + // diferente de InboundRoute, o dialplan de um IVR é + // compilado uma vez ao salvar (não resolvido por chamada), + // então "quem está no grupo agora" ficaria desatualizado + // até a próxima edição — não pedido pelo usuário, não + // implementado. + destinationType IvrOptionDestinationType @default(EXTENSION) @map("destination_type") + destinationNumber String @map("destination_number") // ramal real dentro do contexto abaixo + destinationContext String @default("default") @map("destination_context") label String? diff --git a/packages/telephony/src/ivr-xml.ts b/packages/telephony/src/ivr-xml.ts index 08070b5..e68843d 100644 --- a/packages/telephony/src/ivr-xml.ts +++ b/packages/telephony/src/ivr-xml.ts @@ -23,6 +23,7 @@ function escapeRegexLiteral(digit: string): string { export interface IvrMenuOptionInput { digit: string; + destinationType: "EXTENSION" | "QUEUE" | "IVR"; destinationNumber: string; destinationContext: string; } @@ -38,6 +39,14 @@ export interface IvrMenuOptionInput { * por uma extension nunca é enxergada por OUTRA extension na mesma * passada; só um `transfer` (nova consulta de dialplan) resolve isso — * e uma extension por dígito, casando por `destination_number` normal. + * + * PHASE 67 (dropdown de destino real: ramal/fila/outro IVR) — cada branch + * decide a action pelo `destinationType`, mesmo padrão de + * `buildInboundRouteXml`: EXTENSION continua `bridge` direto pro ramal; + * QUEUE vira `answer` + `callcenter`; IVR vira `transfer` pro + * `IVR_ENTRY_DESTINATION` do contexto alvo (reaproveita a MESMA entrada + * que qualquer chamada de fora usa pra entrar naquele menu). Sem + * CALL_GROUP aqui — motivo no comentário do schema (`IvrMenuOption`). */ export function buildIvrDialplanExtensions( menu: { context: string; greeting?: string | null }, @@ -61,14 +70,27 @@ export function buildIvrDialplanExtensions( ], }; - const branches: DialplanExtensionInput[] = options.map((opt, i) => ({ - name: `IVR opção ${opt.digit}`, - conditionField: "destination_number", - conditionExpr: `^${escapeRegexLiteral(opt.digit)}$`, - continueOnFalse: false, - order: 10 + i, - actions: [{ application: "bridge", data: `user/${opt.destinationNumber}@\${domain_name}` }], - })); + const branches: DialplanExtensionInput[] = options.map((opt, i) => { + let actions: DialplanExtensionInput["actions"]; + if (opt.destinationType === "QUEUE") { + actions = [ + { application: "answer" }, + { application: "callcenter", data: `${opt.destinationNumber}@\${domain_name}` }, + ]; + } else if (opt.destinationType === "IVR") { + actions = [{ application: "transfer", data: `${IVR_ENTRY_DESTINATION} XML ${opt.destinationContext}` }]; + } else { + actions = [{ application: "bridge", data: `user/${opt.destinationNumber}@\${domain_name}` }]; + } + return { + name: `IVR opção ${opt.digit}`, + conditionField: "destination_number", + conditionExpr: `^${escapeRegexLiteral(opt.digit)}$`, + continueOnFalse: false, + order: 10 + i, + actions, + }; + }); return [entry, ...branches]; }