import { createReadStream } from 'node:fs'; import { access, mkdir, rm, writeFile } from 'node:fs/promises'; import { join } from 'node:path'; import type { Readable } from 'node:stream'; import { Injectable } from '@nestjs/common'; import { AppConfig } from '../config/app-config.service'; /** * Filesystem binding for uploaded files (ADR 0011, issue #27): opaque * layout `//`, original filenames and metadata * live in the database, not on disk. Kept behind this interface so an S3 * binding stays possible later without touching callers. */ @Injectable() export class FileStorageService { constructor(private readonly config: AppConfig) {} private pathFor(pondId: string, fileId: string): string { return join(this.config.env.UPLOADS_DIR, pondId, fileId); } async save(pondId: string, fileId: string, data: Buffer): Promise { await mkdir(join(this.config.env.UPLOADS_DIR, pondId), { recursive: true }); await writeFile(this.pathFor(pondId, fileId), data); } createReadStream(pondId: string, fileId: string): Readable { return createReadStream(this.pathFor(pondId, fileId)); } /** Whether the file's bytes are actually on disk. Used by the pond export to * skip an attachment whose bytes are missing (data drift) rather than crash * the archive stream (issue #65). */ async exists(pondId: string, fileId: string): Promise { try { await access(this.pathFor(pondId, fileId)); return true; } catch { return false; } } /** Idempotent — removing an already-absent file is not an error. */ async delete(pondId: string, fileId: string): Promise { await rm(this.pathFor(pondId, fileId), { force: true }); } }