Files
MinglarBackendNestJS/src/modules/user/services/user.service.ts

3984 lines
114 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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,
},
});
for (const interest of interests) {
if (interest.interestImage) {
const key = interest.interestImage.startsWith('http')
? new URL(interest.interestImage).pathname.replace(/^\/+/, '')
: interest.interestImage;
(interest as any).presignedUrl = await getPresignedUrl(bucket, key);
} else {
(interest as any).presignedUrl = null;
}
}
return interests;
}
async getUserByMobileNumber(mobileNumber: string): Promise<User | null> {
return this.prisma.user.findFirst({
where: { mobileNumber: mobileNumber, isActive: true },
});
}
async verifyHostOtp(mobileNumber: string, otp: string): Promise<boolean> {
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<User> {
// 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<boolean> {
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<void> {
// 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<UserAddressDetails> {
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 users 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 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)
// =====================================================
const totalActiveCount = await tx.activities.count({
where: {
isActive: true,
activityInternalStatus: ACTIVITY_INTERNAL_STATUS.ACTIVITY_LISTED,
amInternalStatus: ACTIVITY_AM_INTERNAL_STATUS.ACTIVITY_LISTED,
deletedAt: null,
id: {
notIn: allUserExcludedActivityIds.length
? allUserExcludedActivityIds
: [-1], // prevent empty notIn issue
},
},
});
let randomActivities: any[] = [];
if (totalActiveCount > 0) {
const takeCount = Math.min(5, totalActiveCount);
const randomOffsets = new Set<number>();
while (randomOffsets.size < takeCount) {
randomOffsets.add(Math.floor(Math.random() * totalActiveCount));
}
const randomFetched = await Promise.all(
Array.from(randomOffsets).map((offset) =>
tx.activities.findFirst({
skip: offset,
where: {
isActive: true,
activityInternalStatus:
ACTIVITY_INTERNAL_STATUS.ACTIVITY_LISTED,
amInternalStatus:
ACTIVITY_AM_INTERNAL_STATUS.ACTIVITY_LISTED,
deletedAt: null,
id: {
notIn: allUserExcludedActivityIds.length
? allUserExcludedActivityIds
: [-1], // prevent empty notIn issue
},
},
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,
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,
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) => ({
interestXid: a.activityType.interestXid,
activityId: a.id,
connectionInterestedCount:
connectionInterestMap.get(a.id) ?? 0,
activityTitle: a.activityTitle,
distance: calculateDistance(
userLat,
userLng,
a.checkInLat,
a.checkInLong
),
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 } },
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 {
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 } },
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 } },
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 } },
ActivitiesMedia: {
where: { isActive: true },
select: { id: true, mediaFileName: true, mediaType: true },
},
},
}),
]);
/* =====================================================
7⃣ FINAL RESPONSE
===================================================== */
return {
pagination: { page, limit },
interests: interestsWithActivities,
interestedCount: interestedActivityIds.length,
bucketCount: bucketActivityIds.length,
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) => ({
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) => ({
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) => ({
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,
},
},
select: {
user: {
select: {
profileImage: true,
},
},
},
});
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 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(
userId: number,
searchCriteria: {
activityTitle?: string;
activityType?: string;
checkInCity?: string;
},
) {
const { activityTitle, activityType, checkInCity } = searchCriteria;
// Build the where clause dynamically
const where: any = {
isActive: true,
activityInternalStatus: ACTIVITY_INTERNAL_STATUS.ACTIVITY_LISTED,
amInternalStatus: ACTIVITY_AM_INTERNAL_STATUS.ACTIVITY_LISTED,
};
// Add activityTitle filter if provided
if (activityTitle) {
where.activityTitle = {
contains: activityTitle,
mode: 'insensitive',
};
}
// Add activityType filter if provided
if (activityType) {
where.activityType = {
activityTypeName: {
contains: activityType,
mode: 'insensitive',
},
};
}
// Add checkInCity filter if provided
if (checkInCity) {
where.checkInCity = {
cityName: {
contains: checkInCity,
mode: 'insensitive',
},
};
}
const activities = await this.prisma.activities.findMany({
where,
select: {
id: true,
activityTitle: true,
activityDescription: true,
checkInAddress: true,
activityDurationMins: true,
sustainabilityScore: true,
activityRefNumber: true,
activityType: {
select: {
activityTypeName: true,
energyLevel: {
select: {
energyLevelName: true,
energyColor: true,
energyIcon: true,
},
},
},
},
checkInCity: {
select: {
cityName: true,
},
},
ActivitiesMedia: {
where: { isActive: true },
select: {
id: true,
mediaFileName: true,
mediaType: true,
},
take: 1, // Get first media item
},
},
take: 50, // Limit results to prevent too many
});
// Get interested count for each activity
const activitiesWithCounts = await Promise.all(
activities.map(async (activity) => {
const interestedCount = await this.prisma.userBucketInterested.count({
where: {
activityXid: activity.id,
isActive: true,
},
});
// Attach presigned URLs to media
const mediaWithUrls = await attachMediaWithPresignedUrl(
activity.ActivitiesMedia,
);
return {
...activity,
ActivitiesMediaPresignedUrl: mediaWithUrls,
interestedCount,
rating: 0, // Placeholder
distance: 0, // Placeholder
};
}),
);
return activitiesWithCounts;
}
async getNearbyActivities(
userId: number,
userLat: number,
userLong: number,
radiusKm: number,
page: number,
limit: number,
) {
if (userLat === undefined || userLong === undefined || radiusKm === undefined) {
throw new ApiError(
400,
'Latitude, longitude and radius are required to find nearby activities',
);
}
if (radiusKm <= 0) {
throw new ApiError(400, 'Radius must be greater than 0');
}
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<number, number>();
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 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),
}))
);
return {
experiencesLogged: 25,
citiesDiscovered: 10,
loggedInNetworkCount: 0,
citiesInNetworkCount: 0,
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) => {
// Step 1: Count eligible activities
const totalCount = await tx.activities.count({
where: {
isActive: true,
activityInternalStatus: ACTIVITY_INTERNAL_STATUS.ACTIVITY_LISTED,
amInternalStatus: ACTIVITY_AM_INTERNAL_STATUS.ACTIVITY_LISTED,
deletedAt: null,
},
});
if (totalCount === 0) return [];
// Step 2: Generate 5 unique random offsets
const takeCount = Math.min(5, totalCount);
const randomOffsets = new Set<number>();
while (randomOffsets.size < takeCount) {
randomOffsets.add(Math.floor(Math.random() * totalCount));
}
// Step 3: Fetch activities using skip (efficient for small limit like 5)
const activities = await Promise.all(
Array.from(randomOffsets).map((offset) =>
tx.activities.findFirst({
skip: offset,
where: {
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,
},
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 },
});
if (existing) {
throw new ApiError(400, 'Activity already added');
}
await this.prisma.userBucketInterested.create({
data: {
userXid,
activityXid,
isBucket,
bucketTypeName,
},
});
// ✅ 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 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,
};
}
}