diff --git a/TODO.md b/TODO.md index 557d91f..8db9619 100644 --- a/TODO.md +++ b/TODO.md @@ -2117,6 +2117,35 @@ reportado pelo usuário: "a ligacao entre ramais nao esta funcionando") contexto `default` já nasce `ACTIVE` com as 2 regras. Tenant de teste suspenso ao final (sem endpoint de delete de tenant) +## PHASE 58 — Tela de autoria de IVR (pedido do usuário: "constrói a +tela de IVR no frontend") +- [x] `IvrMenu`/`IvrMenuOption` (novo, RLS real) — nome + contexto + (derivado do nome) + opções (dígito → ramal + rótulo). Nenhuma + tabela nova pro dialplan em si: `IvrMenusController` compila o + menu em `DialplanExtension`/`DialplanVersion` do contexto do menu + (mesmas 2 formas de `` já testadas com DTMF real na + PHASE 56 — entrada com `play_and_get_digits` + `transfer`, uma + extension por dígito) e já gera + ativa a versão nova, automático +- [x] `greeting` (prompt do menu) passa pela MESMA proteção anti-RCE já + aplicada em `data` de dialplan (`IsSafeDialplanData`) — é texto + livre do tenant, não pode virar `${system(...)}` +- [x] Tela "Telefonia > IVR": lista de menus com as opções de cada um, + criação com nome/contexto (auto-gerado, editável) + linhas + dinâmicas de opção (dígito + select de ramal existente + rótulo + opcional), remoção com confirmação de 2 cliques. Mostra o + contexto/destino fixo (`ivr_entry`) que uma Rota de Entrada precisa + usar pra apontar pro menu +- [x] Testado ponta a ponta criando um menu DE VERDADE pela API (não só + lendo o XML manualmente escrito antes): softphone externo discou + um DID apontado pro menu recém-criado, atendeu, tocou o prompt, + colheu o dígito com DTMF real (`uuid_recv_dtmf`) e bridged com o + ramal certo — confirma que o compilador produz XML funcionalmente + idêntico ao testado manualmente na PHASE 56 +- [ ] Sem pipeline de upload/TTS de prompt de áudio (texto livre/tom + padrão só); sem sub-menu (IVR dentro de IVR) nem destino "fila"; + "Rotas de Entrada" ainda não tem um seletor dedicado de "IVR" como + destino (usuário copia contexto/`ivr_entry` da tela de IVR) + --- ## Riscos conhecidos diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index 7fe7ea2..5b9c8dc 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -4,6 +4,7 @@ import { AuthModule } from "./auth/auth.module"; import { ExtensionsModule } from "./extensions/extensions.module"; import { TrunksModule } from "./trunks/trunks.module"; import { InboundRoutesModule } from "./inbound-routes/inbound-routes.module"; +import { IvrModule } from "./ivr/ivr.module"; import { DialplanModule } from "./dialplan/dialplan.module"; import { QueuesModule } from "./queues/queues.module"; import { AgentsModule } from "./agents/agents.module"; @@ -31,6 +32,7 @@ import { PlansModule } from "./plans/plans.module"; ExtensionsModule, TrunksModule, InboundRoutesModule, + IvrModule, DialplanModule, QueuesModule, AgentsModule, diff --git a/apps/api/src/ivr/dto/create-ivr-menu.dto.ts b/apps/api/src/ivr/dto/create-ivr-menu.dto.ts new file mode 100644 index 0000000..2e271e1 --- /dev/null +++ b/apps/api/src/ivr/dto/create-ivr-menu.dto.ts @@ -0,0 +1,69 @@ +import { Type } from "class-transformer"; +import { ArrayMaxSize, ArrayMinSize, IsArray, IsIn, IsOptional, IsString, Matches, MaxLength, ValidateNested } from "class-validator"; +import { ALLOWED_IVR_DIGITS } from "@b2bcall/telephony"; +import { IsSafeDialplanData } from "../../dialplan/dto/safe-dialplan-data.validator"; + +export class IvrMenuOptionDto { + @IsIn(ALLOWED_IVR_DIGITS) + digit!: string; + + @IsString() + @Matches(/^[a-zA-Z0-9_-]{1,40}$/, { message: "destinationNumber deve ser alfanumérico (1 a 40 caracteres)" }) + destinationNumber!: string; + + @IsOptional() + @IsString() + @MaxLength(80) + destinationContext?: string; + + @IsOptional() + @IsString() + @MaxLength(80) + label?: string; +} + +export class CreateIvrMenuDto { + @IsString() + @MaxLength(80) + name!: string; + + // Vira o `context` do dialplan compilado — derivado do nome no + // frontend (slug), mas validado aqui como qualquer outro context. + @IsString() + @Matches(/^[a-z0-9-]{1,60}$/, { message: "context deve ser minúsculo, com letras/números/hífen (1 a 60 caracteres)" }) + context!: string; + + @IsOptional() + @IsString() + @MaxLength(500) + @IsSafeDialplanData({ message: "greeting usa uma função não permitida (ver docs/EXTENSIONS.md — nunca system/bg_system/curl/db/lua/shell)" }) + greeting?: string; + + @IsArray() + @ArrayMinSize(1) + @ArrayMaxSize(12) + @ValidateNested({ each: true }) + @Type(() => IvrMenuOptionDto) + options!: IvrMenuOptionDto[]; +} + +export class UpdateIvrMenuDto { + @IsOptional() + @IsString() + @MaxLength(80) + name?: string; + + @IsOptional() + @IsString() + @MaxLength(500) + @IsSafeDialplanData({ message: "greeting usa uma função não permitida (ver docs/EXTENSIONS.md — nunca system/bg_system/curl/db/lua/shell)" }) + greeting?: string; + + @IsOptional() + @IsArray() + @ArrayMinSize(1) + @ArrayMaxSize(12) + @ValidateNested({ each: true }) + @Type(() => IvrMenuOptionDto) + options?: IvrMenuOptionDto[]; +} diff --git a/apps/api/src/ivr/ivr-menus.controller.ts b/apps/api/src/ivr/ivr-menus.controller.ts new file mode 100644 index 0000000..232efba --- /dev/null +++ b/apps/api/src/ivr/ivr-menus.controller.ts @@ -0,0 +1,259 @@ +import { + BadRequestException, + Body, + ConflictException, + Controller, + Delete, + Get, + HttpCode, + HttpStatus, + NotFoundException, + Param, + Patch, + Post, + UseGuards, +} from "@nestjs/common"; +import { XMLValidator } from "fast-xml-parser"; +import { getPrismaClient, withTenantContext, Prisma } from "@b2bcall/database"; +import { recordAuditEvent, type AccessTokenClaims } from "@b2bcall/auth"; +import { buildDialplanXml, buildIvrDialplanExtensions, type IvrMenuOptionInput } from "@b2bcall/telephony"; +import { JwtAuthGuard } from "../common/guards/jwt-auth.guard"; +import { PermissionGuard } from "../common/guards/permission.guard"; +import { RequirePermission } from "../common/decorators/require-permission.decorator"; +import { CurrentUser } from "../common/decorators/current-user.decorator"; +import { CreateIvrMenuDto, UpdateIvrMenuDto } from "./dto/create-ivr-menu.dto"; + +/** + * 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` + * recompila e reativa uma versão nova do contexto correspondente, o + * mesmo fluxo generate+activate que o editor manual faz, só que + * automático. Uma `InboundRoute` aponta pra cá com + * `destinationContext = IvrMenu.context` e + * `destinationNumber = IVR_ENTRY_DESTINATION` ("ivr_entry"). + */ +async function compileAndActivateIvrDialplan( + prisma: ReturnType, + tenantId: string, + userId: string, + menu: { context: string; greeting: string | null }, + options: IvrMenuOptionInput[], +): Promise { + // Substitui as linhas compiladas anteriores desse contexto — nunca + // acumula lixo de compilações antigas (mesmo padrão de "editar" já + // usado em reset-password/reveal-password: nunca reexpor/reaproveitar + // o estado velho, sempre um recorte limpo do estado atual). + await withTenantContext(prisma, tenantId, (tx) => + tx.dialplanExtension.updateMany({ + where: { tenantId, context: menu.context, deletedAt: null }, + data: { deletedAt: new Date(), enabled: false }, + }), + ); + + const compiled = buildIvrDialplanExtensions(menu, options); + await withTenantContext(prisma, tenantId, async (tx) => { + for (const ext of compiled) { + await tx.dialplanExtension.create({ + data: { + tenantId, + context: menu.context, + name: ext.name, + conditionField: ext.conditionField, + conditionExpr: ext.conditionExpr, + actions: ext.actions as unknown as Prisma.InputJsonValue, + continueOnFalse: ext.continueOnFalse, + order: ext.order, + }, + }); + } + }); + + const xml = buildDialplanXml(menu.context, compiled); + const validation = XMLValidator.validate(xml); + if (validation !== true) { + throw new BadRequestException(`XML gerado invalido: ${validation.err.msg}`); + } + + const last = await withTenantContext(prisma, tenantId, (tx) => + tx.dialplanVersion.findFirst({ where: { tenantId, context: menu.context }, orderBy: { version: "desc" } }), + ); + const nextVersion = (last?.version ?? 0) + 1; + + await withTenantContext(prisma, tenantId, async (tx) => { + await tx.dialplanVersion.updateMany({ + where: { tenantId, context: menu.context, status: "ACTIVE" }, + data: { status: "SUPERSEDED" }, + }); + await tx.dialplanVersion.create({ + data: { + tenantId, + context: menu.context, + version: nextVersion, + generatedXml: xml, + status: "ACTIVE", + createdByUserId: userId, + activatedAt: new Date(), + }, + }); + }); +} + +@UseGuards(JwtAuthGuard, PermissionGuard) +@Controller("ivr-menus") +export class IvrMenusController { + @RequirePermission("ivr.manage") + @Post() + async create(@CurrentUser() user: AccessTokenClaims, @Body() dto: CreateIvrMenuDto) { + const prisma = getPrismaClient(); + const tenantId = user.tenantId!; + + let menu; + try { + menu = await withTenantContext(prisma, tenantId, async (tx) => { + 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) => ({ + tenantId, + ivrMenuId: created.id, + digit: opt.digit, + destinationNumber: opt.destinationNumber, + destinationContext: opt.destinationContext ?? "default", + label: opt.label, + })), + }); + return created; + }); + } catch (err) { + if (err instanceof Prisma.PrismaClientKnownRequestError && err.code === "P2002") { + throw new ConflictException("Já existe um menu de IVR com esse contexto neste tenant"); + } + 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, { + action: "IVR_MENU_CREATE", + tenantId, + userId: user.sub, + entityType: "ivr_menu", + entityId: menu.id, + after: { name: menu.name, context: menu.context, optionCount: dto.options.length }, + }); + + return this.get(user, menu.id); + } + + @RequirePermission("ivr.view") + @Get() + async list(@CurrentUser() user: AccessTokenClaims) { + const prisma = getPrismaClient(); + const tenantId = user.tenantId!; + return withTenantContext(prisma, tenantId, (tx) => + tx.ivrMenu.findMany({ + where: { deletedAt: null }, + include: { options: { orderBy: { digit: "asc" } } }, + orderBy: { name: "asc" }, + }), + ); + } + + @RequirePermission("ivr.view") + @Get(":id") + async get(@CurrentUser() user: AccessTokenClaims, @Param("id") id: string) { + const prisma = getPrismaClient(); + const tenantId = user.tenantId!; + const menu = await withTenantContext(prisma, tenantId, (tx) => + tx.ivrMenu.findFirst({ + where: { id, deletedAt: null }, + include: { options: { orderBy: { digit: "asc" } } }, + }), + ); + if (!menu) throw new NotFoundException(); + return menu; + } + + @RequirePermission("ivr.manage") + @Patch(":id") + async update(@CurrentUser() user: AccessTokenClaims, @Param("id") id: string, @Body() dto: UpdateIvrMenuDto) { + const prisma = getPrismaClient(); + const tenantId = user.tenantId!; + + const existing = await withTenantContext(prisma, tenantId, (tx) => + tx.ivrMenu.findFirst({ where: { id, deletedAt: null }, include: { options: true } }), + ); + if (!existing) throw new NotFoundException(); + + const menu = await withTenantContext(prisma, tenantId, async (tx) => { + const updated = await tx.ivrMenu.update({ + where: { id }, + data: { ...(dto.name !== undefined ? { name: dto.name } : {}), ...(dto.greeting !== undefined ? { greeting: dto.greeting } : {}) }, + }); + if (dto.options) { + await tx.ivrMenuOption.deleteMany({ where: { ivrMenuId: id } }); + await tx.ivrMenuOption.createMany({ + data: dto.options.map((opt) => ({ + tenantId, + ivrMenuId: id, + digit: opt.digit, + destinationNumber: opt.destinationNumber, + destinationContext: opt.destinationContext ?? "default", + label: opt.label, + })), + }); + } + 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, { + action: "IVR_MENU_UPDATE", + tenantId, + userId: user.sub, + entityType: "ivr_menu", + entityId: id, + after: { name: menu.name, greeting: menu.greeting, optionCount: options.length }, + }); + + return this.get(user, id); + } + + @RequirePermission("ivr.manage") + @Delete(":id") + @HttpCode(HttpStatus.NO_CONTENT) + async remove(@CurrentUser() user: AccessTokenClaims, @Param("id") id: string) { + const prisma = getPrismaClient(); + const tenantId = user.tenantId!; + + const menu = await withTenantContext(prisma, tenantId, (tx) => tx.ivrMenu.findFirst({ where: { id, deletedAt: null } })); + if (!menu) throw new NotFoundException(); + + await withTenantContext(prisma, tenantId, async (tx) => { + await tx.ivrMenu.update({ where: { id }, data: { deletedAt: new Date(), enabled: false } }); + await tx.dialplanExtension.updateMany({ + where: { tenantId, context: menu.context, deletedAt: null }, + data: { deletedAt: new Date(), enabled: false }, + }); + }); + + await recordAuditEvent(prisma, { + action: "IVR_MENU_DELETE", + tenantId, + userId: user.sub, + entityType: "ivr_menu", + entityId: id, + }); + } +} diff --git a/apps/api/src/ivr/ivr.module.ts b/apps/api/src/ivr/ivr.module.ts new file mode 100644 index 0000000..3cbd382 --- /dev/null +++ b/apps/api/src/ivr/ivr.module.ts @@ -0,0 +1,7 @@ +import { Module } from "@nestjs/common"; +import { IvrMenusController } from "./ivr-menus.controller"; + +@Module({ + controllers: [IvrMenusController], +}) +export class IvrModule {} diff --git a/apps/frontend/src/app/app/telefonia/ivr/actions.ts b/apps/frontend/src/app/app/telefonia/ivr/actions.ts new file mode 100644 index 0000000..65cda3e --- /dev/null +++ b/apps/frontend/src/app/app/telefonia/ivr/actions.ts @@ -0,0 +1,55 @@ +"use server"; + +import { revalidatePath } from "next/cache"; +import { requireSession } from "@/lib/session"; +import { apiFetch, ApiError } from "@/lib/api"; +import type { IvrMenu } from "@/lib/callcenter-types"; + +function extractErrorMessage(err: unknown): string { + if (err instanceof ApiError) { + try { + const parsed = JSON.parse(err.message); + if (Array.isArray(parsed.message)) return parsed.message.join(" "); + if (typeof parsed.message === "string") return parsed.message; + } catch { + // corpo não era JSON + } + return err.message || "Falha inesperada na API."; + } + return "Falha inesperada. Tente novamente."; +} + +export interface IvrMenuOptionInput { + digit: string; + destinationNumber: string; + label?: string; +} + +export interface CreateIvrMenuInput { + name: string; + context: string; + greeting?: string; + options: IvrMenuOptionInput[]; +} + +export async function createIvrMenu(input: CreateIvrMenuInput): Promise<{ ok: true; menu: IvrMenu } | { ok: false; error: string }> { + const session = await requireSession(); + try { + const menu = await apiFetch("/ivr-menus", session.accessToken, { method: "POST", body: JSON.stringify(input) }); + revalidatePath("/app/telefonia/ivr"); + return { ok: true, menu }; + } catch (err) { + return { ok: false, error: extractErrorMessage(err) }; + } +} + +export async function deleteIvrMenu(id: string): Promise<{ ok: true } | { ok: false; error: string }> { + const session = await requireSession(); + try { + await apiFetch(`/ivr-menus/${id}`, session.accessToken, { method: "DELETE" }); + revalidatePath("/app/telefonia/ivr"); + return { ok: true }; + } catch (err) { + return { ok: false, error: extractErrorMessage(err) }; + } +} diff --git a/apps/frontend/src/app/app/telefonia/ivr/ivr-view.tsx b/apps/frontend/src/app/app/telefonia/ivr/ivr-view.tsx new file mode 100644 index 0000000..ed65923 --- /dev/null +++ b/apps/frontend/src/app/app/telefonia/ivr/ivr-view.tsx @@ -0,0 +1,314 @@ +"use client"; + +import { useMemo, useState, useTransition } from "react"; +import { useRouter } from "next/navigation"; +import { ListTree, Plus, Trash2, X } from "lucide-react"; +import { Panel, PanelHeader } from "@/components/ui/panel"; +import { Button } from "@/components/ui/button"; +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 { ALLOWED_IVR_DIGITS, IVR_ENTRY_DESTINATION, type IvrMenu } from "@/lib/callcenter-types"; +import type { Extension } from "@/lib/extension-types"; +import { createIvrMenu, deleteIvrMenu, type IvrMenuOptionInput } from "./actions"; + +function slugifyContext(name: string): string { + return ( + "ivr-" + + name + .normalize("NFD") + .replace(/[̀-ͯ]/g, "") + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, "") + ); +} + +export function IvrView({ menus, extensions }: { menus: IvrMenu[]; extensions: Extension[] }) { + const [showForm, setShowForm] = useState(false); + + return ( +
+
+
+

IVR

+

+ Menus de atendimento automático — a pessoa liga, ouve um prompt e escolhe um dígito, que cai num ramal + deste tenant. Pra receber ligações por esse menu, aponte uma rota de entrada (Telefonia > Rotas de + Entrada) pro contexto e destino mostrados abaixo de cada menu. +

+
+ +
+ + {showForm && m.context)} onDone={() => setShowForm(false)} />} + + + + {menus.length === 0 ? ( + + ) : ( +
    + {menus.map((menu) => ( +
  • +
    + + + {menu.name} + {menu.enabled ? "Ativo" : "Desativado"} + + +
    +

    + Rota de entrada: contexto {menu.context} · destino{" "} + {IVR_ENTRY_DESTINATION} +

    + + + + + + + + + + {menu.options.map((opt) => ( + + + + + + ))} + +
    DígitoDestinoDescrição
    {opt.digit}{opt.destinationNumber}{opt.label ?? "—"}
    +
  • + ))} +
+ )} +
+
+ ); +} + +interface OptionRow { + digit: string; + destinationNumber: string; + label: string; +} + +function NewIvrMenuForm({ + extensions, + existingContexts, + onDone, +}: { + extensions: Extension[]; + 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 [error, setError] = useState(null); + const [pending, startTransition] = useTransition(); + + const effectiveContext = contextTouched ? context : slugifyContext(name); + const usedDigits = useMemo(() => new Set(options.map((o) => o.digit)), [options]); + + function updateOption(index: number, patch: Partial) { + setOptions((prev) => prev.map((o, i) => (i === index ? { ...o, ...patch } : o))); + } + + function addOption() { + const nextDigit = ALLOWED_IVR_DIGITS.find((d) => !usedDigits.has(d)) ?? "1"; + setOptions((prev) => [...prev, { digit: nextDigit, destinationNumber: "", label: "" }]); + } + + function removeOption(index: number) { + setOptions((prev) => prev.filter((_, i) => i !== index)); + } + + function onSubmit(e: React.FormEvent) { + e.preventDefault(); + setError(null); + if (!name.trim() || !effectiveContext.trim()) { + setError("Nome é obrigatório."); + return; + } + if (existingContexts.includes(effectiveContext)) { + 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."); + return; + } + const digits = options.map((o) => o.digit); + if (new Set(digits).size !== digits.length) { + setError("Não pode repetir o mesmo dígito em duas opções."); + return; + } + + const payloadOptions: IvrMenuOptionInput[] = options.map((o) => ({ + digit: o.digit, + destinationNumber: o.destinationNumber.trim(), + label: o.label.trim() || undefined, + })); + + startTransition(async () => { + const result = await createIvrMenu({ name: name.trim(), context: effectiveContext, options: payloadOptions }); + if (!result.ok) { + setError(result.error); + return; + } + onDone(); + }); + } + + return ( + +
+
+
+ Nome do menu + setName(e.target.value)} placeholder="Ex.: Vendas" disabled={pending} /> +
+
+ Contexto (gerado do nome, editável) + { + setContextTouched(true); + setContext(e.target.value); + }} + placeholder="ivr-vendas" + disabled={pending} + /> +
+
+ +
+
+ Opções do menu + +
+ {options.map((opt, i) => ( +
+
+ Dígito + +
+
+ Ramal de destino + +
+
+ Descrição (opcional) + updateOption(i, { label: e.target.value })} + placeholder="Ex.: Vendas" + disabled={pending} + /> +
+
+ +
+
+ ))} +
+ + {error && ( +

+ {error} +

+ )} +
+ +
+
+
+ ); +} + +function DeleteIvrMenuButton({ menuId, menuName }: { menuId: string; menuName: string }) { + const router = useRouter(); + const [confirming, setConfirming] = useState(false); + const [pending, startTransition] = useTransition(); + const [error, setError] = useState(null); + + function onClick() { + if (!confirming) { + setConfirming(true); + return; + } + setError(null); + startTransition(async () => { + const result = await deleteIvrMenu(menuId); + if (!result.ok) { + setError(result.error); + setConfirming(false); + return; + } + router.refresh(); + }); + } + + return ( +
+ {error && {error}} + +
+ ); +} diff --git a/apps/frontend/src/app/app/telefonia/ivr/page.tsx b/apps/frontend/src/app/app/telefonia/ivr/page.tsx new file mode 100644 index 0000000..ea35daf --- /dev/null +++ b/apps/frontend/src/app/app/telefonia/ivr/page.tsx @@ -0,0 +1,14 @@ +import { requireSession } from "@/lib/session"; +import { apiFetch } from "@/lib/api"; +import type { IvrMenu } from "@/lib/callcenter-types"; +import type { Extension } from "@/lib/extension-types"; +import { IvrView } from "./ivr-view"; + +export default async function IvrPage() { + const session = await requireSession(); + const [menus, extensions] = await Promise.all([ + apiFetch("/ivr-menus", session.accessToken), + apiFetch("/extensions", session.accessToken), + ]); + return ; +} diff --git a/apps/frontend/src/components/tenant-shell/nav-data.ts b/apps/frontend/src/components/tenant-shell/nav-data.ts index 6af0cd9..f2b4274 100644 --- a/apps/frontend/src/components/tenant-shell/nav-data.ts +++ b/apps/frontend/src/components/tenant-shell/nav-data.ts @@ -105,6 +105,12 @@ export const TENANT_NAV: NavSection[] = [ description: "Números (DID) recebidos por tronco — pra qual ramal/fila/IVR cada um cai", permission: "inbound_routes.view", }, + { + label: "IVR", + href: "/app/telefonia/ivr", + description: "Menus de atendimento automático — prompt + dígito escolhido leva a um ramal", + permission: "ivr.view", + }, { label: "Dialplan", href: "/app/telefonia/dialplan", diff --git a/apps/frontend/src/lib/callcenter-types.ts b/apps/frontend/src/lib/callcenter-types.ts index 9a9ff90..66fd4c0 100644 --- a/apps/frontend/src/lib/callcenter-types.ts +++ b/apps/frontend/src/lib/callcenter-types.ts @@ -57,6 +57,30 @@ export interface InboundRoute { createdAt: string; } +export const ALLOWED_IVR_DIGITS = ["1", "2", "3", "4", "5", "6", "7", "8", "9", "0", "*", "#"] as const; + +/** destination_number fixo pra entrar num menu — toda InboundRoute que + * aponta pra um IvrMenu usa isso como destinationNumber (packages/telephony). */ +export const IVR_ENTRY_DESTINATION = "ivr_entry"; + +export interface IvrMenuOption { + id: string; + digit: string; + destinationNumber: string; + destinationContext: string; + label: string | null; +} + +export interface IvrMenu { + id: string; + name: string; + context: string; + greeting: string | null; + enabled: boolean; + createdAt: string; + options: IvrMenuOption[]; +} + export interface PauseReason { id: string; name: string; diff --git a/docs/INBOUND_ROUTES.md b/docs/INBOUND_ROUTES.md index 183dec4..f7c65e6 100644 --- a/docs/INBOUND_ROUTES.md +++ b/docs/INBOUND_ROUTES.md @@ -123,15 +123,43 @@ bidirecional confirmado); dígito `2` → resposta alternativa (tom diferente + desliga) — confirma que a ramificação distingue de verdade, não só "sempre cai na primeira opção". +## Tela de autoria de IVR (PHASE 58) + +`Telefonia > IVR` (`apps/frontend/.../telefonia/ivr`) — cria um `IvrMenu` +(nome + contexto derivado do nome) com opções (dígito → ramal + rótulo +opcional), sem precisar tocar no editor genérico de dialplan. Por baixo, +`IvrMenusController` (`POST/PATCH/DELETE /ivr-menus`) compila o menu + +opções em `buildIvrDialplanExtensions` (`packages/telephony/src/ivr-xml.ts`) +e já gera + ativa uma nova versão do dialplan do contexto — o mesmo fluxo +generate+activate manual, automático. `IVR_ENTRY_DESTINATION` +("ivr_entry") é o valor fixo que toda `InboundRoute` precisa usar como +`destinationNumber` pra entrar nesse menu (`destinationContext = +IvrMenu.context`). + +`greeting` (o prompt tocado ao entrar) passa pela MESMA proteção +anti-RCE de `data` de dialplan (`IsSafeDialplanData`) — é texto livre do +tenant, nunca pode virar `${system(...)}`. Sem pipeline de upload/TTS de +áudio ainda: null usa um tom padrão; texto livre vira o argumento `file` +de `play_and_get_digits` (aceita qualquer caminho/URL que o FreeSWITCH +já resolva, incluindo `tone_stream://`). + +Testado ponta a ponta criando um menu de verdade via API (não só +lendo XML manualmente escrito): softphone externo discou o DID, o menu +recém-criado atendeu, tocou o prompt, colheu o dígito com DTMF real +(`uuid_recv_dtmf`), e bridged com o ramal certo — confirmando que o +compilador produz XML funcionalmente idêntico ao testado manualmente na +PHASE 56. + ## O que falta -- Tela de frontend pra autoria de IVR — hoje o menu é construído à mão - no editor genérico de dialplan (`Telefonia > Dialplan`, escolhendo um - `context` novo), não tem uma UI dedicada de "menu com opções" ainda. - Backend já suporta tudo que uma UI assim precisaria gerar. +- Sem pipeline de upload/TTS de prompt de áudio — hoje é texto livre + (tom padrão ou um caminho/URL que o FreeSWITCH já sabe tocar). +- 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 de "fila" ou "IVR" como destino ainda — hoje é - texto livre pro `destinationContext`/`destinationNumber`. + 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). - 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/auth/src/seed.ts b/packages/auth/src/seed.ts index ab9a990..448073c 100644 --- a/packages/auth/src/seed.ts +++ b/packages/auth/src/seed.ts @@ -25,6 +25,8 @@ const PERMISSIONS: Array<{ key: string; description: string }> = [ { key: "trunks.manage", description: "Criar/editar troncos" }, { key: "inbound_routes.view", description: "Ver rotas de entrada" }, { key: "inbound_routes.manage", description: "Criar/editar rotas de entrada" }, + { key: "ivr.view", description: "Ver menus de IVR" }, + { key: "ivr.manage", description: "Criar/editar menus de IVR" }, { key: "agents.view", description: "Ver agentes" }, { key: "agents.manage", description: "Criar/editar agentes" }, { key: "queues.view", description: "Ver filas" }, @@ -62,6 +64,7 @@ const ROLE_PERMISSIONS: Record = { "extensions.view", "trunks.view", "inbound_routes.view", + "ivr.view", "agents.view", "agents.manage", "queues.view", diff --git a/packages/database/prisma/migrations/20260830210000_ivr_menus/migration.sql b/packages/database/prisma/migrations/20260830210000_ivr_menus/migration.sql new file mode 100644 index 0000000..4259684 --- /dev/null +++ b/packages/database/prisma/migrations/20260830210000_ivr_menus/migration.sql @@ -0,0 +1,49 @@ +-- PHASE 58: menu de IVR (autoria por cima do dialplan genérico) +CREATE TABLE "ivr_menus" ( + "id" UUID NOT NULL, + "tenant_id" UUID NOT NULL, + "name" TEXT NOT NULL, + "context" TEXT NOT NULL, + "greeting" TEXT, + "enabled" BOOLEAN NOT NULL DEFAULT true, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + "deleted_at" TIMESTAMP(3), + + CONSTRAINT "ivr_menus_pkey" PRIMARY KEY ("id") +); + +CREATE TABLE "ivr_menu_options" ( + "id" UUID NOT NULL, + "tenant_id" UUID NOT NULL, + "ivr_menu_id" UUID NOT NULL, + "digit" TEXT NOT NULL, + "destination_number" TEXT NOT NULL, + "destination_context" TEXT NOT NULL DEFAULT 'default', + "label" TEXT, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "ivr_menu_options_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "ivr_menus_tenant_id_idx" ON "ivr_menus"("tenant_id"); +CREATE UNIQUE INDEX "ivr_menus_tenant_id_context_key" ON "ivr_menus"("tenant_id", "context"); +CREATE INDEX "ivr_menu_options_tenant_id_idx" ON "ivr_menu_options"("tenant_id"); +CREATE UNIQUE INDEX "ivr_menu_options_ivr_menu_id_digit_key" ON "ivr_menu_options"("ivr_menu_id", "digit"); + +-- AddForeignKey +ALTER TABLE "ivr_menus" ADD CONSTRAINT "ivr_menus_tenant_id_fkey" FOREIGN KEY ("tenant_id") REFERENCES "tenants"("id") ON DELETE RESTRICT ON UPDATE CASCADE; +ALTER TABLE "ivr_menu_options" ADD CONSTRAINT "ivr_menu_options_tenant_id_fkey" FOREIGN KEY ("tenant_id") REFERENCES "tenants"("id") ON DELETE RESTRICT ON UPDATE CASCADE; +ALTER TABLE "ivr_menu_options" ADD CONSTRAINT "ivr_menu_options_ivr_menu_id_fkey" FOREIGN KEY ("ivr_menu_id") REFERENCES "ivr_menus"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- Tabelas de negocio tenant-scoped: RLS obrigatorio (ver docs/TENANT_ISOLATION.md). +ALTER TABLE "ivr_menus" ENABLE ROW LEVEL SECURITY; +ALTER TABLE "ivr_menus" FORCE ROW LEVEL SECURITY; +CREATE POLICY "tenant_isolation" ON "ivr_menus" + USING (tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::uuid); + +ALTER TABLE "ivr_menu_options" ENABLE ROW LEVEL SECURITY; +ALTER TABLE "ivr_menu_options" FORCE ROW LEVEL SECURITY; +CREATE POLICY "tenant_isolation" ON "ivr_menu_options" + USING (tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::uuid); diff --git a/packages/database/prisma/schema.prisma b/packages/database/prisma/schema.prisma index 6d9311f..853cf32 100644 --- a/packages/database/prisma/schema.prisma +++ b/packages/database/prisma/schema.prisma @@ -66,6 +66,8 @@ model Tenant { extensions Extension[] trunks Trunk[] inboundRoutes InboundRoute[] + ivrMenus IvrMenu[] + ivrMenuOptions IvrMenuOption[] dialplanExtensions DialplanExtension[] dialplanVersions DialplanVersion[] queues Queue[] @@ -480,6 +482,64 @@ model InboundRoute { @@map("inbound_routes") } +// Menu de IVR (PHASE 58) — tela de autoria por cima do dialplan genérico +// (docs/INBOUND_ROUTES.md, "IVR"): compilado em `DialplanExtension`/ +// `DialplanVersion` do contexto `IvrMenu.context` toda vez que o menu ou +// as opções mudam (`buildIvrDialplanExtensions`, packages/telephony). +// `IvrMenu.context` é o mesmo valor que uma `InboundRoute.destinationContext` +// deve apontar; a entrada do menu sempre usa `destination_number` fixo +// (`IVR_ENTRY_DESTINATION`, "ivr_entry"), então `InboundRoute.destinationNumber` +// deve ser exatamente isso. +model IvrMenu { + id String @id @default(uuid()) @db.Uuid + tenantId String @map("tenant_id") @db.Uuid + + name String + context String + + // Prompt tocado ao entrar no menu — texto livre validado contra a + // MESMA proteção anti-RCE de `data` de dialplan (secao 180): vira o + // argumento `file` de `play_and_get_digits`, então nunca pode conter + // `${funcname(...)}`. Null = usa um tom padrão (sem gravação real + // ainda — nenhuma pipeline de upload/TTS de prompt existe até aqui). + greeting String? + + enabled Boolean @default(true) + + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + deletedAt DateTime? @map("deleted_at") + + tenant Tenant @relation(fields: [tenantId], references: [id]) + options IvrMenuOption[] + + @@unique([tenantId, context]) + @@index([tenantId]) + @@map("ivr_menus") +} + +model IvrMenuOption { + id String @id @default(uuid()) @db.Uuid + tenantId String @map("tenant_id") @db.Uuid + ivrMenuId String @map("ivr_menu_id") @db.Uuid + + 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") + + label String? + + createdAt DateTime @default(now()) @map("created_at") + + tenant Tenant @relation(fields: [tenantId], references: [id]) + ivrMenu IvrMenu @relation(fields: [ivrMenuId], references: [id], onDelete: Cascade) + + @@unique([ivrMenuId, digit]) + @@index([tenantId]) + @@map("ivr_menu_options") +} + enum DialplanVersionStatus { DRAFT ACTIVE diff --git a/packages/telephony/src/index.ts b/packages/telephony/src/index.ts index 0a82c1e..c9e159e 100644 --- a/packages/telephony/src/index.ts +++ b/packages/telephony/src/index.ts @@ -5,4 +5,5 @@ export * from "./directory-xml"; export * from "./gateway-xml"; export * from "./dialplan-xml"; export * from "./default-dialplan"; +export * from "./ivr-xml"; export * from "./queue-xml"; diff --git a/packages/telephony/src/ivr-xml.ts b/packages/telephony/src/ivr-xml.ts new file mode 100644 index 0000000..08070b5 --- /dev/null +++ b/packages/telephony/src/ivr-xml.ts @@ -0,0 +1,74 @@ +import type { DialplanExtensionInput } from "./dialplan-xml"; + +/** + * destination_number fixo usado pra entrar num menu de IVR (PHASE 58) — + * toda `InboundRoute` que aponta pra um `IvrMenu` deve usar exatamente + * este valor como `destinationNumber`, com `destinationContext` igual ao + * `IvrMenu.context`. + */ +export const IVR_ENTRY_DESTINATION = "ivr_entry"; + +const DEFAULT_GREETING = "tone_stream://%(500,0,800)"; +const INVALID_TONE = "tone_stream://%(500,0,400)"; + +/** Digits válidos num menu de IVR — os mesmos que um telefone real manda. */ +export const ALLOWED_IVR_DIGITS = ["0", "1", "2", "3", "4", "5", "6", "7", "8", "9", "*", "#"] as const; +export type AllowedIvrDigit = (typeof ALLOWED_IVR_DIGITS)[number]; + +function escapeRegexLiteral(digit: string): string { + // Só "*" precisa de escape entre os digits permitidos (regex especial); + // os demais (0-9, #) já são literais seguros. + return digit === "*" ? "\\*" : digit; +} + +export interface IvrMenuOptionInput { + digit: string; + destinationNumber: string; + destinationContext: string; +} + +/** + * Compila um `IvrMenu` + suas opções nas mesmas 2 formas de `` + * já testadas ponta a ponta com DTMF real (PHASE 56): uma entrada que + * atende, toca o prompt, coleta 1 dígito com `play_and_get_digits` e + * `transfer`e pro PRÓPRIO contexto usando o dígito coletado como novo + * `destination_number` — achado real, documentado em + * docs/INBOUND_ROUTES.md: FreeSWITCH resolve todas as condições de um + * contexto ANTES de executar qualquer ação, então uma variable setada + * 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. + */ +export function buildIvrDialplanExtensions( + menu: { context: string; greeting?: string | null }, + options: IvrMenuOptionInput[], +): DialplanExtensionInput[] { + const greetingFile = menu.greeting?.trim() || DEFAULT_GREETING; + + const entry: DialplanExtensionInput = { + name: "IVR entrada", + conditionField: "destination_number", + conditionExpr: `^${IVR_ENTRY_DESTINATION}$`, + continueOnFalse: false, + order: 1, + actions: [ + { application: "answer" }, + { + application: "play_and_get_digits", + data: `1 1 3 5000 # ${greetingFile} ${INVALID_TONE} ivr_choice \\d+ 3000`, + }, + { application: "transfer", data: `\${ivr_choice} XML ${menu.context}` }, + ], + }; + + 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}` }], + })); + + return [entry, ...branches]; +}