299 lines
10 KiB
TypeScript
299 lines
10 KiB
TypeScript
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 { prismaClient } from '../../../../../common/database/prisma.lambda.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 pqqService = new HostService(prismaClient);
|
||
|
||
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<void> {
|
||
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<string> {
|
||
// 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<APIGatewayProxyResult> => {
|
||
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<void>((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) => {
|
||
if (val === '' || val === 'null' || val === 'undefined') fields[fieldname] = null;
|
||
else {
|
||
try {
|
||
fields[fieldname] = JSON.parse(val);
|
||
} 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 pqqService.findHeaderByCompositeKey(
|
||
activityXid,
|
||
pqqQuestionXid,
|
||
);
|
||
|
||
let header;
|
||
if (existingHeader) {
|
||
console.log("🔄 Updating existing PQQ header");
|
||
header = await pqqService.updateHeader(
|
||
existingHeader.id,
|
||
pqqAnswerXid,
|
||
comments
|
||
);
|
||
} else {
|
||
console.log("🆕 Creating new PQQ header");
|
||
header = await pqqService.createHeader(
|
||
activityXid,
|
||
pqqQuestionXid,
|
||
pqqAnswerXid,
|
||
comments
|
||
);
|
||
}
|
||
|
||
// 7) Get existing supporting files
|
||
const existingSupportingFiles = await pqqService.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<any> = [];
|
||
|
||
// 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<number, any>();
|
||
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 pqqService.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 pqqService.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".
|
||
|
||
// 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: {
|
||
headerId: header.id,
|
||
activityXid,
|
||
pqqQuestionXid,
|
||
pqqAnswerXid,
|
||
comments,
|
||
files: {
|
||
added: addedResults,
|
||
deleted: deletedResults,
|
||
existingKeptCount: (existingSupportingFiles.length - deletedResults.filter(d => d.success).length)
|
||
},
|
||
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;
|
||
}
|
||
});
|