diff --git a/src/modules/host/handlers/getByIdPQQ.ts b/src/modules/host/handlers/getByIdPQQ.ts index 23c5db9..70b4dba 100644 --- a/src/modules/host/handlers/getByIdPQQ.ts +++ b/src/modules/host/handlers/getByIdPQQ.ts @@ -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); diff --git a/src/modules/host/handlers/getLatestQuestionDetailsPQQ.ts b/src/modules/host/handlers/getLatestQuestionDetailsPQQ.ts index 91376f8..8667d31 100644 --- a/src/modules/host/handlers/getLatestQuestionDetailsPQQ.ts +++ b/src/modules/host/handlers/getLatestQuestionDetailsPQQ.ts @@ -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); diff --git a/src/modules/host/handlers/submitPqqAns.ts b/src/modules/host/handlers/submitPqqAns.ts index 0130786..3d832b5 100644 --- a/src/modules/host/handlers/submitPqqAns.ts +++ b/src/modules/host/handlers/submitPqqAns.ts @@ -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 { - 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 { + 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 { + 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 => { - 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((resolve, reject) => { - const bb = Busboy({ headers: { 'content-type': contentType } }); + // 3) 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; - 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; + } +}); \ No newline at end of file diff --git a/src/modules/host/services/host.service.ts b/src/modules/host/services/host.service.ts index 0c92a10..62ffd65 100644 --- a/src/modules/host/services/host.service.ts +++ b/src/modules/host/services/host.service.ts @@ -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,