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

2728 lines
78 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

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

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

import { Injectable } from '@nestjs/common';
import { PrismaClient, User, UserAddressDetails } from '@prisma/client';
import * as bcrypt from 'bcryptjs';
import { getPresignedUrl } from '../../../common/middlewares/aws/getPreSignedUrl';
import {
ACTIVITY_AM_INTERNAL_STATUS,
ACTIVITY_INTERNAL_STATUS,
} from '../../../common/utils/constants/host.constant';
import ApiError from '../../../common/utils/helper/ApiError';
import { UserPersonalInfoSchema } from '../../../common/utils/validation/user/addPersonalInfo.validation';
import { AddSchoolCompanyDetailDTO } from '../dto/addSchoolCompanyDetail.dto';
import config from '@/config/config';
// function deg2rad(deg) {
// return deg * (Math.PI / 180);
// }
// function getDistanceFromLatLon(userLat1, userLon1, activityLat2, activityLon2) {
// const R = 6371; // Earth radius in km
// const dLat = deg2rad(activityLat2 - userLat1);
// const dLon = deg2rad(activityLon2 - userLon1);
// const a =
// Math.sin(dLat / 2) * Math.sin(dLat / 2) +
// Math.cos(deg2rad(userLat1)) *
// Math.cos(deg2rad(activityLat2)) *
// Math.sin(dLon / 2) *
// Math.sin(dLon / 2);
// const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
// return R * c;
// }
const 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);
};
const bucket = config.aws.bucketName;
/* =====================================================
HELPER: RANK & PAGINATE ACTIVITIES
===================================================== */
async function rankAndPaginateActivities(
tx: any,
whereClause: any,
page: number,
limit: number,
) {
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,
// 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 {
page,
limit,
totalCount,
hasMore: skip + limit < totalCount,
activities: formattedActivities,
};
}
@Injectable()
export class UserService {
constructor(private prisma: PrismaClient) { }
async getUserById(userId: number) {
return this.prisma.user.findUnique({
where: { id: userId, isActive: true },
});
}
async addPersonalInfo(userId: number, data: UserPersonalInfoSchema) {
return await this.prisma.$transaction(async (tx) => {
const updatedUser = await tx.user.update({
where: { id: userId },
data: {
firstName: data.firstName,
lastName: data.lastName ?? null,
genderName: data.genderName,
dateOfBirth: data.dateOfBirth ? new Date(data.dateOfBirth) : null,
isProfileUpdated: true,
},
});
return updatedUser;
});
}
async getAllInterestDetails() {
const interests = await this.prisma.interests.findMany({
where: { isActive: true },
select: {
id: true,
interestName: true,
interestColor: true,
interestImage: true,
displayOrder: true,
},
});
for (const interest of interests) {
if (interest.interestImage) {
const key = interest.interestImage.startsWith('http')
? new URL(interest.interestImage).pathname.replace(/^\/+/, '')
: interest.interestImage;
(interest as any).presignedUrl = await getPresignedUrl(bucket, key);
} else {
(interest as any).presignedUrl = null;
}
}
return interests;
}
async getUserByMobileNumber(mobileNumber: string): Promise<User | null> {
return this.prisma.user.findFirst({
where: { mobileNumber: mobileNumber, isActive: true },
});
}
async verifyHostOtp(mobileNumber: string, otp: string): Promise<boolean> {
const user = await this.prisma.user.findFirst({
where: { mobileNumber: mobileNumber, isActive: true },
select: {
id: true,
mobileNumber: true,
UserOtp: {
where: { isActive: true, isVerified: false },
orderBy: { createdAt: 'desc' },
take: 1,
},
},
});
if (!user) {
throw new ApiError(404, 'User not found.');
}
const userOtp = user.UserOtp[0];
if (!userOtp) {
throw new ApiError(400, 'No OTP found.');
}
if (new Date() > userOtp.expiresOn) {
throw new ApiError(400, 'OTP has expired.');
}
const isMatch = await bcrypt.compare(otp, userOtp.otpCode);
if (!isMatch) {
throw new ApiError(400, 'Invalid OTP.');
}
await this.prisma.userOtp.update({
where: { id: userOtp.id },
data: {
isVerified: true,
verifiedOn: new Date(),
isActive: false,
},
});
return true;
}
async setUserPasscode(userId: number, userPasscode: string): Promise<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,
},
});
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 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: activitiyTypesOfUserInterests.map((at) => at.id),
},
},
skip,
take: limit,
orderBy: { id: 'desc' },
select: {
id: true,
activityTitle: true,
activityDurationMins: true,
sustainabilityScore: true,
checkInLat: true,
checkInLong: true,
activityType: {
select: {
interestXid: true,
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,
},
});
/* =====================================================
2⃣ MOST HYPED ACTIVITIES (RANKED)
===================================================== */
const mostHypedGrouped = await tx.userBucketInterested.groupBy({
by: ['activityXid'],
where: {
isActive: true,
isBucket: false,
},
_count: {
activityXid: true,
},
orderBy: {
_count: {
activityXid: 'desc',
},
},
skip,
take: limit,
});
const totalHypedActivities = mostHypedTotalCount.length;
const mostHypedActivityIds = mostHypedGrouped.map((a) => a.activityXid);
// 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 },
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,
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 = {
isActive: true,
activityInternalStatus: ACTIVITY_INTERNAL_STATUS.ACTIVITY_LISTED,
amInternalStatus: ACTIVITY_AM_INTERNAL_STATUS.ACTIVITY_LISTED,
createdAt: { gte: new Date(Date.now() - 31 * 24 * 60 * 60 * 1000) },
};
const formattedNewArrivalsActivities = await rankAndPaginateActivities(
tx,
newArrivalsWhere,
page,
limit,
);
/* =====================================================
4⃣ OTHER STATES ACTIVITIES (RANKED)
===================================================== */
const otherStatesWhere: any = {
isActive: true,
activityInternalStatus: ACTIVITY_INTERNAL_STATUS.ACTIVITY_LISTED,
amInternalStatus: ACTIVITY_AM_INTERNAL_STATUS.ACTIVITY_LISTED,
};
if (effectiveCountryXid) {
otherStatesWhere.checkInCountryXid = effectiveCountryXid;
}
if (effectiveStateXid) {
otherStatesWhere.checkInStateXid = { not: effectiveStateXid };
}
const formattedOtherStatesActivities = await rankAndPaginateActivities(
tx,
otherStatesWhere,
page,
limit,
);
/* =====================================================
5⃣ OVERSEAS ACTIVITIES (RANKED)
===================================================== */
const overseasWhere: any = {
isActive: true,
activityInternalStatus: ACTIVITY_INTERNAL_STATUS.ACTIVITY_LISTED,
amInternalStatus: ACTIVITY_AM_INTERNAL_STATUS.ACTIVITY_LISTED,
};
if (effectiveCountryXid) {
overseasWhere.checkInCountryXid = { not: effectiveCountryXid };
}
const formattedOverSeasActivities = await rankAndPaginateActivities(
tx,
overseasWhere,
page,
limit,
);
const formattedActivities = await Promise.all(
activities.map(async (activity) => {
const cheapestPrice =
activity.ActivityVenues.flatMap((v) => v.ActivityPrices)
.map((p) => p.sellPrice)
.filter(Boolean)
.sort((a, b) => a - b)[0] ?? null;
return {
interestXid: activity.activityType.interestXid,
activityId: activity.id,
activityTitle: activity.activityTitle,
activityDurationMins: activity.activityDurationMins,
sustainabilityScore: activity.sustainabilityScore,
cheapestPrice,
energyLevel: activity.activityType.energyLevel
? {
...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: 25,
citiesDiscovered: 10,
loggedInNetworkCount: 0,
citiesInNetworkCount: 0,
pagination: {
page,
limit,
},
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,
},
});
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;
/* =====================================================
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,
...excludeUserInterestCondition,
},
skip,
take: limit,
orderBy: { id: 'desc' },
select: {
id: true,
activityTitle: true,
activityType: {
select: {
interestXid: true,
energyLevel: true,
},
},
ActivitiesMedia: {
where: { isActive: true },
select: { id: true, mediaFileName: true, mediaType: true },
},
},
});
const formattedOtherInterestActivities = await Promise.all(
otherInterestActivities.map(async (a) => ({
interestXid: a.activityType.interestXid,
activityId: a.id,
activityTitle: a.activityTitle,
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) } },
select: {
id: true,
activityTitle: true,
activityType: { select: { energyLevel: true } },
ActivitiesMedia: {
where: { isActive: true },
select: { id: true, mediaFileName: true, mediaType: true },
},
},
});
const mostHypedActivities = await Promise.all(
mostHypedGrouped.map(async (g) => {
const act = hypedActivities.find((a) => a.id === g.activityXid);
if (!act) return null;
return {
activityId: act.id,
activityTitle: act.activityTitle,
hypeCount: g._count.activityXid,
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 = {
isActive: true,
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: {
activityTitle: true,
activityType: { select: { energyLevel: true } },
ActivitiesMedia: {
where: { isActive: true },
select: { id: true, mediaFileName: true, mediaType: true },
},
},
});
/* =====================================================
6⃣ OTHER STATES & OVERSEAS
===================================================== */
const otherStatesWhere: any = {
isActive: true,
...excludeUserInterestCondition,
};
if (effectiveCountryXid)
otherStatesWhere.checkInCountryXid = effectiveCountryXid;
if (effectiveStateXid)
otherStatesWhere.checkInStateXid = { not: effectiveStateXid };
const overseasWhere: any = {
isActive: true,
...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: {
activityTitle: true,
activityType: { select: { energyLevel: true } },
ActivitiesMedia: {
where: { isActive: true },
select: { id: true, mediaFileName: true, mediaType: true },
},
},
}),
tx.activities.findMany({
where: overseasWhere,
skip,
take: limit,
select: {
activityTitle: true,
activityType: { select: { energyLevel: true } },
ActivitiesMedia: {
where: { isActive: true },
select: { id: true, mediaFileName: true, mediaType: true },
},
},
}),
]);
/* =====================================================
7⃣ FINAL RESPONSE
===================================================== */
return {
pagination: { page, limit },
interests: interestsWithActivities,
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) => ({
activityTitle: a.activityTitle,
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) => ({
activityTitle: a.activityTitle,
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) => ({
activityTitle: a.activityTitle,
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,
activityType: {
select: {
interestXid: true, // ✅ VERY IMPORTANT
activityTypeName: true,
energyLevel: {
select: {
id: true,
energyLevelName: true,
energyColor: true,
energyIcon: true,
},
},
},
},
ActivityOtherDetails: {
where: { isActive: true },
select: {
id: true,
exclusiveNotes: true,
dosNotes: true,
dontsNotes: true,
tipsNotes: true,
termsAndCondition: true,
},
},
ActivityEligibility: {
where: { isActive: true },
select: {
id: true,
isAgeRestriction: true,
ageRestrictionName: true,
ageEntered: true,
ageIn: true,
minAge: true,
maxAge: true,
isWeightRestriction: true,
weightRestrictionName: true,
weightEntered: true,
weightIn: true,
minWeight: true,
maxWeight: true,
isHeightRestriction: true,
heightRestrictionName: true,
heightEntered: true,
heightIn: true,
minHeight: true,
maxHeight: true,
},
},
ActivityTrainers: {
where: { isActive: true },
select: {
id: true,
totalAmount: true,
},
},
ActivityAllowedEntry: {
where: { isActive: true },
select: {
id: true,
allowedEntryTypeXid: true,
allowedEntryType: {
select: {
id: true,
allowedEntryTypeName: true,
},
},
},
},
ActivityFoodCost: {
where: { isActive: true },
select: {
id: true,
totalAmount: true,
},
},
activityFoodTypes: {
where: { isActive: true },
select: {
id: true,
foodTypeXid: true,
foodType: {
select: {
id: true,
foodTypeName: true,
},
},
},
},
ActivityEquipments: {
where: { isActive: true },
select: {
id: true,
equipmentName: true,
isEquipmentChargeable: true,
equipmentTotalPrice: true,
},
},
ActivityNavigationModes: {
where: { isActive: true },
select: {
id: true,
navigationModeXid: true,
navigationMode: {
select: {
id: true,
navigationModeName: true,
navigationModeIcon: true,
},
},
isInActivityChargeable: true,
navigationModesTotalPrice: true,
},
},
ActivityAmenities: {
where: { isActive: true },
select: {
id: true,
amenitiesXid: true,
amenities: {
select: {
id: true,
amenitiesName: true,
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,
},
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,
},
},
ActivityPrices: {
select: {
id: true,
sellPrice: true,
},
},
},
},
ActivitiesMedia: {
where: { isActive: true },
select: {
id: true,
mediaFileName: true,
mediaType: true,
},
},
},
});
// ================= 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,
);
}
}),
);
}
const interestedCount = await tx.userBucketInterested.count({
where: {
activityXid,
isActive: true,
},
});
const prices = activity.ActivityVenues.flatMap((v) =>
v.ActivityPrices.map((p) => p.sellPrice),
).filter((p) => p !== null) as number[];
const cheapestPrice = prices.length > 0 ? Math.min(...prices) : null;
const totalCapacity = activity.ActivityVenues.map(
(v) => v.venueCapacity ?? 0,
).reduce((sum, capacity) => sum + capacity, 0);
return {
activity,
interestedCount,
cheapestPrice,
totalCapacity,
rating: 0, // ⭐ Placeholder, implement rating logic as needed
distance: 0,
};
});
}
async searchActivities(
userId: number,
searchCriteria: {
activityTitle?: string;
activityType?: string;
checkInCity?: string;
},
) {
const { activityTitle, activityType, checkInCity } = searchCriteria;
// Build the where clause dynamically
const where: any = {
isActive: true,
activityInternalStatus: ACTIVITY_INTERNAL_STATUS.ACTIVITY_LISTED,
amInternalStatus: ACTIVITY_AM_INTERNAL_STATUS.ACTIVITY_LISTED,
};
// Add activityTitle filter if provided
if (activityTitle) {
where.activityTitle = {
contains: activityTitle,
mode: 'insensitive',
};
}
// Add activityType filter if provided
if (activityType) {
where.activityType = {
activityTypeName: {
contains: activityType,
mode: 'insensitive',
},
};
}
// Add checkInCity filter if provided
if (checkInCity) {
where.checkInCity = {
cityName: {
contains: checkInCity,
mode: 'insensitive',
},
};
}
const activities = await this.prisma.activities.findMany({
where,
select: {
id: true,
activityTitle: true,
activityDescription: true,
checkInAddress: true,
activityDurationMins: true,
sustainabilityScore: true,
activityRefNumber: true,
activityType: {
select: {
activityTypeName: true,
energyLevel: {
select: {
energyLevelName: true,
energyColor: true,
energyIcon: true,
},
},
},
},
checkInCity: {
select: {
cityName: true,
},
},
ActivitiesMedia: {
where: { isActive: true },
select: {
id: true,
mediaFileName: true,
mediaType: true,
},
take: 1, // Get first media item
},
},
take: 50, // Limit results to prevent too many
});
// Get interested count for each activity
const activitiesWithCounts = await Promise.all(
activities.map(async (activity) => {
const interestedCount = await this.prisma.userBucketInterested.count({
where: {
activityXid: activity.id,
isActive: true,
},
});
// Attach presigned URLs to media
const mediaWithUrls = await attachMediaWithPresignedUrl(
activity.ActivitiesMedia,
);
return {
...activity,
ActivitiesMediaPresignedUrl: mediaWithUrls,
interestedCount,
rating: 0, // Placeholder
distance: 0, // Placeholder
};
}),
);
return activitiesWithCounts;
}
// CONNECTIONS
async getAllConnectionDetailsOfUser(userXid: number) {
return await this.prisma.connectDetails.findMany({
where: { userXid, isActive: true },
select: {
id: true,
schoolCompany: {
select: {
id: true,
schoolCompanyName: true,
isSchool: true,
cityXid: true,
cities: {
select: {
id: true,
cityName: true,
stateXid: true,
states: {
select: {
id: true,
stateName: true
}
}
}
}
},
},
},
});
}
async searchSchoolsAndCompanies(searchQuery: string, isSchool: boolean) {
if (!searchQuery) {
throw new ApiError(
400,
'Search query is required to search for schools or companies',
);
}
const results = await this.prisma.schoolCompany.findMany({
where: {
schoolCompanyName: {
contains: searchQuery,
mode: 'insensitive',
},
isSchool: isSchool,
isActive: true,
deletedAt: null,
},
select: {
id: true,
schoolCompanyName: true,
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,
isActive: true,
createdAt: true,
},
});
return results;
}
async addOrFindSchoolCompanyDetail(dto: AddSchoolCompanyDetailDTO) {
const { schoolCompanyName, isSchool, cityXid, userId } = dto;
const normalizedName = normalizeName(schoolCompanyName);
// ✅ 1. Verify city exists
const cityExists = await this.prisma.cities.findFirst({
where: {
id: cityXid,
isActive: true,
deletedAt: null,
},
});
if (!cityExists) {
throw new ApiError(404, "City not found");
}
// ✅ 2. Check existing (lowercase match)
let schoolCompany = await this.prisma.schoolCompany.findFirst({
where: {
schoolCompanyName: normalizedName,
cityXid,
isSchool,
isActive: true,
deletedAt: null,
},
});
let isNewSchoolCompany = false;
if (!schoolCompany) {
schoolCompany = await this.prisma.schoolCompany.create({
data: {
schoolCompanyName: normalizedName,
isSchool,
cityXid,
},
});
isNewSchoolCompany = true;
}
// 4⃣ Check if user already connected
const existingConnection = await this.prisma.connectDetails.findFirst({
where: {
userXid: userId,
schoolCompanyXid: schoolCompany.id,
isActive: true,
},
});
if (existingConnection) {
return {
isNew: false,
data: schoolCompany,
message: "Already connected",
};
}
// 5⃣ Create connectDetails safely
await this.prisma.connectDetails.create({
data: {
userXid: userId,
schoolCompanyXid: schoolCompany.id,
isActive: true,
},
});
return true;
}
async getAllActivitiesFromConnectionsUserInterests(
userId: number,
schoolCompanyXids: number[],
page: number,
limit: number,
countryName: string,
stateName: string,
cityName: string,
) {
const data = await this.prisma.$transaction(async (tx) => {
const networkUsers = await tx.connectDetails.findMany({
where: {
isActive: true,
schoolCompanyXid: {
in: schoolCompanyXids,
},
userXid: {
not: userId,
},
},
select: {
userXid: true,
},
});
if (!networkUsers.length) {
return {
interests: [],
mostHypedActivities: null,
newArrivalsActivities: null,
otherStatesActivities: null,
overSeasActivities: null,
};
}
const networkUserIds = [...new Set(networkUsers.map(u => u.userXid))];
const networkUserInterests = await tx.userInterests.findMany({
where: {
userXid: { in: networkUserIds },
isActive: true,
},
distinct: ['interestXid'],
select: {
interestXid: true,
interest: {
select: {
id: true,
interestName: true,
interestColor: true,
interestImage: true,
displayOrder: true,
}
}
}
});
const distinctInterests = networkUserInterests.map(i => i.interestXid);
if (!distinctInterests.length) {
return {
interests: [],
mostHypedActivities: null,
newArrivalsActivities: null,
otherStatesActivities: null,
overSeasActivities: null,
};
}
const activityTypes = await tx.activityTypes.findMany({
where: {
interestXid: {
in: distinctInterests,
},
isActive: true,
},
select: { id: true }
});
if (!activityTypes.length) {
return {
interests: [],
mostHypedActivities: null,
newArrivalsActivities: null,
otherStatesActivities: null,
overSeasActivities: null,
};
}
const userAddressDetails = await tx.userAddressDetails.findFirst({
where: { userXid: userId },
select: {
stateXid: true,
cityXid: true,
countryXid: true,
},
});
let effectiveLocation: {
countryXid?: number | null;
stateXid?: number | null;
cityXid?: number | null;
} | null = null;
if (countryName && stateName && cityName) {
effectiveLocation = await findOrCreateLocation(tx, {
countryName,
stateName,
cityName,
});
} else if (userAddressDetails) {
effectiveLocation = {
countryXid: userAddressDetails.countryXid,
stateXid: userAddressDetails.stateXid,
cityXid: userAddressDetails.cityXid,
};
}
const skip = (page - 1) * limit;
const effectiveCountryXid = effectiveLocation?.countryXid ?? null;
const effectiveStateXid = effectiveLocation?.stateXid ?? null;
/* =====================================================
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: activityTypes.map(at => at.id),
},
},
skip,
take: limit,
orderBy: { id: 'desc' },
select: {
id: true,
activityTitle: true,
activityDurationMins: true,
sustainabilityScore: true,
checkInLat: true,
checkInLong: true,
activityType: {
select: {
interestXid: true,
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 activityTypeIds = activityTypes.map(a => a.id);
const mostHypedTotalCount = await tx.userBucketInterested.groupBy({
by: ['activityXid'],
where: {
isActive: true,
isBucket: false,
Activities: {
activityTypeXid: { in: activityTypeIds },
isActive: true,
activityInternalStatus: ACTIVITY_INTERNAL_STATUS.ACTIVITY_LISTED,
amInternalStatus: ACTIVITY_AM_INTERNAL_STATUS.ACTIVITY_LISTED,
}
}
});
const totalHypedActivities = mostHypedTotalCount.length;
/* =====================================================
2⃣ MOST HYPED ACTIVITIES (RANKED)
===================================================== */
const mostHypedGrouped = await tx.userBucketInterested.groupBy({
by: ['activityXid'],
where: {
isActive: true,
isBucket: false,
Activities: {
activityTypeXid: { in: activityTypeIds },
isActive: true,
activityInternalStatus: ACTIVITY_INTERNAL_STATUS.ACTIVITY_LISTED,
amInternalStatus: ACTIVITY_AM_INTERNAL_STATUS.ACTIVITY_LISTED,
}
},
_count: {
activityXid: true,
},
orderBy: {
_count: {
activityXid: 'desc',
},
},
skip,
take: limit,
});
const mostHypedActivityIds = mostHypedGrouped.map(a => a.activityXid);
// Fetch metadata for ranking only for the top hyped ones (optimization: double sorting might be needed if we want to sort BY rating WITHIN the hyped list, but usually Hyped = Count.
// IF user wants the standard 4-step ranking applied TO the most hyped items:
const mostHypedActivitiesRaw = await tx.activities.findMany({
where: {
id: { in: mostHypedActivityIds }
},
select: {
id: true,
activityTitle: true,
sustainabilityScore: true,
totalScore: true,
activityType: {
select: {
energyLevel: {
select: {
id: true,
energyLevelName: true,
energyColor: true,
energyIcon: true,
},
},
},
},
ActivitiesMedia: {
where: { isActive: true },
select: {
id: true,
mediaFileName: true,
mediaType: true,
},
},
// Fetch ranking metadata
ItineraryActivities: {
select: {
ActivityFeedbacks: {
select: { activityStars: true },
},
},
},
ActivityVenues: {
select: {
ActivityPrices: {
select: { sellPrice: true },
},
},
},
},
});
const hypeCountMap = new Map(
mostHypedGrouped.map(g => [g.activityXid, g._count.activityXid])
);
// Sort Most Hyped by the 4 criteria
const mostHypedSorted = mostHypedActivitiesRaw.map(act => {
const feedbacks = act.ItineraryActivities.flatMap(ia => ia.ActivityFeedbacks);
const totalStars = feedbacks.reduce((sum, f) => sum + f.activityStars, 0);
const avgRating = feedbacks.length > 0 ? totalStars / feedbacks.length : 0;
const prices = act.ActivityVenues.flatMap(v => v.ActivityPrices.map(p => p.sellPrice)).filter(p => p !== null) as number[];
const minPrice = prices.length > 0 ? Math.min(...prices) : Infinity;
return {
...act, // Keep original fields for final output
avgRating,
minPrice,
sustainabilityScore: act.sustainabilityScore ?? 0,
totalScore: act.totalScore ?? 0,
hypeCount: hypeCountMap.get(act.id) ?? 0
};
}).sort((a, b) => {
// 1. Rating (Highest first)
if (b.avgRating !== a.avgRating) return b.avgRating - a.avgRating;
// 2. Price (Lowest first)
if (a.minPrice !== b.minPrice) return a.minPrice - b.minPrice;
// 3. Sustainability Score
if (b.sustainabilityScore !== a.sustainabilityScore) return b.sustainabilityScore - a.sustainabilityScore;
// 4. Quality Score
return b.totalScore - a.totalScore;
});
const mostHypedActivities = await Promise.all(
mostHypedSorted.map(async activity => ({
activityId: activity.id,
activityTitle: activity.activityTitle,
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 = {
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);
/* =====================================================
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: activityTypeIds },
};
if (effectiveCountryXid) {
otherStatesWhere.checkInCountryXid = effectiveCountryXid;
}
if (effectiveStateXid) {
otherStatesWhere.checkInStateXid = { not: effectiveStateXid };
}
const formattedOtherStatesActivities = await rankAndPaginateActivities(tx, otherStatesWhere, page, limit);
/* =====================================================
5⃣ OVERSEAS ACTIVITIES (RANKED)
===================================================== */
const overseasWhere: any = {
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);
const formattedActivities = await Promise.all(
activities.map(async (activity) => {
const cheapestPrice =
activity.ActivityVenues.flatMap(v => v.ActivityPrices)
.map(p => p.sellPrice)
.filter(Boolean)
.sort((a, b) => a - b)[0] ?? null;
return {
interestXid: activity.activityType.interestXid,
activityId: activity.id,
activityTitle: activity.activityTitle,
activityDurationMins: activity.activityDurationMins,
sustainabilityScore: activity.sustainabilityScore,
cheapestPrice,
energyLevel: activity.activityType.energyLevel
? {
...activity.activityType.energyLevel,
presignedUrl: await attachPresignedUrl(
activity.activityType.energyLevel.energyIcon
),
}
: null,
media: await attachMediaWithPresignedUrl(activity.ActivitiesMedia),
};
})
);
const interestsWithActivities = await Promise.all(
networkUserInterests
.sort((a, b) =>
a.interest.interestName.localeCompare(b.interest.interestName)
)
.map(async (ui) => ({
interestId: ui.interest.id,
interestName: ui.interest.interestName,
interestColor: ui.interest.interestColor,
interestImage: ui.interest.interestImage,
interestImagePresignedUrl: await attachPresignedUrl(
ui.interest.interestImage
),
displayOrder: ui.interest.displayOrder,
page,
limit,
hasMore: formattedActivities.length === limit,
activities: formattedActivities
.filter(a => a.interestXid === ui.interestXid)
.map(({ interestXid, ...rest }) => rest),
}))
);
return {
experiencesLogged: 25,
citiesDiscovered: 10,
loggedInNetworkCount: 0,
citiesInNetworkCount: 0,
pagination: {
page,
limit,
},
interests: interestsWithActivities,
otherStatesActivities: formattedOtherStatesActivities,
overSeasActivities: formattedOverSeasActivities,
newArrivalsActivities: formattedNewArrivalsActivities,
mostHypedActivities: formattedMostHypedActivities,
};
});
return data;
}
async viewMoreActivitiesByInterest(
userId: number,
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 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;
});
}
}