// src/modules/host/services/host.service.ts import { Injectable } from '@nestjs/common'; import { PrismaClient, User } from '@prisma/client'; import * as bcrypt from 'bcryptjs'; import { z } from 'zod'; import { getPresignedUrl } from '../../../common/middlewares/aws/getPreSignedUrl'; 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) { const lastrecord = await tx.activities.findFirst({ orderBy: { id: 'desc', }, select: { id: true, }, }); const nextId = lastrecord ? lastrecord.id + 1 : 1; return `ACT-${String(nextId).padStart(6, '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 bucket = config.aws.bucketName; @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 }, select: { id: true, emailAddress: 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, }, }, 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 } }); } async getHostByEmail(email: string): Promise { return this.prisma.user.findUnique({ where: { emailAddress: email } }); } async verifyHostOtp(email: string, otp: string): Promise { const user = await this.prisma.user.findUnique({ 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(otp, 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 hostDetails = await this.prisma.hostHeader.findFirst({ where: { userXid: user_xid }, select: { id: true, userXid: true, }, }); await this.prisma.hostHeader.update({ where: { id: hostDetails.id }, data: { stepper: STEPPER.AGREEMENT_ACCEPTED, isApproved: true, agreementAccepted: true, }, }); } 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: 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; const maxPoints = question.maxPoints; const userPoints = answer.answerPoints; totalUserPoints += userPoints; totalMaxPoints += maxPoints; // Category const category = question.pqqSubCategories.category; const categoryId = category.id; if (!categories[categoryId]) { categories[categoryId] = { categoryId, categoryName: category.categoryName, userPoints: 0, maxPoints: 0, }; } categories[categoryId].userPoints += userPoints; categories[categoryId].maxPoints += maxPoints; } // Overall percent const overallPercentage = totalMaxPoints > 0 ? 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, 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' }, }); } 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, }, }, 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, isInActivityChargeable: true, navigationModesTotalPrice: true, navigationMode: { select: { id: true, navigationModeName: true, navigationModeIcon: 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, }, }, 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, }, }, }, }, 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.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 host = await tx.hostHeader.findFirst({ where: { userXid: userId, isActive: true }, }); if (!host) throw new ApiError(404, 'Host not found for the user'); const activityType = await tx.activityTypes.findUnique({ where: { id: activityTypeXid }, }); if (!activityType) throw new ApiError(404, 'Activity type not found'); if (frequenciesXid) { const freq = await tx.frequencies.findUnique({ where: { id: frequenciesXid }, }); if (!freq) throw new ApiError(404, 'Frequency not found'); } const referenceNumber = await generateActivityRefNumber(tx); const created = await tx.activities.create({ data: { hostXid: host.id, activityTypeXid, frequenciesXid: frequenciesXid || null, activityInternalStatus: ACTIVITY_INTERNAL_STATUS.DRAFT_PQ, activityDisplayStatus: ACTIVITY_DISPLAY_STATUS.DRAFT_PQ, amInternalStatus: ACTIVITY_AM_INTERNAL_STATUS.DRAFT_PQ, amDisplayStatus: ACTIVITY_AM_DISPLAY_STATUS.DRAFT_PQ, activityRefNumber: referenceNumber, }, }); const questions = await tx.pQQCategories.findMany({ where: { isActive: true }, select: { id: true, categoryName: true, displayOrder: true, pqqsubCategories: { where: { isActive: true }, select: { id: true, subCategoryName: true, displayOrder: true, questions: { where: { isActive: true }, select: { id: true, questionName: true, maxPoints: true, displayOrder: true, }, orderBy: { displayOrder: 'asc' }, }, }, orderBy: { displayOrder: 'asc' }, }, }, orderBy: { displayOrder: 'asc' }, }); // FLATTEN questions const allQuestions: number[] = []; for (const cat of questions) { for (const sub of cat.pqqsubCategories) { for (const q of sub.questions) { allQuestions.push(q.id); } } } await tx.activityPQQheader.createMany({ data: allQuestions.map((id) => ({ activityXid: created.id, pqqQuestionXid: id, pqqAnswerXid: null, })), }); const pqqHeaderData = await tx.activityPQQheader.findMany({ where: { activityXid: created.id, isActive: true, }, select: { 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, 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; } } /* -------------------------------- * 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, }, }); 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, 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) && payload.equipments.length) { for (const eq of payload.equipments) { const isChargeable = toBool(eq.isEquipmentChargeable); const totalPrice = isChargeable ? toNumber(eq.equipmentTotalPrice) ?? 0 : 0; // On submit enforce > 0, on draft just skip invalid/zero if (!isDraft && isChargeable && totalPrice <= 0) { throw new ApiError( 400, 'equipmentTotalPrice must be > 0 when equipment is chargeable', ); } if (!isChargeable || totalPrice <= 0) continue; const { basePrice, taxDetails } = computeBasePriceAndTaxes( totalPrice, rootTaxes, ); const equipment = await tx.activityEquipments.create({ data: { activityXid, equipmentName: eq.equipmentName, isEquipmentChargeable: isChargeable, equipmentBasePrice: basePrice, equipmentTotalPrice: totalPrice, }, }); if (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, 'transportTotalPrice must be > 0 when pickup/drop is chargeable', ); } 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, navigationModeXid: mode.navigationModeXid, 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, 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; } }