Implement validation for required fields in PQQ handlers and enhance file upload logic in submitPqqAns. Added error handling for missing activity and question IDs, and improved S3 file management with delete functionality for existing files. Updated HostService methods for better file handling and header management.
This commit is contained in:
@@ -31,6 +31,10 @@ export const handler = safeHandler(async (
|
||||
|
||||
const { question_xid, activity_xid } = body;
|
||||
|
||||
if(!question_xid || !activity_xid){
|
||||
throw new ApiError(400, "Question and activity xid are required.")
|
||||
}
|
||||
|
||||
// Fetch user with their HostHeader stepper info
|
||||
const pqqQuestionDetails = await hostService.getPQQQuestionDetail(question_xid, activity_xid);
|
||||
|
||||
|
||||
@@ -30,6 +30,9 @@ export const handler = safeHandler(async (
|
||||
}
|
||||
|
||||
const { activity_xid } = body;
|
||||
if(!activity_xid){
|
||||
throw new ApiError(400, "Activity id is required.")
|
||||
}
|
||||
|
||||
// Fetch user with their HostHeader stepper info
|
||||
const pqqQuestionDetails = await hostService.getLatestQuestionDetailsPQQ(activity_xid);
|
||||
|
||||
@@ -14,153 +14,291 @@ const pqqService = new HostService(prisma);
|
||||
|
||||
const s3 = new AWS.S3({ region: config.aws.region });
|
||||
|
||||
async function uploadToS3(buffer: Buffer, mimeType: string, originalName: string, prefix: string): Promise<string> {
|
||||
const uniqueKey = `${crypto.randomUUID()}_${originalName}`;
|
||||
const s3Key = `${prefix}/${uniqueKey}`;
|
||||
// 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, '');
|
||||
}
|
||||
|
||||
await s3.upload({
|
||||
Bucket: config.aws.bucketName,
|
||||
Key: s3Key,
|
||||
Body: buffer,
|
||||
ContentType: mimeType,
|
||||
ACL: 'private'
|
||||
// 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);
|
||||
// Don't throw error here, continue with upload
|
||||
}
|
||||
}
|
||||
|
||||
return `https://${config.aws.bucketName}.s3.${config.aws.region}.amazonaws.com/${s3Key}`;
|
||||
async function uploadToS3(buffer: Buffer, mimeType: string, originalName: string, prefix: string, existingUrl?: string): Promise<string> {
|
||||
let s3Key: string;
|
||||
|
||||
// If existing URL provided, use the same S3 key to replace the file
|
||||
if (existingUrl) {
|
||||
s3Key = getS3KeyFromUrl(existingUrl);
|
||||
// Delete existing file first
|
||||
await deleteFromS3(s3Key);
|
||||
} else {
|
||||
// Generate new unique key for new file
|
||||
const uniqueKey = `${crypto.randomUUID()}_${originalName}`;
|
||||
s3Key = `${prefix}/${uniqueKey}`;
|
||||
}
|
||||
|
||||
// Upload new file (replaces existing if same key)
|
||||
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);
|
||||
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?.startsWith("multipart/form-data"))
|
||||
throw new ApiError(400, "Content-Type must be multipart/form-data");
|
||||
// 2) Content-Type check
|
||||
const contentType = event.headers["content-type"] || event.headers["Content-Type"];
|
||||
if (!contentType?.startsWith("multipart/form-data"))
|
||||
throw new ApiError(400, "Content-Type must be multipart/form-data");
|
||||
|
||||
if (!event.isBase64Encoded)
|
||||
throw new ApiError(400, "Body must be base64 encoded");
|
||||
if (!event.isBase64Encoded)
|
||||
throw new ApiError(400, "Body must be base64 encoded");
|
||||
|
||||
const bodyBuffer = Buffer.from(event.body!, "base64");
|
||||
const bodyBuffer = Buffer.from(event.body!, "base64");
|
||||
|
||||
const fields: any = {};
|
||||
const files: Array<{ buffer: Buffer; mimeType: string; fileName: string; fieldName: string }> = [];
|
||||
const fields: any = {};
|
||||
const files: Array<{ buffer: Buffer; mimeType: string; fileName: string; fieldName: string }> = [];
|
||||
|
||||
// 3) Parse multipart data
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const bb = Busboy({ headers: { 'content-type': contentType } });
|
||||
// 3) 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;
|
||||
const chunks: Buffer[] = [];
|
||||
let totalSize = 0;
|
||||
const MAX_SIZE = 5 * 1024 * 1024; // 5 MB
|
||||
|
||||
file.on('data', (chunk) => {
|
||||
totalSize += chunk.length;
|
||||
if (totalSize > MAX_SIZE) {
|
||||
file.resume();
|
||||
return reject(new ApiError(400, `File ${filename} exceeds 5MB limit.`));
|
||||
}
|
||||
chunks.push(chunk);
|
||||
});
|
||||
|
||||
file.on('end', () => {
|
||||
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) => {
|
||||
try {
|
||||
fields[fieldname] = JSON.parse(val);
|
||||
} catch {
|
||||
fields[fieldname] = val;
|
||||
}
|
||||
});
|
||||
|
||||
bb.on('close', () => {
|
||||
console.log("✅ Busboy parsing completed");
|
||||
resolve();
|
||||
});
|
||||
|
||||
bb.on('error', (err) => {
|
||||
console.error("❌ Busboy error:", err);
|
||||
reject(new ApiError(400, `Multipart parsing error: ${err.message}`));
|
||||
});
|
||||
|
||||
bb.end(bodyBuffer);
|
||||
});
|
||||
|
||||
console.log("📌 Parsed Files:", files);
|
||||
|
||||
// 4) 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) throw new ApiError(400, "activityXid is required");
|
||||
if (!pqqQuestionXid) throw new ApiError(400, "pqqQuestionXid is required");
|
||||
if (!pqqAnswerXid) throw new ApiError(400, "pqqAnswerXid is required");
|
||||
|
||||
// 5) Create or update header
|
||||
const header = await pqqService.createOrUpdateHeader(
|
||||
activityXid,
|
||||
pqqQuestionXid,
|
||||
pqqAnswerXid,
|
||||
comments
|
||||
);
|
||||
|
||||
// 6) Upload files
|
||||
const uploadedFiles: any[] = [];
|
||||
|
||||
for (const file of files) {
|
||||
const url = await uploadToS3(
|
||||
file.buffer,
|
||||
file.mimeType,
|
||||
file.fileName,
|
||||
`ActivityOnboarding/Activity_${activityXid}/supportings`
|
||||
);
|
||||
|
||||
const supporting = await pqqService.addSupportingFile(
|
||||
header.id,
|
||||
file.mimeType,
|
||||
url
|
||||
);
|
||||
|
||||
uploadedFiles.push(supporting);
|
||||
bb.on('file', (fieldname, file, info) => {
|
||||
const { filename, mimeType } = info;
|
||||
|
||||
// Skip if no filename (empty file field)
|
||||
if (!filename) {
|
||||
file.resume();
|
||||
return;
|
||||
}
|
||||
|
||||
return {
|
||||
statusCode: 200,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Access-Control-Allow-Origin": "*"
|
||||
},
|
||||
body: JSON.stringify({
|
||||
success: true,
|
||||
message: "PQQ answer submitted successfully",
|
||||
data: {
|
||||
headerId: header.id,
|
||||
uploadedFiles
|
||||
}
|
||||
})
|
||||
};
|
||||
const chunks: Buffer[] = [];
|
||||
let totalSize = 0;
|
||||
const MAX_SIZE = 5 * 1024 * 1024; // 5 MB
|
||||
|
||||
} catch (error: any) {
|
||||
console.error("❌ Error in submitPqqAnswer:", error);
|
||||
throw error;
|
||||
file.on('data', (chunk) => {
|
||||
totalSize += chunk.length;
|
||||
if (totalSize > MAX_SIZE) {
|
||||
file.resume();
|
||||
return reject(new ApiError(400, `File ${filename} exceeds 5MB limit.`));
|
||||
}
|
||||
chunks.push(chunk);
|
||||
});
|
||||
|
||||
file.on('end', () => {
|
||||
// Only add file if we have data
|
||||
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) => {
|
||||
// Handle empty or null values
|
||||
if (val === '' || val === 'null' || val === 'undefined') {
|
||||
fields[fieldname] = null;
|
||||
} else {
|
||||
try {
|
||||
fields[fieldname] = JSON.parse(val);
|
||||
} catch {
|
||||
fields[fieldname] = val;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
bb.on('close', () => {
|
||||
console.log("✅ Busboy parsing completed");
|
||||
console.log("📌 Fields:", fields);
|
||||
console.log("📁 Files:", files.length);
|
||||
resolve();
|
||||
});
|
||||
|
||||
bb.on('error', (err) => {
|
||||
console.error("❌ Busboy error:", err);
|
||||
reject(new ApiError(400, `Multipart parsing error: ${err.message}`));
|
||||
});
|
||||
|
||||
bb.end(bodyBuffer);
|
||||
});
|
||||
|
||||
// 4) Extract required fields - only activityXid, pqqQuestionXid, pqqAnswerXid are required
|
||||
const activityXid = Number(fields.activityXid);
|
||||
const pqqQuestionXid = Number(fields.pqqQuestionXid);
|
||||
const pqqAnswerXid = Number(fields.pqqAnswerXid);
|
||||
|
||||
// Comments and files are optional
|
||||
const comments = fields.comments || null;
|
||||
|
||||
// Validate required fields
|
||||
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");
|
||||
|
||||
console.log(`📝 Processing - Activity: ${activityXid}, Question: ${pqqQuestionXid}, Answer: ${pqqAnswerXid}`);
|
||||
console.log(`💬 Comments: ${comments ? 'Provided' : 'Not provided'}`);
|
||||
console.log(`📎 Files: ${files.length}`);
|
||||
|
||||
// 5) UPSERT: Check if header already exists for this combination
|
||||
const existingHeader = await pqqService.findHeaderByCompositeKey(
|
||||
activityXid,
|
||||
pqqQuestionXid,
|
||||
pqqAnswerXid
|
||||
);
|
||||
|
||||
let header;
|
||||
if (existingHeader) {
|
||||
console.log("🔄 Updating existing PQQ header");
|
||||
// Update existing header (comments can be null)
|
||||
header = await pqqService.updateHeader(
|
||||
existingHeader.id,
|
||||
comments
|
||||
);
|
||||
} else {
|
||||
console.log("🆕 Creating new PQQ header");
|
||||
// Create new header (comments can be null)
|
||||
header = await pqqService.createHeader(
|
||||
activityXid,
|
||||
pqqQuestionXid,
|
||||
pqqAnswerXid,
|
||||
comments
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// 6) Get existing supporting files for this header
|
||||
const existingSupportingFiles = await pqqService.getSupportingFilesByHeaderId(header.id);
|
||||
console.log(`📁 Found ${existingSupportingFiles.length} existing supporting files`);
|
||||
|
||||
// 7) Handle file UPSERT - only if files are provided
|
||||
const uploadedFiles: any[] = [];
|
||||
|
||||
if (files.length > 0) {
|
||||
console.log("📤 Processing file uploads...");
|
||||
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
const file = files[i];
|
||||
const existingFile = existingSupportingFiles[i] || null;
|
||||
|
||||
const url = await uploadToS3(
|
||||
file.buffer,
|
||||
file.mimeType,
|
||||
file.fileName,
|
||||
`ActivityOnboarding/supportings/${activityXid}`,
|
||||
existingFile ? existingFile.mediaFileName : undefined
|
||||
);
|
||||
|
||||
let supporting;
|
||||
if (existingFile) {
|
||||
// Update existing supporting file record
|
||||
supporting = await pqqService.updateSupportingFile(
|
||||
existingFile.id,
|
||||
file.mimeType,
|
||||
url
|
||||
);
|
||||
console.log(`🔄 Updated supporting file: ${existingFile.id}`);
|
||||
} else {
|
||||
// Create new supporting file record
|
||||
supporting = await pqqService.addSupportingFile(
|
||||
header.id,
|
||||
file.mimeType,
|
||||
url
|
||||
);
|
||||
console.log(`🆕 Created new supporting file: ${supporting.id}`);
|
||||
}
|
||||
|
||||
uploadedFiles.push(supporting);
|
||||
}
|
||||
|
||||
// 8) Delete any remaining existing files that weren't replaced
|
||||
if (existingSupportingFiles.length > files.length) {
|
||||
const filesToDelete = existingSupportingFiles.slice(files.length);
|
||||
console.log(`🗑️ Deleting ${filesToDelete.length} unused supporting files`);
|
||||
|
||||
for (const fileToDelete of filesToDelete) {
|
||||
await pqqService.deleteSupportingFile(fileToDelete.id);
|
||||
// Also delete from S3
|
||||
const s3Key = getS3KeyFromUrl(fileToDelete.mediaFileName);
|
||||
await deleteFromS3(s3Key);
|
||||
console.log(`🗑️ Deleted supporting file: ${fileToDelete.id}`);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
console.log("📭 No files provided in request");
|
||||
|
||||
// If no files provided but existing files exist, delete them (cleanup)
|
||||
if (existingSupportingFiles.length > 0) {
|
||||
console.log(`🗑️ No new files provided, deleting ${existingSupportingFiles.length} existing files`);
|
||||
for (const fileToDelete of existingSupportingFiles) {
|
||||
await pqqService.deleteSupportingFile(fileToDelete.id);
|
||||
const s3Key = getS3KeyFromUrl(fileToDelete.mediaFileName);
|
||||
await deleteFromS3(s3Key);
|
||||
console.log(`🗑️ Deleted supporting file: ${fileToDelete.id}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 9) Prepare 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: comments,
|
||||
files: {
|
||||
uploaded: uploadedFiles,
|
||||
total: uploadedFiles.length
|
||||
},
|
||||
operation: existingHeader ? 'updated' : 'created',
|
||||
fileOperation: files.length > 0 ?
|
||||
(existingSupportingFiles.length > 0 ? 'replaced' : 'added') :
|
||||
(existingSupportingFiles.length > 0 ? 'removed' : 'unchanged')
|
||||
}
|
||||
})
|
||||
};
|
||||
|
||||
} catch (error: any) {
|
||||
console.error("❌ Error in submitPqqAnswer:", error);
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
@@ -560,43 +560,102 @@ export class HostService {
|
||||
return `HOSTREFNO-${String(nextId).padStart(6, '0')}`;
|
||||
}
|
||||
|
||||
async createOrUpdateHeader(
|
||||
// async createOrUpdateHeader(
|
||||
// activityXid: number,
|
||||
// pqqQuestionXid: number,
|
||||
// pqqAnswerXid: number,
|
||||
// comments: string | null
|
||||
// ) {
|
||||
// // find existing header
|
||||
// const existing = await this.prisma.activityPQQheader.findFirst({
|
||||
// where: { activityXid, pqqQuestionXid, deletedAt: null }
|
||||
// });
|
||||
|
||||
// if (!existing) {
|
||||
// return await this.prisma.activityPQQheader.create({
|
||||
// data: {
|
||||
// activityXid,
|
||||
// pqqQuestionXid,
|
||||
// pqqAnswerXid,
|
||||
// comments
|
||||
// }
|
||||
// });
|
||||
// }
|
||||
|
||||
// // mark old supportings deleted
|
||||
// await this.prisma.activityPQQSupportings.updateMany({
|
||||
// where: { activityPqqHeaderXid: existing.id },
|
||||
// data: {
|
||||
// isActive: false,
|
||||
// deletedAt: new Date()
|
||||
// }
|
||||
// });
|
||||
|
||||
// // update header
|
||||
// return await this.prisma.activityPQQheader.update({
|
||||
// where: { id: existing.id },
|
||||
// data: {
|
||||
// pqqAnswerXid,
|
||||
// comments
|
||||
// }
|
||||
// });
|
||||
// }
|
||||
|
||||
// async addSupportingFile(
|
||||
// headerId: number,
|
||||
// mimeType: string,
|
||||
// fileUrl: string
|
||||
// ) {
|
||||
// return await this.prisma.activityPQQSupportings.create({
|
||||
// data: {
|
||||
// activityPqqHeaderXid: headerId,
|
||||
// mediaType: mimeType,
|
||||
// mediaFileName: fileUrl
|
||||
// }
|
||||
// });
|
||||
// }
|
||||
|
||||
async createHeader(
|
||||
activityXid: number,
|
||||
pqqQuestionXid: number,
|
||||
pqqAnswerXid: number,
|
||||
comments: string | null
|
||||
comments?: string | null
|
||||
) {
|
||||
// find existing header
|
||||
const existing = await this.prisma.activityPQQheader.findFirst({
|
||||
where: { activityXid, pqqQuestionXid, deletedAt: null }
|
||||
});
|
||||
|
||||
if (!existing) {
|
||||
return await this.prisma.activityPQQheader.create({
|
||||
data: {
|
||||
activityXid,
|
||||
pqqQuestionXid,
|
||||
pqqAnswerXid,
|
||||
comments
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// mark old supportings deleted
|
||||
await this.prisma.activityPQQSupportings.updateMany({
|
||||
where: { activityPqqHeaderXid: existing.id },
|
||||
return await this.prisma.activityPQQheader.create({
|
||||
data: {
|
||||
isActive: false,
|
||||
deletedAt: new Date()
|
||||
activityXid,
|
||||
pqqQuestionXid,
|
||||
pqqAnswerXid,
|
||||
comments: comments || null // Handle null comments
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// update header
|
||||
async findHeaderByCompositeKey(
|
||||
activityXid: number,
|
||||
pqqQuestionXid: number,
|
||||
pqqAnswerXid: number
|
||||
) {
|
||||
return await this.prisma.activityPQQheader.findFirst({
|
||||
where: {
|
||||
activityXid,
|
||||
pqqQuestionXid,
|
||||
pqqAnswerXid
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async updateHeader(
|
||||
headerId: number,
|
||||
comments?: string | null
|
||||
) {
|
||||
return await this.prisma.activityPQQheader.update({
|
||||
where: { id: existing.id },
|
||||
where: {
|
||||
id: headerId
|
||||
},
|
||||
data: {
|
||||
pqqAnswerXid,
|
||||
comments
|
||||
comments: comments || null, // Handle null comments
|
||||
updatedAt: new Date()
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -615,6 +674,42 @@ export class HostService {
|
||||
});
|
||||
}
|
||||
|
||||
async getSupportingFilesByHeaderId(headerId: number) {
|
||||
return await this.prisma.activityPQQSupportings.findMany({
|
||||
where: {
|
||||
activityPqqHeaderXid: headerId
|
||||
},
|
||||
orderBy: {
|
||||
id: 'asc' // Maintain consistent order
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async updateSupportingFile(
|
||||
supportingFileId: number,
|
||||
mimeType: string,
|
||||
fileUrl: string
|
||||
) {
|
||||
return await this.prisma.activityPQQSupportings.update({
|
||||
where: {
|
||||
id: supportingFileId
|
||||
},
|
||||
data: {
|
||||
mediaType: mimeType,
|
||||
mediaFileName: fileUrl,
|
||||
updatedAt: new Date()
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async deleteSupportingFile(supportingFileId: number) {
|
||||
return await this.prisma.activityPQQSupportings.delete({
|
||||
where: {
|
||||
id: supportingFileId
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async getAllActivityTypesWithInterest(search?: string) {
|
||||
const where: any = {
|
||||
isActive: true,
|
||||
|
||||
Reference in New Issue
Block a user