diff --git a/.gitignore b/.gitignore index 4d2c324..5cfcb83 100644 --- a/.gitignore +++ b/.gitignore @@ -30,3 +30,6 @@ Thumbs.db # Docker volumes / data infrastructure/**/data/ +# Gravações (bind mount, ver docs/RECORDING.md) — nunca commitar áudio de +# chamadas de tenants nem os arquivos do object storage local. +/data/ diff --git a/TODO.md b/TODO.md index 748f8f9..2c8934a 100644 --- a/TODO.md +++ b/TODO.md @@ -426,8 +426,48 @@ - [ ] Cross-tenant reports pra platform admin (secao 157) — sem console de plataforma ainda -## PHASE 18+ — ver `agente.md` seções 90 em diante (Recordings, AI, -Billing, Frontend, Security, Tests) +## PHASE 18 — Recording / Object Storage (agente.md secao 90-94) +- [x] `packages/storage`: `ObjectStorageProvider` (secao 92) — Local + (filesystem, com checagem de path traversal) e S3-compatible + (`@aws-sdk/client-s3`, preparado pra MinIO — nunca exercitado nesta + sessão, sem servidor S3 disponível). Escolhido por + `STORAGE_PROVIDER` env, cada processo monta o seu +- [x] `buildRecordingObjectKey` (secao 93): + `tenants/{tenant_id}/recordings/YYYY/MM/DD/{call_id}.wav`, sempre + montada no servidor +- [x] Bind mounts (não volumes nomeados) pro spool de gravação e pro + storage local — `apps/api` roda no host, precisa enxergar os mesmos + arquivos que `fs-events` escreve (mesmo padrão de `REDIS_URL`) +- [x] `apps/predictive-dialer`: `RECORD_STEREO=true` + + `execute_on_answer='record_session ...'` quando + `Campaign.recordingEnabled` — `origination_uuid` pré-gerado pra + poder montar o path de gravação antes do originate +- [x] `apps/freeswitch-events/src/recording.ts`: em CALL_ENDED (encadeado + depois do CDR terminar, não em paralelo — evita a mesma corrida já + corrigida na fase CDR), sobe a gravação, cria `Recording` + (`retentionUntil` a partir de `Plan.recordingRetentionDays`), apaga + o spool local +- [x] `GET /recordings`, `GET /recordings/:id`, `GET /recordings/:id/audio` + (stream autenticado, nunca URL direta pro storage) +- [x] Retenção (secao 94): `runRetentionSweep` no boot do `apps/api` + a + cada hora — apaga o objeto, marca `status=DELETED` (linha nunca + apagada, fica como auditoria) +- [x] **Bug real, achado no teste desta fase**: `Recording.sizeBytes` + (BigInt) quebrava `GET /recordings` com 500 — Fastify não serializa + BigInt nativamente (mesma classe de bug já corrigida uma vez no + logger). Corrigido convertendo pra `number` na resposta. +- [x] Testado ponta a ponta: gravação real criada (RIFF WAVE, PCM 16-bit, + ESTÉREO 8000Hz — RECORD_STEREO confirmado), upload com path exato da + secao 93, download via API com md5 idêntico ao objeto original, + varredura de retenção apagando objeto + status DELETED + list/ + download bloqueados depois +- [ ] Chamadas manuais/internas não são gravadas — só o caminho do + discador tem originate próprio +- [ ] `max_recording_storage_gb` (Plan) existe mas não é aplicado +- [ ] Transcrição (secao 95+, fase IA) + +## PHASE 19+ — ver `agente.md` seções 95 em diante (AI, Billing, Frontend, +Security, Tests) --- diff --git a/apps/api/package.json b/apps/api/package.json index 01738d2..a060c87 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -13,6 +13,7 @@ "@b2bcall/database": "workspace:*", "@b2bcall/entitlements": "workspace:*", "@b2bcall/shared": "workspace:*", + "@b2bcall/storage": "workspace:*", "@b2bcall/telephony": "workspace:*", "@fastify/cors": "11.3.0", "@fastify/helmet": "13.1.1", diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index 943b91c..0db1ddb 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -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 {} diff --git a/apps/api/src/main.ts b/apps/api/src/main.ts index ecf1aae..6cc0751 100644 --- a/apps/api/src/main.ts +++ b/apps/api/src/main.ts @@ -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( @@ -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(); diff --git a/apps/api/src/recordings/recordings.controller.ts b/apps/api/src/recordings/recordings.controller.ts new file mode 100644 index 0000000..ea4f83c --- /dev/null +++ b/apps/api/src/recordings/recordings.controller.ts @@ -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); + } +} diff --git a/apps/api/src/recordings/recordings.module.ts b/apps/api/src/recordings/recordings.module.ts new file mode 100644 index 0000000..8e25a32 --- /dev/null +++ b/apps/api/src/recordings/recordings.module.ts @@ -0,0 +1,7 @@ +import { Module } from "@nestjs/common"; +import { RecordingsController } from "./recordings.controller"; + +@Module({ + controllers: [RecordingsController], +}) +export class RecordingsModule {} diff --git a/apps/api/src/recordings/retention-sweep.ts b/apps/api/src/recordings/retention-sweep.ts new file mode 100644 index 0000000..a8dc409 --- /dev/null +++ b/apps/api/src/recordings/retention-sweep.ts @@ -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 { + 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, + }); + } + } + } +} diff --git a/apps/freeswitch-events/Dockerfile b/apps/freeswitch-events/Dockerfile index 82579de..9f0a5cc 100644 --- a/apps/freeswitch-events/Dockerfile +++ b/apps/freeswitch-events/Dockerfile @@ -15,6 +15,7 @@ COPY packages/types packages/types COPY packages/shared packages/shared COPY packages/telephony packages/telephony COPY packages/database packages/database +COPY packages/storage packages/storage COPY apps/freeswitch-events apps/freeswitch-events RUN pnpm install --frozen-lockfile --filter @b2bcall/freeswitch-events... diff --git a/apps/freeswitch-events/package.json b/apps/freeswitch-events/package.json index 019840a..cc7f77d 100644 --- a/apps/freeswitch-events/package.json +++ b/apps/freeswitch-events/package.json @@ -11,6 +11,7 @@ "dependencies": { "@b2bcall/database": "workspace:*", "@b2bcall/shared": "workspace:*", + "@b2bcall/storage": "workspace:*", "@b2bcall/telephony": "workspace:*", "esl": "11.2.1", "ioredis": "^6.0.0" diff --git a/apps/freeswitch-events/src/main.ts b/apps/freeswitch-events/src/main.ts index 6c25fac..4d11ba6 100644 --- a/apps/freeswitch-events/src/main.ts +++ b/apps/freeswitch-events/src/main.ts @@ -5,6 +5,7 @@ import { createLogger } from "@b2bcall/shared"; import { updateTrunkStatusFromGatewayEvent } from "./trunk-status"; import { resolveTenantIdForAgent, resolveTenantIdForQueue } from "./tenant-resolve"; import { persistCallEvent } from "./cdr"; +import { uploadRecordingIfPresent } from "./recording"; const logger = createLogger("b2bcall-fs-events"); @@ -139,9 +140,23 @@ async function main() { logger.error("falha ao publicar evento normalizado no Redis", { error: String(err) }); }); - persistCallEvent(normalized).catch((err) => { - logger.error("falha ao persistir CDR", { error: String(err), type: normalized.type }); - }); + // .then() em vez de esperar aqui (await bloquearia o processamento do + // próximo evento ESL) — mas o upload da gravação só roda DEPOIS do + // persistCallEvent terminar de verdade, nunca em paralelo com ele: + // uploadRecordingIfPresent lê Call.talkTime/durationSeconds, que é + // exatamente o que persistCallEvent acabou de calcular no CALL_ENDED + // (mesma classe de corrida já corrigida uma vez neste arquivo, ver + // docs/CDR.md — aqui evitada por ordenação, não por retry). + persistCallEvent(normalized) + .then(() => { + if (normalized.type === "CALL_ENDED" && normalized.tenantId && normalized.callUuid) { + return uploadRecordingIfPresent(normalized.tenantId, normalized.callUuid); + } + return undefined; + }) + .catch((err) => { + logger.error("falha ao persistir CDR/gravacao", { error: String(err), type: normalized.type }); + }); logger.info(`evento: ${normalized.type}`, { callUuid: normalized.callUuid, diff --git a/apps/freeswitch-events/src/recording.ts b/apps/freeswitch-events/src/recording.ts new file mode 100644 index 0000000..76bd25c --- /dev/null +++ b/apps/freeswitch-events/src/recording.ts @@ -0,0 +1,103 @@ +import { access, rm } from "node:fs/promises"; +import { join } from "node:path"; +import { getPrismaClient, withTenantContext } from "@b2bcall/database"; +import { getObjectStorageProvider, buildRecordingObjectKey } from "@b2bcall/storage"; +import { createLogger } from "@b2bcall/shared"; + +const logger = createLogger("b2bcall-fs-events"); + +const SPOOL_DIR = process.env.RECORDINGS_SPOOL_DIR ?? "/recordings"; +const STORAGE_KIND = (process.env.STORAGE_PROVIDER ?? "local").toUpperCase() as "LOCAL" | "S3"; + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +async function fileExists(path: string): Promise { + try { + await access(path); + return true; + } catch { + return false; + } +} + +/** + * Sobe pro object storage a gravação de uma chamada que acabou de + * terminar (agente.md secao 90-93) — chamado a partir de CALL_ENDED em + * main.ts, sempre depois do CDR já ter persistido `Call` (precisa de + * `Call.createdAt`/`talkTime` pra `recordedAt`/`durationSeconds`). + * + * `originateSimulatedAnswerLeg`/`originateRealPstnLeg` + * (apps/predictive-dialer) só setam `execute_on_answer='record_session + * ...'` quando `Campaign.recordingEnabled` é true — pra qualquer outra + * chamada, o arquivo simplesmente não existe, e essa função não faz nada + * (checagem por existência de arquivo, não por reconsultar a campanha). + */ +export async function uploadRecordingIfPresent(tenantId: string, callId: string): Promise { + const spoolPath = join(SPOOL_DIR, `${callId}.wav`); + + // record_session termina de gravar no CHANNEL_HANGUP_COMPLETE, mas pode + // levar um instante pra flush terminar — tenta algumas vezes antes de + // desistir, em vez de perder a gravação por uma corrida. + let exists = await fileExists(spoolPath); + for (let attempt = 0; !exists && attempt < 5; attempt++) { + await sleep(300); + exists = await fileExists(spoolPath); + } + if (!exists) return; + + const prisma = getPrismaClient(); + + try { + const call = await withTenantContext(prisma, tenantId, (tx) => tx.call.findUniqueOrThrow({ where: { id: callId } })); + const tenant = await prisma.tenant.findUniqueOrThrow({ where: { id: tenantId }, include: { plan: true } }); + + const recordedAt = call.createdAt; + const objectKey = buildRecordingObjectKey(tenantId, callId, recordedAt); + const storage = getObjectStorageProvider(); + const { sizeBytes, checksum } = await storage.putObject(objectKey, spoolPath); + + const retentionDays = tenant.plan.recordingRetentionDays; + const retentionUntil = retentionDays + ? new Date(recordedAt.getTime() + retentionDays * 24 * 60 * 60 * 1000) + : null; + + await withTenantContext(prisma, tenantId, (tx) => + tx.recording.create({ + data: { + tenantId, + callId, + storageProvider: STORAGE_KIND, + objectKey, + format: "wav", + durationSeconds: call.talkTime ?? call.durationSeconds, + channels: 2, + sizeBytes, + checksum, + recordedAt, + retentionUntil, + }, + }), + ); + + await rm(spoolPath, { force: true }); + logger.info("gravacao enviada pro object storage", { callId, objectKey, sizeBytes }); + } catch (err) { + logger.error("falha ao processar gravacao", { error: String(err), callId }); + await withTenantContext(prisma, tenantId, (tx) => + tx.recording + .create({ + data: { + tenantId, + callId, + storageProvider: STORAGE_KIND, + objectKey: "", + recordedAt: new Date(), + status: "FAILED", + }, + }) + .catch(() => undefined), + ); + } +} diff --git a/apps/predictive-dialer/src/originate.ts b/apps/predictive-dialer/src/originate.ts index 6aecf3f..7ab58eb 100644 --- a/apps/predictive-dialer/src/originate.ts +++ b/apps/predictive-dialer/src/originate.ts @@ -1,3 +1,4 @@ +import { randomUUID } from "node:crypto"; import type { FreeSwitchTelephonyProvider } from "@b2bcall/telephony"; export interface OriginateIdentifiers { @@ -20,6 +21,34 @@ function channelVars(ids: OriginateIdentifiers): Record { }; } +const RECORDINGS_SPOOL_DIR = process.env.RECORDINGS_SPOOL_DIR ?? "/recordings"; + +/** + * Gravação (agente.md secao 90-91): quando habilitada, grava pro spool + * local do FreeSWITCH via `execute_on_answer='record_session ...'` — roda + * assim que a chamada atende, antes da aplicação principal (`&callcenter`) + * começar. `RECORD_STEREO=true` grava canal A (cliente) e canal B (agente) + * separados, quando tecnicamente adequado (secao 91) — o mod_callcenter + * bridgeia um leg só por vez, então isso vale pra qualquer chamada que + * chega a conectar com um agente. + * + * O nome do arquivo usa `origination_uuid` pré-gerado aqui (em vez de + * deixar o provider sortear um) — precisamos saber o uuid ANTES de montar + * o comando de originate, já que o path de gravação entra no mesmo + * comando. Esse 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 — é assim que + * `apps/freeswitch-events/src/recording.ts` acha o arquivo certo. + */ +function recordingVars(originationUuid: string, recordingEnabled: boolean): Record { + if (!recordingEnabled) return {}; + const path = `${RECORDINGS_SPOOL_DIR}/${originationUuid}.wav`; + return { + RECORD_STEREO: "true", + execute_on_answer: `record_session ${path}`, + }; +} + /** * Perna "atendida" sintética (modo simulação): quando o outcome sorteado é * ANSWERED, origina uma chamada de verdade no FreeSWITCH (`null/dummy`, @@ -33,12 +62,18 @@ export async function originateSimulatedAnswerLeg( ids: OriginateIdentifiers, queueId: string, domain: string, + recordingEnabled: boolean, ): Promise<{ uuid: string }> { + const originationUuid = randomUUID(); return provider.originate({ destination: "null/dummy", application: "callcenter", applicationArgs: `${queueId}@${domain}`, - channelVariables: channelVars(ids), + channelVariables: { + ...channelVars(ids), + ...recordingVars(originationUuid, recordingEnabled), + origination_uuid: originationUuid, + }, }); } @@ -61,13 +96,19 @@ export async function originateRealPstnLeg( callerIdName?: string; callerIdNumber?: string; ringTimeoutSeconds: number; + recordingEnabled: boolean; }, ): Promise<{ uuid: string }> { + const originationUuid = randomUUID(); return provider.originate({ destination: `sofia/gateway/${params.trunkId}/${params.phoneNumber}`, application: "callcenter", applicationArgs: `${params.queueId}@${params.domain}`, - channelVariables: channelVars(ids), + channelVariables: { + ...channelVars(ids), + ...recordingVars(originationUuid, params.recordingEnabled), + origination_uuid: originationUuid, + }, callerIdName: params.callerIdName, callerIdNumber: params.callerIdNumber, timeoutSeconds: params.ringTimeoutSeconds, diff --git a/apps/predictive-dialer/src/tick.ts b/apps/predictive-dialer/src/tick.ts index 05a0ea8..64acd2c 100644 --- a/apps/predictive-dialer/src/tick.ts +++ b/apps/predictive-dialer/src/tick.ts @@ -176,6 +176,7 @@ async function runSimulatedAttempt( { tenantId: tenant.id, attemptId, campaignId: campaign.id, leadId }, campaign.queueId, tenant.telephonyDomain ?? "", + campaign.recordingEnabled, ); registerQueuedAttempt(uuid, { @@ -228,6 +229,7 @@ async function runRealAttempt( callerIdName: campaign.callerIdName ?? undefined, callerIdNumber: campaign.callerIdNumber ?? undefined, ringTimeoutSeconds: campaign.ringTimeout, + recordingEnabled: campaign.recordingEnabled, }, ); diff --git a/docker-compose.yml b/docker-compose.yml index 5f4d6c0..bca9685 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -87,6 +87,10 @@ services: volumes: - freeswitch_external_gateways:/etc/freeswitch/sip_profiles/external - freeswitch_callcenter_queues:/etc/freeswitch/autoload_configs/callcenter_queues.conf.d + # Bind mount (não volume nomeado do Docker) porque apps/api roda no + # host e precisa enxergar os mesmos arquivos que o FreeSWITCH grava + # (agente.md secao 90-91) — ver docs/RECORDING.md. + - ./data/recordings-spool:/recordings # Nenhuma porta publicada no host: SIP/RTP ainda não têm troncos reais # configurados, e o Event Socket (8021) só deve ser alcançável por outros # containers na rede interna do compose (agente.md secao 22). @@ -116,6 +120,21 @@ services: # REDIS_URL do .env que aponta pra localhost (uso pelo apps/api, que # ainda roda no host) — ver docs/NETWORK_ARCHITECTURE.md. REDIS_URL: redis://:${REDIS_PASSWORD}@redis:6379 + # Gravações (agente.md secao 90-93) — mesmo padrão do REDIS_URL + # acima: path Docker-interno aqui, path do host no .env pro + # apps/api enxergar os mesmos arquivos (ver docs/RECORDING.md). + RECORDINGS_SPOOL_DIR: /recordings + STORAGE_PROVIDER: ${STORAGE_PROVIDER:-local} + LOCAL_STORAGE_ROOT: /data/object-storage + S3_BUCKET: ${S3_BUCKET:-} + S3_REGION: ${S3_REGION:-} + S3_ENDPOINT: ${S3_ENDPOINT:-} + S3_ACCESS_KEY_ID: ${S3_ACCESS_KEY_ID:-} + S3_SECRET_ACCESS_KEY: ${S3_SECRET_ACCESS_KEY:-} + S3_FORCE_PATH_STYLE: ${S3_FORCE_PATH_STYLE:-} + volumes: + - ./data/recordings-spool:/recordings + - ./data/object-storage-local:/data/object-storage predictive-dialer: build: @@ -138,6 +157,10 @@ services: # por default. Ver docs/PREDICTIVE_DIALER.md. DIALER_SIMULATION: ${DIALER_SIMULATION:-true} ALLOW_REAL_OUTBOUND_CALLS: ${ALLOW_REAL_OUTBOUND_CALLS:-false} + # Só usado pra montar o path do record_session (agente.md secao 91) + # — quem de fato grava e' o processo FreeSWITCH, não este worker; + # não precisa do volume montado aqui, só saber o path Docker-interno. + RECORDINGS_SPOOL_DIR: /recordings secrets: freeswitch_pat: diff --git a/docs/RECORDING.md b/docs/RECORDING.md new file mode 100644 index 0000000..8d847b6 --- /dev/null +++ b/docs/RECORDING.md @@ -0,0 +1,146 @@ +# 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. diff --git a/packages/database/prisma/migrations/20260828171229_recordings/migration.sql b/packages/database/prisma/migrations/20260828171229_recordings/migration.sql new file mode 100644 index 0000000..a5b5811 --- /dev/null +++ b/packages/database/prisma/migrations/20260828171229_recordings/migration.sql @@ -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); diff --git a/packages/database/prisma/schema.prisma b/packages/database/prisma/schema.prisma index ff27a51..6ab20e7 100644 --- a/packages/database/prisma/schema.prisma +++ b/packages/database/prisma/schema.prisma @@ -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") +} diff --git a/packages/storage/package.json b/packages/storage/package.json new file mode 100644 index 0000000..a969525 --- /dev/null +++ b/packages/storage/package.json @@ -0,0 +1,17 @@ +{ + "name": "@b2bcall/storage", + "version": "0.0.1", + "private": true, + "main": "src/index.ts", + "types": "src/index.ts", + "scripts": { + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@aws-sdk/client-s3": "3.1110.0" + }, + "devDependencies": { + "@types/node": "^22.20.1", + "typescript": "^5.7.0" + } +} diff --git a/packages/storage/src/index.ts b/packages/storage/src/index.ts new file mode 100644 index 0000000..6e7f7eb --- /dev/null +++ b/packages/storage/src/index.ts @@ -0,0 +1,37 @@ +export type { ObjectStorageProvider, PutObjectResult } from "./types"; +export { buildRecordingObjectKey } from "./path"; +export { LocalObjectStorageProvider } from "./local-provider"; +export { S3ObjectStorageProvider } from "./s3-provider"; + +import type { ObjectStorageProvider } from "./types"; +import { LocalObjectStorageProvider } from "./local-provider"; +import { S3ObjectStorageProvider } from "./s3-provider"; + +let provider: ObjectStorageProvider | undefined; + +/** + * Fábrica lida por env (`STORAGE_PROVIDER=local|s3`, default `local`) — + * cada processo (apps/freeswitch-events, apps/api) monta seu próprio + * provider a partir das mesmas variáveis, nunca hardcoda qual backend usar. + */ +export function getObjectStorageProvider(): ObjectStorageProvider { + if (provider) return provider; + + const kind = process.env.STORAGE_PROVIDER ?? "local"; + if (kind === "s3") { + const bucket = process.env.S3_BUCKET; + if (!bucket) throw new Error("S3_BUCKET nao configurado (STORAGE_PROVIDER=s3)"); + provider = new S3ObjectStorageProvider({ + bucket, + region: process.env.S3_REGION, + endpoint: process.env.S3_ENDPOINT, + accessKeyId: process.env.S3_ACCESS_KEY_ID, + secretAccessKey: process.env.S3_SECRET_ACCESS_KEY, + forcePathStyle: process.env.S3_FORCE_PATH_STYLE === "true", + }); + } else { + const root = process.env.LOCAL_STORAGE_ROOT ?? "/data/object-storage"; + provider = new LocalObjectStorageProvider(root); + } + return provider; +} diff --git a/packages/storage/src/local-provider.ts b/packages/storage/src/local-provider.ts new file mode 100644 index 0000000..eb5d857 --- /dev/null +++ b/packages/storage/src/local-provider.ts @@ -0,0 +1,53 @@ +import { createHash } from "node:crypto"; +import { createReadStream } from "node:fs"; +import { mkdir, rm, stat, copyFile } from "node:fs/promises"; +import { dirname, join, normalize, relative } from "node:path"; +import type { Readable } from "node:stream"; +import type { ObjectStorageProvider, PutObjectResult } from "./types"; + +/** + * Backend local (agente.md secao 92) — raiz configurável via + * `LOCAL_STORAGE_ROOT`. Cada `key` (já construída por `buildRecordingObjectKey`, + * nunca fornecida crua pelo client) vira um caminho dentro dessa raiz; + * `assertKeyInsideRoot` é a última linha de defesa contra path traversal + * (`../..`) caso algum bug em outro lugar deixe passar uma key malformada. + */ +export class LocalObjectStorageProvider implements ObjectStorageProvider { + constructor(private readonly root: string) {} + + private resolvePath(key: string): string { + const full = normalize(join(this.root, key)); + const rel = relative(this.root, full); + if (rel.startsWith("..") || rel === "") { + throw new Error(`Object key fora da raiz de storage: ${key}`); + } + return full; + } + + async putObject(key: string, sourceFilePath: string): Promise { + const destPath = this.resolvePath(key); + await mkdir(dirname(destPath), { recursive: true }); + await copyFile(sourceFilePath, destPath); + + const [{ size }, checksum] = await Promise.all([stat(destPath), sha256File(destPath)]); + return { sizeBytes: size, checksum }; + } + + async getObjectStream(key: string): Promise { + return createReadStream(this.resolvePath(key)); + } + + async deleteObject(key: string): Promise { + await rm(this.resolvePath(key), { force: true }); + } +} + +function sha256File(path: string): Promise { + return new Promise((resolve, reject) => { + const hash = createHash("sha256"); + const stream = createReadStream(path); + stream.on("data", (chunk) => hash.update(chunk)); + stream.on("end", () => resolve(hash.digest("hex"))); + stream.on("error", reject); + }); +} diff --git a/packages/storage/src/path.ts b/packages/storage/src/path.ts new file mode 100644 index 0000000..311cf75 --- /dev/null +++ b/packages/storage/src/path.ts @@ -0,0 +1,14 @@ +/** + * Estrutura de path segura (agente.md secao 93): + * `tenants/{tenant_id}/recordings/YYYY/MM/DD/{call_id}.wav`. Sempre + * construída no servidor a partir de dados já confiáveis (tenantId do JWT/ + * contexto do worker, callId do próprio Call) — nunca aceita um object key + * vindo do client, então um tenant nunca consegue montar uma key que + * aponte pra dentro da pasta de outro tenant. + */ +export function buildRecordingObjectKey(tenantId: string, callId: string, recordedAt: Date, format = "wav"): string { + const yyyy = recordedAt.getUTCFullYear(); + const mm = String(recordedAt.getUTCMonth() + 1).padStart(2, "0"); + const dd = String(recordedAt.getUTCDate()).padStart(2, "0"); + return `tenants/${tenantId}/recordings/${yyyy}/${mm}/${dd}/${callId}.${format}`; +} diff --git a/packages/storage/src/s3-provider.ts b/packages/storage/src/s3-provider.ts new file mode 100644 index 0000000..c5b5407 --- /dev/null +++ b/packages/storage/src/s3-provider.ts @@ -0,0 +1,78 @@ +import { createHash } from "node:crypto"; +import { createReadStream } from "node:fs"; +import { stat } from "node:fs/promises"; +import type { Readable } from "node:stream"; +import { S3Client, PutObjectCommand, GetObjectCommand, DeleteObjectCommand } from "@aws-sdk/client-s3"; +import type { ObjectStorageProvider, PutObjectResult } from "./types"; + +export interface S3ProviderOptions { + bucket: string; + region?: string; + /** Endpoint custom pra S3-compatible (MinIO, secao 92) — omitido usa + * AWS S3 real. */ + endpoint?: string; + accessKeyId?: string; + secretAccessKey?: string; + /** MinIO e a maioria dos S3-compatible precisam de path-style + * (`endpoint/bucket/key`) em vez do virtual-hosted-style padrão da AWS. */ + forcePathStyle?: boolean; +} + +/** + * Backend S3-compatible (agente.md secao 92) — preparado pra AWS S3 e + * MinIO. **Nunca exercitado nesta sessão**: não existe um servidor S3/MinIO + * disponível neste laboratório pra testar contra. A implementação segue a + * API padrão do `@aws-sdk/client-s3`; revisar com um MinIO real antes de + * confiar em produção. + */ +export class S3ObjectStorageProvider implements ObjectStorageProvider { + private readonly client: S3Client; + private readonly bucket: string; + + constructor(options: S3ProviderOptions) { + this.bucket = options.bucket; + this.client = new S3Client({ + region: options.region ?? "us-east-1", + endpoint: options.endpoint, + forcePathStyle: options.forcePathStyle, + credentials: + options.accessKeyId && options.secretAccessKey + ? { accessKeyId: options.accessKeyId, secretAccessKey: options.secretAccessKey } + : undefined, + }); + } + + async putObject(key: string, sourceFilePath: string): Promise { + const [{ size }, checksum] = await Promise.all([stat(sourceFilePath), sha256File(sourceFilePath)]); + + await this.client.send( + new PutObjectCommand({ + Bucket: this.bucket, + Key: key, + Body: createReadStream(sourceFilePath), + ContentLength: size, + }), + ); + + return { sizeBytes: size, checksum }; + } + + async getObjectStream(key: string): Promise { + const result = await this.client.send(new GetObjectCommand({ Bucket: this.bucket, Key: key })); + return result.Body as Readable; + } + + async deleteObject(key: string): Promise { + await this.client.send(new DeleteObjectCommand({ Bucket: this.bucket, Key: key })); + } +} + +function sha256File(path: string): Promise { + return new Promise((resolve, reject) => { + const hash = createHash("sha256"); + const stream = createReadStream(path); + stream.on("data", (chunk) => hash.update(chunk)); + stream.on("end", () => resolve(hash.digest("hex"))); + stream.on("error", reject); + }); +} diff --git a/packages/storage/src/types.ts b/packages/storage/src/types.ts new file mode 100644 index 0000000..0e25280 --- /dev/null +++ b/packages/storage/src/types.ts @@ -0,0 +1,23 @@ +import type { Readable } from "node:stream"; + +/** + * ObjectStorageProvider (agente.md secao 92) — abstração sobre onde os + * bytes de uma gravação (ou qualquer outro artefato binário futuro: + * transcrição, export de relatório) realmente moram. O domínio nunca deve + * falar diretamente com filesystem ou S3 — só com esta interface. + */ +export interface PutObjectResult { + sizeBytes: number; + checksum: string; +} + +export interface ObjectStorageProvider { + /** Envia o conteúdo de `sourceFilePath` (arquivo local) pra `key`. */ + putObject(key: string, sourceFilePath: string): Promise; + + /** Stream de leitura pro conteúdo de `key` — usado pra servir download + * autenticado via apps/api, nunca expondo a key/URL bruta ao client. */ + getObjectStream(key: string): Promise; + + deleteObject(key: string): Promise; +} diff --git a/packages/storage/tsconfig.json b/packages/storage/tsconfig.json new file mode 100644 index 0000000..5a24989 --- /dev/null +++ b/packages/storage/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3b0d2d5..390a29d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -26,6 +26,9 @@ importers: '@b2bcall/shared': specifier: workspace:* version: link:../../packages/shared + '@b2bcall/storage': + specifier: workspace:* + version: link:../../packages/storage '@b2bcall/telephony': specifier: workspace:* version: link:../../packages/telephony @@ -127,6 +130,9 @@ importers: '@b2bcall/shared': specifier: workspace:* version: link:../../packages/shared + '@b2bcall/storage': + specifier: workspace:* + version: link:../../packages/storage '@b2bcall/telephony': specifier: workspace:* version: link:../../packages/telephony @@ -242,6 +248,19 @@ importers: specifier: ^5.7.0 version: 5.9.3 + packages/storage: + dependencies: + '@aws-sdk/client-s3': + specifier: 3.1110.0 + version: 3.1110.0 + devDependencies: + '@types/node': + specifier: ^22.20.1 + version: 22.20.1 + typescript: + specifier: ^5.7.0 + version: 5.9.3 + packages/telephony: dependencies: esl: @@ -260,6 +279,78 @@ importers: packages: + '@aws-sdk/checksums@3.1000.29': + resolution: {integrity: sha512-Dtu0gr4dnATZAPwEYbpCsG+MpLM7OAliy2gTepEFQwl1vZ6DL3QMH2FveMa3HLvPsOdhJsPRB3KtxVhph9T75A==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/client-s3@3.1110.0': + resolution: {integrity: sha512-40xbEcWjdaYKlZ4/NvndIJ3LotAEQAvHVQ7Z4NVy4Z4xGRN7xXJlHI9bMh/4aMJQ++6h5W5sv+wqjfk0rEKOBg==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/core@3.977.9': + resolution: {integrity: sha512-reqPFEQrZxDZpeGj4PFMepBeR5LGYHRqq/L0motTzgFkCRBA4rFdaVXDSLYyGHhxVz7sT2PDnPN9CluGSfgyJA==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-env@3.972.70': + resolution: {integrity: sha512-H404B7dJl2mCrBqahDEYsanB0xhdDp6tXnXcTUnXmmpy2Q3J0Ho0bUajZ2jr/RdwzCyS59Gi8xXIFwPLGBl6Uw==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-http@3.972.72': + resolution: {integrity: sha512-X98zYOrVOeuosCX+6ktf29FC2N2GHPLia7qv6mzPzTc+RPAuHWCDS++Z6JK7eGYqb/v6uaW7bAXaOvDBfol+0w==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-ini@3.973.15': + resolution: {integrity: sha512-Rykg6s5ceBuynMOGWgoowO4N+27JfnqXAnVaSunZl0hOO1XodSrxGNz6sCEbnmS0lAfQZDKyb3fbr46gSuv6Sg==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-login@3.972.77': + resolution: {integrity: sha512-Jb59xfEISoN5mmbnA+HYqdtrSX3CgCtJoof+V5D8/TgUI56W63GEEd5Y58WijU3Ou6+WEgaLD1feVzaRXV5IDQ==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-node@3.972.81': + resolution: {integrity: sha512-Rml+WitoFvXmv6JZ18U/xGdGDGGvB/mOin0ya0lTnTrdC0Z1lrVxTYh7iNklZBcvcRMrs4DoEf6xy1KWyrLQQw==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-process@3.972.70': + resolution: {integrity: sha512-2ry03fGRJr4sV3jI+ocjj5JqALnFD6ymM5KiNCDZMvq8bX2GSbE0vji4aM43TVCl2nXqqLRZaUxdq/KeWRAY4Q==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-sso@3.973.14': + resolution: {integrity: sha512-jkhg/8ocAAoc0RFyLMhCw+/zZh7gystQgd4F4hznNa8P4Cc501PQmxd+jGLiMHodPJ+7Zv/3znM62gZojyasmA==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-web-identity@3.972.76': + resolution: {integrity: sha512-d3AGyVu759PGr35mEB2s22xxlNEA5rpdxtSPJthfPFJvoQ8dt357iVPECqWfUxXp1toJAvKmbtcIYVGigaGsCA==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/middleware-sdk-s3@3.972.75': + resolution: {integrity: sha512-wMIsNumRVKaNMKhvU/s9VrdEwE8S6gSzXp4RygFG5BEMnGkkXf8cjh8zf7cKJBpUDpqTWqwbz5isEgp9rH6Lng==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/nested-clients@3.997.44': + resolution: {integrity: sha512-NhEgryjlBF9w38ZXqGymQV28IhkYa1mKhlbYnqIis57AYwWGVYfUPgg/qC2rLRqOUfblxx++irvju10kVTa8Vw==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/signature-v4-multi-region@3.996.46': + resolution: {integrity: sha512-L+2xZTye/2T96f3lwCws0Zw6GG2JHZW9e8FpVgGBeeExSKyeoZ6CWRpBml/7DNiK/O26jrgPM9F+Ay8VkgzUWQ==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/token-providers@3.1116.0': + resolution: {integrity: sha512-ygIivKqh8aHzNkucOCXHyIBgBpLPfrSI0mCqXF+vLBsPTUKqj0VSqAY0GFPe7lQl4HntjOcQ+KSyS7oUV2C54Q==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/types@3.974.5': + resolution: {integrity: sha512-LkwLL2BLbC6wNNm4JaH9mbEqBMdOZCct6VAYqhdN4U1xrWM+fUJQEfbHwQgDypapOWTRtlk25akb5afM0P8CIQ==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/xml-builder@3.972.40': + resolution: {integrity: sha512-wlFmCIGUlwF4zx/kncw+bmxTQh1HeSJq4mYV/V5cZUSJadDP3kXvGW8Rn21cimj/7y9ju+47oYWXi97vF7czaA==} + engines: {node: '>=20.0.0'} + + '@aws/lambda-invoke-store@0.3.0': + resolution: {integrity: sha512-sl4Bm6yiMNYrZKkqqDFWN0UfnWhlS8ivKxrYl+6t0gCLrqr8y3B2IqZZbFRkfaVVp7C/baApyh71P+LeE1A2sQ==} + engines: {node: '>=18.0.0'} + '@babel/helper-string-parser@7.29.7': resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==} engines: {node: '>=6.9.0'} @@ -786,6 +877,30 @@ packages: '@types/react': optional: true + '@smithy/core@3.33.3': + resolution: {integrity: sha512-CsOeKq/9kA3y6VJHt+/+VTCtBaxJ4OTFpgrjIUhPpDIKxBci1k2bJaQASF2h/ELWrulGp+t97DZ0mevfAD8idg==} + engines: {node: '>=18.0.0'} + + '@smithy/credential-provider-imds@4.5.2': + resolution: {integrity: sha512-A9uSdn72ozbRUSit0eib0TW7nXuNPlaeM0zcGkJ+nE6tFcSDbnmtwoxbTCFBukVQcszDAyvsd7+rTduPTXpygg==} + engines: {node: '>=18.0.0'} + + '@smithy/fetch-http-handler@5.7.2': + resolution: {integrity: sha512-nZyWTmSpJEXl6VtWVMBJve/7x12DZu6sIX1z1a+ZMaHlQQRs9Zpu6NbTe/gmxYXVRpkjxyDYpZ5gx2IM6f/Wkw==} + engines: {node: '>=18.0.0'} + + '@smithy/node-http-handler@4.11.3': + resolution: {integrity: sha512-2jY1tSpERfPfWqyBV2pH+iGFaghVsIJszJNsT7hxtQYhVJpWDyc0LqOWI+nXOxOAHaEfZ4PXXtp1wW1TGpHhkA==} + engines: {node: '>=18.0.0'} + + '@smithy/signature-v4@5.7.3': + resolution: {integrity: sha512-7ImGm+FkHRLcBaRttIAMZ6bzJZWb2cJGoYjq46F2UjycujWzrL9GEN9h4w7eQyXJYnltrUhxbbieBAIRrdqpow==} + engines: {node: '>=18.0.0'} + + '@smithy/types@4.17.2': + resolution: {integrity: sha512-FOKpVZob9MPTn2znRzGrnsMHv7BOsKVw3XiP/cOyYLDVZ9qKp4nifIiSCuUU/fIj5Vu0UOAxCFr+qRAtG0NUkA==} + engines: {node: '>=18.0.0'} + '@socket.io/component-emitter@3.1.2': resolution: {integrity: sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==} @@ -930,6 +1045,9 @@ packages: better-result@2.10.0: resolution: {integrity: sha512-oQhh0y1qo2/ZKdAAEvHZAqKKiHOFU5k/bW96fE2ScgQOVkJRiHwB+nOS1SgFsYqRlxMDWvefXi9Q3px7QvgNDw==} + bowser@2.14.1: + resolution: {integrity: sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==} + c12@3.3.4: resolution: {integrity: sha512-cM0ApFQSBXuourJejzwv/AuPRvAxordTyParRVcHjjtXirtkzM0uK2L9TTn9s0cXZbG7E55jCivRQzoxYmRAlA==} peerDependencies: @@ -1628,6 +1746,171 @@ packages: snapshots: + '@aws-sdk/checksums@3.1000.29': + dependencies: + '@aws-sdk/core': 3.977.9 + '@aws-sdk/types': 3.974.5 + '@smithy/core': 3.33.3 + '@smithy/types': 4.17.2 + tslib: 2.8.1 + + '@aws-sdk/client-s3@3.1110.0': + dependencies: + '@aws-sdk/checksums': 3.1000.29 + '@aws-sdk/core': 3.977.9 + '@aws-sdk/credential-provider-node': 3.972.81 + '@aws-sdk/middleware-sdk-s3': 3.972.75 + '@aws-sdk/signature-v4-multi-region': 3.996.46 + '@aws-sdk/types': 3.974.5 + '@smithy/core': 3.33.3 + '@smithy/fetch-http-handler': 5.7.2 + '@smithy/node-http-handler': 4.11.3 + '@smithy/types': 4.17.2 + tslib: 2.8.1 + + '@aws-sdk/core@3.977.9': + dependencies: + '@aws-sdk/types': 3.974.5 + '@aws-sdk/xml-builder': 3.972.40 + '@aws/lambda-invoke-store': 0.3.0 + '@smithy/core': 3.33.3 + '@smithy/signature-v4': 5.7.3 + '@smithy/types': 4.17.2 + bowser: 2.14.1 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-env@3.972.70': + dependencies: + '@aws-sdk/core': 3.977.9 + '@aws-sdk/types': 3.974.5 + '@smithy/core': 3.33.3 + '@smithy/types': 4.17.2 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-http@3.972.72': + dependencies: + '@aws-sdk/core': 3.977.9 + '@aws-sdk/types': 3.974.5 + '@smithy/core': 3.33.3 + '@smithy/fetch-http-handler': 5.7.2 + '@smithy/node-http-handler': 4.11.3 + '@smithy/types': 4.17.2 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-ini@3.973.15': + dependencies: + '@aws-sdk/core': 3.977.9 + '@aws-sdk/credential-provider-env': 3.972.70 + '@aws-sdk/credential-provider-http': 3.972.72 + '@aws-sdk/credential-provider-login': 3.972.77 + '@aws-sdk/credential-provider-process': 3.972.70 + '@aws-sdk/credential-provider-sso': 3.973.14 + '@aws-sdk/credential-provider-web-identity': 3.972.76 + '@aws-sdk/nested-clients': 3.997.44 + '@aws-sdk/types': 3.974.5 + '@smithy/core': 3.33.3 + '@smithy/credential-provider-imds': 4.5.2 + '@smithy/types': 4.17.2 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-login@3.972.77': + dependencies: + '@aws-sdk/core': 3.977.9 + '@aws-sdk/nested-clients': 3.997.44 + '@aws-sdk/types': 3.974.5 + '@smithy/core': 3.33.3 + '@smithy/types': 4.17.2 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-node@3.972.81': + dependencies: + '@aws-sdk/credential-provider-env': 3.972.70 + '@aws-sdk/credential-provider-http': 3.972.72 + '@aws-sdk/credential-provider-ini': 3.973.15 + '@aws-sdk/credential-provider-process': 3.972.70 + '@aws-sdk/credential-provider-sso': 3.973.14 + '@aws-sdk/credential-provider-web-identity': 3.972.76 + '@aws-sdk/types': 3.974.5 + '@smithy/core': 3.33.3 + '@smithy/credential-provider-imds': 4.5.2 + '@smithy/types': 4.17.2 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-process@3.972.70': + dependencies: + '@aws-sdk/core': 3.977.9 + '@aws-sdk/types': 3.974.5 + '@smithy/core': 3.33.3 + '@smithy/types': 4.17.2 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-sso@3.973.14': + dependencies: + '@aws-sdk/core': 3.977.9 + '@aws-sdk/nested-clients': 3.997.44 + '@aws-sdk/token-providers': 3.1116.0 + '@aws-sdk/types': 3.974.5 + '@smithy/core': 3.33.3 + '@smithy/types': 4.17.2 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-web-identity@3.972.76': + dependencies: + '@aws-sdk/core': 3.977.9 + '@aws-sdk/nested-clients': 3.997.44 + '@aws-sdk/types': 3.974.5 + '@smithy/core': 3.33.3 + '@smithy/types': 4.17.2 + tslib: 2.8.1 + + '@aws-sdk/middleware-sdk-s3@3.972.75': + dependencies: + '@aws-sdk/core': 3.977.9 + '@aws-sdk/signature-v4-multi-region': 3.996.46 + '@aws-sdk/types': 3.974.5 + '@smithy/core': 3.33.3 + '@smithy/types': 4.17.2 + tslib: 2.8.1 + + '@aws-sdk/nested-clients@3.997.44': + dependencies: + '@aws-sdk/core': 3.977.9 + '@aws-sdk/signature-v4-multi-region': 3.996.46 + '@aws-sdk/types': 3.974.5 + '@smithy/core': 3.33.3 + '@smithy/fetch-http-handler': 5.7.2 + '@smithy/node-http-handler': 4.11.3 + '@smithy/types': 4.17.2 + tslib: 2.8.1 + + '@aws-sdk/signature-v4-multi-region@3.996.46': + dependencies: + '@aws-sdk/types': 3.974.5 + '@smithy/signature-v4': 5.7.3 + '@smithy/types': 4.17.2 + tslib: 2.8.1 + + '@aws-sdk/token-providers@3.1116.0': + dependencies: + '@aws-sdk/core': 3.977.9 + '@aws-sdk/nested-clients': 3.997.44 + '@aws-sdk/types': 3.974.5 + '@smithy/core': 3.33.3 + '@smithy/types': 4.17.2 + tslib: 2.8.1 + + '@aws-sdk/types@3.974.5': + dependencies: + '@smithy/types': 4.17.2 + tslib: 2.8.1 + + '@aws-sdk/xml-builder@3.972.40': + dependencies: + '@smithy/types': 4.17.2 + tslib: 2.8.1 + + '@aws/lambda-invoke-store@0.3.0': {} + '@babel/helper-string-parser@7.29.7': optional: true @@ -2078,6 +2361,39 @@ snapshots: optionalDependencies: '@types/react': 19.2.18 + '@smithy/core@3.33.3': + dependencies: + '@smithy/types': 4.17.2 + tslib: 2.8.1 + + '@smithy/credential-provider-imds@4.5.2': + dependencies: + '@smithy/core': 3.33.3 + '@smithy/types': 4.17.2 + tslib: 2.8.1 + + '@smithy/fetch-http-handler@5.7.2': + dependencies: + '@smithy/core': 3.33.3 + '@smithy/types': 4.17.2 + tslib: 2.8.1 + + '@smithy/node-http-handler@4.11.3': + dependencies: + '@smithy/core': 3.33.3 + '@smithy/types': 4.17.2 + tslib: 2.8.1 + + '@smithy/signature-v4@5.7.3': + dependencies: + '@smithy/core': 3.33.3 + '@smithy/types': 4.17.2 + tslib: 2.8.1 + + '@smithy/types@4.17.2': + dependencies: + tslib: 2.8.1 + '@socket.io/component-emitter@3.1.2': {} '@standard-schema/spec@1.1.0': {} @@ -2259,6 +2575,8 @@ snapshots: better-result@2.10.0: {} + bowser@2.14.1: {} + c12@3.3.4(magicast@0.5.4): dependencies: chokidar: 5.0.0