feat(ivr): dropdown de destino (fila/outro IVR) numa opção de menu, com bloqueio de ciclo (PHASE 67)
Mesmo gap que Rotas de Entrada já tinha resolvido na PHASE 62: uma opção dentro de um menu de IVR só sabia apontar pra ramal (bridge fixo). Novo IvrMenuOption.destinationType (EXTENSION/QUEUE/IVR) decide a action de cada branch em buildIvrDialplanExtensions, mesmo padrão de buildInboundRouteXml — QUEUE vira answer+callcenter, IVR vira transfer pro IVR_ENTRY_DESTINATION do menu alvo (efetivamente um sub-menu). Sem CALL_GROUP aqui de propósito: diferente de InboundRoute (resolvido a cada chamada), o dialplan de um IVR é compilado uma vez ao salvar — "quem está no grupo agora" ficaria desatualizado até a próxima edição. "Outro IVR" cria a primeira forma de um menu apontar pra outro (uma InboundRoute nunca é ela mesma um menu, nunca formava ciclo antes). assertNoIvrCycle monta o grafo com todos os menus do tenant antes de compilar e rejeita qualquer save que criaria um ciclo, em create e update. Os dois editores de IVR (form clássico e o editor visual de nós) ganharam o mesmo par de dropdowns "Tipo"/"Destino" já usado em Rotas de Entrada. Testado ponta a ponta com Playwright, tenant/fila/2 menus de IVR reais criados na hora: menu com opção Fila e menu com opção Outro IVR apontando pro primeiro, os dois persistindo certo depois de reload completo. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BFaBaBSQGhyXGEgtTYZGV8
This commit is contained in:
@@ -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';
|
||||
@@ -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?
|
||||
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user