feat(ivr): upload de áudio pro prompt do IVR

Pedido do usuário: "adiciona upload de áudio pro prompt do IVR" — até
aqui o prompt era só texto livre (na prática, sempre um tom padrão,
nunca voz de verdade).

`POST /ivr-menus/:id/prompt` (multipart via @fastify/multipart —
primeiro upload de arquivo binário desta API) só aceita WAV (cabeçalho
RIFF/WAVE validado antes de gravar; esta implantação do FreeSWITCH não
tem mod_shout, então MP3 nunca funcionaria de qualquer forma). Gravado
num bind mount NOVO (./data/ivr-prompts no host ↔ /ivr-prompts no
container freeswitch) — mesma convenção já usada 3x neste projeto pra
arquivo que o FreeSWITCH precisa enxergar de verdade (gateways externos,
filas do callcenter, spool de gravação), mas na direção contrária:
apps/api (host) escreve o que o usuário sobe, o FreeSWITCH lê ao vivo
durante play_and_get_digits. Um fetch em rede (S3/HTTP) durante uma
chamada ativa foi descartado de propósito — latência/confiabilidade
desnecessárias pra um prompt de poucos segundos.

GET /ivr-menus/:id/prompt (autenticado) serve o preview — mesmo
princípio do player de gravações, nunca uma URL direta pro storage.
Achado real corrigido antes de commitar: minha primeira versão do
delete de menu deixava o .wav órfão no disco — agora deletar o menu ou
trocar/remover o prompt sempre limpa o arquivo, confirmado com um teste
real de upload+delete.

Tela "Telefonia > IVR" ganhou upload/troca/remoção de áudio por menu +
player de preview.

Testado ponta a ponta com um WAV real de 44.1kHz/mono (não o formato
"nativo" de telefonia, de propósito, pra confirmar que funciona com o
que uma pessoa qualquer gravaria): softphone externo discou o DID,
play_and_get_digits abriu e tocou o arquivo até o fim duas vezes
(mod_sndfile resample automático, sem erro no log), colheu o dígito
real e bridged corretamente com o ramal de destino.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BFaBaBSQGhyXGEgtTYZGV8
This commit is contained in:
2026-08-30 17:51:31 -03:00
parent 36e85c1abf
commit 486c39803a
10 changed files with 389 additions and 10 deletions

View File

@@ -19,6 +19,7 @@
"@b2bcall/telephony": "workspace:*",
"@fastify/cors": "11.3.0",
"@fastify/helmet": "13.1.1",
"@fastify/multipart": "^10.1.1",
"@fastify/rate-limit": "11.2.0",
"@nestjs/common": "^12.0.1",
"@nestjs/core": "^12.0.1",

View File

@@ -11,8 +11,14 @@ import {
Param,
Patch,
Post,
Req,
Res,
UseGuards,
} from "@nestjs/common";
import { createReadStream } from "node:fs";
import { mkdir, unlink, writeFile } from "node:fs/promises";
import { join } from "node:path";
import type { FastifyReply, FastifyRequest } from "fastify";
import { XMLValidator } from "fast-xml-parser";
import { getPrismaClient, withTenantContext, Prisma } from "@b2bcall/database";
import { recordAuditEvent, type AccessTokenClaims } from "@b2bcall/auth";
@@ -23,6 +29,26 @@ import { RequirePermission } from "../common/decorators/require-permission.decor
import { CurrentUser } from "../common/decorators/current-user.decorator";
import { CreateIvrMenuDto, UpdateIvrMenuDto } from "./dto/create-ivr-menu.dto";
// Path do HOST (apps/api roda fora do Docker) — o mesmo diretório
// aparece como /ivr-prompts dentro do container freeswitch (ver
// docker-compose.yml). `IvrMenu.greeting` grava o path como o
// FreeSWITCH enxerga (container), nunca o path do host.
const IVR_PROMPTS_HOST_ROOT = process.env.IVR_PROMPTS_HOST_ROOT ?? "/opt/b2bcall/data/ivr-prompts";
const IVR_PROMPTS_CONTAINER_ROOT = "/ivr-prompts";
function isValidWavHeader(buf: Buffer): boolean {
// RIFF....WAVE — cabeçalho mínimo, suficiente pra rejeitar qualquer
// coisa que não seja WAV antes de gravar no disco compartilhado com o
// FreeSWITCH (mod_sndfile está carregado e resample sozinho qualquer
// sample rate/canais válidos; não há mod_shout nesta implantação, então
// MP3 nunca funcionaria — melhor rejeitar cedo com mensagem clara).
return buf.length >= 12 && buf.toString("ascii", 0, 4) === "RIFF" && buf.toString("ascii", 8, 12) === "WAVE";
}
function promptHostPath(tenantId: string, menuId: string): string {
return join(IVR_PROMPTS_HOST_ROOT, tenantId, `${menuId}.wav`);
}
/**
* 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`
@@ -248,6 +274,14 @@ export class IvrMenusController {
});
});
if (menu.greeting?.startsWith(IVR_PROMPTS_CONTAINER_ROOT)) {
try {
await unlink(promptHostPath(tenantId, id));
} catch {
// arquivo já não existia — sem problema, o menu já foi apagado.
}
}
await recordAuditEvent(prisma, {
action: "IVR_MENU_DELETE",
tenantId,
@@ -256,4 +290,118 @@ export class IvrMenusController {
entityId: id,
});
}
/**
* Upload do prompt de áudio (PHASE 59, docs/INBOUND_ROUTES.md) —
* único endpoint desta API que recebe um arquivo binário. Grava no
* disco compartilhado com o FreeSWITCH (nunca no object storage de
* gravações — aquele é lido por humanos depois da chamada via proxy
* autenticado; este precisa ser lido pelo PRÓPRIO FreeSWITCH ao vivo
* durante `play_and_get_digits`, então tem que ser um arquivo local
* de verdade, não uma URL de rede) e recompila o dialplan do menu com
* o novo `greeting` apontando pro path que o FreeSWITCH enxerga.
*/
@RequirePermission("ivr.manage")
@Post(":id/prompt")
async uploadPrompt(@CurrentUser() user: AccessTokenClaims, @Param("id") id: string, @Req() request: FastifyRequest) {
const prisma = getPrismaClient();
const tenantId = user.tenantId!;
const menu = await withTenantContext(prisma, tenantId, (tx) =>
tx.ivrMenu.findFirst({ where: { id, deletedAt: null }, include: { options: true } }),
);
if (!menu) throw new NotFoundException();
const data = await request.file();
if (!data) throw new BadRequestException("Nenhum arquivo enviado");
const buffer = await data.toBuffer();
if (!isValidWavHeader(buffer)) {
throw new BadRequestException(
"Arquivo não é um WAV válido — só WAV é aceito (esta implantação do FreeSWITCH não tem suporte a MP3)",
);
}
const hostPath = promptHostPath(tenantId, id);
await mkdir(join(IVR_PROMPTS_HOST_ROOT, tenantId), { recursive: true });
await writeFile(hostPath, buffer);
const containerPath = `${IVR_PROMPTS_CONTAINER_ROOT}/${tenantId}/${id}.wav`;
const updated = await withTenantContext(prisma, tenantId, (tx) =>
tx.ivrMenu.update({ where: { id }, data: { greeting: containerPath } }),
);
const options: IvrMenuOptionInput[] = menu.options.map((o) => ({
digit: o.digit,
destinationNumber: o.destinationNumber,
destinationContext: o.destinationContext,
}));
await compileAndActivateIvrDialplan(prisma, tenantId, user.sub, updated, options);
await recordAuditEvent(prisma, {
action: "IVR_MENU_PROMPT_UPLOAD",
tenantId,
userId: user.sub,
entityType: "ivr_menu",
entityId: id,
after: { filename: data.filename, sizeBytes: buffer.length },
});
return this.get(user, id);
}
@RequirePermission("ivr.manage")
@Delete(":id/prompt")
async removePrompt(@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: true } }),
);
if (!menu) throw new NotFoundException();
const updated = await withTenantContext(prisma, tenantId, (tx) => tx.ivrMenu.update({ where: { id }, data: { greeting: null } }));
try {
await unlink(promptHostPath(tenantId, id));
} catch {
// arquivo já não existia — nada a fazer, greeting já voltou a null.
}
const options: IvrMenuOptionInput[] = menu.options.map((o) => ({
digit: o.digit,
destinationNumber: o.destinationNumber,
destinationContext: o.destinationContext,
}));
await compileAndActivateIvrDialplan(prisma, tenantId, user.sub, updated, options);
await recordAuditEvent(prisma, {
action: "IVR_MENU_PROMPT_DELETE",
tenantId,
userId: user.sub,
entityType: "ivr_menu",
entityId: id,
});
return this.get(user, id);
}
/** Preview autenticado do prompt — mesmo princípio do player de
* gravações (nunca uma URL direta pro storage/disco). */
@RequirePermission("ivr.view")
@Get(":id/prompt")
async downloadPrompt(@CurrentUser() user: AccessTokenClaims, @Param("id") id: string, @Res() reply: FastifyReply) {
const prisma = getPrismaClient();
const tenantId = user.tenantId!;
const menu = await withTenantContext(prisma, tenantId, (tx) => tx.ivrMenu.findFirst({ where: { id, deletedAt: null } }));
if (!menu || !menu.greeting?.startsWith(IVR_PROMPTS_CONTAINER_ROOT)) {
throw new NotFoundException();
}
reply.header("Content-Type", "audio/wav");
reply.header("Content-Disposition", "inline");
reply.send(createReadStream(promptHostPath(tenantId, id)));
}
}

View File

@@ -6,6 +6,7 @@ import { ValidationPipe } from "@nestjs/common";
import helmet from "@fastify/helmet";
import cors from "@fastify/cors";
import rateLimit from "@fastify/rate-limit";
import multipart from "@fastify/multipart";
import { AppModule } from "./app.module";
import { DomainExceptionFilter } from "./common/filters/domain-exception.filter";
import { runRetentionSweep } from "./recordings/retention-sweep";
@@ -38,6 +39,14 @@ async function bootstrap() {
timeWindow: "1 minute",
});
// Upload de prompt de áudio do IVR (PHASE 59) — único endpoint desta
// API que recebe um arquivo binário; limite de tamanho aqui evita um
// upload gigante travar o processo (ver ivr-menus.controller.ts pra
// validação de formato/conteúdo).
await app.register(multipart, {
limits: { fileSize: 8 * 1024 * 1024 },
});
app.useGlobalPipes(
new ValidationPipe({
whitelist: true,

View File

@@ -0,0 +1,31 @@
import { NextResponse } from "next/server";
import { API_BASE_URL } from "@/lib/api";
import { getSession } from "@/lib/session";
/**
* Proxy autenticado pro áudio do prompt de IVR — mesmo princípio do
* player de gravações (`/api/recordings/[id]/audio`): o elemento
* `<audio>` não manda `Authorization: Bearer ...`, só cookie, então
* reencaminha com o access token do lado do servidor.
*/
export async function GET(request: Request, { params }: { params: Promise<{ id: string }> }) {
const session = await getSession();
if (!session) return new NextResponse(null, { status: 401 });
const { id } = await params;
const upstream = await fetch(`${API_BASE_URL}/ivr-menus/${id}/prompt`, {
headers: { Authorization: `Bearer ${session.accessToken}` },
cache: "no-store",
});
if (!upstream.ok || !upstream.body) {
return new NextResponse(null, { status: upstream.status });
}
const headers = new Headers();
const contentType = upstream.headers.get("content-type");
if (contentType) headers.set("Content-Type", contentType);
headers.set("Content-Disposition", "inline");
return new NextResponse(upstream.body, { status: 200, headers });
}

View File

@@ -2,7 +2,7 @@
import { revalidatePath } from "next/cache";
import { requireSession } from "@/lib/session";
import { apiFetch, ApiError } from "@/lib/api";
import { apiFetch, ApiError, API_BASE_URL } from "@/lib/api";
import type { IvrMenu } from "@/lib/callcenter-types";
function extractErrorMessage(err: unknown): string {
@@ -53,3 +53,39 @@ export async function deleteIvrMenu(id: string): Promise<{ ok: true } | { ok: fa
return { ok: false, error: extractErrorMessage(err) };
}
}
// Upload de arquivo binário — não usa `apiFetch` de propósito: aquele
// helper sempre manda `Content-Type: application/json`, e um multipart
// precisa do boundary calculado pelo próprio runtime (nunca setado à
// mão), então o fetch aqui é cru.
export async function uploadIvrMenuPrompt(menuId: string, formData: FormData): Promise<{ ok: true; menu: IvrMenu } | { ok: false; error: string }> {
const session = await requireSession();
try {
const res = await fetch(`${API_BASE_URL}/ivr-menus/${menuId}/prompt`, {
method: "POST",
headers: { Authorization: `Bearer ${session.accessToken}` },
body: formData,
});
if (!res.ok) {
const body = await res.json().catch(() => ({ message: "Falha ao enviar áudio" }));
const message = Array.isArray(body.message) ? body.message.join(" ") : (body.message ?? "Falha ao enviar áudio");
return { ok: false, error: message };
}
const menu = (await res.json()) as IvrMenu;
revalidatePath("/app/telefonia/ivr");
return { ok: true, menu };
} catch {
return { ok: false, error: "Falha inesperada ao enviar áudio." };
}
}
export async function deleteIvrMenuPrompt(menuId: string): Promise<{ ok: true; menu: IvrMenu } | { ok: false; error: string }> {
const session = await requireSession();
try {
const menu = await apiFetch<IvrMenu>(`/ivr-menus/${menuId}/prompt`, session.accessToken, { method: "DELETE" });
revalidatePath("/app/telefonia/ivr");
return { ok: true, menu };
} catch (err) {
return { ok: false, error: extractErrorMessage(err) };
}
}

View File

@@ -1,8 +1,8 @@
"use client";
import { useMemo, useState, useTransition } from "react";
import { useMemo, useRef, useState, useTransition } from "react";
import { useRouter } from "next/navigation";
import { ListTree, Plus, Trash2, X } from "lucide-react";
import { ListTree, Music, Plus, Trash2, Upload, 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";
@@ -10,7 +10,9 @@ 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";
import { createIvrMenu, deleteIvrMenu, deleteIvrMenuPrompt, uploadIvrMenuPrompt, type IvrMenuOptionInput } from "./actions";
const CUSTOM_PROMPT_PREFIX = "/ivr-prompts/";
function slugifyContext(name: string): string {
return (
@@ -66,6 +68,7 @@ export function IvrView({ menus, extensions }: { menus: IvrMenu[]; extensions: E
Rota de entrada: contexto <span className="text-foreground">{menu.context}</span> · destino{" "}
<span className="text-foreground">{IVR_ENTRY_DESTINATION}</span>
</p>
<PromptControl menu={menu} />
<Table>
<THead>
<TR>
@@ -93,6 +96,67 @@ export function IvrView({ menus, extensions }: { menus: IvrMenu[]; extensions: E
);
}
function PromptControl({ menu }: { menu: IvrMenu }) {
const router = useRouter();
const fileInputRef = useRef<HTMLInputElement>(null);
const [pending, startTransition] = useTransition();
const [error, setError] = useState<string | null>(null);
const hasCustomPrompt = menu.greeting?.startsWith(CUSTOM_PROMPT_PREFIX) ?? false;
function onFileChosen(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
if (!file) return;
setError(null);
const formData = new FormData();
formData.append("file", file);
startTransition(async () => {
const result = await uploadIvrMenuPrompt(menu.id, formData);
if (!result.ok) {
setError(result.error);
return;
}
router.refresh();
});
e.target.value = "";
}
function onRemove() {
setError(null);
startTransition(async () => {
const result = await deleteIvrMenuPrompt(menu.id);
if (!result.ok) {
setError(result.error);
return;
}
router.refresh();
});
}
return (
<div className="flex flex-wrap items-center gap-3 rounded-md border border-border bg-muted/40 px-3 py-2 text-sm">
<Music className="h-3.5 w-3.5 shrink-0 text-muted-foreground" aria-hidden />
{hasCustomPrompt ? (
<>
<span className="text-foreground">Prompt de áudio enviado</span>
{/* eslint-disable-next-line jsx-a11y/media-has-caption -- prompt de voz, sem faixa de legenda aplicável */}
<audio controls preload="none" src={`/api/ivr-menus/${menu.id}/prompt`} className="h-8" />
<Button type="button" variant="ghost" size="sm" onClick={onRemove} disabled={pending}>
<Trash2 className="h-3.5 w-3.5" aria-hidden /> Remover
</Button>
</>
) : (
<span className="text-muted-foreground">Sem áudio enviado toca um tom padrão</span>
)}
<input ref={fileInputRef} type="file" accept="audio/wav,.wav" className="hidden" onChange={onFileChosen} disabled={pending} />
<Button type="button" variant="ghost" size="sm" onClick={() => fileInputRef.current?.click()} disabled={pending}>
<Upload className="h-3.5 w-3.5" aria-hidden /> {pending ? "Enviando…" : hasCustomPrompt ? "Trocar áudio (WAV)" : "Enviar áudio (WAV)"}
</Button>
{error && <span className="text-xs text-destructive">{error}</span>}
</div>
);
}
interface OptionRow {
digit: string;
destinationNumber: string;