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
92 lines
3.3 KiB
TypeScript
92 lines
3.3 KiB
TypeScript
"use server";
|
|
|
|
import { revalidatePath } from "next/cache";
|
|
import { requireSession } from "@/lib/session";
|
|
import { apiFetch, ApiError, API_BASE_URL } 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<IvrMenu>("/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<void>(`/ivr-menus/${id}`, session.accessToken, { method: "DELETE" });
|
|
revalidatePath("/app/telefonia/ivr");
|
|
return { ok: true };
|
|
} catch (err) {
|
|
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) };
|
|
}
|
|
}
|