import { Injectable, Logger, OnModuleInit, ServiceUnavailableException, } from "@nestjs/common"; import { ConfigService } from "@nestjs/config"; import { CreateBucketCommand, DeleteObjectCommand, GetObjectCommand, HeadBucketCommand, PutObjectCommand, S3Client, } from "@aws-sdk/client-s3"; import type { Readable } from "node:stream"; /** * S3 / MinIO object storage for document blobs. MySQL keeps only the pointer * (`storageKey`) + metadata; the bytes live here. Same bucket the migration's * `blob_extract.py` writes to, so keys stay under the `service/…` and * `policy/…` prefixes it established. * * Env (see deploy/.env.dev): S3_ENDPOINT, S3_BUCKET, and creds — S3_ACCESS_KEY * / S3_SECRET_KEY, falling back to MINIO_ROOT_USER / MINIO_ROOT_PASSWORD so a * single MinIO credential set drives both the migration and the API. */ @Injectable() export class StorageService implements OnModuleInit { private readonly logger = new Logger(StorageService.name); private readonly client: S3Client | null; readonly bucket: string; constructor(config: ConfigService) { const endpoint = config.get("S3_ENDPOINT"); this.bucket = config.get("S3_BUCKET") ?? "jorgecuadros-documents"; const accessKeyId = config.get("S3_ACCESS_KEY") ?? config.get("MINIO_ROOT_USER"); const secretAccessKey = config.get("S3_SECRET_KEY") ?? config.get("MINIO_ROOT_PASSWORD"); if (!endpoint || !accessKeyId || !secretAccessKey) { this.logger.warn( "Object storage not configured (missing S3_ENDPOINT / credentials); " + "document upload & download are disabled.", ); this.client = null; return; } this.client = new S3Client({ endpoint, region: config.get("S3_REGION") ?? "us-east-1", credentials: { accessKeyId, secretAccessKey }, forcePathStyle: true, // MinIO needs path-style addressing }); } /** Best-effort bucket check on boot; never blocks API startup. */ async onModuleInit() { if (!this.client) return; try { await this.client.send(new HeadBucketCommand({ Bucket: this.bucket })); } catch { try { await this.client.send(new CreateBucketCommand({ Bucket: this.bucket })); this.logger.log(`Created bucket "${this.bucket}".`); } catch (err) { this.logger.warn( `Could not verify/create bucket "${this.bucket}": ${(err as Error).message}`, ); } } } /** * Whether the deployment has object storage at all. Callers use this to * refuse work up front instead of failing halfway through — a recibo batch * that dies on its first `put` leaves a FAILED batch and no explanation the * office can act on. */ get available(): boolean { return this.client !== null; } private require(): S3Client { if (!this.client) { throw new ServiceUnavailableException( "El almacenamiento de documentos no está configurado.", ); } return this.client; } async put(key: string, body: Buffer, contentType?: string): Promise { await this.require().send( new PutObjectCommand({ Bucket: this.bucket, Key: key, Body: body, ContentType: contentType, }), ); } async getStream(key: string): Promise<{ stream: Readable; contentType?: string; contentLength?: number; }> { const out = await this.require().send( new GetObjectCommand({ Bucket: this.bucket, Key: key }), ); return { stream: out.Body as Readable, contentType: out.ContentType, contentLength: out.ContentLength, }; } /** Best-effort blob delete; a missing object is not an error. */ async delete(key: string): Promise { if (!this.client) return; try { await this.client.send( new DeleteObjectCommand({ Bucket: this.bucket, Key: key }), ); } catch (err) { this.logger.warn(`Failed to delete blob "${key}": ${(err as Error).message}`); } } }