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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user