Files
MinglarBackendNestJS/src/modules/host/services/host.service.ts

4501 lines
136 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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<typeof hostCompanyDetailsSchema>;
// 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, maxLength: number = 50) =>
v ? v.trim().toLowerCase().substring(0, maxLength) : 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 trimmedStateName = stateName.trim().substring(0, 50);
const state = await tx.states.findFirst({
where: {
stateName: { equals: trimmedStateName, mode: 'insensitive' },
countryXid,
isActive: true,
},
});
if (state) return state.id;
const created = await tx.states.create({
data: {
stateName: trimmedStateName,
countryXid,
},
});
return created.id;
};
const findOrCreateCity = async (
tx: any,
cityName?: string | null,
stateXid?: number | null,
) => {
if (!cityName || !stateXid) return null;
const trimmedCityName = cityName.trim().substring(0, 50);
const city = await tx.cities.findFirst({
where: {
cityName: { equals: trimmedCityName, mode: 'insensitive' },
stateXid,
isActive: true,
},
});
if (city) return city.id;
const created = await tx.cities.create({
data: {
cityName: trimmedCityName,
stateXid,
},
});
return created.id;
};
const bucket = config.aws.bucketName;
const s3 = new AWS.S3({
region: config.aws.region,
});
function getS3KeyFromStoredPath(path?: string | null) {
if (!path) return null;
return path.startsWith('http') ? path.split('.com/')[1] || null : path;
}
function resolveIncomingLogoPath(path?: string | null) {
if (typeof path !== 'string') return null;
const trimmed = path.trim();
return trimmed.length ? trimmed : null;
}
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) { }
private async getValidLogoUrl(
model: 'hostHeader' | 'hostParent',
recordId: number,
logoPath?: string | null,
) {
const key = getS3KeyFromStoredPath(logoPath);
if (!key) return null;
try {
await s3
.headObject({
Bucket: bucket,
Key: key,
})
.promise();
return await getPresignedUrl(bucket, key);
} catch (error: any) {
const statusCode = error?.statusCode;
const errorCode = error?.code;
const isMissingObject =
statusCode === 404 ||
errorCode === 'NotFound' ||
errorCode === 'NoSuchKey';
if (isMissingObject) {
await (this.prisma as any)[model].update({
where: { id: recordId },
data: { logoPath: null },
});
return null;
}
throw error;
}
}
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 },
select: {
id: true,
logoPath: true,
companyName: true,
address1: true,
address2: true,
pinCode: true,
isSubsidairy: true,
registrationNumber: true,
panNumber: true,
gstNumber: true,
formationDate: true,
companyTypeXid: true,
websiteUrl: true,
instagramUrl: true,
facebookUrl: true,
linkedinUrl: true,
twitterUrl: true,
stepper: true,
hostStatusInternal: true,
hostStatusDisplay: true,
adminStatusInternal: true,
adminStatusDisplay: true,
amStatus: true,
agreementAccepted: true,
assignedOn: true,
agreementStartDate: true,
isApproved: true,
durationNumber: true,
durationFrequency: true,
isCommisionBase: true,
commisionPer: true,
amountPerBooking: true,
payoutDurationNum: true,
payoutDurationFrequency: true,
referencedBy: true,
hostParent: {
select: {
id: true,
logoPath: true,
companyName: true,
firstName: true,
lastName: true,
mobileNumber: true,
address1: true,
address2: true,
cities: {
select: {
id: true,
cityName: true
}
},
states: {
select: {
id: true,
stateName: true
}
},
countries: {
select: {
id: true,
countryName: true
}
},
pinCode: true,
registrationNumber: true,
panNumber: true,
gstNumber: true,
formationDate: true,
companyTypes: {
select: {
id: true,
companyTypeName: true
}
},
websiteUrl: true,
instagramUrl: true,
facebookUrl: true,
linkedinUrl: true,
twitterUrl: true,
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,
dateOfBirth: 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 resolvedLogoUrl = await this.getValidLogoUrl(
'hostHeader',
host.id,
host.logoPath,
);
if (!resolvedLogoUrl) {
host.logoPath = null;
}
(host as any).logoPresignedUrl = resolvedLogoUrl;
}
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 resolvedParentLogoUrl = await this.getValidLogoUrl(
'hostParent',
parent.id,
parent.logoPath,
);
if (!resolvedParentLogoUrl) {
parent.logoPath = null;
}
(parent as any).logoPresignedUrl = resolvedParentLogoUrl;
}
// 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<User> {
return this.prisma.user.findUnique({ where: { emailAddress: email } });
}
async verifyHostOtp(email: string, otp: string): Promise<boolean> {
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<Partial<User>> {
// Find user by id
const user = await this.prisma.user.findUnique({
where: { id: user_xid, isActive: true },
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 user;
}
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 hostHeader = await this.prisma.hostHeader.findFirst({
where: { id: hostXid, isActive: true },
select: {
id: true,
isCommisionBase: true,
commisionPer: true,
durationNumber: true,
durationFrequency: true,
amountPerBooking: true,
agreementStartDate: true,
payoutDurationNum: true,
payoutDurationFrequency: true,
registrationNumber: true,
companyName: true,
companyTypes: {
select: {
id: true,
companyTypeName: true
}
}
}
});
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 both missing
if (!agreement && !hostHeader) {
throw new ApiError(404, 'No active agreement found for this host');
}
let presignedUrl = "";
if (agreement?.filePath) {
const key = agreement.filePath.startsWith('http')
? agreement.filePath.split('.com/')[1]
: agreement.filePath;
const bucket = config.aws.bucketName;
presignedUrl = await getPresignedUrl(bucket, key);
}
return {
hostHeader: hostHeader || null,
agreement: agreement
? {
...agreement,
presignedUrl
}
: null
};
}
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,
options?: {
deleteCompanyLogo?: boolean;
deleteParentCompanyLogo?: boolean;
},
) {
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.RE_SUBMITTED;
}
// 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,
firstName: parentCompanyData.firstName || null,
lastName: parentCompanyData.lastName || null,
mobileNumber: parentCompanyData.mobileNumber || 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,
} as any,
});
// 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: options?.deleteCompanyLogo
? null
: resolveIncomingLogoPath(companyData.logoPath) ??
existingHostCompany.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,
},
});
// // 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,
firstName: parentCompanyData.firstName || null,
lastName: parentCompanyData.lastName || null,
mobileNumber: parentCompanyData.mobileNumber || 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: options?.deleteParentCompanyLogo
? null
: resolveIncomingLogoPath(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,
} as any,
});
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,
firstName: parentCompanyData.firstName || null,
lastName: parentCompanyData.lastName || null,
mobileNumber: parentCompanyData.mobileNumber || 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: options?.deleteParentCompanyLogo
? null
: resolveIncomingLogoPath(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,
} as any,
});
// 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<string, number> = {};
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<object>} - 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');
}
const normalizedActivityTitle =
typeof payload.activityTitle === 'string'
? payload.activityTitle.trim()
: '';
if (normalizedActivityTitle) {
payload.activityTitle = normalizedActivityTitle;
const duplicateActivity = await tx.activities.findFirst({
where: {
id: { not: existingActivity.id },
isActive: true,
activityTitle: {
equals: normalizedActivityTitle,
mode: 'insensitive',
},
},
select: { id: true },
});
if (duplicateActivity) {
throw new ApiError(
400,
'Same activity name already exists. Please choose a different name.',
);
}
}
/* --------------------------------
* 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;
}
}