made get score api for pqq

This commit is contained in:
2025-11-24 23:19:18 +05:30
parent c4e470be05
commit 7056f32e24
9 changed files with 687 additions and 128 deletions

View File

@@ -4,6 +4,7 @@ import { PrismaService } from '../../../common/database/prisma.service';
import ApiError from '../../../common/utils/helper/ApiError';
import { verifyHostToken } from '../../../common/middlewares/jwt/authForHost';
import { HostService } from '../services/host.service';
import { verifyMinglarAdminHostToken } from '@/common/middlewares/jwt/authForMinglarAdmin&Host';
const prismaService = new PrismaService();
const hostService = new HostService(prismaService);
@@ -18,7 +19,7 @@ export const handler = safeHandler(async (
}
// Verify token and get user info
const userInfo = await verifyHostToken(token);
const userInfo = await verifyMinglarAdminHostToken(token);
const userId = Number(userInfo.id);
let body: { question_xid: number, activity_xid: number };

View File

@@ -0,0 +1,310 @@
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';
import { LAST_QUESTION_ID } from '@/common/utils/constants/host.constant';
const prisma = new PrismaService();
const pqqService = 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<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
}
}
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);
// 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");
const bodyBuffer = Buffer.from(event.body!, "base64");
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 } });
bb.on('file', (fieldname, file, info) => {
const { filename, mimeType } = info;
// Skip if no filename (empty file field)
if (!filename) {
file.resume();
return;
}
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', () => {
// 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 (pqqQuestionXid !== LAST_QUESTION_ID.Q_ID) throw new ApiError(400, "Wrong question id.")
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)
const header = await pqqService.updateHeader(
existingHeader.id,
comments
);
} else {
console.log("🆕 Creating new PQQ header");
// Create new header (comments can be null)
const header = await pqqService.createHeader(
activityXid,
pqqQuestionXid,
pqqAnswerXid,
comments
);
}
// Calculate score after answer submission
const score = await pqqService.calculatePqqScoreForUser(activityXid);
// 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,
score,
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

@@ -1,9 +1,9 @@
import { verifyMinglarAdminHostToken } from '@/common/middlewares/jwt/authForMinglarAdmin&Host';
import { APIGatewayProxyEvent, APIGatewayProxyResult, Context } from 'aws-lambda';
import { safeHandler } from '../../../common/utils/handlers/safeHandler';
import { PrismaService } from '../../../common/database/prisma.service';
import { HostService } from '../services/host.service';
import { safeHandler } from '../../../common/utils/handlers/safeHandler';
import ApiError from '../../../common/utils/helper/ApiError';
import { verifyHostToken } from '@/common/middlewares/jwt/authForHost';
import { HostService } from '../services/host.service';
const prismaService = new PrismaService();
const hostService = new HostService(prismaService);
@@ -18,7 +18,7 @@ export const handler = safeHandler(async (
throw new ApiError(400, 'This is a protected route. Please provide a valid token.');
}
const userInfo = await verifyHostToken(token);
const userInfo = await verifyMinglarAdminHostToken(token);
const id = Number(userInfo.id)
if (!id) {

View File

@@ -48,9 +48,17 @@ export class HostService {
include: {
hostParent: true,
HostBankDetails: true,
HostDocuments: true,
HostDocuments: {
include: {
documentType: true,
},
},
HostSuggestion: true,
HostTrack: true,
countries: true,
currencies: true,
states: true,
cities: true,
}
});
@@ -241,7 +249,8 @@ export class HostService {
select: {
pqqQuestionXid: true,
pqqAnswerXid: true,
ActivityPQQSupportings: true
ActivityPQQSupportings: true,
ActivityPQQSuggestions: true
}
})
}
@@ -293,14 +302,14 @@ export class HostService {
const createdHost = await tx.hostHeader.create({
data: {
userXid: user_xid,
user: { connect: { id: user_xid } },
companyName: companyData.companyName,
hostRefNumber: refNumber,
address1: companyData.address1,
address2: companyData.address2,
cityXid: companyData.cityXid,
stateXid: companyData.stateXid,
countryXid: companyData.countryXid,
cities: { connect: { id: companyData.cityXid } },
states: { connect: { id: companyData.stateXid } },
countries: { connect: { id: companyData.countryXid } },
pinCode: companyData.pinCode,
logoPath: companyData.logoPath || null,
isSubsidairy: companyData.isSubsidairy,
@@ -314,7 +323,7 @@ export class HostService {
facebookUrl: companyData.facebookUrl || null,
linkedinUrl: companyData.linkedinUrl || null,
twitterUrl: companyData.twitterUrl || null,
currencyXid: companyData.currencyXid,
// currencyXid: companyData.currencyXid,
stepper: STEPPER.UNDER_REVIEW,
hostStatusInternal: HOST_STATUS_INTERNAL.HOST_SUBMITTED,
hostStatusDisplay: HOST_STATUS_DISPLAY.UNDER_REVIEW,
@@ -383,9 +392,9 @@ export class HostService {
companyName: companyData.companyName,
address1: companyData.address1,
address2: companyData.address2,
cityXid: companyData.cityXid,
stateXid: companyData.stateXid,
countryXid: companyData.countryXid,
cities: { connect: { id: companyData.cityXid } },
states: { connect: { id: companyData.stateXid } },
countries: { connect: { id: companyData.countryXid } },
pinCode: companyData.pinCode,
logoPath: companyData.logoPath || null,
isSubsidairy: companyData.isSubsidairy,
@@ -399,7 +408,7 @@ export class HostService {
facebookUrl: companyData.facebookUrl || null,
linkedinUrl: companyData.linkedinUrl || null,
twitterUrl: companyData.twitterUrl || null,
currencyXid: companyData.currencyXid,
// currencyXid: companyData.currencyXid,
stepper: STEPPER.UNDER_REVIEW
// hostRefNumber: DO NOT UPDATE
},
@@ -628,6 +637,87 @@ export class HostService {
// });
// }
async calculatePqqScoreForUser(activityXid: number) {
// 1. Get all headers for this activity (user's answers)
const answers = await this.prisma.activityPQQheader.findMany({
where: { activityXid },
include: {
pqqQuestions: {
include: {
pqqSubCategories: {
include: {
category: true
}
}
}
},
pqqAnswers: true
}
});
if (!answers.length) {
return {
overallPercentage: 0,
categoryWise: {}
};
}
// Prepare accumulators
let totalUserPoints = 0;
let totalMaxPoints = 0;
// For category-wise scoring
const categories: any = {}; // { [categoryId]: { userPoints, maxPoints, name } }
for (const item of answers) {
const question = item.pqqQuestions;
const answer = item.pqqAnswers;
const maxPoints = question.maxPoints;
const userPoints = answer.answerPoints;
totalUserPoints += userPoints;
totalMaxPoints += maxPoints;
// Category info
const category = question.pqqSubCategories.category;
const categoryId = category.id;
if (!categories[categoryId]) {
categories[categoryId] = {
categoryId,
categoryName: category.categoryName,
userPoints: 0,
maxPoints: 0
};
}
categories[categoryId].userPoints += userPoints;
categories[categoryId].maxPoints += maxPoints;
}
// Overall percent
const overallPercentage = totalMaxPoints > 0
? (totalUserPoints / totalMaxPoints) * 100
: 0;
// Category percentages
const categoryWise: any = {};
for (const catId in categories) {
const c = categories[catId];
categoryWise[c.categoryName] =
c.maxPoints > 0 ? (c.userPoints / c.maxPoints) * 100 : 0;
}
return {
overallPercentage,
categoryWise
};
}
async createHeader(
activityXid: number,
pqqQuestionXid: number,

View File

@@ -485,82 +485,87 @@ export class MinglarService {
})
}
async getAllHostApplications(userId: number, userRoleXid: number, search?: string, userStatus?: string) {
// Build where clause based on user role
const whereClause: any = {
async getAllHostApplications(
userId: number,
userRoleXid: number,
search?: string,
userStatus?: string
) {
const filters: any = {
isActive: true,
user: {
roleXid: {
notIn: [ROLE.CO_ADMIN, ROLE.ACCOUNT_MANAGER]
},
}
}
};
// Add search filter if search query is provided
if (search && search.trim() !== '') {
const searchTerm = search.trim();
/** -----------------------------------
* SEARCH FILTER (ID, EMAIL, NAME)
* ----------------------------------- */
if (search?.trim()) {
const term = search.trim();
// Check if search term is a number (for ID search)
const isNumeric = /^\d+$/.test(searchTerm);
if (isNumeric) {
// Search by host ID
whereClause.id = parseInt(searchTerm);
if (/^\d+$/.test(term)) {
// Search by Host ID
filters.id = Number(term);
} else {
// Search by email or name
whereClause.user = {
...whereClause.user,
filters.user = {
...filters.user,
OR: [
{ emailAddress: { contains: searchTerm, mode: 'insensitive' } },
{ firstName: { contains: searchTerm, mode: 'insensitive' } },
{ lastName: { contains: searchTerm, mode: 'insensitive' } }
{ emailAddress: { contains: term, mode: "insensitive" } },
{ firstName: { contains: term, mode: "insensitive" } },
{ lastName: { contains: term, mode: "insensitive" } }
]
};
}
}
// Apply userStatus filter (case-insensitive) e.g. userStatus=new / NEW / New
if (userStatus && userStatus.trim().toLowerCase() === MINGLAR_STATUS_DISPLAY.NEW.toLowerCase()) {
whereClause.adminStatusDisplay = MINGLAR_STATUS_DISPLAY.NEW;
/** -----------------------------------
* USER STATUS FILTER (NEW)
* ----------------------------------- */
if (
userStatus &&
userStatus.trim().toLowerCase() === MINGLAR_STATUS_DISPLAY.NEW.toLowerCase()
) {
filters.adminStatusDisplay = MINGLAR_STATUS_DISPLAY.NEW;
}
/** -----------------------------------
* ROLE-BASED FILTER:
* CO_ADMIN & ACCOUNT_MANAGER only see assigned hosts
* ----------------------------------- */
if (userRoleXid === ROLE.CO_ADMIN || userRoleXid === ROLE.ACCOUNT_MANAGER) {
whereClause.accountManagerXid = userId;
filters.accountManagerXid = userId;
}
const hostHeaders = await this.prisma.hostHeader.findMany({
where: whereClause,
/** -----------------------------------
* MAIN QUERY
* ----------------------------------- */
const results = await this.prisma.hostHeader.findMany({
where: filters,
select: {
id: true,
hostStatusInternal: true,
hostStatusDisplay: true,
adminStatusDisplay: true,
adminStatusInternal: true,
createdAt: true,
companyName: true,
assignedOn: true,
cities: {
select: {
id: true,
cityName: true,
}
},
adminStatusDisplay: true,
countries: {
select: {
id: true,
countryName: true,
}
},
states: {
select: {
id: true,
stateName: true,
}
},
cities: { select: { id: true, cityName: true } },
states: { select: { id: true, stateName: true } },
countries: { select: { id: true, countryName: true } },
user: {
select: {
id: true,
firstName: true,
lastName: true,
emailAddress: true,
mobileNumber: true,
mobileNumber: true
}
},
accountManager: {
@@ -570,30 +575,34 @@ export class MinglarService {
lastName: true,
emailAddress: true,
mobileNumber: true,
roleXid: true,
roleXid: true
}
}
},
orderBy: {
createdAt: 'desc'
}
orderBy: { createdAt: "desc" }
});
// Transform the data to return host, hostStatusDisplay, submittedOn, and accountManager details
return hostHeaders.map(host => ({
hostId: host.id,
host: host.user,
hostStatusDisplay: host.hostStatusDisplay,
submittedOn: host.createdAt,
accountManager: host.accountManager || null,
companyName: host.companyName || null,
city: host.cities || null,
state: host.states || null,
country: host.countries || null,
assignedOn: host.assignedOn || null,
/** -----------------------------------
* TRANSFORM RESPONSE
* ----------------------------------- */
return results.map(h => ({
hostId: h.id,
host: h.user,
hostStatusDisplay: h.hostStatusDisplay,
hostStatusInternal: h.hostStatusInternal,
adminStatusDisplay: h.adminStatusDisplay,
adminStatusInternal: h.adminStatusInternal,
submittedOn: h.createdAt,
accountManager: h.accountManager || null,
companyName: h.companyName || null,
city: h.cities || null,
state: h.states || null,
country: h.countries || null,
assignedOn: h.assignedOn || null
}));
}
async getAllCoadminAndAM() {
// 1. Fetch all required users (Admin, Co-Admin, AM)
const users = await this.prisma.user.findMany({