Files
MinglarBackendNestJS/src/modules/minglaradmin/services/minglar.service.ts

2015 lines
53 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.

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 { User } from '@prisma/client';
import * as bcrypt from 'bcryptjs';
import { PrismaService } from '../../../common/database/prisma.service';
import { PrismaClient } from '@prisma/client';
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 getUserDetails(id: number) {
return await this.prisma.user.findUnique({
where: { id: id },
});
}
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 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: {
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');
return paginationService.createPaginatedResponse(
hostActivities,
totalCount,
paginationOptions || { page: 1, limit: 10, skip: 0 }
);
}
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' } },
],
};
}
}
/** 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,
},
To_Review: {
internal: MINGLAR_STATUS_INTERNAL.AM_TO_REVIEW,
display: MINGLAR_STATUS_DISPLAY.TO_REVIEW,
},
Enhancing: {
internal: MINGLAR_STATUS_INTERNAL.AM_REJECTED,
display: MINGLAR_STATUS_DISPLAY.ENHANCING,
},
};
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] },
};
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 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 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: {
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,
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 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 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,
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 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,
activityPqqHeaderId: item.id,
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,
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;
}
}