// src/modules/host/services/host.service.ts import { Injectable } from '@nestjs/common'; import { PrismaClient, User } from '@prisma/client'; import AWS from 'aws-sdk'; import * as bcrypt from 'bcryptjs'; import dayjs from 'dayjs'; import { PDFDocument, StandardFonts } from 'pdf-lib'; import { z } from 'zod'; import { getPresignedUrl } from '../../../common/middlewares/aws/getPreSignedUrl'; import { AGREEMENT_TEMPLATE } from '../../../common/utils/constants/agreementTemplate'; import { RESTRICTION_NAME, ROLE, ROLE_NAME, USER_STATUS, } from '../../../common/utils/constants/common.constant'; import { ACTIVITY_AM_DISPLAY_STATUS, ACTIVITY_AM_INTERNAL_STATUS, ACTIVITY_DISPLAY_STATUS, ACTIVITY_INTERNAL_STATUS, HOST_STATUS_DISPLAY, HOST_STATUS_INTERNAL, STEPPER, } from '../../../common/utils/constants/host.constant'; import { ACTIVITY_TRACK_STATUS, ACTIVITY_TRACK_TYPE, MINGLAR_STATUS_DISPLAY, MINGLAR_STATUS_INTERNAL, } from '../../../common/utils/constants/minglar.constant'; import ApiError from '../../../common/utils/helper/ApiError'; import { hostCompanyDetailsSchema } from '../../../common/utils/validation/host/hostCompanyDetails.validation'; import config from '../../../config/config'; import { CreateActivityInput } from '../dto/createActivity.schema'; import { AddPaymentDetailsDTO, CreateHostDto, UpdateHostDto, } from '../dto/host.dto'; function sanitizeDocumentName(name?: string) { if (!name) return null; return name .replace(/[^a-zA-Z0-9 _-]/g, '') // remove / . .substring(0, 100); } type HostCompanyDetailsInput = z.infer; // Document input after S3 upload (with S3 URL as filePath) interface HostDocumentInput { documentTypeXid: number; documentName: string; filePath: string; // S3 URL } export async function generateActivityRefNumber( tx: any, hostXid: number, activityTypeXid: number, hostRefNumber: string ) { // 1️⃣ Get ActivityType with Interest const activityType = await tx.activityTypes.findUnique({ where: { id: activityTypeXid }, include: { interests: true, // relation is named "interests" in schema }, }); if (!activityType || !activityType.interests) { throw new Error("Invalid activity type or interest not found"); } // Use the Interest's ID from the relation const interestId = activityType.interests.id; const interestCode = activityType.interests.interestCode; // 2️⃣ Check if this host already has activities under this interest const existingActivityForInterest = await tx.activities.findFirst({ where: { hostXid, activityType: { is: { interestXid: interestId, }, }, }, select: { activityRefNumber: true, }, }); let interestSequence: number; if (existingActivityForInterest?.activityRefNumber) { // Extract existing interest sequence from ref number const match = existingActivityForInterest.activityRefNumber.match( new RegExp(`E-${interestCode}(\\d{3})-`) ); interestSequence = match ? parseInt(match[1], 10) : 1; } else { // Count distinct interests already used by this host const distinctInterests = await tx.activities.findMany({ where: { hostXid }, select: { activityType: { select: { interestXid: true, }, }, }, }); const uniqueInterestIds = new Set( distinctInterests .map((a: any) => a.activityType?.interestXid) .filter((id: number | null | undefined): id is number => id != null) ); interestSequence = uniqueInterestIds.size + 1; } // 3️⃣ Count activities for same host + same interest + same activityType const activityTypeCount = await tx.activities.count({ where: { hostXid, activityTypeXid, }, }); const nextActivityTypeSequence = activityTypeCount + 1; return `${hostRefNumber}-E-${interestCode}${String(interestSequence).padStart( 3, "0" )}-${String(nextActivityTypeSequence).padStart(2, "0")}`; } function round2(value: number) { return Math.round(value); } function computeBasePriceAndTaxes( sellPrice: number, taxes: Array<{ id: number; taxPer: number }>, ) { if (!taxes?.length) { return { basePrice: round2(sellPrice), taxDetails: [] as Array<{ taxXid: number; taxPer: number; taxAmount: number; }>, }; } const totalTaxPer = taxes.reduce( (sum, t) => sum + (Number(t.taxPer) || 0), 0, ); const denominator = 1 + totalTaxPer / 100; const basePrice = denominator > 0 ? round2(sellPrice / denominator) : round2(sellPrice); const taxDetails = taxes.map((t) => ({ taxXid: t.id, taxPer: t.taxPer, taxAmount: round2(basePrice * (t.taxPer / 100)), })); return { basePrice, taxDetails }; } const normalize = (v?: string | null) => v ? v.trim().toLowerCase() : null; async function renderAgreementPdf(vars: { effectiveDate: string; companyName: string; companyType?: string | null; fullAddress: string; durationText: string; expiryDate: string; commissionText: string; acceptDate: string; }) { const pdfDoc = await PDFDocument.create(); const font = await pdfDoc.embedFont(StandardFonts.Helvetica); const fontBold = await pdfDoc.embedFont(StandardFonts.HelveticaBold); const fontSize = 10; const titleSize = 14; const margin = 50; const { width, height } = { width: 595, height: 842 }; // A4 const contentWidth = width - 2 * margin; let page = pdfDoc.addPage([width, height]); let cursorY = height - margin; const addNewPage = () => { page = pdfDoc.addPage([width, height]); cursorY = height - margin; }; const drawLine = (text: string, isBold = false, size = fontSize) => { const currentFont = isBold ? fontBold : font; const words = text.split(' '); let line = ''; for (const word of words) { const testLine = line + word + ' '; const testLineWidth = currentFont.widthOfTextAtSize(testLine.trim(), size); if (testLineWidth > contentWidth && line !== '') { if (cursorY < margin + 20) addNewPage(); page.drawText(line.trim(), { x: margin, y: cursorY, size, font: currentFont }); cursorY -= size * 1.5; line = word + ' '; } else { line = testLine; } } if (line !== '') { if (cursorY < margin + 20) addNewPage(); page.drawText(line.trim(), { x: margin, y: cursorY, size, font: currentFont }); cursorY -= size * 1.5; } }; let template = AGREEMENT_TEMPLATE; template = template.replace(/\[EFFECTIVE_DATE\]/g, vars.effectiveDate); template = template.replace(/\[HOST_LEGAL_NAME\]/g, vars.companyName); template = template.replace(/\[COMPANY_TYPE\]/g, vars.companyType || 'Entity'); template = template.replace(/\[FULL_ADDRESS\]/g, vars.fullAddress); template = template.replace(/\[DURATION_TEXT\]/g, vars.durationText); template = template.replace(/\[EXPIRY_DATE\]/g, vars.expiryDate); template = template.replace(/\[COMMISSION_TEXT\]/g, vars.commissionText); template = template.replace(/\[ACCEPT_DATE\]/g, vars.acceptDate); const lines = template.split('\n'); for (const line of lines) { const trimmed = line.trim(); if (!trimmed) { cursorY -= fontSize; // Paragraph spacing continue; } // Heuristic for titles/headers const isTitle = trimmed === trimmed.toUpperCase() && trimmed.length > 5; const isSubHeader = /^\d+\.?\s/.test(trimmed) || /^[A-Z]\)\s/.test(trimmed); drawLine(trimmed, isTitle || isSubHeader, isTitle ? titleSize : fontSize); } const pdfBytes = await pdfDoc.save(); return Buffer.from(pdfBytes); } function buildDurationText(host: any) { if (!host.durationNumber || !host.durationFrequency) return 'N/A'; return `${host.durationNumber} ${host.durationFrequency}`; } function buildExpiryDate(host: any) { if (!host.agreementStartDate || !host.durationNumber || !host.durationFrequency) return 'N/A'; const start = dayjs(host.agreementStartDate); const duration = host.durationNumber; // Handle frequency units for dayjs (months/years) const unit = host.durationFrequency.toLowerCase().includes('month') ? 'month' : 'year'; return start.add(duration, unit as any).format('DD-MMM-YY'); } function buildCommissionText(host: any) { if (host.isCommisionBase) { return `${host.commisionPer || 0}%`; } else { return `INR ${host.amountPerBooking || 0} per booking`; } } function buildFullAddress(host: any) { const parts = [ host.address1, host.address2, host.cities?.cityName, host.states?.stateName, host.countries?.countryName, host.pinCode, ].filter(Boolean); return parts.join(', '); } // generateAgreementPdfBuffer and generateAgreementDocxBuffer removed // End of agreement rendering logic const findOrCreateCountry = async ( tx: any, countryName?: string | null, ) => { if (!countryName) return null; const name = normalize(countryName); let country = await tx.countries.findFirst({ where: { countryName: { equals: name, mode: 'insensitive' }, isActive: true, }, }); if (!country) { country = await tx.countries.create({ data: { countryName: countryName.trim(), countryCode: countryName .substring(0, 2) .toUpperCase(), // fallback countryFlag: '', }, }); } return country.id; }; const findOrCreateState = async ( tx: any, stateName?: string | null, countryXid?: number | null, ) => { if (!stateName || !countryXid) return null; const state = await tx.states.findFirst({ where: { stateName: { equals: stateName.trim(), mode: 'insensitive' }, countryXid, isActive: true, }, }); if (state) return state.id; const created = await tx.states.create({ data: { stateName: stateName.trim(), countryXid, }, }); return created.id; }; const findOrCreateCity = async ( tx: any, cityName?: string | null, stateXid?: number | null, ) => { if (!cityName || !stateXid) return null; const city = await tx.cities.findFirst({ where: { cityName: { equals: cityName.trim(), mode: 'insensitive' }, stateXid, isActive: true, }, }); if (city) return city.id; const created = await tx.cities.create({ data: { cityName: cityName.trim(), stateXid, }, }); return created.id; }; const bucket = config.aws.bucketName; const s3 = new AWS.S3({ region: config.aws.region, }); type UpdateHostProfileInput = { firstName?: string; lastName?: string | null; isdCode?: string; mobileNumber?: string; dateOfBirth?: Date; address?: { address1?: string; address2?: string; countryXid?: number; stateXid?: number; cityXid?: number; pinCode?: string; }; }; @Injectable() export class HostService { constructor(private prisma: PrismaClient) { } async createHost(data: CreateHostDto) { return this.prisma.user.create({ data }); } async getAllHosts() { return this.prisma.user.findMany({ where: { roleXid: 3 } }); } async getActivityDetailsById(activityXid: number) { return this.prisma.activities.findFirst({ where: { id: activityXid } }); } async getHostIdByUserXid(user_xid: number) { const host = await this.prisma.hostHeader.findFirst({ where: { userXid: user_xid }, select: { id: true, stepper: true }, }); const user = await this.prisma.user.findUnique({ where: { id: user_xid, isActive: true }, select: { id: true, emailAddress: true, userRefNumber: true }, }); return { host, user }; } async getHostById(id: number) { const host = await this.prisma.hostHeader.findFirst({ where: { userXid: id }, include: { hostParent: { include: { HostParenetDocuments: { select: { id: true, filePath: true, documentName: true, documentTypeXid: true, documentType: true, }, }, }, }, HostBankDetails: true, HostDocuments: { include: { documentType: true, }, }, accountManager: { select: { id: true, firstName: true, lastName: true, emailAddress: true, mobileNumber: true, profileImage: true, userRefNumber: true, }, }, user: { select: { id: true, emailAddress: true, firstName: true, lastName: true, mobileNumber: true, profileImage: true, userStatus: true, userRefNumber: true, userAddressDetails: { where: { isActive: true }, select: { id: true, address1: true, address2: true, locationAddress: true, locationLat: true, locationLong: true, pinCode: true, cityXid: true, cities: { select: { id: true, cityName: true, } }, stateXid: true, states: { select: { id: true, stateName: true, } }, countryXid: true, country:{ select: { id: true, countryName: true } } } } }, }, companyTypes: { select: { id: true, companyTypeName: true, }, }, HostSuggestion: { where: { isActive: true, isreviewed: false, }, select: { id: true, hostXid: true, title: true, comments: true, isparent: true, }, }, countries: true, currencies: true, states: true, cities: true, }, }); if (!host) { // If host record doesn't exist yet, return stepper 1 (NOT_SUBMITTED) // so callers (like the stepper endpoint) can show initial step. return { stepper: STEPPER.NOT_SUBMITTED } as any; } if (host.HostDocuments?.length) { for (const doc of host.HostDocuments) { if (doc.filePath) { const filePath = doc.filePath; // If full URL is saved, extract only key const key = filePath.startsWith('http') ? filePath.split('.com/')[1] : filePath; (doc as any).presignedUrl = await getPresignedUrl(bucket, key); } } } if (host.user?.profileImage) { const key = host.user.profileImage.startsWith('http') ? host.user.profileImage.split('.com/')[1] : host.user.profileImage; host.user.profileImage = await getPresignedUrl(bucket, key); } if (host?.logoPath) { const key = host.logoPath.startsWith('http') ? host.logoPath.split('.com/')[1] : host.logoPath; host.logoPath = await getPresignedUrl(bucket, key); } if (host.accountManager?.profileImage) { const key = host.accountManager.profileImage.startsWith('http') ? host.accountManager.profileImage.split('.com/')[1] : host.accountManager.profileImage; host.accountManager.profileImage = await getPresignedUrl(bucket, key); } if (host.hostParent?.length) { const parent = host.hostParent[0]; // since you allow only 1 parent // Parent company logo if (parent.logoPath) { const key = parent.logoPath.startsWith('http') ? parent.logoPath.split('.com/')[1] : parent.logoPath; parent.logoPath = await getPresignedUrl(bucket, key); } // Parent documents if (parent.HostParenetDocuments?.length) { for (const doc of parent.HostParenetDocuments) { if (doc.filePath) { const key = doc.filePath.startsWith('http') ? doc.filePath.split('.com/')[1] : doc.filePath; (doc as any).presignedUrl = await getPresignedUrl(bucket, key); } } } } return host; } async updateHost(id: number, data: UpdateHostDto) { return this.prisma.user.update({ where: { id }, data, }); } async deleteHost(id: number) { return this.prisma.user.delete({ where: { id } }); } /** * Update the logged-in Host's personal profile details. * Email is intentionally NOT editable here. */ async updateHostProfileDetails(userId: number, input: UpdateHostProfileInput) { return this.prisma.$transaction(async (tx) => { const user = await tx.user.findUnique({ where: { id: userId, isActive: true }, select: { id: true, roleXid: true }, }); if (!user) throw new ApiError(404, 'User not found'); if (user.roleXid !== ROLE.HOST) throw new ApiError(403, 'Access denied.'); // 1) Update `User` (whitelist only) const userUpdateData: any = {}; if (input.firstName !== undefined) userUpdateData.firstName = input.firstName || null; if (input.lastName !== undefined) userUpdateData.lastName = input.lastName; if (input.isdCode !== undefined) userUpdateData.isdCode = input.isdCode || null; if (input.mobileNumber !== undefined) userUpdateData.mobileNumber = input.mobileNumber || null; if (input.dateOfBirth !== undefined) userUpdateData.dateOfBirth = input.dateOfBirth; if (Object.keys(userUpdateData).length > 0) { await tx.user.update({ where: { id: userId }, data: { ...userUpdateData, isProfileUpdated: true, }, }); } // 2) Update/Create `UserAddressDetails` (if any address field sent) const addressData = input.address || {}; const hasAnyAddressField = Object.values(addressData).some((v) => v !== undefined); if (hasAnyAddressField) { const existingAddress = await tx.userAddressDetails.findFirst({ where: { userXid: userId, isActive: true }, select: { id: true }, }); const addressUpdateData: any = {}; if (addressData.address1 !== undefined) addressUpdateData.address1 = addressData.address1; if (addressData.address2 !== undefined) addressUpdateData.address2 = addressData.address2; if (addressData.countryXid !== undefined) addressUpdateData.countryXid = addressData.countryXid; if (addressData.stateXid !== undefined) addressUpdateData.stateXid = addressData.stateXid; if (addressData.cityXid !== undefined) addressUpdateData.cityXid = addressData.cityXid; if (addressData.pinCode !== undefined) addressUpdateData.pinCode = addressData.pinCode; if (existingAddress) { await tx.userAddressDetails.update({ where: { id: existingAddress.id }, data: addressUpdateData, }); } else { const required = ['address1', 'countryXid', 'stateXid', 'cityXid', 'pinCode'] as const; const missing = required.filter((k) => addressData[k] === undefined); if (missing.length) { throw new ApiError(400, `Missing required address fields: ${missing.join(', ')}`); } await tx.userAddressDetails.create({ data: { userXid: userId, ...addressUpdateData, }, }); } } // 3) Return updated profile snapshot (including read-only email) const updated = await tx.user.findUnique({ where: { id: userId }, select: { id: true, firstName: true, lastName: true, emailAddress: true, isdCode: true, mobileNumber: true, dateOfBirth: true, profileImage: true, isProfileUpdated: true, userAddressDetails: { where: { isActive: true }, take: 1, select: { id: true, address1: true, address2: true, countryXid: true, stateXid: true, cityXid: true, pinCode: true, }, }, }, }); return { user: updated, address: updated?.userAddressDetails?.[0] ?? null, }; }); } async getHostByEmail(email: string): Promise { return this.prisma.user.findUnique({ where: { emailAddress: email } }); } async verifyHostOtp(email: string, otp: string): Promise { const trimmedOtp = (otp || '').toString().trim(); const user = await this.prisma.user.findFirst({ where: { emailAddress: email, isActive: true }, select: { id: true, emailAddress: true, UserOtp: { where: { isActive: true, isVerified: false }, orderBy: { createdAt: 'desc' }, take: 1, }, }, }); if (!user) { throw new ApiError(404, 'User not found.'); } const userOtp = user.UserOtp[0]; if (!userOtp) { throw new ApiError(400, 'No OTP found.'); } if (new Date() > userOtp.expiresOn) { throw new ApiError(400, 'OTP has expired.'); } const isMatch = await bcrypt.compare(trimmedOtp, userOtp.otpCode); if (!isMatch) { throw new ApiError(400, 'Invalid OTP.'); } await this.prisma.userOtp.update({ where: { id: userOtp.id }, data: { isVerified: true, verifiedOn: new Date(), isActive: false, }, }); return true; } async loginForHost(emailAddress: string, userPassword: string) { const existingUser = await this.prisma.user.findUnique({ where: { emailAddress: emailAddress, isActive: true }, select: { id: true, roleXid: true, firstName: true, lastName: true, emailAddress: true, mobileNumber: true, userPassword: true, userStatus: true, }, }); if (!existingUser) { throw new ApiError(404, 'User not found'); } if (existingUser.userStatus == USER_STATUS.REJECTED) { throw new ApiError( 403, 'You are not allowed to login. Please contact minglar admin.', ); } if (existingUser.roleXid !== 4) { throw new ApiError(403, 'Access denied. Not a host user.'); } const matchPassword = await bcrypt.compare( userPassword, existingUser.userPassword, ); if (!matchPassword) { throw new ApiError(401, 'Invalid credentials'); } return existingUser; } async createMinglarUser(email: string) { const newUser = await this.prisma.user.create({ data: { emailAddress: email, roleXid: ROLE.HOST }, }); return newUser; } async createPassword(user_xid: number, password: string): Promise { // Find user by id const user = await this.prisma.user.findUnique({ where: { id: user_xid }, select: { id: true, emailAddress: true, userPassword: true }, }); if (!user) { throw new ApiError(404, 'User not found'); } // Check if password already exists if (user.userPassword) { throw new ApiError( 400, 'Password already exists. Use update password instead.', ); } // Hash the password const saltRounds = parseInt(process.env.SALT_ROUNDS || '10', 10); const hashedPassword = await bcrypt.hash(password, saltRounds); // Update user with hashed password await this.prisma.user.update({ where: { id: user.id }, data: { userPassword: hashedPassword, isEmailVerfied: true, userStatus: USER_STATUS.ACTIVE, }, }); return true; } async getBankBranchById(bankBranchXid: number) { return await this.prisma.bankBranches.findUnique({ where: { id: bankBranchXid }, select: { id: true, ifscCode: true, bankXid: true, }, }); } async addPaymentDetails(data: AddPaymentDetailsDTO) { return await this.prisma.$transaction(async (tx) => { const existingAccount = await tx.hostBankDetails.findFirst({ where: { accountNumber: data.accountNumber, isActive: true, }, }); if (existingAccount) { throw new ApiError( 400, 'Host account with this account number already exists.', ); } const addedPaymentDetails = await tx.hostBankDetails.create({ data, }); if (!addedPaymentDetails) { throw new ApiError(400, 'Failed to add payment details'); } await tx.hostHeader.update({ where: { id: data.hostXid }, data: { stepper: STEPPER.BANK_DETAILS_UPDATED, currencyXid: data.currencyXid, }, }); }); } async getAllHostActivity( search?: string, user_xid?: number, paginationOptions?: { page: number; limit: number; skip: number }, ) { const hostDetails = await this.prisma.hostHeader.findFirst({ where: { userXid: user_xid, isActive: true }, }); const whereClause: any = { isActive: true, hostXid: hostDetails.id, }; if (!hostDetails) { return { data: [], total: 0, page: paginationOptions?.page || 1, limit: paginationOptions?.limit || 10, }; } // 🔍 SEARCH (fixed) if (search?.trim()) { const term = search.trim(); whereClause.OR = [ { activityRefNumber: { contains: term, mode: 'insensitive' } }, { activityTitle: { contains: term, mode: 'insensitive' } }, { activityType: { activityTypeName: { contains: term, mode: 'insensitive' }, }, }, ]; } const [hostAllActivities, totalCount] = await Promise.all([ this.prisma.activities.findMany({ where: whereClause, select: { id: true, activityRefNumber: true, activityTitle: true, totalScore: true, activityInternalStatus: true, activityDisplayStatus: true, amInternalStatus: true, amDisplayStatus: true, createdAt: true, checkInAddress: true, frequency: { select: { id: true, frequencyName: true, }, }, ActivityAmDetails: { select: { accountManager: { select: { id: true, firstName: true, lastName: true, profileImage: true, emailAddress: true, roleXid: true, }, }, }, }, activityType: { select: { id: true, activityTypeName: true, interests: { select: { id: true, interestName: true, }, }, energyLevel: { select: { id: true, energyLevelName: true, energyIcon: true, energyColor: true, }, }, }, }, }, skip: paginationOptions?.skip || 0, take: paginationOptions?.limit || 10, orderBy: { id: 'desc' }, }), this.prisma.activities.count({ where: whereClause }), ]); for (const activity of hostAllActivities) { /** 2️⃣ Process AM Profile Image */ const am = activity.ActivityAmDetails?.[0]?.accountManager; if (am?.profileImage) { const key = am.profileImage.startsWith('http') ? am.profileImage.split('.com/')[1] : am.profileImage; const presignedUrl = await getPresignedUrl(bucket, key); activity.ActivityAmDetails[0].accountManager = { ...am, profileImage: presignedUrl, }; } } const { paginationService, } = require('@/common/utils/pagination/pagination.service'); return paginationService.createPaginatedResponse( hostAllActivities, totalCount, paginationOptions || { page: 1, limit: 10, skip: 0 }, ); } async acceptMinglarAgreement(user_xid: number) { const host = await this.prisma.hostHeader.findFirst({ where: { userXid: user_xid, isActive: true }, include: { cities: true, states: true, countries: true, companyTypes: true, }, }); if (!host) { throw new ApiError(404, 'Host not found for this user'); } const effectiveDate = host.agreementStartDate ? dayjs(host.agreementStartDate).format('DD-MMM-YY') : dayjs().format('DD-MMM-YY'); const durationText = buildDurationText(host); const expiryDate = buildExpiryDate(host); const commissionText = buildCommissionText(host); const fullAddress = buildFullAddress(host); const acceptDate = dayjs().format('DD-MMM-YYYY'); const agreementVars = { effectiveDate, companyName: host.companyName, companyType: host.companyTypes?.companyTypeName, fullAddress, durationText, expiryDate, commissionText, acceptDate, }; let pdfUrl: string | null = null; try { const pdfBuffer = await renderAgreementPdf(agreementVars); const existingCount = await this.prisma.hostAgreement.count({ where: { hostXid: host.id, isActive: true }, }); const nextVersionNumber = `AG${existingCount + 1}`; const baseKey = `Documents/Host/${host.id}/agreements/${nextVersionNumber}`; const pdfKey = `${baseKey}.pdf`; await s3 .upload({ Bucket: config.aws.bucketName, Key: pdfKey, Body: pdfBuffer, ContentType: 'application/pdf', ACL: 'private', }) .promise(); pdfUrl = `https://${config.aws.bucketName}.s3.${config.aws.region}.amazonaws.com/${pdfKey}`; } catch (error) { console.error('Error generating or uploading PDF:', error); // Continue without PDF - will return dynamic fields instead } try { const existingCount = await this.prisma.hostAgreement.count({ where: { hostXid: host.id, isActive: true }, }); const nextVersionNumber = `AG${existingCount + 1}`; await this.prisma.$transaction(async (tx) => { // Optional: mark previous agreements inactive await tx.hostAgreement.updateMany({ where: { hostXid: host.id, isActive: true }, data: { isActive: false }, }); await tx.hostAgreement.create({ data: { hostXid: host.id, filePath: pdfUrl, versionNumber: nextVersionNumber, isActive: true, }, }); await tx.hostHeader.update({ where: { id: host.id }, data: { stepper: STEPPER.AGREEMENT_ACCEPTED, isApproved: true, agreementAccepted: true, agreementStartDate: host.agreementStartDate || new Date(), }, }); }); } catch (error) { console.error('Error creating host agreement record:', error); // Continue without creating agreement record - will return dynamic fields instead } // Return dynamic fields and PDF URL return { filePath: pdfUrl, dynamicFields: agreementVars, }; } /** * Get the latest (active) agreement for a specific host by hostXid. */ async getLatestHostAgreement(hostXid: number) { if (!hostXid || Number.isNaN(hostXid)) { throw new ApiError(400, 'Valid hostXid is required'); } const agreement = await this.prisma.hostAgreement.findFirst({ where: { hostXid, isActive: true }, orderBy: { createdAt: 'desc' }, select: { id: true, hostXid: true, filePath: true, versionNumber: true, createdAt: true, updatedAt: true, }, }); if (!agreement) { throw new ApiError(404, 'No active agreement found for this host'); } const filePath = agreement.filePath; // If full URL is saved, extract only S3 key part const key = filePath.startsWith('http') ? filePath.split('.com/')[1] : filePath; const bucket = config.aws.bucketName; const presignedUrl = await getPresignedUrl(bucket, key); return { ...agreement, presignedUrl, }; } async getPQQQuestionDetail(question_xid: number, activity_xid: number) { const detailsOfQuestion = await this.prisma.activityPQQheader.findFirst({ where: { activityXid: activity_xid, pqqQuestionXid: question_xid, isActive: true, }, select: { pqqQuestionXid: true, pqqAnswerXid: true, ActivityPQQSupportings: { select: { id: true, activityPqqHeaderXid: true, mediaFileName: true, mediaType: true, }, }, ActivityPQQSuggestions: { where: { isActive: true, isReviewed: false }, select: { id: true, title: true, comments: true, }, }, }, }); if (detailsOfQuestion.ActivityPQQSupportings?.length) { for (const doc of detailsOfQuestion.ActivityPQQSupportings) { if (doc.mediaFileName) { const filePath = doc.mediaFileName; // If full URL is saved, extract only key const key = filePath.startsWith('http') ? filePath.split('.com/')[1] : filePath; (doc as any).presignedUrl = await getPresignedUrl(bucket, key); } } } return detailsOfQuestion; } async getLatestQuestionDetailsPQQ(activity_xid: number) { return await this.prisma.activityPQQheader.findFirst({ where: { activityXid: activity_xid, isActive: true, pqqAnswerXid: { not: null, }, }, select: { pqqQuestionXid: true, pqqAnswerXid: true, pqqQuestions: { select: { pqqSubCategoryXid: true, pqqSubCategories: { select: { categoryXid: true, }, }, }, }, }, orderBy: { id: 'desc' }, }); } async getParentDocumentsByHostId(userId: number) { const host = await this.prisma.hostHeader.findFirst({ where: { userXid: userId }, select: { id: true }, }); if (!host) return []; const parents = await this.prisma.hostParent.findMany({ where: { hostXid: host.id }, include: { HostParenetDocuments: true }, }); return parents.flatMap((p) => p.HostParenetDocuments); } async deleteExistingParentRecords(userId: number) { const host = await this.prisma.hostHeader.findFirst({ where: { userXid: userId }, select: { id: true }, }); if (!host) return; const parents = await this.prisma.hostParent.findMany({ where: { hostXid: host.id }, select: { id: true }, }); if (!parents.length) return; const parentIds = parents.map((p) => p.id); // 1️⃣ Delete documents first await this.prisma.hostParenetDocuments.deleteMany({ where: { hostParentXid: { in: parentIds } }, }); // 2️⃣ Then delete parent records await this.prisma.hostParent.deleteMany({ where: { id: { in: parentIds } }, }); } async addOrUpdateCompanyDetails( user_xid: number, companyData: HostCompanyDetailsInput, documents: HostDocumentInput[], parentCompanyData?: any | null, parentDocuments?: HostDocumentInput[], isDraft: boolean = false, ) { return await this.prisma.$transaction(async (tx) => { // Check if host already has a company const existingHostCompany = await tx.hostHeader.findFirst({ where: { userXid: user_xid }, include: { hostParent: true }, }); console.log(existingHostCompany, '-: Existing hai'); let existingParentCompany; if (existingHostCompany) { existingParentCompany = await tx.hostParent.findFirst({ where: { hostXid: existingHostCompany.id }, select: { id: true, logoPath: true, }, }); } let hostStatusInternal; let hostStatusDisplay; let minglarStatusInternal; let minglarStatusDisplay; if (existingHostCompany) { hostStatusInternal = existingHostCompany.hostStatusInternal; hostStatusDisplay = existingHostCompany.hostStatusDisplay; minglarStatusInternal = existingHostCompany.adminStatusInternal; minglarStatusDisplay = existingHostCompany.adminStatusDisplay; } // CASE 1: Host was asked to update AND is submitting final if ( existingHostCompany && existingHostCompany.hostStatusInternal === HOST_STATUS_INTERNAL.HOST_TO_UPDATE && !isDraft ) { hostStatusInternal = HOST_STATUS_INTERNAL.HOST_SUBMITTED; hostStatusDisplay = HOST_STATUS_DISPLAY.UNDER_REVIEW; minglarStatusInternal = MINGLAR_STATUS_INTERNAL.AM_TO_REVIEW; minglarStatusDisplay = MINGLAR_STATUS_DISPLAY.TO_REVIEW; } // CASE 2: Admin has rejected but host can resubmit else if ( existingHostCompany && existingHostCompany.hostStatusInternal === HOST_STATUS_INTERNAL.REJECTED && !isDraft ) { hostStatusInternal = HOST_STATUS_INTERNAL.HOST_SUBMITTED; hostStatusDisplay = HOST_STATUS_DISPLAY.UNDER_REVIEW; minglarStatusInternal = MINGLAR_STATUS_INTERNAL.ADMIN_TO_REVIEW; minglarStatusDisplay = MINGLAR_STATUS_DISPLAY.RE_SUBMITTED; } // CASE 2: Host was asked to update BUT saving draft else if ( existingHostCompany && existingHostCompany.hostStatusInternal === HOST_STATUS_INTERNAL.HOST_TO_UPDATE && isDraft ) { // keep original hostStatusInternal = existingHostCompany.hostStatusInternal; hostStatusDisplay = existingHostCompany.hostStatusDisplay; minglarStatusInternal = existingHostCompany.adminStatusInternal; minglarStatusDisplay = existingHostCompany.adminStatusDisplay; } // CASE 3: Normal create or update else { hostStatusInternal = isDraft ? HOST_STATUS_INTERNAL.DRAFT : HOST_STATUS_INTERNAL.HOST_SUBMITTED; hostStatusDisplay = isDraft ? HOST_STATUS_DISPLAY.DRAFT : HOST_STATUS_DISPLAY.UNDER_REVIEW; minglarStatusInternal = isDraft ? MINGLAR_STATUS_INTERNAL.DRAFT : MINGLAR_STATUS_INTERNAL.ADMIN_TO_REVIEW; minglarStatusDisplay = isDraft ? MINGLAR_STATUS_DISPLAY.DRAFT : MINGLAR_STATUS_DISPLAY.NEW; } const stepper = isDraft ? STEPPER.NOT_SUBMITTED : STEPPER.UNDER_REVIEW; // ------------------------------------------------------- // CREATE FLOW // ------------------------------------------------------- if (!existingHostCompany) { if (!isDraft) { console.log('First time direct final submit.'); const existingByPan = await tx.hostHeader.findFirst({ where: { panNumber: companyData.panNumber }, }); if (existingByPan) throw new ApiError( 400, 'Company already exists with this pan/bin number', ); } console.log('First Time Aaya hai'); const createdHost = await tx.hostHeader.create({ data: { user: { connect: { id: user_xid } }, companyName: companyData.companyName, address1: companyData.address1, address2: companyData.address2, cities: companyData.cityXid ? { connect: { id: companyData.cityXid } } : undefined, states: companyData.stateXid ? { connect: { id: companyData.stateXid } } : undefined, countries: companyData.countryXid ? { connect: { id: companyData.countryXid } } : undefined, pinCode: companyData.pinCode, logoPath: companyData.logoPath || null, isSubsidairy: companyData.isSubsidairy, registrationNumber: companyData.registrationNumber, panNumber: companyData.panNumber, gstNumber: companyData.gstNumber || null, formationDate: companyData.formationDate ? new Date(companyData.formationDate as any) : null, companyTypes: companyData.companyTypeXid ? { connect: { id: companyData.companyTypeXid } } : undefined, referencedBy: companyData.referencedBy || null, websiteUrl: companyData.websiteUrl || null, instagramUrl: companyData.instagramUrl || null, facebookUrl: companyData.facebookUrl || null, linkedinUrl: companyData.linkedinUrl || null, twitterUrl: companyData.twitterUrl || null, stepper, hostStatusInternal, hostStatusDisplay, adminStatusInternal: minglarStatusInternal, adminStatusDisplay: minglarStatusDisplay, }, }); // host documents if (documents?.length) { const docsData = documents.map((doc) => ({ hostXid: createdHost.id, documentTypeXid: doc.documentTypeXid, documentName: sanitizeDocumentName(doc.documentName), filePath: doc.filePath, })); await tx.hostDocuments.createMany({ data: docsData }); } // parent create if (companyData.isSubsidairy && parentCompanyData) { console.log('Parent ke saath aaya hai first time.'); const createdParent = await tx.hostParent.create({ data: { host: { connect: { id: createdHost.id } }, companyName: parentCompanyData.companyName || null, address1: parentCompanyData.address1 || null, address2: parentCompanyData.address2 || null, // Safely handle city connection - only connect if valid ID exists cities: parentCompanyData?.cityXid && !isNaN(Number(parentCompanyData.cityXid)) ? { connect: { id: Number(parentCompanyData.cityXid) } } : undefined, states: parentCompanyData?.stateXid && !isNaN(Number(parentCompanyData.stateXid)) ? { connect: { id: Number(parentCompanyData.stateXid) } } : undefined, countries: parentCompanyData?.countryXid && !isNaN(Number(parentCompanyData.countryXid)) ? { connect: { id: Number(parentCompanyData.countryXid) } } : undefined, pinCode: parentCompanyData.pinCode || null, logoPath: parentCompanyData.logoPath || null, registrationNumber: parentCompanyData.registrationNumber || null, panNumber: parentCompanyData.panNumber || null, gstNumber: parentCompanyData.gstNumber || null, formationDate: parentCompanyData.formationDate ? new Date(parentCompanyData.formationDate as any) : null, companyTypes: parentCompanyData.companyTypeXid ? { connect: { id: parentCompanyData.companyTypeXid } } : undefined, websiteUrl: parentCompanyData.websiteUrl || null, instagramUrl: parentCompanyData.instagramUrl || null, facebookUrl: parentCompanyData.facebookUrl || null, linkedinUrl: parentCompanyData.linkedinUrl || null, twitterUrl: parentCompanyData.twitterUrl || null, }, }); // parent docs if (parentDocuments?.length) { const parentDocsData = parentDocuments.map((doc) => ({ hostParentXid: createdParent.id, documentTypeXid: doc.documentTypeXid, documentName: sanitizeDocumentName(doc.documentName), filePath: doc.filePath, })); await tx.hostParenetDocuments.createMany({ data: parentDocsData }); } } // ⭐ FIX — TRACK USING createdHost (no null risk) await tx.hostTrack.create({ data: { hostXid: createdHost.id, updatedByRole: ROLE_NAME.HOST, updatedByXid: user_xid, trackStatus: createdHost.hostStatusInternal, }, }); return createdHost; } // ------------------------------------------------------- // UPDATE FLOW // ------------------------------------------------------- const updatedHost = await tx.hostHeader.update({ where: { id: existingHostCompany.id }, data: { companyName: companyData.companyName, address1: companyData.address1, address2: companyData.address2, // Safely handle city connection - only connect if valid ID exists cities: companyData.cityXid && !isNaN(Number(companyData.cityXid)) ? { connect: { id: Number(companyData.cityXid) } } : undefined, // Don't change if not provided // Same for state states: companyData.stateXid && !isNaN(Number(companyData.stateXid)) ? { connect: { id: Number(companyData.stateXid) } } : undefined, // Same for country countries: companyData.countryXid && !isNaN(Number(companyData.countryXid)) ? { connect: { id: Number(companyData.countryXid) } } : undefined, pinCode: companyData.pinCode, logoPath: companyData.logoPath || existingHostCompany.logoPath, isSubsidairy: companyData.isSubsidairy, registrationNumber: companyData.registrationNumber, panNumber: companyData.panNumber, gstNumber: companyData.gstNumber || null, formationDate: companyData.formationDate ? new Date(companyData.formationDate as any) : null, companyTypes: companyData.companyTypeXid ? { connect: { id: companyData.companyTypeXid } } : undefined, referencedBy: companyData.referencedBy || null, websiteUrl: companyData.websiteUrl || null, instagramUrl: companyData.instagramUrl || null, facebookUrl: companyData.facebookUrl || null, linkedinUrl: companyData.linkedinUrl || null, twitterUrl: companyData.twitterUrl || null, stepper, hostStatusInternal, hostStatusDisplay, adminStatusInternal: minglarStatusInternal, adminStatusDisplay: minglarStatusDisplay, }, }); // // documents UPSERT // if (documents?.length) { // for (const doc of documents) { // if (!doc.filePath) continue; // const existingDoc = await tx.hostDocuments.findFirst({ // where: { // hostXid: updatedHost.id, // documentTypeXid: doc.documentTypeXid, // }, // }); // if (existingDoc) { // await tx.hostDocuments.update({ // where: { id: existingDoc.id }, // data: { // filePath: doc.filePath, // documentName: // sanitizeDocumentName(doc.documentName) || // existingDoc.documentName, // }, // }); // } else { // await tx.hostDocuments.create({ // data: { // hostXid: updatedHost.id, // documentTypeXid: doc.documentTypeXid, // documentName: sanitizeDocumentName(doc.documentName), // filePath: doc.filePath, // }, // }); // } // } // } // documents handling (FINAL FIX) if (documents?.length) { for (const doc of documents) { if (!doc.filePath) continue; // 🔹 CUSTOM DOCUMENTS → ALWAYS CREATE if (doc.documentTypeXid === 9) { await tx.hostDocuments.create({ data: { hostXid: updatedHost.id, documentTypeXid: doc.documentTypeXid, documentName: sanitizeDocumentName(doc.documentName), filePath: doc.filePath, }, }); continue; } // 🔹 NORMAL DOCUMENTS → UPSERT (ONE PER TYPE) const existingDoc = await tx.hostDocuments.findFirst({ where: { hostXid: updatedHost.id, documentTypeXid: doc.documentTypeXid, }, }); if (existingDoc) { await tx.hostDocuments.update({ where: { id: existingDoc.id }, data: { filePath: doc.filePath, documentName: sanitizeDocumentName(doc.documentName) || existingDoc.documentName, }, }); } else { await tx.hostDocuments.create({ data: { hostXid: updatedHost.id, documentTypeXid: doc.documentTypeXid, documentName: sanitizeDocumentName(doc.documentName), filePath: doc.filePath, }, }); } } } // parent logic untouched if (companyData.isSubsidairy) { const parentRecords = existingHostCompany.hostParent; const parentRecord = Array.isArray(parentRecords) ? parentRecords[0] : parentRecords; console.log('Yaha aaya update in the apretn me'); if (!parentRecord) { console.log('Parent record nahi mila to create kar raha hai.'); const createdParent = await tx.hostParent.create({ data: { host: { connect: { id: updatedHost.id } }, companyName: parentCompanyData.companyName || null, address1: parentCompanyData.address1 || null, address2: parentCompanyData.address2 || null, cities: parentCompanyData?.cityXid && !isNaN(Number(parentCompanyData.cityXid)) ? { connect: { id: Number(parentCompanyData.cityXid) } } : undefined, states: parentCompanyData?.stateXid && !isNaN(Number(parentCompanyData.stateXid)) ? { connect: { id: Number(parentCompanyData.stateXid) } } : undefined, countries: parentCompanyData?.countryXid && !isNaN(Number(parentCompanyData.countryXid)) ? { connect: { id: Number(parentCompanyData.countryXid) } } : undefined, pinCode: parentCompanyData.pinCode || null, logoPath: parentCompanyData?.logoPath || existingParentCompany?.logoPath || null, registrationNumber: parentCompanyData.registrationNumber || null, panNumber: parentCompanyData.panNumber || null, gstNumber: parentCompanyData.gstNumber || null, formationDate: parentCompanyData.formationDate ? new Date(parentCompanyData.formationDate as any) : null, companyTypes: parentCompanyData.companyTypeXid ? { connect: { id: parentCompanyData.companyTypeXid } } : undefined, websiteUrl: parentCompanyData.websiteUrl || null, instagramUrl: parentCompanyData.instagramUrl || null, facebookUrl: parentCompanyData.facebookUrl || null, linkedinUrl: parentCompanyData.linkedinUrl || null, twitterUrl: parentCompanyData.twitterUrl || null, }, }); if (parentDocuments?.length) { for (const doc of parentDocuments) { await tx.hostParenetDocuments.create({ data: { hostParentXid: createdParent.id, documentTypeXid: doc.documentTypeXid, documentName: sanitizeDocumentName(doc.documentName), filePath: doc.filePath, }, }); } } } else { await tx.hostParent.update({ where: { id: parentRecord.id }, data: { companyName: parentCompanyData.companyName || null, address1: parentCompanyData.address1 || null, address2: parentCompanyData.address2 || null, cities: parentCompanyData?.cityXid && !isNaN(Number(parentCompanyData.cityXid)) ? { connect: { id: Number(parentCompanyData.cityXid) } } : undefined, states: parentCompanyData?.stateXid && !isNaN(Number(parentCompanyData.stateXid)) ? { connect: { id: Number(parentCompanyData.stateXid) } } : undefined, countries: parentCompanyData?.countryXid && !isNaN(Number(parentCompanyData.countryXid)) ? { connect: { id: Number(parentCompanyData.countryXid) } } : undefined, pinCode: parentCompanyData.pinCode || null, logoPath: parentCompanyData?.logoPath || existingParentCompany?.logoPath || null, registrationNumber: parentCompanyData.registrationNumber || null, panNumber: parentCompanyData.panNumber || null, gstNumber: parentCompanyData.gstNumber || null, formationDate: parentCompanyData.formationDate ? new Date(parentCompanyData.formationDate as any) : null, companyTypes: parentCompanyData.companyTypeXid ? { connect: { id: parentCompanyData.companyTypeXid } } : undefined, websiteUrl: parentCompanyData.websiteUrl || null, instagramUrl: parentCompanyData.instagramUrl || null, facebookUrl: parentCompanyData.facebookUrl || null, linkedinUrl: parentCompanyData.linkedinUrl || null, twitterUrl: parentCompanyData.twitterUrl || null, }, }); // if (parentDocuments?.length) { // for (const doc of parentDocuments) { // const existingParentDoc = await tx.hostParenetDocuments.findFirst( // { // where: { // hostParentXid: parentRecord.id, // documentTypeXid: doc.documentTypeXid, // }, // }, // ); // if (existingParentDoc) { // await tx.hostParenetDocuments.update({ // where: { id: existingParentDoc.id }, // data: { // filePath: doc.filePath, // documentName: // sanitizeDocumentName(doc.documentName) || // existingParentDoc.documentName, // }, // }); // } else { // await tx.hostParenetDocuments.create({ // data: { // hostParentXid: parentRecord.id, // documentTypeXid: doc.documentTypeXid, // documentName: sanitizeDocumentName(doc.documentName), // filePath: doc.filePath, // }, // }); // } // } // } if (parentDocuments?.length) { const parentDocsData = parentDocuments .filter((doc) => doc.filePath) .map((doc) => ({ hostParentXid: parentRecord.id, documentTypeXid: doc.documentTypeXid, documentName: sanitizeDocumentName(doc.documentName), filePath: doc.filePath, })); if (parentDocsData.length) { await tx.hostParenetDocuments.createMany({ data: parentDocsData, }); } } } } else { console.log('Last ke else block me aaya hai'); const previousParent = existingHostCompany.hostParent; let prevParentId = null; if (Array.isArray(previousParent) && previousParent.length) { prevParentId = previousParent[0].id; } else if ( previousParent && typeof previousParent === 'object' && 'id' in previousParent ) { prevParentId = previousParent.id; } if (prevParentId) { await tx.hostParenetDocuments.deleteMany({ where: { hostParentXid: prevParentId }, }); await tx.hostParent.delete({ where: { id: prevParentId } }); } } // ⭐ FIX — USE updatedHost instead of re-querying hostHeader await tx.hostTrack.create({ data: { hostXid: updatedHost.id, updatedByRole: ROLE_NAME.HOST, updatedByXid: user_xid, trackStatus: updatedHost.hostStatusInternal, }, }); // suggestion update unchanged if (!isDraft) { await tx.hostSuggestion.updateMany({ where: { hostXid: updatedHost.id, isActive: true, isreviewed: false }, data: { isreviewed: true, reviewedByXid: user_xid, reviewOn: new Date(), }, }); } return updatedHost; }); } async getSuggestionDetails(user_xid: number) { const hostDetails = await this.prisma.hostHeader.findFirst({ where: { userXid: user_xid, isActive: true }, include: { user: { select: { id: true, emailAddress: true, firstName: true, userRefNumber: true, }, }, accountManager: { select: { id: true, emailAddress: true, firstName: true, }, }, }, }); if (!hostDetails) { return { hostSuggestionDetails: [], hostDetails: null }; } const hostSuggestionDetails = await this.prisma.hostSuggestion.findMany({ where: { hostXid: hostDetails.id, isActive: true, isreviewed: false }, }); if (hostSuggestionDetails) { await this.prisma.hostSuggestion.updateMany({ where: { hostXid: hostDetails.id, isActive: true, isreviewed: false }, data: { isreviewed: true, reviewedByXid: hostDetails.id, reviewOn: new Date(), }, }); } return { hostSuggestionDetails, hostDetails }; } // 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 calculatePqqScoreForUser(activityXid: number) { return await this.prisma.$transaction(async (tx) => { // 1. Get all headers for this activity (user's answers) const answers = await this.prisma.activityPQQheader.findMany({ where: { activityXid, isActive: true }, 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: Record< number, { categoryId: number; categoryName: string; userPoints: number; maxPoints: number; } > = {}; for (const item of answers) { const question = item.pqqQuestions; const answer = item.pqqAnswers; // may be null if no answer selected // skip if question missing if (!question) continue; const maxPoints = Number(question.maxPoints || 0); const userPoints = Number(answer?.answerPoints || 0); totalUserPoints += userPoints; totalMaxPoints += maxPoints; // Category (guard nested relations) const category = question.pqqSubCategories?.category; if (!category) continue; 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 ? round2((totalUserPoints / totalMaxPoints) * 100) : 0; // ---------- 🔥 ONLY FIRST 2 CATEGORIES ---------- const categoryArray = Object.values(categories); // Sort by categoryId (or change to displayOrder if needed) categoryArray.sort((a, b) => a.categoryId - b.categoryId); // Take only first 2 categories const topTwo = categoryArray.slice(0, 2); const categoryWise: Record = {}; for (const c of topTwo) { categoryWise[c.categoryName] = c.maxPoints > 0 ? round2((c.userPoints / c.maxPoints) * 100) : 0; } await this.prisma.activities.update({ where: { id: activityXid, }, data: { totalScore: round2(overallPercentage), sustainabilityScore: round2(categoryWise.Sustainability), safetyScore: round2(categoryWise.Safety), }, }); // Return final score object return { overallPercentage, categoryWise, }; }); } async createHeader( activityXid: number, pqqQuestionXid: number, pqqAnswerXid: number, comments?: string | null, ) { return await this.prisma.activityPQQheader.create({ data: { activityXid, pqqQuestionXid, pqqAnswerXid, comments: comments || null, // Handle null comments }, }); } async findHeaderByCompositeKey(activityXid: number, pqqQuestionXid: number) { return await this.prisma.activityPQQheader.findFirst({ where: { activityXid, pqqQuestionXid, }, }); } async updateHeader( headerId: number, pqqAnswerXid: number, comments?: string | null, ) { return await this.prisma.activityPQQheader.update({ where: { id: headerId, }, data: { comments: comments || null, // Handle null comments pqqAnswerXid: pqqAnswerXid, updatedAt: new Date(), }, }); } async addSupportingFile(headerId: number, mimeType: string, fileUrl: string) { return await this.prisma.activityPQQSupportings.create({ data: { activityPqqHeaderXid: headerId, mediaType: mimeType, mediaFileName: fileUrl, }, }); } async getSupportingFilesByHeaderId(headerId: number) { return await this.prisma.activityPQQSupportings.findMany({ where: { activityPqqHeaderXid: headerId, }, orderBy: { id: 'asc', // Maintain consistent order }, }); } async submitpqqforreview(activity_xid: number, user_xid: number) { return await this.prisma.$transaction(async (tx) => { const activity = await this.prisma.activities.findFirst({ where: { id: activity_xid, isActive: true }, select: { id: true, activityTitle: true, activityRefNumber: true, activityDisplayStatus: true, activityInternalStatus: true, amInternalStatus: true, amDisplayStatus: true, }, }); if (!activity) { throw new ApiError(404, 'Activity not found'); } if ( activity.activityInternalStatus == ACTIVITY_INTERNAL_STATUS.PQ_TO_UPDATE ) { return await this.prisma.$transaction(async (tx) => { await this.prisma.activities.update({ where: { id: activity_xid }, data: { activityInternalStatus: ACTIVITY_INTERNAL_STATUS.PQ_SUBMITTED, activityDisplayStatus: ACTIVITY_DISPLAY_STATUS.PQ_IN_REVIEW, amInternalStatus: ACTIVITY_AM_INTERNAL_STATUS.PQ_TO_REVIEW, amDisplayStatus: ACTIVITY_AM_DISPLAY_STATUS.REVISED, }, }); await tx.activityTrack.create({ data: { activityXid: activity_xid, trackType: ACTIVITY_TRACK_TYPE.PQ, trackStatus: ACTIVITY_TRACK_STATUS.PQ_SUBMITTED, updatedByXid: user_xid, updatedByRole: ROLE_NAME.HOST, updatedOn: new Date(), }, }); }); } else { return await this.prisma.$transaction(async (tx) => { await this.prisma.activities.update({ where: { id: activity_xid }, data: { activityInternalStatus: ACTIVITY_INTERNAL_STATUS.PQ_SUBMITTED, activityDisplayStatus: ACTIVITY_DISPLAY_STATUS.PQ_IN_REVIEW, amInternalStatus: ACTIVITY_AM_INTERNAL_STATUS.PQ_TO_REVIEW, amDisplayStatus: ACTIVITY_AM_DISPLAY_STATUS.NEW, }, }); await tx.activityTrack.create({ data: { activityXid: activity_xid, trackType: ACTIVITY_TRACK_TYPE.PQ, trackStatus: ACTIVITY_TRACK_STATUS.PQ_SUBMITTED, updatedByXid: user_xid, updatedByRole: ROLE_NAME.HOST, updatedOn: new Date(), }, }); }); } }); } 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 markPQQSuggestionReviewed( user_xid: number, activityPqqHeaderXid: number, activityPQQSuggestionId: number, ) { return await this.prisma.activityPQQSuggestions.update({ where: { id: activityPQQSuggestionId, activityPqqHeaderXid: activityPqqHeaderXid, isActive: true, isReviewed: false, }, data: { isReviewed: true, reviewedByXid: user_xid, reviewedOn: new Date(), }, }); } async getAllPQQQuesAndSubmittedAns(activity_xid: number) { return await this.prisma.activityPQQheader.findMany({ where: { isActive: true, activityXid: activity_xid }, select: { id: true, activityXid: true, pqqQuestionXid: true, pqqAnswerXid: true, comments: true, activity: { select: { id: true, activityType: { select: { id: true, activityTypeName: true } } } }, pqqQuestions: { select: { questionName: true, maxPoints: true, displayOrder: true, pqqSubCategories: { select: { id: true, subCategoryName: true, displayOrder: true, category: { select: { id: true, categoryName: true, displayOrder: true, }, }, }, }, }, }, ActivityPQQSuggestions: { select: { id: true, title: true, comments: true, isReviewed: true, reviewedBy: true, reviewedOn: true, }, }, pqqAnswers: { select: { id: true, displayOrder: true, answerName: true, answerPoints: true, }, }, ActivityPQQSupportings: { select: { id: true, mediaFileName: true, mediaType: true, }, }, }, }); } async getAllActivityTypesWithInterest(search?: string) { const where: any = { isActive: true, deletedAt: null, }; if (search && search.trim() !== '') { const q = search.trim(); where.OR = [ { activityTypeName: { contains: q, mode: 'insensitive' } }, { interests: { interestName: { contains: q, mode: 'insensitive' } } }, ]; } return await this.prisma.activityTypes.findMany({ where, select: { id: true, activityTypeName: true, interestXid: true, interests: { select: { id: true, interestName: true, displayOrder: true, }, }, }, orderBy: { activityTypeName: 'asc' }, }); } /************* ✨ Windsurf Command ⭐ *************/ /** * Get all details of an activity and its venue. * * @param {number} activityXid - The id of the activity to fetch. * * @returns {Promise} - The activity details with its venue. * * @example * const activity = await getAllDetailsOfActivityAndVenue(1); * console.log(activity); */ /******* 88cdc2a8-b07f-4da8-972a-4e00f5399a65 *******/ async getAllDetailsOfActivityAndVenue(activityXid: number) { const activity = await this.prisma.activities.findFirst({ where: { id: activityXid, isActive: true }, select: { id: true, activityTitle: true, activityDescription: true, activityDisplayStatus: true, activityInternalStatus: true, activityRefNumber: true, checkInAddress: true, checkInLat: true, checkInLong: true, checkOutAddress: true, checkOutLat: true, checkOutLong: true, pickUpDropAvailable: true, pickUpDropIsChargeable: true, inActivityAvailable: true, activityDurationMins: true, totalScore: true, isCheckOutSame: true, activityType: { select: { id: true, activityTypeName: true, interests: { select: { id: true, interestName: true, }, }, energyLevel: { select: { id: true, energyLevelName: true, energyIcon: true, energyColor: true, displayOrder: true, }, }, }, }, ActivitiesMedia: { where: { isActive: true, }, select: { id: true, mediaType: true, mediaFileName: true, isCoverImage: true }, }, ActivityVenues: { where: { isActive: true, }, select: { id: true, venueName: true, venueLabel: true, venueCapacity: true, availableSeats: true, isMinPeopleReqMandatory: true, minPeopleRequired: true, minReqfullfilledBeforeMins: true, venueDescription: true, ActivityVenueArtifacts: { select: { id: true, mediaType: true, mediaFileName: true, }, }, ActivityPrices: { select: { id: true, noOfSession: true, isPackage: true, sessionValidity: true, sessionValidityFrequency: true, sellPrice: true, ActivityPriceTaxes: { select: { id: true, taxXid: true, taxPer: true, taxes: { select: { id: true, countryXid: true, country: { select: { countryName: true, countryCode: true, countryFlag: true, }, }, taxName: true, taxPer: true, }, }, }, }, }, orderBy: { createdAt: 'asc' }, }, }, }, ActivityPickUpDetails: { where: { isActive: true, }, select: { id: true, isPickUp: true, locationAddress: true, locationLat: true, locationLong: true, transportTotalPrice: true, transportBasePrice: true, }, }, activityPickUpTransports: { where: { isActive: true, }, select: { transportModeXid: true, transportMode: { select: { transportModeName: true, transportModeIcon: true, }, }, }, }, foodAvailable: true, foodIsChargeable: true, activityFoodTypes: { where: { isActive: true }, select: { id: true, foodType: { select: { id: true, foodTypeName: true, }, }, }, }, ActivityFoodCost: { where: { isActive: true, }, select: { id: true, totalAmount: true, }, }, activityCuisines: { where: { isActive: true, }, select: { id: true, foodCuisine: { select: { id: true, cuisineName: true, }, }, }, }, alcoholAvailable: true, trainerAvailable: true, trainerIsChargeable: true, ActivityTrainers: { where: { isActive: true, }, select: { id: true, totalAmount: true, }, }, ActivityNavigationModes: { where: { isActive: true, }, select: { id: true, navigationModeName: true, isInActivityChargeable: true, navigationModesTotalPrice: true, }, }, equipmentAvailable: true, equipmentIsChargeable: true, ActivityEquipments: { where: { isActive: true, }, select: { id: true, equipmentName: true, isEquipmentChargeable: true, equipmentTotalPrice: true, }, }, ActivityOtherDetails: { where: { isActive: true, }, select: { id: true, exclusiveNotes: true, dosNotes: true, dontsNotes: true, tipsNotes: true, termsAndCondition: true, Cancellations: true, SafetyInstruction: true }, }, ActivityEligibility: { where: { isActive: true, }, select: { id: true, isAgeRestriction: true, // ageRestriction: { // select: { // id: true, // ageRestrictionName: true, // minAge: true, // maxAge: true, // }, // }, ageRestrictionName: true, ageEntered: true, minAge: true, maxAge: true, isWeightRestriction: true, weightRestrictionName: true, weightEntered: true, weightIn: true, minWeight: true, maxWeight: true, isHeightRestriction: true, heightRestrictionName: true, heightEntered: true, heightIn: true, minHeight: true, maxHeight: true, }, }, ActivityAllowedEntry: { where: { isActive: true, }, select: { id: true, allowedEntryType: { select: { id: true, allowedEntryTypeName: true, }, }, }, }, frequency: { where: { isActive: true }, select: { id: true, frequencyName: true } }, ActivityAmenities: { where: { isActive: true, }, select: { id: true, amenities: { select: { id: true, amenitiesName: true, amenitiesIcon: true }, }, }, }, cancellationAvailable: true, cancellationAllowedBeforeMins: true, // accountManager: { // select: { // id: true, // firstName: true, // lastName: true, // emailAddress: true, // mobileNumber: true, // } // }, host: { select: { id: true, companyName: true, stepper: true, adminStatusDisplay: true, adminStatusInternal: true, user: { select: { id: true, userRefNumber: true, firstName: true, lastName: true, }, }, }, }, }, }); if (!activity) { throw new ApiError(404, 'Activity not found'); } if (Array.isArray(activity.ActivitiesMedia)) { for (const media of activity.ActivitiesMedia) { if (!media?.mediaFileName) continue; const filePath = media.mediaFileName; // ✅ Robust S3 key extraction const key = filePath.startsWith('http') ? new URL(filePath).pathname.replace(/^\/+/, '') : filePath; (media as any).presignedUrl = await getPresignedUrl(bucket, key); } } if (Array.isArray(activity.ActivityAmenities)) { for (const item of activity.ActivityAmenities) { const filePath = item?.amenities?.amenitiesIcon; if (!filePath) continue; // ✅ Robust S3 key extraction const key = filePath.startsWith('http') ? new URL(filePath).pathname.replace(/^\/+/, '') : filePath; (item.amenities as any).presignedUrl = await getPresignedUrl(bucket, key); } } if (Array.isArray(activity.ActivityVenues)) { for (const venue of activity.ActivityVenues) { if (!Array.isArray(venue.ActivityVenueArtifacts)) continue; for (const artifact of venue.ActivityVenueArtifacts) { if (!artifact?.mediaFileName) continue; const filePath = artifact.mediaFileName; // ✅ Robust S3 key extraction const key = filePath.startsWith('http') ? new URL(filePath).pathname.replace(/^\/+/, '') : filePath; (artifact as any).preSignedURL = await getPresignedUrl(bucket, key); } } } return activity; } // async createActivity( // userId: number, // activityTypeXid: number, // frequenciesXid?: number, // ) { // return await this.prisma.$transaction(async (tx) => { // // Fetch host // const host = await tx.hostHeader.findFirst({ // where: { userXid: userId, isActive: true }, // }); // if (!host) throw new ApiError(404, 'Host not found for the user'); // // Validate activityType // const activityType = await tx.activityTypes.findUnique({ // where: { id: activityTypeXid }, // }); // if (!activityType) throw new ApiError(404, 'Activity type not found'); // // Validate frequency // if (frequenciesXid) { // const freq = await tx.frequencies.findUnique({ // where: { id: frequenciesXid }, // }); // if (!freq) throw new ApiError(404, 'Frequency not found'); // } // // Generate reference number // const referenceNumber = await generateActivityRefNumber(tx); // // Create activity // 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, // }, // }); // return created; // }); // } async createActivityAndAllQuestionsEntry( userId: number, activityTypeXid: number, frequenciesXid: number, ) { return await this.prisma.$transaction(async (tx) => { const hostUserDetail = await tx.user.findFirst({ where: { id: userId, isActive: true }, select: { id: true, userRefNumber: true, } }) if (!hostUserDetail) { throw new ApiError(404, 'User not found'); } 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, isActive: true }, include: { interests: true, // ✅ correct energyLevel: true, // ✅ this is correct already }, }); 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, host.id, activityTypeXid, hostUserDetail.userRefNumber); 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: { comments: true, pqqAnswerXid: true, 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, }, }, }, }, // 🔥 ALL ANSWER OPTIONS FOR THIS QUESTION PQQAnswers: { where: { isActive: true }, select: { id: true, answerName: true, answerPoints: true, displayOrder: true, }, orderBy: { displayOrder: 'asc' }, }, }, }, 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 ------------------ 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]; 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); } subCat.questions.push({ id: q.id, questionName: q.questionName, maxPoints: q.maxPoints, comments: item.comments || null, displayOrder: q.displayOrder, allAnswerOptions: q.PQQAnswers || [], suggestions: item.ActivityPQQSuggestions || [], supportings: item.ActivityPQQSupportings || [], }); } 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, ); } } return { activity_xid: created.id, activityType: activityType, sortedCategories, }; }); } /** * Create a full activity with related records based on payload from the onboarding form. * This method will create Activities + ActivityOtherDetails + ActivitiesMedia + * ActivityVenues + ActivityPrices + ActivityFoodTypes + ActivityCuisine + * ActivityPickUpTransport/Details + ActivityNavigationModes + ActivityEquipments + * ActivityAmenities + ActivityEligibility */ async createOrUpdateActivity( userId: number, payload: CreateActivityInput, isDraft: boolean, ) { /* ===================================================== * HELPERS * ===================================================== */ const toBool = (v: any) => v === true || v === 'true' || v === 1 || v === '1'; const toBoolOrNull = (v: any): boolean | null => { if (v === null || v === undefined || v === '') return null; return v === true || v === 'true' || v === 1 || v === '1'; }; const toNumber = (v: any) => v === undefined || v === null || v === '' ? undefined : Number(v); const round2 = (v: number) => Math.round(v); const computeBasePriceAndTaxes = ( sellPrice: number, taxes: Array<{ id: number; taxPer: number }>, ) => { if (!taxes.length) { return { basePrice: round2(sellPrice), taxDetails: [] }; } const totalTaxPer = taxes.reduce((s, t) => s + Number(t.taxPer || 0), 0); const basePrice = round2(sellPrice / (1 + totalTaxPer / 100)); return { basePrice, taxDetails: taxes.map((t) => ({ taxXid: t.id, taxPer: t.taxPer, taxAmount: round2(basePrice * (t.taxPer / 100)), })), }; }; /* ===================================================== * DURATION CONVERSION * ===================================================== */ const durationDays = Number(payload.durationDays ?? 0); const durationHours = Number(payload.durationHours ?? 0); const durationMins = Number(payload.durationMins ?? 0); /* ===================================================== * BASIC GUARDS * ===================================================== */ if (!payload.activityXid) { throw new ApiError(400, 'activityXid is required'); } payload.venues = Array.isArray(payload.venues) ? payload.venues .filter(v => v && typeof v === 'object') .map(v => ({ ...v, venueName: v.venueName ?? null, venueLabel: v.venueLabel ?? null, prices: Array.isArray(v.prices) ? v.prices : [], media: Array.isArray(v.media) ? v.media : [], })) : []; /* ===================================================== * HARD NORMALIZATION (SERVICE-LEVEL) * ===================================================== */ payload.foodAvailable = toBoolOrNull(payload.foodAvailable); payload.alcoholAvailable = toBoolOrNull(payload.alcoholAvailable); payload.trainerAvailable = toBoolOrNull(payload.trainerAvailable); payload.pickUpDropAvailable = toBoolOrNull(payload.pickUpDropAvailable); payload.inActivityAvailable = toBoolOrNull(payload.inActivityAvailable); payload.equipmentAvailable = toBoolOrNull(payload.equipmentAvailable); payload.cancellationAvailable = toBoolOrNull(payload.cancellationAvailable); payload.isInstantBooking = toBool(payload.isInstantBooking); payload.isCheckOutSame = toBool(payload.isCheckOutSame); payload.alcoholAvailable = toBoolOrNull(payload.alcoholAvailable); payload.trainerTotalAmount = toNumber(payload.trainerTotalAmount); payload.cancellationAllowedBeforeMins = toNumber( payload.cancellationAllowedBeforeMins, ); /* ===================================================== * CANCELLATION VALIDATION (NO CONVERSION) * ===================================================== */ if (payload.cancellationAvailable) { if (!isDraft) { if ( typeof payload.cancellationAllowedBeforeMins !== 'number' || Number.isNaN(payload.cancellationAllowedBeforeMins) || payload.cancellationAllowedBeforeMins <= 0 ) { throw new ApiError( 400, 'cancellationAllowedBeforeMins must be a positive number (in minutes)', ); } } } else { delete payload.cancellationAllowedBeforeMins; } const trainerIsChargeable = toBool(payload.trainerIsChargeable); const pickUpDropIsChargeable = toBool(payload.pickUpDropIsChargeable); if (payload.trainerAvailable && trainerIsChargeable) { if (!isDraft) { if ( typeof payload.trainerTotalAmount !== 'number' || Number.isNaN(payload.trainerTotalAmount) || payload.trainerTotalAmount <= 0 ) { throw new ApiError(400, 'trainerTotalAmount must be > 0'); } } } else { // If trainer cost is not chargeable, default the amount to 0 payload.trainerTotalAmount = 0; } if (payload.venues && !Array.isArray(payload.venues)) { throw new ApiError(400, 'venues must be an array'); } payload.venues?.forEach((v, idx) => { v.isMinPeopleReqMandatory = toBool(v.isMinPeopleReqMandatory); if (!isDraft) { if (!v.venueName) { throw new ApiError(400, `venues[${idx}] venueName required`); } if (v.isMinPeopleReqMandatory && !v.minPeopleRequired) { throw new ApiError( 400, `venues[${idx}] min people requirement missing`, ); } if (!Array.isArray(v.prices) || !v.prices.length) { throw new ApiError( 400, `venues[${idx}] must have at least one price`, ); } } }); /* ===================================================== * ROOT TAX * ===================================================== */ const taxIds = Array.isArray(payload.taxXids) ? payload.taxXids.map(Number) : []; const rootTaxes = taxIds.length > 0 ? await this.prisma.taxes.findMany({ where: { id: { in: taxIds }, isActive: true }, select: { id: true, taxPer: true }, }) : []; if (taxIds.length !== rootTaxes.length) { throw new ApiError(400, 'Invalid or inactive tax provided'); } const eligibility = payload.eligibility; if (eligibility?.isAgeRestriction) { if (eligibility.ageRestrictionName == RESTRICTION_NAME.ABOVE) { eligibility.minAge = toNumber(eligibility.ageEntered); eligibility.maxAge = 150; } else if (eligibility.ageRestrictionName == RESTRICTION_NAME.BELOW) { eligibility.maxAge = toNumber(eligibility.ageEntered); eligibility.minAge = 0; } } if (eligibility?.isWeightRestriction) { if (eligibility.weightRestrictionName == RESTRICTION_NAME.ABOVE) { eligibility.minWeight = toNumber(eligibility.weightEntered); eligibility.maxWeight = 400; } else if (eligibility.weightRestrictionName == RESTRICTION_NAME.BELOW) { eligibility.maxWeight = toNumber(eligibility.weightEntered); eligibility.minWeight = 0; } } if (eligibility?.isHeightRestriction) { if (eligibility.heightRestrictionName == RESTRICTION_NAME.ABOVE) { eligibility.minHeight = toNumber(eligibility.heightEntered); eligibility.maxHeight = 250; } else if (eligibility.heightRestrictionName == RESTRICTION_NAME.BELOW) { eligibility.maxHeight = toNumber(eligibility.heightEntered); eligibility.minHeight = 0; } } /* ===================================================== * TRANSACTION * ===================================================== */ return await this.prisma.$transaction(async (tx) => { /* -------------------------------- * 1️⃣ HOST * -------------------------------- */ const host = await tx.hostHeader.findFirst({ where: { userXid: userId, isActive: true }, }); if (!host) throw new ApiError(404, 'Host not found'); /* -------------------------------- * 2️⃣ ACTIVITY * -------------------------------- */ const existingActivity = await tx.activities.findFirst({ where: { id: Number(payload.activityXid), hostXid: host.id, isActive: true, }, }); if (!existingActivity) { throw new ApiError(404, 'Activity not found'); } /* -------------------------------- * 3️⃣ STATUS DECISION * -------------------------------- */ let activityInternalStatus; let activityDisplayStatus; let amInternalStatus; let amDisplayStatus; const wasRejected = existingActivity.activityInternalStatus === ACTIVITY_INTERNAL_STATUS.ACTIVITY_REJECTED; if (wasRejected) { if (isDraft) { activityInternalStatus = existingActivity.activityInternalStatus; activityDisplayStatus = existingActivity.activityDisplayStatus; amInternalStatus = existingActivity.amInternalStatus; amDisplayStatus = existingActivity.amDisplayStatus; } else { activityInternalStatus = ACTIVITY_INTERNAL_STATUS.ACTIVITY_SUBMITTED; activityDisplayStatus = ACTIVITY_DISPLAY_STATUS.ACTIVITY_IN_REVIEW; amInternalStatus = ACTIVITY_AM_INTERNAL_STATUS.ACTIVITY_TO_REVIEW; amDisplayStatus = ACTIVITY_AM_DISPLAY_STATUS.ACTIVITY_REVISED; } } else { if (isDraft) { activityInternalStatus = ACTIVITY_INTERNAL_STATUS.ACTIVITY_DRAFT; activityDisplayStatus = ACTIVITY_DISPLAY_STATUS.ACTIVITY_DRAFT; amInternalStatus = existingActivity.amInternalStatus; amDisplayStatus = existingActivity.amDisplayStatus; } else { activityInternalStatus = ACTIVITY_INTERNAL_STATUS.ACTIVITY_SUBMITTED; activityDisplayStatus = ACTIVITY_DISPLAY_STATUS.ACTIVITY_IN_REVIEW; amInternalStatus = ACTIVITY_AM_INTERNAL_STATUS.ACTIVITY_SUBMITED; amDisplayStatus = ACTIVITY_AM_DISPLAY_STATUS.ACTIVITY_NEW; } } /* -------------------------------- * 🌍 RESOLVE CHECK-IN LOCATION * -------------------------------- */ const checkInCountryXid = await findOrCreateCountry( tx, payload.checkInCountryName, ); const checkInStateXid = await findOrCreateState( tx, payload.checkInStateName, checkInCountryXid, ); const checkInCityXid = await findOrCreateCity( tx, payload.checkInCityName, checkInStateXid, ); /* -------------------------------- * 🌍 RESOLVE CHECK-OUT LOCATION * -------------------------------- */ const checkOutCountryXid = await findOrCreateCountry( tx, payload.checkOutCountryName, ); const checkOutStateXid = await findOrCreateState( tx, payload.checkOutStateName, checkOutCountryXid, ); const checkOutCityXid = await findOrCreateCity( tx, payload.checkOutCityName, checkOutStateXid, ); /* -------------------------------- * 4️⃣ UPDATE ACTIVITY CORE + FLAGS * -------------------------------- */ const activity = await tx.activities.update({ where: { id: existingActivity.id }, data: { activityTypeXid: payload.activityTypeXid ?? undefined, frequenciesXid: payload.frequenciesXid ?? undefined, activityTitle: payload.activityTitle ?? undefined, activityDescription: payload.activityDescription ?? undefined, checkInLat: payload.checkInLat ?? undefined, checkInLong: payload.checkInLong ?? undefined, checkInAddress: payload.checkInAddress ?? undefined, isCheckOutSame: toBool(payload.isCheckOutSame), checkOutLat: payload.checkOutLat ?? undefined, checkOutLong: payload.checkOutLong ?? undefined, checkOutAddress: payload.checkOutAddress ?? undefined, // energyLevelXid: payload.energyLevelXid ?? undefined, activityDurationMins: durationMins ?? undefined, currencyXid: payload.currencyXid ?? undefined, sustainabilityScore: payload.sustainabilityScore ?? undefined, safetyScore: payload.safetyScore ?? undefined, isInstantBooking: payload.isInstantBooking ?? undefined, foodAvailable: payload.foodAvailable, foodIsChargeable: toBool(payload.foodIsChargeable), alcoholAvailable: payload.alcoholAvailable, trainerAvailable: payload.trainerAvailable, trainerIsChargeable: toBool(payload.trainerIsChargeable), pickUpDropAvailable: payload.pickUpDropAvailable, pickUpDropIsChargeable: toBool(payload.pickUpDropIsChargeable), inActivityAvailable: payload.inActivityAvailable, inActivityIsChargeable: toBool(payload.inActivityIsChargeable), equipmentAvailable: payload.equipmentAvailable, equipmentIsChargeable: toBool(payload.equipmentIsChargeable), cancellationAvailable: payload.cancellationAvailable, cancellationAllowedBeforeMins: payload.cancellationAvailable ? payload.cancellationAllowedBeforeMins : null, activityInternalStatus, activityDisplayStatus, amInternalStatus, amDisplayStatus, checkInCountryXid: checkInCountryXid ?? undefined, checkInStateXid: checkInStateXid ?? undefined, checkInCityXid: checkInCityXid ?? undefined, checkOutCountryXid: checkOutCountryXid ?? undefined, checkOutStateXid: checkOutStateXid ?? undefined, checkOutCityXid: checkOutCityXid ?? undefined, }, }); const activityXid = activity.id; /* -------------------------------- * 5️⃣ CLEAN OLD ACTIVITY MEDIA * -------------------------------- */ await tx.activitiesMedia.deleteMany({ where: { activityXid } }); /* -------------------------------- * 6️⃣ SAVE NEW ACTIVITY MEDIA * -------------------------------- */ if (Array.isArray(payload.media) && payload.media.length) { await tx.activitiesMedia.createMany({ data: payload.media.map((m, index) => ({ activityXid, mediaType: m.mediaType ?? 'unknown', mediaFileName: m.mediaFileName, isCoverImage: m.isCoverImage ?? false, displayOrder: index + 1, })), }); } /* -------------------------------- * 7️⃣ CLEAN OLD VENUES & RELATED DATA * -------------------------------- */ const oldVenueIds = ( await tx.activityVenues.findMany({ where: { activityXid }, select: { id: true }, }) ).map((v) => v.id); if (oldVenueIds.length) { // Clean venue artifacts (media) await tx.activityVenueArtifacts.deleteMany({ where: { activityVenueXid: { in: oldVenueIds } }, }); // Clean price taxes and prices const priceIds = ( await tx.activityPrices.findMany({ where: { activityVenueXid: { in: oldVenueIds } }, select: { id: true }, }) ).map((p) => p.id); if (priceIds.length) { await tx.activityPriceTaxes.deleteMany({ where: { activityPriceXid: { in: priceIds } }, }); await tx.activityPrices.deleteMany({ where: { id: { in: priceIds } }, }); } // Clean venues await tx.activityVenues.deleteMany({ where: { id: { in: oldVenueIds } }, }); } /* -------------------------------- * 8️⃣ CREATE VENUES WITH MEDIA & PRICES (DRAFT SAFE) * -------------------------------- */ for (const venue of payload.venues ?? []) { const venueRow = await tx.activityVenues.create({ data: { activityXid, venueName: venue.venueName ?? null, venueLabel: venue.venueLabel ?? null, venueCapacity: toNumber(venue.venueCapacity) ?? 0, availableSeats: toNumber(venue.availableSeats) ?? 0, isMinPeopleReqMandatory: venue.isMinPeopleReqMandatory, minPeopleRequired: toNumber(venue.minPeopleRequired) ?? null, minReqfullfilledBeforeMins: toNumber(venue.minReqfullfilledBeforeMins) ?? null, venueDescription: venue.venueDescription ?? null, }, }); // Create venue media/artifacts if (Array.isArray(venue.media) && venue.media.length) { await tx.activityVenueArtifacts.createMany({ data: venue.media.map((m) => ({ activityVenueXid: venueRow.id, mediaType: m.mediaType ?? 'image', mediaFileName: m.mediaFileName, })), }); } // Create venue prices with taxes for (const price of venue.prices ?? []) { const sellPrice = Number(price.sellPrice); // On submit enforce > 0, on draft just skip invalid if (!isDraft) { if (!sellPrice || sellPrice <= 0) { throw new ApiError( 400, 'sellPrice must be > 0 for submitted activities', ); } } if (!sellPrice || sellPrice <= 0) continue; const { basePrice, taxDetails } = computeBasePriceAndTaxes( sellPrice, rootTaxes, ); const priceRow = await tx.activityPrices.create({ data: { activityVenueXid: venueRow.id, noOfSession: price.noOfSession ?? 1, isPackage: price.isPackage ?? false, sessionValidity: price.sessionValidity ?? 0, sessionValidityFrequency: price.sessionValidityFrequency ?? 'Days', basePrice, sellPrice, }, }); if (taxDetails.length) { await tx.activityPriceTaxes.createMany({ data: taxDetails.map((t) => ({ activityPriceXid: priceRow.id, taxXid: t.taxXid, taxPer: t.taxPer, taxAmount: t.taxAmount, })), }); } } } /* 8.1️⃣ CLEAN & CREATE FOOD COST (if chargeable) */ const oldFoodCostIds = ( await tx.activityFoodCost.findMany({ where: { activityXid }, select: { id: true }, }) ).map((f) => f.id); if (oldFoodCostIds.length) { await tx.activityFoodTaxes.deleteMany({ where: { activityFoodCostXid: { in: oldFoodCostIds } }, }); await tx.activityFoodCost.deleteMany({ where: { id: { in: oldFoodCostIds } }, }); } if (payload.foodAvailable && payload.foodIsChargeable) { const foodTotalAmount = toNumber(payload.foodTotalAmount) ?? 0; if (!isDraft && foodTotalAmount <= 0) { throw new ApiError( 400, 'foodTotalAmount must be > 0 when foodIsChargeable', ); } if (foodTotalAmount > 0) { const { basePrice, taxDetails } = computeBasePriceAndTaxes( foodTotalAmount, rootTaxes, ); const foodCost = await tx.activityFoodCost.create({ data: { activityXid, baseAmount: basePrice, totalAmount: foodTotalAmount, }, }); if (taxDetails.length) { await tx.activityFoodTaxes.createMany({ data: taxDetails.map((t) => ({ activityFoodCostXid: foodCost.id, taxXid: t.taxXid, taxPer: t.taxPer, taxAmount: t.taxAmount, })), }); } } } // 🍲 FOOD TYPES await tx.activityFoodTypes.deleteMany({ where: { activityXid } }); if (Array.isArray(payload.foodTypeIds) && payload.foodTypeIds.length) { await tx.activityFoodTypes.createMany({ data: payload.foodTypeIds.map((foodTypeId) => ({ activityXid, foodTypeXid: foodTypeId, })), }); } // 🍛 CUISINES await tx.activityCuisine.deleteMany({ where: { activityXid } }); if (Array.isArray(payload.cuisineIds) && payload.cuisineIds.length) { await tx.activityCuisine.createMany({ data: payload.cuisineIds.map((cuisineId) => ({ activityXid, foodCuisineXid: cuisineId, })), }); } /* -------------------------------- * 9️⃣ CLEAN & CREATE EQUIPMENT WITH TAXES * -------------------------------- */ const oldEquipmentIds = ( await tx.activityEquipments.findMany({ where: { activityXid }, select: { id: true }, }) ).map((e) => e.id); if (oldEquipmentIds.length) { await tx.activityEquipmentTaxes.deleteMany({ where: { activityEquipmentXid: { in: oldEquipmentIds } }, }); await tx.activityEquipments.deleteMany({ where: { id: { in: oldEquipmentIds } }, }); } if (Array.isArray(payload.equipments)) { for (const eq of payload.equipments) { const isChargeable = toBool(eq.isEquipmentChargeable); const totalPrice = isChargeable ? toNumber(eq.equipmentTotalPrice) ?? 0 : 0; // ❌ Validate only on submit if (!isDraft && isChargeable && totalPrice <= 0) { throw new ApiError( 400, 'equipmentTotalPrice must be > 0 when equipment is chargeable', ); } const { basePrice, taxDetails } = isChargeable && totalPrice > 0 ? computeBasePriceAndTaxes(totalPrice, rootTaxes) : { basePrice: 0, taxDetails: [] }; // ✅ ALWAYS CREATE EQUIPMENT const equipment = await tx.activityEquipments.create({ data: { activityXid, equipmentName: eq.equipmentName, isEquipmentChargeable: isChargeable, equipmentBasePrice: basePrice, equipmentTotalPrice: totalPrice, }, }); // 💰 Taxes ONLY if chargeable if (isChargeable && taxDetails.length) { await tx.activityEquipmentTaxes.createMany({ data: taxDetails.map((t) => ({ activityEquipmentXid: equipment.id, taxXid: t.taxXid, taxPer: t.taxPer, taxAmount: t.taxAmount, })), }); } } } /* -------------------------------- * 🔟 CLEAN & CREATE TRAINER WITH TAXES * -------------------------------- */ const oldTrainerIds = ( await tx.activityTrainers.findMany({ where: { activityXid }, select: { id: true }, }) ).map((t) => t.id); if (oldTrainerIds.length) { await tx.activityTrainerTaxes.deleteMany({ where: { activityTrainerXid: { in: oldTrainerIds } }, }); await tx.activityTrainers.deleteMany({ where: { id: { in: oldTrainerIds } }, }); } if (payload.trainerAvailable) { const isChargeable = trainerIsChargeable; const totalAmount = isChargeable ? payload.trainerTotalAmount : 0; const { basePrice, taxDetails } = isChargeable && totalAmount > 0 ? computeBasePriceAndTaxes(totalAmount, rootTaxes) : { basePrice: 0, taxDetails: [] }; const trainer = await tx.activityTrainers.create({ data: { activityXid, baseAmount: basePrice, totalAmount, }, }); if (isChargeable && taxDetails.length) { await tx.activityTrainerTaxes.createMany({ data: taxDetails.map((t) => ({ activityTrainerXid: trainer.id, taxXid: t.taxXid, taxPer: t.taxPer, taxAmount: t.taxAmount, })), }); } } /* -------------------------------- * 1️⃣1️⃣ CLEAN & CREATE PICKUP/DROP TRANSPORTS (INDEPENDENT ARRAY) * -------------------------------- */ /* -------------------------------- * 1️⃣1️⃣ CLEAN OLD PICKUP/DROP TRANSPORT DATA (INDEPENDENT FROM NAVIGATION MODES) * -------------------------------- */ // Clean up old pickup transport modes (independent array) await tx.activityPickUpTransport.deleteMany({ where: { activityXid: Number(activityXid), }, }); // Clean up old pickup details and their taxes (independent from transport modes) const oldPickupDetailIds = ( await tx.activityPickUpDetails.findMany({ where: { activitiesXid: activityXid }, select: { id: true }, }) ).map((p) => p.id); if (oldPickupDetailIds.length) { await tx.activityPickUpTransportTaxes.deleteMany({ where: { activityPickUpDetailsXid: { in: oldPickupDetailIds }, }, }); await tx.activityPickUpDetails.deleteMany({ where: { id: { in: oldPickupDetailIds } }, }); } /* -------------------------------- * 1️⃣1️⃣ CREATE PICKUP TRANSPORTS (INDEPENDENT ARRAY - JUST TRANSPORT MODES) * -------------------------------- */ if (Array.isArray(payload.pickupTransports)) { for (const transport of payload.pickupTransports) { // ✅ CREATE TRANSPORT MODE INDEPENDENTLY (NO RELATION TO PICKUP DETAILS) await tx.activityPickUpTransport.create({ data: { activityXid: activityXid, transportModeXid: transport.transportModeXid, }, }); } } /* -------------------------------- * 1️⃣1️⃣ CREATE PICKUP DETAILS (INDEPENDENT ARRAY - SEPARATE FROM TRANSPORT MODES) * -------------------------------- */ if (Array.isArray(payload.pickupDetails)) { for (const detail of payload.pickupDetails) { const isChargeable = pickUpDropIsChargeable; // 🔒 HARD RULE: NOT chargeable → ALWAYS 0 const totalPrice = isChargeable ? toNumber(detail.transportTotalPrice) ?? 0 : 0; // ❌ Validate ONLY when chargeable + submit if (!isDraft && isChargeable && totalPrice <= 0) { throw new ApiError( 400, 'Pick-up and drop-off price is required.', ); } const { basePrice, taxDetails } = isChargeable && totalPrice > 0 ? computeBasePriceAndTaxes(totalPrice, rootTaxes) : { basePrice: 0, taxDetails: [] }; // ✅ ALWAYS CREATE PICKUP DETAIL const pickupDetail = await tx.activityPickUpDetails.create({ data: { activitiesXid: activityXid, isPickUp: toBool(detail.isPickUp), locationLat: toNumber(detail.locationLat), locationLong: toNumber(detail.locationLong), locationAddress: detail.locationAddress ?? null, // ✅ Guaranteed consistency transportBasePrice: basePrice, transportTotalPrice: totalPrice, }, }); // 💰 Taxes ONLY when chargeable if (isChargeable && taxDetails.length) { await tx.activityPickUpTransportTaxes.createMany({ data: taxDetails.map((t) => ({ activityPickUpDetailsXid: pickupDetail.id, taxXid: t.taxXid, taxPer: t.taxPer, taxAmount: t.taxAmount, })), }); } } } /* -------------------------------- * 1️⃣2️⃣ CLEAN & CREATE NAVIGATION MODES WITH TAXES * -------------------------------- */ const oldNavIds = ( await tx.activityNavigationModes.findMany({ where: { activityXid }, select: { id: true }, }) ).map((n) => n.id); if (oldNavIds.length) { await tx.activityNavigationModesTaxes.deleteMany({ where: { activityNavigationModeXid: { in: oldNavIds } }, }); await tx.activityNavigationModes.deleteMany({ where: { id: { in: oldNavIds } }, }); } /* -------------------------------- * 1️⃣2️⃣ CREATE NAVIGATION MODES (PER MODE) * -------------------------------- */ if (Array.isArray(payload.navigationModes)) { for (const mode of payload.navigationModes) { const isChargeable = toBool(mode.isChargeable); const totalPrice = isChargeable ? (toNumber(mode.totalPrice) ?? 0) : 0; if (!isDraft && isChargeable && totalPrice <= 0) { throw new ApiError( 400, 'totalPrice must be > 0 when navigation mode is chargeable', ); } let basePrice = 0; let taxDetails: Array<{ taxXid: number; taxPer: number; taxAmount: number; }> = []; if (isChargeable) { const result = computeBasePriceAndTaxes(totalPrice, rootTaxes); basePrice = result.basePrice; taxDetails = result.taxDetails; } /* 1️⃣ CREATE NAVIGATION MODE ROW */ const navMode = await tx.activityNavigationModes.create({ data: { activityXid, navigationModeName: mode.navigationModeName, isInActivityChargeable: isChargeable, navigationModesBasePrice: basePrice, navigationModesTotalPrice: totalPrice, }, }); /* 2️⃣ CREATE TAXES (ONLY IF CHARGEABLE) */ if (taxDetails.length) { await tx.activityNavigationModesTaxes.createMany({ data: taxDetails.map((t) => ({ activityNavigationModeXid: navMode.id, taxXid: t.taxXid, taxPer: t.taxPer, taxAmount: t.taxAmount, })), }); } } } /* -------------------------------- * 1️⃣3️⃣ CLEAN & CREATE AMENITIES * -------------------------------- */ await tx.activityAmenities.deleteMany({ where: { activityXid } }); if (Array.isArray(payload.amenitiesIds) && payload.amenitiesIds.length) { await tx.activityAmenities.createMany({ data: payload.amenitiesIds.map((amenityId) => ({ activityXid, amenitiesXid: amenityId, })), }); } /* -------------------------------- * 1️⃣4️⃣ CLEAN & CREATE ELIGIBILITY * -------------------------------- */ await tx.activityEligibility.deleteMany({ where: { activityXid } }); if (payload.eligibility) { await tx.activityEligibility.create({ data: { activityXid, isAgeRestriction: toBool(payload.eligibility.isAgeRestriction), ageRestrictionName: payload.eligibility.ageRestrictionName, ageEntered: payload.eligibility.ageEntered, ageIn: payload.eligibility.ageIn, minAge: payload.eligibility.minAge, maxAge: payload.eligibility.maxAge, isWeightRestriction: toBool( payload.eligibility.isWeightRestriction, ), weightRestrictionName: payload.eligibility.weightRestrictionName ?? null, weightEntered: toNumber(payload.eligibility.weightEntered), weightIn: payload.eligibility.weightIn ?? null, minWeight: toNumber(payload.eligibility.minWeight), maxWeight: toNumber(payload.eligibility.maxWeight), isHeightRestriction: toBool( payload.eligibility.isHeightRestriction, ), heightRestrictionName: payload.eligibility.heightRestrictionName ?? null, heightEntered: toNumber(payload.eligibility.heightEntered), heightIn: payload.eligibility.heightIn ?? null, minHeight: toNumber(payload.eligibility.minHeight), maxHeight: toNumber(payload.eligibility.maxHeight), }, }); } /* -------------------------------- * 1️⃣5️⃣ CLEAN & CREATE OTHER DETAILS * -------------------------------- */ await tx.activityOtherDetails.deleteMany({ where: { activityXid } }); if (payload.otherDetails) { await tx.activityOtherDetails.create({ data: { activityXid, exclusiveNotes: payload.otherDetails.exclusiveNotes ?? null, SafetyInstruction: (() => { const s = payload.otherDetails.safetyInstruction ?? null; if (s === undefined || s === null) return null; if (typeof s !== 'string') { throw new ApiError(400, 'safetyInstruction must be a string'); } return s; })(), Cancellations: (() => { const c = payload.otherDetails.cancellations ?? null; if (c === undefined || c === null) return null; if (typeof c !== 'string') { throw new ApiError(400, 'cancellations must be a string'); } return c; })(), dosNotes: payload.otherDetails.dosNotes ?? null, dontsNotes: payload.otherDetails.dontsNotes ?? null, tipsNotes: payload.otherDetails.tipsNotes ?? null, termsAndCondition: payload.otherDetails.termsAndCondition ?? null, }, }); } /* -------------------------------- * 1️⃣6️⃣ CLEAN & CREATE FOOD TYPES * -------------------------------- */ await tx.activityFoodTypes.deleteMany({ where: { activityXid } }); if (Array.isArray(payload.foodTypeIds) && payload.foodTypeIds.length) { await tx.activityFoodTypes.createMany({ data: payload.foodTypeIds.map((foodTypeId) => ({ activityXid, foodTypeXid: foodTypeId, })), }); } /* -------------------------------- * 1️⃣7️⃣ CLEAN & CREATE CUISINES * -------------------------------- */ await tx.activityCuisine.deleteMany({ where: { activityXid } }); if (Array.isArray(payload.cuisineIds) && payload.cuisineIds.length) { await tx.activityCuisine.createMany({ data: payload.cuisineIds.map((cuisineId) => ({ activityXid, foodCuisineXid: cuisineId, })), }); } const allowedEntryIds = Array.isArray(payload.allowedEntryTypes) ? payload.allowedEntryTypes.map(Number) : []; if (allowedEntryIds.length) { const validEntryTypes = await this.prisma.allowedEntryTypes.findMany({ where: { id: { in: allowedEntryIds }, isActive: true }, select: { id: true }, }); if (validEntryTypes.length !== allowedEntryIds.length) throw new ApiError( 400, 'Invalid or inactive allowed entry type(s) provided', ); } /* -------------------------------- * CLEAN & CREATE ALLOWED ENTRY * -------------------------------- */ await tx.activityAllowedEntry.deleteMany({ where: { activityXid } }); if (allowedEntryIds.length) { await tx.activityAllowedEntry.createMany({ data: allowedEntryIds.map((entryId) => ({ activityXid, allowedEntryTypeXid: entryId, })), }); } /* -------------------------------- * ✅ MARK ACTIVITY SUGGESTIONS AS REVIEWED * (REJECTED → ENHANCE → SUBMIT FLOW) * -------------------------------- */ if (wasRejected && !isDraft) { await tx.activitySuggestions.updateMany({ where: { activityXid: activityXid, isActive: true, isReviewed: false, }, data: { isReviewed: true, reviewedByXid: userId, reviewedOn: new Date(), }, }); } /* -------------------------------- * 1️⃣8️⃣ ACTIVITY TRACK * -------------------------------- */ await tx.activityTrack.create({ data: { activityXid, trackType: 'ACTIVITY', trackStatus: activityInternalStatus, updatedByXid: userId, updatedByRole: ROLE_NAME.HOST, updatedOn: new Date(), }, }); /* -------------------------------- * 1️⃣9️⃣ RESPONSE * -------------------------------- */ return { activityXid, activityRefNumber: activity.activityRefNumber, status: isDraft ? ACTIVITY_INTERNAL_STATUS.ACTIVITY_DRAFT : ACTIVITY_INTERNAL_STATUS.ACTIVITY_SUBMITTED, }; }); } async getAllPQUpdatedResponse(activityXid: number) { const pqqHeaderData = await this.prisma.activityPQQheader.findMany({ where: { activityXid: activityXid, isActive: true, }, select: { id: true, comments: true, pqqAnswerXid: true, 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, }, }, }, }, // 🔥 ALL ANSWER OPTIONS FOR THIS QUESTION PQQAnswers: { where: { isActive: true }, select: { id: true, answerName: true, answerPoints: true, displayOrder: true, }, orderBy: { displayOrder: 'asc' }, }, }, }, ActivityPQQSuggestions: { where: { isActive: true, isReviewed: false }, 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 // 1️⃣ Category level if (!grouped[cat.id]) { grouped[cat.id] = { id: cat.id, categoryName: cat.categoryName, displayOrder: cat.displayOrder, activityPqqHeaderId: item.id, // ✅ Added to match AM response pqqsubCategories: [], }; } else if (!grouped[cat.id].activityPqqHeaderId) { grouped[cat.id].activityPqqHeaderId = item.id; // Ensures it's set if missing } 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, pqqAnswerXid: item.pqqAnswerXid, comments: item.comments || null, displayOrder: q.displayOrder, allAnswerOptions: q.PQQAnswers || [], // 🔥 All answers 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; } }