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

3568 lines
109 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 * as bcrypt from 'bcryptjs';
import { z } from 'zod';
import { getPresignedUrl } from '../../../common/middlewares/aws/getPreSignedUrl';
import {
RESTRICTION_NAME,
ROLE,
ROLE_NAME,
USER_STATUS,
} from '../../../common/utils/constants/common.constant';
import {
ACTIVITY_AM_DISPLAY_STATUS,
ACTIVITY_AM_INTERNAL_STATUS,
ACTIVITY_DISPLAY_STATUS,
ACTIVITY_INTERNAL_STATUS,
HOST_STATUS_DISPLAY,
HOST_STATUS_INTERNAL,
STEPPER,
} from '../../../common/utils/constants/host.constant';
import {
ACTIVITY_TRACK_STATUS,
ACTIVITY_TRACK_TYPE,
MINGLAR_STATUS_DISPLAY,
MINGLAR_STATUS_INTERNAL,
} from '../../../common/utils/constants/minglar.constant';
import ApiError from '../../../common/utils/helper/ApiError';
import { hostCompanyDetailsSchema } from '../../../common/utils/validation/host/hostCompanyDetails.validation';
import config from '../../../config/config';
import { CreateActivityInput } from '../dto/createActivity.schema';
import {
AddPaymentDetailsDTO,
CreateHostDto,
UpdateHostDto,
} from '../dto/host.dto';
function sanitizeDocumentName(name?: string) {
if (!name) return null;
return name
.replace(/[^a-zA-Z0-9 _-]/g, '') // remove / .
.substring(0, 100);
}
type HostCompanyDetailsInput = z.infer<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) {
const lastrecord = await tx.activities.findFirst({
orderBy: {
id: 'desc',
},
select: {
id: true,
},
});
const nextId = lastrecord ? lastrecord.id + 1 : 1;
return `ACT-${String(nextId).padStart(6, '0')}`;
}
function round2(value: number) {
return Math.round(value);
}
function computeBasePriceAndTaxes(
sellPrice: number,
taxes: Array<{ id: number; taxPer: number }>,
) {
if (!taxes?.length) {
return {
basePrice: round2(sellPrice),
taxDetails: [] as Array<{
taxXid: number;
taxPer: number;
taxAmount: number;
}>,
};
}
const totalTaxPer = taxes.reduce(
(sum, t) => sum + (Number(t.taxPer) || 0),
0,
);
const denominator = 1 + totalTaxPer / 100;
const basePrice =
denominator > 0 ? round2(sellPrice / denominator) : round2(sellPrice);
const taxDetails = taxes.map((t) => ({
taxXid: t.id,
taxPer: t.taxPer,
taxAmount: round2(basePrice * (t.taxPer / 100)),
}));
return { basePrice, taxDetails };
}
const bucket = config.aws.bucketName;
@Injectable()
export class HostService {
constructor(private prisma: PrismaClient) { }
async createHost(data: CreateHostDto) {
return this.prisma.user.create({ data });
}
async getAllHosts() {
return this.prisma.user.findMany({ where: { roleXid: 3 } });
}
async getActivityDetailsById(activityXid: number) {
return this.prisma.activities.findFirst({ where: { id: activityXid } });
}
async getHostIdByUserXid(user_xid: number) {
const host = await this.prisma.hostHeader.findFirst({
where: { userXid: user_xid },
select: { id: true, stepper: true },
});
const user = await this.prisma.user.findUnique({
where: { id: user_xid },
select: { id: true, emailAddress: true },
});
return { host, user };
}
async getHostById(id: number) {
const host = await this.prisma.hostHeader.findFirst({
where: { userXid: id },
include: {
hostParent: {
include: {
HostParenetDocuments: {
select: {
id: true,
filePath: true,
documentName: true,
documentTypeXid: true,
documentType: true,
},
},
},
},
HostBankDetails: true,
HostDocuments: {
include: {
documentType: true,
},
},
accountManager: {
select: {
id: true,
firstName: true,
lastName: true,
emailAddress: true,
mobileNumber: true,
profileImage: true,
userRefNumber: true,
},
},
user: {
select: {
id: true,
emailAddress: true,
firstName: true,
lastName: true,
mobileNumber: true,
profileImage: true,
userStatus: true,
userRefNumber: true,
},
},
companyTypes: {
select: {
id: true,
companyTypeName: true,
},
},
HostSuggestion: {
where: {
isActive: true,
isreviewed: false,
},
select: {
id: true,
hostXid: true,
title: true,
comments: true,
isparent: true,
},
},
countries: true,
currencies: true,
states: true,
cities: true,
},
});
if (!host) {
// If host record doesn't exist yet, return stepper 1 (NOT_SUBMITTED)
// so callers (like the stepper endpoint) can show initial step.
return { stepper: STEPPER.NOT_SUBMITTED } as any;
}
if (host.HostDocuments?.length) {
for (const doc of host.HostDocuments) {
if (doc.filePath) {
const filePath = doc.filePath;
// If full URL is saved, extract only key
const key = filePath.startsWith('http')
? filePath.split('.com/')[1]
: filePath;
(doc as any).presignedUrl = await getPresignedUrl(bucket, key);
}
}
}
if (host.user?.profileImage) {
const key = host.user.profileImage.startsWith('http')
? host.user.profileImage.split('.com/')[1]
: host.user.profileImage;
host.user.profileImage = await getPresignedUrl(bucket, key);
}
if (host?.logoPath) {
const key = host.logoPath.startsWith('http')
? host.logoPath.split('.com/')[1]
: host.logoPath;
host.logoPath = await getPresignedUrl(bucket, key);
}
if (host.accountManager?.profileImage) {
const key = host.accountManager.profileImage.startsWith('http')
? host.accountManager.profileImage.split('.com/')[1]
: host.accountManager.profileImage;
host.accountManager.profileImage = await getPresignedUrl(bucket, key);
}
if (host.hostParent?.length) {
const parent = host.hostParent[0]; // since you allow only 1 parent
// Parent company logo
if (parent.logoPath) {
const key = parent.logoPath.startsWith('http')
? parent.logoPath.split('.com/')[1]
: parent.logoPath;
parent.logoPath = await getPresignedUrl(bucket, key);
}
// Parent documents
if (parent.HostParenetDocuments?.length) {
for (const doc of parent.HostParenetDocuments) {
if (doc.filePath) {
const key = doc.filePath.startsWith('http')
? doc.filePath.split('.com/')[1]
: doc.filePath;
(doc as any).presignedUrl = await getPresignedUrl(bucket, key);
}
}
}
}
return host;
}
async updateHost(id: number, data: UpdateHostDto) {
return this.prisma.user.update({
where: { id },
data,
});
}
async deleteHost(id: number) {
return this.prisma.user.delete({ where: { id } });
}
async getHostByEmail(email: string): Promise<User> {
return this.prisma.user.findUnique({ where: { emailAddress: email } });
}
async verifyHostOtp(email: string, otp: string): Promise<boolean> {
const user = await this.prisma.user.findUnique({
where: { emailAddress: email },
select: {
id: true,
emailAddress: true,
UserOtp: {
where: { isActive: true, isVerified: false },
orderBy: { createdAt: 'desc' },
take: 1,
},
},
});
if (!user) {
throw new ApiError(404, 'User not found.');
}
const userOtp = user.UserOtp[0];
if (!userOtp) {
throw new ApiError(400, 'No OTP found.');
}
if (new Date() > userOtp.expiresOn) {
throw new ApiError(400, 'OTP has expired.');
}
const isMatch = await bcrypt.compare(otp, userOtp.otpCode);
if (!isMatch) {
throw new ApiError(400, 'Invalid OTP.');
}
await this.prisma.userOtp.update({
where: { id: userOtp.id },
data: {
isVerified: true,
verifiedOn: new Date(),
isActive: false,
},
});
return true;
}
async loginForHost(emailAddress: string, userPassword: string) {
const existingUser = await this.prisma.user.findUnique({
where: { emailAddress: emailAddress, isActive: true },
select: {
id: true,
roleXid: true,
firstName: true,
lastName: true,
emailAddress: true,
mobileNumber: true,
userPassword: true,
userStatus: true,
},
});
if (!existingUser) {
throw new ApiError(404, 'User not found');
}
if (existingUser.userStatus == USER_STATUS.REJECTED) {
throw new ApiError(
403,
'You are not allowed to login. Please contact minglar admin.',
);
}
if (existingUser.roleXid !== 4) {
throw new ApiError(403, 'Access denied. Not a host user.');
}
const matchPassword = await bcrypt.compare(
userPassword,
existingUser.userPassword,
);
if (!matchPassword) {
throw new ApiError(401, 'Invalid credentials');
}
return existingUser;
}
async createMinglarUser(email: string) {
const newUser = await this.prisma.user.create({
data: { emailAddress: email, roleXid: ROLE.HOST },
});
return newUser;
}
async createPassword(user_xid: number, password: string): Promise<boolean> {
// Find user by id
const user = await this.prisma.user.findUnique({
where: { id: user_xid },
select: { id: true, emailAddress: true, userPassword: true },
});
if (!user) {
throw new ApiError(404, 'User not found');
}
// Check if password already exists
if (user.userPassword) {
throw new ApiError(
400,
'Password already exists. Use update password instead.',
);
}
// Hash the password
const saltRounds = parseInt(process.env.SALT_ROUNDS || '10', 10);
const hashedPassword = await bcrypt.hash(password, saltRounds);
// Update user with hashed password
await this.prisma.user.update({
where: { id: user.id },
data: {
userPassword: hashedPassword,
isEmailVerfied: true,
userStatus: USER_STATUS.ACTIVE,
},
});
return true;
}
async getBankBranchById(bankBranchXid: number) {
return await this.prisma.bankBranches.findUnique({
where: { id: bankBranchXid },
select: {
id: true,
ifscCode: true,
bankXid: true,
},
});
}
async addPaymentDetails(data: AddPaymentDetailsDTO) {
return await this.prisma.$transaction(async (tx) => {
const existingAccount = await tx.hostBankDetails.findFirst({
where: {
accountNumber: data.accountNumber,
isActive: true,
},
});
if (existingAccount) {
throw new ApiError(
400,
'Host account with this account number already exists.',
);
}
const addedPaymentDetails = await tx.hostBankDetails.create({
data,
});
if (!addedPaymentDetails) {
throw new ApiError(400, 'Failed to add payment details');
}
await tx.hostHeader.update({
where: { id: data.hostXid },
data: {
stepper: STEPPER.BANK_DETAILS_UPDATED,
currencyXid: data.currencyXid,
},
});
});
}
async getAllHostActivity(
search?: string,
user_xid?: number,
paginationOptions?: { page: number; limit: number; skip: number },
) {
const hostDetails = await this.prisma.hostHeader.findFirst({
where: { userXid: user_xid, isActive: true },
});
const whereClause: any = {
isActive: true,
hostXid: hostDetails.id,
};
if (!hostDetails) {
return {
data: [],
total: 0,
page: paginationOptions?.page || 1,
limit: paginationOptions?.limit || 10,
};
}
// 🔍 SEARCH (fixed)
if (search?.trim()) {
const term = search.trim();
whereClause.OR = [
{ activityRefNumber: { contains: term, mode: 'insensitive' } },
{ activityTitle: { contains: term, mode: 'insensitive' } },
{
activityType: {
activityTypeName: { contains: term, mode: 'insensitive' },
},
},
];
}
const [hostAllActivities, totalCount] = await Promise.all([
this.prisma.activities.findMany({
where: whereClause,
select: {
id: true,
activityRefNumber: true,
activityTitle: true,
totalScore: true,
activityInternalStatus: true,
activityDisplayStatus: true,
amInternalStatus: true,
amDisplayStatus: true,
createdAt: true,
checkInAddress: true,
frequency: {
select: {
id: true,
frequencyName: true,
},
},
ActivityAmDetails: {
select: {
accountManager: {
select: {
id: true,
firstName: true,
lastName: true,
profileImage: true,
emailAddress: true,
roleXid: true,
},
},
},
},
activityType: {
select: {
id: true,
activityTypeName: true,
interests: {
select: {
id: true,
interestName: true,
},
},
energyLevel: {
select: {
id: true,
energyLevelName: true,
energyIcon: true,
energyColor: true,
},
},
},
},
},
skip: paginationOptions?.skip || 0,
take: paginationOptions?.limit || 10,
orderBy: { id: 'desc' },
}),
this.prisma.activities.count({ where: whereClause }),
]);
for (const activity of hostAllActivities) {
/** 2⃣ Process AM Profile Image */
const am = activity.ActivityAmDetails?.[0]?.accountManager;
if (am?.profileImage) {
const key = am.profileImage.startsWith('http')
? am.profileImage.split('.com/')[1]
: am.profileImage;
const presignedUrl = await getPresignedUrl(bucket, key);
activity.ActivityAmDetails[0].accountManager = {
...am,
profileImage: presignedUrl,
};
}
}
const {
paginationService,
} = require('@/common/utils/pagination/pagination.service');
return paginationService.createPaginatedResponse(
hostAllActivities,
totalCount,
paginationOptions || { page: 1, limit: 10, skip: 0 },
);
}
async acceptMinglarAgreement(user_xid: number) {
const hostDetails = await this.prisma.hostHeader.findFirst({
where: { userXid: user_xid },
select: {
id: true,
userXid: true,
},
});
await this.prisma.hostHeader.update({
where: { id: hostDetails.id },
data: {
stepper: STEPPER.AGREEMENT_ACCEPTED,
isApproved: true,
agreementAccepted: true,
},
});
}
async getPQQQuestionDetail(question_xid: number, activity_xid: number) {
const detailsOfQuestion = await this.prisma.activityPQQheader.findFirst({
where: {
activityXid: activity_xid,
pqqQuestionXid: question_xid,
isActive: true,
},
select: {
pqqQuestionXid: true,
pqqAnswerXid: true,
ActivityPQQSupportings: {
select: {
id: true,
activityPqqHeaderXid: true,
mediaFileName: true,
mediaType: true,
},
},
ActivityPQQSuggestions: {
where: { isActive: true, isReviewed: false },
select: {
id: true,
title: true,
comments: true,
},
},
},
});
if (detailsOfQuestion.ActivityPQQSupportings?.length) {
for (const doc of detailsOfQuestion.ActivityPQQSupportings) {
if (doc.mediaFileName) {
const filePath = doc.mediaFileName;
// If full URL is saved, extract only key
const key = filePath.startsWith('http')
? filePath.split('.com/')[1]
: filePath;
(doc as any).presignedUrl = await getPresignedUrl(bucket, key);
}
}
}
return detailsOfQuestion;
}
async getLatestQuestionDetailsPQQ(activity_xid: number) {
return await this.prisma.activityPQQheader.findFirst({
where: {
activityXid: activity_xid,
isActive: true,
pqqAnswerXid: {
not: null,
},
},
select: {
pqqQuestionXid: true,
pqqAnswerXid: true,
pqqQuestions: {
select: {
pqqSubCategoryXid: true,
pqqSubCategories: {
select: {
categoryXid: true,
},
},
},
},
},
orderBy: { id: 'desc' },
});
}
async getParentDocumentsByHostId(userId: number) {
const host = await this.prisma.hostHeader.findFirst({
where: { userXid: userId },
select: { id: true },
});
if (!host) return [];
const parents = await this.prisma.hostParent.findMany({
where: { hostXid: host.id },
include: { HostParenetDocuments: true },
});
return parents.flatMap((p) => p.HostParenetDocuments);
}
async deleteExistingParentRecords(userId: number) {
const host = await this.prisma.hostHeader.findFirst({
where: { userXid: userId },
select: { id: true },
});
if (!host) return;
const parents = await this.prisma.hostParent.findMany({
where: { hostXid: host.id },
select: { id: true },
});
if (!parents.length) return;
const parentIds = parents.map((p) => p.id);
// 1⃣ Delete documents first
await this.prisma.hostParenetDocuments.deleteMany({
where: { hostParentXid: { in: parentIds } },
});
// 2⃣ Then delete parent records
await this.prisma.hostParent.deleteMany({
where: { id: { in: parentIds } },
});
}
async addOrUpdateCompanyDetails(
user_xid: number,
companyData: HostCompanyDetailsInput,
documents: HostDocumentInput[],
parentCompanyData?: any | null,
parentDocuments?: HostDocumentInput[],
isDraft: boolean = false,
) {
return await this.prisma.$transaction(async (tx) => {
// Check if host already has a company
const existingHostCompany = await tx.hostHeader.findFirst({
where: { userXid: user_xid },
include: { hostParent: true },
});
console.log(existingHostCompany, '-: Existing hai');
let existingParentCompany;
if (existingHostCompany) {
existingParentCompany = await tx.hostParent.findFirst({
where: { hostXid: existingHostCompany.id },
select: {
id: true,
logoPath: true,
},
});
}
let hostStatusInternal;
let hostStatusDisplay;
let minglarStatusInternal;
let minglarStatusDisplay;
if (existingHostCompany) {
hostStatusInternal = existingHostCompany.hostStatusInternal;
hostStatusDisplay = existingHostCompany.hostStatusDisplay;
minglarStatusInternal = existingHostCompany.adminStatusInternal;
minglarStatusDisplay = existingHostCompany.adminStatusDisplay;
}
// CASE 1: Host was asked to update AND is submitting final
if (
existingHostCompany &&
existingHostCompany.hostStatusInternal ===
HOST_STATUS_INTERNAL.HOST_TO_UPDATE &&
!isDraft
) {
hostStatusInternal = HOST_STATUS_INTERNAL.HOST_SUBMITTED;
hostStatusDisplay = HOST_STATUS_DISPLAY.UNDER_REVIEW;
minglarStatusInternal = MINGLAR_STATUS_INTERNAL.AM_TO_REVIEW;
minglarStatusDisplay = MINGLAR_STATUS_DISPLAY.TO_REVIEW;
}
// CASE 2: Host was asked to update BUT saving draft
else if (
existingHostCompany &&
existingHostCompany.hostStatusInternal ===
HOST_STATUS_INTERNAL.HOST_TO_UPDATE &&
isDraft
) {
// keep original
hostStatusInternal = existingHostCompany.hostStatusInternal;
hostStatusDisplay = existingHostCompany.hostStatusDisplay;
minglarStatusInternal = existingHostCompany.adminStatusInternal;
minglarStatusDisplay = existingHostCompany.adminStatusDisplay;
}
// CASE 3: Normal create or update
else {
hostStatusInternal = isDraft
? HOST_STATUS_INTERNAL.DRAFT
: HOST_STATUS_INTERNAL.HOST_SUBMITTED;
hostStatusDisplay = isDraft
? HOST_STATUS_DISPLAY.DRAFT
: HOST_STATUS_DISPLAY.UNDER_REVIEW;
minglarStatusInternal = isDraft
? MINGLAR_STATUS_INTERNAL.DRAFT
: MINGLAR_STATUS_INTERNAL.ADMIN_TO_REVIEW;
minglarStatusDisplay = isDraft
? MINGLAR_STATUS_DISPLAY.DRAFT
: MINGLAR_STATUS_DISPLAY.NEW;
}
const stepper = isDraft ? STEPPER.NOT_SUBMITTED : STEPPER.UNDER_REVIEW;
// -------------------------------------------------------
// CREATE FLOW
// -------------------------------------------------------
if (!existingHostCompany) {
if (!isDraft) {
console.log('First time direct final submit.');
const existingByPan = await tx.hostHeader.findFirst({
where: { panNumber: companyData.panNumber },
});
if (existingByPan)
throw new ApiError(
400,
'Company already exists with this pan/bin number',
);
}
console.log('First Time Aaya hai');
const createdHost = await tx.hostHeader.create({
data: {
user: { connect: { id: user_xid } },
companyName: companyData.companyName,
address1: companyData.address1,
address2: companyData.address2,
cities: companyData.cityXid
? { connect: { id: companyData.cityXid } }
: undefined,
states: companyData.stateXid
? { connect: { id: companyData.stateXid } }
: undefined,
countries: companyData.countryXid
? { connect: { id: companyData.countryXid } }
: undefined,
pinCode: companyData.pinCode,
logoPath: companyData.logoPath || null,
isSubsidairy: companyData.isSubsidairy,
registrationNumber: companyData.registrationNumber,
panNumber: companyData.panNumber,
gstNumber: companyData.gstNumber || null,
formationDate: companyData.formationDate
? new Date(companyData.formationDate as any)
: null,
companyTypes: companyData.companyTypeXid
? { connect: { id: companyData.companyTypeXid } }
: undefined,
referencedBy: companyData.referencedBy || null,
websiteUrl: companyData.websiteUrl || null,
instagramUrl: companyData.instagramUrl || null,
facebookUrl: companyData.facebookUrl || null,
linkedinUrl: companyData.linkedinUrl || null,
twitterUrl: companyData.twitterUrl || null,
stepper,
hostStatusInternal,
hostStatusDisplay,
adminStatusInternal: minglarStatusInternal,
adminStatusDisplay: minglarStatusDisplay,
},
});
// host documents
if (documents?.length) {
const docsData = documents.map((doc) => ({
hostXid: createdHost.id,
documentTypeXid: doc.documentTypeXid,
documentName: sanitizeDocumentName(doc.documentName),
filePath: doc.filePath,
}));
await tx.hostDocuments.createMany({ data: docsData });
}
// parent create
if (companyData.isSubsidairy && parentCompanyData) {
console.log('Parent ke saath aaya hai first time.');
const createdParent = await tx.hostParent.create({
data: {
host: { connect: { id: createdHost.id } },
companyName: parentCompanyData.companyName || null,
address1: parentCompanyData.address1 || null,
address2: parentCompanyData.address2 || null,
// Safely handle city connection - only connect if valid ID exists
cities:
parentCompanyData?.cityXid &&
!isNaN(Number(parentCompanyData.cityXid))
? { connect: { id: Number(parentCompanyData.cityXid) } }
: undefined,
states:
parentCompanyData?.stateXid &&
!isNaN(Number(parentCompanyData.stateXid))
? { connect: { id: Number(parentCompanyData.stateXid) } }
: undefined,
countries:
parentCompanyData?.countryXid &&
!isNaN(Number(parentCompanyData.countryXid))
? { connect: { id: Number(parentCompanyData.countryXid) } }
: undefined,
pinCode: parentCompanyData.pinCode || null,
logoPath: parentCompanyData.logoPath || null,
registrationNumber: parentCompanyData.registrationNumber || null,
panNumber: parentCompanyData.panNumber || null,
gstNumber: parentCompanyData.gstNumber || null,
formationDate: parentCompanyData.formationDate
? new Date(parentCompanyData.formationDate as any)
: null,
companyTypes: parentCompanyData.companyTypeXid
? { connect: { id: parentCompanyData.companyTypeXid } }
: undefined,
websiteUrl: parentCompanyData.websiteUrl || null,
instagramUrl: parentCompanyData.instagramUrl || null,
facebookUrl: parentCompanyData.facebookUrl || null,
linkedinUrl: parentCompanyData.linkedinUrl || null,
twitterUrl: parentCompanyData.twitterUrl || null,
},
});
// parent docs
if (parentDocuments?.length) {
const parentDocsData = parentDocuments.map((doc) => ({
hostParentXid: createdParent.id,
documentTypeXid: doc.documentTypeXid,
documentName: sanitizeDocumentName(doc.documentName),
filePath: doc.filePath,
}));
await tx.hostParenetDocuments.createMany({ data: parentDocsData });
}
}
// ⭐ FIX — TRACK USING createdHost (no null risk)
await tx.hostTrack.create({
data: {
hostXid: createdHost.id,
updatedByRole: ROLE_NAME.HOST,
updatedByXid: user_xid,
trackStatus: createdHost.hostStatusInternal,
},
});
return createdHost;
}
// -------------------------------------------------------
// UPDATE FLOW
// -------------------------------------------------------
const updatedHost = await tx.hostHeader.update({
where: { id: existingHostCompany.id },
data: {
companyName: companyData.companyName,
address1: companyData.address1,
address2: companyData.address2,
// Safely handle city connection - only connect if valid ID exists
cities:
companyData.cityXid && !isNaN(Number(companyData.cityXid))
? { connect: { id: Number(companyData.cityXid) } }
: undefined, // Don't change if not provided
// Same for state
states:
companyData.stateXid && !isNaN(Number(companyData.stateXid))
? { connect: { id: Number(companyData.stateXid) } }
: undefined,
// Same for country
countries:
companyData.countryXid && !isNaN(Number(companyData.countryXid))
? { connect: { id: Number(companyData.countryXid) } }
: undefined,
pinCode: companyData.pinCode,
logoPath: companyData.logoPath || existingHostCompany.logoPath,
isSubsidairy: companyData.isSubsidairy,
registrationNumber: companyData.registrationNumber,
panNumber: companyData.panNumber,
gstNumber: companyData.gstNumber || null,
formationDate: companyData.formationDate
? new Date(companyData.formationDate as any)
: null,
companyTypes: companyData.companyTypeXid
? { connect: { id: companyData.companyTypeXid } }
: undefined,
referencedBy: companyData.referencedBy || null,
websiteUrl: companyData.websiteUrl || null,
instagramUrl: companyData.instagramUrl || null,
facebookUrl: companyData.facebookUrl || null,
linkedinUrl: companyData.linkedinUrl || null,
twitterUrl: companyData.twitterUrl || null,
stepper,
hostStatusInternal,
hostStatusDisplay,
adminStatusInternal: minglarStatusInternal,
adminStatusDisplay: minglarStatusDisplay,
},
});
// // documents UPSERT
// if (documents?.length) {
// for (const doc of documents) {
// if (!doc.filePath) continue;
// const existingDoc = await tx.hostDocuments.findFirst({
// where: {
// hostXid: updatedHost.id,
// documentTypeXid: doc.documentTypeXid,
// },
// });
// if (existingDoc) {
// await tx.hostDocuments.update({
// where: { id: existingDoc.id },
// data: {
// filePath: doc.filePath,
// documentName:
// sanitizeDocumentName(doc.documentName) ||
// existingDoc.documentName,
// },
// });
// } else {
// await tx.hostDocuments.create({
// data: {
// hostXid: updatedHost.id,
// documentTypeXid: doc.documentTypeXid,
// documentName: sanitizeDocumentName(doc.documentName),
// filePath: doc.filePath,
// },
// });
// }
// }
// }
// documents handling (FINAL FIX)
if (documents?.length) {
for (const doc of documents) {
if (!doc.filePath) continue;
// 🔹 CUSTOM DOCUMENTS → ALWAYS CREATE
if (doc.documentTypeXid === 9) {
await tx.hostDocuments.create({
data: {
hostXid: updatedHost.id,
documentTypeXid: doc.documentTypeXid,
documentName: sanitizeDocumentName(doc.documentName),
filePath: doc.filePath,
},
});
continue;
}
// 🔹 NORMAL DOCUMENTS → UPSERT (ONE PER TYPE)
const existingDoc = await tx.hostDocuments.findFirst({
where: {
hostXid: updatedHost.id,
documentTypeXid: doc.documentTypeXid,
},
});
if (existingDoc) {
await tx.hostDocuments.update({
where: { id: existingDoc.id },
data: {
filePath: doc.filePath,
documentName:
sanitizeDocumentName(doc.documentName) ||
existingDoc.documentName,
},
});
} else {
await tx.hostDocuments.create({
data: {
hostXid: updatedHost.id,
documentTypeXid: doc.documentTypeXid,
documentName: sanitizeDocumentName(doc.documentName),
filePath: doc.filePath,
},
});
}
}
}
// parent logic untouched
if (companyData.isSubsidairy) {
const parentRecords = existingHostCompany.hostParent;
const parentRecord = Array.isArray(parentRecords)
? parentRecords[0]
: parentRecords;
console.log('Yaha aaya update in the apretn me');
if (!parentRecord) {
console.log('Parent record nahi mila to create kar raha hai.');
const createdParent = await tx.hostParent.create({
data: {
host: { connect: { id: updatedHost.id } },
companyName: parentCompanyData.companyName || null,
address1: parentCompanyData.address1 || null,
address2: parentCompanyData.address2 || null,
cities:
parentCompanyData?.cityXid &&
!isNaN(Number(parentCompanyData.cityXid))
? { connect: { id: Number(parentCompanyData.cityXid) } }
: undefined,
states:
parentCompanyData?.stateXid &&
!isNaN(Number(parentCompanyData.stateXid))
? { connect: { id: Number(parentCompanyData.stateXid) } }
: undefined,
countries:
parentCompanyData?.countryXid &&
!isNaN(Number(parentCompanyData.countryXid))
? { connect: { id: Number(parentCompanyData.countryXid) } }
: undefined,
pinCode: parentCompanyData.pinCode || null,
logoPath:
parentCompanyData?.logoPath ||
existingParentCompany?.logoPath ||
null,
registrationNumber: parentCompanyData.registrationNumber || null,
panNumber: parentCompanyData.panNumber || null,
gstNumber: parentCompanyData.gstNumber || null,
formationDate: parentCompanyData.formationDate
? new Date(parentCompanyData.formationDate as any)
: null,
companyTypes: parentCompanyData.companyTypeXid
? { connect: { id: parentCompanyData.companyTypeXid } }
: undefined,
websiteUrl: parentCompanyData.websiteUrl || null,
instagramUrl: parentCompanyData.instagramUrl || null,
facebookUrl: parentCompanyData.facebookUrl || null,
linkedinUrl: parentCompanyData.linkedinUrl || null,
twitterUrl: parentCompanyData.twitterUrl || null,
},
});
if (parentDocuments?.length) {
for (const doc of parentDocuments) {
await tx.hostParenetDocuments.create({
data: {
hostParentXid: createdParent.id,
documentTypeXid: doc.documentTypeXid,
documentName: sanitizeDocumentName(doc.documentName),
filePath: doc.filePath,
},
});
}
}
} else {
await tx.hostParent.update({
where: { id: parentRecord.id },
data: {
companyName: parentCompanyData.companyName || null,
address1: parentCompanyData.address1 || null,
address2: parentCompanyData.address2 || null,
cities:
parentCompanyData?.cityXid &&
!isNaN(Number(parentCompanyData.cityXid))
? { connect: { id: Number(parentCompanyData.cityXid) } }
: undefined,
states:
parentCompanyData?.stateXid &&
!isNaN(Number(parentCompanyData.stateXid))
? { connect: { id: Number(parentCompanyData.stateXid) } }
: undefined,
countries:
parentCompanyData?.countryXid &&
!isNaN(Number(parentCompanyData.countryXid))
? { connect: { id: Number(parentCompanyData.countryXid) } }
: undefined,
pinCode: parentCompanyData.pinCode || null,
logoPath:
parentCompanyData?.logoPath ||
existingParentCompany?.logoPath ||
null,
registrationNumber: parentCompanyData.registrationNumber || null,
panNumber: parentCompanyData.panNumber || null,
gstNumber: parentCompanyData.gstNumber || null,
formationDate: parentCompanyData.formationDate
? new Date(parentCompanyData.formationDate as any)
: null,
companyTypes: parentCompanyData.companyTypeXid
? { connect: { id: parentCompanyData.companyTypeXid } }
: undefined,
websiteUrl: parentCompanyData.websiteUrl || null,
instagramUrl: parentCompanyData.instagramUrl || null,
facebookUrl: parentCompanyData.facebookUrl || null,
linkedinUrl: parentCompanyData.linkedinUrl || null,
twitterUrl: parentCompanyData.twitterUrl || null,
},
});
// if (parentDocuments?.length) {
// for (const doc of parentDocuments) {
// const existingParentDoc = await tx.hostParenetDocuments.findFirst(
// {
// where: {
// hostParentXid: parentRecord.id,
// documentTypeXid: doc.documentTypeXid,
// },
// },
// );
// if (existingParentDoc) {
// await tx.hostParenetDocuments.update({
// where: { id: existingParentDoc.id },
// data: {
// filePath: doc.filePath,
// documentName:
// sanitizeDocumentName(doc.documentName) ||
// existingParentDoc.documentName,
// },
// });
// } else {
// await tx.hostParenetDocuments.create({
// data: {
// hostParentXid: parentRecord.id,
// documentTypeXid: doc.documentTypeXid,
// documentName: sanitizeDocumentName(doc.documentName),
// filePath: doc.filePath,
// },
// });
// }
// }
// }
if (parentDocuments?.length) {
const parentDocsData = parentDocuments
.filter((doc) => doc.filePath)
.map((doc) => ({
hostParentXid: parentRecord.id,
documentTypeXid: doc.documentTypeXid,
documentName: sanitizeDocumentName(doc.documentName),
filePath: doc.filePath,
}));
if (parentDocsData.length) {
await tx.hostParenetDocuments.createMany({
data: parentDocsData,
});
}
}
}
} else {
console.log('Last ke else block me aaya hai');
const previousParent = existingHostCompany.hostParent;
let prevParentId = null;
if (Array.isArray(previousParent) && previousParent.length) {
prevParentId = previousParent[0].id;
} else if (
previousParent &&
typeof previousParent === 'object' &&
'id' in previousParent
) {
prevParentId = previousParent.id;
}
if (prevParentId) {
await tx.hostParenetDocuments.deleteMany({
where: { hostParentXid: prevParentId },
});
await tx.hostParent.delete({ where: { id: prevParentId } });
}
}
// ⭐ FIX — USE updatedHost instead of re-querying hostHeader
await tx.hostTrack.create({
data: {
hostXid: updatedHost.id,
updatedByRole: ROLE_NAME.HOST,
updatedByXid: user_xid,
trackStatus: updatedHost.hostStatusInternal,
},
});
// suggestion update unchanged
if (!isDraft) {
await tx.hostSuggestion.updateMany({
where: { hostXid: updatedHost.id, isActive: true, isreviewed: false },
data: {
isreviewed: true,
reviewedByXid: user_xid,
reviewOn: new Date(),
},
});
}
return updatedHost;
});
}
async getSuggestionDetails(user_xid: number) {
const hostDetails = await this.prisma.hostHeader.findFirst({
where: { userXid: user_xid, isActive: true },
include: {
user: {
select: {
id: true,
emailAddress: true,
firstName: true,
userRefNumber: true,
},
},
accountManager: {
select: {
id: true,
emailAddress: true,
firstName: true,
},
},
},
});
if (!hostDetails) {
return { hostSuggestionDetails: [], hostDetails: null };
}
const hostSuggestionDetails = await this.prisma.hostSuggestion.findMany({
where: { hostXid: hostDetails.id, isActive: true, isreviewed: false },
});
if (hostSuggestionDetails) {
await this.prisma.hostSuggestion.updateMany({
where: { hostXid: hostDetails.id, isActive: true, isreviewed: false },
data: {
isreviewed: true,
reviewedByXid: hostDetails.id,
reviewOn: new Date(),
},
});
}
return { hostSuggestionDetails, hostDetails };
}
// async createOrUpdateHeader(
// activityXid: number,
// pqqQuestionXid: number,
// pqqAnswerXid: number,
// comments: string | null
// ) {
// // find existing header
// const existing = await this.prisma.activityPQQheader.findFirst({
// where: { activityXid, pqqQuestionXid, deletedAt: null }
// });
// if (!existing) {
// return await this.prisma.activityPQQheader.create({
// data: {
// activityXid,
// pqqQuestionXid,
// pqqAnswerXid,
// comments
// }
// });
// }
// // mark old supportings deleted
// await this.prisma.activityPQQSupportings.updateMany({
// where: { activityPqqHeaderXid: existing.id },
// data: {
// isActive: false,
// deletedAt: new Date()
// }
// });
// // update header
// return await this.prisma.activityPQQheader.update({
// where: { id: existing.id },
// data: {
// pqqAnswerXid,
// comments
// }
// });
// }
// async addSupportingFile(
// headerId: number,
// mimeType: string,
// fileUrl: string
// ) {
// return await this.prisma.activityPQQSupportings.create({
// data: {
// activityPqqHeaderXid: headerId,
// mediaType: mimeType,
// mediaFileName: fileUrl
// }
// });
// }
async calculatePqqScoreForUser(activityXid: number) {
return await this.prisma.$transaction(async (tx) => {
// 1. Get all headers for this activity (user's answers)
const answers = await this.prisma.activityPQQheader.findMany({
where: { activityXid, isActive: true },
include: {
pqqQuestions: {
include: {
pqqSubCategories: {
include: {
category: true,
},
},
},
},
pqqAnswers: true,
},
});
if (!answers.length) {
return {
overallPercentage: 0,
categoryWise: {},
};
}
// Prepare accumulators
let totalUserPoints = 0;
let totalMaxPoints = 0;
// For category-wise scoring
const categories: Record<
number,
{
categoryId: number;
categoryName: string;
userPoints: number;
maxPoints: number;
}
> = {};
for (const item of answers) {
const question = item.pqqQuestions;
const answer = item.pqqAnswers;
const maxPoints = question.maxPoints;
const userPoints = answer.answerPoints;
totalUserPoints += userPoints;
totalMaxPoints += maxPoints;
// Category
const category = question.pqqSubCategories.category;
const categoryId = category.id;
if (!categories[categoryId]) {
categories[categoryId] = {
categoryId,
categoryName: category.categoryName,
userPoints: 0,
maxPoints: 0,
};
}
categories[categoryId].userPoints += userPoints;
categories[categoryId].maxPoints += maxPoints;
}
// Overall percent
const overallPercentage =
totalMaxPoints > 0
? round2((totalUserPoints / totalMaxPoints) * 100)
: 0;
// ---------- 🔥 ONLY FIRST 2 CATEGORIES ----------
const categoryArray = Object.values(categories);
// Sort by categoryId (or change to displayOrder if needed)
categoryArray.sort((a, b) => a.categoryId - b.categoryId);
// Take only first 2 categories
const topTwo = categoryArray.slice(0, 2);
const categoryWise: Record<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,
pqqQuestions: {
select: {
questionName: true,
maxPoints: true,
displayOrder: true,
pqqSubCategories: {
select: {
id: true,
subCategoryName: true,
displayOrder: true,
category: {
select: {
id: true,
categoryName: true,
displayOrder: true,
},
},
},
},
},
},
ActivityPQQSuggestions: {
select: {
id: true,
title: true,
comments: true,
isReviewed: true,
reviewedBy: true,
reviewedOn: true,
},
},
pqqAnswers: {
select: {
id: true,
displayOrder: true,
answerName: true,
answerPoints: true,
},
},
ActivityPQQSupportings: {
select: {
id: true,
mediaFileName: true,
mediaType: true,
},
},
},
});
}
async getAllActivityTypesWithInterest(search?: string) {
const where: any = {
isActive: true,
deletedAt: null,
};
if (search && search.trim() !== '') {
const q = search.trim();
where.OR = [
{ activityTypeName: { contains: q, mode: 'insensitive' } },
{ interests: { interestName: { contains: q, mode: 'insensitive' } } },
];
}
return await this.prisma.activityTypes.findMany({
where,
select: {
id: true,
activityTypeName: true,
interestXid: true,
interests: {
select: {
id: true,
interestName: true,
displayOrder: true,
},
},
},
orderBy: { activityTypeName: 'asc' },
});
}
async getAllDetailsOfActivityAndVenue(activityXid: number) {
const activity = await this.prisma.activities.findFirst({
where: { id: activityXid, isActive: true },
select: {
id: true,
activityTitle: true,
activityDescription: true,
activityDisplayStatus: true,
activityInternalStatus: true,
activityRefNumber: true,
checkInAddress: true,
checkInLat: true,
checkInLong: true,
checkOutAddress: true,
checkOutLat: true,
checkOutLong: true,
pickUpDropAvailable: true,
pickUpDropIsChargeable: true,
inActivityAvailable: true,
activityDurationMins: true,
totalScore: true,
isCheckOutSame: true,
activityType: {
select: {
id: true,
activityTypeName: true,
interests: {
select: {
id: true,
interestName: true,
},
},
energyLevel: {
select: {
id: true,
energyLevelName: true,
energyIcon: true,
energyColor: true,
displayOrder: true,
},
},
},
},
ActivitiesMedia: {
where: {
isActive: true,
},
select: {
id: true,
mediaType: true,
mediaFileName: true,
},
},
ActivityVenues: {
where: {
isActive: true,
},
select: {
id: true,
venueName: true,
venueLabel: true,
venueCapacity: true,
availableSeats: true,
isMinPeopleReqMandatory: true,
minPeopleRequired: true,
minReqfullfilledBeforeMins: true,
venueDescription: true,
ActivityVenueArtifacts: {
select: {
id: true,
mediaType: true,
mediaFileName: true,
},
},
ActivityPrices: {
select: {
id: true,
noOfSession: true,
isPackage: true,
sessionValidity: true,
sessionValidityFrequency: true,
sellPrice: true,
ActivityPriceTaxes: {
select: {
id: true,
taxXid: true,
taxPer: true,
taxes: {
select: {
id: true,
countryXid: true,
country: {
select: {
countryName: true,
countryCode: true,
countryFlag: true,
},
},
taxName: true,
taxPer: true,
},
},
},
},
},
orderBy: { createdAt: 'asc' },
},
},
},
ActivityPickUpDetails: {
where: {
isActive: true,
},
select: {
id: true,
isPickUp: true,
locationAddress: true,
locationLat: true,
locationLong: true,
transportTotalPrice: true,
transportBasePrice: true,
},
},
activityPickUpTransports: {
where: {
isActive: true,
},
select: {
transportModeXid: true,
transportMode: {
select: {
transportModeName: true,
transportModeIcon: true,
},
},
},
},
foodAvailable: true,
foodIsChargeable: true,
activityFoodTypes: {
where: { isActive: true },
select: {
id: true,
foodType: {
select: {
id: true,
foodTypeName: true,
},
},
},
},
ActivityFoodCost: {
where: {
isActive: true,
},
select: {
id: true,
totalAmount: true,
},
},
activityCuisines: {
where: {
isActive: true,
},
select: {
id: true,
foodCuisine: {
select: {
id: true,
cuisineName: true,
},
},
},
},
alcoholAvailable: true,
trainerAvailable: true,
trainerIsChargeable: true,
ActivityTrainers: {
where: {
isActive: true,
},
select: {
id: true,
totalAmount: true,
},
},
ActivityNavigationModes: {
where: {
isActive: true,
},
select: {
id: true,
isInActivityChargeable: true,
navigationModesTotalPrice: true,
navigationMode: {
select: {
id: true,
navigationModeName: true,
navigationModeIcon: true,
},
},
},
},
equipmentAvailable: true,
equipmentIsChargeable: true,
ActivityEquipments: {
where: {
isActive: true,
},
select: {
id: true,
equipmentName: true,
isEquipmentChargeable: true,
equipmentTotalPrice: true,
},
},
ActivityOtherDetails: {
where: {
isActive: true,
},
select: {
id: true,
exclusiveNotes: true,
dosNotes: true,
dontsNotes: true,
tipsNotes: true,
termsAndCondition: true,
},
},
ActivityEligibility: {
where: {
isActive: true,
},
select: {
id: true,
isAgeRestriction: true,
// ageRestriction: {
// select: {
// id: true,
// ageRestrictionName: true,
// minAge: true,
// maxAge: true,
// },
// },
ageRestrictionName: true,
ageEntered: true,
minAge: true,
maxAge: true,
isWeightRestriction: true,
weightRestrictionName: true,
weightEntered: true,
weightIn: true,
minWeight: true,
maxWeight: true,
isHeightRestriction: true,
heightRestrictionName: true,
heightEntered: true,
heightIn: true,
minHeight: true,
maxHeight: true,
},
},
ActivityAllowedEntry: {
where: {
isActive: true,
},
select: {
id: true,
allowedEntryType: {
select: {
id: true,
allowedEntryTypeName: true,
},
},
},
},
frequency: {
where: {
isActive: true
},
select: {
id: true,
frequencyName: true
}
},
ActivityAmenities: {
where: {
isActive: true,
},
select: {
id: true,
amenities: {
select: {
id: true,
amenitiesName: true,
},
},
},
},
cancellationAvailable: true,
cancellationAllowedBeforeMins: true,
// accountManager: {
// select: {
// id: true,
// firstName: true,
// lastName: true,
// emailAddress: true,
// mobileNumber: true,
// }
// },
host: {
select: {
id: true,
companyName: true,
stepper: true,
adminStatusDisplay: true,
adminStatusInternal: true,
user: {
select: {
id: true,
userRefNumber: true,
firstName: true,
lastName: true,
},
},
},
},
},
});
if (!activity) {
throw new ApiError(404, 'Activity not found');
}
if (Array.isArray(activity.ActivitiesMedia)) {
for (const media of activity.ActivitiesMedia) {
if (!media?.mediaFileName) continue;
const filePath = media.mediaFileName;
// ✅ Robust S3 key extraction
const key = filePath.startsWith('http')
? new URL(filePath).pathname.replace(/^\/+/, '')
: filePath;
(media as any).presignedUrl = await getPresignedUrl(bucket, key);
}
}
if (Array.isArray(activity.ActivityVenues)) {
for (const venue of activity.ActivityVenues) {
if (!Array.isArray(venue.ActivityVenueArtifacts)) continue;
for (const artifact of venue.ActivityVenueArtifacts) {
if (!artifact?.mediaFileName) continue;
const filePath = artifact.mediaFileName;
// ✅ Robust S3 key extraction
const key = filePath.startsWith('http')
? new URL(filePath).pathname.replace(/^\/+/, '')
: filePath;
(artifact as any).preSignedURL = await getPresignedUrl(bucket, key);
}
}
}
return activity;
}
async createActivity(
userId: number,
activityTypeXid: number,
frequenciesXid?: number,
) {
return await this.prisma.$transaction(async (tx) => {
// Fetch host
const host = await tx.hostHeader.findFirst({
where: { userXid: userId, isActive: true },
});
if (!host) throw new ApiError(404, 'Host not found for the user');
// Validate activityType
const activityType = await tx.activityTypes.findUnique({
where: { id: activityTypeXid },
});
if (!activityType) throw new ApiError(404, 'Activity type not found');
// Validate frequency
if (frequenciesXid) {
const freq = await tx.frequencies.findUnique({
where: { id: frequenciesXid },
});
if (!freq) throw new ApiError(404, 'Frequency not found');
}
// Generate reference number
const referenceNumber = await generateActivityRefNumber(tx);
// Create activity
const created = await tx.activities.create({
data: {
hostXid: host.id,
activityTypeXid,
frequenciesXid: frequenciesXid || null,
activityInternalStatus: ACTIVITY_INTERNAL_STATUS.DRAFT_PQ,
activityDisplayStatus: ACTIVITY_DISPLAY_STATUS.DRAFT_PQ,
amInternalStatus: ACTIVITY_AM_INTERNAL_STATUS.DRAFT_PQ,
amDisplayStatus: ACTIVITY_AM_DISPLAY_STATUS.DRAFT_PQ,
activityRefNumber: referenceNumber,
},
});
return created;
});
}
async createActivityAndAllQuestionsEntry(
userId: number,
activityTypeXid: number,
frequenciesXid: number,
) {
return await this.prisma.$transaction(async (tx) => {
const host = await tx.hostHeader.findFirst({
where: { userXid: userId, isActive: true },
});
if (!host) throw new ApiError(404, 'Host not found for the user');
const activityType = await tx.activityTypes.findUnique({
where: { id: activityTypeXid },
});
if (!activityType) throw new ApiError(404, 'Activity type not found');
if (frequenciesXid) {
const freq = await tx.frequencies.findUnique({
where: { id: frequenciesXid },
});
if (!freq) throw new ApiError(404, 'Frequency not found');
}
const referenceNumber = await generateActivityRefNumber(tx);
const created = await tx.activities.create({
data: {
hostXid: host.id,
activityTypeXid,
frequenciesXid: frequenciesXid || null,
activityInternalStatus: ACTIVITY_INTERNAL_STATUS.DRAFT_PQ,
activityDisplayStatus: ACTIVITY_DISPLAY_STATUS.DRAFT_PQ,
amInternalStatus: ACTIVITY_AM_INTERNAL_STATUS.DRAFT_PQ,
amDisplayStatus: ACTIVITY_AM_DISPLAY_STATUS.DRAFT_PQ,
activityRefNumber: referenceNumber,
},
});
const questions = await tx.pQQCategories.findMany({
where: { isActive: true },
select: {
id: true,
categoryName: true,
displayOrder: true,
pqqsubCategories: {
where: { isActive: true },
select: {
id: true,
subCategoryName: true,
displayOrder: true,
questions: {
where: { isActive: true },
select: {
id: true,
questionName: true,
maxPoints: true,
displayOrder: true,
},
orderBy: { displayOrder: 'asc' },
},
},
orderBy: { displayOrder: 'asc' },
},
},
orderBy: { displayOrder: 'asc' },
});
// FLATTEN questions
const allQuestions: number[] = [];
for (const cat of questions) {
for (const sub of cat.pqqsubCategories) {
for (const q of sub.questions) {
allQuestions.push(q.id);
}
}
}
await tx.activityPQQheader.createMany({
data: allQuestions.map((id) => ({
activityXid: created.id,
pqqQuestionXid: id,
pqqAnswerXid: null,
})),
});
const pqqHeaderData = await tx.activityPQQheader.findMany({
where: {
activityXid: created.id,
isActive: true,
},
select: {
comments: true,
pqqAnswerXid: true,
pqqQuestions: {
select: {
id: true,
questionName: true,
maxPoints: true,
displayOrder: true,
pqqSubCategories: {
select: {
id: true,
subCategoryName: true,
displayOrder: true,
category: {
select: {
id: true,
categoryName: true,
displayOrder: true,
},
},
},
},
// 🔥 ALL ANSWER OPTIONS FOR THIS QUESTION
PQQAnswers: {
where: { isActive: true },
select: {
id: true,
answerName: true,
answerPoints: true,
displayOrder: true,
},
orderBy: { displayOrder: 'asc' },
},
},
},
ActivityPQQSuggestions: {
where: { isActive: true },
select: {
id: true,
title: true,
comments: true,
activityPqqHeaderXid: true,
},
},
ActivityPQQSupportings: {
where: { isActive: true },
select: {
id: true,
mediaType: true,
mediaFileName: true,
},
},
},
orderBy: { id: 'asc' },
});
// ---------------- GROUPING ------------------
const grouped: any = {};
for (const item of pqqHeaderData) {
const q = item.pqqQuestions;
const sub = q.pqqSubCategories;
const cat = sub.category;
// 1⃣ Category level
if (!grouped[cat.id]) {
grouped[cat.id] = {
id: cat.id,
categoryName: cat.categoryName,
displayOrder: cat.displayOrder,
pqqsubCategories: [],
};
}
const category = grouped[cat.id];
let subCat = category.pqqsubCategories.find(
(s: any) => s.id === sub.id,
);
if (!subCat) {
subCat = {
id: sub.id,
subCategoryName: sub.subCategoryName,
displayOrder: sub.displayOrder,
questions: [],
};
category.pqqsubCategories.push(subCat);
}
subCat.questions.push({
id: q.id,
questionName: q.questionName,
maxPoints: q.maxPoints,
comments: item.comments || null,
displayOrder: q.displayOrder,
allAnswerOptions: q.PQQAnswers || [],
suggestions: item.ActivityPQQSuggestions || [],
supportings: item.ActivityPQQSupportings || [],
});
}
const sortedCategories: any = Object.values(grouped).sort(
(a: any, b: any) => a.displayOrder - b.displayOrder,
);
for (const cat of sortedCategories) {
cat.pqqsubCategories.sort(
(a: any, b: any) => a.displayOrder - b.displayOrder,
);
for (const sub of cat.pqqsubCategories) {
sub.questions.sort(
(a: any, b: any) => a.displayOrder - b.displayOrder,
);
}
}
return {
activity_xid: created.id,
sortedCategories,
};
});
}
/**
* Create a full activity with related records based on payload from the onboarding form.
* This method will create Activities + ActivityOtherDetails + ActivitiesMedia +
* ActivityVenues + ActivityPrices + ActivityFoodTypes + ActivityCuisine +
* ActivityPickUpTransport/Details + ActivityNavigationModes + ActivityEquipments +
* ActivityAmenities + ActivityEligibility
*/
async createOrUpdateActivity(
userId: number,
payload: CreateActivityInput,
isDraft: boolean,
) {
/* =====================================================
* HELPERS
* ===================================================== */
const toBool = (v: any) =>
v === true || v === 'true' || v === 1 || v === '1';
const toBoolOrNull = (v: any): boolean | null => {
if (v === null || v === undefined || v === '') return null;
return v === true || v === 'true' || v === 1 || v === '1';
};
const toNumber = (v: any) =>
v === undefined || v === null || v === '' ? undefined : Number(v);
const round2 = (v: number) => Math.round(v);
const computeBasePriceAndTaxes = (
sellPrice: number,
taxes: Array<{ id: number; taxPer: number }>,
) => {
if (!taxes.length) {
return { basePrice: round2(sellPrice), taxDetails: [] };
}
const totalTaxPer = taxes.reduce((s, t) => s + Number(t.taxPer || 0), 0);
const basePrice = round2(sellPrice / (1 + totalTaxPer / 100));
return {
basePrice,
taxDetails: taxes.map((t) => ({
taxXid: t.id,
taxPer: t.taxPer,
taxAmount: round2(basePrice * (t.taxPer / 100)),
})),
};
};
/* =====================================================
* DURATION CONVERSION
* ===================================================== */
const durationDays = Number(payload.durationDays ?? 0);
const durationHours = Number(payload.durationHours ?? 0);
const durationMins = Number(payload.durationMins ?? 0);
/* =====================================================
* BASIC GUARDS
* ===================================================== */
if (!payload.activityXid) {
throw new ApiError(400, 'activityXid is required');
}
payload.venues = Array.isArray(payload.venues)
? payload.venues
.filter(v => v && typeof v === 'object')
.map(v => ({
...v,
venueName: v.venueName ?? null,
venueLabel: v.venueLabel ?? null,
prices: Array.isArray(v.prices) ? v.prices : [],
media: Array.isArray(v.media) ? v.media : [],
}))
: [];
/* =====================================================
* HARD NORMALIZATION (SERVICE-LEVEL)
* ===================================================== */
payload.foodAvailable = toBoolOrNull(payload.foodAvailable);
payload.alcoholAvailable = toBoolOrNull(payload.alcoholAvailable);
payload.trainerAvailable = toBoolOrNull(payload.trainerAvailable);
payload.pickUpDropAvailable = toBoolOrNull(payload.pickUpDropAvailable);
payload.inActivityAvailable = toBoolOrNull(payload.inActivityAvailable);
payload.equipmentAvailable = toBoolOrNull(payload.equipmentAvailable);
payload.cancellationAvailable = toBoolOrNull(payload.cancellationAvailable);
payload.isInstantBooking = toBool(payload.isInstantBooking);
payload.isCheckOutSame = toBool(payload.isCheckOutSame);
payload.alcoholAvailable = toBoolOrNull(payload.alcoholAvailable);
payload.trainerTotalAmount = toNumber(payload.trainerTotalAmount);
payload.cancellationAllowedBeforeMins = toNumber(
payload.cancellationAllowedBeforeMins,
);
/* =====================================================
* CANCELLATION VALIDATION (NO CONVERSION)
* ===================================================== */
if (payload.cancellationAvailable) {
if (!isDraft) {
if (
typeof payload.cancellationAllowedBeforeMins !== 'number' ||
Number.isNaN(payload.cancellationAllowedBeforeMins) ||
payload.cancellationAllowedBeforeMins <= 0
) {
throw new ApiError(
400,
'cancellationAllowedBeforeMins must be a positive number (in minutes)',
);
}
}
} else {
delete payload.cancellationAllowedBeforeMins;
}
const trainerIsChargeable = toBool(payload.trainerIsChargeable);
const pickUpDropIsChargeable = toBool(payload.pickUpDropIsChargeable);
if (payload.trainerAvailable && trainerIsChargeable) {
if (!isDraft) {
if (
typeof payload.trainerTotalAmount !== 'number' ||
Number.isNaN(payload.trainerTotalAmount) ||
payload.trainerTotalAmount <= 0
) {
throw new ApiError(400, 'trainerTotalAmount must be > 0');
}
}
} else {
// If trainer cost is not chargeable, default the amount to 0
payload.trainerTotalAmount = 0;
}
if (payload.venues && !Array.isArray(payload.venues)) {
throw new ApiError(400, 'venues must be an array');
}
payload.venues?.forEach((v, idx) => {
v.isMinPeopleReqMandatory = toBool(v.isMinPeopleReqMandatory);
if (!isDraft) {
if (!v.venueName) {
throw new ApiError(400, `venues[${idx}] venueName required`);
}
if (v.isMinPeopleReqMandatory && !v.minPeopleRequired) {
throw new ApiError(
400,
`venues[${idx}] min people requirement missing`,
);
}
if (!Array.isArray(v.prices) || !v.prices.length) {
throw new ApiError(
400,
`venues[${idx}] must have at least one price`,
);
}
}
});
/* =====================================================
* ROOT TAX
* ===================================================== */
const taxIds = Array.isArray(payload.taxXids)
? payload.taxXids.map(Number)
: [];
const rootTaxes =
taxIds.length > 0
? await this.prisma.taxes.findMany({
where: { id: { in: taxIds }, isActive: true },
select: { id: true, taxPer: true },
})
: [];
if (taxIds.length !== rootTaxes.length) {
throw new ApiError(400, 'Invalid or inactive tax provided');
}
const eligibility = payload.eligibility;
if (eligibility?.isAgeRestriction) {
if (eligibility.ageRestrictionName == RESTRICTION_NAME.ABOVE) {
eligibility.minAge = toNumber(eligibility.ageEntered);
eligibility.maxAge = 150;
} else if (eligibility.ageRestrictionName == RESTRICTION_NAME.BELOW) {
eligibility.maxAge = toNumber(eligibility.ageEntered);
eligibility.minAge = 0;
}
}
if (eligibility?.isWeightRestriction) {
if (eligibility.weightRestrictionName == RESTRICTION_NAME.ABOVE) {
eligibility.minWeight = toNumber(eligibility.weightEntered);
eligibility.maxWeight = 400;
} else if (eligibility.weightRestrictionName == RESTRICTION_NAME.BELOW) {
eligibility.maxWeight = toNumber(eligibility.weightEntered);
eligibility.minWeight = 0;
}
}
if (eligibility?.isHeightRestriction) {
if (eligibility.heightRestrictionName == RESTRICTION_NAME.ABOVE) {
eligibility.minHeight = toNumber(eligibility.heightEntered);
eligibility.maxHeight = 250;
} else if (eligibility.heightRestrictionName == RESTRICTION_NAME.BELOW) {
eligibility.maxHeight = toNumber(eligibility.heightEntered);
eligibility.minHeight = 0;
}
}
/* =====================================================
* TRANSACTION
* ===================================================== */
return await this.prisma.$transaction(async (tx) => {
/* --------------------------------
* 1⃣ HOST
* -------------------------------- */
const host = await tx.hostHeader.findFirst({
where: { userXid: userId, isActive: true },
});
if (!host) throw new ApiError(404, 'Host not found');
/* --------------------------------
* 2⃣ ACTIVITY
* -------------------------------- */
const existingActivity = await tx.activities.findFirst({
where: {
id: Number(payload.activityXid),
hostXid: host.id,
isActive: true,
},
});
if (!existingActivity) {
throw new ApiError(404, 'Activity not found');
}
/* --------------------------------
* 3⃣ STATUS DECISION
* -------------------------------- */
let activityInternalStatus;
let activityDisplayStatus;
let amInternalStatus;
let amDisplayStatus;
const wasRejected =
existingActivity.activityInternalStatus ===
ACTIVITY_INTERNAL_STATUS.ACTIVITY_REJECTED;
if (wasRejected) {
if (isDraft) {
activityInternalStatus = existingActivity.activityInternalStatus;
activityDisplayStatus = existingActivity.activityDisplayStatus;
amInternalStatus = existingActivity.amInternalStatus;
amDisplayStatus = existingActivity.amDisplayStatus;
} else {
activityInternalStatus = ACTIVITY_INTERNAL_STATUS.ACTIVITY_SUBMITTED;
activityDisplayStatus = ACTIVITY_DISPLAY_STATUS.ACTIVITY_IN_REVIEW;
amInternalStatus = ACTIVITY_AM_INTERNAL_STATUS.ACTIVITY_TO_REVIEW;
amDisplayStatus = ACTIVITY_AM_DISPLAY_STATUS.ACTIVITY_REVISED;
}
} else {
if (isDraft) {
activityInternalStatus = ACTIVITY_INTERNAL_STATUS.ACTIVITY_DRAFT;
activityDisplayStatus = ACTIVITY_DISPLAY_STATUS.ACTIVITY_DRAFT;
amInternalStatus = existingActivity.amInternalStatus;
amDisplayStatus = existingActivity.amDisplayStatus;
} else {
activityInternalStatus = ACTIVITY_INTERNAL_STATUS.ACTIVITY_SUBMITTED;
activityDisplayStatus = ACTIVITY_DISPLAY_STATUS.ACTIVITY_IN_REVIEW;
amInternalStatus = ACTIVITY_AM_INTERNAL_STATUS.ACTIVITY_SUBMITED;
amDisplayStatus = ACTIVITY_AM_DISPLAY_STATUS.ACTIVITY_NEW;
}
}
/* --------------------------------
* 4⃣ UPDATE ACTIVITY CORE + FLAGS
* -------------------------------- */
const activity = await tx.activities.update({
where: { id: existingActivity.id },
data: {
activityTypeXid: payload.activityTypeXid ?? undefined,
frequenciesXid: payload.frequenciesXid ?? undefined,
activityTitle: payload.activityTitle ?? undefined,
activityDescription: payload.activityDescription ?? undefined,
checkInLat: payload.checkInLat ?? undefined,
checkInLong: payload.checkInLong ?? undefined,
checkInAddress: payload.checkInAddress ?? undefined,
isCheckOutSame: toBool(payload.isCheckOutSame),
checkOutLat: payload.checkOutLat ?? undefined,
checkOutLong: payload.checkOutLong ?? undefined,
checkOutAddress: payload.checkOutAddress ?? undefined,
// energyLevelXid: payload.energyLevelXid ?? undefined,
activityDurationMins: durationMins ?? undefined,
currencyXid: payload.currencyXid ?? undefined,
sustainabilityScore: payload.sustainabilityScore ?? undefined,
safetyScore: payload.safetyScore ?? undefined,
isInstantBooking: payload.isInstantBooking ?? undefined,
foodAvailable: payload.foodAvailable,
foodIsChargeable: toBool(payload.foodIsChargeable),
alcoholAvailable: payload.alcoholAvailable,
trainerAvailable: payload.trainerAvailable,
trainerIsChargeable: toBool(payload.trainerIsChargeable),
pickUpDropAvailable: payload.pickUpDropAvailable,
pickUpDropIsChargeable: toBool(payload.pickUpDropIsChargeable),
inActivityAvailable: payload.inActivityAvailable,
inActivityIsChargeable: toBool(payload.inActivityIsChargeable),
equipmentAvailable: payload.equipmentAvailable,
equipmentIsChargeable: toBool(payload.equipmentIsChargeable),
cancellationAvailable: payload.cancellationAvailable,
cancellationAllowedBeforeMins: payload.cancellationAvailable
? payload.cancellationAllowedBeforeMins
: null,
activityInternalStatus,
activityDisplayStatus,
amInternalStatus,
amDisplayStatus,
},
});
const activityXid = activity.id;
/* --------------------------------
* 5⃣ CLEAN OLD ACTIVITY MEDIA
* -------------------------------- */
await tx.activitiesMedia.deleteMany({ where: { activityXid } });
/* --------------------------------
* 6⃣ SAVE NEW ACTIVITY MEDIA
* -------------------------------- */
if (Array.isArray(payload.media) && payload.media.length) {
await tx.activitiesMedia.createMany({
data: payload.media.map((m, index) => ({
activityXid,
mediaType: m.mediaType ?? 'unknown',
mediaFileName: m.mediaFileName,
displayOrder: index + 1,
})),
});
}
/* --------------------------------
* 7⃣ CLEAN OLD VENUES & RELATED DATA
* -------------------------------- */
const oldVenueIds = (
await tx.activityVenues.findMany({
where: { activityXid },
select: { id: true },
})
).map((v) => v.id);
if (oldVenueIds.length) {
// Clean venue artifacts (media)
await tx.activityVenueArtifacts.deleteMany({
where: { activityVenueXid: { in: oldVenueIds } },
});
// Clean price taxes and prices
const priceIds = (
await tx.activityPrices.findMany({
where: { activityVenueXid: { in: oldVenueIds } },
select: { id: true },
})
).map((p) => p.id);
if (priceIds.length) {
await tx.activityPriceTaxes.deleteMany({
where: { activityPriceXid: { in: priceIds } },
});
await tx.activityPrices.deleteMany({
where: { id: { in: priceIds } },
});
}
// Clean venues
await tx.activityVenues.deleteMany({
where: { id: { in: oldVenueIds } },
});
}
/* --------------------------------
* 8⃣ CREATE VENUES WITH MEDIA & PRICES (DRAFT SAFE)
* -------------------------------- */
for (const venue of payload.venues ?? []) {
const venueRow = await tx.activityVenues.create({
data: {
activityXid,
venueName: venue.venueName ?? null,
venueLabel: venue.venueLabel ?? null,
venueCapacity: toNumber(venue.venueCapacity) ?? 0,
availableSeats: toNumber(venue.availableSeats) ?? 0,
isMinPeopleReqMandatory: venue.isMinPeopleReqMandatory,
minPeopleRequired: toNumber(venue.minPeopleRequired) ?? null,
minReqfullfilledBeforeMins:
toNumber(venue.minReqfullfilledBeforeMins) ?? null,
venueDescription: venue.venueDescription ?? null,
},
});
// Create venue media/artifacts
if (Array.isArray(venue.media) && venue.media.length) {
await tx.activityVenueArtifacts.createMany({
data: venue.media.map((m) => ({
activityVenueXid: venueRow.id,
mediaType: m.mediaType ?? 'image',
mediaFileName: m.mediaFileName,
})),
});
}
// Create venue prices with taxes
for (const price of venue.prices ?? []) {
const sellPrice = Number(price.sellPrice);
// On submit enforce > 0, on draft just skip invalid
if (!isDraft) {
if (!sellPrice || sellPrice <= 0) {
throw new ApiError(
400,
'sellPrice must be > 0 for submitted activities',
);
}
}
if (!sellPrice || sellPrice <= 0) continue;
const { basePrice, taxDetails } = computeBasePriceAndTaxes(
sellPrice,
rootTaxes,
);
const priceRow = await tx.activityPrices.create({
data: {
activityVenueXid: venueRow.id,
noOfSession: price.noOfSession ?? 1,
isPackage: price.isPackage ?? false,
sessionValidity: price.sessionValidity ?? 0,
sessionValidityFrequency:
price.sessionValidityFrequency ?? 'Days',
basePrice,
sellPrice,
},
});
if (taxDetails.length) {
await tx.activityPriceTaxes.createMany({
data: taxDetails.map((t) => ({
activityPriceXid: priceRow.id,
taxXid: t.taxXid,
taxPer: t.taxPer,
taxAmount: t.taxAmount,
})),
});
}
}
}
/* 8.1️⃣ CLEAN & CREATE FOOD COST (if chargeable) */
const oldFoodCostIds = (
await tx.activityFoodCost.findMany({
where: { activityXid },
select: { id: true },
})
).map((f) => f.id);
if (oldFoodCostIds.length) {
await tx.activityFoodTaxes.deleteMany({
where: { activityFoodCostXid: { in: oldFoodCostIds } },
});
await tx.activityFoodCost.deleteMany({
where: { id: { in: oldFoodCostIds } },
});
}
if (payload.foodAvailable && payload.foodIsChargeable) {
const foodTotalAmount = toNumber(payload.foodTotalAmount) ?? 0;
if (!isDraft && foodTotalAmount <= 0) {
throw new ApiError(
400,
'foodTotalAmount must be > 0 when foodIsChargeable',
);
}
if (foodTotalAmount > 0) {
const { basePrice, taxDetails } = computeBasePriceAndTaxes(
foodTotalAmount,
rootTaxes,
);
const foodCost = await tx.activityFoodCost.create({
data: {
activityXid,
baseAmount: basePrice,
totalAmount: foodTotalAmount,
},
});
if (taxDetails.length) {
await tx.activityFoodTaxes.createMany({
data: taxDetails.map((t) => ({
activityFoodCostXid: foodCost.id,
taxXid: t.taxXid,
taxPer: t.taxPer,
taxAmount: t.taxAmount,
})),
});
}
}
}
// 🍲 FOOD TYPES
await tx.activityFoodTypes.deleteMany({ where: { activityXid } });
if (Array.isArray(payload.foodTypeIds) && payload.foodTypeIds.length) {
await tx.activityFoodTypes.createMany({
data: payload.foodTypeIds.map((foodTypeId) => ({
activityXid,
foodTypeXid: foodTypeId,
})),
});
}
// 🍛 CUISINES
await tx.activityCuisine.deleteMany({ where: { activityXid } });
if (Array.isArray(payload.cuisineIds) && payload.cuisineIds.length) {
await tx.activityCuisine.createMany({
data: payload.cuisineIds.map((cuisineId) => ({
activityXid,
foodCuisineXid: cuisineId,
})),
});
}
/* --------------------------------
* 9⃣ CLEAN & CREATE EQUIPMENT WITH TAXES
* -------------------------------- */
const oldEquipmentIds = (
await tx.activityEquipments.findMany({
where: { activityXid },
select: { id: true },
})
).map((e) => e.id);
if (oldEquipmentIds.length) {
await tx.activityEquipmentTaxes.deleteMany({
where: { activityEquipmentXid: { in: oldEquipmentIds } },
});
await tx.activityEquipments.deleteMany({
where: { id: { in: oldEquipmentIds } },
});
}
if (Array.isArray(payload.equipments) && payload.equipments.length) {
for (const eq of payload.equipments) {
const isChargeable = toBool(eq.isEquipmentChargeable);
const totalPrice = isChargeable
? toNumber(eq.equipmentTotalPrice) ?? 0
: 0;
// On submit enforce > 0, on draft just skip invalid/zero
if (!isDraft && isChargeable && totalPrice <= 0) {
throw new ApiError(
400,
'equipmentTotalPrice must be > 0 when equipment is chargeable',
);
}
if (!isChargeable || totalPrice <= 0) continue;
const { basePrice, taxDetails } = computeBasePriceAndTaxes(
totalPrice,
rootTaxes,
);
const equipment = await tx.activityEquipments.create({
data: {
activityXid,
equipmentName: eq.equipmentName,
isEquipmentChargeable: isChargeable,
equipmentBasePrice: basePrice,
equipmentTotalPrice: totalPrice,
},
});
if (taxDetails.length) {
await tx.activityEquipmentTaxes.createMany({
data: taxDetails.map((t) => ({
activityEquipmentXid: equipment.id,
taxXid: t.taxXid,
taxPer: t.taxPer,
taxAmount: t.taxAmount,
})),
});
}
}
}
/* --------------------------------
* 🔟 CLEAN & CREATE TRAINER WITH TAXES
* -------------------------------- */
const oldTrainerIds = (
await tx.activityTrainers.findMany({
where: { activityXid },
select: { id: true },
})
).map((t) => t.id);
if (oldTrainerIds.length) {
await tx.activityTrainerTaxes.deleteMany({
where: { activityTrainerXid: { in: oldTrainerIds } },
});
await tx.activityTrainers.deleteMany({
where: { id: { in: oldTrainerIds } },
});
}
if (payload.trainerAvailable) {
const isChargeable = trainerIsChargeable;
const totalAmount = isChargeable
? payload.trainerTotalAmount
: 0;
const { basePrice, taxDetails } = isChargeable && totalAmount > 0
? computeBasePriceAndTaxes(totalAmount, rootTaxes)
: { basePrice: 0, taxDetails: [] };
const trainer = await tx.activityTrainers.create({
data: {
activityXid,
baseAmount: basePrice,
totalAmount,
},
});
if (isChargeable && taxDetails.length) {
await tx.activityTrainerTaxes.createMany({
data: taxDetails.map((t) => ({
activityTrainerXid: trainer.id,
taxXid: t.taxXid,
taxPer: t.taxPer,
taxAmount: t.taxAmount,
})),
});
}
}
/* --------------------------------
* 1⃣1⃣ CLEAN & CREATE PICKUP/DROP TRANSPORTS (INDEPENDENT ARRAY)
* -------------------------------- */
/* --------------------------------
* 1⃣1⃣ CLEAN OLD PICKUP/DROP TRANSPORT DATA (INDEPENDENT FROM NAVIGATION MODES)
* -------------------------------- */
// Clean up old pickup transport modes (independent array)
await tx.activityPickUpTransport.deleteMany({
where: {
activityXid: Number(activityXid),
},
});
// Clean up old pickup details and their taxes (independent from transport modes)
const oldPickupDetailIds = (
await tx.activityPickUpDetails.findMany({
where: { activitiesXid: activityXid },
select: { id: true },
})
).map((p) => p.id);
if (oldPickupDetailIds.length) {
await tx.activityPickUpTransportTaxes.deleteMany({
where: {
activityPickUpDetailsXid: { in: oldPickupDetailIds },
},
});
await tx.activityPickUpDetails.deleteMany({
where: { id: { in: oldPickupDetailIds } },
});
}
/* --------------------------------
* 1⃣1⃣ CREATE PICKUP TRANSPORTS (INDEPENDENT ARRAY - JUST TRANSPORT MODES)
* -------------------------------- */
if (Array.isArray(payload.pickupTransports)) {
for (const transport of payload.pickupTransports) {
// ✅ CREATE TRANSPORT MODE INDEPENDENTLY (NO RELATION TO PICKUP DETAILS)
await tx.activityPickUpTransport.create({
data: {
activityXid: activityXid,
transportModeXid: transport.transportModeXid,
},
});
}
}
/* --------------------------------
* 1⃣1⃣ CREATE PICKUP DETAILS (INDEPENDENT ARRAY - SEPARATE FROM TRANSPORT MODES)
* -------------------------------- */
if (Array.isArray(payload.pickupDetails)) {
for (const detail of payload.pickupDetails) {
const isChargeable = pickUpDropIsChargeable;
// 🔒 HARD RULE: NOT chargeable → ALWAYS 0
const totalPrice = isChargeable
? toNumber(detail.transportTotalPrice) ?? 0
: 0;
// ❌ Validate ONLY when chargeable + submit
if (!isDraft && isChargeable && totalPrice <= 0) {
throw new ApiError(
400,
'transportTotalPrice must be > 0 when pickup/drop is chargeable',
);
}
const { basePrice, taxDetails } =
isChargeable && totalPrice > 0
? computeBasePriceAndTaxes(totalPrice, rootTaxes)
: { basePrice: 0, taxDetails: [] };
// ✅ ALWAYS CREATE PICKUP DETAIL
const pickupDetail = await tx.activityPickUpDetails.create({
data: {
activitiesXid: activityXid,
isPickUp: toBool(detail.isPickUp),
locationLat: toNumber(detail.locationLat),
locationLong: toNumber(detail.locationLong),
locationAddress: detail.locationAddress ?? null,
// ✅ Guaranteed consistency
transportBasePrice: basePrice,
transportTotalPrice: totalPrice,
},
});
// 💰 Taxes ONLY when chargeable
if (isChargeable && taxDetails.length) {
await tx.activityPickUpTransportTaxes.createMany({
data: taxDetails.map((t) => ({
activityPickUpDetailsXid: pickupDetail.id,
taxXid: t.taxXid,
taxPer: t.taxPer,
taxAmount: t.taxAmount,
})),
});
}
}
}
/* --------------------------------
* 1⃣2⃣ CLEAN & CREATE NAVIGATION MODES WITH TAXES
* -------------------------------- */
const oldNavIds = (
await tx.activityNavigationModes.findMany({
where: { activityXid },
select: { id: true },
})
).map((n) => n.id);
if (oldNavIds.length) {
await tx.activityNavigationModesTaxes.deleteMany({
where: { activityNavigationModeXid: { in: oldNavIds } },
});
await tx.activityNavigationModes.deleteMany({
where: { id: { in: oldNavIds } },
});
}
/* --------------------------------
* 1⃣2⃣ CREATE NAVIGATION MODES (PER MODE)
* -------------------------------- */
if (Array.isArray(payload.navigationModes)) {
for (const mode of payload.navigationModes) {
const isChargeable = toBool(mode.isChargeable);
const totalPrice = isChargeable
? (toNumber(mode.totalPrice) ?? 0)
: 0;
if (!isDraft && isChargeable && totalPrice <= 0) {
throw new ApiError(
400,
'totalPrice must be > 0 when navigation mode is chargeable',
);
}
let basePrice = 0;
let taxDetails: Array<{
taxXid: number;
taxPer: number;
taxAmount: number;
}> = [];
if (isChargeable) {
const result = computeBasePriceAndTaxes(totalPrice, rootTaxes);
basePrice = result.basePrice;
taxDetails = result.taxDetails;
}
/* 1⃣ CREATE NAVIGATION MODE ROW */
const navMode = await tx.activityNavigationModes.create({
data: {
activityXid,
navigationModeXid: mode.navigationModeXid,
isInActivityChargeable: isChargeable,
navigationModesBasePrice: basePrice,
navigationModesTotalPrice: totalPrice,
},
});
/* 2⃣ CREATE TAXES (ONLY IF CHARGEABLE) */
if (taxDetails.length) {
await tx.activityNavigationModesTaxes.createMany({
data: taxDetails.map((t) => ({
activityNavigationModeXid: navMode.id,
taxXid: t.taxXid,
taxPer: t.taxPer,
taxAmount: t.taxAmount,
})),
});
}
}
}
/* --------------------------------
* 1⃣3⃣ CLEAN & CREATE AMENITIES
* -------------------------------- */
await tx.activityAmenities.deleteMany({ where: { activityXid } });
if (Array.isArray(payload.amenitiesIds) && payload.amenitiesIds.length) {
await tx.activityAmenities.createMany({
data: payload.amenitiesIds.map((amenityId) => ({
activityXid,
amenitiesXid: amenityId,
})),
});
}
/* --------------------------------
* 1⃣4⃣ CLEAN & CREATE ELIGIBILITY
* -------------------------------- */
await tx.activityEligibility.deleteMany({ where: { activityXid } });
if (payload.eligibility) {
await tx.activityEligibility.create({
data: {
activityXid,
isAgeRestriction: toBool(payload.eligibility.isAgeRestriction),
ageRestrictionName: payload.eligibility.ageRestrictionName,
ageEntered: payload.eligibility.ageEntered,
ageIn: payload.eligibility.ageIn,
minAge: payload.eligibility.minAge,
maxAge: payload.eligibility.maxAge,
isWeightRestriction: toBool(
payload.eligibility.isWeightRestriction,
),
weightRestrictionName:
payload.eligibility.weightRestrictionName ?? null,
weightEntered: toNumber(payload.eligibility.weightEntered),
weightIn: payload.eligibility.weightIn ?? null,
minWeight: toNumber(payload.eligibility.minWeight),
maxWeight: toNumber(payload.eligibility.maxWeight),
isHeightRestriction: toBool(
payload.eligibility.isHeightRestriction,
),
heightRestrictionName:
payload.eligibility.heightRestrictionName ?? null,
heightEntered: toNumber(payload.eligibility.heightEntered),
heightIn: payload.eligibility.heightIn ?? null,
minHeight: toNumber(payload.eligibility.minHeight),
maxHeight: toNumber(payload.eligibility.maxHeight),
},
});
}
/* --------------------------------
* 1⃣5⃣ CLEAN & CREATE OTHER DETAILS
* -------------------------------- */
await tx.activityOtherDetails.deleteMany({ where: { activityXid } });
if (payload.otherDetails) {
await tx.activityOtherDetails.create({
data: {
activityXid,
exclusiveNotes: payload.otherDetails.exclusiveNotes ?? null,
dosNotes: payload.otherDetails.dosNotes ?? null,
dontsNotes: payload.otherDetails.dontsNotes ?? null,
tipsNotes: payload.otherDetails.tipsNotes ?? null,
termsAndCondition: payload.otherDetails.termsAndCondition ?? null,
},
});
}
/* --------------------------------
* 1⃣6⃣ CLEAN & CREATE FOOD TYPES
* -------------------------------- */
await tx.activityFoodTypes.deleteMany({ where: { activityXid } });
if (Array.isArray(payload.foodTypeIds) && payload.foodTypeIds.length) {
await tx.activityFoodTypes.createMany({
data: payload.foodTypeIds.map((foodTypeId) => ({
activityXid,
foodTypeXid: foodTypeId,
})),
});
}
/* --------------------------------
* 1⃣7⃣ CLEAN & CREATE CUISINES
* -------------------------------- */
await tx.activityCuisine.deleteMany({ where: { activityXid } });
if (Array.isArray(payload.cuisineIds) && payload.cuisineIds.length) {
await tx.activityCuisine.createMany({
data: payload.cuisineIds.map((cuisineId) => ({
activityXid,
foodCuisineXid: cuisineId,
})),
});
}
const allowedEntryIds = Array.isArray(payload.allowedEntryTypes)
? payload.allowedEntryTypes.map(Number)
: [];
if (allowedEntryIds.length) {
const validEntryTypes = await this.prisma.allowedEntryTypes.findMany({
where: { id: { in: allowedEntryIds }, isActive: true },
select: { id: true },
});
if (validEntryTypes.length !== allowedEntryIds.length)
throw new ApiError(
400,
'Invalid or inactive allowed entry type(s) provided',
);
}
/* --------------------------------
* CLEAN & CREATE ALLOWED ENTRY
* -------------------------------- */
await tx.activityAllowedEntry.deleteMany({ where: { activityXid } });
if (allowedEntryIds.length) {
await tx.activityAllowedEntry.createMany({
data: allowedEntryIds.map((entryId) => ({
activityXid,
allowedEntryTypeXid: entryId,
})),
});
}
/* --------------------------------
* ✅ MARK ACTIVITY SUGGESTIONS AS REVIEWED
* (REJECTED → ENHANCE → SUBMIT FLOW)
* -------------------------------- */
if (wasRejected && !isDraft) {
await tx.activitySuggestions.updateMany({
where: {
activityXid: activityXid,
isActive: true,
isReviewed: false,
},
data: {
isReviewed: true,
reviewedByXid: userId,
reviewedOn: new Date(),
},
});
}
/* --------------------------------
* 1⃣8⃣ ACTIVITY TRACK
* -------------------------------- */
await tx.activityTrack.create({
data: {
activityXid,
trackType: 'ACTIVITY',
trackStatus: activityInternalStatus,
updatedByXid: userId,
updatedByRole: ROLE_NAME.HOST,
updatedOn: new Date(),
},
});
/* --------------------------------
* 1⃣9⃣ RESPONSE
* -------------------------------- */
return {
activityXid,
activityRefNumber: activity.activityRefNumber,
status: isDraft
? ACTIVITY_INTERNAL_STATUS.ACTIVITY_DRAFT
: ACTIVITY_INTERNAL_STATUS.ACTIVITY_SUBMITTED,
};
});
}
async getAllPQUpdatedResponse(activityXid: number) {
const pqqHeaderData = await this.prisma.activityPQQheader.findMany({
where: {
activityXid: activityXid,
isActive: true,
},
select: {
id: true,
comments: true,
pqqAnswerXid: true,
pqqQuestions: {
select: {
id: true,
questionName: true,
maxPoints: true,
displayOrder: true,
pqqSubCategories: {
select: {
id: true,
subCategoryName: true,
displayOrder: true,
category: {
select: {
id: true,
categoryName: true,
displayOrder: true,
},
},
},
},
// 🔥 ALL ANSWER OPTIONS FOR THIS QUESTION
PQQAnswers: {
where: { isActive: true },
select: {
id: true,
answerName: true,
answerPoints: true,
displayOrder: true,
},
orderBy: { displayOrder: 'asc' },
},
},
},
ActivityPQQSuggestions: {
where: { isActive: true, isReviewed: false },
select: {
id: true,
title: true,
comments: true,
activityPqqHeaderXid: true,
},
},
ActivityPQQSupportings: {
where: { isActive: true },
select: {
id: true,
mediaType: true,
mediaFileName: true,
},
},
},
orderBy: { id: 'asc' },
});
// ---------- GROUPING START ----------
const grouped: any = {};
for (const item of pqqHeaderData) {
const q = item.pqqQuestions;
const sub = q.pqqSubCategories;
const cat = sub.category;
// 1⃣ Category level
// 1⃣ Category level
if (!grouped[cat.id]) {
grouped[cat.id] = {
id: cat.id,
categoryName: cat.categoryName,
displayOrder: cat.displayOrder,
activityPqqHeaderId: item.id, // ✅ Added to match AM response
pqqsubCategories: [],
};
} else if (!grouped[cat.id].activityPqqHeaderId) {
grouped[cat.id].activityPqqHeaderId = item.id; // Ensures it's set if missing
}
const category = grouped[cat.id];
// 2⃣ Subcategory level
let subCat = category.pqqsubCategories.find((s: any) => s.id === sub.id);
if (!subCat) {
subCat = {
id: sub.id,
subCategoryName: sub.subCategoryName,
displayOrder: sub.displayOrder,
questions: [],
};
category.pqqsubCategories.push(subCat);
}
// 3⃣ Questions level
subCat.questions.push({
id: q.id,
questionName: q.questionName,
maxPoints: q.maxPoints,
pqqAnswerXid: item.pqqAnswerXid,
comments: item.comments || null,
displayOrder: q.displayOrder,
allAnswerOptions: q.PQQAnswers || [], // 🔥 All answers
suggestions: item.ActivityPQQSuggestions,
supportings: item.ActivityPQQSupportings,
});
}
// ---------- SORTING ----------
const sortedCategories: any = Object.values(grouped).sort(
(a: any, b: any) => a.displayOrder - b.displayOrder,
);
for (const cat of sortedCategories) {
cat.pqqsubCategories.sort(
(a: any, b: any) => a.displayOrder - b.displayOrder,
);
for (const sub of cat.pqqsubCategories) {
sub.questions.sort((a: any, b: any) => a.displayOrder - b.displayOrder);
}
}
// ---------- PRESIGNED URL GENERATION ----------
for (const cat of sortedCategories) {
for (const sub of cat.pqqsubCategories) {
for (const q of sub.questions) {
if (q.supportings?.length) {
for (const doc of q.supportings) {
if (doc.mediaFileName) {
const filePath = doc.mediaFileName;
const key = filePath.startsWith('http')
? filePath.split('.com/')[1]
: filePath;
doc.presignedUrl = await getPresignedUrl(bucket, key);
}
}
}
}
}
}
// ---------- RETURN GROUPED STRUCTURE ----------
return sortedCategories;
}
}