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

779 lines
23 KiB
TypeScript

import { ROLE, USER_STATUS } from '@/common/utils/constants/common.constant';
import { HOST_STATUS_DISPLAY, HOST_STATUS_INTERNAL } from '@/common/utils/constants/host.constant';
import { MINGLAR_INVITATION_STATUS, MINGLAR_STATUS_DISPLAY, MINGLAR_STATUS_INTERNAL } from '@/common/utils/constants/minglar.constant';
import { Injectable } from '@nestjs/common';
import { 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';
@Injectable()
export class MinglarService {
constructor(private prisma: PrismaService) { }
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 createHost(data: CreateMinglarDto) {
return this.prisma.user.create({ data });
}
async getAllHosts() {
return this.prisma.user.findMany({ where: { roleXid: 3 } });
}
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 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 }
});
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');
}
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 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.');
}
// Create user with INVITED status
const user = await tx.user.create({
data: {
emailAddress: emailAddress,
roleXid: roleXid,
userStatus: USER_STATUS.INVITED,
},
});
// 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 }>
) {
return await this.prisma.$transaction(async (tx) => {
// 1. Update User table
const userUpdateData: any = {};
if (userData.firstName !== undefined) userUpdateData.firstName = userData.firstName;
if (userData.lastName !== undefined) userUpdateData.lastName = userData.lastName;
if (userData.mobileNumber !== undefined) userUpdateData.mobileNumber = userData.mobileNumber;
if (userData.dateOfBirth !== undefined) userUpdateData.dateOfBirth = new Date(userData.dateOfBirth);
if (userData.profileImage !== undefined) userUpdateData.profileImage = userData.profileImage;
if (Object.keys(userUpdateData).length > 0) {
await tx.user.update({
where: { id: userId },
data: userUpdateData,
});
}
// 2. Update or create UserAddressDetails
if (Object.keys(addressData).length > 0) {
const existingAddress = await tx.userAddressDetails.findFirst({
where: { userXid: userId, isActive: true },
});
const addressUpdateData: any = {};
if (addressData.address1 !== undefined) addressUpdateData.address1 = addressData.address1;
if (addressData.address2 !== undefined) addressUpdateData.address2 = addressData.address2;
if (addressData.stateXid !== undefined) addressUpdateData.stateXid = addressData.stateXid;
if (addressData.countryXid !== undefined) addressUpdateData.countryXid = addressData.countryXid;
if (addressData.cityXid !== undefined) addressUpdateData.cityXid = addressData.cityXid;
if (addressData.pinCode !== undefined) addressUpdateData.pinCode = addressData.pinCode;
if (existingAddress) {
await tx.userAddressDetails.update({
where: { id: existingAddress.id },
data: addressUpdateData,
});
} else {
if (!addressData.address1 || !addressData.stateXid || !addressData.countryXid || !addressData.cityXid || !addressData.pinCode) {
throw new ApiError(400, 'All address fields are required for new address');
}
await tx.userAddressDetails.create({
data: {
userXid: userId,
...addressUpdateData,
},
});
}
}
// 3. Update or create UserDocuments (store S3 URL in fileName field)
if (documents && documents.length > 0) {
const existingDocs = await tx.userDocuments.findMany({
where: { userXid: userId, isActive: true },
orderBy: { createdAt: 'asc' },
});
// Update existing documents or create new ones
for (let i = 0; i < documents.length; i++) {
const doc = documents[i];
if (existingDocs[i]) {
// Update existing document
await tx.userDocuments.update({
where: { id: existingDocs[i].id },
data: { fileName: doc.filePath }, // Store S3 URL in fileName
});
} else {
// Create new document
await tx.userDocuments.create({
data: {
userXid: userId,
fileName: doc.filePath, // Store S3 URL in fileName
},
});
}
}
}
// 4. Fetch updated user data to calculate percentage
const updatedUser = await tx.user.findUnique({
where: { id: userId },
include: {
userAddressDetails: {
where: { isActive: true },
take: 1,
},
userDocuments: {
where: { isActive: true },
},
},
});
if (!updatedUser) {
throw new ApiError(404, 'User not found');
}
// 5. Calculate profile completion percentage
let percentage = 0;
// Profile Image: 15%
if (updatedUser.profileImage) {
percentage += 15;
}
// Name and Phone Number: 15%
if (updatedUser.firstName && updatedUser.lastName && updatedUser.mobileNumber) {
percentage += 15;
}
// Location Info: 25%
if (updatedUser.userAddressDetails && updatedUser.userAddressDetails.length > 0) {
const address = updatedUser.userAddressDetails[0];
if (address.address1 && address.stateXid && address.countryXid && address.cityXid && address.pinCode) {
percentage += 25;
}
}
// Documents (Aadhar and PAN): 45%
if (updatedUser.userDocuments && updatedUser.userDocuments.length >= 2) {
percentage += 45;
} else if (updatedUser.userDocuments && updatedUser.userDocuments.length === 1) {
percentage += 22.5; // Half if only one document
}
const profilePercentage = Math.min(percentage, 100)
if (profilePercentage > 80) {
await this.prisma.user.update({
where: {
id: userId
},
data: {
isProfileUpdated: true
}
})
}
return {
user: {
id: updatedUser.id,
firstName: updatedUser.firstName,
lastName: updatedUser.lastName,
mobileNumber: updatedUser.mobileNumber,
dateOfBirth: updatedUser.dateOfBirth,
profileImage: updatedUser.profileImage,
},
address: updatedUser.userAddressDetails[0] || null,
documents: updatedUser.userDocuments,
profileCompletionPercentage: Math.min(percentage, 100),
};
});
}
async getAllInvitationDetails() {
return await this.prisma.inviteDetails.findMany({
where: {
isMinglarInvitation: true,
isActive: true,
},
include: {
user: {
select: {
id: true,
firstName: true,
lastName: true,
emailAddress: true,
mobileNumber: true,
roleXid: true,
role: {
select: {
id: true,
roleName: true,
}
}
}
}
}
})
}
async getAllHostApplications(userId: number, userRoleXid: number, search?: string) {
// Build where clause based on user role
const whereClause: any = {
isActive: true,
user: {
roleXid: {
notIn: [ROLE.CO_ADMIN, ROLE.ACCOUNT_MANAGER]
},
}
};
// Add search filter if search query is provided
if (search && search.trim() !== '') {
const searchTerm = search.trim();
// Check if search term is a number (for ID search)
const isNumeric = /^\d+$/.test(searchTerm);
if (isNumeric) {
// Search by host ID
whereClause.id = parseInt(searchTerm);
} else {
// Search by email or name
whereClause.user = {
...whereClause.user,
OR: [
{ emailAddress: { contains: searchTerm, mode: 'insensitive' } },
{ firstName: { contains: searchTerm, mode: 'insensitive' } },
{ lastName: { contains: searchTerm, mode: 'insensitive' } }
]
};
}
}
// If user is Co_Admin or Account_Manager, filter by assigned hosts only
if (userRoleXid === ROLE.CO_ADMIN || userRoleXid === ROLE.ACCOUNT_MANAGER) {
whereClause.accountManagerXid = userId;
}
// If user is Minglar Admin, show all hosts (no additional filter needed)
const hostHeaders = await this.prisma.hostHeader.findMany({
where: whereClause,
select: {
id: true,
hostStatusDisplay: true,
createdAt: true,
companyName: true,
user: {
select: {
id: true,
firstName: true,
lastName: true,
emailAddress: true,
mobileNumber: true,
}
},
accountManager: {
select: {
id: true,
firstName: true,
lastName: true,
emailAddress: true,
mobileNumber: true,
roleXid: true,
}
}
},
orderBy: {
createdAt: 'desc'
}
});
// Transform the data to return host, hostStatusDisplay, submittedOn, and accountManager details
return hostHeaders.map(host => ({
hostId: host.id,
host: host.user,
hostStatusDisplay: host.hostStatusDisplay,
submittedOn: host.createdAt,
accountManager: host.accountManager || null
}));
}
async getAllCoadminAndAM() {
// 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,
},
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;
});
// 4. Attach host counts to each user
return users.map((user) => ({
...user,
assignedHostCount: hostCountMap[user.id] ?? 0,
}));
}
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 &&
hostDetails.adminStatusDisplay !== MINGLAR_STATUS_DISPLAY.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.TO_REVIEW,
}
});
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) {
// 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: false,
isreviewed: false,
reviewedByXid: reviewedByXid,
reviewOn: null,
isActive: true
}
});
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 editAgreementDetails(
host_xid: number,
agreementStartDate: string,
duration: number,
isCommisionBase: boolean,
commisionPer: number,
amountPerBooking: number,
durationFrequency: string,
payoutDurationNum: number,
payoutDurationFrequency: string
) {
return await this.prisma.hostHeader.update({
where: { id: host_xid },
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
}
})
}
async acceptHostApplication(host_xid: number, user_xid: number) {
return await this.prisma.hostHeader.update({
where: {
id: host_xid,
hostStatusInternal: HOST_STATUS_INTERNAL.HOST_SUBMITTED,
hostStatusDisplay: HOST_STATUS_DISPLAY.UNDER_REVIEW,
adminStatusInternal: MINGLAR_STATUS_INTERNAL.AM_TO_REVIEW,
adminStatusDisplay: MINGLAR_STATUS_DISPLAY.TO_REVIEW
},
data: {
isApproved: true,
hostStatusInternal: HOST_STATUS_INTERNAL.APPROVED,
hostStatusDisplay: HOST_STATUS_DISPLAY.APPROVED,
adminStatusInternal: MINGLAR_STATUS_INTERNAL.AM_APPROVED,
adminStatusDisplay: MINGLAR_STATUS_DISPLAY.APPROVED
}
})
}
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 tx.user.update({
where: { id: hostDetails.userXid },
data: {
userStatus: USER_STATUS.REJECTED
}
})
})
}
}