# Gravação e Object Storage Agente.md secao 90-94. Fecha a gravação de chamadas do Call Center e a abstração de storage por trás dela — a especificação lista "Recording" e "Object Storage" como dois passos separados na ordem de implementação (secao 232), mas eles são acoplados o suficiente (Recording precisa de um lugar pra guardar bytes) que ficaram nesta única fase. ## `packages/storage` — `ObjectStorageProvider` (secao 92) Abstração pequena: `putObject(key, sourceFilePath)`, `getObjectStream(key)`, `deleteObject(key)`. Dois backends: - `LocalObjectStorageProvider`: copia pro filesystem local, com uma checagem de path traversal (`assertKeyInsideRoot`) como última linha de defesa — mesmo a `key` sendo sempre construída no servidor, nunca vinda do client. - `S3ObjectStorageProvider`: `@aws-sdk/client-s3`, preparado pra AWS S3 e MinIO (`forcePathStyle`, `endpoint` customizável). **Nunca exercitado nesta sessão** — não existe servidor S3/MinIO disponível neste laboratório. `getObjectStorageProvider()` escolhe o backend por `STORAGE_PROVIDER` (`local`/`s3`) — cada processo (fs-events, apps/api) monta o seu a partir das mesmas variáveis de ambiente, nunca hardcoda qual backend usar. ## Path (secao 93) `buildRecordingObjectKey(tenantId, callId, recordedAt)` → `tenants/{tenant_id}/recordings/YYYY/MM/DD/{call_id}.wav`. Sempre montada no servidor a partir de dados já confiáveis — um tenant nunca consegue montar uma key que aponte pra dentro da pasta de outro. ## Volumes: bind mount, não volume nomeado do Docker Diferente dos outros compartilhamentos com o FreeSWITCH (gateways, filas), `/recordings` e `/data/object-storage` usam **bind mount** pra um diretório real do host (`./data/recordings-spool`, `./data/object-storage-local`) — porque `apps/api` roda no host, não em Docker (mesma limitação de sempre, ver docs/NETWORK_ARCHITECTURE.md), e precisa ler os mesmos arquivos que `fs-events` escreve. `LOCAL_STORAGE_ROOT` tem valores diferentes por ambiente (`.env`: path do host; docker-compose.yml do `fs-events`: path Docker-interno pro mesmo diretório) — mesmo padrão já usado pra `REDIS_URL`. ## Quem grava: `apps/predictive-dialer/src/originate.ts` Só chamadas originadas pelo `PredictiveDialerEngine` com `Campaign.recordingEnabled=true` são gravadas nesta fase — é o único caminho de originate que o sistema controla hoje (chamadas manuais/ internas não passam por aqui ainda). Quando habilitado, o originate ganha duas channel variables extras: - `RECORD_STEREO=true` (secao 91): canal A = cliente, canal B = agente, quando tecnicamente adequado. - `execute_on_answer='record_session /recordings/{uuid}.wav'`: roda assim que a chamada atende, antes da aplicação principal (`&callcenter`) começar. O `origination_uuid` é pré-gerado em `originate.ts` (em vez de deixar o provider sortear um) porque o path de gravação precisa dele ANTES do comando de originate ser montado — esse mesmo uuid vira `Call.id` no CDR (fase CDR: `Call.id` = o próprio `freeswitch_uuid`), então o arquivo gravado e o registro do `Call` sempre têm o mesmo nome. ## Quem sobe: `apps/freeswitch-events/src/recording.ts` Disparado a partir de `CALL_ENDED`, sempre **depois** de `persistCallEvent` terminar (encadeado via `.then()`, não em paralelo) — `uploadRecordingIfPresent` lê `Call.talkTime`/`durationSeconds`, que é exatamente o que `persistCallEvent` acabou de calcular nesse mesmo evento (mesma classe de corrida já corrigida uma vez na fase CDR, evitada aqui por ordenação em vez de retry). Checa se `/recordings/{callId}.wav` existe (com até 5 tentativas de 300ms — `record_session` pode levar um instante pra terminar o flush depois do hangup); se não existir, não faz nada (chamada não gravada, comportamento normal). Se existir: sobe pro object storage, calcula `retentionUntil` a partir de `Plan.recordingRetentionDays` (secao 94, null = sem limite), cria a linha `Recording`, apaga o arquivo do spool local. ## API (`apps/api/src/recordings`) `GET /recordings` (filtros: campanha, agente, data), `GET /recordings/:id`, `GET /recordings/:id/audio` — o áudio nunca é exposto via URL direta pro storage (nem presigned): sempre passa pelo controller autenticado, que faz `storage.getObjectStream()` e manda o stream direto na resposta (`reply.send(stream)` do Fastify aceita um `Readable`). Permissions `recordings.view`/`recordings.download`, já existiam desde a fase RBAC. ## Retenção (secao 94) `Plan.recordingRetentionDays`/`transcriptionRetentionDays` (o segundo ainda sem uso — fase AI/Transcrição). `runRetentionSweep` (`apps/api/src/recordings/retention-sweep.ts`) roda no boot do `apps/api` e depois a cada hora (`setInterval` — `apps/api` já é um processo de longa duração, não precisou de um serviço dedicado só pra isso): varre `Recording` com `status=AVAILABLE` e `retentionUntil` vencido (fan-out por tenant, mesmo padrão de `trunk-status.ts`), apaga o objeto no storage, marca `status=DELETED` — a linha em si nunca é apagada, fica como registro de auditoria. ## Correção real achada testando esta fase `Recording.sizeBytes` é `BigInt` no Prisma (arquivos podem, em teoria, passar de 2^31 bytes) — o serializador JSON padrão do Fastify não sabe lidar com `BigInt` e quebrava `GET /recordings` com 500 (`TypeError: Do not know how to serialize a BigInt`). Mesma classe de bug já corrigida uma vez no logger (`packages/shared/src/logger.ts`, fase Event Socket). Corrigido convertendo `sizeBytes` pra `number` na resposta (tamanho de gravação nunca chega perto de `Number.MAX_SAFE_INTEGER`). ## Verificado ponta a ponta ``` Campanha com recordingEnabled:true, 5 leads, 2 ANSWERED simulados: record_session cria /recordings/{uuid}.wav no container do FreeSWITCH (confirmado: RIFF WAVE, PCM 16-bit, ESTÉREO, 8000Hz — RECORD_STEREO funcionando) CALL_ENDED -> upload pro object storage local, path exatamente tenants/{tenant}/recordings/2026/08/28/{uuid}.wav arquivo do spool apagado depois do upload confirmado Recording criado com sizeBytes/checksum reais, durationSeconds batendo com o talk_time do Call GET /recordings -> lista os 2, sizeBytes serializado como number (sem crash de BigInt) GET /recordings/:id/audio -> download autenticado, md5 idêntico ao arquivo original no object storage Teste de retenção: marquei retentionUntil como já vencido, rodei runRetentionSweep() -> objeto apagado do storage, status vira DELETED, GET /recordings (lista) não mostra mais, GET /recordings/:id ainda mostra (auditoria), GET /recordings/:id/audio -> 404 ``` typecheck do workspace inteiro limpo. ## O que falta - Chamadas manuais/internas (não originadas pelo discador) não são gravadas — sem um caminho de originate próprio pra elas ainda. - `S3ObjectStorageProvider` nunca testado contra um MinIO/S3 real. - `max_recording_storage_gb` (Plan) existe mas não é aplicado — nada verifica quota de espaço usado antes de gravar. - Transcrição (secao 95+, IA) — próxima fase.