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
+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>