import { Injectable } from '@nestjs/common'; import { PrismaClient, User, UserAddressDetails } from '@prisma/client'; import ApiError from '../../../common/utils/helper/ApiError'; import * as bcrypt from 'bcryptjs'; import { UserPersonalInfoSchema } from '../../../common/utils/validation/user/addPersonalInfo.validation'; import { ACTIVITY_AM_INTERNAL_STATUS, ACTIVITY_INTERNAL_STATUS } from '../../../common/utils/constants/host.constant'; import { getPresignedUrl } from '../../../common/middlewares/aws/getPreSignedUrl'; import config from '@/config/config'; import { isNotIn } from 'class-validator'; // function deg2rad(deg) { // return deg * (Math.PI / 180); // } // function getDistanceFromLatLon(userLat1, userLon1, activityLat2, activityLon2) { // const R = 6371; // Earth radius in km // const dLat = deg2rad(activityLat2 - userLat1); // const dLon = deg2rad(activityLon2 - userLon1); // const a = // Math.sin(dLat / 2) * Math.sin(dLat / 2) + // Math.cos(deg2rad(userLat1)) * // Math.cos(deg2rad(activityLat2)) * // Math.sin(dLon / 2) * // Math.sin(dLon / 2); // const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); // return R * c; // } const attachMediaWithPresignedUrl = async (mediaArr = []) => { return ( await Promise.all( mediaArr.map(async (m) => { if (!m?.mediaFileName) return null; const key = m.mediaFileName.startsWith('http') ? new URL(m.mediaFileName).pathname.replace(/^\/+/, '') : m.mediaFileName; return { id: m.id, mediaType: m.mediaType, mediaFileName: m.mediaFileName, presignedUrl: await getPresignedUrl(bucket, key), }; }) ) ).filter(Boolean); }; const bucket = config.aws.bucketName; @Injectable() export class UserService { constructor(private prisma: PrismaClient) { } async getUserById(userId: number) { return this.prisma.user.findUnique({ where: { id: userId, isActive: true }, }); } async addPersonalInfo(userId: number, data: UserPersonalInfoSchema) { return await this.prisma.$transaction(async (tx) => { const updatedUser = await tx.user.update({ where: { id: userId }, data: { firstName: data.firstName, lastName: data.lastName ?? null, genderName: data.genderName, dateOfBirth: data.dateOfBirth ? new Date(data.dateOfBirth) : null, isProfileUpdated: true, }, }); return updatedUser; }); } async getAllInterestDetails() { const interests = await this.prisma.interests.findMany({ where: { isActive: true }, select: { id: true, interestName: true, interestColor: true, interestImage: true, displayOrder: true } }) for (const interest of interests) { if (interest.interestImage) { const key = interest.interestImage.startsWith('http') ? new URL(interest.interestImage).pathname.replace(/^\/+/, '') : interest.interestImage; (interest as any).presignedUrl = await getPresignedUrl(bucket, key); } else { (interest as any).presignedUrl = null; } } return interests; } async getUserByMobileNumber(mobileNumber: string): Promise { return this.prisma.user.findFirst({ where: { mobileNumber: mobileNumber, isActive: true }, }); } async verifyHostOtp(mobileNumber: string, otp: string): Promise { const user = await this.prisma.user.findFirst({ where: { mobileNumber: mobileNumber, isActive: true }, select: { id: true, mobileNumber: 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 setUserPasscode(userId: number, userPasscode: string): Promise { // Validate passcode format (6 digits) if (!userPasscode || userPasscode.length !== 6 || !/^\d{6}$/.test(userPasscode)) { throw new ApiError(400, 'Passcode must be exactly 6 digits'); } // Hash the passcode const hashedPasscode = await bcrypt.hash(userPasscode, 10); // Update user with passcode const updatedUser = await this.prisma.user.update({ where: { id: userId }, data: { userPasscode: hashedPasscode, }, }); if (!updatedUser) { throw new ApiError(400, 'Failed to set passcode'); } return updatedUser; } async setUserInterests(userId: number, interest_Xid: number[]): Promise { // Remove existing interests await this.prisma.userInterests.deleteMany({ where: { userXid: userId }, }); // Add new interests const interestRecords = interest_Xid.map((interestId) => ({ userXid: userId, interestXid: interestId, })); await this.prisma.userInterests.createMany({ data: interestRecords, }); } async setUserLocationDetails( userId: number, countryName: string, stateName: string, cityName: string, pinCode: string, latitude?: number, longitude?: number, locationName?: string, locationAddress?: string ): Promise { return this.prisma.$transaction(async (tx) => { // 1️⃣ Country: find or create let country = await tx.countries.findUnique({ where: { countryName }, select: { id: true }, }); if (!country) { country = await tx.countries.create({ data: { countryName, countryCode: countryName.slice(0, 3).toUpperCase(), countryFlag: '', }, select: { id: true }, }); } // 2️⃣ State: find or create (GLOBAL UNIQUE) let state = await tx.states.findUnique({ where: { stateName }, select: { id: true }, }); if (!state) { state = await tx.states.create({ data: { stateName, countryXid: country.id, }, select: { id: true }, }); } // 3️⃣ City: find or create (GLOBAL UNIQUE) let city = await tx.cities.findUnique({ where: { cityName }, select: { id: true }, }); if (!city) { city = await tx.cities.create({ data: { cityName, stateXid: state.id, }, select: { id: true }, }); } return tx.userAddressDetails.create({ data: { user: { connect: { id: userId } }, country: { connect: { id: country.id } }, states: { connect: { id: state.id } }, cities: { connect: { id: city.id } }, address1: locationAddress ?? '', pinCode, locationName: locationName ?? null, locationAddress: locationAddress ?? null, locationLat: latitude ?? null, locationLong: longitude ?? null, }, }); }); } async getLandingPageAllDetails(userId: number) { const data = await this.prisma.$transaction(async (tx) => { const userAddressDetails = await tx.userAddressDetails.findFirst({ where: { userXid: userId }, select: { id: true, address1: true, address2: true, pinCode: true, locationName: true, stateXid: true, cityXid: true, countryXid: true, locationLat: true, locationLong: true, } }) const userStateXid = userAddressDetails?.stateXid ?? null; const userCountryXid = userAddressDetails?.countryXid ?? null; const userInterests = await tx.userInterests.findMany({ where: { userXid: userId, isActive: true }, select: { id: true, interestXid: true, interest: { select: { id: true, interestName: true, interestColor: true, interestImage: true, displayOrder: true } }, } }) if (!userInterests.length) { return { userAddress: null, activities: [] }; } const activitiyTypesOfUserInterests = await tx.activityTypes.findMany({ where: { interestXid: { in: userInterests.map(ui => ui.interestXid) }, isActive: true }, select: { id: true } }) if (!activitiyTypesOfUserInterests.length) { return { userAddressDetails, activities: [], }; } const activities = await tx.activities.findMany({ where: { isActive: true, activityInternalStatus: ACTIVITY_INTERNAL_STATUS.ACTIVITY_LISTED, amInternalStatus: ACTIVITY_AM_INTERNAL_STATUS.ACTIVITY_LISTED, activityTypeXid: { in: activitiyTypesOfUserInterests.map(at => at.id), }, }, select: { id: true, activityTitle: true, activityDurationMins: true, sustainabilityScore: true, checkInLat: true, checkInLong: true, activityType: { select: { interestXid: true, // ✅ VERY IMPORTANT energyLevel: { select: { id: true, energyLevelName: true, energyColor: true, energyIcon: true, }, }, }, }, ActivityVenues: { select: { ActivityPrices: { select: { sellPrice: true, }, }, }, }, ActivitiesMedia: { where: { isActive: true }, select: { id: true, mediaFileName: true, mediaType: true, }, }, }, }); const otherStatesActivities = await tx.activities.findMany({ where: { isActive: true, activityInternalStatus: ACTIVITY_INTERNAL_STATUS.ACTIVITY_LISTED, amInternalStatus: ACTIVITY_AM_INTERNAL_STATUS.ACTIVITY_LISTED, // ✅ Only user's interest types activityTypeXid: { in: activitiyTypesOfUserInterests.map(at => at.id), }, // ✅ Exclude user's state ...(userStateXid && { checkInStateXid: { not: userStateXid }, }), ...(userCountryXid && { checkInCountryXid: userCountryXid, }), }, select: { id: true, activityTitle: true, activityType: { select: { energyLevel: { select: { id: true, energyLevelName: true, energyColor: true, energyIcon: true, }, }, }, }, ActivitiesMedia: { where: { isActive: true }, select: { id: true, mediaFileName: true, mediaType: true, }, }, }, }); const overSeasActivity = await tx.activities.findMany({ where: { isActive: true, activityInternalStatus: ACTIVITY_INTERNAL_STATUS.ACTIVITY_LISTED, amInternalStatus: ACTIVITY_AM_INTERNAL_STATUS.ACTIVITY_LISTED, // ✅ Only user's interest types activityTypeXid: { in: activitiyTypesOfUserInterests.map(at => at.id), }, // ✅ Exclude user's state ...(userCountryXid && { checkInCountryXid: { not: userCountryXid }, }), }, select: { id: true, activityTitle: true, activityType: { select: { energyLevel: { select: { id: true, energyLevelName: true, energyColor: true, energyIcon: true, }, }, }, }, ActivitiesMedia: { where: { isActive: true }, select: { id: true, mediaFileName: true, mediaType: true, }, }, }, }) const formattedActivities = await Promise.all( activities.map(async (activity) => { let cheapestPrice: number | null = null; for (const venue of activity.ActivityVenues) { for (const price of venue.ActivityPrices) { if ( typeof price.sellPrice === 'number' && (cheapestPrice === null || price.sellPrice < cheapestPrice) ) { cheapestPrice = price.sellPrice; } } } return { interestXid: activity.activityType.interestXid, activityTitle: activity.activityTitle, activityDurationMins: activity.activityDurationMins, sustainabilityScore: activity.sustainabilityScore, cheapestPrice, rating: 4, distanceFromUser: 2, connectionsCount: 10, energyLevel: activity.activityType?.energyLevel ?? null, media: await attachMediaWithPresignedUrl(activity.ActivitiesMedia), }; }) ); const formattedOtherStatesActivities = await Promise.all( otherStatesActivities.map(async (activity) => ({ activityTitle: activity.activityTitle, energyLevel: activity.activityType.energyLevel, media: await attachMediaWithPresignedUrl(activity.ActivitiesMedia), })) ); const formattedOverSeasActivities = await Promise.all( overSeasActivity.map(async (activity) => ({ activityTitle: activity.activityTitle, energyLevel: activity.activityType.energyLevel, media: await attachMediaWithPresignedUrl(activity.ActivitiesMedia), })) ); const interestsWithActivities = userInterests.map(ui => { const activitiesForInterest = formattedActivities.filter( act => act.interestXid === ui.interestXid ); return { interestId: ui.interest.id, interestName: ui.interest.interestName, interestColor: ui.interest.interestColor, interestImage: ui.interest.interestImage, displayOrder: ui.interest.displayOrder, activities: activitiesForInterest.map(({ interestXid, ...rest }) => rest), }; }); return { userAddressDetails, experiencesLogged: 25, citiesDiscovered: 10, loggedInNetworkCount: 0, citiesInNetworkCount: 0, interests: interestsWithActivities, otherStatesActivities: formattedOtherStatesActivities, overSeasActivities: formattedOverSeasActivities }; }) return data; } }