import config from '../../../../../config/config'; import { APIGatewayProxyEvent, APIGatewayProxyResult } from 'aws-lambda'; import AWS from 'aws-sdk'; import Busboy from 'busboy'; import crypto from 'crypto'; import { PrismaService } from '../../../../../common/database/prisma.service'; import { verifyHostToken } from '../../../../../common/middlewares/jwt/authForHost'; import { safeHandler } from '../../../../../common/utils/handlers/safeHandler'; import ApiError from '../../../../../common/utils/helper/ApiError'; import { HostService } from '../../../services/host.service'; const prisma = new PrismaService(); const hostService = new HostService(prisma); const s3 = new AWS.S3({ region: config.aws.region }); // Function to extract S3 key from URL function getS3KeyFromUrl(url: string): string { const bucketBaseUrl = `https://${config.aws.bucketName}.s3.${config.aws.region}.amazonaws.com/`; return url.replace(bucketBaseUrl, ''); } // Function to delete file from S3 async function deleteFromS3(s3Key: string): Promise { try { await s3.deleteObject({ Bucket: config.aws.bucketName, Key: s3Key, }).promise(); console.log(`✅ File deleted from S3: ${s3Key}`); } catch (error) { console.error(`❌ Error deleting file from S3: ${s3Key}`, error); // continue — we don't want S3 deletion failure to crash the whole request } } async function uploadToS3(buffer: Buffer, mimeType: string, originalName: string, prefix: string, existingUrl?: string): Promise { // We intentionally do NOT reuse old key. If existingUrl is provided we delete old file and create a new random key. if (existingUrl) { try { const oldKey = getS3KeyFromUrl(existingUrl); await deleteFromS3(oldKey); } catch (err) { console.warn('Warning deleting existingUrl before upload', err); } } const uniqueKey = `${crypto.randomUUID()}_${originalName}`; const s3Key = `${prefix}/${uniqueKey}`; await s3.upload({ Bucket: config.aws.bucketName, Key: s3Key, Body: buffer, ContentType: mimeType, ACL: 'private' }).promise(); console.log(`✅ File uploaded to S3: ${s3Key}`); return `https://${config.aws.bucketName}.s3.${config.aws.region}.amazonaws.com/${s3Key}`; } export const handler = safeHandler(async (event: APIGatewayProxyEvent): Promise => { try { // 1) Auth const token = event.headers['x-auth-token'] || event.headers['X-Auth-Token']; if (!token) throw new ApiError(401, 'Missing token.'); const user = await verifyHostToken(token); // 2) Content-Type check const contentType = event.headers["content-type"] || event.headers["Content-Type"]; if (!contentType?.includes("multipart/form-data")) throw new ApiError(400, "Content-Type must be multipart/form-data"); // 3) Body decoding const bodyBuffer = event.isBase64Encoded ? Buffer.from(event.body!, "base64") : Buffer.from(event.body!, "binary"); const fields: any = {}; const files: Array<{ buffer: Buffer; mimeType: string; fileName: string; fieldName: string }> = []; // 4) Parse multipart data await new Promise((resolve, reject) => { const bb = Busboy({ headers: { 'content-type': contentType } }); bb.on('file', (fieldname, file, info) => { const { filename, mimeType } = info; if (!filename) { file.resume(); return; } const chunks: Buffer[] = []; let size = 0; const MAX_SIZE = 5 * 1024 * 1024; file.on("data", (chunk) => { size += chunk.length; if (size > MAX_SIZE) { file.destroy(new Error(`File ${filename} exceeds 5MB limit.`)); return; } chunks.push(chunk); }); file.on("end", () => { if (chunks.length > 0) { files.push({ buffer: Buffer.concat(chunks), mimeType, fileName: filename, fieldName: fieldname, }); } }); file.on("error", (err) => reject(new ApiError(400, `File upload error: ${err.message}`)) ); }); bb.on("field", (fieldname, val) => { console.log(`FIELD RAW: ${fieldname} =`, val); if (val === '' || val === 'null' || val === 'undefined') fields[fieldname] = null; else { try { const cleaned = val.trim(); // If it starts and ends with quotes, remove them const withoutQuotes = (cleaned.startsWith('"') && cleaned.endsWith('"')) ? cleaned.slice(1, -1) : cleaned; fields[fieldname] = JSON.parse(withoutQuotes); } catch { fields[fieldname] = val; } } }); bb.on("close", () => resolve()); bb.on("error", (err) => reject(new ApiError(400, `Multipart parsing error: ${err.message}`)) ); // IMPORTANT FIX for HTTP API bb.write(bodyBuffer); bb.end(); }); // 5) Extract required fields const activityXid = Number(fields.activityXid); const pqqQuestionXid = Number(fields.pqqQuestionXid); const pqqAnswerXid = Number(fields.pqqAnswerXid); const comments = fields.comments || null; if (!activityXid || isNaN(activityXid)) throw new ApiError(400, "Valid activityXid is required"); if (!pqqQuestionXid || isNaN(pqqQuestionXid)) throw new ApiError(400, "Valid pqqQuestionXid is required"); if (!pqqAnswerXid || isNaN(pqqAnswerXid)) throw new ApiError(400, "Valid pqqAnswerXid is required"); // 6) UPSERT header const existingHeader = await hostService.findHeaderByCompositeKey( activityXid, pqqQuestionXid, ); let header; if (existingHeader) { console.log("🔄 Updating existing PQQ header"); header = await hostService.updateHeader( existingHeader.id, pqqAnswerXid, comments ); } else { console.log("🆕 Creating new PQQ header"); header = await hostService.createHeader( activityXid, pqqQuestionXid, pqqAnswerXid, comments ); } // 7) Get existing supporting files const existingSupportingFiles = await hostService.getSupportingFilesByHeaderId(header.id); console.log(`📁 Found ${existingSupportingFiles.length} existing supporting files`); // 8) Parse incoming control fields // fields.deletedFiles should be array like [{ id: number, url: string }, ...] or null const deletedFiles: Array<{ id: number; url?: string }> = Array.isArray(fields.deletedFiles) ? fields.deletedFiles : []; // fields.existingFiles can be an array of urls; we accept it but do not require it const existingFilesFromFront: string[] = Array.isArray(fields.existingFiles) ? fields.existingFiles : []; // Prepare response trackers const deletedResults: Array<{ id: number; success: boolean; reason?: string }> = []; const addedResults: Array = []; // 9) Handle explicit deletions (ONLY delete ids provided in deletedFiles) if (deletedFiles.length > 0) { console.log(`đŸ—‘ī¸ Processing ${deletedFiles.length} explicit deletions`); // Build a map of existing supporting files by id for quick lookup const existingById = new Map(); for (const f of existingSupportingFiles) { existingById.set(f.id, f); } for (const del of deletedFiles) { const id = Number(del.id); if (!id || !existingById.has(id)) { deletedResults.push({ id, success: false, reason: 'Not found or invalid id' }); continue; } const record = existingById.get(id); try { // delete from s3 if (record.mediaFileName) { const s3Key = getS3KeyFromUrl(record.mediaFileName); await deleteFromS3(s3Key); } // delete DB record await hostService.deleteSupportingFile(record.id); deletedResults.push({ id: record.id, success: true }); console.log(`đŸ—‘ī¸ Deleted supporting file record ${record.id}`); } catch (err: any) { console.error(`❌ Failed to delete supporting file id ${id}`, err); deletedResults.push({ id, success: false, reason: err.message || 'delete failed' }); } } } else { console.log('â„šī¸ No explicit deletions requested (deletedFiles empty)'); } // 10) Handle new uploaded files (these are ALWAYS added as new rows) if (files.length > 0) { console.log(`📤 Processing ${files.length} uploaded new file(s)`); for (const file of files) { try { const url = await uploadToS3( file.buffer, file.mimeType, file.fileName, `ActivityOnboarding/supportings/${activityXid}` ); // create DB record const supporting = await hostService.addSupportingFile( header.id, file.mimeType, url ); addedResults.push(supporting); console.log(`🆕 Created new supporting file record: ${supporting.id}`); } catch (err: any) { console.error('❌ Error uploading/creating supporting file', err); // push failure result but continue processing other files addedResults.push({ success: false, reason: err.message || 'upload/create failed' }); } } } else { console.log('📭 No new files uploaded in request'); } // NOTE: We DO NOT delete or modify existing supporting files that were not listed in deletedFiles. // This satisfies your Case 2: "if no files are provided, do not touch existing supporting files". const allPQPQuestionAnswerResponse = await hostService.getAllPQUpdatedResponse(activityXid) // 11) Compose response const responseMessage = existingHeader ? "PQQ answer updated successfully" : "PQQ answer submitted successfully"; return { statusCode: 200, headers: { "Content-Type": "application/json", "Access-Control-Allow-Origin": "*" }, body: JSON.stringify({ success: true, message: responseMessage, data: { responseOfUpdatedData: allPQPQuestionAnswerResponse, operation: existingHeader ? 'updated' : 'created', // summary label for UI convenience: fileOperation: (deletedResults.length > 0 || addedResults.length > 0) ? 'modified' : 'unchanged' } }) }; } catch (error: any) { console.error("❌ Error in submitPqqAnswer:", error); throw error; } });