feat(recording): gravacao de chamadas + object storage abstraction

Fecha agente.md secao 90-94. A especificacao lista "Recording" e "Object
Storage" como dois passos separados na ordem de implementacao (secao
232), mas ficaram numa unica fase — sao acoplados o suficiente (Recording
precisa de um lugar pra guardar bytes) pra fazer sentido construir juntos.

## packages/storage — ObjectStorageProvider (secao 92)

Abstracao pequena: putObject/getObjectStream/deleteObject. Dois backends:
LocalObjectStorageProvider (filesystem, com checagem de path traversal
mesmo a key sendo sempre montada no servidor) e S3ObjectStorageProvider
(@aws-sdk/client-s3, preparado pra AWS S3 e MinIO via endpoint/
forcePathStyle customizaveis — nunca exercitado nesta sessao, sem
servidor S3 disponivel neste laboratorio). Escolhido por STORAGE_PROVIDER
env.

buildRecordingObjectKey (secao 93):
tenants/{tenant_id}/recordings/YYYY/MM/DD/{call_id}.wav, sempre montada
no servidor a partir de dados confiaveis.

## Bind mounts, nao volumes nomeados

/recordings e /data/object-storage usam bind mount pra um diretorio real
do host — apps/api roda no host, nao em Docker, e precisa enxergar os
mesmos arquivos que fs-events escreve. LOCAL_STORAGE_ROOT tem valores
diferentes por ambiente (mesmo padrao ja usado pra REDIS_URL).

## Quem grava: apps/predictive-dialer

So' chamadas originadas pelo discador com Campaign.recordingEnabled sao
gravadas nesta fase (unico caminho de originate que o sistema controla
hoje). RECORD_STEREO=true + execute_on_answer='record_session ...'
adicionados ao originate; origination_uuid pre-gerado (em vez de deixar o
provider sortear) porque o path de gravacao precisa dele antes do
comando de originate ser montado — o mesmo uuid vira Call.id no CDR.

## Quem sobe: apps/freeswitch-events/src/recording.ts

Em CALL_ENDED, encadeado depois do persistCallEvent terminar (nao em
paralelo) — uploadRecordingIfPresent le Call.talkTime/durationSeconds,
que e' exatamente o que persistCallEvent acabou de calcular no mesmo
evento (mesma classe de corrida ja corrigida uma vez na fase CDR, aqui
evitada por ordenacao). Sobe pro storage, cria Recording (retentionUntil
a partir de Plan.recordingRetentionDays), apaga o spool local.

## API + retencao

GET /recordings, GET /recordings/:id, GET /recordings/:id/audio (stream
autenticado, nunca URL direta pro storage). runRetentionSweep (secao 94)
no boot do apps/api + a cada hora — apaga o objeto, marca status=DELETED
(linha nunca apagada, fica como auditoria).

## Bug real achado testando esta fase

Recording.sizeBytes (BigInt) quebrava GET /recordings com 500 — Fastify
nao serializa BigInt nativamente (mesma classe de bug ja corrigida uma
vez no logger, fase Event Socket). Corrigido convertendo pra number na
resposta.

Verificado ponta a ponta: gravacao real criada (RIFF WAVE, PCM 16-bit,
ESTEREO 8000Hz — RECORD_STEREO confirmado), upload com path exato da
secao 93, download via API com md5 identico ao objeto original, varredura
de retencao apagando objeto + status DELETED + list/download bloqueados
depois. typecheck do workspace inteiro limpo.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X1HxY46WGU4G1zmVDNKcWw
This commit is contained in:
2026-08-28 14:36:03 -03:00
parent 56499f4b99
commit c24a86776c
26 changed files with 1196 additions and 7 deletions

View File

@@ -0,0 +1,51 @@
-- CreateEnum
CREATE TYPE "storage_provider" AS ENUM ('LOCAL', 'S3');
-- CreateEnum
CREATE TYPE "recording_status" AS ENUM ('AVAILABLE', 'DELETED', 'FAILED');
-- AlterTable
ALTER TABLE "plans" ADD COLUMN "recording_retention_days" INTEGER,
ADD COLUMN "transcription_retention_days" INTEGER;
-- CreateTable
CREATE TABLE "recordings" (
"id" UUID NOT NULL,
"tenant_id" UUID NOT NULL,
"call_id" UUID NOT NULL,
"storage_provider" "storage_provider" NOT NULL,
"object_key" TEXT NOT NULL,
"format" TEXT NOT NULL DEFAULT 'wav',
"duration_seconds" INTEGER,
"channels" INTEGER NOT NULL DEFAULT 2,
"size_bytes" BIGINT,
"checksum" TEXT,
"recorded_at" TIMESTAMP(3) NOT NULL,
"retention_until" TIMESTAMP(3),
"status" "recording_status" NOT NULL DEFAULT 'AVAILABLE',
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "recordings_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "recordings_call_id_key" ON "recordings"("call_id");
-- CreateIndex
CREATE INDEX "recordings_tenant_id_recorded_at_idx" ON "recordings"("tenant_id", "recorded_at");
-- CreateIndex
CREATE INDEX "recordings_retention_until_idx" ON "recordings"("retention_until");
-- AddForeignKey
ALTER TABLE "recordings" ADD CONSTRAINT "recordings_tenant_id_fkey" FOREIGN KEY ("tenant_id") REFERENCES "tenants"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "recordings" ADD CONSTRAINT "recordings_call_id_fkey" FOREIGN KEY ("call_id") REFERENCES "calls"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- Tabela de negocio tenant-scoped: RLS obrigatorio (ver docs/TENANT_ISOLATION.md).
ALTER TABLE "recordings" ENABLE ROW LEVEL SECURITY;
ALTER TABLE "recordings" FORCE ROW LEVEL SECURITY;
CREATE POLICY "tenant_isolation" ON "recordings"
USING (tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::uuid);

View File

@@ -56,6 +56,7 @@ model Tenant {
calls Call[]
callLegs CallLeg[]
callEvents CallEvent[]
recordings Recording[]
@@map("tenants")
}
@@ -80,6 +81,10 @@ model Plan {
maxMonthlyCalls Int? @map("max_monthly_calls")
maxRecordingStorageGb Int? @map("max_recording_storage_gb")
// agente.md secao 94 — null = sem retenção automática (nunca apagado).
recordingRetentionDays Int? @map("recording_retention_days")
transcriptionRetentionDays Int? @map("transcription_retention_days")
recordingEnabled Boolean @default(true) @map("recording_enabled")
aiEnabled Boolean @default(false) @map("ai_enabled")
aiTranscriptionEnabled Boolean @default(false) @map("ai_transcription_enabled")
@@ -1007,6 +1012,7 @@ model Call {
disposition Disposition? @relation(fields: [dispositionId], references: [id])
legs CallLeg[]
events CallEvent[]
recording Recording?
@@index([tenantId, createdAt])
@@index([tenantId, queueId])
@@ -1060,3 +1066,55 @@ model CallEvent {
@@index([tenantId, callId, occurredAt])
@@map("call_events")
}
enum StorageProvider {
LOCAL
S3
@@map("storage_provider")
}
enum RecordingStatus {
AVAILABLE
DELETED
FAILED
@@map("recording_status")
}
// "recordings" (agente.md secao 90-94) — só chamadas originadas pelo
// PredictiveDialerEngine com Campaign.recordingEnabled são gravadas nesta
// fase (é o único caminho de originate que o sistema controla hoje; ver
// docs/RECORDING.md). `objectKey` segue a estrutura da secao 93
// (tenants/{tenant_id}/recordings/YYYY/MM/DD/{call_id}.wav) — nunca
// aceita um valor vindo do client, sempre construído no servidor.
model Recording {
id String @id @default(uuid()) @db.Uuid
tenantId String @map("tenant_id") @db.Uuid
callId String @unique @map("call_id") @db.Uuid
storageProvider StorageProvider @map("storage_provider")
objectKey String @map("object_key")
format String @default("wav")
durationSeconds Int? @map("duration_seconds")
channels Int @default(2)
sizeBytes BigInt? @map("size_bytes")
checksum String?
recordedAt DateTime @map("recorded_at")
retentionUntil DateTime? @map("retention_until")
status RecordingStatus @default(AVAILABLE)
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
tenant Tenant @relation(fields: [tenantId], references: [id])
call Call @relation(fields: [callId], references: [id])
@@index([tenantId, recordedAt])
@@index([retentionUntil])
@@map("recordings")
}