From 4a7e5fbb1ed38aa372f27706074488fed07a6927 Mon Sep 17 00:00:00 2001 From: Mayank Mishra Date: Wed, 3 Dec 2025 19:21:21 +0530 Subject: [PATCH] Add PQQ functionality: Introduce new endpoints for creating activities and submitting answers, along with updates to the Minglar service for retrieving PQQ details. Update serverless configuration to include new function files. --- prisma/schema.prisma | 4 +- serverless.yml | 1 + serverless/functions/minglaradmin.yml | 16 + serverless/functions/pqq.yml | 29 ++ .../createActivityAndAllQuestionsEntry.ts | 62 +++ .../Activity_Hub/OnBoarding/getPQQScore.ts | 23 +- .../Activity_Hub/OnBoarding/submitPQAnswer.ts | 301 +++++++++++++++ src/modules/host/services/host.service.ts | 365 +++++++++++++++++- .../hosthub/pqp/getAllPQPDetailsForAM.ts | 50 +++ .../minglaradmin/services/minglar.service.ts | 139 +++++++ 10 files changed, 975 insertions(+), 15 deletions(-) create mode 100644 serverless/functions/pqq.yml create mode 100644 src/modules/host/handlers/Activity_Hub/OnBoarding/createActivityAndAllQuestionsEntry.ts create mode 100644 src/modules/host/handlers/Activity_Hub/OnBoarding/submitPQAnswer.ts create mode 100644 src/modules/minglaradmin/handlers/hosthub/pqp/getAllPQPDetailsForAM.ts diff --git a/prisma/schema.prisma b/prisma/schema.prisma index d2eb6c5..4db51d4 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -1141,8 +1141,8 @@ model ActivityPQQheader { activity Activities @relation(fields: [activityXid], references: [id], onDelete: Cascade) pqqQuestionXid Int @map("pqq_question_xid") pqqQuestions PQQQuestions @relation(fields: [pqqQuestionXid], references: [id], onDelete: Restrict) - pqqAnswerXid Int @map("pqq_answer_xid") - pqqAnswers PQQAnswers @relation(fields: [pqqAnswerXid], references: [id], onDelete: Restrict) + pqqAnswerXid Int? @map("pqq_answer_xid") + pqqAnswers PQQAnswers? @relation(fields: [pqqAnswerXid], references: [id], onDelete: Restrict) comments String? @map("comments") @db.VarChar(200) isActive Boolean @default(true) @map("is_active") createdAt DateTime @default(now()) @map("created_at") diff --git a/serverless.yml b/serverless.yml index 2e24c30..04eba8f 100644 --- a/serverless.yml +++ b/serverless.yml @@ -88,6 +88,7 @@ functions: - ${file(./serverless/functions/host.yml)} - ${file(./serverless/functions/minglaradmin.yml)} - ${file(./serverless/functions/prepopulate.yml)} + - ${file(./serverless/functions/pqq.yml)} plugins: - serverless-offline \ No newline at end of file diff --git a/serverless/functions/minglaradmin.yml b/serverless/functions/minglaradmin.yml index 3a0e234..bf68211 100644 --- a/serverless/functions/minglaradmin.yml +++ b/serverless/functions/minglaradmin.yml @@ -406,3 +406,19 @@ addPQQSuggestion: - httpApi: path: /minglaradmin/hosthub/hosts/add-Pqq-suggestion method: post + +getAllPQPDetailsForAM: + handler: src/modules/minglaradmin/handlers/hosthub/pqp/getAllPQPDetailsForAM.handler + memorySize: 384 + package: + patterns: + - 'src/modules/minglaradmin/handlers/hosthub/pqp/getAllPQPDetailsForAM**' + - 'src/modules/minglaradmin/services/**' + - ${file(./serverless/patterns/base.yml):pattern1} + - ${file(./serverless/patterns/base.yml):pattern2} + - ${file(./serverless/patterns/base.yml):pattern3} + - ${file(./serverless/patterns/base.yml):pattern4} + events: + - httpApi: + path: /minglaradmin/hosthub/pqp/pqp-details-for-am/{activityXid} + method: get diff --git a/serverless/functions/pqq.yml b/serverless/functions/pqq.yml new file mode 100644 index 0000000..eabfb3f --- /dev/null +++ b/serverless/functions/pqq.yml @@ -0,0 +1,29 @@ +createActivityAndAllQuestionsEntry: + handler: src/modules/host/handlers/Activity_Hub/OnBoarding/createActivityAndAllQuestionsEntry.handler + memorySize: 384 + package: + patterns: + - 'src/modules/host/handlers/Activity_Hub/OnBoarding/createActivityAndAllQuestionsEntry**' + - ${file(./serverless/patterns/base.yml):pattern1} + - ${file(./serverless/patterns/base.yml):pattern2} + - ${file(./serverless/patterns/base.yml):pattern3} + - ${file(./serverless/patterns/base.yml):pattern4} + events: + - httpApi: + path: /host/Activity_Hub/OnBoarding/create-activity + method: post + +submitPQAnswer: + handler: src/modules/host/handlers/Activity_Hub/OnBoarding/submitPQAnswer.handler + memorySize: 384 + package: + patterns: + - 'src/modules/host/handlers/Activity_Hub/OnBoarding/submitPQAnswer**' + - ${file(./serverless/patterns/base.yml):pattern1} + - ${file(./serverless/patterns/base.yml):pattern2} + - ${file(./serverless/patterns/base.yml):pattern3} + - ${file(./serverless/patterns/base.yml):pattern4} + events: + - httpApi: + path: /host/Activity_Hub/OnBoarding/submit-pq-answer + method: patch diff --git a/src/modules/host/handlers/Activity_Hub/OnBoarding/createActivityAndAllQuestionsEntry.ts b/src/modules/host/handlers/Activity_Hub/OnBoarding/createActivityAndAllQuestionsEntry.ts new file mode 100644 index 0000000..e24bea3 --- /dev/null +++ b/src/modules/host/handlers/Activity_Hub/OnBoarding/createActivityAndAllQuestionsEntry.ts @@ -0,0 +1,62 @@ +import { verifyHostToken } from '../../../../../common/middlewares/jwt/authForHost'; +import { + APIGatewayProxyEvent, + APIGatewayProxyResult, + Context, +} from 'aws-lambda'; +import { PrismaService } from '../../../../../common/database/prisma.service'; +import { safeHandler } from '../../../../../common/utils/handlers/safeHandler'; +import ApiError from '../../../../../common/utils/helper/ApiError'; +import { HostService } from '../../../services/host.service'; + +const prismaService = new PrismaService(); +const hostService = new HostService(prismaService); + +export const handler = safeHandler( + async ( + event: APIGatewayProxyEvent, + context?: Context, + ): Promise => { + // Verify authentication token + const token = + event.headers['x-auth-token'] || event.headers['X-Auth-Token']; + if (!token) { + throw new ApiError( + 401, + 'This is a protected route. Please provide a valid token.', + ); + } + + // Verify token and get user info + const userInfo = await verifyHostToken(token); + + let body: any = {}; + try { + body = event.body ? JSON.parse(event.body) : {}; + } catch (err) { + throw new ApiError(400, 'Invalid JSON in request body'); + } + + const { activityTypeXid, frequenciesXid } = body; + + if (!activityTypeXid || !frequenciesXid) { + throw new ApiError(400, 'activityType and frequency ID is required'); + } + + // Get all host applications from service based on user role + const createdData = await hostService.createActivityAndAllQuestionsEntry(userInfo.id, activityTypeXid, frequenciesXid); + + return { + statusCode: 200, + headers: { + 'Content-Type': 'application/json', + 'Access-Control-Allow-Origin': '*', + }, + body: JSON.stringify({ + success: true, + message: 'Activity created successfully', + data: createdData + }), + }; + }, +); diff --git a/src/modules/host/handlers/Activity_Hub/OnBoarding/getPQQScore.ts b/src/modules/host/handlers/Activity_Hub/OnBoarding/getPQQScore.ts index e2db262..173eb9b 100644 --- a/src/modules/host/handlers/Activity_Hub/OnBoarding/getPQQScore.ts +++ b/src/modules/host/handlers/Activity_Hub/OnBoarding/getPQQScore.ts @@ -10,7 +10,7 @@ import ApiError from '../../../../../common/utils/helper/ApiError'; import { HostService } from '../../../services/host.service'; const prisma = new PrismaService(); -const pqqService = new HostService(prisma); +const hostService = new HostService(prisma); const s3 = new AWS.S3({ region: config.aws.region }); @@ -121,20 +121,20 @@ export const handler = safeHandler(async (event: APIGatewayProxyEvent): Promise< throw new ApiError(400, "Missing required fields"); // UPSERT header - const existingHeader = await pqqService.findHeaderByCompositeKey(activityXid, pqqQuestionXid); + const existingHeader = await hostService.findHeaderByCompositeKey(activityXid, pqqQuestionXid); let header; if (existingHeader) { - header = await pqqService.updateHeader(existingHeader.id, pqqAnswerXid, comments); + header = await hostService.updateHeader(existingHeader.id, pqqAnswerXid, comments); } else { - header = await pqqService.createHeader(activityXid, pqqQuestionXid, pqqAnswerXid, comments); + header = await hostService.createHeader(activityXid, pqqQuestionXid, pqqAnswerXid, comments); } // SCORE - const score = await pqqService.calculatePqqScoreForUser(activityXid); + const score = await hostService.calculatePqqScoreForUser(activityXid); // Existing supporting files - const existingSupportingFiles = await pqqService.getSupportingFilesByHeaderId(header.id); + const existingSupportingFiles = await hostService.getSupportingFilesByHeaderId(header.id); // Read deletedFiles from frontend const deletedFiles = Array.isArray(fields.deletedFiles) ? fields.deletedFiles : []; @@ -156,7 +156,7 @@ export const handler = safeHandler(async (event: APIGatewayProxyEvent): Promise< } // Delete from DB - await pqqService.deleteSupportingFile(record.id); + await hostService.deleteSupportingFile(record.id); deleteResults.push({ id: record.id, deleted: true }); } } @@ -171,11 +171,13 @@ export const handler = safeHandler(async (event: APIGatewayProxyEvent): Promise< `ActivityOnboarding/supportings/${activityXid}` ); - const newRec = await pqqService.addSupportingFile(header.id, file.mimeType, url); + const newRec = await hostService.addSupportingFile(header.id, file.mimeType, url); addResults.push(newRec); } } + const getAllUpdatedQuestionResponse = await hostService.getAllPQUpdatedResponse(activityXid) + // CASE 2 — NO deletion & NO new files => DO NOTHING to existing files return { @@ -191,10 +193,7 @@ export const handler = safeHandler(async (event: APIGatewayProxyEvent): Promise< pqqAnswerXid, comments, score, - files: { - added: addResults, - deleted: deleteResults - } + getAllUpdatedQuestionResponse } }) }; diff --git a/src/modules/host/handlers/Activity_Hub/OnBoarding/submitPQAnswer.ts b/src/modules/host/handlers/Activity_Hub/OnBoarding/submitPQAnswer.ts new file mode 100644 index 0000000..b74d084 --- /dev/null +++ b/src/modules/host/handlers/Activity_Hub/OnBoarding/submitPQAnswer.ts @@ -0,0 +1,301 @@ +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; + } +}); diff --git a/src/modules/host/services/host.service.ts b/src/modules/host/services/host.service.ts index 6175754..250e72a 100644 --- a/src/modules/host/services/host.service.ts +++ b/src/modules/host/services/host.service.ts @@ -567,7 +567,13 @@ export class HostService { async getLatestQuestionDetailsPQQ(activity_xid: number) { return await this.prisma.activityPQQheader.findFirst({ - where: { activityXid: activity_xid, isActive: true }, + where: { + activityXid: activity_xid, + isActive: true, + pqqAnswerXid: { + not: null + } + }, select: { pqqQuestionXid: true, pqqAnswerXid: true, @@ -1528,4 +1534,361 @@ export class HostService { }); } + async createActivityAndAllQuestionsEntry( + userId: number, + activityTypeXid: number, + frequenciesXid: number + ) { + return await this.prisma.$transaction(async (tx) => { + + // -------------- TYPES FIXED HERE ------------------- + type GroupedCategory = { + id: number; + categoryName: string; + displayOrder: number; + pqqsubCategories: { + id: number; + subCategoryName: string; + displayOrder: number; + questions: { + id: number; + questionName: string; + maxPoints: number; + displayOrder: number; + PQQAnswers: any[]; + suggestions: any[]; + supportings: any[]; + }[]; + }[]; + }; + + // --------------------------------------------------- + + const host = await tx.hostHeader.findFirst({ + where: { userXid: userId, isActive: true }, + }); + if (!host) throw new ApiError(404, 'Host not found for the user'); + + const activityType = await tx.activityTypes.findUnique({ + where: { id: activityTypeXid }, + }); + if (!activityType) throw new ApiError(404, 'Activity type not found'); + + if (frequenciesXid) { + const freq = await tx.frequencies.findUnique({ + where: { id: frequenciesXid }, + }); + if (!freq) throw new ApiError(404, 'Frequency not found'); + } + + const referenceNumber = await generateActivityRefNumber(tx); + + const created = await tx.activities.create({ + data: { + hostXid: host.id, + activityTypeXid, + frequenciesXid: frequenciesXid || null, + activityInternalStatus: ACTIVITY_INTERNAL_STATUS.DRAFT_PQ, + activityDisplayStatus: ACTIVITY_DISPLAY_STATUS.DRAFT_PQ, + amInternalStatus: ACTIVITY_AM_INTERNAL_STATUS.DRAFT_PQ, + amDisplayStatus: ACTIVITY_AM_DISPLAY_STATUS.DRAFT_PQ, + activityRefNumber: referenceNumber, + }, + }); + + const questions = await tx.pQQCategories.findMany({ + where: { isActive: true }, + select: { + id: true, + categoryName: true, + displayOrder: true, + pqqsubCategories: { + where: { isActive: true }, + select: { + id: true, + subCategoryName: true, + displayOrder: true, + questions: { + where: { isActive: true }, + select: { + id: true, + questionName: true, + maxPoints: true, + displayOrder: true, + }, + orderBy: { displayOrder: 'asc' }, + }, + }, + orderBy: { displayOrder: 'asc' }, + }, + }, + orderBy: { displayOrder: 'asc' }, + }); + + // FLATTEN questions + const allQuestions: number[] = []; + for (const cat of questions) { + for (const sub of cat.pqqsubCategories) { + for (const q of sub.questions) { + allQuestions.push(q.id); + } + } + } + + await tx.activityPQQheader.createMany({ + data: allQuestions.map((id) => ({ + activityXid: created.id, + pqqQuestionXid: id, + pqqAnswerXid: null, + })), + }); + + const pqqHeaderData = await tx.activityPQQheader.findMany({ + where: { activityXid: created.id, isActive: true }, + select: { + pqqQuestions: { + select: { + id: true, + questionName: true, + maxPoints: true, + displayOrder: true, + pqqSubCategories: { + select: { + id: true, + subCategoryName: true, + displayOrder: true, + category: { + select: { + id: true, + categoryName: true, + displayOrder: true, + }, + }, + }, + }, + }, + }, + pqqAnswers: { + select: { + id: true, + answerName: true, + answerPoints: true, + displayOrder: true, + }, + }, + ActivityPQQSuggestions: { + where: { isActive: true }, + select: { + id: true, + title: true, + comments: true, + }, + }, + ActivityPQQSupportings: { + where: { isActive: true }, + select: { + id: true, + mediaType: true, + mediaFileName: true, + }, + }, + }, + orderBy: { id: "asc" }, + }); + + // ---------------- GROUPING ------------------ + const grouped: Record = {}; + + for (const item of pqqHeaderData) { + const q = item.pqqQuestions; + const sub = q.pqqSubCategories; + const cat = sub.category; + + if (!grouped[cat.id]) { + grouped[cat.id] = { + id: cat.id, + categoryName: cat.categoryName, + displayOrder: cat.displayOrder, + pqqsubCategories: [], + }; + } + + const category = grouped[cat.id]; + let subCat = category.pqqsubCategories.find((x) => x.id === sub.id); + + if (!subCat) { + subCat = { + id: sub.id, + subCategoryName: sub.subCategoryName, + displayOrder: sub.displayOrder, + questions: [], + }; + category.pqqsubCategories.push(subCat); + } + + subCat.questions.push({ + id: q.id, + questionName: q.questionName, + maxPoints: q.maxPoints, + displayOrder: q.displayOrder, + PQQAnswers: item.pqqAnswers ? [item.pqqAnswers] : [], + suggestions: item.ActivityPQQSuggestions || [], + supportings: item.ActivityPQQSupportings || [], + }); + } + + const sortedCategories = Object.values(grouped) as GroupedCategory[]; + + // SORT + sortedCategories.sort((a, b) => a.displayOrder - b.displayOrder); + for (const cat of sortedCategories) { + cat.pqqsubCategories.sort((a, b) => a.displayOrder - b.displayOrder); + for (const sub of cat.pqqsubCategories) { + sub.questions.sort((a, b) => a.displayOrder - b.displayOrder); + } + } + + return sortedCategories; + }); + } + + + async getAllPQUpdatedResponse(activityXid: number) { + const pqqHeaderData = await this.prisma.activityPQQheader.findMany({ + where: { + activityXid: activityXid, + isActive: true, + }, + select: { + pqqQuestions: { + select: { + id: true, + questionName: true, + maxPoints: true, + displayOrder: true, + pqqSubCategories: { + select: { + id: true, + subCategoryName: true, + displayOrder: true, + category: { + select: { + id: true, + categoryName: true, + displayOrder: true + } + } + } + } + } + }, + pqqAnswers: { + select: { + id: true, + answerName: true, + answerPoints: true, + displayOrder: true + } + }, + ActivityPQQSuggestions: { + where: { isActive: true }, + select: { + id: true, + title: true, + comments: true, + activityPqqHeaderXid: true + } + }, + ActivityPQQSupportings: { + where: { isActive: true }, + select: { + id: true, + mediaType: true, + mediaFileName: true + } + }, + }, + orderBy: { id: "asc" } + }); + + // ---------- GROUPING START ---------- + const grouped: any = {}; + + for (const item of pqqHeaderData) { + const q = item.pqqQuestions; + const sub = q.pqqSubCategories; + const cat = sub.category; + + // 1️⃣ Category level + if (!grouped[cat.id]) { + grouped[cat.id] = { + id: cat.id, + categoryName: cat.categoryName, + displayOrder: cat.displayOrder, + pqqsubCategories: [] + }; + } + const category = grouped[cat.id]; + + // 2️⃣ Subcategory level + let subCat = category.pqqsubCategories.find((s: any) => s.id === sub.id); + if (!subCat) { + subCat = { + id: sub.id, + subCategoryName: sub.subCategoryName, + displayOrder: sub.displayOrder, + questions: [] + }; + category.pqqsubCategories.push(subCat); + } + + // 3️⃣ Questions level + subCat.questions.push({ + id: q.id, + questionName: q.questionName, + maxPoints: q.maxPoints, + displayOrder: q.displayOrder, + PQQAnswers: item.pqqAnswers ? [item.pqqAnswers] : [], + suggestions: item.ActivityPQQSuggestions, + supportings: item.ActivityPQQSupportings + }); + } + + // ---------- SORTING ---------- + const sortedCategories: any = Object.values(grouped) + .sort((a: any, b: any) => a.displayOrder - b.displayOrder); + + for (const cat of sortedCategories) { + cat.pqqsubCategories.sort((a: any, b: any) => a.displayOrder - b.displayOrder); + + for (const sub of cat.pqqsubCategories) { + sub.questions.sort((a: any, b: any) => a.displayOrder - b.displayOrder); + } + } + + // ---------- PRESIGNED URL GENERATION ---------- + for (const cat of sortedCategories) { + for (const sub of cat.pqqsubCategories) { + for (const q of sub.questions) { + if (q.supportings?.length) { + for (const doc of q.supportings) { + if (doc.mediaFileName) { + const filePath = doc.mediaFileName; + const key = filePath.startsWith("http") + ? filePath.split(".com/")[1] + : filePath; + + doc.presignedUrl = await getPresignedUrl(bucket, key); + } + } + } + } + } + } + + // ---------- RETURN GROUPED STRUCTURE ---------- + return sortedCategories; + } + + } diff --git a/src/modules/minglaradmin/handlers/hosthub/pqp/getAllPQPDetailsForAM.ts b/src/modules/minglaradmin/handlers/hosthub/pqp/getAllPQPDetailsForAM.ts new file mode 100644 index 0000000..ac805e8 --- /dev/null +++ b/src/modules/minglaradmin/handlers/hosthub/pqp/getAllPQPDetailsForAM.ts @@ -0,0 +1,50 @@ +import { PrismaService } from '../../../../../common/database/prisma.service'; +import { verifyMinglarAdminToken } from '../../../../../common/middlewares/jwt/authForMinglarAdmin'; +import { safeHandler } from '../../../../../common/utils/handlers/safeHandler'; +import ApiError from '../../../../../common/utils/helper/ApiError'; +import { APIGatewayProxyEvent, APIGatewayProxyResult, Context } from 'aws-lambda'; +import { MinglarService } from '../../../services/minglar.service'; + +const prismaService = new PrismaService(); +const minglarService = new MinglarService(prismaService); + +export const handler = safeHandler(async ( + event: APIGatewayProxyEvent, + context?: Context +): Promise => { + // Get host ID from path parameters + const token = event.headers['x-auth-token'] || event.headers['X-Auth-Token'] + if (!token) { + throw new ApiError(400, 'This is a protected route. Please provide a valid token.'); + } + + await verifyMinglarAdminToken(token); + + const activityXid = event.pathParameters?.activityXid; + if (!activityXid) { + throw new ApiError( + 400, + 'Host ID is required in path parameters.', + ); + } + + + const pqpDetails = await minglarService.getAllPQPDetailsForAM(Number(activityXid)); + + if (!pqpDetails) { + throw new ApiError(404, 'Record not found'); + } + + return { + statusCode: 200, + headers: { + 'Content-Type': 'application/json', + 'Access-Control-Allow-Origin': '*', + }, + body: JSON.stringify({ + success: true, + message: 'PQ details retrieved successfully', + data: pqpDetails, + }), + }; +}); diff --git a/src/modules/minglaradmin/services/minglar.service.ts b/src/modules/minglaradmin/services/minglar.service.ts index 32080ec..bb64649 100644 --- a/src/modules/minglaradmin/services/minglar.service.ts +++ b/src/modules/minglaradmin/services/minglar.service.ts @@ -1558,6 +1558,7 @@ export class MinglarService { hostStatusDisplay: HOST_STATUS_DISPLAY.UNDER_REVIEW, }, data: { + stepper: STEPPER.NOT_SUBMITTED, hostStatusInternal: HOST_STATUS_INTERNAL.HOST_TO_UPDATE, hostStatusDisplay: HOST_STATUS_DISPLAY.ENHANCING, adminStatusInternal: MINGLAR_STATUS_INTERNAL.AM_REJECTED, @@ -1875,4 +1876,142 @@ export class MinglarService { return host; } + + async getAllPQPDetailsForAM(activityXid: number) { + const pqqHeaderData = await this.prisma.activityPQQheader.findMany({ + where: { + activityXid: activityXid, + isActive: true, + }, + select: { + pqqQuestions: { + select: { + id: true, + questionName: true, + maxPoints: true, + displayOrder: true, + pqqSubCategories: { + select: { + id: true, + subCategoryName: true, + displayOrder: true, + category: { + select: { + id: true, + categoryName: true, + displayOrder: true + } + } + } + } + } + }, + pqqAnswers: { + select: { + id: true, + answerName: true, + answerPoints: true, + displayOrder: true + } + }, + ActivityPQQSuggestions: { + where: { isActive: true }, + select: { + id: true, + title: true, + comments: true, + activityPqqHeaderXid: true + } + }, + ActivityPQQSupportings: { + where: { isActive: true }, + select: { + id: true, + mediaType: true, + mediaFileName: true + } + }, + }, + orderBy: { id: "asc" } + }); + + // ---------- GROUPING START ---------- + const grouped: any = {}; + + for (const item of pqqHeaderData) { + const q = item.pqqQuestions; + const sub = q.pqqSubCategories; + const cat = sub.category; + + // 1️⃣ Category level + if (!grouped[cat.id]) { + grouped[cat.id] = { + id: cat.id, + categoryName: cat.categoryName, + displayOrder: cat.displayOrder, + pqqsubCategories: [] + }; + } + const category = grouped[cat.id]; + + // 2️⃣ Subcategory level + let subCat = category.pqqsubCategories.find((s: any) => s.id === sub.id); + if (!subCat) { + subCat = { + id: sub.id, + subCategoryName: sub.subCategoryName, + displayOrder: sub.displayOrder, + questions: [] + }; + category.pqqsubCategories.push(subCat); + } + + // 3️⃣ Questions level + subCat.questions.push({ + id: q.id, + questionName: q.questionName, + maxPoints: q.maxPoints, + displayOrder: q.displayOrder, + PQQAnswers: item.pqqAnswers ? [item.pqqAnswers] : [], + suggestions: item.ActivityPQQSuggestions, + supportings: item.ActivityPQQSupportings + }); + } + + // ---------- SORTING ---------- + const sortedCategories: any = Object.values(grouped) + .sort((a: any, b: any) => a.displayOrder - b.displayOrder); + + for (const cat of sortedCategories) { + cat.pqqsubCategories.sort((a: any, b: any) => a.displayOrder - b.displayOrder); + + for (const sub of cat.pqqsubCategories) { + sub.questions.sort((a: any, b: any) => a.displayOrder - b.displayOrder); + } + } + + // ---------- PRESIGNED URL GENERATION ---------- + for (const cat of sortedCategories) { + for (const sub of cat.pqqsubCategories) { + for (const q of sub.questions) { + if (q.supportings?.length) { + for (const doc of q.supportings) { + if (doc.mediaFileName) { + const filePath = doc.mediaFileName; + const key = filePath.startsWith("http") + ? filePath.split(".com/")[1] + : filePath; + + doc.presignedUrl = await getPresignedUrl(bucket, key); + } + } + } + } + } + } + + // ---------- RETURN GROUPED STRUCTURE ---------- + return sortedCategories; + + } }