2287 lines
61 KiB
TypeScript
2287 lines
61 KiB
TypeScript
import { getPresignedUrl } from '@/common/middlewares/aws/getPreSignedUrl';
|
||
import {
|
||
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_INVITATION_STATUS,
|
||
MINGLAR_STATUS_DISPLAY,
|
||
MINGLAR_STATUS_INTERNAL,
|
||
} from '@/common/utils/constants/minglar.constant';
|
||
import { PaginationOptions } from '@/common/utils/pagination/pagination.types';
|
||
import config from '@/config/config';
|
||
import { Injectable } from '@nestjs/common';
|
||
import { PrismaClient, User } from '@prisma/client';
|
||
import * as bcrypt from 'bcryptjs';
|
||
import { PrismaService } from '../../../common/database/prisma.service';
|
||
import ApiError from '../../../common/utils/helper/ApiError';
|
||
import { CreateMinglarDto, UpdateMinglarDto } from '../dto/minglar.dto';
|
||
import { sendAMEmailForHostAssign } from './AMEmail.service';
|
||
|
||
const bucket = config.aws.bucketName;
|
||
|
||
@Injectable()
|
||
export class MinglarService {
|
||
constructor(private prisma: PrismaService | PrismaClient) { }
|
||
|
||
async createPassword(user_xid: number, password: string): Promise<boolean> {
|
||
// Find user by id
|
||
const user = await this.prisma.user.findUnique({
|
||
where: { id: user_xid, isActive: true, userStatus: USER_STATUS.INVITED },
|
||
select: { id: true, emailAddress: true, userPassword: true },
|
||
});
|
||
|
||
const invitationDetails = await this.prisma.inviteDetails.findMany({
|
||
where: {
|
||
userXid: user.id,
|
||
isActive: true,
|
||
isMinglarInvitation: true,
|
||
},
|
||
});
|
||
if (invitationDetails.length > 0) {
|
||
await this.prisma.inviteDetails.update({
|
||
where: { id: invitationDetails[0].id },
|
||
data: {
|
||
invitation_status: MINGLAR_INVITATION_STATUS.ACCEPTED,
|
||
accepted_on: new Date(),
|
||
is_accepted: 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,
|
||
userStatus: USER_STATUS.ACTIVE,
|
||
isEmailVerfied: true,
|
||
},
|
||
});
|
||
|
||
return true;
|
||
}
|
||
|
||
async generateHostRefNumber(tx: any, role_xid: number) {
|
||
const lastrecord = await tx.user.findFirst({
|
||
orderBy: {
|
||
id: 'desc',
|
||
},
|
||
select: {
|
||
id: true,
|
||
},
|
||
});
|
||
let referenceId = '';
|
||
|
||
const nextId = lastrecord ? lastrecord.id + 1 : 1;
|
||
|
||
if (role_xid === ROLE.ACCOUNT_MANAGER) {
|
||
referenceId = `AM-${String(nextId).padStart(6, '0')}`;
|
||
} else if (role_xid === ROLE.CO_ADMIN) {
|
||
referenceId = `CA-${String(nextId).padStart(6, '0')}`;
|
||
}
|
||
|
||
return referenceId;
|
||
}
|
||
|
||
async createHost(data: CreateMinglarDto) {
|
||
return this.prisma.user.create({ data });
|
||
}
|
||
|
||
async getAllHosts() {
|
||
return this.prisma.user.findMany({ where: { roleXid: ROLE.HOST } });
|
||
}
|
||
|
||
async updateHost(id: number, data: UpdateMinglarDto) {
|
||
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 getHostXidByActivityId(activityId: number) {
|
||
const activityDetails = await this.prisma.activities.findFirst({
|
||
where: { id: activityId },
|
||
});
|
||
return activityDetails.hostXid;
|
||
}
|
||
|
||
async getUserDetails(id: number) {
|
||
const hostDetail = await this.prisma.hostHeader.findFirst({
|
||
where: { id: id },
|
||
});
|
||
const userDetails = await this.prisma.user.findUnique({
|
||
where: { id: hostDetail.userXid },
|
||
});
|
||
return userDetails;
|
||
}
|
||
|
||
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 loginForMinglar(emailAddress: string, userPassword: string) {
|
||
const existingUser = await this.prisma.user.findUnique({
|
||
where: {
|
||
emailAddress: emailAddress,
|
||
isActive: true,
|
||
userStatus: USER_STATUS.ACTIVE,
|
||
},
|
||
select: {
|
||
id: true,
|
||
firstName: true,
|
||
lastName: true,
|
||
emailAddress: true,
|
||
mobileNumber: true,
|
||
roleXid: true,
|
||
isProfileUpdated: true,
|
||
userStatus: true,
|
||
profileImage: true,
|
||
userPassword: true,
|
||
},
|
||
});
|
||
|
||
if (!existingUser) {
|
||
throw new ApiError(404, 'User not found');
|
||
}
|
||
|
||
if (
|
||
existingUser.roleXid !== ROLE.MINGLAR_ADMIN &&
|
||
existingUser.roleXid !== ROLE.CO_ADMIN &&
|
||
existingUser.roleXid !== ROLE.ACCOUNT_MANAGER
|
||
) {
|
||
throw new ApiError(403, 'Access denied.');
|
||
}
|
||
|
||
const matchPassword = await bcrypt.compare(
|
||
userPassword,
|
||
existingUser.userPassword,
|
||
);
|
||
if (!matchPassword) {
|
||
throw new ApiError(401, 'Invalid credentials');
|
||
}
|
||
|
||
if (existingUser?.profileImage) {
|
||
const key = existingUser.profileImage.startsWith('http')
|
||
? existingUser.profileImage.split('.com/')[1]
|
||
: existingUser.profileImage;
|
||
|
||
existingUser.profileImage = await getPresignedUrl(bucket, key);
|
||
}
|
||
|
||
delete existingUser.userPassword;
|
||
|
||
return existingUser;
|
||
}
|
||
|
||
async checkUserExists(emailAddress: string) {
|
||
return await this.prisma.user.findUnique({
|
||
where: { emailAddress: emailAddress, isActive: true },
|
||
});
|
||
}
|
||
|
||
async createUserForInvite(emailAddress: string, roleXid: number) {
|
||
return await this.prisma.user.create({
|
||
data: {
|
||
emailAddress: emailAddress,
|
||
roleXid: roleXid,
|
||
userStatus: USER_STATUS.INVITED,
|
||
},
|
||
});
|
||
}
|
||
|
||
async getAllHostActivityForMinglar(
|
||
search?: string,
|
||
hostXid?: number,
|
||
paginationOptions?: { page: number; limit: number; skip: number },
|
||
) {
|
||
const whereClause: any = {
|
||
isActive: true,
|
||
activityInternalStatus: { notIn: [ACTIVITY_INTERNAL_STATUS.DRAFT_PQ] },
|
||
...(hostXid ? { hostXid } : {}),
|
||
};
|
||
|
||
// 🔥 FIXED SEARCH CONDITION
|
||
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 [hostActivities, 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,
|
||
host: {
|
||
select: {
|
||
companyName: true,
|
||
user: {
|
||
select: {
|
||
firstName: true,
|
||
lastName: true,
|
||
userRefNumber: 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,
|
||
},
|
||
},
|
||
},
|
||
},
|
||
},
|
||
skip: paginationOptions?.skip || 0,
|
||
take: paginationOptions?.limit || 10,
|
||
orderBy: { id: 'desc' },
|
||
}),
|
||
this.prisma.activities.count({ where: whereClause }),
|
||
]);
|
||
|
||
// Process each activity
|
||
for (const activity of hostActivities) {
|
||
/** 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');
|
||
|
||
let hostDetails = null;
|
||
|
||
if (hostXid) {
|
||
hostDetails = await this.prisma.hostHeader.findUnique({
|
||
where: { id: hostXid },
|
||
select: {
|
||
companyName: true,
|
||
user: {
|
||
select: {
|
||
firstName: true,
|
||
lastName: true,
|
||
userRefNumber: true,
|
||
},
|
||
},
|
||
},
|
||
});
|
||
}
|
||
|
||
const paginatedResponse = paginationService.createPaginatedResponse(
|
||
hostActivities,
|
||
totalCount,
|
||
paginationOptions || { page: 1, limit: 10, skip: 0 },
|
||
);
|
||
|
||
// 👇 ADD THIS BLOCK
|
||
if (hostActivities.length === 0 && hostDetails) {
|
||
paginatedResponse.data = [
|
||
{
|
||
id: null,
|
||
activityRefNumber: null,
|
||
activityTitle: null,
|
||
totalScore: null,
|
||
activityInternalStatus: null,
|
||
activityDisplayStatus: null,
|
||
amInternalStatus: null,
|
||
amDisplayStatus: null,
|
||
createdAt: null,
|
||
host: hostDetails,
|
||
ActivityAmDetails: [],
|
||
activityType: null,
|
||
},
|
||
];
|
||
}
|
||
|
||
return paginatedResponse;
|
||
}
|
||
|
||
async createUserRevenue(
|
||
userXid: number,
|
||
isFixedSalary: boolean,
|
||
perValue: number,
|
||
) {
|
||
return await this.prisma.userRevenue.create({
|
||
data: {
|
||
userXid: userXid,
|
||
is_fixed_salary: isFixedSalary,
|
||
per_value: perValue || 0,
|
||
isActive: true,
|
||
},
|
||
});
|
||
}
|
||
|
||
async createInviteDetails(
|
||
userXid: number,
|
||
invitedBy: number,
|
||
invitationStatus: string,
|
||
) {
|
||
return await this.prisma.inviteDetails.create({
|
||
data: {
|
||
userXid: userXid,
|
||
is_invited: true,
|
||
invited_by: invitedBy,
|
||
invited_on: new Date(),
|
||
is_accepted: false,
|
||
invitation_status: invitationStatus,
|
||
isActive: true,
|
||
isMinglarInvitation: true,
|
||
},
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Invite teammate flow: checks existing user, creates user, revenue and invite details
|
||
* All operations are performed inside a single DB transaction to avoid races.
|
||
*/
|
||
async inviteTeammate(
|
||
emailAddress: string,
|
||
roleXid: number,
|
||
isFixedSalary: boolean,
|
||
perValue: number,
|
||
invitedBy: number,
|
||
) {
|
||
return await this.prisma.$transaction(async (tx) => {
|
||
// Check existing user
|
||
const existingUser = await tx.user.findFirst({
|
||
where: { emailAddress: emailAddress, isActive: true },
|
||
});
|
||
|
||
if (existingUser) {
|
||
throw new ApiError(400, 'User already exists.');
|
||
}
|
||
|
||
const referenceNumber = await this.generateHostRefNumber(tx, roleXid);
|
||
|
||
// Create user with INVITED status
|
||
const user = await tx.user.create({
|
||
data: {
|
||
emailAddress: emailAddress,
|
||
roleXid: roleXid,
|
||
userStatus: USER_STATUS.INVITED,
|
||
userRefNumber: referenceNumber,
|
||
},
|
||
});
|
||
|
||
// Create revenue record
|
||
await tx.userRevenue.create({
|
||
data: {
|
||
userXid: user.id,
|
||
is_fixed_salary: isFixedSalary,
|
||
per_value: perValue || 0,
|
||
isActive: true,
|
||
},
|
||
});
|
||
|
||
// Create invite details
|
||
await tx.inviteDetails.create({
|
||
data: {
|
||
userXid: user.id,
|
||
is_invited: true,
|
||
invited_by: invitedBy,
|
||
invited_on: new Date(),
|
||
is_accepted: false,
|
||
invitation_status: MINGLAR_INVITATION_STATUS.INVITED,
|
||
isActive: true,
|
||
isMinglarInvitation: true,
|
||
},
|
||
});
|
||
|
||
return user;
|
||
});
|
||
}
|
||
|
||
async updateProfile(
|
||
userId: number,
|
||
userData: {
|
||
firstName?: string;
|
||
lastName?: string;
|
||
mobileNumber?: string;
|
||
dateOfBirth?: string;
|
||
profileImage?: string;
|
||
},
|
||
addressData: {
|
||
address1?: string;
|
||
address2?: string;
|
||
stateXid?: number;
|
||
countryXid?: number;
|
||
cityXid?: number;
|
||
pinCode?: string;
|
||
},
|
||
documents: Array<{
|
||
fileName: string;
|
||
filePath: string;
|
||
documentTypeName?: string;
|
||
}>,
|
||
) {
|
||
try {
|
||
return await this.prisma.$transaction(async (tx) => {
|
||
console.log('Starting transaction for user:', userId);
|
||
|
||
// 1. Update User table (optimized)
|
||
const userUpdateData: any = {};
|
||
const userFields = [
|
||
'firstName',
|
||
'lastName',
|
||
'mobileNumber',
|
||
'dateOfBirth',
|
||
'profileImage',
|
||
];
|
||
|
||
userFields.forEach((field) => {
|
||
if (userData[field as keyof typeof userData] !== undefined) {
|
||
if (field === 'dateOfBirth' && userData.dateOfBirth) {
|
||
userUpdateData[field] = new Date(userData.dateOfBirth);
|
||
} else {
|
||
userUpdateData[field] = userData[field as keyof typeof userData];
|
||
}
|
||
}
|
||
});
|
||
|
||
if (Object.keys(userUpdateData).length > 0) {
|
||
console.log('Updating user data:', userUpdateData);
|
||
await tx.user.update({
|
||
where: { id: userId },
|
||
data: userUpdateData,
|
||
});
|
||
}
|
||
|
||
// 2. Update or create UserAddressDetails
|
||
if (Object.keys(addressData).length > 0) {
|
||
console.log('Processing address data:', addressData);
|
||
|
||
const existingAddress = await tx.userAddressDetails.findFirst({
|
||
where: { userXid: userId, isActive: true },
|
||
select: { id: true }, // Only select needed field
|
||
});
|
||
|
||
const addressUpdateData: any = {};
|
||
const addressFields = [
|
||
'address1',
|
||
'address2',
|
||
'stateXid',
|
||
'countryXid',
|
||
'cityXid',
|
||
'pinCode',
|
||
];
|
||
|
||
addressFields.forEach((field) => {
|
||
if (addressData[field as keyof typeof addressData] !== undefined) {
|
||
addressUpdateData[field] =
|
||
addressData[field as keyof typeof addressData];
|
||
}
|
||
});
|
||
|
||
if (existingAddress) {
|
||
await tx.userAddressDetails.update({
|
||
where: { id: existingAddress.id },
|
||
data: addressUpdateData,
|
||
});
|
||
} else {
|
||
// Validate required fields
|
||
const requiredFields = [
|
||
'address1',
|
||
'stateXid',
|
||
'countryXid',
|
||
'cityXid',
|
||
'pinCode',
|
||
];
|
||
const missingFields = requiredFields.filter(
|
||
(field) => !addressData[field as keyof typeof addressData],
|
||
);
|
||
|
||
if (missingFields.length > 0) {
|
||
throw new ApiError(
|
||
400,
|
||
`Missing required address fields: ${missingFields.join(', ')}`,
|
||
);
|
||
}
|
||
|
||
await tx.userAddressDetails.create({
|
||
data: {
|
||
userXid: userId,
|
||
...addressUpdateData,
|
||
},
|
||
});
|
||
}
|
||
}
|
||
|
||
// 3. Handle documents more efficiently
|
||
if (documents && documents.length > 0) {
|
||
console.log('Processing documents:', documents.length);
|
||
|
||
// Use deleteMany and createMany for better performance
|
||
await tx.userDocuments.deleteMany({
|
||
where: { userXid: userId, isActive: true },
|
||
});
|
||
|
||
if (documents.length > 0) {
|
||
await tx.userDocuments.createMany({
|
||
data: documents.map((doc) => ({
|
||
userXid: userId,
|
||
documentTypeName: doc.documentTypeName,
|
||
fileName: doc.filePath,
|
||
isActive: true,
|
||
})),
|
||
});
|
||
}
|
||
}
|
||
|
||
// 4. Fetch updated user data efficiently
|
||
const updatedUser = await tx.user.findUnique({
|
||
where: { id: userId },
|
||
select: {
|
||
id: true,
|
||
firstName: true,
|
||
lastName: true,
|
||
mobileNumber: true,
|
||
dateOfBirth: true,
|
||
profileImage: true,
|
||
roleXid: true,
|
||
userAddressDetails: {
|
||
where: { isActive: true },
|
||
take: 1,
|
||
select: {
|
||
id: true,
|
||
address1: true,
|
||
address2: true,
|
||
stateXid: true,
|
||
countryXid: true,
|
||
cityXid: true,
|
||
pinCode: true,
|
||
},
|
||
},
|
||
userDocuments: {
|
||
where: { isActive: true },
|
||
select: {
|
||
id: true,
|
||
fileName: true,
|
||
documentTypeName: true,
|
||
},
|
||
},
|
||
},
|
||
});
|
||
|
||
if (!updatedUser) {
|
||
throw new ApiError(404, 'User not found after update');
|
||
}
|
||
|
||
await tx.user.update({
|
||
where: { id: userId },
|
||
data: { isProfileUpdated: true },
|
||
});
|
||
|
||
console.log('Transaction completed successfully');
|
||
|
||
return {
|
||
user: {
|
||
id: updatedUser.id,
|
||
firstName: updatedUser.firstName,
|
||
lastName: updatedUser.lastName,
|
||
mobileNumber: updatedUser.mobileNumber,
|
||
dateOfBirth: updatedUser.dateOfBirth,
|
||
profileImage: updatedUser.profileImage,
|
||
roleXid: updatedUser.roleXid,
|
||
},
|
||
address: updatedUser.userAddressDetails[0] || null,
|
||
documents: updatedUser.userDocuments,
|
||
};
|
||
});
|
||
} catch (error) {
|
||
console.error('Error in updateProfile transaction:', error);
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
async getAllInvitationDetails(
|
||
search?: string,
|
||
paginationOptions?: PaginationOptions,
|
||
) {
|
||
const filters: any = {
|
||
isMinglarInvitation: true,
|
||
isActive: true,
|
||
};
|
||
|
||
if (search?.trim()) {
|
||
const term = search.trim();
|
||
filters.user = {
|
||
OR: [
|
||
{ emailAddress: { contains: term, mode: 'insensitive' as const } },
|
||
{ firstName: { contains: term, mode: 'insensitive' as const } },
|
||
{ lastName: { contains: term, mode: 'insensitive' as const } },
|
||
{ userRefNumber: { contains: term, mode: 'insensitive' as const } },
|
||
],
|
||
};
|
||
}
|
||
|
||
const totalCount = await this.prisma.inviteDetails.count({
|
||
where: filters,
|
||
});
|
||
|
||
const invitations = await this.prisma.inviteDetails.findMany({
|
||
where: filters,
|
||
include: {
|
||
user: {
|
||
select: {
|
||
id: true,
|
||
firstName: true,
|
||
lastName: true,
|
||
emailAddress: true,
|
||
mobileNumber: true,
|
||
roleXid: true,
|
||
userRefNumber: true,
|
||
role: {
|
||
select: {
|
||
id: true,
|
||
roleName: true,
|
||
},
|
||
},
|
||
},
|
||
},
|
||
},
|
||
orderBy: {
|
||
createdAt: 'desc',
|
||
},
|
||
skip: paginationOptions?.skip ?? 0,
|
||
take: paginationOptions?.limit ?? 10,
|
||
});
|
||
|
||
return {
|
||
data: invitations,
|
||
totalCount,
|
||
};
|
||
}
|
||
|
||
// Update your MinglarService method
|
||
async getAllHostApplications(
|
||
userId: number,
|
||
userRoleXid: number,
|
||
search?: string,
|
||
userStatus?: string,
|
||
paginationOptions?: PaginationOptions,
|
||
roleFilter?: string,
|
||
applicationStatus?: string,
|
||
) {
|
||
const filters: any = {
|
||
isActive: true,
|
||
user: {
|
||
roleXid: {
|
||
notIn: [ROLE.CO_ADMIN, ROLE.ACCOUNT_MANAGER],
|
||
},
|
||
},
|
||
};
|
||
|
||
/** SEARCH FILTER **/
|
||
// if (search?.trim()) {
|
||
// const term = search.trim();
|
||
|
||
// if (/^\d+$/.test(term)) {
|
||
// filters.id = Number(term);
|
||
// } else {
|
||
// filters.user = {
|
||
// ...filters.user,
|
||
// OR: [
|
||
// { emailAddress: { contains: term, mode: 'insensitive' } },
|
||
// { firstName: { contains: term, mode: 'insensitive' } },
|
||
// { lastName: { contains: term, mode: 'insensitive' } },
|
||
// ],
|
||
// };
|
||
// }
|
||
// }
|
||
if (search?.trim()) {
|
||
const term = search.trim();
|
||
filters.AND = [
|
||
{
|
||
OR: [
|
||
{
|
||
companyName: {
|
||
contains: term,
|
||
mode: 'insensitive',
|
||
},
|
||
},
|
||
{
|
||
user: {
|
||
OR: [
|
||
{
|
||
firstName: {
|
||
contains: term,
|
||
mode: 'insensitive',
|
||
},
|
||
},
|
||
{
|
||
lastName: {
|
||
contains: term,
|
||
mode: 'insensitive',
|
||
},
|
||
},
|
||
{
|
||
userRefNumber: {
|
||
contains: term,
|
||
mode: 'insensitive',
|
||
},
|
||
},
|
||
],
|
||
},
|
||
},
|
||
],
|
||
},
|
||
];
|
||
}
|
||
|
||
/** USER STATUS FILTER **/
|
||
if (
|
||
userStatus &&
|
||
userStatus.trim().toLowerCase() ===
|
||
MINGLAR_STATUS_DISPLAY.NEW.toLowerCase()
|
||
) {
|
||
filters.adminStatusInternal = MINGLAR_STATUS_INTERNAL.ADMIN_TO_REVIEW;
|
||
}
|
||
|
||
/** APPLICATION STATUS FILTER (NEW) **/
|
||
const APPLICATION_STATUS_MAP: Record<
|
||
string,
|
||
{ internal: string; display: string }
|
||
> = {
|
||
New: {
|
||
internal: MINGLAR_STATUS_INTERNAL.AM_TO_REVIEW,
|
||
display: MINGLAR_STATUS_DISPLAY.NEW,
|
||
},
|
||
Re_Submitted: {
|
||
internal: MINGLAR_STATUS_INTERNAL.AM_TO_REVIEW,
|
||
display: MINGLAR_STATUS_DISPLAY.RE_SUBMITTED,
|
||
},
|
||
Enhancing: {
|
||
internal: MINGLAR_STATUS_INTERNAL.AM_REJECTED,
|
||
display: MINGLAR_STATUS_DISPLAY.ENHANCING,
|
||
},
|
||
Approved: {
|
||
internal: MINGLAR_STATUS_INTERNAL.AM_APPROVED,
|
||
display: MINGLAR_STATUS_DISPLAY.APPROVED,
|
||
}
|
||
};
|
||
|
||
if (applicationStatus?.trim()) {
|
||
const key = applicationStatus.trim();
|
||
const statusObj = APPLICATION_STATUS_MAP[key];
|
||
|
||
if (statusObj) {
|
||
filters.adminStatusInternal = statusObj.internal;
|
||
filters.adminStatusDisplay = statusObj.display;
|
||
}
|
||
}
|
||
|
||
/** ROLE-BASED FILTER **/
|
||
if (userRoleXid === ROLE.CO_ADMIN || userRoleXid === ROLE.ACCOUNT_MANAGER) {
|
||
filters.accountManagerXid = userId;
|
||
}
|
||
|
||
// COUNT
|
||
const totalCount = await this.prisma.hostHeader.count({ where: filters });
|
||
|
||
// MAIN QUERY
|
||
const results = await this.prisma.hostHeader.findMany({
|
||
where: filters,
|
||
select: {
|
||
id: true,
|
||
hostStatusInternal: true,
|
||
hostStatusDisplay: true,
|
||
adminStatusDisplay: true,
|
||
adminStatusInternal: true,
|
||
createdAt: true,
|
||
companyName: true,
|
||
assignedOn: true,
|
||
cities: { select: { id: true, cityName: true } },
|
||
states: { select: { id: true, stateName: true } },
|
||
countries: { select: { id: true, countryName: true } },
|
||
user: {
|
||
select: {
|
||
id: true,
|
||
firstName: true,
|
||
lastName: true,
|
||
emailAddress: true,
|
||
mobileNumber: true,
|
||
userRefNumber: true,
|
||
profileImage: true,
|
||
},
|
||
},
|
||
accountManager: {
|
||
select: {
|
||
id: true,
|
||
firstName: true,
|
||
lastName: true,
|
||
emailAddress: true,
|
||
mobileNumber: true,
|
||
roleXid: true,
|
||
profileImage: true,
|
||
},
|
||
},
|
||
},
|
||
orderBy: { createdAt: 'desc' },
|
||
skip: paginationOptions?.skip || 0,
|
||
take: paginationOptions?.limit || 10,
|
||
});
|
||
|
||
for (const user of results) {
|
||
const am = user.accountManager;
|
||
|
||
if (am?.profileImage) {
|
||
const key = am.profileImage.startsWith('http')
|
||
? am.profileImage.split('.com/')[1]
|
||
: am.profileImage;
|
||
|
||
am.profileImage = await getPresignedUrl(bucket, key);
|
||
}
|
||
}
|
||
|
||
const transformedData = results.map((h) => ({
|
||
hostId: h.id,
|
||
host: h.user,
|
||
hostStatusDisplay: h.hostStatusDisplay,
|
||
hostStatusInternal: h.hostStatusInternal,
|
||
adminStatusDisplay: h.adminStatusDisplay,
|
||
adminStatusInternal: h.adminStatusInternal,
|
||
submittedOn: h.createdAt,
|
||
accountManager: h.accountManager || null,
|
||
companyName: h.companyName || null,
|
||
city: h.cities || null,
|
||
state: h.states || null,
|
||
country: h.countries || null,
|
||
assignedOn: h.assignedOn || null,
|
||
}));
|
||
|
||
return { data: transformedData, totalCount };
|
||
}
|
||
|
||
async getAllOnboardingHostApplications(
|
||
paginationOptions?: PaginationOptions,
|
||
search?: string,
|
||
) {
|
||
const where: any = {
|
||
isActive: true,
|
||
hostStatusInternal: { notIn: [HOST_STATUS_INTERNAL.DRAFT] },
|
||
adminStatusInternal: { notIn: [MINGLAR_STATUS_INTERNAL.AM_TO_REVIEW] },
|
||
};
|
||
|
||
if (search?.trim()) {
|
||
const term = search.trim();
|
||
where.AND = [
|
||
{
|
||
OR: [
|
||
{
|
||
companyName: {
|
||
contains: term,
|
||
mode: 'insensitive',
|
||
},
|
||
},
|
||
{
|
||
user: {
|
||
OR: [
|
||
{
|
||
firstName: {
|
||
contains: term,
|
||
mode: 'insensitive',
|
||
},
|
||
},
|
||
{
|
||
lastName: {
|
||
contains: term,
|
||
mode: 'insensitive',
|
||
},
|
||
},
|
||
{
|
||
userRefNumber: {
|
||
contains: term,
|
||
mode: 'insensitive',
|
||
},
|
||
},
|
||
],
|
||
},
|
||
},
|
||
],
|
||
},
|
||
];
|
||
}
|
||
|
||
const totalCount = await this.prisma.hostHeader.count({ where });
|
||
|
||
const onBoardingHostApp = await this.prisma.hostHeader.findMany({
|
||
where,
|
||
select: {
|
||
id: true,
|
||
companyName: true,
|
||
adminStatusDisplay: true,
|
||
adminStatusInternal: true,
|
||
assignedOn: true,
|
||
accountManagerXid: true,
|
||
createdAt: true,
|
||
user: {
|
||
select: {
|
||
id: true,
|
||
firstName: true,
|
||
lastName: true,
|
||
emailAddress: true,
|
||
userRefNumber: true,
|
||
mobileNumber: true,
|
||
},
|
||
},
|
||
accountManager: {
|
||
select: {
|
||
id: true,
|
||
firstName: true,
|
||
lastName: true,
|
||
profileImage: true,
|
||
},
|
||
},
|
||
companyTypes: {
|
||
select: {
|
||
id: true,
|
||
companyTypeName: true,
|
||
},
|
||
},
|
||
},
|
||
orderBy: {
|
||
createdAt: 'desc',
|
||
},
|
||
skip: paginationOptions?.skip ?? 0,
|
||
take: paginationOptions?.limit ?? undefined,
|
||
});
|
||
|
||
/** ---------------------------------
|
||
* Add presigned URL for AM profile
|
||
* --------------------------------- */
|
||
for (const host of onBoardingHostApp) {
|
||
const am = host.accountManager;
|
||
|
||
if (am?.profileImage) {
|
||
const key = am.profileImage.startsWith('http')
|
||
? am.profileImage.split('.com/')[1]
|
||
: am.profileImage;
|
||
|
||
am.profileImage = await getPresignedUrl(bucket, key);
|
||
}
|
||
}
|
||
|
||
return {
|
||
data: onBoardingHostApp,
|
||
totalCount,
|
||
};
|
||
}
|
||
|
||
async getAllOnboardingHostApplications_New(
|
||
paginationOptions?: PaginationOptions,
|
||
search?: string,
|
||
) {
|
||
const where: any = {
|
||
isActive: true,
|
||
adminStatusInternal: MINGLAR_STATUS_INTERNAL.ADMIN_TO_REVIEW,
|
||
};
|
||
|
||
if (search?.trim()) {
|
||
const term = search.trim();
|
||
where.AND = [
|
||
{
|
||
OR: [
|
||
{
|
||
companyName: {
|
||
contains: term,
|
||
mode: 'insensitive',
|
||
},
|
||
},
|
||
{
|
||
user: {
|
||
OR: [
|
||
{
|
||
firstName: {
|
||
contains: term,
|
||
mode: 'insensitive',
|
||
},
|
||
},
|
||
{
|
||
lastName: {
|
||
contains: term,
|
||
mode: 'insensitive',
|
||
},
|
||
},
|
||
{
|
||
userRefNumber: {
|
||
contains: term,
|
||
mode: 'insensitive',
|
||
},
|
||
},
|
||
],
|
||
},
|
||
},
|
||
],
|
||
},
|
||
];
|
||
}
|
||
|
||
const totalCount = await this.prisma.hostHeader.count({ where });
|
||
|
||
const onBoardingHostApp = await this.prisma.hostHeader.findMany({
|
||
where,
|
||
select: {
|
||
id: true,
|
||
companyName: true,
|
||
adminStatusDisplay: true,
|
||
adminStatusInternal: true,
|
||
assignedOn: true,
|
||
accountManagerXid: true,
|
||
createdAt: true,
|
||
cities: {
|
||
select: {
|
||
id: true,
|
||
cityName: true,
|
||
},
|
||
},
|
||
countries: {
|
||
select: {
|
||
id: true,
|
||
countryName: true,
|
||
},
|
||
},
|
||
states: {
|
||
select: {
|
||
id: true,
|
||
stateName: true,
|
||
},
|
||
},
|
||
user: {
|
||
select: {
|
||
id: true,
|
||
firstName: true,
|
||
lastName: true,
|
||
emailAddress: true,
|
||
userRefNumber: true,
|
||
},
|
||
},
|
||
accountManager: {
|
||
select: {
|
||
id: true,
|
||
firstName: true,
|
||
lastName: true,
|
||
profileImage: true,
|
||
},
|
||
},
|
||
companyTypes: {
|
||
select: {
|
||
id: true,
|
||
companyTypeName: true,
|
||
},
|
||
},
|
||
},
|
||
orderBy: {
|
||
createdAt: 'desc',
|
||
},
|
||
skip: paginationOptions?.skip ?? 0,
|
||
take: paginationOptions?.limit ?? 10,
|
||
});
|
||
|
||
/** ---------------------------------
|
||
* Add presigned URL for AM profile
|
||
* --------------------------------- */
|
||
for (const host of onBoardingHostApp) {
|
||
const am = host.accountManager;
|
||
|
||
if (am?.profileImage) {
|
||
const key = am.profileImage.startsWith('http')
|
||
? am.profileImage.split('.com/')[1]
|
||
: am.profileImage;
|
||
|
||
am.profileImage = await getPresignedUrl(bucket, key);
|
||
}
|
||
}
|
||
|
||
return {
|
||
data: onBoardingHostApp,
|
||
totalCount,
|
||
};
|
||
}
|
||
|
||
async getAllCoadminAndAM(search?: string) {
|
||
// Build search filter if search term is provided
|
||
const searchFilter = search
|
||
? {
|
||
OR: [
|
||
{ email: { contains: search, mode: 'insensitive' as const } },
|
||
{ firstName: { contains: search, mode: 'insensitive' as const } },
|
||
{ lastName: { contains: search, mode: 'insensitive' as const } },
|
||
{
|
||
userRefNumber: { contains: search, mode: 'insensitive' as const },
|
||
},
|
||
],
|
||
}
|
||
: {};
|
||
|
||
// 1. Fetch all required users (Admin, Co-Admin, AM)
|
||
const users = await this.prisma.user.findMany({
|
||
where: {
|
||
roleXid: {
|
||
in: [
|
||
ROLE.MINGLAR_ADMIN, // Admin
|
||
ROLE.CO_ADMIN, // Co-Admin
|
||
ROLE.ACCOUNT_MANAGER, // AM
|
||
],
|
||
},
|
||
isActive: true,
|
||
userStatus: USER_STATUS.ACTIVE,
|
||
isProfileUpdated: true,
|
||
...searchFilter,
|
||
},
|
||
include: {
|
||
role: {
|
||
select: {
|
||
id: true,
|
||
roleName: true,
|
||
},
|
||
},
|
||
},
|
||
});
|
||
|
||
if (!users.length) return [];
|
||
|
||
const userIds = users.map((u) => u.id);
|
||
|
||
// 2. Count assigned hosts for ANY user (Admin / Co-Admin / AM)
|
||
const groupedHosts = await this.prisma.hostHeader.groupBy({
|
||
by: ['accountManagerXid'],
|
||
where: {
|
||
accountManagerXid: { in: userIds }, // assigned user
|
||
isActive: true,
|
||
},
|
||
_count: {
|
||
id: true,
|
||
},
|
||
});
|
||
|
||
// 3. Build quick lookup map: userId -> hostCount
|
||
const hostCountMap: Record<number, number> = {};
|
||
groupedHosts.forEach((g) => {
|
||
const uid = Number(g.accountManagerXid);
|
||
hostCountMap[uid] = g._count.id;
|
||
});
|
||
|
||
for (const user of users) {
|
||
const am = user.profileImage;
|
||
|
||
if (user?.profileImage) {
|
||
const key = user.profileImage.startsWith('http')
|
||
? user.profileImage.split('.com/')[1]
|
||
: user.profileImage;
|
||
|
||
user.profileImage = await getPresignedUrl(bucket, key);
|
||
}
|
||
}
|
||
|
||
// 4. Attach host counts to each user
|
||
return users.map((user) => ({
|
||
...user,
|
||
assignedHostCount: hostCountMap[user.id] ?? 0,
|
||
}));
|
||
}
|
||
|
||
async getAllInvitedCoadminAndAM(
|
||
search?: string,
|
||
paginationOptions?: PaginationOptions,
|
||
) {
|
||
const baseFilters: any = {
|
||
roleXid: {
|
||
in: [
|
||
ROLE.MINGLAR_ADMIN, // Admin
|
||
ROLE.CO_ADMIN, // Co-Admin
|
||
ROLE.ACCOUNT_MANAGER, // AM
|
||
],
|
||
},
|
||
isActive: true,
|
||
userStatus: {
|
||
not: USER_STATUS.DE_ACTIVATED, // Exclude DE_ACTIVATED status
|
||
},
|
||
};
|
||
|
||
if (search?.trim()) {
|
||
const term = search.trim();
|
||
baseFilters.OR = [
|
||
{ emailAddress: { contains: term, mode: 'insensitive' as const } },
|
||
{ firstName: { contains: term, mode: 'insensitive' as const } },
|
||
{ lastName: { contains: term, mode: 'insensitive' as const } },
|
||
{ userRefNumber: { contains: term, mode: 'insensitive' as const } },
|
||
];
|
||
}
|
||
|
||
const totalCount = await this.prisma.user.count({
|
||
where: baseFilters,
|
||
});
|
||
|
||
const users = await this.prisma.user.findMany({
|
||
where: baseFilters,
|
||
include: {
|
||
role: {
|
||
select: {
|
||
id: true,
|
||
roleName: true,
|
||
},
|
||
},
|
||
},
|
||
orderBy: {
|
||
createdAt: 'desc',
|
||
},
|
||
skip: paginationOptions?.skip ?? 0,
|
||
take: paginationOptions?.limit ?? 10,
|
||
});
|
||
|
||
return {
|
||
data: users,
|
||
totalCount,
|
||
};
|
||
}
|
||
|
||
async assignAMToHost(
|
||
userId: number,
|
||
hostXid: number,
|
||
accountManagerXid: number,
|
||
) {
|
||
const hostDetails = await this.prisma.hostHeader.findFirst({
|
||
where: { id: hostXid },
|
||
});
|
||
|
||
if (!hostDetails) {
|
||
throw new ApiError(404, 'Host not found');
|
||
}
|
||
|
||
if (hostDetails.accountManagerXid !== null) {
|
||
throw new ApiError(400, 'AM already assigned to this host');
|
||
}
|
||
|
||
if (
|
||
hostDetails.adminStatusInternal !==
|
||
MINGLAR_STATUS_INTERNAL.AM_NOT_ASSIGNED
|
||
) {
|
||
throw new ApiError(400, 'Invalid host status');
|
||
}
|
||
|
||
await this.prisma.hostHeader.update({
|
||
where: { id: hostXid },
|
||
data: {
|
||
accountManagerXid: accountManagerXid,
|
||
assignedOn: new Date(),
|
||
hostStatusInternal: HOST_STATUS_INTERNAL.HOST_SUBMITTED,
|
||
hostStatusDisplay: HOST_STATUS_DISPLAY.UNDER_REVIEW,
|
||
adminStatusInternal: MINGLAR_STATUS_INTERNAL.AM_TO_REVIEW,
|
||
adminStatusDisplay: MINGLAR_STATUS_DISPLAY.NEW,
|
||
},
|
||
});
|
||
return true;
|
||
}
|
||
|
||
/**
|
||
* Notify Account Manager by email after assignment.
|
||
* Encapsulates lookup + email send so handlers can call a single method.
|
||
*/
|
||
async notifyAMOfAssignment(accountManagerXid: number): Promise<boolean> {
|
||
if (!accountManagerXid) return false;
|
||
|
||
const amUser = await this.prisma.user.findUnique({
|
||
where: { id: accountManagerXid, isActive: true },
|
||
select: { emailAddress: true },
|
||
});
|
||
|
||
if (!amUser || !amUser.emailAddress) {
|
||
console.warn(
|
||
`AM notification skipped: user not found or missing email for id=${accountManagerXid}`,
|
||
);
|
||
return false;
|
||
}
|
||
|
||
try {
|
||
await sendAMEmailForHostAssign(amUser.emailAddress);
|
||
return true;
|
||
} catch (err) {
|
||
console.error('Error sending AM assignment email', err);
|
||
return false;
|
||
}
|
||
}
|
||
|
||
async addHostSuggestion(
|
||
hostXid: number,
|
||
title: string,
|
||
comments: string,
|
||
reviewedByXid: number,
|
||
isParent: boolean = false,
|
||
) {
|
||
// Check if host exists
|
||
const hostHeader = await this.prisma.hostHeader.findUnique({
|
||
where: { id: hostXid },
|
||
select: { id: true },
|
||
});
|
||
console.log(hostHeader);
|
||
|
||
if (!hostHeader) {
|
||
throw new ApiError(404, 'Host not found');
|
||
}
|
||
|
||
// Create suggestion in host_suggestion table
|
||
await this.prisma.hostSuggestion.create({
|
||
data: {
|
||
hostXid: hostXid,
|
||
title: title,
|
||
comments: comments,
|
||
isparent: isParent,
|
||
isreviewed: false,
|
||
reviewOn: null,
|
||
isActive: true,
|
||
},
|
||
});
|
||
|
||
return true;
|
||
}
|
||
|
||
async addPqqSuggestion(
|
||
title: string,
|
||
comments: string,
|
||
activity_pqq_header_xid: number,
|
||
reviewedByXid: number,
|
||
) {
|
||
// Check if host exists
|
||
const ActivityHeader = await this.prisma.activityPQQheader.findUnique({
|
||
where: { id: activity_pqq_header_xid, isActive: true },
|
||
select: { id: true },
|
||
});
|
||
|
||
if (!ActivityHeader) {
|
||
throw new ApiError(404, 'Host not found');
|
||
}
|
||
|
||
await this.prisma.activityPQQSuggestions.create({
|
||
data: {
|
||
title: title,
|
||
comments: comments,
|
||
isReviewed: false,
|
||
reviewedOn: new Date(),
|
||
isActive: true,
|
||
activityPqqHeaderXid: activity_pqq_header_xid,
|
||
reviewedByXid: reviewedByXid,
|
||
},
|
||
});
|
||
|
||
return true;
|
||
}
|
||
|
||
async addActivtiySuggestion(
|
||
title: string,
|
||
comments: string,
|
||
activity_xid: number,
|
||
reviewedByXid: number,
|
||
) {
|
||
// Check if host exists
|
||
const ActivityHeader = await this.prisma.activities.findUnique({
|
||
where: { id: activity_xid, isActive: true },
|
||
select: { id: true },
|
||
});
|
||
|
||
if (!ActivityHeader) {
|
||
throw new ApiError(404, 'Host not found');
|
||
}
|
||
|
||
await this.prisma.activitySuggestions.create({
|
||
data: {
|
||
title: title,
|
||
comments: comments,
|
||
isReviewed: false,
|
||
reviewedOn: new Date(),
|
||
isActive: true,
|
||
activityXid: activity_xid,
|
||
reviewedByXid: reviewedByXid,
|
||
},
|
||
});
|
||
|
||
return true;
|
||
}
|
||
|
||
async getHostSuggestions(userId: number) {
|
||
const hostDetail = await this.prisma.hostHeader.findFirst({
|
||
where: { userXid: userId, isActive: true },
|
||
});
|
||
|
||
const suggestions = await this.prisma.hostSuggestion.findMany({
|
||
where: { hostXid: hostDetail.id, isreviewed: false, isActive: true },
|
||
select: {
|
||
id: true,
|
||
title: true,
|
||
comments: true,
|
||
isparent: true,
|
||
isreviewed: true,
|
||
reviewOn: true,
|
||
},
|
||
orderBy: {
|
||
id: 'asc',
|
||
},
|
||
});
|
||
|
||
return suggestions;
|
||
}
|
||
|
||
async getHostSuggestionsForActivity(actvityXid: number) {
|
||
const suggestions = await this.prisma.activitySuggestions.findMany({
|
||
where: { activityXid: actvityXid, isReviewed: false, isActive: true },
|
||
select: {
|
||
id: true,
|
||
title: true,
|
||
comments: true,
|
||
isReviewed: true,
|
||
reviewedOn: true,
|
||
},
|
||
orderBy: {
|
||
id: 'asc',
|
||
},
|
||
});
|
||
|
||
return suggestions;
|
||
}
|
||
|
||
async getSuggestionsForAM(hostXid: number) {
|
||
const suggestions = await this.prisma.hostSuggestion.findMany({
|
||
where: { hostXid: hostXid, isreviewed: false, isActive: true },
|
||
select: {
|
||
id: true,
|
||
title: true,
|
||
comments: true,
|
||
isparent: true,
|
||
isreviewed: true,
|
||
reviewOn: true,
|
||
},
|
||
orderBy: {
|
||
id: 'asc',
|
||
},
|
||
});
|
||
|
||
return suggestions;
|
||
}
|
||
|
||
async acceptHostApplication(host_xid: number, user_xid: number) {
|
||
return await this.prisma.$transaction(async (tx) => {
|
||
await this.prisma.hostHeader.update({
|
||
where: {
|
||
id: host_xid,
|
||
hostStatusInternal: HOST_STATUS_INTERNAL.HOST_SUBMITTED,
|
||
adminStatusInternal: MINGLAR_STATUS_INTERNAL.AM_TO_REVIEW,
|
||
},
|
||
data: {
|
||
hostStatusInternal: HOST_STATUS_INTERNAL.APPROVED,
|
||
hostStatusDisplay: HOST_STATUS_DISPLAY.APPROVED,
|
||
adminStatusInternal: MINGLAR_STATUS_INTERNAL.AM_APPROVED,
|
||
adminStatusDisplay: MINGLAR_STATUS_DISPLAY.APPROVED,
|
||
stepper: STEPPER.COMPANY_DETAILS_APPROVED,
|
||
},
|
||
});
|
||
|
||
await this.prisma.hostTrack.create({
|
||
data: {
|
||
hostXid: host_xid,
|
||
updatedByRole: ROLE_NAME.ACCOUNT_MANAGER,
|
||
updatedByXid: user_xid,
|
||
trackStatus: MINGLAR_STATUS_INTERNAL.AM_APPROVED,
|
||
},
|
||
});
|
||
});
|
||
}
|
||
|
||
async acceptHostApplicationMinglarAdmin(
|
||
host_xid: number,
|
||
user_xid: number,
|
||
agreementStartDate: string,
|
||
duration: number,
|
||
isCommisionBase: boolean,
|
||
commisionPer: number,
|
||
amountPerBooking: number,
|
||
durationFrequency: string,
|
||
payoutDurationNum: number,
|
||
payoutDurationFrequency: string,
|
||
) {
|
||
return await this.prisma.$transaction(async (tx) => {
|
||
await this.prisma.hostHeader.update({
|
||
where: {
|
||
id: host_xid,
|
||
hostStatusInternal: HOST_STATUS_INTERNAL.HOST_SUBMITTED,
|
||
adminStatusInternal: MINGLAR_STATUS_INTERNAL.ADMIN_TO_REVIEW,
|
||
},
|
||
data: {
|
||
durationNumber: Number(duration),
|
||
durationFrequency: durationFrequency,
|
||
agreementStartDate: new Date(agreementStartDate),
|
||
isCommisionBase: isCommisionBase,
|
||
commisionPer: commisionPer ? Number(commisionPer) : null, // Convert to number if exists
|
||
amountPerBooking: amountPerBooking ? Number(amountPerBooking) : null, // Convert to number if exists
|
||
payoutDurationNum: Number(payoutDurationNum), // Convert to number
|
||
payoutDurationFrequency: payoutDurationFrequency,
|
||
hostStatusInternal: HOST_STATUS_INTERNAL.HOST_SUBMITTED,
|
||
hostStatusDisplay: HOST_STATUS_DISPLAY.UNDER_REVIEW,
|
||
adminStatusInternal: MINGLAR_STATUS_INTERNAL.AM_NOT_ASSIGNED,
|
||
adminStatusDisplay: MINGLAR_STATUS_DISPLAY.AM_NOT_ASSIGNED,
|
||
},
|
||
});
|
||
|
||
await this.prisma.hostTrack.create({
|
||
data: {
|
||
hostXid: host_xid,
|
||
updatedByRole: ROLE_NAME.MINGLAR_ADMIN,
|
||
updatedByXid: user_xid,
|
||
trackStatus: MINGLAR_STATUS_INTERNAL.AM_NOT_ASSIGNED,
|
||
},
|
||
});
|
||
});
|
||
}
|
||
|
||
async rejectHostApplication(host_xid: number, user_xid: number) {
|
||
await this.prisma.$transaction(async (tx) => {
|
||
const hostDetails = await tx.hostHeader.findFirst({
|
||
where: { id: host_xid },
|
||
select: { id: true, userXid: true },
|
||
});
|
||
if (!hostDetails) {
|
||
throw new Error('Host not found');
|
||
}
|
||
await tx.hostHeader.update({
|
||
where: {
|
||
id: host_xid,
|
||
hostStatusInternal: HOST_STATUS_INTERNAL.HOST_SUBMITTED,
|
||
hostStatusDisplay: HOST_STATUS_DISPLAY.UNDER_REVIEW,
|
||
},
|
||
data: {
|
||
stepper: STEPPER.NOT_SUBMITTED,
|
||
hostStatusInternal: HOST_STATUS_INTERNAL.REJECTED,
|
||
hostStatusDisplay: HOST_STATUS_DISPLAY.REJECTED,
|
||
adminStatusInternal: MINGLAR_STATUS_INTERNAL.ADMIN_REJECTED,
|
||
adminStatusDisplay: MINGLAR_STATUS_DISPLAY.REJECTED,
|
||
},
|
||
});
|
||
|
||
await this.prisma.hostTrack.create({
|
||
data: {
|
||
hostXid: hostDetails.id,
|
||
updatedByRole: ROLE_NAME.MINGLAR_ADMIN,
|
||
updatedByXid: user_xid,
|
||
trackStatus: MINGLAR_STATUS_INTERNAL.ADMIN_REJECTED,
|
||
},
|
||
});
|
||
|
||
// await tx.user.update({
|
||
// where: { id: hostDetails.userXid },
|
||
// data: {
|
||
// userStatus: USER_STATUS.REJECTED,
|
||
// },
|
||
// });
|
||
});
|
||
}
|
||
|
||
async rejectHostApplicationAM(host_xid: number, user_xid: number) {
|
||
return await this.prisma.$transaction(async (tx) => {
|
||
const hostDetails = await this.prisma.hostHeader.findFirst({
|
||
where: { id: host_xid },
|
||
select: { id: true, userXid: true },
|
||
});
|
||
if (!hostDetails) {
|
||
throw new Error('Host not found');
|
||
}
|
||
await this.prisma.hostHeader.update({
|
||
where: {
|
||
id: host_xid,
|
||
hostStatusInternal: HOST_STATUS_INTERNAL.HOST_SUBMITTED,
|
||
hostStatusDisplay: HOST_STATUS_DISPLAY.UNDER_REVIEW,
|
||
},
|
||
data: {
|
||
stepper: STEPPER.NOT_SUBMITTED,
|
||
hostStatusInternal: HOST_STATUS_INTERNAL.HOST_TO_UPDATE,
|
||
hostStatusDisplay: HOST_STATUS_DISPLAY.ENHANCING,
|
||
adminStatusInternal: MINGLAR_STATUS_INTERNAL.AM_REJECTED,
|
||
adminStatusDisplay: MINGLAR_STATUS_DISPLAY.ENHANCING,
|
||
},
|
||
});
|
||
|
||
await this.prisma.hostTrack.create({
|
||
data: {
|
||
hostXid: hostDetails.id,
|
||
updatedByRole: ROLE_NAME.ACCOUNT_MANAGER,
|
||
updatedByXid: user_xid,
|
||
trackStatus: MINGLAR_STATUS_INTERNAL.AM_REJECTED,
|
||
},
|
||
});
|
||
});
|
||
}
|
||
|
||
async getAMdetailById(id: number) {
|
||
const user = await this.prisma.user.findUnique({
|
||
where: { id: id, isActive: true, userStatus: USER_STATUS.ACTIVE },
|
||
select: {
|
||
id: true,
|
||
firstName: true,
|
||
lastName: true,
|
||
emailAddress: true,
|
||
isdCode: true,
|
||
mobileNumber: true,
|
||
roleXid: true,
|
||
userStatus: true,
|
||
isProfileUpdated: true,
|
||
dateOfBirth: true,
|
||
profileImage: true,
|
||
isEmailVerfied: true,
|
||
isMobileVerfied: true,
|
||
isBiometric: true,
|
||
createdAt: true,
|
||
userAddressDetails: {
|
||
select: {
|
||
id: true,
|
||
userXid: true,
|
||
address1: true,
|
||
address2: true,
|
||
locationAddress: true,
|
||
locationLat: true,
|
||
locationLong: true,
|
||
locationName: true,
|
||
pinCode: true,
|
||
country: {
|
||
select: {
|
||
id: true,
|
||
countryName: true,
|
||
},
|
||
},
|
||
cities: {
|
||
select: {
|
||
id: true,
|
||
cityName: true,
|
||
},
|
||
},
|
||
states: {
|
||
select: {
|
||
id: true,
|
||
stateName: true,
|
||
},
|
||
},
|
||
},
|
||
},
|
||
userDocuments: {
|
||
select: {
|
||
id: true,
|
||
fileName: true,
|
||
},
|
||
},
|
||
userRevenues: {
|
||
select: {
|
||
id: true,
|
||
is_fixed_salary: true,
|
||
per_value: true,
|
||
},
|
||
},
|
||
},
|
||
});
|
||
|
||
if (user.userDocuments?.length) {
|
||
for (const media of user.userDocuments) {
|
||
if (!media.fileName) continue;
|
||
|
||
// Extract S3 key if URL or keep raw key
|
||
const key = media.fileName.startsWith('http')
|
||
? media.fileName.split('.com/')[1]
|
||
: media.fileName;
|
||
|
||
media.fileName = await getPresignedUrl(bucket, key);
|
||
}
|
||
}
|
||
|
||
if (user.profileImage) {
|
||
const key = user.profileImage.startsWith('http')
|
||
? user.profileImage.split('.com/')[1]
|
||
: user.profileImage;
|
||
|
||
user.profileImage = await getPresignedUrl(bucket, key);
|
||
}
|
||
return user;
|
||
}
|
||
|
||
async getBasicUserDetails(user_xid) {
|
||
return await this.prisma.user.findFirst({
|
||
where: {
|
||
id: user_xid,
|
||
},
|
||
select: {
|
||
id: true,
|
||
firstName: true,
|
||
lastName: true,
|
||
emailAddress: true,
|
||
userStatus: true,
|
||
isProfileUpdated: true,
|
||
roleXid: true,
|
||
role: true,
|
||
},
|
||
});
|
||
}
|
||
|
||
async rejectPQQbyAM(activityId: number, user_xid: number) {
|
||
return await this.prisma.$transaction(async (tx) => {
|
||
await tx.activities.update({
|
||
where: {
|
||
id: activityId,
|
||
isActive: true,
|
||
},
|
||
data: {
|
||
activityInternalStatus: ACTIVITY_INTERNAL_STATUS.PQ_TO_UPDATE,
|
||
activityDisplayStatus: ACTIVITY_DISPLAY_STATUS.ENHANCING,
|
||
amInternalStatus: ACTIVITY_AM_INTERNAL_STATUS.PQ_REJECTED,
|
||
amDisplayStatus: ACTIVITY_AM_DISPLAY_STATUS.ENHANCING,
|
||
},
|
||
});
|
||
|
||
await tx.activityTrack.create({
|
||
data: {
|
||
activityXid: activityId,
|
||
trackType: ACTIVITY_TRACK_TYPE.PQ,
|
||
trackStatus: ACTIVITY_TRACK_STATUS.REJECTED_BY_AM,
|
||
updatedByXid: user_xid,
|
||
updatedByRole: ROLE_NAME.ACCOUNT_MANAGER,
|
||
updatedOn: new Date(),
|
||
},
|
||
});
|
||
});
|
||
}
|
||
|
||
|
||
async rejectActivityApplicationByAM(activityId: number, user_xid: number) {
|
||
return await this.prisma.$transaction(async (tx) => {
|
||
await tx.activities.update({
|
||
where: {
|
||
id: activityId,
|
||
isActive: true
|
||
},
|
||
data: {
|
||
activityInternalStatus: ACTIVITY_INTERNAL_STATUS.ACTIVITY_REJECTED,
|
||
activityDisplayStatus: ACTIVITY_DISPLAY_STATUS.ENHANCING,
|
||
amInternalStatus: ACTIVITY_AM_INTERNAL_STATUS.ACTIVITY_REJECTED,
|
||
amDisplayStatus: ACTIVITY_AM_DISPLAY_STATUS.ENHANCING
|
||
}
|
||
})
|
||
|
||
await tx.activityTrack.create({
|
||
data: {
|
||
activityXid: activityId,
|
||
trackType: ACTIVITY_TRACK_TYPE.ACTIVITY,
|
||
trackStatus: ACTIVITY_TRACK_STATUS.REJECTED_BY_AM,
|
||
updatedByXid: user_xid,
|
||
updatedByRole: ROLE_NAME.ACCOUNT_MANAGER,
|
||
updatedOn: new Date()
|
||
}
|
||
})
|
||
})
|
||
}
|
||
|
||
async acceptPQByAM(activityId: number, user_xid: number) {
|
||
return await this.prisma.$transaction(async (tx) => {
|
||
await tx.activities.update({
|
||
where: {
|
||
id: activityId,
|
||
isActive: true,
|
||
},
|
||
data: {
|
||
activityInternalStatus: ACTIVITY_INTERNAL_STATUS.PQ_APPROVED,
|
||
activityDisplayStatus: ACTIVITY_DISPLAY_STATUS.PQ_APPROVED,
|
||
amInternalStatus: ACTIVITY_AM_INTERNAL_STATUS.PQ_APPROVED,
|
||
amDisplayStatus: ACTIVITY_AM_DISPLAY_STATUS.PQ_APPROVED,
|
||
},
|
||
});
|
||
|
||
await tx.activityTrack.create({
|
||
data: {
|
||
activityXid: activityId,
|
||
trackType: ACTIVITY_TRACK_TYPE.PQ,
|
||
trackStatus: ACTIVITY_TRACK_STATUS.ACCEPTED_BY_AM,
|
||
updatedByXid: user_xid,
|
||
updatedByRole: ROLE_NAME.ACCOUNT_MANAGER,
|
||
updatedOn: new Date(),
|
||
},
|
||
});
|
||
});
|
||
}
|
||
|
||
|
||
async acceptActivityApplicationByAM(activityId: number, user_xid: number) {
|
||
return await this.prisma.$transaction(async (tx) => {
|
||
await tx.activities.update({
|
||
where: {
|
||
id: activityId,
|
||
isActive: true
|
||
},
|
||
data: {
|
||
activityInternalStatus: ACTIVITY_INTERNAL_STATUS.ACTIVITY_APPROVED,
|
||
activityDisplayStatus: ACTIVITY_DISPLAY_STATUS.NOT_LISTED,
|
||
amInternalStatus: ACTIVITY_AM_INTERNAL_STATUS.ACTIVITY_APPROVED,
|
||
amDisplayStatus: ACTIVITY_AM_DISPLAY_STATUS.NOT_LISTED
|
||
}
|
||
})
|
||
|
||
await tx.activityTrack.create({
|
||
data: {
|
||
activityXid: activityId,
|
||
trackType: ACTIVITY_TRACK_TYPE.ACTIVITY,
|
||
trackStatus: ACTIVITY_TRACK_STATUS.ACCEPTED_BY_AM,
|
||
updatedByXid: user_xid,
|
||
updatedByRole: ROLE_NAME.ACCOUNT_MANAGER,
|
||
updatedOn: new Date()
|
||
}
|
||
})
|
||
})
|
||
}
|
||
|
||
async getHostDetailsById(host_xid) {
|
||
const host = await this.prisma.hostHeader.findFirst({
|
||
where: { id: host_xid },
|
||
include: {
|
||
companyTypes: {
|
||
select: {
|
||
id: true,
|
||
companyTypeName: true,
|
||
},
|
||
},
|
||
hostParent: {
|
||
include: {
|
||
HostParenetDocuments: {
|
||
select: {
|
||
id: true,
|
||
filePath: true,
|
||
documentName: true,
|
||
documentTypeXid: true,
|
||
documentType: true,
|
||
},
|
||
},
|
||
cities: {
|
||
select: {
|
||
id: true,
|
||
cityName: true,
|
||
},
|
||
},
|
||
countries: {
|
||
select: {
|
||
id: true,
|
||
countryName: true,
|
||
},
|
||
},
|
||
states: {
|
||
select: {
|
||
id: true,
|
||
stateName: true,
|
||
},
|
||
},
|
||
companyTypes: {
|
||
select: {
|
||
id: true,
|
||
companyTypeName: true,
|
||
},
|
||
},
|
||
},
|
||
},
|
||
HostBankDetails: true,
|
||
HostDocuments: {
|
||
include: {
|
||
documentType: true,
|
||
},
|
||
},
|
||
user: {
|
||
select: {
|
||
id: true,
|
||
emailAddress: true,
|
||
firstName: true,
|
||
lastName: true,
|
||
mobileNumber: true,
|
||
profileImage: true,
|
||
userStatus: true,
|
||
userRefNumber: true,
|
||
},
|
||
},
|
||
HostSuggestion: true,
|
||
HostTrack: true,
|
||
countries: true,
|
||
currencies: true,
|
||
states: true,
|
||
cities: true,
|
||
},
|
||
});
|
||
|
||
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.logoPath) {
|
||
const key = host.logoPath.startsWith('http')
|
||
? host.logoPath.split('.com/')[1]
|
||
: host.logoPath;
|
||
|
||
host.logoPath = 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.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 getAllPQPDetailsForAM(activityXid: number) {
|
||
const pqqHeaderData = await this.prisma.activityPQQheader.findMany({
|
||
where: {
|
||
activityXid: activityXid,
|
||
isActive: true,
|
||
},
|
||
select: {
|
||
id: true,
|
||
comments: true,
|
||
pqqAnswerXid: true,
|
||
activity: {
|
||
select: {
|
||
id: true,
|
||
activityTitle: true,
|
||
activityRefNumber: true,
|
||
activityDisplayStatus: true,
|
||
activityInternalStatus: true,
|
||
amInternalStatus: true,
|
||
amDisplayStatus: true,
|
||
activityType: {
|
||
select: {
|
||
id: true,
|
||
activityTypeName: true
|
||
}
|
||
},
|
||
host: {
|
||
select: {
|
||
id: true,
|
||
companyName: true,
|
||
logoPath: true,
|
||
user: {
|
||
select: {
|
||
userRefNumber: 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
|
||
if (!grouped[cat.id]) {
|
||
grouped[cat.id] = {
|
||
id: cat.id,
|
||
categoryName: cat.categoryName,
|
||
displayOrder: cat.displayOrder,
|
||
hostId: item.activity.host.id,
|
||
hostCompanyName: item.activity.host.companyName,
|
||
activityTypeName: item.activity.activityType.activityTypeName,
|
||
hostLogoPath: item.activity.host.logoPath,
|
||
activityRefNumber: item.activity.activityRefNumber,
|
||
activityDisplayStatus: item.activity.activityDisplayStatus,
|
||
activityInternalStatus: item.activity.activityInternalStatus,
|
||
amInternalStatus: item.activity.amInternalStatus,
|
||
amDisplayStatus: item.activity.amDisplayStatus,
|
||
userRefNumber: item.activity.host.user.userRefNumber,
|
||
pqqsubCategories: [],
|
||
};
|
||
} else if (!grouped[cat.id].activityPqqHeaderId) {
|
||
// Ensure header id is present at category level
|
||
grouped[cat.id].activityPqqHeaderId = item.id;
|
||
}
|
||
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,
|
||
activityPqqHeaderId: item.id,
|
||
questionName: q.questionName,
|
||
maxPoints: q.maxPoints,
|
||
pqqAnswerXid: item.pqqAnswerXid,
|
||
comments: item.comments || null,
|
||
displayOrder: q.displayOrder,
|
||
allAnswerOptions: q.PQQAnswers || [],
|
||
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;
|
||
}
|
||
}
|