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:
2026-08-31 09:41:22 -03:00
parent 97ef8a6ba8
commit 432cd55adb
12 changed files with 427 additions and 78 deletions

39
TODO.md
View File

@@ -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 `sip-user`/`sip-domain`/`sip-server` corretos e a senha presente
(não vazia) assim que o agente com ramal vinculado abre `/app`. (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 ## Riscos conhecidos

View File

@@ -3,10 +3,18 @@ import { ArrayMaxSize, ArrayMinSize, IsArray, IsIn, IsNumber, IsOptional, IsStri
import { ALLOWED_IVR_DIGITS } from "@b2bcall/telephony"; import { ALLOWED_IVR_DIGITS } from "@b2bcall/telephony";
import { IsSafeDialplanData } from "../../dialplan/dto/safe-dialplan-data.validator"; import { IsSafeDialplanData } from "../../dialplan/dto/safe-dialplan-data.validator";
const IVR_OPTION_DESTINATION_TYPES = ["EXTENSION", "QUEUE", "IVR"] as const;
export class IvrMenuOptionDto { export class IvrMenuOptionDto {
@IsIn(ALLOWED_IVR_DIGITS) @IsIn(ALLOWED_IVR_DIGITS)
digit!: string; 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() @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;

View File

@@ -49,6 +49,53 @@ function promptHostPath(tenantId: string, menuId: string): string {
return join(IVR_PROMPTS_HOST_ROOT, tenantId, `${menuId}.wav`); 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<void> {
const menus = await tx.ivrMenu.findMany({
where: { tenantId, deletedAt: null },
select: { context: true, options: { select: { destinationType: true, destinationContext: true } } },
});
const edges = new Map<string, string[]>();
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<string>();
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 * 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` * editor genérico de dialplan (PHASE 56/57): criar/editar um `IvrMenu`
@@ -133,22 +180,31 @@ export class IvrMenusController {
const prisma = getPrismaClient(); const prisma = getPrismaClient();
const tenantId = user.tenantId!; 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; let menu;
try { try {
menu = await withTenantContext(prisma, tenantId, async (tx) => { menu = await withTenantContext(prisma, tenantId, async (tx) => {
await assertNoIvrCycle(tx, tenantId, dto.context, createOptions);
const created = await tx.ivrMenu.create({ const created = await tx.ivrMenu.create({
data: { tenantId, name: dto.name, context: dto.context, greeting: dto.greeting }, data: { tenantId, name: dto.name, context: dto.context, greeting: dto.greeting },
}); });
await tx.ivrMenuOption.createMany({ await tx.ivrMenuOption.createMany({
data: dto.options.map((opt) => ({ data: createOptions.map((opt, i) => ({
tenantId, tenantId,
ivrMenuId: created.id, ivrMenuId: created.id,
digit: opt.digit, digit: opt.digit,
destinationType: opt.destinationType,
destinationNumber: opt.destinationNumber, destinationNumber: opt.destinationNumber,
destinationContext: opt.destinationContext ?? "default", destinationContext: opt.destinationContext,
label: opt.label, label: dto.options[i].label,
positionX: opt.positionX, positionX: dto.options[i].positionX,
positionY: opt.positionY, positionY: dto.options[i].positionY,
})), })),
}); });
return created; return created;
@@ -160,11 +216,6 @@ export class IvrMenusController {
throw err; 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 compileAndActivateIvrDialplan(prisma, tenantId, user.sub, menu, createOptions);
await recordAuditEvent(prisma, { await recordAuditEvent(prisma, {
@@ -219,7 +270,24 @@ export class IvrMenusController {
); );
if (!existing) throw new NotFoundException(); 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) => { const menu = await withTenantContext(prisma, tenantId, async (tx) => {
if (dto.options) {
await assertNoIvrCycle(tx, tenantId, existing.context, options);
}
const updated = await tx.ivrMenu.update({ const updated = await tx.ivrMenu.update({
where: { id }, where: { id },
data: { data: {
@@ -232,12 +300,13 @@ export class IvrMenusController {
if (dto.options) { if (dto.options) {
await tx.ivrMenuOption.deleteMany({ where: { ivrMenuId: id } }); await tx.ivrMenuOption.deleteMany({ where: { ivrMenuId: id } });
await tx.ivrMenuOption.createMany({ await tx.ivrMenuOption.createMany({
data: dto.options.map((opt) => ({ data: dto.options.map((opt, i) => ({
tenantId, tenantId,
ivrMenuId: id, ivrMenuId: id,
digit: opt.digit, digit: opt.digit,
destinationType: options[i].destinationType,
destinationNumber: opt.destinationNumber, destinationNumber: opt.destinationNumber,
destinationContext: opt.destinationContext ?? "default", destinationContext: options[i].destinationContext,
label: opt.label, label: opt.label,
positionX: opt.positionX, positionX: opt.positionX,
positionY: opt.positionY, positionY: opt.positionY,
@@ -247,10 +316,6 @@ export class IvrMenusController {
return updated; 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 compileAndActivateIvrDialplan(prisma, tenantId, user.sub, menu, options);
await recordAuditEvent(prisma, { await recordAuditEvent(prisma, {
@@ -342,6 +407,7 @@ export class IvrMenusController {
const options: IvrMenuOptionInput[] = menu.options.map((o) => ({ const options: IvrMenuOptionInput[] = menu.options.map((o) => ({
digit: o.digit, digit: o.digit,
destinationType: o.destinationType,
destinationNumber: o.destinationNumber, destinationNumber: o.destinationNumber,
destinationContext: o.destinationContext, destinationContext: o.destinationContext,
})); }));
@@ -380,6 +446,7 @@ export class IvrMenusController {
const options: IvrMenuOptionInput[] = menu.options.map((o) => ({ const options: IvrMenuOptionInput[] = menu.options.map((o) => ({
digit: o.digit, digit: o.digit,
destinationType: o.destinationType,
destinationNumber: o.destinationNumber, destinationNumber: o.destinationNumber,
destinationContext: o.destinationContext, destinationContext: o.destinationContext,
})); }));

View File

@@ -3,7 +3,7 @@
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
import { requireSession } from "@/lib/session"; import { requireSession } from "@/lib/session";
import { apiFetch, ApiError, API_BASE_URL } from "@/lib/api"; 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 { function extractErrorMessage(err: unknown): string {
if (err instanceof ApiError) { if (err instanceof ApiError) {
@@ -21,7 +21,9 @@ function extractErrorMessage(err: unknown): string {
export interface IvrMenuOptionInput { export interface IvrMenuOptionInput {
digit: string; digit: string;
destinationType?: IvrOptionDestinationType;
destinationNumber: string; destinationNumber: string;
destinationContext?: string;
label?: string; label?: string;
positionX?: number; positionX?: number;
positionY?: number; positionY?: number;

View File

@@ -19,14 +19,23 @@ import "@xyflow/react/dist/style.css";
import { PhoneIncoming, Plus, Save, Trash2 } from "lucide-react"; import { PhoneIncoming, Plus, Save, Trash2 } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input, Select } from "@/components/ui/input"; 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 type { Extension } from "@/lib/extension-types";
import { updateIvrMenuOptions, type IvrMenuOptionInput } from "./actions"; import { updateIvrMenuOptions, type IvrMenuOptionInput } from "./actions";
interface OptionState { interface OptionState {
key: string; key: string;
digit: string; digit: string;
destinationNumber: string; destinationType: IvrOptionDestinationType;
destinationValue: string;
label: string; label: string;
positionX: number | null; positionX: number | null;
positionY: number | null; positionY: number | null;
@@ -37,6 +46,8 @@ interface EntryNodeData extends Record<string, unknown> {}
interface OptionNodeData extends Record<string, unknown> { interface OptionNodeData extends Record<string, unknown> {
option: OptionState; option: OptionState;
extensions: Extension[]; extensions: Extension[];
queues: Queue[];
menus: IvrMenu[];
usedDigits: string[]; usedDigits: string[];
onChange: (key: string, patch: Partial<OptionState>) => void; onChange: (key: string, patch: Partial<OptionState>) => void;
onRemove: (key: string) => void; onRemove: (key: string) => void;
@@ -56,7 +67,7 @@ function EntryNode(_props: NodeProps<Node<EntryNodeData, "entry">>) {
} }
function OptionNode({ data }: NodeProps<Node<OptionNodeData, "option">>) { function OptionNode({ data }: NodeProps<Node<OptionNodeData, "option">>) {
const { option, extensions, usedDigits, onChange, onRemove } = data; const { option, extensions, queues, menus, usedDigits, onChange, onRemove } = data;
return ( return (
<div className="w-[230px] space-y-2 rounded-lg border border-border bg-surface px-3 py-3 shadow-panel"> <div className="w-[230px] space-y-2 rounded-lg border border-border bg-surface px-3 py-3 shadow-panel">
<Handle type="target" position={Position.Top} /> <Handle type="target" position={Position.Top} />
@@ -85,15 +96,39 @@ function OptionNode({ data }: NodeProps<Node<OptionNodeData, "option">>) {
</div> </div>
<Select <Select
className="nodrag" className="nodrag"
value={option.destinationNumber} value={option.destinationType}
onChange={(e) => onChange(option.key, { destinationNumber: e.target.value })} onChange={(e) => onChange(option.key, { destinationType: e.target.value as IvrOptionDestinationType, destinationValue: "" })}
> >
<option value="">Selecione um ramal</option> {IVR_OPTION_DESTINATION_TYPES.map((t) => (
{extensions.map((ext) => ( <option key={t} value={t}>
{IVR_OPTION_DESTINATION_TYPE_LABELS[t]}
</option>
))}
</Select>
<Select
className="nodrag"
value={option.destinationValue}
onChange={(e) => onChange(option.key, { destinationValue: e.target.value })}
>
<option value="">Selecione</option>
{option.destinationType === "EXTENSION" &&
extensions.map((ext) => (
<option key={ext.id} value={ext.number}> <option key={ext.id} value={ext.number}>
{ext.number} {ext.name} {ext.number} {ext.name}
</option> </option>
))} ))}
{option.destinationType === "QUEUE" &&
queues.map((q) => (
<option key={q.id} value={q.id}>
{q.name}
</option>
))}
{option.destinationType === "IVR" &&
menus.map((m) => (
<option key={m.id} value={m.id}>
{m.name}
</option>
))}
</Select> </Select>
<Input <Input
className="nodrag" className="nodrag"
@@ -114,6 +149,8 @@ function layoutNodes(
options: OptionState[], options: OptionState[],
entryPosition: { x: number; y: number } | null, entryPosition: { x: number; y: number } | null,
extensions: Extension[], extensions: Extension[],
queues: Queue[],
menus: IvrMenu[],
onChange: OptionNodeData["onChange"], onChange: OptionNodeData["onChange"],
onRemove: OptionNodeData["onRemove"], onRemove: OptionNodeData["onRemove"],
) { ) {
@@ -129,7 +166,7 @@ function layoutNodes(
id: option.key, id: option.key,
type: "option", type: "option",
position: option.positionX != null && option.positionY != null ? { x: option.positionX, y: option.positionY } : { x: i * COLUMN_WIDTH, y: 160 }, position: option.positionX != null && option.positionY != null ? { x: option.positionX, y: option.positionY } : { x: i * COLUMN_WIDTH, y: 160 },
data: { option, extensions, usedDigits, onChange, onRemove }, data: { option, extensions, queues, menus, usedDigits, onChange, onRemove },
draggable: true, draggable: true,
})); }));
const edges: Edge[] = options.map((option) => ({ const edges: Edge[] = options.map((option) => ({
@@ -155,18 +192,29 @@ function nextKey(): string {
* direto os campos (dígito/ramal/descrição); salvar recompila o * direto os campos (dígito/ramal/descrição); salvar recompila o
* dialplan no backend exatamente como o formulário antigo fazia. * 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<OptionState[]>( const initialOptions = useMemo<OptionState[]>(
() => () =>
menu.options.map((o) => ({ menu.options.map((o) => ({
key: o.id, key: o.id,
digit: o.digit, 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 ?? "", label: o.label ?? "",
positionX: o.positionX, positionX: o.positionX,
positionY: o.positionY, positionY: o.positionY,
})), })),
[menu.options], [menu.options, menus],
); );
const initialEntryPosition = useMemo( const initialEntryPosition = useMemo(
() => (menu.entryPositionX != null && menu.entryPositionY != null ? { x: menu.entryPositionX, y: menu.entryPositionY } : null), () => (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( 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) // 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 [nodes, setNodes, onNodesChange] = useNodesState(initialNodes);
const [edges, setEdges, onEdgesChange] = useEdgesState(initialEdges); const [edges, setEdges, onEdgesChange] = useEdgesState(initialEdges);
@@ -212,7 +260,10 @@ export function IvrFlowEditor({ menu, extensions }: { menu: IvrMenu; extensions:
setSaved(false); setSaved(false);
const usedDigits = new Set(options.map((o) => o.digit)); const usedDigits = new Set(options.map((o) => o.digit));
const nextDigit = ALLOWED_IVR_DIGITS.find((d) => !usedDigits.has(d)) ?? "1"; 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() { function onSave() {
@@ -221,8 +272,8 @@ export function IvrFlowEditor({ menu, extensions }: { menu: IvrMenu; extensions:
setError("O menu precisa de pelo menos uma opção."); setError("O menu precisa de pelo menos uma opção.");
return; return;
} }
if (options.some((o) => !o.destinationNumber.trim())) { if (options.some((o) => !o.destinationValue.trim())) {
setError("Toda opção precisa de um ramal de destino selecionado."); setError("Toda opção precisa de um destino selecionado.");
return; return;
} }
const digits = options.map((o) => o.digit); 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. // pelo react-flow é o estado de nós, nunca `data.option` de volta.
const payload: IvrMenuOptionInput[] = options.map((o) => { const payload: IvrMenuOptionInput[] = options.map((o) => {
const node = nodes.find((n) => n.id === o.key); 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 { return {
digit: o.digit, digit: o.digit,
destinationNumber: o.destinationNumber.trim(), destinationType: "IVR",
destinationNumber: IVR_ENTRY_DESTINATION,
destinationContext: target?.context ?? "default",
label: o.label.trim() || undefined, label: o.label.trim() || undefined,
positionX: node?.position.x, ...position,
positionY: node?.position.y, };
}
return {
digit: o.digit,
destinationType: o.destinationType,
destinationNumber: o.destinationValue.trim(),
label: o.label.trim() || undefined,
...position,
}; };
}); });
const entryNode = nodes.find((n) => n.id === ENTRY_NODE_ID); const entryNode = nodes.find((n) => n.id === ENTRY_NODE_ID);

View File

@@ -8,7 +8,15 @@ import { Button } from "@/components/ui/button";
import { Input, Select, 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 } from "@/components/ui/table"; 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 type { Extension } from "@/lib/extension-types";
import { createIvrMenu, deleteIvrMenu, deleteIvrMenuPrompt, uploadIvrMenuPrompt, type IvrMenuOptionInput } from "./actions"; import { createIvrMenu, deleteIvrMenu, deleteIvrMenuPrompt, uploadIvrMenuPrompt, type IvrMenuOptionInput } from "./actions";
import { IvrFlowEditor } from "./ivr-flow-editor"; 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); const [showForm, setShowForm] = useState(false);
return ( return (
@@ -47,7 +55,15 @@ export function IvrView({ menus, extensions }: { menus: IvrMenu[]; extensions: E
</Button> </Button>
</div> </div>
{showForm && <NewIvrMenuForm extensions={extensions} existingContexts={menus.map((m) => m.context)} onDone={() => setShowForm(false)} />} {showForm && (
<NewIvrMenuForm
extensions={extensions}
queues={queues}
menus={menus}
existingContexts={menus.map((m) => m.context)}
onDone={() => setShowForm(false)}
/>
)}
<Panel> <Panel>
<PanelHeader title="Menus cadastrados" description={`${menus.length} menu(s) neste tenant`} /> <PanelHeader title="Menus cadastrados" description={`${menus.length} menu(s) neste tenant`} />
@@ -70,7 +86,12 @@ export function IvrView({ menus, extensions }: { menus: IvrMenu[]; extensions: E
<span className="text-foreground">{IVR_ENTRY_DESTINATION}</span> <span className="text-foreground">{IVR_ENTRY_DESTINATION}</span>
</p> </p>
<PromptControl menu={menu} /> <PromptControl menu={menu} />
<IvrFlowEditor menu={menu} extensions={extensions} /> <IvrFlowEditor
menu={menu}
extensions={extensions}
queues={queues}
menus={menus.filter((m) => m.id !== menu.id)}
/>
</li> </li>
))} ))}
</ul> </ul>
@@ -143,23 +164,28 @@ function PromptControl({ menu }: { menu: IvrMenu }) {
interface OptionRow { interface OptionRow {
digit: string; digit: string;
destinationNumber: string; destinationType: IvrOptionDestinationType;
destinationValue: string;
label: string; label: string;
} }
function NewIvrMenuForm({ function NewIvrMenuForm({
extensions, extensions,
queues,
menus,
existingContexts, existingContexts,
onDone, onDone,
}: { }: {
extensions: Extension[]; extensions: Extension[];
queues: Queue[];
menus: IvrMenu[];
existingContexts: string[]; existingContexts: string[];
onDone: () => void; onDone: () => void;
}) { }) {
const [name, setName] = useState(""); const [name, setName] = useState("");
const [context, setContext] = useState(""); const [context, setContext] = useState("");
const [contextTouched, setContextTouched] = useState(false); const [contextTouched, setContextTouched] = useState(false);
const [options, setOptions] = useState<OptionRow[]>([{ digit: "1", destinationNumber: "", label: "" }]); const [options, setOptions] = useState<OptionRow[]>([{ digit: "1", destinationType: "EXTENSION", destinationValue: "", label: "" }]);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [pending, startTransition] = useTransition(); const [pending, startTransition] = useTransition();
@@ -172,7 +198,7 @@ function NewIvrMenuForm({
function addOption() { function addOption() {
const nextDigit = ALLOWED_IVR_DIGITS.find((d) => !usedDigits.has(d)) ?? "1"; 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) { function removeOption(index: number) {
@@ -190,8 +216,8 @@ function NewIvrMenuForm({
setError(`Já existe um menu com o contexto "${effectiveContext}" — escolha outro nome.`); setError(`Já existe um menu com o contexto "${effectiveContext}" — escolha outro nome.`);
return; return;
} }
if (options.length === 0 || options.some((o) => !o.destinationNumber.trim())) { if (options.length === 0 || options.some((o) => !o.destinationValue.trim())) {
setError("Toda opção precisa de um dígito e um ramal de destino."); setError("Toda opção precisa de um dígito e um destino selecionado.");
return; return;
} }
const digits = options.map((o) => o.digit); const digits = options.map((o) => o.digit);
@@ -200,11 +226,24 @@ function NewIvrMenuForm({
return; return;
} }
const payloadOptions: IvrMenuOptionInput[] = options.map((o) => ({ const payloadOptions: IvrMenuOptionInput[] = options.map((o) => {
if (o.destinationType === "IVR") {
const target = menus.find((m) => m.id === o.destinationValue);
return {
digit: o.digit, digit: o.digit,
destinationNumber: o.destinationNumber.trim(), destinationType: "IVR",
destinationNumber: IVR_ENTRY_DESTINATION,
destinationContext: target?.context ?? "default",
label: o.label.trim() || undefined, label: o.label.trim() || undefined,
})); };
}
return {
digit: o.digit,
destinationType: o.destinationType,
destinationNumber: o.destinationValue.trim(),
label: o.label.trim() || undefined,
};
});
startTransition(async () => { startTransition(async () => {
const result = await createIvrMenu({ name: name.trim(), context: effectiveContext, options: payloadOptions }); const result = await createIvrMenu({ name: name.trim(), context: effectiveContext, options: payloadOptions });
@@ -247,7 +286,7 @@ function NewIvrMenuForm({
</Button> </Button>
</div> </div>
{options.map((opt, i) => ( {options.map((opt, i) => (
<div key={i} className="grid grid-cols-1 gap-3 rounded-md border border-border p-3 sm:grid-cols-[6rem_1fr_1fr_auto]"> <div key={i} className="grid grid-cols-1 gap-3 rounded-md border border-border p-3 sm:grid-cols-[5rem_7rem_1fr_1fr_auto]">
<div> <div>
<FieldLabel htmlFor={`ivr-opt-${i}-digit`}>Dígito</FieldLabel> <FieldLabel htmlFor={`ivr-opt-${i}-digit`}>Dígito</FieldLabel>
<Select <Select
@@ -264,20 +303,51 @@ function NewIvrMenuForm({
</Select> </Select>
</div> </div>
<div> <div>
<FieldLabel htmlFor={`ivr-opt-${i}-dest`}>Ramal de destino</FieldLabel> <FieldLabel htmlFor={`ivr-opt-${i}-type`}>Tipo</FieldLabel>
<Select <Select
id={`ivr-opt-${i}-dest`} id={`ivr-opt-${i}-type`}
value={opt.destinationNumber} value={opt.destinationType}
onChange={(e) => updateOption(i, { destinationNumber: e.target.value })} onChange={(e) => updateOption(i, { destinationType: e.target.value as IvrOptionDestinationType, destinationValue: "" })}
disabled={pending} disabled={pending}
> >
<option value="">Selecione um ramal</option> {IVR_OPTION_DESTINATION_TYPES.map((t) => (
{extensions.map((ext) => ( <option key={t} value={t}>
{IVR_OPTION_DESTINATION_TYPE_LABELS[t]}
</option>
))}
</Select>
</div>
<div>
<FieldLabel htmlFor={`ivr-opt-${i}-dest`}>Destino</FieldLabel>
<Select
id={`ivr-opt-${i}-dest`}
value={opt.destinationValue}
onChange={(e) => updateOption(i, { destinationValue: e.target.value })}
disabled={pending}
>
<option value="">Selecione</option>
{opt.destinationType === "EXTENSION" &&
extensions.map((ext) => (
<option key={ext.id} value={ext.number}> <option key={ext.id} value={ext.number}>
{ext.number} {ext.name} {ext.number} {ext.name}
</option> </option>
))} ))}
{opt.destinationType === "QUEUE" &&
queues.map((q) => (
<option key={q.id} value={q.id}>
{q.name}
</option>
))}
{opt.destinationType === "IVR" &&
menus.map((m) => (
<option key={m.id} value={m.id}>
{m.name}
</option>
))}
</Select> </Select>
{opt.destinationType === "IVR" && menus.length === 0 && (
<p className="mt-1 text-xs text-muted-foreground">Crie outro menu de IVR primeiro pra poder apontar pra ele.</p>
)}
</div> </div>
<div> <div>
<FieldLabel htmlFor={`ivr-opt-${i}-label`}>Descrição (opcional)</FieldLabel> <FieldLabel htmlFor={`ivr-opt-${i}-label`}>Descrição (opcional)</FieldLabel>

View File

@@ -1,14 +1,15 @@
import { requireSession } from "@/lib/session"; import { requireSession } from "@/lib/session";
import { apiFetch } from "@/lib/api"; import { apiFetch } from "@/lib/api";
import type { IvrMenu } from "@/lib/callcenter-types"; import type { IvrMenu, Queue } from "@/lib/callcenter-types";
import type { Extension } from "@/lib/extension-types"; import type { Extension } from "@/lib/extension-types";
import { IvrView } from "./ivr-view"; import { IvrView } from "./ivr-view";
export default async function IvrPage() { export default async function IvrPage() {
const session = await requireSession(); const session = await requireSession();
const [menus, extensions] = await Promise.all([ const [menus, extensions, queues] = await Promise.all([
apiFetch<IvrMenu[]>("/ivr-menus", session.accessToken), apiFetch<IvrMenu[]>("/ivr-menus", session.accessToken),
apiFetch<Extension[]>("/extensions", session.accessToken), apiFetch<Extension[]>("/extensions", session.accessToken),
apiFetch<Queue[]>("/queues", session.accessToken),
]); ]);
return <IvrView menus={menus} extensions={extensions} />; return <IvrView menus={menus} extensions={extensions} queues={queues} />;
} }

View File

@@ -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). */ * aponta pra um IvrMenu usa isso como destinationNumber (packages/telephony). */
export const IVR_ENTRY_DESTINATION = "ivr_entry"; 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<IvrOptionDestinationType, string> = {
EXTENSION: "Ramal",
QUEUE: "Fila",
IVR: "Outro IVR",
};
export interface IvrMenuOption { export interface IvrMenuOption {
id: string; id: string;
digit: string; digit: string;
destinationType: IvrOptionDestinationType;
destinationNumber: string; destinationNumber: string;
destinationContext: string; destinationContext: string;
label: string | null; label: string | null;

View File

@@ -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 cada tipo (ramais cadastrados, menus de IVR, filas, ou os valores
distintos de `callGroup` já usados em algum ramal). 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="<queueId>@<domain>"`.
- **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 ## 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
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 - 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 não foi pedido) um jeito de discar um grupo de dentro do próprio
dialplan "default" via feature code. dialplan "default" via feature code.

View File

@@ -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';

View File

@@ -545,6 +545,14 @@ model IvrMenu {
@@map("ivr_menus") @@map("ivr_menus")
} }
enum IvrOptionDestinationType {
EXTENSION
QUEUE
IVR
@@map("ivr_option_destination_type")
}
model IvrMenuOption { model IvrMenuOption {
id String @id @default(uuid()) @db.Uuid id String @id @default(uuid()) @db.Uuid
tenantId String @map("tenant_id") @db.Uuid tenantId String @map("tenant_id") @db.Uuid
@@ -552,6 +560,25 @@ model IvrMenuOption {
digit String // "0".."9", "*" ou "#" — validado na API, é o que vira o regex da extension de branching digit String // "0".."9", "*" ou "#" — validado na API, é o que vira o regex da extension de branching
// 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 destinationNumber String @map("destination_number") // ramal real dentro do contexto abaixo
destinationContext String @default("default") @map("destination_context") destinationContext String @default("default") @map("destination_context")

View File

@@ -23,6 +23,7 @@ function escapeRegexLiteral(digit: string): string {
export interface IvrMenuOptionInput { export interface IvrMenuOptionInput {
digit: string; digit: string;
destinationType: "EXTENSION" | "QUEUE" | "IVR";
destinationNumber: string; destinationNumber: string;
destinationContext: string; destinationContext: string;
} }
@@ -38,6 +39,14 @@ export interface IvrMenuOptionInput {
* por uma extension nunca é enxergada por OUTRA extension na mesma * por uma extension nunca é enxergada por OUTRA extension na mesma
* passada; só um `transfer` (nova consulta de dialplan) resolve isso — * passada; só um `transfer` (nova consulta de dialplan) resolve isso —
* e uma extension por dígito, casando por `destination_number` normal. * 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( export function buildIvrDialplanExtensions(
menu: { context: string; greeting?: string | null }, menu: { context: string; greeting?: string | null },
@@ -61,14 +70,27 @@ export function buildIvrDialplanExtensions(
], ],
}; };
const branches: DialplanExtensionInput[] = options.map((opt, i) => ({ 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}`, name: `IVR opção ${opt.digit}`,
conditionField: "destination_number", conditionField: "destination_number",
conditionExpr: `^${escapeRegexLiteral(opt.digit)}$`, conditionExpr: `^${escapeRegexLiteral(opt.digit)}$`,
continueOnFalse: false, continueOnFalse: false,
order: 10 + i, order: 10 + i,
actions: [{ application: "bridge", data: `user/${opt.destinationNumber}@\${domain_name}` }], actions,
})); };
});
return [entry, ...branches]; return [entry, ...branches];
} }