diff --git a/apps/api/package.json b/apps/api/package.json index 3f0941a..05584eb 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -11,6 +11,7 @@ "test": "jest" }, "dependencies": { + "@aws-sdk/client-s3": "^3.665.0", "@jorgecuadros/database": "workspace:*", "@nestjs/common": "^10.4.4", "@nestjs/config": "^3.3.0", diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index 7833e82..478ce06 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -1,6 +1,7 @@ import { Module } from "@nestjs/common"; import { ConfigModule } from "@nestjs/config"; import { PrismaModule } from "./prisma/prisma.module"; +import { StorageModule } from "./storage/storage.module"; import { CommonModule } from "./common/common.module"; import { UsersModule } from "./users/users.module"; import { AuthModule } from "./auth/auth.module"; @@ -16,6 +17,7 @@ import { AppController } from "./app.controller"; imports: [ ConfigModule.forRoot({ isGlobal: true }), PrismaModule, + StorageModule, CommonModule, UsersModule, AuthModule, diff --git a/apps/api/src/policies/policies.controller.ts b/apps/api/src/policies/policies.controller.ts index 706af73..434bba9 100644 --- a/apps/api/src/policies/policies.controller.ts +++ b/apps/api/src/policies/policies.controller.ts @@ -8,9 +8,15 @@ import { Post, Query, Req, + Res, + StreamableFile, + UploadedFile, UseGuards, + UseInterceptors, } 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 { AbilityGuard } from "../auth/ability.guard"; import { RequireAbility } from "../auth/require-ability.decorator"; @@ -240,4 +246,40 @@ export class PoliciesController { removeClaim(@Param("id") id: string, @Param("childId") childId: string) { 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 { + 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); + } } diff --git a/apps/api/src/policies/policies.service.ts b/apps/api/src/policies/policies.service.ts index df64066..2877e99 100644 --- a/apps/api/src/policies/policies.service.ts +++ b/apps/api/src/policies/policies.service.ts @@ -1,6 +1,9 @@ import { Injectable, NotFoundException } from "@nestjs/common"; +import { randomUUID } from "node:crypto"; import { Prisma } from "@jorgecuadros/database"; 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 { CreatePolicyDto, UpdatePolicyDto } from "./policy.dto"; import { @@ -77,7 +80,10 @@ function daysUntil(policyTo: Date | null, from: Date): number | null { @Injectable() export class PoliciesService { - constructor(private readonly prisma: PrismaService) {} + constructor( + private readonly prisma: PrismaService, + private readonly storage: StorageService, + ) {} private statusWhere( status: PolicyStatus | undefined, @@ -479,6 +485,47 @@ export class PoliciesService { }; } + // --- documents ------------------------------------------------------------ + // Blob in object storage under `policy//…`; 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) ----------------------- listLookups() { diff --git a/apps/api/src/properties/properties.controller.ts b/apps/api/src/properties/properties.controller.ts index b5abe5b..bd912ac 100644 --- a/apps/api/src/properties/properties.controller.ts +++ b/apps/api/src/properties/properties.controller.ts @@ -9,10 +9,16 @@ import { Put, Query, Req, + Res, + StreamableFile, + UploadedFile, UseGuards, + UseInterceptors, } from "@nestjs/common"; +import { FileInterceptor } from "@nestjs/platform-express"; 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 { AbilityGuard } from "../auth/ability.guard"; import { RequireAbility } from "../auth/require-ability.decorator"; @@ -196,7 +202,35 @@ export class PropertiesController { 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 { + 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") @RequireAbility("property:update") diff --git a/apps/api/src/properties/properties.service.ts b/apps/api/src/properties/properties.service.ts index 1430ebb..1c5172f 100644 --- a/apps/api/src/properties/properties.service.ts +++ b/apps/api/src/properties/properties.service.ts @@ -1,6 +1,9 @@ import { Injectable, NotFoundException } from "@nestjs/common"; +import { randomUUID } from "node:crypto"; import { Prisma, ServiceKind } from "@jorgecuadros/database"; import { PrismaService } from "../prisma/prisma.service"; +import { StorageService } from "../storage/storage.service"; +import { extForUpload } from "../storage/upload-file"; import { toDate } from "../common/coerce"; import { CreatePropertyDto, @@ -83,7 +86,10 @@ function daysUntil(dueDate: Date | null | undefined, from: Date): number | null @Injectable() export class PropertiesService { - constructor(private readonly prisma: PrismaService) {} + constructor( + private readonly prisma: PrismaService, + private readonly storage: StorageService, + ) {} private trustWhere( trust: TrustFilter | undefined, @@ -543,16 +549,45 @@ export class PropertiesService { } // --- documents ------------------------------------------------------------ - // Removing a pointer row only; uploading files needs the object-storage - // client wired into the API (today only the migration writes to MinIO). + // The blob lives in object storage (MinIO); the row is just the pointer. Keys + // stay under the `service//…` 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) { await this.ensureProperty(propertyId); const row = await this.prisma.serviceDocument.findFirst({ where: { id, propertyId }, - select: { id: true }, + select: { id: true, storageKey: true }, }); 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; } } diff --git a/apps/api/src/storage/storage.module.ts b/apps/api/src/storage/storage.module.ts new file mode 100644 index 0000000..4ff630f --- /dev/null +++ b/apps/api/src/storage/storage.module.ts @@ -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 {} diff --git a/apps/api/src/storage/storage.service.ts b/apps/api/src/storage/storage.service.ts new file mode 100644 index 0000000..6dd102c --- /dev/null +++ b/apps/api/src/storage/storage.service.ts @@ -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("S3_ENDPOINT"); + this.bucket = config.get("S3_BUCKET") ?? "jorgecuadros-documents"; + const accessKeyId = + config.get("S3_ACCESS_KEY") ?? config.get("MINIO_ROOT_USER"); + const secretAccessKey = + config.get("S3_SECRET_KEY") ?? config.get("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("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 { + 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 { + 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}`); + } + } +} diff --git a/apps/api/src/storage/upload-file.ts b/apps/api/src/storage/upload-file.ts new file mode 100644 index 0000000..207f8cb --- /dev/null +++ b/apps/api/src/storage/upload-file.ts @@ -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 = { + "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}`; +} diff --git a/apps/web/src/app/clientes/[id]/page.tsx b/apps/web/src/app/clientes/[id]/page.tsx index 33b0f35..3e1b49b 100644 --- a/apps/web/src/app/clientes/[id]/page.tsx +++ b/apps/web/src/app/clientes/[id]/page.tsx @@ -3,7 +3,13 @@ import { useEffect, useState } from "react"; import Link from "next/link"; import { AppShell } from "@/components/AppShell"; -import { archiveCustomer, getCustomer, restoreCustomer } from "@/lib/api"; +import { + archiveCustomer, + getCustomer, + policyDocumentDownloadUrl, + propertyDocumentDownloadUrl, + restoreCustomer, +} from "@/lib/api"; import { useCan } from "@/lib/abilities"; import { domainLabel, @@ -790,15 +796,15 @@ function TxRow({ t }: { t: Transaction }) { /* ----------------------------------------------------------- Documentos */ function DocumentosSection({ data }: { data: CustomerDetail }) { - type Doc = { type: string; key: string | null; scope: string }; + type Doc = { type: string; scope: string; href: string | null }; const docs: Doc[] = []; data.properties.forEach((p) => { const label = [p.addressLine1].filter(Boolean).join("") || "Propiedad"; p.documents.forEach((d) => docs.push({ type: d.documentType || "Documento", - key: d.storageKey, scope: label, + href: d.id ? propertyDocumentDownloadUrl(p.id, d.id) : null, }), ); }); @@ -806,8 +812,8 @@ function DocumentosSection({ data }: { data: CustomerDetail }) { p.documents.forEach((d) => docs.push({ type: d.documentType || "Documento", - key: d.storageKey, scope: `Póliza ${p.policyNumber ?? ""}`.trim(), + href: d.id ? policyDocumentDownloadUrl(p.id, d.id) : null, }), ); }); @@ -821,28 +827,24 @@ function DocumentosSection({ data }: { data: CustomerDetail }) { No hay documentos registrados para este cliente. ) : ( - <> -
- {docs.map((d, i) => ( -
- - ▤ - -
-
{d.type}
-
{d.scope}
-
+
+ {docs.map((d, i) => ( +
+ + ▤ + +
+
{d.type}
+
{d.scope}
- ))} -
-
- Los archivos se almacenan en el object storage - (storageKey); no se descargan desde esta vista. -
- + {d.href && ( + + Descargar + + )} +
+ ))} +
)}
diff --git a/apps/web/src/app/polizas/[id]/page.tsx b/apps/web/src/app/polizas/[id]/page.tsx index 7e5a1d9..b875881 100644 --- a/apps/web/src/app/polizas/[id]/page.tsx +++ b/apps/web/src/app/polizas/[id]/page.tsx @@ -8,9 +8,12 @@ import { archivePolicy, getLookups, getPolicy, + policyDocumentDownloadUrl, removePolicyChild, + removePolicyDocument, restorePolicy, updatePolicyChild, + uploadPolicyDocument, } from "@/lib/api"; import { useCan } from "@/lib/abilities"; import { ChildCollection, type ChildConfig } from "@/components/ChildCollection"; @@ -101,7 +104,7 @@ function Detail({ id }: { id: string }) { )} {data.claims.length > 0 && } - + ); @@ -664,7 +667,35 @@ function CoberturasSection({ data }: { data: PolicyDetail }) { } /* -------------------------------------------------------- Documentos */ -function DocumentosSection({ data }: { data: PolicyDetail }) { +function DocumentosSection({ + data, + onChange, +}: { + data: PolicyDetail; + onChange: () => void; +}) { + const canEdit = useCan("policy:update"); + const [file, setFile] = useState(null); + const [type, setType] = useState(""); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(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 (
@@ -674,25 +705,74 @@ function DocumentosSection({ data }: { data: PolicyDetail }) { No hay documentos registrados para esta póliza. ) : ( - <> -
- {data.documents.map((d, i) => ( -
- - ▤ - -
-
{d.documentType || "Documento"}
-
{d.storageKey || "—"}
-
+
+ {data.documents.map((d, i) => ( +
+ + ▤ + +
+
{d.documentType || "Documento"}
+
{d.storageKey || "—"}
- ))} + {d.id && ( + + Descargar + + )} + {canEdit && d.id && ( + + )} +
+ ))} +
+ )} + {canEdit && ( +
+ {error && ( +
+ {error} +
+ )} +
+ setType(e.target.value)} + /> + setFile(e.target.files?.[0] ?? null)} + /> +
-
- Los archivos se almacenan en el object storage (storageKey); no se - descargan desde esta vista. -
- +
)}
diff --git a/apps/web/src/app/servicios/[id]/page.tsx b/apps/web/src/app/servicios/[id]/page.tsx index 5d241d3..f26da6b 100644 --- a/apps/web/src/app/servicios/[id]/page.tsx +++ b/apps/web/src/app/servicios/[id]/page.tsx @@ -7,11 +7,13 @@ import { addService, archiveProperty, getProperty, + propertyDocumentDownloadUrl, removePropertyDocument, removeService, removeTrust, restoreProperty, updateService, + uploadPropertyDocument, upsertTrust, } from "@/lib/api"; import { useCan } from "@/lib/abilities"; @@ -206,51 +208,111 @@ function PropertyEditor({ + + + ); +} + +/** Upload / download / delete document blobs stored in object storage (MinIO). */ +function DocumentsEditor({ + data, + onChange, +}: { + data: PropertyDetail; + onChange: () => void; +}) { + const [file, setFile] = useState(null); + const [type, setType] = useState(""); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(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 ( +
+

Documentos

{data.documents.length > 0 && ( -
-

Documentos

-
- - - - - - {data.documents.map((d) => ( - - - - + + ))} + +
TipoClaveAcción
{d.documentType ?? "—"}{d.storageKey ?? "—"} - -

- La carga de nuevos documentos requiere el almacenamiento de archivos - (pendiente); aquí solo se pueden eliminar los existentes. -

+ Descargar + + )} + + +
)} - + {error &&
{error}
} +
+ setType(e.target.value)} + /> + setFile(e.target.files?.[0] ?? null)} + /> + +
+
); } @@ -744,17 +806,21 @@ function DocumentosSection({ data }: { data: PropertyDetail }) { -
+
{d.documentType || "Documento"}
{d.storageKey || "—"}
+ {d.id && ( + + Descargar + + )}
))}
-
- Los archivos se almacenan en el object storage (storageKey); no se - descargan desde esta vista. -
)} diff --git a/apps/web/src/lib/api.ts b/apps/web/src/lib/api.ts index 6212e7f..4e69668 100644 --- a/apps/web/src/lib/api.ts +++ b/apps/web/src/lib/api.ts @@ -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 { + const q = type ? `?type=${encodeURIComponent(type)}` : ""; + return uploadFile(`/properties/${propertyId}/documents${q}`, file); +} + +export function removePolicyDocument( + policyId: string, + documentId: string, +): Promise { + 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 { + const q = type ? `?type=${encodeURIComponent(type)}` : ""; + return uploadFile(`/policies/${policyId}/documents${q}`, file); +} + /* ------------------------------------------- Billing / statements module */ export interface MovementQuery { @@ -604,11 +645,19 @@ export function listIngest(): Promise { return apiFetch("/ops/ingest"); } -/** Multipart upload — not JSON, so it bypasses apiFetch's Content-Type. */ -export async function uploadIngest(name: string, file: File): Promise { +/** + * 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 { const body = new FormData(); - body.append("file", file, name); - const res = await fetch(`${API_ORIGIN}/ops/ingest/${encodeURIComponent(name)}`, { + body.append("file", file, filename ?? file.name); + const res = await fetch(`${API_ORIGIN}${path}`, { method: "POST", credentials: "include", body, @@ -623,6 +672,11 @@ export async function uploadIngest(name: string, file: File): Promise { } throw new ApiError(res.status, message); } + return res.status === 204 ? undefined : res.json().catch(() => undefined); +} + +export function uploadIngest(name: string, file: File): Promise { + return uploadFile(`/ops/ingest/${encodeURIComponent(name)}`, file, name); } export function deleteIngest(name: string): Promise { diff --git a/docker-compose.yml b/docker-compose.yml index 3a926cc..c979070 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -18,6 +18,25 @@ services: timeout: 5s 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: build: context: . @@ -26,6 +45,8 @@ services: depends_on: mysql: condition: service_healthy + minio: + condition: service_healthy environment: DATABASE_URL: mysql://jorgecuadros:jorgecuadros@mysql:3306/jorgecuadros SESSION_SECRET: ${SESSION_SECRET:?SESSION_SECRET must be set} @@ -34,6 +55,10 @@ services: INGEST_DIR: /data/ingest BACKUP_DIR: /data/backups 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: - ingest_data:/data/ingest - backup_data:/data/backups @@ -56,3 +81,4 @@ volumes: mysql_data: ingest_data: backup_data: + minio_data: diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 34605df..81e0574 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -10,6 +10,9 @@ importers: apps/api: dependencies: + '@aws-sdk/client-s3': + specifier: ^3.665.0 + version: 3.1093.0 '@jorgecuadros/database': specifier: workspace:* version: link:../../packages/database @@ -145,6 +148,78 @@ packages: 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'} + '@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': resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==} engines: {node: '>=6.9.0'} @@ -615,6 +690,30 @@ packages: '@sinonjs/fake-timers@10.3.0': 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': resolution: {integrity: sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==} @@ -942,6 +1041,9 @@ packages: resolution: {integrity: sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==} 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: resolution: {integrity: sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==} @@ -2768,6 +2870,171 @@ snapshots: transitivePeerDependencies: - 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': dependencies: '@babel/helper-validator-identifier': 7.29.7 @@ -3368,6 +3635,39 @@ snapshots: dependencies: '@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/helpers@0.5.5': @@ -3795,6 +4095,8 @@ snapshots: transitivePeerDependencies: - supports-color + bowser@2.14.1: {} + brace-expansion@1.1.16: dependencies: balanced-match: 1.0.2