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:
@@ -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",
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {}
|
||||||
@@ -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}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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}`;
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
@@ -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> {
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
Generated
+302
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user