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

@@ -14,6 +14,7 @@ import { SuppressionModule } from "./suppression/suppression.module";
import { DispositionsModule } from "./dispositions/dispositions.module";
import { CallsModule } from "./calls/calls.module";
import { ReportsModule } from "./reports/reports.module";
import { RecordingsModule } from "./recordings/recordings.module";
@Module({
imports: [
@@ -32,6 +33,7 @@ import { ReportsModule } from "./reports/reports.module";
DispositionsModule,
CallsModule,
ReportsModule,
RecordingsModule,
],
})
export class AppModule {}

View File

@@ -8,6 +8,9 @@ import cors from "@fastify/cors";
import rateLimit from "@fastify/rate-limit";
import { AppModule } from "./app.module";
import { DomainExceptionFilter } from "./common/filters/domain-exception.filter";
import { runRetentionSweep } from "./recordings/retention-sweep";
const RETENTION_SWEEP_INTERVAL_MS = 60 * 60 * 1000;
async function bootstrap() {
const app = await NestFactory.create<NestFastifyApplication>(
@@ -51,6 +54,15 @@ async function bootstrap() {
const port = Number(process.env.API_PORT ?? 3000);
await app.listen(port, "127.0.0.1");
console.log(`b2bcall-api ouvindo em http://127.0.0.1:${port}`);
// Retenção de gravações (agente.md secao 94: "scheduler deverá aplicar
// retenção"). apps/api já é um processo de longa duração — não precisa
// de um serviço dedicado só pra isso; roda uma vez no boot e depois de
// hora em hora.
runRetentionSweep().catch((err) => console.error("falha na varredura de retencao (boot)", err));
setInterval(() => {
runRetentionSweep().catch((err) => console.error("falha na varredura de retencao", err));
}, RETENTION_SWEEP_INTERVAL_MS);
}
bootstrap();

View File

@@ -0,0 +1,90 @@
import { Controller, Get, NotFoundException, Param, Query, Res, UseGuards } from "@nestjs/common";
import type { FastifyReply } from "fastify";
import { getPrismaClient, withTenantContext, type Prisma, type Recording } from "@b2bcall/database";
import { getObjectStorageProvider } from "@b2bcall/storage";
import type { AccessTokenClaims } from "@b2bcall/auth";
import { JwtAuthGuard } from "../common/guards/jwt-auth.guard";
import { PermissionGuard } from "../common/guards/permission.guard";
import { RequirePermission } from "../common/decorators/require-permission.decorator";
import { CurrentUser } from "../common/decorators/current-user.decorator";
/**
* Gravações (agente.md secao 90-94). O áudio nunca é exposto via URL
* direta pro object storage (nem presigned) — sempre passa por aqui, com
* a mesma checagem de permissão/tenant de qualquer outro dado sensível
* (mesmo princípio de "nunca confiar em id vindo do client sem checar
* contra o tenant do JWT", secao 31).
*/
// `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 (mesma classe de bug já corrigida uma vez no logger, ver
// packages/shared/src/logger.ts). Tamanho de gravação nunca chega perto
// de Number.MAX_SAFE_INTEGER, então converter pra number aqui é seguro.
function serializeRecording(recording: Recording) {
return { ...recording, sizeBytes: recording.sizeBytes != null ? Number(recording.sizeBytes) : null };
}
@UseGuards(JwtAuthGuard, PermissionGuard)
@Controller("recordings")
export class RecordingsController {
@RequirePermission("recordings.view")
@Get()
async list(
@CurrentUser() user: AccessTokenClaims,
@Query("campaignId") campaignId?: string,
@Query("agentId") agentId?: string,
@Query("from") from?: string,
@Query("to") to?: string,
) {
const prisma = getPrismaClient();
const tenantId = user.tenantId!;
const where: Prisma.RecordingWhereInput = { tenantId, status: "AVAILABLE" };
if (from || to) {
where.recordedAt = {
...(from ? { gte: new Date(from) } : {}),
...(to ? { lte: new Date(to) } : {}),
};
}
if (campaignId || agentId) {
where.call = {
...(campaignId ? { campaignId } : {}),
...(agentId ? { agentId } : {}),
};
}
const recordings = await withTenantContext(prisma, tenantId, (tx) =>
tx.recording.findMany({ where, orderBy: { recordedAt: "desc" }, take: 500 }),
);
return recordings.map(serializeRecording);
}
@RequirePermission("recordings.view")
@Get(":id")
async get(@CurrentUser() user: AccessTokenClaims, @Param("id") id: string) {
const prisma = getPrismaClient();
const tenantId = user.tenantId!;
const recording = await withTenantContext(prisma, tenantId, (tx) => tx.recording.findFirst({ where: { id, tenantId } }));
if (!recording) throw new NotFoundException();
return serializeRecording(recording);
}
@RequirePermission("recordings.download")
@Get(":id/audio")
async download(@CurrentUser() user: AccessTokenClaims, @Param("id") id: string, @Res() reply: FastifyReply) {
const prisma = getPrismaClient();
const tenantId = user.tenantId!;
const recording = await withTenantContext(prisma, tenantId, (tx) =>
tx.recording.findFirst({ where: { id, tenantId, status: "AVAILABLE" } }),
);
if (!recording) throw new NotFoundException();
const storage = getObjectStorageProvider();
const stream = await storage.getObjectStream(recording.objectKey);
reply.header("Content-Type", `audio/${recording.format}`);
reply.header("Content-Disposition", `attachment; filename="${recording.callId}.${recording.format}"`);
reply.send(stream);
}
}

View File

@@ -0,0 +1,7 @@
import { Module } from "@nestjs/common";
import { RecordingsController } from "./recordings.controller";
@Module({
controllers: [RecordingsController],
})
export class RecordingsModule {}

View File

@@ -0,0 +1,45 @@
import { getPrismaClient, withTenantContext } from "@b2bcall/database";
import { getObjectStorageProvider } from "@b2bcall/storage";
import { createLogger } from "@b2bcall/shared";
const logger = createLogger("b2bcall-api");
/**
* Aplica retenção de gravações (agente.md secao 94): Plan.recordingRetentionDays
* define `Recording.retentionUntil` no momento da criação (ver
* apps/freeswitch-events/src/recording.ts); esta varredura periódica
* apaga o objeto no storage e marca a linha como DELETED (nunca apaga a
* linha em si — fica como registro de auditoria de "isso existiu e foi
* retido pelo tempo configurado"). `retentionUntil = null` (plano sem
* limite) nunca é varrido.
*/
export async function runRetentionSweep(): Promise<void> {
const prisma = getPrismaClient();
const storage = getObjectStorageProvider();
const now = new Date();
const tenants = await prisma.tenant.findMany({ where: { status: "ACTIVE" }, select: { id: true } });
for (const tenant of tenants) {
const expired = await withTenantContext(prisma, tenant.id, (tx) =>
tx.recording.findMany({
where: { tenantId: tenant.id, status: "AVAILABLE", retentionUntil: { lte: now } },
}),
);
for (const recording of expired) {
try {
await storage.deleteObject(recording.objectKey);
await withTenantContext(prisma, tenant.id, (tx) =>
tx.recording.update({ where: { id: recording.id }, data: { status: "DELETED" } }),
);
logger.info("gravacao apagada por retencao", { recordingId: recording.id, tenantId: tenant.id });
} catch (err) {
logger.error("falha ao apagar gravacao por retencao", {
error: String(err),
recordingId: recording.id,
});
}
}
}
}