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