feat(storage): wire MinIO/S3 document upload & download into the API + web
Build and Push Images / Build jorgecuadros-web (push) Successful in 1m37s
Build and Push Images / Build jorgecuadros-api (push) Successful in 2m8s

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:
2026-07-23 19:19:57 -07:00
co-authored by Claude Opus 4.8
parent 45afb824ef
commit afe2411c86
15 changed files with 957 additions and 102 deletions
+40 -5
View File
@@ -1,6 +1,9 @@
import { Injectable, NotFoundException } from "@nestjs/common";
import { randomUUID } from "node:crypto";
import { Prisma, ServiceKind } from "@jorgecuadros/database";
import { PrismaService } from "../prisma/prisma.service";
import { StorageService } from "../storage/storage.service";
import { extForUpload } from "../storage/upload-file";
import { toDate } from "../common/coerce";
import {
CreatePropertyDto,
@@ -83,7 +86,10 @@ function daysUntil(dueDate: Date | null | undefined, from: Date): number | null
@Injectable()
export class PropertiesService {
constructor(private readonly prisma: PrismaService) {}
constructor(
private readonly prisma: PrismaService,
private readonly storage: StorageService,
) {}
private trustWhere(
trust: TrustFilter | undefined,
@@ -543,16 +549,45 @@ export class PropertiesService {
}
// --- documents ------------------------------------------------------------
// Removing a pointer row only; uploading files needs the object-storage
// client wired into the API (today only the migration writes to MinIO).
// The blob lives in object storage (MinIO); the row is just the pointer. Keys
// stay under the `service/<propertyId>/…` prefix the migration established.
async addDocument(
propertyId: string,
file: { buffer: Buffer; originalname?: string; mimetype?: string },
documentType?: string,
) {
await this.ensureProperty(propertyId);
const ext = extForUpload(file);
const key = `service/${propertyId}/${randomUUID()}${ext}`;
await this.storage.put(key, file.buffer, file.mimetype);
return this.prisma.serviceDocument.create({
data: {
propertyId,
documentType: documentType?.trim() || "DOCUMENT",
storageKey: key,
},
});
}
async getDocument(propertyId: string, id: string) {
const row = await this.prisma.serviceDocument.findFirst({
where: { id, propertyId },
});
if (!row) throw new NotFoundException(`Document ${id} not found on property ${propertyId}`);
const blob = await this.storage.getStream(row.storageKey);
return { row, ...blob };
}
async removeDocument(propertyId: string, id: string) {
await this.ensureProperty(propertyId);
const row = await this.prisma.serviceDocument.findFirst({
where: { id, propertyId },
select: { id: true },
select: { id: true, storageKey: true },
});
if (!row) throw new NotFoundException(`Document ${id} not found on property ${propertyId}`);
return this.prisma.serviceDocument.delete({ where: { id } });
const deleted = await this.prisma.serviceDocument.delete({ where: { id } });
await this.storage.delete(row.storageKey);
return deleted;
}
}