// src/modules/host/services/host.service.ts import { Injectable } from '@nestjs/common'; import { PrismaService } from '../../../common/database/prisma.service'; import { AddPaymentDetailsDTO, CreateHostDto, UpdateHostDto } from '../dto/host.dto'; import * as bcrypt from 'bcryptjs'; import ApiError from '../../../common/utils/helper/ApiError'; import { User } from '@prisma/client'; import { z } from 'zod'; import { hostCompanyDetailsSchema } from '@/common/utils/validation/host/hostCompanyDetails.validation'; import { HOST_STATUS_DISPLAY, HOST_STATUS_INTERNAL, STEPPER } from '@/common/utils/constants/host.constant'; import { MINGLAR_STATUS_DISPLAY, MINGLAR_STATUS_INTERNAL } from '@/common/utils/constants/minglar.constant'; import { ROLE, USER_STATUS } from '@/common/utils/constants/common.constant'; import { getPresignedUrl } from '@/common/middlewares/aws/getPreSignedUrl'; import config from '@/config/config'; type HostCompanyDetailsInput = z.infer; // Document input after S3 upload (with S3 URL as filePath) interface HostDocumentInput { documentTypeXid: number; documentName: string; filePath: string; // S3 URL } @Injectable() export class HostService { constructor(private prisma: PrismaService) { } async createHost(data: CreateHostDto) { return this.prisma.user.create({ data }); } async getAllHosts() { return this.prisma.user.findMany({ where: { roleXid: 3 } }); } async getHostIdByUserXid(user_xid: number) { const host = await this.prisma.hostHeader.findFirst({ where: { userXid: user_xid }, select: { id: true, companyName: true, countryXid: true, stepper: true }, }); return host; } async getHostById(id: number) { const host = await this.prisma.hostHeader.findFirst({ where: { userXid: id }, include: { hostParent: true, HostBankDetails: true, HostDocuments: true, HostSuggestion: true, HostTrack: true, } }); if (!host) { // If host record doesn't exist yet, return stepper 1 (NOT_SUBMITTED) // so callers (like the stepper endpoint) can show initial step. return { stepper: STEPPER.NOT_SUBMITTED } as any; } if (host.HostDocuments?.length) { const bucket = config.aws.bucketName; 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); } } } return host; } async updateHost(id: number, data: UpdateHostDto) { return this.prisma.user.update({ where: { id }, data, }); } async deleteHost(id: number) { return this.prisma.user.delete({ where: { id } }); } async getHostByEmail(email: string): Promise { return this.prisma.user.findUnique({ where: { emailAddress: email } }); } async verifyHostOtp(email: string, otp: string): Promise { const user = await this.prisma.user.findUnique({ where: { emailAddress: email }, select: { id: true, emailAddress: true, UserOtp: { where: { isActive: true, isVerified: false }, orderBy: { createdAt: 'desc' }, take: 1, }, }, }); if (!user) { throw new ApiError(404, 'User not found.'); } const userOtp = user.UserOtp[0]; if (!userOtp) { throw new ApiError(400, 'No OTP found.'); } if (new Date() > userOtp.expiresOn) { throw new ApiError(400, 'OTP has expired.'); } const isMatch = await bcrypt.compare(otp, userOtp.otpCode); if (!isMatch) { throw new ApiError(400, 'Invalid OTP.'); } await this.prisma.userOtp.update({ where: { id: userOtp.id }, data: { isVerified: true, verifiedOn: new Date(), isActive: false, }, }); return true; } async loginForHost(emailAddress: string, userPassword: string) { const existingUser = await this.prisma.user.findUnique({ where: { emailAddress: emailAddress }, }); if (!existingUser) { throw new ApiError(404, 'User not found'); } if (existingUser.roleXid !== 4) { throw new ApiError(403, 'Access denied. Not a host user.'); } const matchPassword = await bcrypt.compare(userPassword, existingUser.userPassword); if (!matchPassword) { throw new ApiError(401, 'Invalid credentials'); } return existingUser; } async createMinglarUser(email: string) { const newUser = await this.prisma.user.create({ data: { emailAddress: email, roleXid: ROLE.HOST }, }); return newUser; } async createPassword(user_xid: number, password: string): Promise { // Find user by id const user = await this.prisma.user.findUnique({ where: { id: user_xid }, select: { id: true, emailAddress: true, userPassword: true }, }); if (!user) { throw new ApiError(404, 'User not found'); } // Check if password already exists if (user.userPassword) { throw new ApiError(400, 'Password already exists. Use update password instead.'); } // Hash the password const saltRounds = parseInt(process.env.SALT_ROUNDS || '10', 10); const hashedPassword = await bcrypt.hash(password, saltRounds); // Update user with hashed password await this.prisma.user.update({ where: { id: user.id }, data: { userPassword: hashedPassword, isEmailVerfied: true, userStatus: USER_STATUS.ACTIVE }, }); return true; } async addPaymentDetails(data: AddPaymentDetailsDTO) { return await this.prisma.$transaction(async (tx) => { const addedPaymentDetails = await tx.hostBankDetails.create({ data, }); if (!addedPaymentDetails) { throw new ApiError(400, 'Failed to add payment details'); } await tx.hostHeader.update({ where: { id: data.hostXid }, data: { stepper: STEPPER.BANK_DETAILS_UPDATED } }) }) } async acceptMinglarAgreement(user_xid: number) { const hostDetails = await this.prisma.hostHeader.findFirst({ where: { userXid: user_xid }, select: { id: true, userXid: true, } }) await this.prisma.hostHeader.update({ where: { id: hostDetails.id }, data: { stepper: STEPPER.AGREEMENT_ACCEPTED, agreementAccepted: true, isApproved: true } }) } async getPQQQuestionDetail(question_xid: number, activity_xid: number) { return await this.prisma.activityPQQheader.findFirst({ where: { activityXid: activity_xid, pqqQuestionXid: question_xid, isActive: true }, select: { pqqQuestionXid: true, pqqAnswerXid: true, ActivityPQQSupportings: true } }) } async getLatestQuestionDetailsPQQ(activity_xid: number) { return await this.prisma.activityPQQheader.findFirst({ where: { activityXid: activity_xid, isActive: true }, select: { pqqQuestionXid: true, pqqAnswerXid: true, pqqQuestions: { select: { pqqSubCategoryXid: true, pqqSubCategories: { select: { categoryXid: true } } } } }, orderBy: { id: 'desc' } }) } async addOrUpdateCompanyDetails( user_xid: number, companyData: HostCompanyDetailsInput, documents: HostDocumentInput[], parentCompanyData?: any | null, parentDocuments?: HostDocumentInput[] ) { return await this.prisma.$transaction(async (tx) => { // Check if host already has a company const existingHostCompany = await tx.hostHeader.findFirst({ where: { userXid: user_xid }, include: { hostParent: true }, }); // CREATE if (!existingHostCompany) { // Optionally check unique registration number const existingByPan = await tx.hostHeader.findFirst({ where: { panNumber: companyData.panNumber }, }); if (existingByPan) throw new ApiError(400, 'Company already exists with this pan/bin number'); const refNumber = await this.generateHostRefNumber(tx); const createdHost = await tx.hostHeader.create({ data: { userXid: user_xid, companyName: companyData.companyName, hostRefNumber: refNumber, address1: companyData.address1, address2: companyData.address2, cityXid: companyData.cityXid, stateXid: companyData.stateXid, countryXid: companyData.countryXid, pinCode: companyData.pinCode, logoPath: companyData.logoPath || null, isSubsidairy: companyData.isSubsidairy, registrationNumber: companyData.registrationNumber, panNumber: companyData.panNumber, gstNumber: companyData.gstNumber || null, formationDate: new Date(companyData.formationDate), companyType: companyData.companyType, websiteUrl: companyData.websiteUrl || null, instagramUrl: companyData.instagramUrl || null, facebookUrl: companyData.facebookUrl || null, linkedinUrl: companyData.linkedinUrl || null, twitterUrl: companyData.twitterUrl || null, currencyXid: companyData.currencyXid, stepper: STEPPER.UNDER_REVIEW, hostStatusInternal: HOST_STATUS_INTERNAL.HOST_SUBMITTED, hostStatusDisplay: HOST_STATUS_DISPLAY.UNDER_REVIEW, adminStatusInternal: MINGLAR_STATUS_INTERNAL.ADMIN_TO_REVIEW, adminStatusDisplay: MINGLAR_STATUS_DISPLAY.NEW, }, }); // Create host documents if (documents?.length) { const docsData = documents.map((doc) => ({ hostXid: createdHost.id, documentTypeXid: doc.documentTypeXid, documentName: doc.documentName, filePath: doc.filePath, })); await tx.hostDocuments.createMany({ data: docsData }); } // Parent company and its docs (if present) if (companyData.isSubsidairy && parentCompanyData) { const createdParent = await tx.hostParent.create({ data: { hostXid: createdHost.id, companyName: parentCompanyData.companyName, address1: parentCompanyData.address1, address2: parentCompanyData.address2 || null, cityXid: parentCompanyData.cityXid, stateXid: parentCompanyData.stateXid, countryXid: parentCompanyData.countryXid, pinCode: parentCompanyData.pinCode, logoPath: parentCompanyData.logoPath || null, isSubsidairy: false, registrationNumber: parentCompanyData.registrationNumber, panNumber: parentCompanyData.panNumber, gstNumber: parentCompanyData.gstNumber || null, formationDate: new Date(parentCompanyData.formationDate), companyType: parentCompanyData.companyType, websiteUrl: parentCompanyData.websiteUrl || null, instagramUrl: parentCompanyData.instagramUrl || null, facebookUrl: parentCompanyData.facebookUrl || null, linkedinUrl: parentCompanyData.linkedinUrl || null, twitterUrl: parentCompanyData.twitterUrl || null, }, }); if (parentDocuments?.length) { const parentDocsData = parentDocuments.map((doc) => ({ hostParentXid: createdParent.id, documentTypeXid: doc.documentTypeXid, documentName: doc.documentName, filePath: doc.filePath, })); await tx.hostParenetDocuments.createMany({ data: parentDocsData }); } } return createdHost; } // UPDATE existing // Prevent changing hostRefNumber const updatedHost = await tx.hostHeader.update({ where: { id: existingHostCompany.id }, data: { companyName: companyData.companyName, address1: companyData.address1, address2: companyData.address2, cityXid: companyData.cityXid, stateXid: companyData.stateXid, countryXid: companyData.countryXid, pinCode: companyData.pinCode, logoPath: companyData.logoPath || null, isSubsidairy: companyData.isSubsidairy, registrationNumber: companyData.registrationNumber, panNumber: companyData.panNumber, gstNumber: companyData.gstNumber || null, formationDate: new Date(companyData.formationDate), companyType: companyData.companyType, websiteUrl: companyData.websiteUrl || null, instagramUrl: companyData.instagramUrl || null, facebookUrl: companyData.facebookUrl || null, linkedinUrl: companyData.linkedinUrl || null, twitterUrl: companyData.twitterUrl || null, currencyXid: companyData.currencyXid, stepper: STEPPER.UNDER_REVIEW // hostRefNumber: DO NOT UPDATE }, }); // Replace host documents (delete old, insert new) await tx.hostDocuments.deleteMany({ where: { hostXid: updatedHost.id } }); if (documents?.length) { const docsData = documents.map((doc) => ({ hostXid: updatedHost.id, documentTypeXid: doc.documentTypeXid, documentName: doc.documentName, filePath: doc.filePath, })); await tx.hostDocuments.createMany({ data: docsData }); } // Parent company create/update and replace parent docs if (companyData.isSubsidairy) { // existingHostCompany.hostParent may be array or single object depending on Prisma schema let parentRecord = (existingHostCompany as any).hostParent; if (Array.isArray(parentRecord)) parentRecord = parentRecord[0]; if (!parentRecord) { // create const createdParent = await tx.hostParent.create({ data: { hostXid: updatedHost.id, companyName: parentCompanyData.companyName, address1: parentCompanyData.address1, address2: parentCompanyData.address2 || null, cityXid: parentCompanyData.cityXid, stateXid: parentCompanyData.stateXid, countryXid: parentCompanyData.countryXid, pinCode: parentCompanyData.pinCode, logoPath: parentCompanyData.logoPath || null, isSubsidairy: false, registrationNumber: parentCompanyData.registrationNumber, panNumber: parentCompanyData.panNumber, gstNumber: parentCompanyData.gstNumber || null, formationDate: new Date(parentCompanyData.formationDate), companyType: parentCompanyData.companyType, websiteUrl: parentCompanyData.websiteUrl || null, instagramUrl: parentCompanyData.instagramUrl || null, facebookUrl: parentCompanyData.facebookUrl || null, linkedinUrl: parentCompanyData.linkedinUrl || null, twitterUrl: parentCompanyData.twitterUrl || null, }, }); if (parentDocuments?.length) { const parentDocsData = parentDocuments.map((doc) => ({ hostParentXid: createdParent.id, documentTypeXid: doc.documentTypeXid, documentName: doc.documentName, filePath: doc.filePath, })); await tx.hostParenetDocuments.createMany({ data: parentDocsData }); } } else { // update await tx.hostParent.update({ where: { id: parentRecord.id }, data: { companyName: parentCompanyData.companyName, address1: parentCompanyData.address1, address2: parentCompanyData.address2 || null, cityXid: parentCompanyData.cityXid, stateXid: parentCompanyData.stateXid, countryXid: parentCompanyData.countryXid, pinCode: parentCompanyData.pinCode, logoPath: parentCompanyData.logoPath || null, registrationNumber: parentCompanyData.registrationNumber, panNumber: parentCompanyData.panNumber, gstNumber: parentCompanyData.gstNumber || null, formationDate: new Date(parentCompanyData.formationDate), companyType: parentCompanyData.companyType, websiteUrl: parentCompanyData.websiteUrl || null, instagramUrl: parentCompanyData.instagramUrl || null, facebookUrl: parentCompanyData.facebookUrl || null, linkedinUrl: parentCompanyData.linkedinUrl || null, twitterUrl: parentCompanyData.twitterUrl || null, }, }); // replace parent docs await tx.hostParenetDocuments.deleteMany({ where: { hostParentXid: parentRecord.id } }); if (parentDocuments?.length) { const parentDocsData = parentDocuments.map((doc) => ({ hostParentXid: parentRecord.id, documentTypeXid: doc.documentTypeXid, documentName: doc.documentName, filePath: doc.filePath, })); await tx.hostParenetDocuments.createMany({ data: parentDocsData }); } } } else { // If previously had a parent and now isSubsidairy=false -> optionally delete parent and its docs const previousParent = (existingHostCompany as any).hostParent; let prevParentId = null; if (Array.isArray(previousParent) && previousParent.length) prevParentId = previousParent[0].id; else if (previousParent && previousParent.id) prevParentId = previousParent.id; if (prevParentId) { await tx.hostParenetDocuments.deleteMany({ where: { hostParentXid: prevParentId } }); await tx.hostParent.delete({ where: { id: prevParentId } }); } } return updatedHost; }); } async getSuggestionDetails(user_xid: number) { const hostDetails = await this.prisma.hostHeader.findFirst({ where: { userXid: user_xid, isActive: true }, include: { user: { select: { id: true, emailAddress: true, firstName: true, } }, accountManager: { select: { id: true, emailAddress: true, firstName: true, } } } }); if (!hostDetails) { return { hostSuggestionDetails: [], hostDetails: null }; } const hostSuggestionDetails = await this.prisma.hostSuggestion.findMany({ where: { hostXid: hostDetails.id, isActive: true, isreviewed: false } }); if (hostSuggestionDetails) { await this.prisma.hostSuggestion.updateMany({ where: { hostXid: hostDetails.id, isActive: true, isreviewed: false }, data: { isreviewed: true, reviewedByXid: hostDetails.id, reviewOn: new Date(), } }) } return { hostSuggestionDetails, hostDetails }; } async generateHostRefNumber(tx: any) { const lastHost = await tx.hostHeader.findFirst({ orderBy: { id: 'desc', }, select: { id: true, } }); const nextId = lastHost ? lastHost.id + 1 : 1; return `HOSTREFNO-${String(nextId).padStart(6, '0')}`; } // async createOrUpdateHeader( // activityXid: number, // pqqQuestionXid: number, // pqqAnswerXid: number, // comments: string | null // ) { // // find existing header // const existing = await this.prisma.activityPQQheader.findFirst({ // where: { activityXid, pqqQuestionXid, deletedAt: null } // }); // if (!existing) { // return await this.prisma.activityPQQheader.create({ // data: { // activityXid, // pqqQuestionXid, // pqqAnswerXid, // comments // } // }); // } // // mark old supportings deleted // await this.prisma.activityPQQSupportings.updateMany({ // where: { activityPqqHeaderXid: existing.id }, // data: { // isActive: false, // deletedAt: new Date() // } // }); // // update header // return await this.prisma.activityPQQheader.update({ // where: { id: existing.id }, // data: { // pqqAnswerXid, // comments // } // }); // } // async addSupportingFile( // headerId: number, // mimeType: string, // fileUrl: string // ) { // return await this.prisma.activityPQQSupportings.create({ // data: { // activityPqqHeaderXid: headerId, // mediaType: mimeType, // mediaFileName: fileUrl // } // }); // } async createHeader( activityXid: number, pqqQuestionXid: number, pqqAnswerXid: number, comments?: string | null ) { return await this.prisma.activityPQQheader.create({ data: { activityXid, pqqQuestionXid, pqqAnswerXid, comments: comments || null // Handle null comments } }); } async findHeaderByCompositeKey( activityXid: number, pqqQuestionXid: number, pqqAnswerXid: number ) { return await this.prisma.activityPQQheader.findFirst({ where: { activityXid, pqqQuestionXid, pqqAnswerXid } }); } async updateHeader( headerId: number, comments?: string | null ) { return await this.prisma.activityPQQheader.update({ where: { id: headerId }, data: { comments: comments || null, // Handle null comments updatedAt: new Date() } }); } async addSupportingFile( headerId: number, mimeType: string, fileUrl: string ) { return await this.prisma.activityPQQSupportings.create({ data: { activityPqqHeaderXid: headerId, mediaType: mimeType, mediaFileName: fileUrl } }); } async getSupportingFilesByHeaderId(headerId: number) { return await this.prisma.activityPQQSupportings.findMany({ where: { activityPqqHeaderXid: headerId }, orderBy: { id: 'asc' // Maintain consistent order } }); } async updateSupportingFile( supportingFileId: number, mimeType: string, fileUrl: string ) { return await this.prisma.activityPQQSupportings.update({ where: { id: supportingFileId }, data: { mediaType: mimeType, mediaFileName: fileUrl, updatedAt: new Date() } }); } async deleteSupportingFile(supportingFileId: number) { return await this.prisma.activityPQQSupportings.delete({ where: { id: supportingFileId } }); } async getAllActivityTypesWithInterest(search?: string) { const where: any = { isActive: true, deletedAt: null, }; if (search && search.trim() !== '') { const q = search.trim(); where.OR = [ { activityTypeName: { contains: q, mode: 'insensitive' } }, { interests: { interestName: { contains: q, mode: 'insensitive' } } }, ]; } return await this.prisma.activityTypes.findMany({ where, select: { id: true, activityTypeName: true, interestXid: true, interests: { select: { id: true, interestName: true, displayOrder: true, }, }, }, orderBy: { activityTypeName: 'asc' }, }); } async createActivity( userId: number, activityTypeXid: number, frequenciesXid?: number, ) { // Find host header for this user const host = await this.prisma.hostHeader.findFirst({ where: { userXid: userId, isActive: true } }); if (!host) { throw new ApiError(404, 'Host not found for the user'); } // Validate activityType exists const activityType = await this.prisma.activityTypes.findUnique({ where: { id: activityTypeXid } }); if (!activityType) { throw new ApiError(404, 'Activity type not found'); } // Optionally validate frequency if (frequenciesXid) { const freq = await this.prisma.frequencies.findUnique({ where: { id: frequenciesXid } }); if (!freq) throw new ApiError(404, 'Frequency not found'); } const created = await this.prisma.activities.create({ data: { hostXid: host.id, activityTypeXid: activityTypeXid, frequenciesXid: frequenciesXid || null, isActive: true, }, }); return created; } }