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; // } async function findOrCreateLocation( tx: any, { countryName, stateName, cityName, }: { countryName: string; stateName: string; cityName: string; } ) { /* --------------------------- 1️⃣ COUNTRY ----------------------------*/ let country = await tx.countries.findFirst({ where: { countryName: { equals: countryName, mode: 'insensitive', }, isActive: true, }, }); if (!country) { country = await tx.countries.create({ data: { countryName: countryName.trim(), countryCode: countryName.slice(0, 3).toUpperCase(), // optional countryFlag: '', isActive: true, }, }); } /* --------------------------- 2️⃣ STATE ----------------------------*/ let state = await tx.states.findFirst({ where: { stateName: { equals: stateName, mode: 'insensitive', }, countryXid: country.id, isActive: true, }, }); if (!state) { state = await tx.states.create({ data: { stateName: stateName.trim(), countryXid: country.id, isActive: true, }, }); } /* --------------------------- 3️⃣ CITY ----------------------------*/ let city = await tx.cities.findFirst({ where: { cityName: { equals: cityName, mode: 'insensitive', }, stateXid: state.id, isActive: true, }, }); if (!city) { city = await tx.cities.create({ data: { cityName: cityName.trim(), stateXid: state.id, isActive: true, }, }); } return { countryXid: country.id, stateXid: state.id, cityXid: city.id, }; } 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, page: number, limit: number, countryName: string, stateName: string, cityName: string, userLat: string, userLong: string ) { 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, } }) let effectiveLocation: { countryXid?: number | null; stateXid?: number | null; cityXid?: number | null; } | null = null; const hasRequestLocation = countryName && stateName && cityName; if (hasRequestLocation) { // ✅ Create/find ONLY if request location is sent effectiveLocation = await findOrCreateLocation(tx, { countryName: countryName!, stateName: stateName!, cityName: cityName!, }); } else if (userAddressDetails) { // ✅ Fallback to user’s saved address effectiveLocation = { countryXid: userAddressDetails.countryXid, stateXid: userAddressDetails.stateXid, cityXid: userAddressDetails.cityXid, }; } const effectiveCountryXid = effectiveLocation?.countryXid ?? null; const effectiveStateXid = effectiveLocation?.stateXid ?? 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 { userAddressDetails, interests: [], otherStatesActivities: null, overSeasActivities: null, }; } const activitiyTypesOfUserInterests = await tx.activityTypes.findMany({ where: { interestXid: { in: userInterests.map(ui => ui.interestXid) }, isActive: true }, select: { id: true } }) if (!activitiyTypesOfUserInterests.length) { return { userAddressDetails, interests: [], otherStatesActivities: null, overSeasActivities: null, }; } const skip = (page - 1) * limit; 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), }, }, skip, take: limit, orderBy: { id: 'desc' }, 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 mostHypedTotalCount = await tx.userBucketInterested.groupBy({ by: ['activityXid'], where: { isActive: true, isBucket: false, }, }); const mostHypedGrouped = await tx.userBucketInterested.groupBy({ by: ['activityXid'], where: { isActive: true, isBucket: false }, _count: { activityXid: true, }, orderBy: { _count: { activityXid: 'desc', }, }, skip, take: limit, }); const totalHypedActivities = mostHypedTotalCount.length; const mostHypedActivityIds = mostHypedGrouped.map(a => a.activityXid); const mostHypedActivitiesRaw = await tx.activities.findMany({ where: { id: { in: mostHypedActivityIds }, isActive: true, activityInternalStatus: ACTIVITY_INTERNAL_STATUS.ACTIVITY_LISTED, amInternalStatus: ACTIVITY_AM_INTERNAL_STATUS.ACTIVITY_LISTED, }, 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 mostHypedActivities = await Promise.all( mostHypedGrouped.map(async g => { const activity = mostHypedActivitiesRaw.find(a => a.id === g.activityXid); if (!activity) return null; return { activityId: activity.id, activityTitle: activity.activityTitle, hypeCount: g._count.activityXid, // 🔥 VERY IMPORTANT energyLevel: activity.activityType.energyLevel, media: await attachMediaWithPresignedUrl(activity.ActivitiesMedia), }; }) ).then(arr => arr.filter(Boolean)); const formattedMostHypedActivities = { page, limit, totalCount: totalHypedActivities, hasMore: skip + limit < totalHypedActivities, activities: mostHypedActivities, }; const newArrivalsCount = await tx.activities.count({ where: { isActive: true, activityInternalStatus: ACTIVITY_INTERNAL_STATUS.ACTIVITY_LISTED, amInternalStatus: ACTIVITY_AM_INTERNAL_STATUS.ACTIVITY_LISTED, createdAt: { gte: new Date(Date.now() - 31 * 24 * 60 * 60 * 1000) } }, }); const newArrivalsActivities = 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), // }, createdAt: { gte: new Date(Date.now() - 31 * 24 * 60 * 60 * 1000) } }, skip, take: limit, orderBy: { id: 'desc' }, 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, }, }, }, }) /* ===================================================== 6️⃣ OTHER STATES ACTIVITIES ===================================================== */ const otherStatesWhere: any = { isActive: true, activityInternalStatus: ACTIVITY_INTERNAL_STATUS.ACTIVITY_LISTED, amInternalStatus: ACTIVITY_AM_INTERNAL_STATUS.ACTIVITY_LISTED, }; if (effectiveCountryXid) { otherStatesWhere.checkInCountryXid = effectiveCountryXid; } if (effectiveStateXid) { otherStatesWhere.checkInStateXid = { not: effectiveStateXid }; } const otherStatesTotalCount = await tx.activities.count({ where: otherStatesWhere, }); const otherStatesActivities = await tx.activities.findMany({ where: otherStatesWhere, skip, take: limit, orderBy: { id: 'desc' }, select: { activityTitle: true, activityType: { select: { energyLevel: true } }, ActivitiesMedia: { where: { isActive: true }, select: { id: true, mediaFileName: true, mediaType: true }, }, }, }); /* ===================================================== 7️⃣ OVERSEAS ACTIVITIES ===================================================== */ const overseasWhere: any = { isActive: true, activityInternalStatus: ACTIVITY_INTERNAL_STATUS.ACTIVITY_LISTED, amInternalStatus: ACTIVITY_AM_INTERNAL_STATUS.ACTIVITY_LISTED, }; if (effectiveCountryXid) { overseasWhere.checkInCountryXid = { not: effectiveCountryXid }; } const overSeasTotalCount = await tx.activities.count({ where: overseasWhere, }); const overSeasActivities = await tx.activities.findMany({ where: overseasWhere, skip, take: limit, orderBy: { id: 'desc' }, select: { activityTitle: true, activityType: { select: { energyLevel: true } }, ActivitiesMedia: { where: { isActive: true }, select: { id: true, mediaFileName: true, mediaType: true }, }, }, }); const formattedActivities = await Promise.all( activities.map(async (activity) => { const cheapestPrice = activity.ActivityVenues.flatMap(v => v.ActivityPrices) .map(p => p.sellPrice) .filter(Boolean) .sort((a, b) => a - b)[0] ?? null; return { interestXid: activity.activityType.interestXid, activityId: activity.id, activityTitle: activity.activityTitle, activityDurationMins: activity.activityDurationMins, sustainabilityScore: activity.sustainabilityScore, cheapestPrice, energyLevel: activity.activityType.energyLevel, media: await attachMediaWithPresignedUrl(activity.ActivitiesMedia), }; }) ); const formattedOtherStatesActivities = { page, limit, totalCount: otherStatesTotalCount, hasMore: skip + limit < otherStatesTotalCount, activities: await Promise.all( otherStatesActivities.map(async a => ({ activityTitle: a.activityTitle, energyLevel: a.activityType.energyLevel, media: await attachMediaWithPresignedUrl(a.ActivitiesMedia), })) ), }; const formattedNewArrivalsActivities = { page, limit, totalCount: newArrivalsCount, hasMore: skip + limit < newArrivalsCount, activities: await Promise.all( newArrivalsActivities.map(async a => ({ activityTitle: a.activityTitle, energyLevel: a.activityType.energyLevel, media: await attachMediaWithPresignedUrl(a.ActivitiesMedia), })) ), }; const interestsWithActivities = [...userInterests] .sort((a, b) => a.interest.interestName.localeCompare(b.interest.interestName) ) .map(ui => ({ interestId: ui.interest.id, interestName: ui.interest.interestName, interestColor: ui.interest.interestColor, interestImage: ui.interest.interestImage, displayOrder: ui.interest.displayOrder, page, limit, hasMore: formattedActivities.length === limit, activities: formattedActivities .filter(a => a.interestXid === ui.interestXid) .map(({ interestXid, ...rest }) => rest), })); return { userAddressDetails, experiencesLogged: 25, citiesDiscovered: 10, loggedInNetworkCount: 0, citiesInNetworkCount: 0, pagination: { page, limit, }, interests: interestsWithActivities, otherStatesActivities: { page, limit, totalCount: otherStatesTotalCount, hasMore: skip + limit < otherStatesTotalCount, activities: await Promise.all( otherStatesActivities.map(async a => ({ activityTitle: a.activityTitle, energyLevel: a.activityType.energyLevel, media: await attachMediaWithPresignedUrl(a.ActivitiesMedia), })) ), }, overSeasActivities: { page, limit, totalCount: overSeasTotalCount, hasMore: skip + limit < overSeasTotalCount, activities: await Promise.all( overSeasActivities.map(async a => ({ activityTitle: a.activityTitle, energyLevel: a.activityType.energyLevel, media: await attachMediaWithPresignedUrl(a.ActivitiesMedia), })) ), }, newArrivalsActivities: formattedNewArrivalsActivities, mostHypedActivities: formattedMostHypedActivities, }; }) return data; } async getActivityDetailsById( userId: number, activityXid: number) { return await this.prisma.$transaction(async (tx) => { const activity = await tx.activities.findUnique({ where: { id: activityXid, isActive: true, activityInternalStatus: ACTIVITY_INTERNAL_STATUS.ACTIVITY_LISTED, amInternalStatus: ACTIVITY_AM_INTERNAL_STATUS.ACTIVITY_LISTED }, select: { id: true, activityTitle: true, activityDurationMins: true, sustainabilityScore: true, checkInLat: true, checkInLong: true, activityRefNumber: true, checkInAddress: true, checkOutAddress: true, checkOutLat: true, checkOutLong: true, activityDescription: true, foodAvailable: true, foodIsChargeable: true, alcoholAvailable: true, trainerAvailable: true, trainerIsChargeable: true, pickUpDropAvailable: true, pickUpDropIsChargeable: true, inActivityAvailable: true, inActivityIsChargeable: true, isLateCheckingAllowed: true, equipmentAvailable: true, equipmentIsChargeable: true, cancellationAvailable: true, cancellationAllowedBeforeMins: true, activityType: { select: { interestXid: true, // ✅ VERY IMPORTANT activityTypeName: true, energyLevel: { select: { id: true, energyLevelName: true, energyColor: true, energyIcon: true, }, }, }, }, ActivityOtherDetails: { where: { isActive: true }, select: { id: true, exclusiveNotes: true, dosNotes: true, dontsNotes: true, tipsNotes: true, termsAndCondition: true } }, ActivityEligibility: { where: { isActive: true }, select: { id: true, isAgeRestriction: true, ageRestrictionName: true, ageEntered: true, ageIn: true, minAge: true, maxAge: true, isWeightRestriction: true, weightRestrictionName: true, weightEntered: true, weightIn: true, minWeight: true, maxWeight: true, isHeightRestriction: true, heightRestrictionName: true, heightEntered: true, heightIn: true, minHeight: true, maxHeight: true } }, ActivityTrainers: { where: { isActive: true }, select: { id: true, totalAmount: true } }, ActivityAllowedEntry: { where: { isActive: true }, select: { id: true, allowedEntryTypeXid: true, allowedEntryType: { select: { id: true, allowedEntryTypeName: true } } } }, ActivityFoodCost: { where: { isActive: true }, select: { id: true, totalAmount: true } }, activityFoodTypes: { where: { isActive: true }, select: { id: true, foodTypeXid: true, foodType: { select: { id: true, foodTypeName: true } } } }, ActivityEquipments: { where: { isActive: true }, select: { id: true, equipmentName: true, isEquipmentChargeable: true, equipmentTotalPrice: true } }, ActivityNavigationModes: { where: { isActive: true }, select: { id: true, navigationModeXid: true, navigationMode: { select: { id: true, navigationModeName: true, navigationModeIcon: true } }, isInActivityChargeable: true, navigationModesTotalPrice: true } }, ActivityAmenities: { where: { isActive: true }, select: { id: true, amenitiesXid: true, amenities: { select: { id: true, amenitiesName: true } } } }, ActivityPickUpDetails: { where: { isActive: true }, select: { id: true, isPickUp: true, locationLat: true, locationLong: true, locationAddress: true, transportTotalPrice: true } }, activityCuisines: { where: { isActive: true }, select: { id: true, foodCuisineXid: true, foodCuisine: { select: { id: true, cuisineName: true } } } }, ActivityVenues: { where: { isActive: true }, select: { venueName: true, venueLabel: true, venueCapacity: true, availableSeats: true, isMinPeopleReqMandatory: true, minPeopleRequired: true, minReqfullfilledBeforeMins: true, venueDescription: true, ActivityPrices: { select: { id: true, sellPrice: true, }, }, }, }, ActivitiesMedia: { where: { isActive: true }, select: { id: true, mediaFileName: true, mediaType: true, }, }, }, }) const interestedCount = await tx.userBucketInterested.count({ where: { activityXid, isActive: true, }, }) return { activity, interestedCount, rating: 0, // ⭐ Placeholder, implement rating logic as needed distance: 0 } }) } }