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
+1
View File
@@ -11,6 +11,7 @@
"test": "jest" "test": "jest"
}, },
"dependencies": { "dependencies": {
"@aws-sdk/client-s3": "^3.665.0",
"@jorgecuadros/database": "workspace:*", "@jorgecuadros/database": "workspace:*",
"@nestjs/common": "^10.4.4", "@nestjs/common": "^10.4.4",
"@nestjs/config": "^3.3.0", "@nestjs/config": "^3.3.0",
+2
View File
@@ -1,6 +1,7 @@
import { Module } from "@nestjs/common"; import { Module } from "@nestjs/common";
import { ConfigModule } from "@nestjs/config"; import { ConfigModule } from "@nestjs/config";
import { PrismaModule } from "./prisma/prisma.module"; import { PrismaModule } from "./prisma/prisma.module";
import { StorageModule } from "./storage/storage.module";
import { CommonModule } from "./common/common.module"; import { CommonModule } from "./common/common.module";
import { UsersModule } from "./users/users.module"; import { UsersModule } from "./users/users.module";
import { AuthModule } from "./auth/auth.module"; import { AuthModule } from "./auth/auth.module";
@@ -16,6 +17,7 @@ import { AppController } from "./app.controller";
imports: [ imports: [
ConfigModule.forRoot({ isGlobal: true }), ConfigModule.forRoot({ isGlobal: true }),
PrismaModule, PrismaModule,
StorageModule,
CommonModule, CommonModule,
UsersModule, UsersModule,
AuthModule, AuthModule,
+43 -1
View File
@@ -8,9 +8,15 @@ import {
Post, Post,
Query, Query,
Req, Req,
Res,
StreamableFile,
UploadedFile,
UseGuards, UseGuards,
UseInterceptors,
} from "@nestjs/common"; } 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 { AuthenticatedGuard } from "../auth/authenticated.guard";
import { AbilityGuard } from "../auth/ability.guard"; import { AbilityGuard } from "../auth/ability.guard";
import { RequireAbility } from "../auth/require-ability.decorator"; import { RequireAbility } from "../auth/require-ability.decorator";
@@ -240,4 +246,40 @@ export class PoliciesController {
removeClaim(@Param("id") id: string, @Param("childId") childId: string) { removeClaim(@Param("id") id: string, @Param("childId") childId: string) {
return this.policies.removeClaim(id, childId); 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);
}
} }
+48 -1
View File
@@ -1,6 +1,9 @@
import { Injectable, NotFoundException } from "@nestjs/common"; import { Injectable, NotFoundException } from "@nestjs/common";
import { randomUUID } from "node:crypto";
import { Prisma } from "@jorgecuadros/database"; import { Prisma } from "@jorgecuadros/database";
import { PrismaService } from "../prisma/prisma.service"; 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 { toDate } from "../common/coerce";
import { CreatePolicyDto, UpdatePolicyDto } from "./policy.dto"; import { CreatePolicyDto, UpdatePolicyDto } from "./policy.dto";
import { import {
@@ -77,7 +80,10 @@ function daysUntil(policyTo: Date | null, from: Date): number | null {
@Injectable() @Injectable()
export class PoliciesService { export class PoliciesService {
constructor(private readonly prisma: PrismaService) {} constructor(
private readonly prisma: PrismaService,
private readonly storage: StorageService,
) {}
private statusWhere( private statusWhere(
status: PolicyStatus | undefined, 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) ----------------------- // --- lookups (providers / policy types / adjusters) -----------------------
listLookups() { listLookups() {
@@ -9,10 +9,16 @@ import {
Put, Put,
Query, Query,
Req, Req,
Res,
StreamableFile,
UploadedFile,
UseGuards, UseGuards,
UseInterceptors,
} from "@nestjs/common"; } from "@nestjs/common";
import { FileInterceptor } from "@nestjs/platform-express";
import { ServiceKind } from "@jorgecuadros/database"; 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 { AuthenticatedGuard } from "../auth/authenticated.guard";
import { AbilityGuard } from "../auth/ability.guard"; import { AbilityGuard } from "../auth/ability.guard";
import { RequireAbility } from "../auth/require-ability.decorator"; import { RequireAbility } from "../auth/require-ability.decorator";
@@ -196,7 +202,35 @@ export class PropertiesController {
return this.properties.removeTrust(id); 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") @Delete(":id/documents/:childId")
@RequireAbility("property:update") @RequireAbility("property:update")
+40 -5
View File
@@ -1,6 +1,9 @@
import { Injectable, NotFoundException } from "@nestjs/common"; import { Injectable, NotFoundException } from "@nestjs/common";
import { randomUUID } from "node:crypto";
import { Prisma, ServiceKind } from "@jorgecuadros/database"; import { Prisma, ServiceKind } from "@jorgecuadros/database";
import { PrismaService } from "../prisma/prisma.service"; import { PrismaService } from "../prisma/prisma.service";
import { StorageService } from "../storage/storage.service";
import { extForUpload } from "../storage/upload-file";
import { toDate } from "../common/coerce"; import { toDate } from "../common/coerce";
import { import {
CreatePropertyDto, CreatePropertyDto,
@@ -83,7 +86,10 @@ function daysUntil(dueDate: Date | null | undefined, from: Date): number | null
@Injectable() @Injectable()
export class PropertiesService { export class PropertiesService {
constructor(private readonly prisma: PrismaService) {} constructor(
private readonly prisma: PrismaService,
private readonly storage: StorageService,
) {}
private trustWhere( private trustWhere(
trust: TrustFilter | undefined, trust: TrustFilter | undefined,
@@ -543,16 +549,45 @@ export class PropertiesService {
} }
// --- documents ------------------------------------------------------------ // --- documents ------------------------------------------------------------
// Removing a pointer row only; uploading files needs the object-storage // The blob lives in object storage (MinIO); the row is just the pointer. Keys
// client wired into the API (today only the migration writes to MinIO). // 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) { async removeDocument(propertyId: string, id: string) {
await this.ensureProperty(propertyId); await this.ensureProperty(propertyId);
const row = await this.prisma.serviceDocument.findFirst({ const row = await this.prisma.serviceDocument.findFirst({
where: { id, propertyId }, where: { id, propertyId },
select: { id: true }, select: { id: true, storageKey: true },
}); });
if (!row) throw new NotFoundException(`Document ${id} not found on property ${propertyId}`); 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;
} }
} }
+10
View File
@@ -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 {}
+122
View File
@@ -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}`);
}
}
}
+32
View File
@@ -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}`;
}
+27 -25
View File
@@ -3,7 +3,13 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import Link from "next/link"; import Link from "next/link";
import { AppShell } from "@/components/AppShell"; 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 { useCan } from "@/lib/abilities";
import { import {
domainLabel, domainLabel,
@@ -790,15 +796,15 @@ function TxRow({ t }: { t: Transaction }) {
/* ----------------------------------------------------------- Documentos */ /* ----------------------------------------------------------- Documentos */
function DocumentosSection({ data }: { data: CustomerDetail }) { 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[] = []; const docs: Doc[] = [];
data.properties.forEach((p) => { data.properties.forEach((p) => {
const label = [p.addressLine1].filter(Boolean).join("") || "Propiedad"; const label = [p.addressLine1].filter(Boolean).join("") || "Propiedad";
p.documents.forEach((d) => p.documents.forEach((d) =>
docs.push({ docs.push({
type: d.documentType || "Documento", type: d.documentType || "Documento",
key: d.storageKey,
scope: label, scope: label,
href: d.id ? propertyDocumentDownloadUrl(p.id, d.id) : null,
}), }),
); );
}); });
@@ -806,8 +812,8 @@ function DocumentosSection({ data }: { data: CustomerDetail }) {
p.documents.forEach((d) => p.documents.forEach((d) =>
docs.push({ docs.push({
type: d.documentType || "Documento", type: d.documentType || "Documento",
key: d.storageKey,
scope: `Póliza ${p.policyNumber ?? ""}`.trim(), 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. No hay documentos registrados para este cliente.
</div> </div>
) : ( ) : (
<> <div className="doc-list">
<div className="doc-list"> {docs.map((d, i) => (
{docs.map((d, i) => ( <div className="doc-item" key={i}>
<div className="doc-item" key={i}> <span className="doc-icon" aria-hidden>
<span className="doc-icon" aria-hidden>
</span>
</span> <div style={{ minWidth: 0, flex: 1 }}>
<div style={{ minWidth: 0 }}> <div className="doc-type">{d.type}</div>
<div className="doc-type">{d.type}</div> <div className="doc-key">{d.scope}</div>
<div className="doc-key">{d.scope}</div>
</div>
</div> </div>
))} {d.href && (
</div> <a className="btn btn-ghost" href={d.href}>
<div Descargar
className="section-note" </a>
style={{ padding: "0 22px 18px" }} )}
> </div>
Los archivos se almacenan en el object storage ))}
(storageKey); no se descargan desde esta vista. </div>
</div>
</>
)} )}
</div> </div>
</section> </section>
+99 -19
View File
@@ -8,9 +8,12 @@ import {
archivePolicy, archivePolicy,
getLookups, getLookups,
getPolicy, getPolicy,
policyDocumentDownloadUrl,
removePolicyChild, removePolicyChild,
removePolicyDocument,
restorePolicy, restorePolicy,
updatePolicyChild, updatePolicyChild,
uploadPolicyDocument,
} from "@/lib/api"; } from "@/lib/api";
import { useCan } from "@/lib/abilities"; import { useCan } from "@/lib/abilities";
import { ChildCollection, type ChildConfig } from "@/components/ChildCollection"; import { ChildCollection, type ChildConfig } from "@/components/ChildCollection";
@@ -101,7 +104,7 @@ function Detail({ id }: { id: string }) {
)} )}
{data.claims.length > 0 && <SiniestrosSection data={data} />} {data.claims.length > 0 && <SiniestrosSection data={data} />}
<CoberturasSection data={data} /> <CoberturasSection data={data} />
<DocumentosSection data={data} /> <DocumentosSection data={data} onChange={reload} />
<ChildrenEditor data={data} onChange={reload} /> <ChildrenEditor data={data} onChange={reload} />
</div> </div>
); );
@@ -664,7 +667,35 @@ function CoberturasSection({ data }: { data: PolicyDetail }) {
} }
/* -------------------------------------------------------- Documentos */ /* -------------------------------------------------------- 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 ( return (
<section className="section"> <section className="section">
<SectionHead rule="docs" title="Documentos" count={data.documents.length} /> <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. No hay documentos registrados para esta póliza.
</div> </div>
) : ( ) : (
<> <div className="doc-list">
<div className="doc-list"> {data.documents.map((d, i) => (
{data.documents.map((d, i) => ( <div className="doc-item" key={d.id ?? i}>
<div className="doc-item" key={d.id ?? i}> <span className="doc-icon" aria-hidden>
<span className="doc-icon" aria-hidden>
</span>
</span> <div style={{ minWidth: 0, flex: 1 }}>
<div style={{ minWidth: 0 }}> <div className="doc-type">{d.documentType || "Documento"}</div>
<div className="doc-type">{d.documentType || "Documento"}</div> <div className="doc-key">{d.storageKey || ""}</div>
<div className="doc-key">{d.storageKey || "—"}</div>
</div>
</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>
<div className="section-note" style={{ padding: "0 22px 18px" }}> </div>
Los archivos se almacenan en el object storage (storageKey); no se
descargan desde esta vista.
</div>
</>
)} )}
</div> </div>
</section> </section>
+111 -45
View File
@@ -7,11 +7,13 @@ import {
addService, addService,
archiveProperty, archiveProperty,
getProperty, getProperty,
propertyDocumentDownloadUrl,
removePropertyDocument, removePropertyDocument,
removeService, removeService,
removeTrust, removeTrust,
restoreProperty, restoreProperty,
updateService, updateService,
uploadPropertyDocument,
upsertTrust, upsertTrust,
} from "@/lib/api"; } from "@/lib/api";
import { useCan } from "@/lib/abilities"; import { useCan } from "@/lib/abilities";
@@ -206,51 +208,111 @@ function PropertyEditor({
<TrustEditor data={data} onChange={onChange} /> <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 && ( {data.documents.length > 0 && (
<div className="card" style={{ padding: 16 }}> <div className="tx-scroll">
<h3 className="section-title" style={{ marginTop: 0 }}>Documentos</h3> <table className="tx-table">
<div className="tx-scroll"> <thead>
<table className="tx-table"> <tr><th>Tipo</th><th>Clave</th><th className="num">Acción</th></tr>
<thead> </thead>
<tr><th>Tipo</th><th>Clave</th><th className="num">Acción</th></tr> <tbody>
</thead> {data.documents.map((d) => (
<tbody> <tr key={d.id ?? d.storageKey}>
{data.documents.map((d) => ( <td>{d.documentType ?? "—"}</td>
<tr key={d.id ?? d.storageKey}> <td className="mono">{d.storageKey ?? "—"}</td>
<td>{d.documentType ?? "—"}</td> <td>
<td className="mono">{d.storageKey ?? "—"}</td> <div className="row-actions">
<td> {d.id && (
<div className="row-actions"> <a
<button
type="button"
className="btn btn-ghost" className="btn btn-ghost"
onClick={async () => { href={propertyDocumentDownloadUrl(data.id, d.id)}
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 Descargar
</button> </a>
</div> )}
</td> <button
</tr> type="button"
))} className="btn btn-ghost"
</tbody> onClick={async () => {
</table> if (!d.id) return;
</div> if (!window.confirm("¿Eliminar este documento?")) return;
<p className="inline-form-note"> try {
La carga de nuevos documentos requiere el almacenamiento de archivos await removePropertyDocument(data.id, d.id);
(pendiente); aquí solo se pueden eliminar los existentes. onChange();
</p> } catch (e) {
window.alert((e as Error)?.message ?? "No se pudo eliminar.");
}
}}
>
Eliminar
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div> </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 className="doc-icon" aria-hidden>
</span> </span>
<div style={{ minWidth: 0 }}> <div style={{ minWidth: 0, flex: 1 }}>
<div className="doc-type">{d.documentType || "Documento"}</div> <div className="doc-type">{d.documentType || "Documento"}</div>
<div className="doc-key">{d.storageKey || "—"}</div> <div className="doc-key">{d.storageKey || "—"}</div>
</div> </div>
{d.id && (
<a
className="btn btn-ghost"
href={propertyDocumentDownloadUrl(data.id, d.id)}
>
Descargar
</a>
)}
</div> </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>
</> </>
)} )}
</div> </div>
+58 -4
View File
@@ -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 */ /* ------------------------------------------- Billing / statements module */
export interface MovementQuery { export interface MovementQuery {
@@ -604,11 +645,19 @@ export function listIngest(): Promise<IngestFile[]> {
return apiFetch<IngestFile[]>("/ops/ingest"); 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(); const body = new FormData();
body.append("file", file, name); body.append("file", file, filename ?? file.name);
const res = await fetch(`${API_ORIGIN}/ops/ingest/${encodeURIComponent(name)}`, { const res = await fetch(`${API_ORIGIN}${path}`, {
method: "POST", method: "POST",
credentials: "include", credentials: "include",
body, body,
@@ -623,6 +672,11 @@ export async function uploadIngest(name: string, file: File): Promise<void> {
} }
throw new ApiError(res.status, message); 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> { export function deleteIngest(name: string): Promise<unknown> {
+26
View File
@@ -18,6 +18,25 @@ services:
timeout: 5s timeout: 5s
retries: 10 retries: 10
minio:
image: minio/minio:RELEASE.2024-10-13T13-34-11Z
restart: unless-stopped
command: server /data --console-address ":9001"
environment:
MINIO_ROOT_USER: ${MINIO_ROOT_USER:-jc_minio}
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:-jc_minio_dev}
ports:
- "9000:9000"
- "9001:9001"
volumes:
- minio_data:/data
healthcheck:
test: ["CMD-SHELL", "mc ready local || curl -f http://localhost:9000/minio/health/live || exit 1"]
interval: 10s
timeout: 5s
retries: 12
start_period: 20s
api: api:
build: build:
context: . context: .
@@ -26,6 +45,8 @@ services:
depends_on: depends_on:
mysql: mysql:
condition: service_healthy condition: service_healthy
minio:
condition: service_healthy
environment: environment:
DATABASE_URL: mysql://jorgecuadros:jorgecuadros@mysql:3306/jorgecuadros DATABASE_URL: mysql://jorgecuadros:jorgecuadros@mysql:3306/jorgecuadros
SESSION_SECRET: ${SESSION_SECRET:?SESSION_SECRET must be set} SESSION_SECRET: ${SESSION_SECRET:?SESSION_SECRET must be set}
@@ -34,6 +55,10 @@ services:
INGEST_DIR: /data/ingest INGEST_DIR: /data/ingest
BACKUP_DIR: /data/backups BACKUP_DIR: /data/backups
MIGRATION_ENV: dev MIGRATION_ENV: dev
S3_ENDPOINT: http://minio:9000
S3_BUCKET: jorgecuadros-documents
MINIO_ROOT_USER: ${MINIO_ROOT_USER:-jc_minio}
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:-jc_minio_dev}
volumes: volumes:
- ingest_data:/data/ingest - ingest_data:/data/ingest
- backup_data:/data/backups - backup_data:/data/backups
@@ -56,3 +81,4 @@ volumes:
mysql_data: mysql_data:
ingest_data: ingest_data:
backup_data: backup_data:
minio_data:
+302
View File
@@ -10,6 +10,9 @@ importers:
apps/api: apps/api:
dependencies: dependencies:
'@aws-sdk/client-s3':
specifier: ^3.665.0
version: 3.1093.0
'@jorgecuadros/database': '@jorgecuadros/database':
specifier: workspace:* specifier: workspace:*
version: link:../../packages/database version: link:../../packages/database
@@ -145,6 +148,78 @@ packages:
resolution: {integrity: sha512-I5wviiIqiFwar9Pdk30Lujk8FczEEc18i22A5c6Z9lbmhPQdTroDnEQdsfXjy404wPe8H62s0I15o4pmMGfTYQ==} resolution: {integrity: sha512-I5wviiIqiFwar9Pdk30Lujk8FczEEc18i22A5c6Z9lbmhPQdTroDnEQdsfXjy404wPe8H62s0I15o4pmMGfTYQ==}
engines: {node: ^18.13.0 || >=20.9.0, npm: ^6.11.0 || ^7.5.6 || >=8.0.0, yarn: '>= 1.13.0'} engines: {node: ^18.13.0 || >=20.9.0, npm: ^6.11.0 || ^7.5.6 || >=8.0.0, yarn: '>= 1.13.0'}
'@aws-sdk/checksums@3.1000.19':
resolution: {integrity: sha512-Hc4N100RdkuWshKBnhPzmpdftfi9mCLz+OHFELHM1QIgMH4QRUUWyWgfiebta/YX2Bd62wTcm3EqAP8TeXv0gA==}
engines: {node: '>=20.0.0'}
'@aws-sdk/client-s3@3.1093.0':
resolution: {integrity: sha512-7452vEdp/nihIBWijnmcTBujXEFfbs4F02wyBDGqmNr6pwyo5GmQorx0zQIVg8QGFLXiBvsWKXBhCdiBcxNnGA==}
engines: {node: '>=20.0.0'}
'@aws-sdk/core@3.976.0':
resolution: {integrity: sha512-0cjRaEdlVoOrsNb9pP5q1Syyc8pXw5xSj2Np2ryReRTr9FppIIRVSdZK4lbnfmc2Hvgux/xBOUU6baB7z8//uA==}
engines: {node: '>=20.0.0'}
'@aws-sdk/credential-provider-env@3.972.60':
resolution: {integrity: sha512-BAkxdoe7tpDDqCghGpuOeHQRbm/2znVvOQm0AvpQbA2tbfMN46doN4zx65fv85ImP3KADwc2zQPmbrlI9MPfMg==}
engines: {node: '>=20.0.0'}
'@aws-sdk/credential-provider-http@3.972.62':
resolution: {integrity: sha512-g/0fGqKTb9xpKdd9AtpmV5Eo3DFKbnkpA2+w0peISSlu7NfAoWOuYBFxsu+yWBtxU89ka55ezoZBCbFaS8pjYQ==}
engines: {node: '>=20.0.0'}
'@aws-sdk/credential-provider-ini@3.973.5':
resolution: {integrity: sha512-ylubazcRfq2TVus/qXucSXeC42Qdjp5HQxTu68K/BsdMiZlcSLD1zkpoCgApXZX1Y6YJhtGGs7ZHhO/GuIgBlw==}
engines: {node: '>=20.0.0'}
'@aws-sdk/credential-provider-login@3.972.67':
resolution: {integrity: sha512-CCygIKJ9YbI3n84OClSaSppkgKKHVj2TGT33c6FRORZrYNZQ1POmD+ip0FLYokiJAK7sSdc3YVkOsBm90oxWMQ==}
engines: {node: '>=20.0.0'}
'@aws-sdk/credential-provider-node@3.972.71':
resolution: {integrity: sha512-HIg7Q2osBzajQwL+1Vkyh2E7Gim3eTNb9RHIsOxDGjW0eZg4oEKtRs5sioCnc73ilhaOm4gX2lHVF8J7+nt2rg==}
engines: {node: '>=20.0.0'}
'@aws-sdk/credential-provider-process@3.972.60':
resolution: {integrity: sha512-YIo3f99hM43QdYG8hDzwGemnR/pU95b0kramqSJUTleCqaB7+HwKf7YZFHqvOgTqZTPx/mRmNIqoDRr3U0Z3Tw==}
engines: {node: '>=20.0.0'}
'@aws-sdk/credential-provider-sso@3.973.4':
resolution: {integrity: sha512-BPdmL8sSBOCv4ngZ+3LHxyc3CNqDCEK37CHioCk7zGrTMY5sUtkH8q+o6qA80nn6w3/fyBPGNE7OIRlmoOxRQA==}
engines: {node: '>=20.0.0'}
'@aws-sdk/credential-provider-web-identity@3.972.66':
resolution: {integrity: sha512-kSAziJboOmZmsR9/MTbiNjowl2BPes1bQuJpne4qAZ62ubi8fjfr/aupJSQje6udBoYxXTQbsL0e0kby2la3ng==}
engines: {node: '>=20.0.0'}
'@aws-sdk/middleware-sdk-s3@3.972.65':
resolution: {integrity: sha512-udwNhRfDTfCB98mAHjjgsnKQlxygB4e0X+Obne/XjJpvVsF0YCQC8ZErd/8Z6IPoLQjtiKHzwqEDbZiLrJEnOg==}
engines: {node: '>=20.0.0'}
'@aws-sdk/nested-clients@3.997.34':
resolution: {integrity: sha512-Y9REVrSwmLM+Qy6sZJ7ofMC2S3Hr3tPP/4CzL5U1olPP7OGoF+6+Px0E49cVQBtSxJtyeLJMf0UaBErfeSahAA==}
engines: {node: '>=20.0.0'}
'@aws-sdk/signature-v4-multi-region@3.996.41':
resolution: {integrity: sha512-QMUytg+FQMGouc8gHS00KoYih3+N6cqmVI/pQGOIo7Nr7OpQaiXjSYOuL+vsPZ1tymY4LAQ8MYcHJmws5LRxng==}
engines: {node: '>=20.0.0'}
'@aws-sdk/token-providers@3.1092.0':
resolution: {integrity: sha512-hBYUAr6iBLNFcsiWTgtBb0stdSw39VOUq4Sp4A5caCNf66BAZplWN4FleKrVpJx5li2YgdnK2DqoFSMWC642FQ==}
engines: {node: '>=20.0.0'}
'@aws-sdk/types@3.974.2':
resolution: {integrity: sha512-3W6IUtSxFbH6X7Wb7DzGCV5QiFQsd0g8bOfntpmDxQlzBoKWUMBu/JPQR0DwkE+Hpnxd6db1tXbOwdeHddG6cA==}
engines: {node: '>=20.0.0'}
'@aws-sdk/xml-builder@3.972.36':
resolution: {integrity: sha512-RdGmS1GLrtaTOLE1ElSluMldNrpk9Emq6uYs8SS8iHlu5xTAmM9rRkM91o48+rIRryBtyO9t+uLYCoMG6jVMVA==}
engines: {node: '>=20.0.0'}
'@aws/lambda-invoke-store@0.3.0':
resolution: {integrity: sha512-sl4Bm6yiMNYrZKkqqDFWN0UfnWhlS8ivKxrYl+6t0gCLrqr8y3B2IqZZbFRkfaVVp7C/baApyh71P+LeE1A2sQ==}
engines: {node: '>=18.0.0'}
'@babel/code-frame@7.29.7': '@babel/code-frame@7.29.7':
resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==} resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==}
engines: {node: '>=6.9.0'} engines: {node: '>=6.9.0'}
@@ -615,6 +690,30 @@ packages:
'@sinonjs/fake-timers@10.3.0': '@sinonjs/fake-timers@10.3.0':
resolution: {integrity: sha512-V4BG07kuYSUkTCSBHG8G8TNhM+F19jXFWnQtzj+we8DrkpSBCee9Z3Ms8yiGer/dlmhe35/Xdgyo3/0rQKg7YA==} resolution: {integrity: sha512-V4BG07kuYSUkTCSBHG8G8TNhM+F19jXFWnQtzj+we8DrkpSBCee9Z3Ms8yiGer/dlmhe35/Xdgyo3/0rQKg7YA==}
'@smithy/core@3.29.7':
resolution: {integrity: sha512-BiEE2bnnGoPKdlGe3L+gOYORDHFGPuYVRLP7iUow/Sflm0B4hC4XY3FC1MRuc7ltzpW2xNnXopKi34TTkULlKQ==}
engines: {node: '>=18.0.0'}
'@smithy/credential-provider-imds@4.4.12':
resolution: {integrity: sha512-ZZPDbl/aRp77aycuoMlo3BTayT4CE2a3uoqETYZU5ySnVbhpl5IJiY7dCZedn+ZusyDLqVv44IvKBiXd2/nK0Q==}
engines: {node: '>=18.0.0'}
'@smithy/fetch-http-handler@5.6.9':
resolution: {integrity: sha512-EJktha5m5MXCwzdXrlWyqb9UCNHNFKlg+PmTpRsdX3dncJPTiqYleM9OKj2mLgdVJHR01d2tU4alG+z2NdH5rQ==}
engines: {node: '>=18.0.0'}
'@smithy/node-http-handler@4.9.9':
resolution: {integrity: sha512-xVBZ3hptB99iNO9XyWqEhC7KD9bP9UPXhuy3h5Y2ItCfBv160D9IIC/Fmmp3EbnWwit4C+KVqlSE+E29Nk/pPg==}
engines: {node: '>=18.0.0'}
'@smithy/signature-v4@5.6.8':
resolution: {integrity: sha512-iGBm6hIwD2MGvVRSgrjVWa4FXtXDq3akxu0DCpnkmBo0xtEHZ/siMRt7ycfZAefYr2UdywUgmGtoRLaq5u56pg==}
engines: {node: '>=18.0.0'}
'@smithy/types@4.16.1':
resolution: {integrity: sha512-0JFs3V2y2M9tKW5na/qxe69Zv+uxLMO7QBbhxF/FHu/Gp2NFZAAL9tWl9PU02xxo07pb3G9FTyjNc6D5uZrJIg==}
engines: {node: '>=18.0.0'}
'@swc/counter@0.1.3': '@swc/counter@0.1.3':
resolution: {integrity: sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==} resolution: {integrity: sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==}
@@ -942,6 +1041,9 @@ packages:
resolution: {integrity: sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==} resolution: {integrity: sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==}
engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16}
bowser@2.14.1:
resolution: {integrity: sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==}
brace-expansion@1.1.16: brace-expansion@1.1.16:
resolution: {integrity: sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==} resolution: {integrity: sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==}
@@ -2768,6 +2870,171 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- chokidar - chokidar
'@aws-sdk/checksums@3.1000.19':
dependencies:
'@aws-sdk/core': 3.976.0
'@aws-sdk/types': 3.974.2
'@smithy/core': 3.29.7
'@smithy/types': 4.16.1
tslib: 2.8.1
'@aws-sdk/client-s3@3.1093.0':
dependencies:
'@aws-sdk/checksums': 3.1000.19
'@aws-sdk/core': 3.976.0
'@aws-sdk/credential-provider-node': 3.972.71
'@aws-sdk/middleware-sdk-s3': 3.972.65
'@aws-sdk/signature-v4-multi-region': 3.996.41
'@aws-sdk/types': 3.974.2
'@smithy/core': 3.29.7
'@smithy/fetch-http-handler': 5.6.9
'@smithy/node-http-handler': 4.9.9
'@smithy/types': 4.16.1
tslib: 2.8.1
'@aws-sdk/core@3.976.0':
dependencies:
'@aws-sdk/types': 3.974.2
'@aws-sdk/xml-builder': 3.972.36
'@aws/lambda-invoke-store': 0.3.0
'@smithy/core': 3.29.7
'@smithy/signature-v4': 5.6.8
'@smithy/types': 4.16.1
bowser: 2.14.1
tslib: 2.8.1
'@aws-sdk/credential-provider-env@3.972.60':
dependencies:
'@aws-sdk/core': 3.976.0
'@aws-sdk/types': 3.974.2
'@smithy/core': 3.29.7
'@smithy/types': 4.16.1
tslib: 2.8.1
'@aws-sdk/credential-provider-http@3.972.62':
dependencies:
'@aws-sdk/core': 3.976.0
'@aws-sdk/types': 3.974.2
'@smithy/core': 3.29.7
'@smithy/fetch-http-handler': 5.6.9
'@smithy/node-http-handler': 4.9.9
'@smithy/types': 4.16.1
tslib: 2.8.1
'@aws-sdk/credential-provider-ini@3.973.5':
dependencies:
'@aws-sdk/core': 3.976.0
'@aws-sdk/credential-provider-env': 3.972.60
'@aws-sdk/credential-provider-http': 3.972.62
'@aws-sdk/credential-provider-login': 3.972.67
'@aws-sdk/credential-provider-process': 3.972.60
'@aws-sdk/credential-provider-sso': 3.973.4
'@aws-sdk/credential-provider-web-identity': 3.972.66
'@aws-sdk/nested-clients': 3.997.34
'@aws-sdk/types': 3.974.2
'@smithy/core': 3.29.7
'@smithy/credential-provider-imds': 4.4.12
'@smithy/types': 4.16.1
tslib: 2.8.1
'@aws-sdk/credential-provider-login@3.972.67':
dependencies:
'@aws-sdk/core': 3.976.0
'@aws-sdk/nested-clients': 3.997.34
'@aws-sdk/types': 3.974.2
'@smithy/core': 3.29.7
'@smithy/types': 4.16.1
tslib: 2.8.1
'@aws-sdk/credential-provider-node@3.972.71':
dependencies:
'@aws-sdk/credential-provider-env': 3.972.60
'@aws-sdk/credential-provider-http': 3.972.62
'@aws-sdk/credential-provider-ini': 3.973.5
'@aws-sdk/credential-provider-process': 3.972.60
'@aws-sdk/credential-provider-sso': 3.973.4
'@aws-sdk/credential-provider-web-identity': 3.972.66
'@aws-sdk/types': 3.974.2
'@smithy/core': 3.29.7
'@smithy/credential-provider-imds': 4.4.12
'@smithy/types': 4.16.1
tslib: 2.8.1
'@aws-sdk/credential-provider-process@3.972.60':
dependencies:
'@aws-sdk/core': 3.976.0
'@aws-sdk/types': 3.974.2
'@smithy/core': 3.29.7
'@smithy/types': 4.16.1
tslib: 2.8.1
'@aws-sdk/credential-provider-sso@3.973.4':
dependencies:
'@aws-sdk/core': 3.976.0
'@aws-sdk/nested-clients': 3.997.34
'@aws-sdk/token-providers': 3.1092.0
'@aws-sdk/types': 3.974.2
'@smithy/core': 3.29.7
'@smithy/types': 4.16.1
tslib: 2.8.1
'@aws-sdk/credential-provider-web-identity@3.972.66':
dependencies:
'@aws-sdk/core': 3.976.0
'@aws-sdk/nested-clients': 3.997.34
'@aws-sdk/types': 3.974.2
'@smithy/core': 3.29.7
'@smithy/types': 4.16.1
tslib: 2.8.1
'@aws-sdk/middleware-sdk-s3@3.972.65':
dependencies:
'@aws-sdk/core': 3.976.0
'@aws-sdk/signature-v4-multi-region': 3.996.41
'@aws-sdk/types': 3.974.2
'@smithy/core': 3.29.7
'@smithy/types': 4.16.1
tslib: 2.8.1
'@aws-sdk/nested-clients@3.997.34':
dependencies:
'@aws-sdk/core': 3.976.0
'@aws-sdk/signature-v4-multi-region': 3.996.41
'@aws-sdk/types': 3.974.2
'@smithy/core': 3.29.7
'@smithy/fetch-http-handler': 5.6.9
'@smithy/node-http-handler': 4.9.9
'@smithy/types': 4.16.1
tslib: 2.8.1
'@aws-sdk/signature-v4-multi-region@3.996.41':
dependencies:
'@aws-sdk/types': 3.974.2
'@smithy/signature-v4': 5.6.8
'@smithy/types': 4.16.1
tslib: 2.8.1
'@aws-sdk/token-providers@3.1092.0':
dependencies:
'@aws-sdk/core': 3.976.0
'@aws-sdk/nested-clients': 3.997.34
'@aws-sdk/types': 3.974.2
'@smithy/core': 3.29.7
'@smithy/types': 4.16.1
tslib: 2.8.1
'@aws-sdk/types@3.974.2':
dependencies:
'@smithy/types': 4.16.1
tslib: 2.8.1
'@aws-sdk/xml-builder@3.972.36':
dependencies:
'@smithy/types': 4.16.1
tslib: 2.8.1
'@aws/lambda-invoke-store@0.3.0': {}
'@babel/code-frame@7.29.7': '@babel/code-frame@7.29.7':
dependencies: dependencies:
'@babel/helper-validator-identifier': 7.29.7 '@babel/helper-validator-identifier': 7.29.7
@@ -3368,6 +3635,39 @@ snapshots:
dependencies: dependencies:
'@sinonjs/commons': 3.0.1 '@sinonjs/commons': 3.0.1
'@smithy/core@3.29.7':
dependencies:
'@smithy/types': 4.16.1
tslib: 2.8.1
'@smithy/credential-provider-imds@4.4.12':
dependencies:
'@smithy/core': 3.29.7
'@smithy/types': 4.16.1
tslib: 2.8.1
'@smithy/fetch-http-handler@5.6.9':
dependencies:
'@smithy/core': 3.29.7
'@smithy/types': 4.16.1
tslib: 2.8.1
'@smithy/node-http-handler@4.9.9':
dependencies:
'@smithy/core': 3.29.7
'@smithy/types': 4.16.1
tslib: 2.8.1
'@smithy/signature-v4@5.6.8':
dependencies:
'@smithy/core': 3.29.7
'@smithy/types': 4.16.1
tslib: 2.8.1
'@smithy/types@4.16.1':
dependencies:
tslib: 2.8.1
'@swc/counter@0.1.3': {} '@swc/counter@0.1.3': {}
'@swc/helpers@0.5.5': '@swc/helpers@0.5.5':
@@ -3795,6 +4095,8 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
bowser@2.14.1: {}
brace-expansion@1.1.16: brace-expansion@1.1.16:
dependencies: dependencies:
balanced-match: 1.0.2 balanced-match: 1.0.2