From 55ebdc474ffef227d42b7ff3f13d4151d09fb7c7 Mon Sep 17 00:00:00 2001 From: Mariano Fuentes Date: Thu, 28 May 2026 11:51:29 -0400 Subject: [PATCH] fix(policies): accept multipart file uploads on POST /policies/:id/pdf fix(policies): accept multipart file uploads on POST /policies/:id/pdf --- apps/api/src/policies/policies.controller.ts | 71 +++++++++++++++++--- 1 file changed, 63 insertions(+), 8 deletions(-) diff --git a/apps/api/src/policies/policies.controller.ts b/apps/api/src/policies/policies.controller.ts index 553dba274..eb8adbdb3 100644 --- a/apps/api/src/policies/policies.controller.ts +++ b/apps/api/src/policies/policies.controller.ts @@ -14,10 +14,14 @@ import { Query, Req, Res, + UploadedFile, UseGuards, + UseInterceptors, } from '@nestjs/common'; +import { FileInterceptor } from '@nestjs/platform-express'; import { ApiBody, + ApiConsumes, ApiHeader, ApiOperation, ApiParam, @@ -513,20 +517,74 @@ export class PoliciesController { @Post(':id/pdf') @RequirePermission('policy', 'update') + @UseInterceptors(FileInterceptor('file')) @ApiOperation({ summary: 'Upload a PDF to a policy or version' }) + @ApiConsumes('multipart/form-data', 'application/json') @ApiParam(POLICY_PARAMS.policyId) + @ApiBody({ + schema: { + oneOf: [ + { + description: 'Multipart file upload (recommended)', + type: 'object', + properties: { + file: { type: 'string', format: 'binary' }, + versionId: { type: 'string', description: 'Target version ID (optional)' }, + }, + required: ['file'], + }, + { + description: 'JSON with base64-encoded file data', + type: 'object', + properties: { + fileName: { type: 'string' }, + fileType: { type: 'string' }, + fileData: { type: 'string', description: 'Base64-encoded file content' }, + versionId: { type: 'string' }, + }, + required: ['fileName', 'fileType', 'fileData'], + }, + ], + }, + }) async uploadPolicyPdf( @Param('id') id: string, + @UploadedFile() file: Express.Multer.File | undefined, @Body() body: { versionId?: string; - fileName: string; - fileType: string; - fileData: string; + fileName?: string; + fileType?: string; + fileData?: string; }, @OrganizationId() organizationId: string, @AuthContext() authContext: AuthContextType, ) { + let fileBuffer: Buffer; + let sanitizedFileName: string; + let fileType: string; + + if (file) { + fileBuffer = file.buffer; + sanitizedFileName = file.originalname.replace(/[^a-zA-Z0-9.-]/g, '_'); + fileType = file.mimetype; + } else if (body.fileData && body.fileName && body.fileType) { + const stripped = body.fileData.replace(/\s/g, ''); + if (!/^[A-Za-z0-9+/\-_]*={0,2}$/.test(stripped)) { + throw new BadRequestException('fileData must be valid base64-encoded content'); + } + fileBuffer = Buffer.from(stripped, 'base64'); + if (fileBuffer.length === 0) { + throw new BadRequestException('fileData must be valid base64-encoded content'); + } + sanitizedFileName = body.fileName.replace(/[^a-zA-Z0-9.-]/g, '_'); + fileType = body.fileType; + } else { + throw new BadRequestException( + 'Upload a file via multipart/form-data or provide fileName, fileType, and fileData in JSON', + ); + } + const { S3Client, PutObjectCommand, DeleteObjectCommand } = await import('@aws-sdk/client-s3'); const bucketName = process.env.APP_AWS_BUCKET_NAME; @@ -547,9 +605,6 @@ export class PoliciesController { }); if (!policy) throw new NotFoundException('Policy not found'); - const sanitizedFileName = body.fileName.replace(/[^a-zA-Z0-9.-]/g, '_'); - const fileBuffer = Buffer.from(body.fileData, 'base64'); - if (body.versionId) { const version = await db.policyVersion.findFirst({ where: { id: body.versionId, policyId: id }, @@ -573,7 +628,7 @@ export class PoliciesController { Bucket: bucketName, Key: s3Key, Body: fileBuffer, - ContentType: body.fileType, + ContentType: fileType, }), ); const oldPdfUrl = version.pdfUrl; @@ -602,7 +657,7 @@ export class PoliciesController { Bucket: bucketName, Key: s3Key, Body: fileBuffer, - ContentType: body.fileType, + ContentType: fileType, }), ); const oldPdfUrl = policy.pdfUrl;