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
+27 -25
View File
@@ -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>
+99 -19
View File
@@ -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>
+111 -45
View File
@@ -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>