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:
@@ -8,9 +8,15 @@ import {
|
||||
Post,
|
||||
Query,
|
||||
Req,
|
||||
Res,
|
||||
StreamableFile,
|
||||
UploadedFile,
|
||||
UseGuards,
|
||||
UseInterceptors,
|
||||
} from "@nestjs/common";
|
||||
import { Request } from "express";
|
||||
import { FileInterceptor } from "@nestjs/platform-express";
|
||||
import { Request, Response } from "express";
|
||||
import { downloadName, type UploadedFileLike } from "../storage/upload-file";
|
||||
import { AuthenticatedGuard } from "../auth/authenticated.guard";
|
||||
import { AbilityGuard } from "../auth/ability.guard";
|
||||
import { RequireAbility } from "../auth/require-ability.decorator";
|
||||
@@ -240,4 +246,40 @@ export class PoliciesController {
|
||||
removeClaim(@Param("id") id: string, @Param("childId") childId: string) {
|
||||
return this.policies.removeClaim(id, childId);
|
||||
}
|
||||
|
||||
// --- documents ------------------------------------------------------------
|
||||
|
||||
@Post(":id/documents")
|
||||
@RequireAbility("policy:update")
|
||||
@UseInterceptors(
|
||||
FileInterceptor("file", { limits: { fileSize: 50 * 1024 * 1024 } }),
|
||||
)
|
||||
addDocument(
|
||||
@Param("id") id: string,
|
||||
@UploadedFile() file: UploadedFileLike | undefined,
|
||||
@Query("type") type: string | undefined,
|
||||
) {
|
||||
if (!file) throw new Error("No se recibió ningún archivo.");
|
||||
return this.policies.addDocument(id, file, type);
|
||||
}
|
||||
|
||||
@Get(":id/documents/:childId/download")
|
||||
async downloadDocument(
|
||||
@Param("id") id: string,
|
||||
@Param("childId") childId: string,
|
||||
@Res({ passthrough: true }) res: Response,
|
||||
): Promise<StreamableFile> {
|
||||
const { row, stream, contentType } = await this.policies.getDocument(id, childId);
|
||||
res.set({
|
||||
"Content-Type": contentType ?? "application/octet-stream",
|
||||
"Content-Disposition": `attachment; filename="${downloadName(row.storageKey, row.documentType)}"`,
|
||||
});
|
||||
return new StreamableFile(stream);
|
||||
}
|
||||
|
||||
@Delete(":id/documents/:childId")
|
||||
@RequireAbility("policy:update")
|
||||
removeDocument(@Param("id") id: string, @Param("childId") childId: string) {
|
||||
return this.policies.removeDocument(id, childId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { Injectable, NotFoundException } from "@nestjs/common";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { Prisma } from "@jorgecuadros/database";
|
||||
import { PrismaService } from "../prisma/prisma.service";
|
||||
import { StorageService } from "../storage/storage.service";
|
||||
import { extForUpload, type UploadedFileLike } from "../storage/upload-file";
|
||||
import { toDate } from "../common/coerce";
|
||||
import { CreatePolicyDto, UpdatePolicyDto } from "./policy.dto";
|
||||
import {
|
||||
@@ -77,7 +80,10 @@ function daysUntil(policyTo: Date | null, from: Date): number | null {
|
||||
|
||||
@Injectable()
|
||||
export class PoliciesService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly storage: StorageService,
|
||||
) {}
|
||||
|
||||
private statusWhere(
|
||||
status: PolicyStatus | undefined,
|
||||
@@ -479,6 +485,47 @@ export class PoliciesService {
|
||||
};
|
||||
}
|
||||
|
||||
// --- documents ------------------------------------------------------------
|
||||
// Blob in object storage under `policy/<policyId>/…`; row is the pointer.
|
||||
|
||||
async addDocument(
|
||||
policyId: string,
|
||||
file: UploadedFileLike,
|
||||
documentType?: string,
|
||||
) {
|
||||
await this.ensurePolicy(policyId);
|
||||
const key = `policy/${policyId}/${randomUUID()}${extForUpload(file)}`;
|
||||
await this.storage.put(key, file.buffer, file.mimetype);
|
||||
return this.prisma.policyDocument.create({
|
||||
data: {
|
||||
policyId,
|
||||
documentType: documentType?.trim() || "DOCUMENT",
|
||||
storageKey: key,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async getDocument(policyId: string, id: string) {
|
||||
const row = await this.prisma.policyDocument.findFirst({
|
||||
where: { id, policyId },
|
||||
});
|
||||
if (!row) throw new NotFoundException(`Document ${id} not found on policy ${policyId}`);
|
||||
const blob = await this.storage.getStream(row.storageKey);
|
||||
return { row, ...blob };
|
||||
}
|
||||
|
||||
async removeDocument(policyId: string, id: string) {
|
||||
await this.ensurePolicy(policyId);
|
||||
const row = await this.prisma.policyDocument.findFirst({
|
||||
where: { id, policyId },
|
||||
select: { id: true, storageKey: true },
|
||||
});
|
||||
if (!row) throw new NotFoundException(`Document ${id} not found on policy ${policyId}`);
|
||||
const deleted = await this.prisma.policyDocument.delete({ where: { id } });
|
||||
await this.storage.delete(row.storageKey);
|
||||
return deleted;
|
||||
}
|
||||
|
||||
// --- lookups (providers / policy types / adjusters) -----------------------
|
||||
|
||||
listLookups() {
|
||||
|
||||
Reference in New Issue
Block a user