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