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

4297 lines
124 KiB
TypeScript
Raw Normal View History

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';
2026-02-09 15:50:56 +05:30
import ApiError from '../../../common/utils/helper/ApiError';
import { UserPersonalInfoSchema } from '../../../common/utils/validation/user/addPersonalInfo.validation';
2026-02-19 16:25:43 +05:30
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;
// }
2026-03-05 18:57:58 +05:30
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
? {
2026-02-19 20:05:33 +05:30
...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 {
2026-02-19 20:05:33 +05:30
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<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,
},
});
2026-03-05 18:57:58 +05:30
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 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
? {
2026-02-19 20:05:33 +05:30
...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;
2026-03-05 18:57:58 +05:30
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,
2026-03-05 18:57:58 +05:30
distance,
rating: 0,
energyLevel: activity.activityType.energyLevel
? {
2026-02-19 20:05:33 +05:30
...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,
2026-03-05 18:57:58 +05:30
locationLat: true,
locationLong: true,
},
});
2026-03-05 18:57:58 +05:30
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,
2026-03-05 18:57:58 +05:30
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,
2026-03-05 18:57:58 +05:30
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,
2026-03-05 18:57:58 +05:30
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,
2026-03-05 18:57:58 +05:30
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)
===================================================== */
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: safeExcludedIds,
},
...excludeUserInterestCondition,
},
});
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: safeExcludedIds,
},
...excludeUserInterestCondition,
},
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,
2026-03-09 15:18:59 +05:30
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,
2026-03-09 15:18:59 +05:30
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,
2026-03-09 15:18:59 +05:30
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,
2026-03-04 12:23:29 +05:30
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
};
});
}
2026-02-19 20:05:33 +05:30
async searchActivities(
activityType?: string
) {
// Build the where clause dynamically
const where: any = {
isActive: true,
activityInternalStatus: ACTIVITY_INTERNAL_STATUS.ACTIVITY_LISTED,
amInternalStatus: ACTIVITY_AM_INTERNAL_STATUS.ACTIVITY_LISTED,
...(activityType && {
activityType: {
is: {
activityTypeName: {
contains: activityType,
mode: 'insensitive',
},
},
},
}),
};
// Add activityType filter if provided
if (activityType) {
where.activityType = {
is: {
activityTypeName: {
contains: activityType,
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: {
id: true,
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);
2026-02-25 14:48:54 +05:30
// 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,
2026-02-25 14:48:54 +05:30
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,
};
}
2026-02-19 16:25:43 +05:30
// CONNECTIONS
2026-02-19 16:25:43 +05:30
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,
2026-02-19 20:05:33 +05:30
cityXid: true,
cities: {
select: {
id: true,
cityName: true,
stateXid: true,
states: {
select: {
id: true,
stateName: true
}
}
}
}
2026-02-19 16:25:43 +05:30
},
},
},
});
}
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,
2026-03-06 15:49:25 +05:30
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;
}
2026-02-19 16:25:43 +05:30
2026-02-19 20:05:33 +05:30
2026-02-19 16:25:43 +05:30
async addOrFindSchoolCompanyDetail(dto: AddSchoolCompanyDetailDTO) {
const { schoolCompanyName, isSchool, cityXid, userId } = dto;
2026-02-19 20:05:33 +05:30
const normalizedName = normalizeName(schoolCompanyName);
2026-02-19 20:05:33 +05:30
// ✅ 1. Verify city exists
2026-02-19 16:25:43 +05:30
const cityExists = await this.prisma.cities.findFirst({
where: {
id: cityXid,
isActive: true,
deletedAt: null,
},
});
2026-02-19 20:05:33 +05:30
2026-02-19 16:25:43 +05:30
if (!cityExists) {
throw new ApiError(404, "City not found");
2026-02-19 16:25:43 +05:30
}
2026-02-19 20:05:33 +05:30
// ✅ 2. Check existing (lowercase match)
let schoolCompany = await this.prisma.schoolCompany.findFirst({
2026-02-19 16:25:43 +05:30
where: {
schoolCompanyName: normalizedName,
cityXid,
isSchool,
2026-02-19 16:25:43 +05:30
isActive: true,
deletedAt: null,
},
});
2026-02-19 20:05:33 +05:30
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) {
2026-02-19 16:25:43 +05:30
return {
isNew: false,
data: schoolCompany,
message: "Already connected",
2026-02-19 16:25:43 +05:30
};
}
2026-02-19 20:05:33 +05:30
// 5⃣ Create connectDetails safely
await this.prisma.connectDetails.create({
2026-02-19 16:25:43 +05:30
data: {
userXid: userId,
schoolCompanyXid: schoolCompany.id,
isActive: true,
2026-02-19 16:25:43 +05:30
},
});
2026-02-19 20:05:33 +05:30
return true;
2026-02-19 16:25:43 +05:30
}
2026-02-19 20:05:33 +05:30
async getAllActivitiesFromConnectionsUserInterests(
userId: number,
schoolCompanyXids: number[],
page: number,
limit: number,
countryName: string,
stateName: string,
cityName: string,
) {
const data = await this.prisma.$transaction(async (tx) => {
2026-02-19 17:52:43 +05:30
const userInterests = await tx.userInterests.findMany({
2026-02-19 20:05:33 +05:30
where: {
userXid: userId,
2026-02-19 20:05:33 +05:30
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 },
2026-02-19 20:05:33 +05:30
},
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) {
2026-02-19 20:05:33 +05:30
return {
interests: [],
mostHypedActivities: null,
newArrivalsActivities: null,
otherStatesActivities: null,
overSeasActivities: null,
};
}
const connectionActivities = await tx.userBucketInterested.findMany({
2026-02-19 20:05:33 +05:30
where: {
userXid: { in: connectionUserIds },
2026-02-19 20:05:33 +05:30
isActive: true,
},
select: {
activityXid: true,
},
2026-02-19 20:05:33 +05:30
});
const connectionActivityIds = [
...new Set(connectionActivities.map((a) => a.activityXid)),
];
if (!connectionActivityIds.length) {
2026-02-19 20:05:33 +05:30
return {
interests: [],
mostHypedActivities: null,
newArrivalsActivities: null,
otherStatesActivities: null,
overSeasActivities: null,
};
}
2026-02-19 20:05:33 +05:30
const activityTypes = await tx.activityTypes.findMany({
where: {
interestXid: {
in: userInterests.map(i => i.interestXid),
2026-02-19 20:05:33 +05:30
},
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);
2026-02-19 20:05:33 +05:30
const userAddressDetails = await tx.userAddressDetails.findFirst({
where: { userXid: userId },
select: {
stateXid: true,
cityXid: true,
countryXid: true,
locationLat: true,
locationLong: true,
2026-02-19 20:05:33 +05:30
},
});
const userLatitude = userAddressDetails?.locationLat ?? null;
const userLongitude = userAddressDetails?.locationLong ?? null;
2026-02-19 20:05:33 +05:30
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;
2026-02-19 20:05:33 +05:30
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);
2026-02-19 20:05:33 +05:30
/* =====================================================
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
2026-02-19 20:05:33 +05:30
isActive: true,
activityInternalStatus: ACTIVITY_INTERNAL_STATUS.ACTIVITY_LISTED,
amInternalStatus: ACTIVITY_AM_INTERNAL_STATUS.ACTIVITY_LISTED,
activityTypeXid: { in: activityTypeIds },
2026-02-19 20:05:33 +05:30
},
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,
},
2026-02-19 20:05:33 +05:30
},
},
},
ActivityVenues: {
select: {
ActivityPrices: {
select: {
2026-02-19 20:05:33 +05:30
sellPrice: true,
},
2026-02-19 20:05:33 +05:30
},
},
},
ActivitiesMedia: {
where: { isActive: true },
select: {
id: true,
mediaFileName: true,
mediaType: true,
},
},
},
});
2026-02-19 20:05:33 +05:30
const mostHypedTotalCount = await tx.userBucketInterested.groupBy({
by: ['activityXid'],
where: {
isActive: true,
isBucket: false,
Activities: {
id: { in: connectionActivityIds },
2026-02-19 20:05:33 +05:30
activityTypeXid: { in: activityTypeIds },
isActive: true,
activityInternalStatus: ACTIVITY_INTERNAL_STATUS.ACTIVITY_LISTED,
amInternalStatus: ACTIVITY_AM_INTERNAL_STATUS.ACTIVITY_LISTED,
}
}
});
2026-02-19 20:05:33 +05:30
const totalHypedActivities = mostHypedTotalCount.length;
2026-02-19 20:05:33 +05:30
/* =====================================================
2 MOST HYPED ACTIVITIES (RANKED)
===================================================== */
const mostHypedGrouped = await tx.userBucketInterested.groupBy({
by: ['activityXid'],
where: {
isActive: true,
isBucket: false,
Activities: {
id: { in: connectionActivityIds },
2026-02-19 20:05:33 +05:30
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,
});
2026-02-19 20:05:33 +05:30
const mostHypedActivityIds = mostHypedGrouped.map(a => a.activityXid);
2026-02-19 20:05:33 +05:30
// 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,
},
2026-02-19 20:05:33 +05:30
},
},
},
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,
2026-02-19 20:05:33 +05:30
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 },
2026-02-19 20:05:33 +05:30
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);
2026-02-19 20:05:33 +05:30
/* =====================================================
4 OTHER STATES ACTIVITIES (RANKED)
===================================================== */
const otherStatesWhere: any = {
id: { in: connectionActivityIds },
2026-02-19 20:05:33 +05:30
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);
2026-02-19 20:05:33 +05:30
/* =====================================================
5 OVERSEAS ACTIVITIES (RANKED)
===================================================== */
const overseasWhere: any = {
id: { in: connectionActivityIds },
2026-02-19 20:05:33 +05:30
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);
2026-02-19 20:05:33 +05:30
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,
);
2026-02-19 20:05:33 +05:30
return {
interestXid: activity.activityType.interestXid,
activityId: activity.id,
activityTitle: activity.activityTitle,
connectionInterestedCount:
connectionInterestMap.get(activity.id) ?? 0,
distance,
rating: 0,
2026-02-19 20:05:33 +05:30
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
2026-02-19 20:05:33 +05:30
.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)
===================================================== */
2026-02-19 20:05:33 +05:30
const totalActiveCount = await tx.activities.count({
where: {
id: { in: connectionActivityIds },
isActive: true,
activityInternalStatus: ACTIVITY_INTERNAL_STATUS.ACTIVITY_LISTED,
amInternalStatus: ACTIVITY_AM_INTERNAL_STATUS.ACTIVITY_LISTED,
activityTypeXid: { in: activityTypeIds },
deletedAt: null,
},
});
let randomActivities: any[] = [];
2026-02-19 20:05:33 +05:30
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: {
id: { in: connectionActivityIds },
isActive: true,
activityInternalStatus: ACTIVITY_INTERNAL_STATUS.ACTIVITY_LISTED,
amInternalStatus: ACTIVITY_AM_INTERNAL_STATUS.ACTIVITY_LISTED,
activityTypeXid: { in: activityTypeIds },
deletedAt: null,
},
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,
};
}),
);
}
2026-02-19 20:05:33 +05:30
return {
experiencesLogged: 25,
citiesDiscovered: 10,
loggedInNetworkCount: 0,
citiesInNetworkCount: 0,
randomActivities,
interestedCount: userInterestedActivityIds.length,
bucketCount: userBucketActivityIds.length,
2026-02-19 20:05:33 +05:30
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,
},
});
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,
};
}
2026-03-06 15:49:25 +05:30
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,
};
}
}