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:
2025-11-22 20:05:43 +05:30
parent 6d48eeb25b
commit d0fd8e6691
4 changed files with 401 additions and 161 deletions

View File

@@ -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);

View File

@@ -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);

View File

@@ -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;
}
});

View File

@@ -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,