feat(storage): wire MinIO/S3 document upload & download into the API + web
The schema has carried `storageKey` pointers and the migration has written blobs to MinIO since day one, but the API had no S3 client — documents could only be deleted, never uploaded or retrieved. This adds the missing wiring. API - StorageModule/StorageService (@aws-sdk/client-s3, path-style for MinIO): put/getStream/delete, best-effort bucket ensure on boot, gracefully disabled when S3 env is absent (ServiceUnavailable on use). - Reads S3_ENDPOINT/S3_BUCKET + S3_ACCESS_KEY/S3_SECRET_KEY, falling back to MINIO_ROOT_USER/MINIO_ROOT_PASSWORD so one credential set drives both the migration and the API. - Property service documents: POST :id/documents (multipart), GET :id/documents/:childId/download (streamed), delete now also drops the blob. - Policy documents: same upload/download/delete (previously had none). - Keys stay under the service/<id>/… and policy/<id>/… prefixes the migration established. Web - api.ts: shared uploadFile() helper (uploadIngest refactored onto it), upload/download/remove helpers for property & policy documents. - Servicios, polizas, clientes detail pages: real Descargar links and an upload control (gated by policy:update / property:update) replacing the "storage pending" notes. Infra - docker-compose: minio service (9000/9001, healthcheck, named volume) + S3 env wired into the api service. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,122 @@
|
||||
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<string>("S3_ENDPOINT");
|
||||
this.bucket = config.get<string>("S3_BUCKET") ?? "jorgecuadros-documents";
|
||||
const accessKeyId =
|
||||
config.get<string>("S3_ACCESS_KEY") ?? config.get<string>("MINIO_ROOT_USER");
|
||||
const secretAccessKey =
|
||||
config.get<string>("S3_SECRET_KEY") ?? config.get<string>("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<string>("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}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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<void> {
|
||||
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<void> {
|
||||
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}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user