import { Injectable } from '@nestjs/common'; import { PrismaClient, User, UserAddressDetails } from '@prisma/client'; import * as bcrypt from 'bcryptjs'; import { getPresignedUrl } from '../../../common/middlewares/aws/getPreSignedUrl'; import { ACTIVITY_AM_INTERNAL_STATUS, ACTIVITY_INTERNAL_STATUS, } from '../../../common/utils/constants/host.constant'; import ApiError from '../../../common/utils/helper/ApiError'; import { UserPersonalInfoSchema } from '../../../common/utils/validation/user/addPersonalInfo.validation'; import { AddSchoolCompanyDetailDTO } from '../dto/addSchoolCompanyDetail.dto'; import config from '@/config/config'; // 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 calculateDistance = ( lat1: number | null, lon1: number | null, lat2: number | null, lon2: number | null, ) => { if (!lat1 || !lon1 || !lat2 || !lon2) return null; const R = 6371; // km const dLat = ((lat2 - lat1) * Math.PI) / 180; const dLon = ((lon2 - lon1) * Math.PI) / 180; const a = Math.sin(dLat / 2) * Math.sin(dLat / 2) + Math.cos((lat1 * Math.PI) / 180) * Math.cos((lat2 * Math.PI) / 180) * Math.sin(dLon / 2) * Math.sin(dLon / 2); const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); return Number((R * c).toFixed(2)); }; const normalizeName = (name: string) => name.trim().toLowerCase().replace(/\s+/g, " "); const attachPresignedUrl = async (file: string | null | undefined) => { if (!file) return null; const key = file.startsWith('http') ? new URL(file).pathname.replace(/^\/+/, '') : file; return await getPresignedUrl(bucket, key); }; 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); }; function deg2rad(deg: number): number { return deg * (Math.PI / 180); } function getDistanceFromLatLon( userLat1: number, userLon1: number, activityLat2: number, activityLon2: number, ): number { 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 bucket = config.aws.bucketName; /* ===================================================== HELPER: RANK & PAGINATE ACTIVITIES ===================================================== */ async function rankAndPaginateActivities( tx: any, whereClause: any, page: number, limit: number, connectionInterestMap ) { const skip = (page - 1) * limit; // 1️⃣ Fetch Metadata for ALL matching activities for in-memory sorting const allCandidates = await tx.activities.findMany({ where: whereClause, select: { id: true, sustainabilityScore: true, totalScore: true, // Quality Score ItineraryActivities: { select: { ActivityFeedbacks: { select: { activityStars: true }, }, }, }, ActivityVenues: { select: { ActivityPrices: { select: { sellPrice: true }, }, }, }, }, }); const totalCount = allCandidates.length; // 2️⃣ Calculate Metrics & Sort const sortedCandidates = allCandidates .map((act: any) => { // Flatten feedbacks const feedbacks = act.ItineraryActivities.flatMap( (ia: any) => ia.ActivityFeedbacks, ); // Avg Rating const totalStars = feedbacks.reduce( (sum: number, f: any) => sum + f.activityStars, 0, ); const avgRating = feedbacks.length > 0 ? totalStars / feedbacks.length : 0; // Min Price const prices = act.ActivityVenues.flatMap((v: any) => v.ActivityPrices.map((p: any) => p.sellPrice), ).filter((p: any) => p !== null) as number[]; const minPrice = prices.length > 0 ? Math.min(...prices) : Infinity; return { id: act.id, avgRating, minPrice, sustainabilityScore: act.sustainabilityScore ?? 0, totalScore: act.totalScore ?? 0, }; }) .sort((a: any, b: any) => { // 1. Rating (Highest first) if (b.avgRating !== a.avgRating) return b.avgRating - a.avgRating; // 2. Price (Lowest first) if (a.minPrice !== b.minPrice) return a.minPrice - b.minPrice; // 3. Sustainability Score (Highest first) if (b.sustainabilityScore !== a.sustainabilityScore) return b.sustainabilityScore - a.sustainabilityScore; // 4. Quality Score (Highest first) return b.totalScore - a.totalScore; }); // 3️⃣ Paginate IDs const paginatedCandidates = sortedCandidates.slice(skip, skip + limit); const targetIds = paginatedCandidates.map((c: any) => c.id); // 4️⃣ Fetch Full Details for the page const activitiesUnsorted = await tx.activities.findMany({ where: { id: { in: targetIds } }, select: { id: true, activityTitle: true, activityDurationMins: true, sustainabilityScore: true, activityType: { select: { interestXid: true, 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, }, }, }, }); // Re-sort to match the calculated order const activities = targetIds .map((id: number) => activitiesUnsorted.find((a: any) => a.id === id)) .filter(Boolean); // 5️⃣ Format Response const formattedActivities = await Promise.all( activities.map(async (activity: any) => { const prices = activity.ActivityVenues.flatMap((v: any) => v.ActivityPrices.map((p: any) => p.sellPrice), ).filter((p: any) => p !== null) as number[]; const cheapestPrice = prices.length > 0 ? Math.min(...prices) : null; return { // interestXid: activity.activityType.interestXid, activityId: activity.id, activityTitle: activity.activityTitle, distance: 0, rating: 0, // activityDurationMins: activity.activityDurationMins, // sustainabilityScore: activity.sustainabilityScore, // cheapestPrice, connectionInterestedCount: connectionInterestMap.get(activity.id) ?? 0, energyLevel: activity.activityType.energyLevel ? { ...activity.activityType.energyLevel, presignedUrl: await attachPresignedUrl( activity.activityType.energyLevel.energyIcon, ), } : null, media: await attachMediaWithPresignedUrl(activity.ActivitiesMedia), }; }), ); return { page, limit, totalCount, hasMore: skip + limit < totalCount, activities: formattedActivities, }; } @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, }, }); const totalActivityCount = await this.prisma.activities.count({ where: { isActive: true, activityInternalStatus: ACTIVITY_INTERNAL_STATUS.ACTIVITY_LISTED, amInternalStatus: ACTIVITY_AM_INTERNAL_STATUS.ACTIVITY_LISTED, } }) 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, totalActivityCount }; } async getUserByMobileNumber(mobileNumber: string): Promise { return this.prisma.user.findFirst({ where: { mobileNumber: mobileNumber, isActive: true }, }); } async verifyHostOtp(mobileNumber: string, otp: string): Promise { const trimmedOtp = (otp || '').toString().trim(); 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(trimmedOtp, 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 verifyUserPasscode(userId: number, passcode: string): Promise { const user = await this.prisma.user.findUnique({ where: { id: userId, isActive: true }, select: { userPasscode: true }, }); if (!user || !user.userPasscode) { throw new ApiError(404, 'User passcode not found'); } const isMatch = await bcrypt.compare(passcode, user.userPasscode); if (!isMatch) { return false; } return true; } 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.findFirst({ where: { stateName, countryXid: country.id }, 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.findFirst({ where: { cityName, stateXid: state.id }, 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, }, }); const userLatitude = userAddressDetails?.locationLat ?? null; const userLongitude = userAddressDetails?.locationLong ?? null; 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 userInterestActivityTypeIds = activitiyTypesOfUserInterests.map((a) => a.id); const userBucketInterested = await tx.userBucketInterested.findMany({ where: { userXid: userId, isActive: true, }, select: { activityXid: true, isBucket: true, }, }); const userBucketActivityIds = userBucketInterested .filter(u => u.isBucket) .map(u => u.activityXid); const userInterestedActivityIds = userBucketInterested .filter(u => !u.isBucket) .map(u => u.activityXid); const allUserExcludedActivityIds = userBucketInterested.map( u => u.activityXid, ); const latestUserActivity = await tx.userBucketInterested.findFirst({ where: { userXid: userId, isActive: true, }, orderBy: { createdAt: 'desc', }, select: { activityXid: true, }, }); let latestCoverImage: string | null = null; let latestCoverImagePresignedUrl: string | null = null; if (latestUserActivity) { const latestActivityImage = await tx.activities.findFirst({ where: { id: latestUserActivity.activityXid, isActive: true, activityInternalStatus: ACTIVITY_INTERNAL_STATUS.ACTIVITY_LISTED, amInternalStatus: ACTIVITY_AM_INTERNAL_STATUS.ACTIVITY_LISTED, }, select: { ActivitiesMedia: { where: { isCoverImage: true, isActive: true, }, select: { mediaFileName: true, }, take: 1, }, }, }); latestCoverImage = latestActivityImage?.ActivitiesMedia?.[0]?.mediaFileName ?? null; latestCoverImagePresignedUrl = latestCoverImage ? await attachPresignedUrl(latestCoverImage) : null; } const userConnectionDetails = await tx.connectDetails.findMany({ where: { userXid: userId, isActive: true }, select: { id: true, schoolCompanyXid: true, } }) const otherConnectionUsers = await tx.connectDetails.findMany({ where: { userXid: { notIn: [userId] }, isActive: true, schoolCompanyXid: { in: userConnectionDetails.map((u) => u.schoolCompanyXid) } }, select: { id: true, userXid: true, } }) const connectionUserIds = otherConnectionUsers.length > 0 ? otherConnectionUsers.map(u => u.userXid) : [-1]; // impossible user id const connectionInterestByActivity = await tx.userBucketInterested.groupBy({ by: ['activityXid'], where: { userXid: { in: connectionUserIds }, isActive: true, }, _count: { activityXid: true, }, }); const connectionInterestMap = new Map( connectionInterestByActivity.map(item => [ item.activityXid, item._count.activityXid, ]) ); const skip = (page - 1) * limit; /* ===================================================== 1️⃣ FETCH ALL CANDIDATES FOR INTERESTS (SIMPLE SORT) ===================================================== */ // Reverted to simple ID based sorting for Interest-based 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: userInterestActivityTypeIds }, id: { notIn: allUserExcludedActivityIds.length ? allUserExcludedActivityIds : [-1], // prevent empty notIn issue }, }, skip, take: limit, orderBy: { id: 'desc' }, select: { id: true, activityTitle: true, activityDurationMins: true, sustainabilityScore: true, checkInLat: true, checkInLong: true, activityType: { select: { interestXid: true, 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, activityXid: { notIn: allUserExcludedActivityIds.length ? allUserExcludedActivityIds : [-1], }, Activities: { activityTypeXid: { in: userInterestActivityTypeIds }, isActive: true, activityInternalStatus: ACTIVITY_INTERNAL_STATUS.ACTIVITY_LISTED, amInternalStatus: ACTIVITY_AM_INTERNAL_STATUS.ACTIVITY_LISTED, } }, }); /* ===================================================== 2️⃣ MOST HYPED ACTIVITIES (RANKED) ===================================================== */ const mostHypedGrouped = await tx.userBucketInterested.groupBy({ by: ['activityXid'], where: { isActive: true, isBucket: false, activityXid: { notIn: allUserExcludedActivityIds.length ? allUserExcludedActivityIds : [-1], }, }, _count: { activityXid: true, }, orderBy: { _count: { activityXid: 'desc', }, }, skip, take: limit, }); const totalHypedActivities = mostHypedTotalCount.length; const mostHypedActivityIds = mostHypedGrouped.map((a) => a.activityXid); // Fetch metadata for ranking only for the top hyped ones (optimization: double sorting might be needed if we want to sort BY rating WITHIN the hyped list, but usually Hyped = Count. // IF user wants the standard 4-step ranking applied TO the most hyped items: const mostHypedActivitiesRaw = await tx.activities.findMany({ where: { id: { in: mostHypedActivityIds, notIn: allUserExcludedActivityIds.length ? allUserExcludedActivityIds : [-1], }, activityTypeXid: { in: userInterestActivityTypeIds }, isActive: true, activityInternalStatus: ACTIVITY_INTERNAL_STATUS.ACTIVITY_LISTED, amInternalStatus: ACTIVITY_AM_INTERNAL_STATUS.ACTIVITY_LISTED, }, select: { id: true, activityTitle: true, sustainabilityScore: true, totalScore: true, activityType: { select: { energyLevel: { select: { id: true, energyLevelName: true, energyColor: true, energyIcon: true, }, }, }, }, ActivitiesMedia: { where: { isActive: true }, select: { id: true, mediaFileName: true, mediaType: true, }, }, // Fetch ranking metadata ItineraryActivities: { select: { ActivityFeedbacks: { select: { activityStars: true }, }, }, }, ActivityVenues: { select: { ActivityPrices: { select: { sellPrice: true }, }, }, }, }, }); // Sort Most Hyped by the 4 criteria const mostHypedSorted = mostHypedActivitiesRaw .map((act) => { const feedbacks = act.ItineraryActivities.flatMap( (ia) => ia.ActivityFeedbacks, ); const totalStars = feedbacks.reduce( (sum, f) => sum + f.activityStars, 0, ); const avgRating = feedbacks.length > 0 ? totalStars / feedbacks.length : 0; const prices = act.ActivityVenues.flatMap((v) => v.ActivityPrices.map((p) => p.sellPrice), ).filter((p) => p !== null) as number[]; const minPrice = prices.length > 0 ? Math.min(...prices) : Infinity; return { ...act, // Keep original fields for final output avgRating, minPrice, sustainabilityScore: act.sustainabilityScore ?? 0, totalScore: act.totalScore ?? 0, hypeCount: mostHypedGrouped.find((g) => g.activityXid === act.id)?._count .activityXid ?? 0, }; }) .sort((a, b) => { // 1. Rating (Highest first) if (b.avgRating !== a.avgRating) return b.avgRating - a.avgRating; // 2. Price (Lowest first) if (a.minPrice !== b.minPrice) return a.minPrice - b.minPrice; // 3. Sustainability Score if (b.sustainabilityScore !== a.sustainabilityScore) return b.sustainabilityScore - a.sustainabilityScore; // 4. Quality Score return b.totalScore - a.totalScore; }); const mostHypedActivities = await Promise.all( mostHypedSorted.map(async (activity) => ({ activityId: activity.id, activityTitle: activity.activityTitle, connectionInterestedCount: connectionInterestMap.get(activity.id) ?? 0, hypeCount: activity.hypeCount, distance: 0, rating: 0, energyLevel: activity.activityType.energyLevel ? { ...activity.activityType.energyLevel, presignedUrl: await attachPresignedUrl( activity.activityType.energyLevel.energyIcon, ), } : null, media: await attachMediaWithPresignedUrl(activity.ActivitiesMedia), })), ); const formattedMostHypedActivities = { page, limit, totalCount: totalHypedActivities, hasMore: skip + limit < totalHypedActivities, activities: mostHypedActivities, }; /* ===================================================== 3️⃣ NEW ARRIVALS (RANKED) ===================================================== */ const newArrivalsWhere = { isActive: true, activityInternalStatus: ACTIVITY_INTERNAL_STATUS.ACTIVITY_LISTED, amInternalStatus: ACTIVITY_AM_INTERNAL_STATUS.ACTIVITY_LISTED, activityTypeXid: { in: userInterestActivityTypeIds }, id: { notIn: allUserExcludedActivityIds.length ? allUserExcludedActivityIds : [-1], }, createdAt: { gte: new Date(Date.now() - 31 * 24 * 60 * 60 * 1000) }, }; const formattedNewArrivalsActivities = await rankAndPaginateActivities( tx, newArrivalsWhere, page, limit, connectionInterestMap ); /* ===================================================== 4️⃣ OTHER STATES ACTIVITIES (RANKED) ===================================================== */ const otherStatesWhere: any = { isActive: true, activityInternalStatus: ACTIVITY_INTERNAL_STATUS.ACTIVITY_LISTED, amInternalStatus: ACTIVITY_AM_INTERNAL_STATUS.ACTIVITY_LISTED, activityTypeXid: { in: userInterestActivityTypeIds }, id: { notIn: allUserExcludedActivityIds.length ? allUserExcludedActivityIds : [-1], // prevent empty notIn issue }, }; if (effectiveCountryXid) { otherStatesWhere.checkInCountryXid = effectiveCountryXid; } if (effectiveStateXid) { otherStatesWhere.checkInStateXid = { not: effectiveStateXid }; } const formattedOtherStatesActivities = await rankAndPaginateActivities( tx, otherStatesWhere, page, limit, connectionInterestMap ); // ===================================================== // 6️⃣ RANDOM ACTIVITIES (5 ONLY - SIMPLE) // ===================================================== let randomActivities: any[] = []; const eligibleRandomActivityIds = await tx.activities.findMany({ where: { isActive: true, activityInternalStatus: ACTIVITY_INTERNAL_STATUS.ACTIVITY_LISTED, amInternalStatus: ACTIVITY_AM_INTERNAL_STATUS.ACTIVITY_LISTED, deletedAt: null, id: { notIn: allUserExcludedActivityIds.length ? allUserExcludedActivityIds : [-1], }, ActivitiesMedia: { some: { isActive: true, isCoverImage: true, }, }, }, select: { id: true, }, }); if (eligibleRandomActivityIds.length > 0) { const takeCount = Math.min(5, eligibleRandomActivityIds.length); const selectedIds = eligibleRandomActivityIds .sort(() => Math.random() - 0.5) .slice(0, takeCount) .map((activity) => activity.id); const randomFetched = await tx.activities.findMany({ where: { id: { in: selectedIds }, }, select: { id: true, activityTitle: true, ActivitiesMedia: { where: { isActive: true, isCoverImage: true }, orderBy: { displayOrder: 'asc' }, take: 1, select: { mediaFileName: true, }, }, }, }); randomActivities = await Promise.all( randomFetched .filter(Boolean) .map(async (activity) => { const cover = activity!.ActivitiesMedia?.[0]; return { activityId: activity!.id, activityTitle: activity!.activityTitle, coverImage: cover?.mediaFileName ?? null, coverImagePresignedUrl: cover?.mediaFileName ? await attachPresignedUrl(cover.mediaFileName) : null, }; }), ); } /* ===================================================== 5️⃣ OVERSEAS ACTIVITIES (RANKED) ===================================================== */ const overseasWhere: any = { isActive: true, activityInternalStatus: ACTIVITY_INTERNAL_STATUS.ACTIVITY_LISTED, amInternalStatus: ACTIVITY_AM_INTERNAL_STATUS.ACTIVITY_LISTED, activityTypeXid: { in: userInterestActivityTypeIds }, id: { notIn: allUserExcludedActivityIds.length ? allUserExcludedActivityIds : [-1], // prevent empty notIn issue }, }; if (effectiveCountryXid) { overseasWhere.checkInCountryXid = { not: effectiveCountryXid }; } const formattedOverSeasActivities = await rankAndPaginateActivities( tx, overseasWhere, page, limit, connectionInterestMap ); 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; const distance = calculateDistance( userLatitude, userLongitude, activity.checkInLat, activity.checkInLong, ); return { interestXid: activity.activityType.interestXid, activityId: activity.id, connectionInterestedCount: connectionInterestMap.get(activity.id) ?? 0, activityTitle: activity.activityTitle, activityDurationMins: activity.activityDurationMins, sustainabilityScore: activity.sustainabilityScore, cheapestPrice, distance, rating: 0, energyLevel: activity.activityType.energyLevel ? { ...activity.activityType.energyLevel, presignedUrl: await attachPresignedUrl( activity.activityType.energyLevel.energyIcon, ), } : null, media: await attachMediaWithPresignedUrl(activity.ActivitiesMedia), }; }), ); const interestsWithActivities = await Promise.all( [...userInterests] .sort((a, b) => a.interest.interestName.localeCompare(b.interest.interestName), ) .map(async (ui) => ({ interestId: ui.interest.id, interestName: ui.interest.interestName, interestColor: ui.interest.interestColor, interestImage: ui.interest.interestImage, interestImagePresignedUrl: await attachPresignedUrl( 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: 0, citiesDiscovered: 0, loggedInNetworkCount: 0, citiesInNetworkCount: 0, rating: 0, latestBucketInterestedCoverImage: latestCoverImage, latestBucketInterestedCoverImagePresignedUrl: latestCoverImagePresignedUrl, interestedCount: userInterestedActivityIds.length, bucketCount: userBucketActivityIds.length, pagination: { page, limit, }, randomActivities, interests: interestsWithActivities, otherStatesActivities: formattedOtherStatesActivities, overSeasActivities: formattedOverSeasActivities, newArrivalsActivities: formattedNewArrivalsActivities, mostHypedActivities: formattedMostHypedActivities, }; }); return data; } async getSurpriseMeDetails( userId: number, page: number, limit: number, countryName: string, stateName: string, cityName: string, ) { const data = await this.prisma.$transaction(async (tx) => { /* ===================================================== 1️⃣ USER LOCATION ===================================================== */ const userAddressDetails = await tx.userAddressDetails.findFirst({ where: { userXid: userId }, select: { stateXid: true, cityXid: true, countryXid: true, locationLat: true, locationLong: true, }, }); const userLat = userAddressDetails?.locationLat ?? null; const userLng = userAddressDetails?.locationLong ?? null; let effectiveLocation: { countryXid?: number | null; stateXid?: number | null; cityXid?: number | null; } | null = null; if (countryName && stateName && cityName) { effectiveLocation = await findOrCreateLocation(tx, { countryName, stateName, cityName, }); } else if (userAddressDetails) { effectiveLocation = { countryXid: userAddressDetails.countryXid, stateXid: userAddressDetails.stateXid, cityXid: userAddressDetails.cityXid, }; } const effectiveCountryXid = effectiveLocation?.countryXid ?? null; const effectiveStateXid = effectiveLocation?.stateXid ?? null; /* ===================================================== 2️⃣ USER INTERESTS (TO EXCLUDE) ===================================================== */ const userInterests = await tx.userInterests.findMany({ where: { userXid: userId, isActive: true }, select: { interestXid: true }, }); const userInterestTypeIds = await tx.activityTypes.findMany({ where: { interestXid: { in: userInterests.map((i) => i.interestXid) }, isActive: true, }, select: { id: true }, }); const excludedActivityTypeIds = userInterestTypeIds.map((a) => a.id); const excludeUserInterestCondition = excludedActivityTypeIds.length > 0 ? { activityTypeXid: { notIn: excludedActivityTypeIds } } : {}; const skip = (page - 1) * limit; const userBucketInterested = await tx.userBucketInterested.findMany({ where: { userXid: userId, isActive: true, }, select: { activityXid: true, isBucket: true, }, }); const bucketActivityIds = userBucketInterested .filter(u => u.isBucket) .map(u => u.activityXid); const interestedActivityIds = userBucketInterested .filter(u => !u.isBucket) .map(u => u.activityXid); const excludedActivityIds = userBucketInterested.map( u => u.activityXid, ); const safeExcludedIds = excludedActivityIds.length > 0 ? excludedActivityIds : [-1]; /* ===================================================== CONNECTION INTEREST MAP ===================================================== */ const userConnectionDetails = await tx.connectDetails.findMany({ where: { userXid: userId, isActive: true }, select: { schoolCompanyXid: true }, }); const otherConnectionUsers = await tx.connectDetails.findMany({ where: { userXid: { not: userId }, isActive: true, schoolCompanyXid: { in: userConnectionDetails.map((u) => u.schoolCompanyXid), }, }, select: { userXid: true }, }); // Prevent empty IN crash const connectionUserIds = otherConnectionUsers.length > 0 ? otherConnectionUsers.map((u) => u.userXid) : [-1]; // Only bucket = true (important!) const connectionInterestByActivity = await tx.userBucketInterested.groupBy({ by: ['activityXid'], where: { userXid: { in: connectionUserIds }, isActive: true, }, _count: { activityXid: true }, }); const connectionInterestMap = new Map( connectionInterestByActivity.map((item) => [ item.activityXid, item._count.activityXid, ]), ); /* ===================================================== 3️⃣ OTHER INTERESTS (GROUPED WITH ACTIVITIES) ===================================================== */ const otherInterests = await tx.interests.findMany({ where: { isActive: true, id: { notIn: userInterests.map((i) => i.interestXid) }, }, orderBy: { interestName: 'asc' }, select: { id: true, interestName: true, interestColor: true, interestImage: true, }, }); const otherInterestActivities = await tx.activities.findMany({ where: { isActive: true, activityInternalStatus: ACTIVITY_INTERNAL_STATUS.ACTIVITY_LISTED, amInternalStatus: ACTIVITY_AM_INTERNAL_STATUS.ACTIVITY_LISTED, id: { notIn: safeExcludedIds }, ...excludeUserInterestCondition, }, skip, take: limit, orderBy: { id: 'desc' }, select: { id: true, activityTitle: true, checkInLat: true, checkInLong: true, activityDurationMins: true, sustainabilityScore: true, ActivityVenues: { select: { ActivityPrices: { select: { sellPrice: true, }, }, }, }, activityType: { select: { interestXid: true, energyLevel: true, }, }, ActivitiesMedia: { where: { isActive: true }, select: { id: true, mediaFileName: true, mediaType: true }, }, }, }); const formattedOtherInterestActivities = await Promise.all( otherInterestActivities.map(async (a) => ({ cheapestPrice: a.ActivityVenues.flatMap(v => v.ActivityPrices) .map(p => p.sellPrice) .filter(Boolean) .sort((a, b) => a - b)[0] ?? null, interestXid: a.activityType.interestXid, activityId: a.id, connectionInterestedCount: connectionInterestMap.get(a.id) ?? 0, activityTitle: a.activityTitle, distance: calculateDistance( userLat, userLng, a.checkInLat, a.checkInLong ), activityDurationMins: a.activityDurationMins, sustainabilityScore: a.sustainabilityScore, rating: 0, energyLevel: { ...a.activityType.energyLevel, presignedUrl: await attachPresignedUrl( a.activityType.energyLevel?.energyIcon, ), }, media: await attachMediaWithPresignedUrl(a.ActivitiesMedia), })), ); const interestsWithActivities = await Promise.all( otherInterests.map(async (interest) => ({ interestId: interest.id, interestName: interest.interestName, interestColor: interest.interestColor, interestImage: interest.interestImage, interestImagePresignedUrl: await attachPresignedUrl( interest.interestImage, ), page, limit, hasMore: formattedOtherInterestActivities.length === limit, activities: formattedOtherInterestActivities .filter((a) => a.interestXid === interest.id) .map(({ interestXid, ...rest }) => rest), })), ); /* ===================================================== 4️⃣ MOST HYPED ===================================================== */ const mostHypedGrouped = await tx.userBucketInterested.groupBy({ by: ['activityXid'], where: { isActive: true, isBucket: false, Activities: excludeUserInterestCondition, }, _count: { activityXid: true }, orderBy: { _count: { activityXid: 'desc' } }, skip, take: limit, }); const totalHypedCount = ( await tx.userBucketInterested.groupBy({ by: ['activityXid'], where: { isActive: true, isBucket: false, Activities: excludeUserInterestCondition, }, }) ).length; const hypedActivities = await tx.activities.findMany({ where: { id: { in: mostHypedGrouped.map((h) => h.activityXid), notIn: safeExcludedIds, }, activityInternalStatus: ACTIVITY_INTERNAL_STATUS.ACTIVITY_LISTED, amInternalStatus: ACTIVITY_AM_INTERNAL_STATUS.ACTIVITY_LISTED, isActive: true, }, select: { id: true, activityTitle: true, checkInLat: true, checkInLong: true, activityType: { select: { energyLevel: true } }, activityDurationMins: true, sustainabilityScore: true, ActivityVenues: { select: { ActivityPrices: { select: { sellPrice: true, }, }, }, }, ActivitiesMedia: { where: { isActive: true }, select: { id: true, mediaFileName: true, mediaType: true }, }, }, }); const mostHypedActivities = await Promise.all( mostHypedGrouped.map(async (g) => { const act = hypedActivities.find((a) => a.id === g.activityXid); if (!act) return null; return { cheapestPrice: act.ActivityVenues.flatMap(v => v.ActivityPrices) .map(p => p.sellPrice) .filter(Boolean) .sort((a, b) => a - b)[0] ?? null, activityDurationMins: act.activityDurationMins, sustainabilityScore: act.sustainabilityScore, activityId: act.id, activityTitle: act.activityTitle, hypeCount: g._count.activityXid, connectionInterestedCount: connectionInterestMap.get(act.id) ?? 0, distance: calculateDistance( userLat, userLng, act.checkInLat, act.checkInLong ), rating: 0, energyLevel: { ...act.activityType.energyLevel, presignedUrl: await attachPresignedUrl( act.activityType.energyLevel?.energyIcon, ), }, media: await attachMediaWithPresignedUrl(act.ActivitiesMedia), }; }), ).then((a) => a.filter(Boolean)); /* ===================================================== 5️⃣ NEW ARRIVALS ===================================================== */ const newArrivalsWhere = { id: { notIn: safeExcludedIds }, 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) }, ...excludeUserInterestCondition, }; const newArrivalsCount = await tx.activities.count({ where: newArrivalsWhere, }); const newArrivalsRaw = await tx.activities.findMany({ where: newArrivalsWhere, skip, take: limit, orderBy: { id: 'desc' }, select: { id: true, activityTitle: true, activityType: { select: { energyLevel: true } }, activityDurationMins: true, sustainabilityScore: true, ActivityVenues: { select: { ActivityPrices: { select: { sellPrice: true, }, }, }, }, ActivitiesMedia: { where: { isActive: true }, select: { id: true, mediaFileName: true, mediaType: true }, }, }, }); /* ===================================================== 6️⃣ OTHER STATES & OVERSEAS ===================================================== */ const otherStatesWhere: any = { isActive: true, id: { notIn: safeExcludedIds }, activityInternalStatus: ACTIVITY_INTERNAL_STATUS.ACTIVITY_LISTED, amInternalStatus: ACTIVITY_AM_INTERNAL_STATUS.ACTIVITY_LISTED, ...excludeUserInterestCondition, }; if (effectiveCountryXid) otherStatesWhere.checkInCountryXid = effectiveCountryXid; if (effectiveStateXid) otherStatesWhere.checkInStateXid = { not: effectiveStateXid }; const overseasWhere: any = { isActive: true, id: { notIn: safeExcludedIds }, activityInternalStatus: ACTIVITY_INTERNAL_STATUS.ACTIVITY_LISTED, amInternalStatus: ACTIVITY_AM_INTERNAL_STATUS.ACTIVITY_LISTED, ...excludeUserInterestCondition, }; if (effectiveCountryXid) overseasWhere.checkInCountryXid = { not: effectiveCountryXid }; const [otherStatesCount, overseasCount] = await Promise.all([ tx.activities.count({ where: otherStatesWhere }), tx.activities.count({ where: overseasWhere }), ]); const [otherStatesRaw, overseasRaw] = await Promise.all([ tx.activities.findMany({ where: otherStatesWhere, skip, take: limit, select: { id: true, activityTitle: true, activityType: { select: { energyLevel: true } }, activityDurationMins: true, sustainabilityScore: true, ActivityVenues: { select: { ActivityPrices: { select: { sellPrice: true, }, }, }, }, ActivitiesMedia: { where: { isActive: true }, select: { id: true, mediaFileName: true, mediaType: true }, }, }, }), tx.activities.findMany({ where: overseasWhere, skip, take: limit, select: { id: true, activityTitle: true, activityType: { select: { energyLevel: true } }, activityDurationMins: true, sustainabilityScore: true, ActivityVenues: { select: { ActivityPrices: { select: { sellPrice: true, }, }, }, }, ActivitiesMedia: { where: { isActive: true }, select: { id: true, mediaFileName: true, mediaType: true }, }, }, }), ]); /* ===================================================== RANDOM ACTIVITIES (5 COVER IMAGES) ===================================================== */ let randomActivities: any[] = []; const eligibleRandomActivityIds = await tx.activities.findMany({ where: { isActive: true, activityInternalStatus: ACTIVITY_INTERNAL_STATUS.ACTIVITY_LISTED, amInternalStatus: ACTIVITY_AM_INTERNAL_STATUS.ACTIVITY_LISTED, deletedAt: null, id: { notIn: safeExcludedIds, }, ActivitiesMedia: { some: { isActive: true, isCoverImage: true, }, }, ...excludeUserInterestCondition, }, select: { id: true, }, }); if (eligibleRandomActivityIds.length > 0) { const takeCount = Math.min(5, eligibleRandomActivityIds.length); const selectedIds = eligibleRandomActivityIds .sort(() => Math.random() - 0.5) .slice(0, takeCount) .map((activity) => activity.id); const randomFetched = await tx.activities.findMany({ where: { id: { in: selectedIds }, }, select: { id: true, activityTitle: true, ActivitiesMedia: { where: { isActive: true, isCoverImage: true }, orderBy: { displayOrder: 'asc' }, take: 1, select: { mediaFileName: true, }, }, }, }); randomActivities = await Promise.all( randomFetched .filter(Boolean) .map(async (activity) => { const cover = activity!.ActivitiesMedia?.[0]; return { activityId: activity!.id, activityTitle: activity!.activityTitle, coverImage: cover?.mediaFileName ?? null, coverImagePresignedUrl: cover?.mediaFileName ? await attachPresignedUrl(cover.mediaFileName) : null, }; }), ); } /* ===================================================== 7️⃣ FINAL RESPONSE ===================================================== */ return { pagination: { page, limit }, interests: interestsWithActivities, interestedCount: interestedActivityIds.length, bucketCount: bucketActivityIds.length, randomActivities, mostHypedActivities: { page, limit, totalCount: totalHypedCount, hasMore: skip + limit < totalHypedCount, activities: mostHypedActivities, }, newArrivalsActivities: { page, limit, totalCount: newArrivalsCount, hasMore: skip + limit < newArrivalsCount, activities: await Promise.all( newArrivalsRaw.map(async (a) => ({ cheapestPrice: a.ActivityVenues.flatMap(v => v.ActivityPrices) .map(p => p.sellPrice) .filter(Boolean) .sort((a, b) => a - b)[0] ?? null, activityDurationMins: a.activityDurationMins, sustainabilityScore: a.sustainabilityScore, activityId: a.id, activityTitle: a.activityTitle, connectionInterestedCount: connectionInterestMap.get(a.id) ?? 0, distance: 0, rating: 0, energyLevel: { ...a.activityType.energyLevel, presignedUrl: await attachPresignedUrl( a.activityType.energyLevel?.energyIcon, ), }, media: await attachMediaWithPresignedUrl(a.ActivitiesMedia), })), ), }, otherStatesActivities: { page, limit, totalCount: otherStatesCount, hasMore: skip + limit < otherStatesCount, activities: await Promise.all( otherStatesRaw.map(async (a) => ({ cheapestPrice: a.ActivityVenues.flatMap(v => v.ActivityPrices) .map(p => p.sellPrice) .filter(Boolean) .sort((a, b) => a - b)[0] ?? null, activityDurationMins: a.activityDurationMins, sustainabilityScore: a.sustainabilityScore, activityId: a.id, activityTitle: a.activityTitle, connectionInterestedCount: connectionInterestMap.get(a.id) ?? 0, distance: 0, rating: 0, energyLevel: { ...a.activityType.energyLevel, presignedUrl: await attachPresignedUrl( a.activityType.energyLevel?.energyIcon, ), }, media: await attachMediaWithPresignedUrl(a.ActivitiesMedia), })), ), }, overSeasActivities: { page, limit, totalCount: overseasCount, hasMore: skip + limit < overseasCount, activities: await Promise.all( overseasRaw.map(async (a) => ({ cheapestPrice: a.ActivityVenues.flatMap(v => v.ActivityPrices) .map(p => p.sellPrice) .filter(Boolean) .sort((a, b) => a - b)[0] ?? null, activityDurationMins: a.activityDurationMins, sustainabilityScore: a.sustainabilityScore, activityId: a.id, activityTitle: a.activityTitle, connectionInterestedCount: connectionInterestMap.get(a.id) ?? 0, distance: 0, rating: 0, energyLevel: { ...a.activityType.energyLevel, presignedUrl: await attachPresignedUrl( a.activityType.energyLevel?.energyIcon, ), }, media: await attachMediaWithPresignedUrl(a.ActivitiesMedia), })), ), }, }; }); 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, checkInCity: { select: { id: true, cityName: true } }, checkOutCity: { select: { id: true, cityName: true } }, checkInState: { select: { id: true, stateName: true } }, checkOutState: { select: { id: true, stateName: 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, SafetyInstruction: true, Cancellations: 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, navigationModeName: true, isInActivityChargeable: true, navigationModesTotalPrice: true, }, }, ActivityAmenities: { where: { isActive: true }, select: { id: true, amenitiesXid: true, amenities: { select: { id: true, amenitiesName: true, amenitiesIcon: true, }, }, }, }, ActivityPickUpDetails: { where: { isActive: true }, select: { id: true, isPickUp: true, locationLat: true, locationLong: true, locationAddress: true, transportTotalPrice: true, }, }, activityPickUpTransports: { where: { isActive: true }, select: { id: true, transportMode: { select: { id: true, transportModeName: true, transportModeIcon: true, }, }, }, }, activityCuisines: { where: { isActive: true }, select: { id: true, foodCuisineXid: true, foodCuisine: { select: { id: true, cuisineName: true, }, }, }, }, ActivityVenues: { where: { isActive: true, ScheduleHeader: { some: {} } }, select: { id: true, venueName: true, venueLabel: true, venueCapacity: true, availableSeats: true, isMinPeopleReqMandatory: true, minPeopleRequired: true, minReqfullfilledBeforeMins: true, venueDescription: true, ActivityVenueArtifacts: { select: { id: true, mediaFileName: true, mediaType: true, }, }, ScheduleHeader: { select: { id: true, scheduleType: true, startDate: true, endDate: true, earlyCheckInMins: true, bookingCutOffMins: true, effectiveFromDt: true, effectiveToDt: true, } }, ActivityPrices: { select: { id: true, sellPrice: true, }, }, }, }, ActivitiesMedia: { where: { isActive: true }, select: { id: true, mediaFileName: true, mediaType: true, }, }, }, }); if (!activity) { throw new Error("Activity not found"); } const userActivityStatus = await tx.userBucketInterested.findFirst({ where: { activityXid: activityXid, userXid: userId, isActive: true, }, select: { isBucket: true, }, }); const isBucket = userActivityStatus?.isBucket === true; const isInterested = userActivityStatus ? userActivityStatus.isBucket === false : false; const userLocation = await tx.userAddressDetails.findFirst({ where: { userXid: userId }, select: { locationLat: true, locationLong: true, }, }); const userLat = userLocation?.locationLat ?? null; const userLng = userLocation?.locationLong ?? null; let distance = 0; if ( userLat && userLng && activity?.checkInLat && activity?.checkInLong ) { distance = calculateDistance( userLat, userLng, activity.checkInLat, activity.checkInLong ) }; // ================= PRESIGNED URL SECTION ================= // 1️⃣ Activity Media if (Array.isArray(activity?.ActivitiesMedia)) { activity.ActivitiesMedia = await Promise.all( activity.ActivitiesMedia.map(async (m: any) => ({ ...m, presignedUrl: await attachPresignedUrl(m.mediaFileName), })), ); } // 2️⃣ Energy Level Icon if (activity?.activityType?.energyLevel?.energyIcon) { activity.activityType.energyLevel.energyIcon = await attachPresignedUrl( activity.activityType.energyLevel.energyIcon, ); } // 5️⃣ PickUp Transport Mode Icons if (Array.isArray(activity?.activityPickUpTransports)) { await Promise.all( activity.activityPickUpTransports.map(async (item: any) => { if (item?.transportMode?.transportModeIcon) { item.transportMode.presignedUrl = await attachPresignedUrl( item.transportMode.transportModeIcon, ); } }), ); } // 3️⃣ Activity Venue Artifacts if (Array.isArray(activity?.ActivityVenues)) { await Promise.all( activity.ActivityVenues.map(async (venue: any) => { if (Array.isArray(venue?.ActivityVenueArtifacts)) { venue.ActivityVenueArtifacts = await Promise.all( venue.ActivityVenueArtifacts.map(async (artifact: any) => ({ ...artifact, presignedUrl: await attachPresignedUrl( artifact.mediaFileName, ), })), ); } }), ); } // 3️⃣ Navigation Mode Icons if (Array.isArray(activity?.ActivityNavigationModes)) { await Promise.all( activity.ActivityNavigationModes.map(async (item: any) => { if (item?.navigationMode?.navigationModeIcon) { item.navigationMode.presignedUrl = await attachPresignedUrl( item.navigationMode.navigationModeIcon, ); } }), ); } // 4️⃣ Amenities Icons (IMPORTANT: make sure amenitiesIcon is selected in Prisma) if (Array.isArray(activity?.ActivityAmenities)) { await Promise.all( activity.ActivityAmenities.map(async (item: any) => { if (item?.amenities?.amenitiesIcon) { item.amenities.presignedUrl = await attachPresignedUrl( item.amenities.amenitiesIcon, ); } }), ); } // 🔹 Get connection users const userConnectionDetails = await tx.connectDetails.findMany({ where: { userXid: userId, isActive: true }, select: { schoolCompanyXid: true }, }); const schoolCompanyXids = userConnectionDetails.map( (c) => c.schoolCompanyXid, ); const connectionUsers = await tx.connectDetails.findMany({ where: { isActive: true, schoolCompanyXid: { in: schoolCompanyXids.length ? schoolCompanyXids : [-1], }, userXid: { not: userId }, }, select: { userXid: true }, }); const connectionUserIds = connectionUsers.map((u) => u.userXid); const connectionInterestedCount = connectionUserIds.length ? await tx.userBucketInterested.count({ where: { activityXid, userXid: { in: connectionUserIds }, isActive: true, }, }) : 0; const prices = activity?.ActivityVenues?.flatMap((v) => v.ActivityPrices.map((p) => p.sellPrice) ).filter((p) => p !== null) ?? []; const cheapestPrice = prices.length > 0 ? Math.min(...prices) : null; const totalCapacity = activity.ActivityVenues.map( (v) => v.venueCapacity ?? 0, ).reduce((sum, capacity) => sum + capacity, 0); const interestedCount = await tx.userBucketInterested.count({ where: { activityXid, isBucket: false, isActive: true, }, }); const interestedUsers = await tx.userBucketInterested.findMany({ where: { activityXid, isBucket: false, isActive: true, user: { isActive: true, profileImage: { not: null }, }, }, select: { user: { select: { profileImage: true, }, }, }, take: 5, }); const randomFive = interestedUsers .sort(() => Math.random() - 0.5) .slice(0, 5); // const interestedUserImages: { profileImage: string }[] = []; // for (const item of randomFive) { // const profileImage = item.user.profileImage; // if (profileImage) { // const key = profileImage.startsWith('http') // ? new URL(profileImage).pathname.replace(/^\/+/, '') // : profileImage; // const presignedUrl = await getPresignedUrl(bucket, key); // interestedUserImages.push({ // profileImage: presignedUrl, // }); // } // } const interestedUserImages = await Promise.all( randomFive.map(async ({ user }) => ({ profileImage: await attachPresignedUrl(user.profileImage), })) ); const checkInLocation = activity?.checkInCity?.cityName && activity?.checkInState?.stateName ? `${activity.checkInCity.cityName}, ${activity.checkInState.stateName}` : null; const checkOutLocation = activity?.checkOutCity?.cityName && activity?.checkOutState?.stateName ? `${activity.checkOutCity.cityName}, ${activity.checkOutState.stateName}` : null; return { activity, interestedCount, connectionInterestedCount, cheapestPrice, totalCapacity, rating: 0, // ⭐ Placeholder, implement rating logic as needed distance: distance || 0, interestedUserImages, isBucket, isInterested, checkInLocation, checkOutLocation }; }); } async searchActivities( activityType?: string ) { // Build the where clause dynamically const where: any = { isActive: true, }; if (activityType && activityType.trim().length > 0) { where.activityTypeName = { contains: activityType.trim(), mode: 'insensitive', }; } const activityTypes = await this.prisma.activityTypes.findMany({ where, select: { id: true, activityTypeName: true, interests: { select: { interestImage: true } } }, orderBy: { activityTypeName: 'asc', }, take: 20, // limit suggestions }); // Get interested count for each activity const formattedResults = await Promise.all( activityTypes.map(async (activity) => { const image = activity.interests?.interestImage ?? null; const presignedUrl = image ? await attachPresignedUrl(image) : null; return { id: activity.id, activityTypeName: activity.activityTypeName, interestImage: image, interestImagePresignedUrl: presignedUrl, }; }), ); return formattedResults; } async getNearbyActivities( userId: number, userLat: number, userLong: number, radiusKm: number, page: number, limit: number, ) { // If lat/long not provided, fetch from user saved address if (userLat === undefined || userLong === undefined) { const userAddress = await this.prisma.userAddressDetails.findFirst({ where: { userXid: userId, isActive: true }, select: { locationLat: true, locationLong: true, }, }); if (!userAddress?.locationLat || !userAddress?.locationLong) { throw new ApiError( 400, 'User location not found. Please provide lat/long.', ); } userLat = userAddress.locationLat; userLong = userAddress.locationLong; } const skip = (page - 1) * limit; // 0.5️⃣ Get connection users const userConnectionDetails = await this.prisma.connectDetails.findMany({ where: { userXid: userId, isActive: true }, select: { schoolCompanyXid: true }, }); const schoolCompanyXids = userConnectionDetails.map( (c) => c.schoolCompanyXid, ); const connectionUsers = await this.prisma.connectDetails.findMany({ where: { isActive: true, schoolCompanyXid: { in: schoolCompanyXids.length ? schoolCompanyXids : [-1] }, userXid: { not: userId }, }, select: { userXid: true }, }); const connectionUserIds = connectionUsers.map((u) => u.userXid); // 0️⃣ Get user's interests and map to activity types const userInterests = await this.prisma.userInterests.findMany({ where: { userXid: userId, isActive: true }, select: { interestXid: true }, }); if (!userInterests.length) { return { page, limit, totalCount: 0, hasMore: false, activities: [], }; } const activityTypeIds = ( await this.prisma.activityTypes.findMany({ where: { interestXid: { in: userInterests.map((u) => u.interestXid) }, isActive: true }, select: { id: true }, }) ).map((t) => t.id); if (!activityTypeIds.length) { return { page, limit, totalCount: 0, hasMore: false, activities: [], }; } // Rough bounding box in degrees to reduce DB scan const earthRadiusKm = 6371; const latDelta = (radiusKm / earthRadiusKm) * (180 / Math.PI); const lonDelta = (radiusKm / (earthRadiusKm * Math.cos(deg2rad(userLat)))) * (180 / Math.PI); const candidates = await this.prisma.activities.findMany({ where: { isActive: true, activityInternalStatus: ACTIVITY_INTERNAL_STATUS.ACTIVITY_LISTED, amInternalStatus: ACTIVITY_AM_INTERNAL_STATUS.ACTIVITY_LISTED, activityTypeXid: { in: activityTypeIds }, checkInLat: { not: null, gte: userLat - latDelta, lte: userLat + latDelta, }, checkInLong: { not: null, gte: userLong - lonDelta, lte: userLong + lonDelta, }, }, select: { id: true, activityTitle: true, activityDurationMins: true, sustainabilityScore: true, checkInLat: true, checkInLong: true, activityType: { select: { interestXid: true, 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 withDistance = candidates .map((activity: any) => { const distanceKm = getDistanceFromLatLon( userLat, userLong, activity.checkInLat, activity.checkInLong, ); return { ...activity, distanceKm, }; }) .filter((a) => a.distanceKm <= radiusKm) .sort((a, b) => a.distanceKm - b.distanceKm); const nearbyActivityIds = withDistance.map((a) => a.id); let connectionInterestMap = new Map(); if (nearbyActivityIds.length && connectionUserIds.length) { const connectionInterestCounts = await this.prisma.userBucketInterested.groupBy({ by: ['activityXid'], where: { activityXid: { in: nearbyActivityIds }, userXid: { in: connectionUserIds }, isActive: true, isBucket: true, // ✅ only real interest }, _count: { activityXid: true }, }); connectionInterestMap = new Map( connectionInterestCounts.map((item) => [ item.activityXid, item._count.activityXid, ]), ); } const totalCount = withDistance.length; const paged = withDistance.slice(skip, skip + limit); const formattedActivities = await Promise.all( paged.map(async (activity: any) => { const prices = activity.ActivityVenues.flatMap((v: any) => v.ActivityPrices.map((p: any) => p.sellPrice), ).filter((p: any) => p !== null) as number[]; const cheapestPrice = prices.length > 0 ? Math.min(...prices) : null; return { activityId: activity.id, activityTitle: activity.activityTitle, connectionInterestedCount: connectionInterestMap.get(activity.id) ?? 0, activityDurationMins: activity.activityDurationMins, sustainabilityScore: activity.sustainabilityScore, rating: 0, distance: activity.distanceKm, cheapestPrice, energyLevel: activity.activityType.energyLevel ? { ...activity.activityType.energyLevel, presignedUrl: await attachPresignedUrl( activity.activityType.energyLevel.energyIcon, ), } : null, media: await attachMediaWithPresignedUrl(activity.ActivitiesMedia), }; }), ); return { page, limit, totalCount, hasMore: skip + limit < totalCount, activities: formattedActivities, }; } // CONNECTIONS async getAllConnectionDetailsOfUser(userXid: number) { return await this.prisma.connectDetails.findMany({ where: { userXid, isActive: true }, select: { id: true, schoolCompany: { select: { id: true, schoolCompanyName: true, isSchool: true, cityXid: true, cities: { select: { id: true, cityName: true, stateXid: true, states: { select: { id: true, stateName: true } } } } }, }, }, }); } async searchConnectionPeople(userXid: number, searchQuery?: string) { const userConnectionDetails = await this.prisma.connectDetails.findMany({ where: { userXid, isActive: true, deletedAt: null, }, select: { schoolCompanyXid: true, }, }); const schoolCompanyXids = [ ...new Set(userConnectionDetails.map((item) => item.schoolCompanyXid)), ]; if (!schoolCompanyXids.length) { return { count: 0, people: [], }; } const trimmedSearchQuery = searchQuery?.trim() ?? ''; const connectionPeople = await this.prisma.connectDetails.findMany({ where: { isActive: true, deletedAt: null, schoolCompanyXid: { in: schoolCompanyXids }, userXid: { not: userXid }, user: { isActive: true, deletedAt: null, ...(trimmedSearchQuery ? { OR: [ { firstName: { contains: trimmedSearchQuery, mode: 'insensitive', }, }, { lastName: { contains: trimmedSearchQuery, mode: 'insensitive', }, }, ], } : {}), }, }, distinct: ['userXid'], orderBy: { createdAt: 'desc', }, take: 10, select: { userXid: true, schoolCompany: { select: { id: true, schoolCompanyName: true, isSchool: true, }, }, user: { select: { id: true, firstName: true, lastName: true, profileImage: true, }, }, }, }); const people = await Promise.all( connectionPeople.map(async (item) => { const firstName = item.user.firstName?.trim() ?? ''; const lastName = item.user.lastName?.trim() ?? ''; const fullName = `${firstName} ${lastName}`.trim(); return { userXid: item.user.id, fullName, firstName: item.user.firstName, lastName: item.user.lastName, profileImage: item.user.profileImage, profileImagePresignedUrl: await attachPresignedUrl( item.user.profileImage, ), schoolCompany: item.schoolCompany, }; }), ); return { count: people.length, people, }; } async searchSchoolsAndCompanies(searchQuery: string, isSchool: boolean) { if (!searchQuery) { throw new ApiError( 400, 'Search query is required to search for schools or companies', ); } const results = await this.prisma.schoolCompany.findMany({ where: { schoolCompanyName: { contains: searchQuery, mode: 'insensitive', }, isSchool: isSchool, isActive: true, deletedAt: null, }, select: { id: true, schoolCompanyName: true, cities: { select: { id: true, cityName: true, states: { select: { id: true, stateName: true } } } }, isSchool: true, isActive: true, createdAt: true, }, }); return results; } async searchCities(searchQuery: string) { if (!searchQuery || searchQuery.length < 2) { throw new ApiError( 400, 'Search query must be at least 2 characters long', ); } const results = await this.prisma.cities.findMany({ where: { cityName: { contains: searchQuery, mode: 'insensitive', }, isActive: true, deletedAt: null, }, select: { id: true, cityName: true, stateXid: true, }, orderBy: { cityName: 'asc', }, take: 50, // reduce latency by limiting results at DB level }); return results; } async addOrFindSchoolCompanyDetail(dto: AddSchoolCompanyDetailDTO) { const { schoolCompanyName, isSchool, cityXid, userId } = dto; const normalizedName = normalizeName(schoolCompanyName); // ✅ 1. Verify city exists const cityExists = await this.prisma.cities.findFirst({ where: { id: cityXid, isActive: true, deletedAt: null, }, }); if (!cityExists) { throw new ApiError(404, "City not found"); } // ✅ 2. Check existing (lowercase match) let schoolCompany = await this.prisma.schoolCompany.findFirst({ where: { schoolCompanyName: normalizedName, cityXid, isSchool, isActive: true, deletedAt: null, }, }); let isNewSchoolCompany = false; if (!schoolCompany) { schoolCompany = await this.prisma.schoolCompany.create({ data: { schoolCompanyName: normalizedName, isSchool, cityXid, }, }); isNewSchoolCompany = true; } // 4️⃣ Check if user already connected const existingConnection = await this.prisma.connectDetails.findFirst({ where: { userXid: userId, schoolCompanyXid: schoolCompany.id, isActive: true, }, }); if (existingConnection) { return { isNew: false, data: schoolCompany, message: "Already connected", }; } // 5️⃣ Create connectDetails safely await this.prisma.connectDetails.create({ data: { userXid: userId, schoolCompanyXid: schoolCompany.id, isActive: true, }, }); return true; } async getAllActivitiesFromConnectionsUserInterests( userId: number, schoolCompanyXids: number[], page: number, limit: number, countryName: string, stateName: string, cityName: string, ) { const data = await this.prisma.$transaction(async (tx) => { const userInterests = await tx.userInterests.findMany({ where: { userXid: userId, isActive: true, }, distinct: ['interestXid'], select: { interestXid: true, interest: { select: { id: true, interestName: true, interestColor: true, interestImage: true, displayOrder: true, } } } }); if (!userInterests.length) { return { interests: [], mostHypedActivities: null, newArrivalsActivities: null, otherStatesActivities: null, overSeasActivities: null, }; } const connectionUsers = await tx.connectDetails.findMany({ where: { isActive: true, schoolCompanyXid: { in: schoolCompanyXids }, userXid: { not: userId }, }, select: { userXid: true, }, }); const connectionUserIds = [ ...new Set(connectionUsers.map((u) => u.userXid)), ]; const connectionInterestByActivity = await tx.userBucketInterested.groupBy({ by: ["activityXid"], where: { userXid: { in: connectionUserIds }, isActive: true, }, _count: { activityXid: true, }, }); const connectionInterestMap = new Map( connectionInterestByActivity.map((item) => [ item.activityXid, item._count.activityXid, ]) ); if (!connectionUserIds.length) { return { interests: [], mostHypedActivities: null, newArrivalsActivities: null, otherStatesActivities: null, overSeasActivities: null, }; } const connectionActivities = await tx.userBucketInterested.findMany({ where: { userXid: { in: connectionUserIds }, isActive: true, }, select: { activityXid: true, }, }); const connectionActivityIds = [ ...new Set(connectionActivities.map((a) => a.activityXid)), ]; if (!connectionActivityIds.length) { return { interests: [], mostHypedActivities: null, newArrivalsActivities: null, otherStatesActivities: null, overSeasActivities: null, }; } const activityTypes = await tx.activityTypes.findMany({ where: { interestXid: { in: userInterests.map(i => i.interestXid), }, isActive: true, }, select: { id: true } }); if (!activityTypes.length) { return { interests: [], mostHypedActivities: null, newArrivalsActivities: null, otherStatesActivities: null, overSeasActivities: null, }; } const activityTypeIds = activityTypes.map((a) => a.id); const userAddressDetails = await tx.userAddressDetails.findFirst({ where: { userXid: userId }, select: { stateXid: true, cityXid: true, countryXid: true, locationLat: true, locationLong: true, }, }); const userLatitude = userAddressDetails?.locationLat ?? null; const userLongitude = userAddressDetails?.locationLong ?? null; let effectiveLocation: { countryXid?: number | null; stateXid?: number | null; cityXid?: number | null; } | null = null; if (countryName && stateName && cityName) { effectiveLocation = await findOrCreateLocation(tx, { countryName, stateName, cityName, }); } else if (userAddressDetails) { effectiveLocation = { countryXid: userAddressDetails.countryXid, stateXid: userAddressDetails.stateXid, cityXid: userAddressDetails.cityXid, }; } const skip = (page - 1) * limit; const effectiveCountryXid = effectiveLocation?.countryXid ?? null; const effectiveStateXid = effectiveLocation?.stateXid ?? null; const userBucketInterested = await tx.userBucketInterested.findMany({ where: { userXid: userId, isActive: true, }, select: { activityXid: true, isBucket: true, }, }); const userBucketActivityIds = userBucketInterested .filter(u => u.isBucket) .map(u => u.activityXid); const userInterestedActivityIds = userBucketInterested .filter(u => !u.isBucket) .map(u => u.activityXid); /* ===================================================== 1️⃣ FETCH ALL CANDIDATES FOR INTERESTS (SIMPLE SORT) ===================================================== */ // Reverted to simple ID based sorting for Interest-based activities const activities = await tx.activities.findMany({ where: { id: { in: connectionActivityIds }, // 🔥 NEW FILTER isActive: true, activityInternalStatus: ACTIVITY_INTERNAL_STATUS.ACTIVITY_LISTED, amInternalStatus: ACTIVITY_AM_INTERNAL_STATUS.ACTIVITY_LISTED, activityTypeXid: { in: activityTypeIds }, }, skip, take: limit, orderBy: { id: 'desc' }, select: { id: true, activityTitle: true, activityDurationMins: true, sustainabilityScore: true, checkInLat: true, checkInLong: true, activityType: { select: { interestXid: true, 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, Activities: { id: { in: connectionActivityIds }, activityTypeXid: { in: activityTypeIds }, isActive: true, activityInternalStatus: ACTIVITY_INTERNAL_STATUS.ACTIVITY_LISTED, amInternalStatus: ACTIVITY_AM_INTERNAL_STATUS.ACTIVITY_LISTED, } } }); const totalHypedActivities = mostHypedTotalCount.length; /* ===================================================== 2️⃣ MOST HYPED ACTIVITIES (RANKED) ===================================================== */ const mostHypedGrouped = await tx.userBucketInterested.groupBy({ by: ['activityXid'], where: { isActive: true, isBucket: false, Activities: { id: { in: connectionActivityIds }, activityTypeXid: { in: activityTypeIds }, isActive: true, activityInternalStatus: ACTIVITY_INTERNAL_STATUS.ACTIVITY_LISTED, amInternalStatus: ACTIVITY_AM_INTERNAL_STATUS.ACTIVITY_LISTED, } }, _count: { activityXid: true, }, orderBy: { _count: { activityXid: 'desc', }, }, skip, take: limit, }); const mostHypedActivityIds = mostHypedGrouped.map(a => a.activityXid); // Fetch metadata for ranking only for the top hyped ones (optimization: double sorting might be needed if we want to sort BY rating WITHIN the hyped list, but usually Hyped = Count. // IF user wants the standard 4-step ranking applied TO the most hyped items: const mostHypedActivitiesRaw = await tx.activities.findMany({ where: { id: { in: mostHypedActivityIds } }, select: { id: true, activityTitle: true, sustainabilityScore: true, totalScore: true, activityType: { select: { energyLevel: { select: { id: true, energyLevelName: true, energyColor: true, energyIcon: true, }, }, }, }, ActivitiesMedia: { where: { isActive: true }, select: { id: true, mediaFileName: true, mediaType: true, }, }, // Fetch ranking metadata ItineraryActivities: { select: { ActivityFeedbacks: { select: { activityStars: true }, }, }, }, ActivityVenues: { select: { ActivityPrices: { select: { sellPrice: true }, }, }, }, }, }); const hypeCountMap = new Map( mostHypedGrouped.map(g => [g.activityXid, g._count.activityXid]) ); // Sort Most Hyped by the 4 criteria const mostHypedSorted = mostHypedActivitiesRaw.map(act => { const feedbacks = act.ItineraryActivities.flatMap(ia => ia.ActivityFeedbacks); const totalStars = feedbacks.reduce((sum, f) => sum + f.activityStars, 0); const avgRating = feedbacks.length > 0 ? totalStars / feedbacks.length : 0; const prices = act.ActivityVenues.flatMap(v => v.ActivityPrices.map(p => p.sellPrice)).filter(p => p !== null) as number[]; const minPrice = prices.length > 0 ? Math.min(...prices) : Infinity; return { ...act, // Keep original fields for final output avgRating, minPrice, sustainabilityScore: act.sustainabilityScore ?? 0, totalScore: act.totalScore ?? 0, hypeCount: hypeCountMap.get(act.id) ?? 0 }; }).sort((a, b) => { // 1. Rating (Highest first) if (b.avgRating !== a.avgRating) return b.avgRating - a.avgRating; // 2. Price (Lowest first) if (a.minPrice !== b.minPrice) return a.minPrice - b.minPrice; // 3. Sustainability Score if (b.sustainabilityScore !== a.sustainabilityScore) return b.sustainabilityScore - a.sustainabilityScore; // 4. Quality Score return b.totalScore - a.totalScore; }); const mostHypedActivities = await Promise.all( mostHypedSorted.map(async activity => ({ activityId: activity.id, activityTitle: activity.activityTitle, connectionInterestedCount: connectionInterestMap.get(activity.id) ?? 0, distance: 0, rating: 0, hypeCount: activity.hypeCount, energyLevel: activity.activityType.energyLevel ? { ...activity.activityType.energyLevel, presignedUrl: await attachPresignedUrl( activity.activityType.energyLevel.energyIcon ), } : null, media: await attachMediaWithPresignedUrl(activity.ActivitiesMedia), })) ); const formattedMostHypedActivities = { page, limit, totalCount: totalHypedActivities, hasMore: skip + limit < totalHypedActivities, activities: mostHypedActivities, }; /* ===================================================== 3️⃣ NEW ARRIVALS (RANKED) ===================================================== */ const newArrivalsWhere = { id: { in: connectionActivityIds }, isActive: true, activityInternalStatus: ACTIVITY_INTERNAL_STATUS.ACTIVITY_LISTED, amInternalStatus: ACTIVITY_AM_INTERNAL_STATUS.ACTIVITY_LISTED, activityTypeXid: { in: activityTypeIds }, createdAt: { gte: new Date(Date.now() - 31 * 24 * 60 * 60 * 1000) } }; const formattedNewArrivalsActivities = await rankAndPaginateActivities(tx, newArrivalsWhere, page, limit, connectionInterestMap); /* ===================================================== 4️⃣ OTHER STATES ACTIVITIES (RANKED) ===================================================== */ const otherStatesWhere: any = { id: { in: connectionActivityIds }, isActive: true, activityInternalStatus: ACTIVITY_INTERNAL_STATUS.ACTIVITY_LISTED, amInternalStatus: ACTIVITY_AM_INTERNAL_STATUS.ACTIVITY_LISTED, activityTypeXid: { in: activityTypeIds }, }; if (effectiveCountryXid) { otherStatesWhere.checkInCountryXid = effectiveCountryXid; } if (effectiveStateXid) { otherStatesWhere.checkInStateXid = { not: effectiveStateXid }; } const formattedOtherStatesActivities = await rankAndPaginateActivities(tx, otherStatesWhere, page, limit, connectionInterestMap); /* ===================================================== 5️⃣ OVERSEAS ACTIVITIES (RANKED) ===================================================== */ const overseasWhere: any = { id: { in: connectionActivityIds }, isActive: true, activityInternalStatus: ACTIVITY_INTERNAL_STATUS.ACTIVITY_LISTED, amInternalStatus: ACTIVITY_AM_INTERNAL_STATUS.ACTIVITY_LISTED, activityTypeXid: { in: activityTypeIds }, }; if (effectiveCountryXid) { overseasWhere.checkInCountryXid = { not: effectiveCountryXid }; } const formattedOverSeasActivities = await rankAndPaginateActivities(tx, overseasWhere, page, limit, connectionInterestMap); 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; const distance = calculateDistance( userLatitude, userLongitude, activity.checkInLat, activity.checkInLong, ); return { interestXid: activity.activityType.interestXid, activityId: activity.id, activityTitle: activity.activityTitle, connectionInterestedCount: connectionInterestMap.get(activity.id) ?? 0, distance, rating: 0, activityDurationMins: activity.activityDurationMins, sustainabilityScore: activity.sustainabilityScore, cheapestPrice, energyLevel: activity.activityType.energyLevel ? { ...activity.activityType.energyLevel, presignedUrl: await attachPresignedUrl( activity.activityType.energyLevel.energyIcon ), } : null, media: await attachMediaWithPresignedUrl(activity.ActivitiesMedia), }; }) ); const interestsWithActivities = await Promise.all( userInterests .sort((a, b) => a.interest.interestName.localeCompare(b.interest.interestName) ) .map(async (ui) => ({ interestId: ui.interest.id, interestName: ui.interest.interestName, interestColor: ui.interest.interestColor, interestImage: ui.interest.interestImage, interestImagePresignedUrl: await attachPresignedUrl( 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), })) ); /* ===================================================== RANDOM ACTIVITIES FROM CONNECTION USERS (5 COVER IMAGES) ===================================================== */ let randomActivities: any[] = []; const eligibleRandomActivityIds = await tx.activities.findMany({ where: { id: { in: connectionActivityIds }, isActive: true, activityInternalStatus: ACTIVITY_INTERNAL_STATUS.ACTIVITY_LISTED, amInternalStatus: ACTIVITY_AM_INTERNAL_STATUS.ACTIVITY_LISTED, activityTypeXid: { in: activityTypeIds }, deletedAt: null, ActivitiesMedia: { some: { isActive: true, isCoverImage: true, }, }, }, select: { id: true, }, }); if (eligibleRandomActivityIds.length > 0) { const takeCount = Math.min(5, eligibleRandomActivityIds.length); const selectedIds = eligibleRandomActivityIds .sort(() => Math.random() - 0.5) .slice(0, takeCount) .map((activity) => activity.id); const randomFetched = await tx.activities.findMany({ where: { id: { in: selectedIds }, }, select: { id: true, activityTitle: true, ActivitiesMedia: { where: { isActive: true, isCoverImage: true }, orderBy: { displayOrder: 'asc' }, take: 1, select: { mediaFileName: true, }, }, }, }); randomActivities = await Promise.all( randomFetched .filter(Boolean) .map(async (activity) => { const cover = activity!.ActivitiesMedia?.[0]; return { activityId: activity!.id, activityTitle: activity!.activityTitle, coverImage: cover?.mediaFileName ?? null, coverImagePresignedUrl: cover?.mediaFileName ? await attachPresignedUrl(cover.mediaFileName) : null, }; }), ); } return { experiencesLogged: 25, citiesDiscovered: 10, loggedInNetworkCount: 0, citiesInNetworkCount: 0, randomActivities, interestedCount: userInterestedActivityIds.length, bucketCount: userBucketActivityIds.length, pagination: { page, limit, }, interests: interestsWithActivities, otherStatesActivities: formattedOtherStatesActivities, overSeasActivities: formattedOverSeasActivities, newArrivalsActivities: formattedNewArrivalsActivities, mostHypedActivities: formattedMostHypedActivities, }; }); return data; } async viewMoreActivitiesByInterest( interestId: number, page: number, limit: number ) { return await this.prisma.$transaction(async (tx) => { const skip = (page - 1) * limit; // 1️⃣ Get activity types under this interest const activityTypes = await tx.activityTypes.findMany({ where: { interestXid: interestId, isActive: true, }, select: { id: true }, }); if (!activityTypes.length) { return { interestId, page, limit, totalCount: 0, hasMore: false, activities: [], }; } // 2️⃣ Total Count const totalCount = await tx.activities.count({ where: { isActive: true, activityInternalStatus: ACTIVITY_INTERNAL_STATUS.ACTIVITY_LISTED, amInternalStatus: ACTIVITY_AM_INTERNAL_STATUS.ACTIVITY_LISTED, activityTypeXid: { in: activityTypes.map((a) => a.id), }, }, }); // 3️⃣ Fetch Paginated 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: activityTypes.map((a) => a.id), }, }, skip, take: limit, orderBy: { id: 'desc' }, select: { id: true, activityTitle: true, activityDurationMins: true, sustainabilityScore: true, totalScore: true, activityType: { select: { 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, }, }, }, }); // 4️⃣ Format Response 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 { activityId: activity.id, activityTitle: activity.activityTitle, activityDurationMins: activity.activityDurationMins, sustainabilityScore: activity.sustainabilityScore, cheapestPrice, energyLevel: activity.activityType.energyLevel ? { ...activity.activityType.energyLevel, presignedUrl: await attachPresignedUrl( activity.activityType.energyLevel.energyIcon ), } : null, media: await attachMediaWithPresignedUrl(activity.ActivitiesMedia), }; }) ); return { interestId, page, limit, totalCount, hasMore: skip + limit < totalCount, activities: formattedActivities, }; }); } async viewMoreActivities( userId: number, type: string, page: number, limit: number, countryName?: string, stateName?: string, cityName?: string, ) { return await this.prisma.$transaction(async (tx) => { const userAddressDetails = await tx.userAddressDetails.findFirst({ where: { userXid: userId }, select: { countryXid: true, stateXid: true, cityXid: true, }, }); let effectiveLocation = null; if (countryName && stateName && cityName) { effectiveLocation = await findOrCreateLocation(tx, { countryName, stateName, cityName, }); } else if (userAddressDetails) { effectiveLocation = userAddressDetails; } const effectiveCountryXid = effectiveLocation?.countryXid ?? null; const effectiveStateXid = effectiveLocation?.stateXid ?? null; const userConnectionDetails = await tx.connectDetails.findMany({ where: { userXid: userId, isActive: true }, select: { id: true, schoolCompanyXid: true, } }) const otherConnectionUsers = await tx.connectDetails.findMany({ where: { userXid: { notIn: [userId] }, isActive: true, schoolCompanyXid: { in: userConnectionDetails.map((u) => u.schoolCompanyXid) } }, select: { id: true, userXid: true, } }) const connectionUserIds = otherConnectionUsers.map(u => u.userXid); const connectionInterestByActivity = await tx.userBucketInterested.groupBy({ by: ['activityXid'], where: { userXid: { in: connectionUserIds }, isActive: true, }, _count: { activityXid: true, }, }); const connectionInterestMap = new Map( connectionInterestByActivity.map(item => [ item.activityXid, item._count.activityXid, ]) ); /* ======================================================= SWITCH BASED VIEW MORE TYPE ======================================================= */ switch (type) { /* ========================================== 1️⃣ MOST HYPED ========================================== */ case 'mostHyped': { const grouped = await tx.userBucketInterested.groupBy({ by: ['activityXid'], where: { isActive: true, isBucket: false, }, _count: { activityXid: true, }, }); const sortedIds = grouped .sort((a, b) => b._count.activityXid - a._count.activityXid) .map(g => g.activityXid); const where = { id: { in: sortedIds }, isActive: true, activityInternalStatus: ACTIVITY_INTERNAL_STATUS.ACTIVITY_LISTED, amInternalStatus: ACTIVITY_AM_INTERNAL_STATUS.ACTIVITY_LISTED, }; return await rankAndPaginateActivities(tx, where, page, limit, connectionInterestMap); } /* ========================================== 2️⃣ NEW ARRIVALS ========================================== */ case 'newArrivals': { const 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), }, }; return await rankAndPaginateActivities(tx, where, page, limit, connectionInterestMap); } /* ========================================== 3️⃣ OTHER STATES ========================================== */ case 'otherStates': { const where: any = { isActive: true, activityInternalStatus: ACTIVITY_INTERNAL_STATUS.ACTIVITY_LISTED, amInternalStatus: ACTIVITY_AM_INTERNAL_STATUS.ACTIVITY_LISTED, }; if (effectiveCountryXid) { where.checkInCountryXid = effectiveCountryXid; } if (effectiveStateXid) { where.checkInStateXid = { not: effectiveStateXid }; } return await rankAndPaginateActivities(tx, where, page, limit, connectionInterestMap); } /* ========================================== 4️⃣ OVERSEAS ========================================== */ case 'overSeas': { const where: any = { isActive: true, activityInternalStatus: ACTIVITY_INTERNAL_STATUS.ACTIVITY_LISTED, amInternalStatus: ACTIVITY_AM_INTERNAL_STATUS.ACTIVITY_LISTED, }; if (effectiveCountryXid) { where.checkInCountryXid = { not: effectiveCountryXid }; } return await rankAndPaginateActivities(tx, where, page, limit, connectionInterestMap); } default: throw new Error('Invalid type'); } }); } async getConnectionCountOfUser(userXid: number) { return await this.prisma.connectDetails.count({ where: { userXid, isActive: true, }, }); } async deleteConnectDetails(userXid: number, connectDetailsXid: number) { if (!connectDetailsXid || isNaN(connectDetailsXid)) { throw new ApiError(400, 'Invalid connection detail ID'); } const existing = await this.prisma.connectDetails.findFirst({ where: { id: connectDetailsXid, userXid, isActive: true, }, }); if (!existing) { throw new ApiError(404, 'Connection detail not found'); } await this.prisma.connectDetails.delete({ where: { id: connectDetailsXid } }); return true; } async getRandomActiveActivity() { return await this.prisma.$transaction(async (tx) => { // Get count of active activities const count = await tx.activities.count({ where: { isActive: true, activityInternalStatus: ACTIVITY_INTERNAL_STATUS.ACTIVITY_LISTED, amInternalStatus: ACTIVITY_AM_INTERNAL_STATUS.ACTIVITY_LISTED, deletedAt: null, }, }); if (count === 0) { return []; } // Determine how many activities to fetch (50 or less if count is smaller) const takeCount = Math.min(50, count); // Fetch random activities - using ORDER BY RANDOM() equivalent approach // Get all IDs first, shuffle, then take 50 const allActivityIds = await tx.activities.findMany({ where: { isActive: true, activityInternalStatus: ACTIVITY_INTERNAL_STATUS.ACTIVITY_LISTED, amInternalStatus: ACTIVITY_AM_INTERNAL_STATUS.ACTIVITY_LISTED, deletedAt: null, }, select: { id: true }, }); // Shuffle array and take first 50 const shuffled = allActivityIds.sort(() => Math.random() - 0.5); const selectedIds = shuffled.slice(0, takeCount).map(a => a.id); // Fetch activities with only activityTitle and ActivitiesMedia const activities = await tx.activities.findMany({ where: { id: { in: selectedIds }, isActive: true, activityInternalStatus: ACTIVITY_INTERNAL_STATUS.ACTIVITY_LISTED, amInternalStatus: ACTIVITY_AM_INTERNAL_STATUS.ACTIVITY_LISTED, deletedAt: null, }, select: { id: true, activityTitle: true, ActivitiesMedia: { where: { isActive: true, }, select: { id: true, mediaFileName: true, mediaType: true, }, orderBy: { displayOrder: 'asc', // Get the first image by display order }, take: 1, // Get only the first image }, }, }); // Process activities to attach presigned URLs and format response const result = await Promise.all( activities.map(async (activity) => { let activityImage = null; let activityImagePresignedUrl = null; // Get the first image and attach presigned URL if (Array.isArray(activity.ActivitiesMedia) && activity.ActivitiesMedia.length > 0) { const firstImage = activity.ActivitiesMedia[0]; activityImage = firstImage.mediaFileName; activityImagePresignedUrl = await attachPresignedUrl(firstImage.mediaFileName); } return { id: activity.id, activityName: activity.activityTitle, activityImage: activityImage, activityImagePresignedUrl: activityImagePresignedUrl, }; }) ); return result; }); } async getFiveRandomActivities() { return await this.prisma.$transaction(async (tx) => { const eligibleRandomActivityIds = await tx.activities.findMany({ where: { isActive: true, activityInternalStatus: ACTIVITY_INTERNAL_STATUS.ACTIVITY_LISTED, amInternalStatus: ACTIVITY_AM_INTERNAL_STATUS.ACTIVITY_LISTED, deletedAt: null, ActivitiesMedia: { some: { isActive: true, isCoverImage: true, }, }, }, select: { id: true, }, }); if (eligibleRandomActivityIds.length === 0) return []; const takeCount = Math.min(5, eligibleRandomActivityIds.length); const selectedIds = eligibleRandomActivityIds .sort(() => Math.random() - 0.5) .slice(0, takeCount) .map((activity) => activity.id); const activities = await tx.activities.findMany({ where: { id: { in: selectedIds }, }, select: { id: true, activityTitle: true, ActivitiesMedia: { where: { isActive: true, isCoverImage: true, }, orderBy: { displayOrder: 'asc', }, take: 1, select: { mediaFileName: true, }, }, }, }); // Step 4: Attach presigned URLs const result = await Promise.all( activities .filter(Boolean) .map(async (activity) => { const media = activity!.ActivitiesMedia?.[0]; let presignedUrl = null; if (media?.mediaFileName) { presignedUrl = await attachPresignedUrl(media.mediaFileName); } return { id: activity!.id, title: activity!.activityTitle, coverImage: media?.mediaFileName ?? null, coverImagePresignedUrl: presignedUrl, }; }) ); return result; }); } async addToBucketInterested( userXid: number, isBucket: boolean, bucketTypeName: string, activityXid: number ) { const activityExists = await this.prisma.activities.findFirst({ where: { id: activityXid, isActive: true }, }); if (!activityExists) { throw new ApiError(404, 'Activity not found'); } const existing = await this.prisma.userBucketInterested.findFirst({ where: { userXid, activityXid, isActive: true }, }); if (existing) { throw new ApiError(400, 'Activity already added'); } await this.prisma.userBucketInterested.create({ data: { userXid, activityXid, isBucket, bucketTypeName, }, }); const latestActivityImage = await this.prisma.activities.findFirst({ where: { id: activityXid, isActive: true, activityInternalStatus: ACTIVITY_INTERNAL_STATUS.ACTIVITY_LISTED, amInternalStatus: ACTIVITY_AM_INTERNAL_STATUS.ACTIVITY_LISTED, }, select: { ActivitiesMedia: { where: { isCoverImage: true }, select: { mediaFileName: true, } } } }) const coverImage = latestActivityImage?.ActivitiesMedia?.[0]?.mediaFileName ?? null; // Generate presigned URL const coverImagePresignedUrl = await attachPresignedUrl(coverImage); // ✅ Get updated counts const [bucketCount, interestedCount] = await Promise.all([ this.prisma.userBucketInterested.count({ where: { userXid, isBucket: true, isActive: true, }, }), this.prisma.userBucketInterested.count({ where: { userXid, isBucket: false, isActive: true, }, }), ]); return { bucketCount, interestedCount, coverImage, coverImagePresignedUrl, }; } async removeFromBucketInterested( userXid: number, isBucket: boolean, bucketTypeName: string, activityXid: number ) { const activityExists = await this.prisma.activities.findFirst({ where: { id: activityXid, isActive: true }, }); if (!activityExists) { throw new ApiError(404, 'Activity not found'); } const existing = await this.prisma.userBucketInterested.findFirst({ where: { userXid, activityXid, isActive: true }, }); if (!existing) { throw new ApiError(400, 'Activity not found in bucket/interested list'); } await this.prisma.userBucketInterested.update({ where: { id: existing.id }, data: { isActive: false, }, }); // Get updated counts const [bucketCount, interestedCount] = await Promise.all([ this.prisma.userBucketInterested.count({ where: { userXid, isBucket: true, isActive: true, }, }), this.prisma.userBucketInterested.count({ where: { userXid, isBucket: false, isActive: true, }, }), ]); return { bucketCount, interestedCount, }; } async getAllBucketActivities(userXid: number) { const bucketActivities = await this.prisma.userBucketInterested.findMany({ where: { userXid, isBucket: true, isActive: true, }, select: { id: true, bucketTypeName: true, activityXid: true, Activities: { select: { activityTitle: true, ActivitiesMedia: { where: { isCoverImage: true, isActive: true, }, select: { mediaFileName: true, }, }, }, }, }, }); const ready: any[] = []; const planning: any[] = []; const oneDay: any[] = []; for (const item of bucketActivities) { const media = item.Activities?.ActivitiesMedia?.[0]?.mediaFileName; let presignedUrl = null; if (media) { presignedUrl = await attachPresignedUrl(media); // your presigned url function } const activityData = { id: item.id, activityXid: item.activityXid, bucketTypeName: item.bucketTypeName, activityTitle: item.Activities?.activityTitle, coverImage: presignedUrl, }; if (item.bucketTypeName === 'Ready') { ready.push(activityData); } else if (item.bucketTypeName === 'Planning') { planning.push(activityData); } else if (item.bucketTypeName === 'One-day') { oneDay.push(activityData); } } return { ready, planning, oneDay, }; } }