diff --git a/TODO.md b/TODO.md
index 8db9619..7ed164f 100644
--- a/TODO.md
+++ b/TODO.md
@@ -2141,10 +2141,40 @@ tela de IVR no frontend")
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)
+- [ ] Sem TTS (texto→voz); 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)
+
+## PHASE 59 — Upload de áudio pro prompt do IVR (pedido do usuário:
+"adiciona upload de áudio pro prompt do IVR")
+- [x] `POST /ivr-menus/:id/prompt` (multipart, `@fastify/multipart` —
+ primeiro upload de arquivo binário desta API) — só aceita WAV
+ (cabeçalho RIFF/WAVE validado; sem `mod_shout` nesta implantação,
+ MP3 nunca funcionaria). Gravado num bind mount NOVO
+ (`./data/ivr-prompts` no host ↔ `/ivr-prompts` no container
+ freeswitch) — mesmo raciocínio já usado 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 FreeSWITCH lê ao vivo
+ durante `play_and_get_digits`. Um fetch em rede (S3/HTTP) durante
+ a chamada foi descartado de propósito — latência desnecessária
+ pra um prompt de poucos segundos
+- [x] `GET /ivr-menus/:id/prompt` (autenticado, `ivr.view`) serve o
+ preview — mesmo princípio do player de gravações, nunca uma URL
+ direta pro storage/disco. Deletar o menu ou trocar/remover o
+ prompt sempre limpa o arquivo do disco (achado real: minha
+ primeira versão do delete de menu deixava o .wav órfão — corrigido
+ antes de commitar, confirmado com teste real de upload+delete)
+- [x] Tela "Telefonia > IVR" ganhou upload/troca/remoção de áudio por
+ menu + player `
+
+ {hasCustomPrompt ? (
+ <>
+ Prompt de áudio enviado
+ {/* eslint-disable-next-line jsx-a11y/media-has-caption -- prompt de voz, sem faixa de legenda aplicável */}
+
+
+ >
+ ) : (
+ Sem áudio enviado — toca um tom padrão
+ )}
+
+
+ {error && {error}}
+
+ );
+}
+
interface OptionRow {
digit: string;
destinationNumber: string;
diff --git a/docker-compose.yml b/docker-compose.yml
index 074044f..fd31221 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -91,6 +91,14 @@ services:
# host e precisa enxergar os mesmos arquivos que o FreeSWITCH grava
# (agente.md secao 90-91) — ver docs/RECORDING.md.
- ./data/recordings-spool:/recordings
+ # Prompts de áudio do IVR (PHASE 59) — mesmo raciocínio do bind
+ # mount acima, mas na direção contrária: apps/api (host) ESCREVE o
+ # arquivo que o usuário sobe, e o FreeSWITCH precisa LER esse mesmo
+ # arquivo ao vivo durante `play_and_get_digits`. Só funciona como
+ # bind mount de disco local — um fetch em rede (S3/HTTP) durante
+ # uma chamada ativa seria latência/confiabilidade desnecessárias
+ # pra um prompt de poucos segundos.
+ - ./data/ivr-prompts:/ivr-prompts
# Event Socket (8021) NUNCA publicado — só alcançável por outros
# containers na rede interna do compose (agente.md secao 22).
# SIP (5060) e RTP (16384-16584, range fixo no Dockerfile) publicados
diff --git a/docs/INBOUND_ROUTES.md b/docs/INBOUND_ROUTES.md
index f7c65e6..cf726f8 100644
--- a/docs/INBOUND_ROUTES.md
+++ b/docs/INBOUND_ROUTES.md
@@ -150,10 +150,38 @@ recém-criado atendeu, tocou o prompt, colheu o dígito com DTMF real
compilador produz XML funcionalmente idêntico ao testado manualmente na
PHASE 56.
+## Upload de áudio pro prompt (PHASE 59)
+
+`POST /ivr-menus/:id/prompt` (multipart, campo `file`) — só aceita WAV
+(cabeçalho RIFF/WAVE validado antes de gravar; sem `mod_shout` nesta
+implantação, MP3 nunca funcionaria mesmo). Gravado num bind mount NOVO
+compartilhado com o container do FreeSWITCH (`./data/ivr-prompts` no
+host ↔ `/ivr-prompts` no container) — mesmo raciocínio já usado pras
+gravações de chamada (`./data/recordings-spool`), mas na direção
+contrária: aqui é `apps/api` (host) que ESCREVE e o FreeSWITCH que LÊ.
+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, e esta é a MESMA convenção já usada 3x neste projeto
+pra arquivos que o FreeSWITCH precisa enxergar (gateways externos, filas
+do callcenter, spool de gravação).
+
+`greeting` passa a guardar o path como o FreeSWITCH enxerga
+(`/ivr-prompts//.wav`), nunca o path do host.
+`GET /ivr-menus/:id/prompt` (autenticado, `ivr.view`) serve o preview —
+mesmo princípio do player de gravações, nunca uma URL direta pro
+storage/disco. Deletar o menu ou trocar/remover o prompt sempre limpa o
+arquivo do disco (nunca deixa órfão).
+
+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/exportaria): 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.
+
## O que falta
-- 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).
+- 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`.
- Tela de frontend "Rotas de Entrada" cobre só CRUD simples (DID →
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index bd014a4..4b8958c 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -69,6 +69,9 @@ importers:
'@fastify/helmet':
specifier: 13.1.1
version: 13.1.1
+ '@fastify/multipart':
+ specifier: ^10.1.1
+ version: 10.1.1
'@fastify/rate-limit':
specifier: 11.2.0
version: 11.2.0
@@ -689,9 +692,15 @@ packages:
'@fastify/ajv-compiler@4.0.6':
resolution: {integrity: sha512-NtuzM0SfaMJbGlnjr9LWQUN5LzgSrbB8tf/wRZNas+4E1O/Nmzl53e7ruT61HDZyRCJGC6FxIogmNZO1c5ETBA==}
+ '@fastify/busboy@3.2.2':
+ resolution: {integrity: sha512-yXSS27qPExaXeuLvMRMXOLtpipzfQYNjG3FkunDWKGfMYjKuhFXko9CVzqxm8jcF+lmtS9Fd89QNdh9XDjnbNg==}
+
'@fastify/cors@11.3.0':
resolution: {integrity: sha512-ggQGua+xHv1MvePbPr0v//xLYEsCXbWspquXCJS9Ot5YoRXq8J8ZWzHnxDBVnbtXosvistXo6LtNzOJswf64Fw==}
+ '@fastify/deepmerge@3.2.1':
+ resolution: {integrity: sha512-N5Oqvltoa2r9z1tbx4xjky0oRR60v+T47Ic4J1ukoVQcptLOrIdRnCSdTGmOmajZuHVKlTnfcmrjyqsGEW1ztA==}
+
'@fastify/error@4.2.0':
resolution: {integrity: sha512-RSo3sVDXfHskiBZKBPRgnQTtIqpi/7zhJOEmAxCiBcM7d0uwdGdxLlsCaLzGs8v8NnxIRlfG0N51p5yFaOentQ==}
@@ -713,6 +722,9 @@ packages:
'@fastify/merge-json-schemas@0.2.1':
resolution: {integrity: sha512-OA3KGBCy6KtIvLf8DINC5880o5iBlDX4SxzLQS8HorJAbqluzLRn80UXU0bxZn7UOFhFgpRJDasfwn9nG4FG4A==}
+ '@fastify/multipart@10.1.1':
+ resolution: {integrity: sha512-jyRHgnFVdchZRKjJRf6kGPEiDp3Bg4MLo4d6/krt8Z4RutLrqL5IYWihx8a4tnv7Tu7JfuHcyC+dU9zPzTxiSg==}
+
'@fastify/proxy-addr@5.1.0':
resolution: {integrity: sha512-INS+6gh91cLUjB+PVHfu1UqcB76Sqtpyp7bnL+FYojhjygvOPA9ctiD/JDKsyD9Xgu4hUhCSJBPig/w7duNajw==}
@@ -3288,11 +3300,15 @@ snapshots:
ajv-formats: 3.0.1(ajv@8.20.0)
fast-uri: 4.1.3
+ '@fastify/busboy@3.2.2': {}
+
'@fastify/cors@11.3.0':
dependencies:
fastify-plugin: 6.0.0
toad-cache: 3.7.4
+ '@fastify/deepmerge@3.2.1': {}
+
'@fastify/error@4.2.0': {}
'@fastify/fast-json-stringify-compiler@5.1.0':
@@ -3320,6 +3336,14 @@ snapshots:
dependencies:
dequal: 2.0.3
+ '@fastify/multipart@10.1.1':
+ dependencies:
+ '@fastify/busboy': 3.2.2
+ '@fastify/deepmerge': 3.2.1
+ '@fastify/error': 4.2.0
+ fastify-plugin: 6.0.0
+ secure-json-parse: 4.1.0
+
'@fastify/proxy-addr@5.1.0':
dependencies:
'@fastify/forwarded': 3.0.2