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:
@@ -11,6 +11,7 @@
|
||||
"test": "jest"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.665.0",
|
||||
"@jorgecuadros/database": "workspace:*",
|
||||
"@nestjs/common": "^10.4.4",
|
||||
"@nestjs/config": "^3.3.0",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { ConfigModule } from "@nestjs/config";
|
||||
import { PrismaModule } from "./prisma/prisma.module";
|
||||
import { StorageModule } from "./storage/storage.module";
|
||||
import { CommonModule } from "./common/common.module";
|
||||
import { UsersModule } from "./users/users.module";
|
||||
import { AuthModule } from "./auth/auth.module";
|
||||
@@ -16,6 +17,7 @@ import { AppController } from "./app.controller";
|
||||
imports: [
|
||||
ConfigModule.forRoot({ isGlobal: true }),
|
||||
PrismaModule,
|
||||
StorageModule,
|
||||
CommonModule,
|
||||
UsersModule,
|
||||
AuthModule,
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -9,10 +9,16 @@ import {
|
||||
Put,
|
||||
Query,
|
||||
Req,
|
||||
Res,
|
||||
StreamableFile,
|
||||
UploadedFile,
|
||||
UseGuards,
|
||||
UseInterceptors,
|
||||
} from "@nestjs/common";
|
||||
import { FileInterceptor } from "@nestjs/platform-express";
|
||||
import { ServiceKind } from "@jorgecuadros/database";
|
||||
import { Request } from "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";
|
||||
@@ -196,7 +202,35 @@ export class PropertiesController {
|
||||
return this.properties.removeTrust(id);
|
||||
}
|
||||
|
||||
// --- documents (remove pointer only) --------------------------------------
|
||||
// --- documents ------------------------------------------------------------
|
||||
|
||||
@Post(":id/documents")
|
||||
@RequireAbility("property: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.properties.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.properties.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("property:update")
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import { Global, Module } from "@nestjs/common";
|
||||
import { StorageService } from "./storage.service";
|
||||
|
||||
/** Global so any feature module can inject StorageService without re-importing. */
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [StorageService],
|
||||
exports: [StorageService],
|
||||
})
|
||||
export class StorageModule {}
|
||||
@@ -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}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { extname } from "node:path";
|
||||
|
||||
/** Multer file shape we rely on (subset of Express.Multer.File). */
|
||||
export interface UploadedFileLike {
|
||||
buffer: Buffer;
|
||||
originalname?: string;
|
||||
mimetype?: string;
|
||||
size?: number;
|
||||
}
|
||||
|
||||
const MIME_EXT: Record<string, string> = {
|
||||
"application/pdf": ".pdf",
|
||||
"image/jpeg": ".jpg",
|
||||
"image/png": ".png",
|
||||
"image/gif": ".gif",
|
||||
"image/tiff": ".tif",
|
||||
"image/bmp": ".bmp",
|
||||
};
|
||||
|
||||
/** File extension for a stored blob, from the original name, else the mimetype. */
|
||||
export function extForUpload(file: UploadedFileLike): string {
|
||||
const fromName = file.originalname ? extname(file.originalname).toLowerCase() : "";
|
||||
if (fromName) return fromName;
|
||||
return (file.mimetype && MIME_EXT[file.mimetype]) || "";
|
||||
}
|
||||
|
||||
/** Download filename for a stored document, from its key + document type. */
|
||||
export function downloadName(storageKey: string, documentType: string): string {
|
||||
const ext = extname(storageKey) || "";
|
||||
const base = documentType.replace(/[^\w.-]+/g, "_") || "document";
|
||||
return base.toLowerCase().endsWith(ext.toLowerCase()) ? base : `${base}${ext}`;
|
||||
}
|
||||
@@ -3,7 +3,13 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { AppShell } from "@/components/AppShell";
|
||||
import { archiveCustomer, getCustomer, restoreCustomer } from "@/lib/api";
|
||||
import {
|
||||
archiveCustomer,
|
||||
getCustomer,
|
||||
policyDocumentDownloadUrl,
|
||||
propertyDocumentDownloadUrl,
|
||||
restoreCustomer,
|
||||
} from "@/lib/api";
|
||||
import { useCan } from "@/lib/abilities";
|
||||
import {
|
||||
domainLabel,
|
||||
@@ -790,15 +796,15 @@ function TxRow({ t }: { t: Transaction }) {
|
||||
|
||||
/* ----------------------------------------------------------- Documentos */
|
||||
function DocumentosSection({ data }: { data: CustomerDetail }) {
|
||||
type Doc = { type: string; key: string | null; scope: string };
|
||||
type Doc = { type: string; scope: string; href: string | null };
|
||||
const docs: Doc[] = [];
|
||||
data.properties.forEach((p) => {
|
||||
const label = [p.addressLine1].filter(Boolean).join("") || "Propiedad";
|
||||
p.documents.forEach((d) =>
|
||||
docs.push({
|
||||
type: d.documentType || "Documento",
|
||||
key: d.storageKey,
|
||||
scope: label,
|
||||
href: d.id ? propertyDocumentDownloadUrl(p.id, d.id) : null,
|
||||
}),
|
||||
);
|
||||
});
|
||||
@@ -806,8 +812,8 @@ function DocumentosSection({ data }: { data: CustomerDetail }) {
|
||||
p.documents.forEach((d) =>
|
||||
docs.push({
|
||||
type: d.documentType || "Documento",
|
||||
key: d.storageKey,
|
||||
scope: `Póliza ${p.policyNumber ?? ""}`.trim(),
|
||||
href: d.id ? policyDocumentDownloadUrl(p.id, d.id) : null,
|
||||
}),
|
||||
);
|
||||
});
|
||||
@@ -821,28 +827,24 @@ function DocumentosSection({ data }: { data: CustomerDetail }) {
|
||||
No hay documentos registrados para este cliente.
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="doc-list">
|
||||
{docs.map((d, i) => (
|
||||
<div className="doc-item" key={i}>
|
||||
<span className="doc-icon" aria-hidden>
|
||||
▤
|
||||
</span>
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<div className="doc-type">{d.type}</div>
|
||||
<div className="doc-key">{d.scope}</div>
|
||||
</div>
|
||||
<div className="doc-list">
|
||||
{docs.map((d, i) => (
|
||||
<div className="doc-item" key={i}>
|
||||
<span className="doc-icon" aria-hidden>
|
||||
▤
|
||||
</span>
|
||||
<div style={{ minWidth: 0, flex: 1 }}>
|
||||
<div className="doc-type">{d.type}</div>
|
||||
<div className="doc-key">{d.scope}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div
|
||||
className="section-note"
|
||||
style={{ padding: "0 22px 18px" }}
|
||||
>
|
||||
Los archivos se almacenan en el object storage
|
||||
(storageKey); no se descargan desde esta vista.
|
||||
</div>
|
||||
</>
|
||||
{d.href && (
|
||||
<a className="btn btn-ghost" href={d.href}>
|
||||
Descargar
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -8,9 +8,12 @@ import {
|
||||
archivePolicy,
|
||||
getLookups,
|
||||
getPolicy,
|
||||
policyDocumentDownloadUrl,
|
||||
removePolicyChild,
|
||||
removePolicyDocument,
|
||||
restorePolicy,
|
||||
updatePolicyChild,
|
||||
uploadPolicyDocument,
|
||||
} from "@/lib/api";
|
||||
import { useCan } from "@/lib/abilities";
|
||||
import { ChildCollection, type ChildConfig } from "@/components/ChildCollection";
|
||||
@@ -101,7 +104,7 @@ function Detail({ id }: { id: string }) {
|
||||
)}
|
||||
{data.claims.length > 0 && <SiniestrosSection data={data} />}
|
||||
<CoberturasSection data={data} />
|
||||
<DocumentosSection data={data} />
|
||||
<DocumentosSection data={data} onChange={reload} />
|
||||
<ChildrenEditor data={data} onChange={reload} />
|
||||
</div>
|
||||
);
|
||||
@@ -664,7 +667,35 @@ function CoberturasSection({ data }: { data: PolicyDetail }) {
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------- Documentos */
|
||||
function DocumentosSection({ data }: { data: PolicyDetail }) {
|
||||
function DocumentosSection({
|
||||
data,
|
||||
onChange,
|
||||
}: {
|
||||
data: PolicyDetail;
|
||||
onChange: () => void;
|
||||
}) {
|
||||
const canEdit = useCan("policy:update");
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [type, setType] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
async function upload() {
|
||||
if (!file) return;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
await uploadPolicyDocument(data.id, file, type.trim() || undefined);
|
||||
setFile(null);
|
||||
setType("");
|
||||
onChange();
|
||||
} catch (e) {
|
||||
setError((e as Error)?.message ?? "No se pudo subir el archivo.");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="section">
|
||||
<SectionHead rule="docs" title="Documentos" count={data.documents.length} />
|
||||
@@ -674,25 +705,74 @@ function DocumentosSection({ data }: { data: PolicyDetail }) {
|
||||
No hay documentos registrados para esta póliza.
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="doc-list">
|
||||
{data.documents.map((d, i) => (
|
||||
<div className="doc-item" key={d.id ?? i}>
|
||||
<span className="doc-icon" aria-hidden>
|
||||
▤
|
||||
</span>
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<div className="doc-type">{d.documentType || "Documento"}</div>
|
||||
<div className="doc-key">{d.storageKey || "—"}</div>
|
||||
</div>
|
||||
<div className="doc-list">
|
||||
{data.documents.map((d, i) => (
|
||||
<div className="doc-item" key={d.id ?? i}>
|
||||
<span className="doc-icon" aria-hidden>
|
||||
▤
|
||||
</span>
|
||||
<div style={{ minWidth: 0, flex: 1 }}>
|
||||
<div className="doc-type">{d.documentType || "Documento"}</div>
|
||||
<div className="doc-key">{d.storageKey || "—"}</div>
|
||||
</div>
|
||||
))}
|
||||
{d.id && (
|
||||
<a
|
||||
className="btn btn-ghost"
|
||||
href={policyDocumentDownloadUrl(data.id, d.id)}
|
||||
>
|
||||
Descargar
|
||||
</a>
|
||||
)}
|
||||
{canEdit && d.id && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost"
|
||||
onClick={async () => {
|
||||
if (!window.confirm("¿Eliminar este documento?")) return;
|
||||
try {
|
||||
await removePolicyDocument(data.id, d.id!);
|
||||
onChange();
|
||||
} catch (e) {
|
||||
window.alert((e as Error)?.message ?? "No se pudo eliminar.");
|
||||
}
|
||||
}}
|
||||
>
|
||||
Eliminar
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{canEdit && (
|
||||
<div style={{ padding: "0 22px 18px" }}>
|
||||
{error && (
|
||||
<div className="state-box state-error" style={{ marginBottom: 12 }}>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
<div className="inline-form">
|
||||
<input
|
||||
className="input"
|
||||
placeholder="Tipo (ej. CARATULA)"
|
||||
value={type}
|
||||
onChange={(e) => setType(e.target.value)}
|
||||
/>
|
||||
<input
|
||||
type="file"
|
||||
className="input"
|
||||
onChange={(e) => setFile(e.target.files?.[0] ?? null)}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
disabled={!file || busy}
|
||||
onClick={upload}
|
||||
>
|
||||
{busy ? "Subiendo…" : "Subir"}
|
||||
</button>
|
||||
</div>
|
||||
<div className="section-note" style={{ padding: "0 22px 18px" }}>
|
||||
Los archivos se almacenan en el object storage (storageKey); no se
|
||||
descargan desde esta vista.
|
||||
</div>
|
||||
</>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -7,11 +7,13 @@ import {
|
||||
addService,
|
||||
archiveProperty,
|
||||
getProperty,
|
||||
propertyDocumentDownloadUrl,
|
||||
removePropertyDocument,
|
||||
removeService,
|
||||
removeTrust,
|
||||
restoreProperty,
|
||||
updateService,
|
||||
uploadPropertyDocument,
|
||||
upsertTrust,
|
||||
} from "@/lib/api";
|
||||
import { useCan } from "@/lib/abilities";
|
||||
@@ -206,51 +208,111 @@ function PropertyEditor({
|
||||
|
||||
<TrustEditor data={data} onChange={onChange} />
|
||||
|
||||
<DocumentsEditor data={data} onChange={onChange} />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
/** Upload / download / delete document blobs stored in object storage (MinIO). */
|
||||
function DocumentsEditor({
|
||||
data,
|
||||
onChange,
|
||||
}: {
|
||||
data: PropertyDetail;
|
||||
onChange: () => void;
|
||||
}) {
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [type, setType] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
async function upload() {
|
||||
if (!file) return;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
await uploadPropertyDocument(data.id, file, type.trim() || undefined);
|
||||
setFile(null);
|
||||
setType("");
|
||||
onChange();
|
||||
} catch (e) {
|
||||
setError((e as Error)?.message ?? "No se pudo subir el archivo.");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="card" style={{ padding: 16 }}>
|
||||
<h3 className="section-title" style={{ marginTop: 0 }}>Documentos</h3>
|
||||
{data.documents.length > 0 && (
|
||||
<div className="card" style={{ padding: 16 }}>
|
||||
<h3 className="section-title" style={{ marginTop: 0 }}>Documentos</h3>
|
||||
<div className="tx-scroll">
|
||||
<table className="tx-table">
|
||||
<thead>
|
||||
<tr><th>Tipo</th><th>Clave</th><th className="num">Acción</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.documents.map((d) => (
|
||||
<tr key={d.id ?? d.storageKey}>
|
||||
<td>{d.documentType ?? "—"}</td>
|
||||
<td className="mono">{d.storageKey ?? "—"}</td>
|
||||
<td>
|
||||
<div className="row-actions">
|
||||
<button
|
||||
type="button"
|
||||
<div className="tx-scroll">
|
||||
<table className="tx-table">
|
||||
<thead>
|
||||
<tr><th>Tipo</th><th>Clave</th><th className="num">Acción</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.documents.map((d) => (
|
||||
<tr key={d.id ?? d.storageKey}>
|
||||
<td>{d.documentType ?? "—"}</td>
|
||||
<td className="mono">{d.storageKey ?? "—"}</td>
|
||||
<td>
|
||||
<div className="row-actions">
|
||||
{d.id && (
|
||||
<a
|
||||
className="btn btn-ghost"
|
||||
onClick={async () => {
|
||||
if (!d.id) return;
|
||||
if (!window.confirm("¿Eliminar este documento?")) return;
|
||||
try {
|
||||
await removePropertyDocument(data.id, d.id);
|
||||
onChange();
|
||||
} catch (e) {
|
||||
window.alert((e as Error)?.message ?? "No se pudo eliminar.");
|
||||
}
|
||||
}}
|
||||
href={propertyDocumentDownloadUrl(data.id, d.id)}
|
||||
>
|
||||
Eliminar
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p className="inline-form-note">
|
||||
La carga de nuevos documentos requiere el almacenamiento de archivos
|
||||
(pendiente); aquí solo se pueden eliminar los existentes.
|
||||
</p>
|
||||
Descargar
|
||||
</a>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost"
|
||||
onClick={async () => {
|
||||
if (!d.id) return;
|
||||
if (!window.confirm("¿Eliminar este documento?")) return;
|
||||
try {
|
||||
await removePropertyDocument(data.id, d.id);
|
||||
onChange();
|
||||
} catch (e) {
|
||||
window.alert((e as Error)?.message ?? "No se pudo eliminar.");
|
||||
}
|
||||
}}
|
||||
>
|
||||
Eliminar
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
{error && <div className="state-box state-error" style={{ marginTop: 12 }}>{error}</div>}
|
||||
<div className="inline-form" style={{ marginTop: 12 }}>
|
||||
<input
|
||||
className="input"
|
||||
placeholder="Tipo (ej. RECIBO)"
|
||||
value={type}
|
||||
onChange={(e) => setType(e.target.value)}
|
||||
/>
|
||||
<input
|
||||
type="file"
|
||||
className="input"
|
||||
onChange={(e) => setFile(e.target.files?.[0] ?? null)}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
disabled={!file || busy}
|
||||
onClick={upload}
|
||||
>
|
||||
{busy ? "Subiendo…" : "Subir"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -744,17 +806,21 @@ function DocumentosSection({ data }: { data: PropertyDetail }) {
|
||||
<span className="doc-icon" aria-hidden>
|
||||
▤
|
||||
</span>
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<div style={{ minWidth: 0, flex: 1 }}>
|
||||
<div className="doc-type">{d.documentType || "Documento"}</div>
|
||||
<div className="doc-key">{d.storageKey || "—"}</div>
|
||||
</div>
|
||||
{d.id && (
|
||||
<a
|
||||
className="btn btn-ghost"
|
||||
href={propertyDocumentDownloadUrl(data.id, d.id)}
|
||||
>
|
||||
Descargar
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="section-note" style={{ padding: "0 22px 18px" }}>
|
||||
Los archivos se almacenan en el object storage (storageKey); no se
|
||||
descargan desde esta vista.
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
+58
-4
@@ -413,6 +413,47 @@ export function removePropertyDocument(
|
||||
});
|
||||
}
|
||||
|
||||
export function propertyDocumentDownloadUrl(
|
||||
propertyId: string,
|
||||
documentId: string,
|
||||
): string {
|
||||
return `${API_ORIGIN}/properties/${propertyId}/documents/${documentId}/download`;
|
||||
}
|
||||
|
||||
export function uploadPropertyDocument(
|
||||
propertyId: string,
|
||||
file: File,
|
||||
type?: string,
|
||||
): Promise<unknown> {
|
||||
const q = type ? `?type=${encodeURIComponent(type)}` : "";
|
||||
return uploadFile(`/properties/${propertyId}/documents${q}`, file);
|
||||
}
|
||||
|
||||
export function removePolicyDocument(
|
||||
policyId: string,
|
||||
documentId: string,
|
||||
): Promise<unknown> {
|
||||
return apiFetch(`/policies/${policyId}/documents/${documentId}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
}
|
||||
|
||||
export function policyDocumentDownloadUrl(
|
||||
policyId: string,
|
||||
documentId: string,
|
||||
): string {
|
||||
return `${API_ORIGIN}/policies/${policyId}/documents/${documentId}/download`;
|
||||
}
|
||||
|
||||
export function uploadPolicyDocument(
|
||||
policyId: string,
|
||||
file: File,
|
||||
type?: string,
|
||||
): Promise<unknown> {
|
||||
const q = type ? `?type=${encodeURIComponent(type)}` : "";
|
||||
return uploadFile(`/policies/${policyId}/documents${q}`, file);
|
||||
}
|
||||
|
||||
/* ------------------------------------------- Billing / statements module */
|
||||
|
||||
export interface MovementQuery {
|
||||
@@ -604,11 +645,19 @@ export function listIngest(): Promise<IngestFile[]> {
|
||||
return apiFetch<IngestFile[]>("/ops/ingest");
|
||||
}
|
||||
|
||||
/** Multipart upload — not JSON, so it bypasses apiFetch's Content-Type. */
|
||||
export async function uploadIngest(name: string, file: File): Promise<void> {
|
||||
/**
|
||||
* Multipart upload — not JSON, so it bypasses apiFetch's Content-Type. `path`
|
||||
* is API-relative (may include a query string); `filename` overrides the part
|
||||
* name sent to the server.
|
||||
*/
|
||||
export async function uploadFile(
|
||||
path: string,
|
||||
file: File,
|
||||
filename?: string,
|
||||
): Promise<unknown> {
|
||||
const body = new FormData();
|
||||
body.append("file", file, name);
|
||||
const res = await fetch(`${API_ORIGIN}/ops/ingest/${encodeURIComponent(name)}`, {
|
||||
body.append("file", file, filename ?? file.name);
|
||||
const res = await fetch(`${API_ORIGIN}${path}`, {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
body,
|
||||
@@ -623,6 +672,11 @@ export async function uploadIngest(name: string, file: File): Promise<void> {
|
||||
}
|
||||
throw new ApiError(res.status, message);
|
||||
}
|
||||
return res.status === 204 ? undefined : res.json().catch(() => undefined);
|
||||
}
|
||||
|
||||
export function uploadIngest(name: string, file: File): Promise<unknown> {
|
||||
return uploadFile(`/ops/ingest/${encodeURIComponent(name)}`, file, name);
|
||||
}
|
||||
|
||||
export function deleteIngest(name: string): Promise<unknown> {
|
||||
|
||||
Reference in New Issue
Block a user