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:
31
apps/frontend/src/app/api/ivr-menus/[id]/prompt/route.ts
Normal file
31
apps/frontend/src/app/api/ivr-menus/[id]/prompt/route.ts
Normal 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 });
|
||||
}
|
||||
@@ -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) };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user