Files
MinglarBackendNestJS/src/modules/user/services/user.service.ts
2026-02-13 17:51:53 +05:30

1630 lines
58 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

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

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

import { Injectable } from '@nestjs/common';
import { PrismaClient, User, UserAddressDetails } from '@prisma/client';
import * as bcrypt from 'bcryptjs';
import { getPresignedUrl } from '../../../common/middlewares/aws/getPreSignedUrl';
import { ACTIVITY_AM_INTERNAL_STATUS, ACTIVITY_INTERNAL_STATUS } from '../../../common/utils/constants/host.constant';
import ApiError from '../../../common/utils/helper/ApiError';
import { UserPersonalInfoSchema } from '../../../common/utils/validation/user/addPersonalInfo.validation';
import 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;
// }
async function findOrCreateLocation(
tx: any,
{
countryName,
stateName,
cityName,
}: {
countryName: string;
stateName: string;
cityName: string;
}
) {
/* ---------------------------
1⃣ COUNTRY
----------------------------*/
let country = await tx.countries.findFirst({
where: {
countryName: {
equals: countryName,
mode: 'insensitive',
},
isActive: true,
},
});
if (!country) {
country = await tx.countries.create({
data: {
countryName: countryName.trim(),
countryCode: countryName.slice(0, 3).toUpperCase(), // optional
countryFlag: '',
isActive: true,
},
});
}
/* ---------------------------
2⃣ STATE
----------------------------*/
let state = await tx.states.findFirst({
where: {
stateName: {
equals: stateName,
mode: 'insensitive',
},
countryXid: country.id,
isActive: true,
},
});
if (!state) {
state = await tx.states.create({
data: {
stateName: stateName.trim(),
countryXid: country.id,
isActive: true,
},
});
}
/* ---------------------------
3⃣ CITY
----------------------------*/
let city = await tx.cities.findFirst({
where: {
cityName: {
equals: cityName,
mode: 'insensitive',
},
stateXid: state.id,
isActive: true,
},
});
if (!city) {
city = await tx.cities.create({
data: {
cityName: cityName.trim(),
stateXid: state.id,
isActive: true,
},
});
}
return {
countryXid: country.id,
stateXid: state.id,
cityXid: city.id,
};
}
const attachMediaWithPresignedUrl = async (mediaArr = []) => {
return (
await Promise.all(
mediaArr.map(async (m) => {
if (!m?.mediaFileName) return null;
const key = m.mediaFileName.startsWith('http')
? new URL(m.mediaFileName).pathname.replace(/^\/+/, '')
: m.mediaFileName;
return {
id: m.id,
mediaType: m.mediaType,
mediaFileName: m.mediaFileName,
presignedUrl: await getPresignedUrl(bucket, key),
};
})
)
).filter(Boolean);
};
const bucket = config.aws.bucketName;
/* =====================================================
HELPER: RANK & PAGINATE ACTIVITIES
===================================================== */
async function rankAndPaginateActivities(
tx: any,
whereClause: any,
page: number,
limit: number
) {
const skip = (page - 1) * limit;
// 1⃣ Fetch Metadata for ALL matching activities for in-memory sorting
const allCandidates = await tx.activities.findMany({
where: whereClause,
select: {
id: true,
sustainabilityScore: true,
totalScore: true, // Quality Score
ItineraryActivities: {
select: {
ActivityFeedbacks: {
select: { activityStars: true },
},
},
},
ActivityVenues: {
select: {
ActivityPrices: {
select: { sellPrice: true },
},
},
},
},
});
const totalCount = allCandidates.length;
// 2⃣ Calculate Metrics & Sort
const sortedCandidates = allCandidates.map((act: any) => {
// Flatten feedbacks
const feedbacks = act.ItineraryActivities.flatMap((ia: any) => ia.ActivityFeedbacks);
// Avg Rating
const totalStars = feedbacks.reduce((sum: number, f: any) => sum + f.activityStars, 0);
const avgRating = feedbacks.length > 0 ? totalStars / feedbacks.length : 0;
// Min Price
const prices = act.ActivityVenues.flatMap((v: any) => v.ActivityPrices.map((p: any) => p.sellPrice)).filter((p: any) => p !== null) as number[];
const minPrice = prices.length > 0 ? Math.min(...prices) : Infinity;
return {
id: act.id,
avgRating,
minPrice,
sustainabilityScore: act.sustainabilityScore ?? 0,
totalScore: act.totalScore ?? 0,
};
}).sort((a: any, b: any) => {
// 1. Rating (Highest first)
if (b.avgRating !== a.avgRating) return b.avgRating - a.avgRating;
// 2. Price (Lowest first)
if (a.minPrice !== b.minPrice) return a.minPrice - b.minPrice;
// 3. Sustainability Score (Highest first)
if (b.sustainabilityScore !== a.sustainabilityScore) return b.sustainabilityScore - a.sustainabilityScore;
// 4. Quality Score (Highest first)
return b.totalScore - a.totalScore;
});
// 3⃣ Paginate IDs
const paginatedCandidates = sortedCandidates.slice(skip, skip + limit);
const targetIds = paginatedCandidates.map((c: any) => c.id);
// 4⃣ Fetch Full Details for the page
const activitiesUnsorted = await tx.activities.findMany({
where: { id: { in: targetIds } },
select: {
id: true,
activityTitle: true,
activityDurationMins: true,
sustainabilityScore: true,
activityType: {
select: {
interestXid: true,
energyLevel: {
select: {
id: true,
energyLevelName: true,
energyColor: true,
energyIcon: true,
},
},
},
},
ActivityVenues: {
select: {
ActivityPrices: {
select: { sellPrice: true },
},
},
},
ActivitiesMedia: {
where: { isActive: true },
select: {
id: true,
mediaFileName: true,
mediaType: true,
},
},
},
});
// Re-sort to match the calculated order
const activities = targetIds
.map((id: number) => activitiesUnsorted.find((a: any) => a.id === id))
.filter(Boolean);
// 5⃣ Format Response
const formattedActivities = await Promise.all(
activities.map(async (activity: any) => {
const prices = activity.ActivityVenues.flatMap((v: any) => v.ActivityPrices.map((p: any) => p.sellPrice)).filter((p: any) => p !== null) as number[];
const cheapestPrice = prices.length > 0 ? Math.min(...prices) : null;
return {
// interestXid: activity.activityType.interestXid,
activityId: activity.id,
activityTitle: activity.activityTitle,
// activityDurationMins: activity.activityDurationMins,
// sustainabilityScore: activity.sustainabilityScore,
// cheapestPrice,
energyLevel: activity.activityType.energyLevel,
media: await attachMediaWithPresignedUrl(activity.ActivitiesMedia),
};
})
);
return {
page,
limit,
totalCount,
hasMore: skip + limit < totalCount,
activities: formattedActivities,
};
}
@Injectable()
export class UserService {
constructor(private prisma: PrismaClient) { }
async getUserById(userId: number) {
return this.prisma.user.findUnique({
where: { id: userId, isActive: true },
});
}
async addPersonalInfo(userId: number, data: UserPersonalInfoSchema) {
return await this.prisma.$transaction(async (tx) => {
const updatedUser = await tx.user.update({
where: { id: userId },
data: {
firstName: data.firstName,
lastName: data.lastName ?? null,
genderName: data.genderName,
dateOfBirth: data.dateOfBirth
? new Date(data.dateOfBirth)
: null,
isProfileUpdated: true,
},
});
return updatedUser;
});
}
async getAllInterestDetails() {
const interests = await this.prisma.interests.findMany({
where: { isActive: true },
select: {
id: true,
interestName: true,
interestColor: true,
interestImage: true,
displayOrder: true
}
})
for (const interest of interests) {
if (interest.interestImage) {
const key = interest.interestImage.startsWith('http')
? new URL(interest.interestImage).pathname.replace(/^\/+/, '')
: interest.interestImage;
(interest as any).presignedUrl = await getPresignedUrl(bucket, key);
} else {
(interest as any).presignedUrl = null;
}
}
return interests;
}
async getUserByMobileNumber(mobileNumber: string): Promise<User | null> {
return this.prisma.user.findFirst({
where: { mobileNumber: mobileNumber, isActive: true },
});
}
async verifyHostOtp(mobileNumber: string, otp: string): Promise<boolean> {
const user = await this.prisma.user.findFirst({
where: { mobileNumber: mobileNumber, isActive: true },
select: {
id: true,
mobileNumber: true,
UserOtp: {
where: { isActive: true, isVerified: false },
orderBy: { createdAt: 'desc' },
take: 1,
},
},
});
if (!user) {
throw new ApiError(404, 'User not found.');
}
const userOtp = user.UserOtp[0];
if (!userOtp) {
throw new ApiError(400, 'No OTP found.');
}
if (new Date() > userOtp.expiresOn) {
throw new ApiError(400, 'OTP has expired.');
}
const isMatch = await bcrypt.compare(otp, userOtp.otpCode);
if (!isMatch) {
throw new ApiError(400, 'Invalid OTP.');
}
await this.prisma.userOtp.update({
where: { id: userOtp.id },
data: {
isVerified: true,
verifiedOn: new Date(),
isActive: false,
},
});
return true;
}
async setUserPasscode(userId: number, userPasscode: string): Promise<User> {
// Validate passcode format (6 digits)
if (!userPasscode || userPasscode.length !== 6 || !/^\d{6}$/.test(userPasscode)) {
throw new ApiError(400, 'Passcode must be exactly 6 digits');
}
// Hash the passcode
const hashedPasscode = await bcrypt.hash(userPasscode, 10);
// Update user with passcode
const updatedUser = await this.prisma.user.update({
where: { id: userId },
data: {
userPasscode: hashedPasscode,
},
});
if (!updatedUser) {
throw new ApiError(400, 'Failed to set passcode');
}
return updatedUser;
}
async verifyUserPasscode(userId: number, passcode: string): Promise<boolean> {
const user = await this.prisma.user.findUnique({
where: { id: userId, isActive: true },
select: { userPasscode: true },
});
if (!user || !user.userPasscode) {
throw new ApiError(404, 'User passcode not found');
}
const isMatch = await bcrypt.compare(passcode, user.userPasscode);
if (!isMatch) {
return false;
}
return true;
}
async setUserInterests(userId: number, interest_Xid: number[]): Promise<void> {
// Remove existing interests
await this.prisma.userInterests.deleteMany({
where: { userXid: userId },
});
// Add new interests
const interestRecords = interest_Xid.map((interestId) => ({
userXid: userId,
interestXid: interestId,
}));
await this.prisma.userInterests.createMany({
data: interestRecords,
});
}
async setUserLocationDetails(
userId: number,
countryName: string,
stateName: string,
cityName: string,
pinCode: string,
latitude?: number,
longitude?: number,
locationName?: string,
locationAddress?: string
): Promise<UserAddressDetails> {
return this.prisma.$transaction(async (tx) => {
// 1⃣ Country: find or create
let country = await tx.countries.findUnique({
where: { countryName },
select: { id: true },
});
if (!country) {
country = await tx.countries.create({
data: {
countryName,
countryCode: countryName.slice(0, 3).toUpperCase(),
countryFlag: '',
},
select: { id: true },
});
}
// 2⃣ State: find or create (GLOBAL UNIQUE)
let state = await tx.states.findFirst({
where: { stateName, countryXid: country.id },
select: { id: true },
});
if (!state) {
state = await tx.states.create({
data: {
stateName,
countryXid: country.id,
},
select: { id: true },
});
}
// 3⃣ City: find or create (GLOBAL UNIQUE)
let city = await tx.cities.findFirst({
where: { cityName, stateXid: state.id },
select: { id: true },
});
if (!city) {
city = await tx.cities.create({
data: {
cityName,
stateXid: state.id,
},
select: { id: true },
});
}
return tx.userAddressDetails.create({
data: {
user: { connect: { id: userId } },
country: { connect: { id: country.id } },
states: { connect: { id: state.id } },
cities: { connect: { id: city.id } },
address1: locationAddress ?? '',
pinCode,
locationName: locationName ?? null,
locationAddress: locationAddress ?? null,
locationLat: latitude ?? null,
locationLong: longitude ?? null,
},
});
});
}
async getLandingPageAllDetails(
userId: number,
page: number,
limit: number,
countryName: string,
stateName: string,
cityName: string,
userLat: string,
userLong: string
) {
const data = await this.prisma.$transaction(async (tx) => {
const userAddressDetails = await tx.userAddressDetails.findFirst({
where: { userXid: userId },
select: {
id: true,
address1: true,
address2: true,
pinCode: true,
locationName: true,
stateXid: true,
cityXid: true,
countryXid: true,
locationLat: true,
locationLong: true,
}
})
let effectiveLocation: {
countryXid?: number | null;
stateXid?: number | null;
cityXid?: number | null;
} | null = null;
const hasRequestLocation = countryName && stateName && cityName;
if (hasRequestLocation) {
// ✅ Create/find ONLY if request location is sent
effectiveLocation = await findOrCreateLocation(tx, {
countryName: countryName!,
stateName: stateName!,
cityName: cityName!,
});
} else if (userAddressDetails) {
// ✅ Fallback to users saved address
effectiveLocation = {
countryXid: userAddressDetails.countryXid,
stateXid: userAddressDetails.stateXid,
cityXid: userAddressDetails.cityXid,
};
}
const effectiveCountryXid = effectiveLocation?.countryXid ?? null;
const effectiveStateXid = effectiveLocation?.stateXid ?? null;
const userInterests = await tx.userInterests.findMany({
where: { userXid: userId, isActive: true },
select: {
id: true,
interestXid: true,
interest: {
select: {
id: true,
interestName: true,
interestColor: true,
interestImage: true,
displayOrder: true
}
},
}
})
if (!userInterests.length) {
return {
userAddressDetails,
interests: [],
otherStatesActivities: null,
overSeasActivities: null,
};
}
const activitiyTypesOfUserInterests = await tx.activityTypes.findMany({
where: { interestXid: { in: userInterests.map(ui => ui.interestXid) }, isActive: true },
select: {
id: true
}
})
if (!activitiyTypesOfUserInterests.length) {
return {
userAddressDetails,
interests: [],
otherStatesActivities: null,
overSeasActivities: null,
};
}
const skip = (page - 1) * limit;
/* =====================================================
1⃣ FETCH ALL CANDIDATES FOR INTERESTS (SIMPLE SORT)
===================================================== */
// Reverted to simple ID based sorting for Interest-based activities
const activities = await tx.activities.findMany({
where: {
isActive: true,
activityInternalStatus: ACTIVITY_INTERNAL_STATUS.ACTIVITY_LISTED,
amInternalStatus: ACTIVITY_AM_INTERNAL_STATUS.ACTIVITY_LISTED,
activityTypeXid: {
in: activitiyTypesOfUserInterests.map(at => at.id),
},
},
skip,
take: limit,
orderBy: { id: 'desc' },
select: {
id: true,
activityTitle: true,
activityDurationMins: true,
sustainabilityScore: true,
checkInLat: true,
checkInLong: true,
activityType: {
select: {
interestXid: true,
energyLevel: {
select: {
id: true,
energyLevelName: true,
energyColor: true,
energyIcon: true,
},
},
},
},
ActivityVenues: {
select: {
ActivityPrices: {
select: {
sellPrice: true,
},
},
},
},
ActivitiesMedia: {
where: { isActive: true },
select: {
id: true,
mediaFileName: true,
mediaType: true,
},
},
},
});
const mostHypedTotalCount = await tx.userBucketInterested.groupBy({
by: ['activityXid'],
where: {
isActive: true,
isBucket: false,
},
});
/* =====================================================
2⃣ MOST HYPED ACTIVITIES (RANKED)
===================================================== */
const mostHypedGrouped = await tx.userBucketInterested.groupBy({
by: ['activityXid'],
where: {
isActive: true,
isBucket: false
},
_count: {
activityXid: true,
},
orderBy: {
_count: {
activityXid: 'desc',
},
},
skip,
take: limit,
});
const totalHypedActivities = mostHypedTotalCount.length;
const mostHypedActivityIds = mostHypedGrouped.map(a => a.activityXid);
// Fetch metadata for ranking only for the top hyped ones (optimization: double sorting might be needed if we want to sort BY rating WITHIN the hyped list, but usually Hyped = Count.
// IF user wants the standard 4-step ranking applied TO the most hyped items:
const mostHypedActivitiesRaw = await tx.activities.findMany({
where: {
id: { in: mostHypedActivityIds },
isActive: true,
activityInternalStatus: ACTIVITY_INTERNAL_STATUS.ACTIVITY_LISTED,
amInternalStatus: ACTIVITY_AM_INTERNAL_STATUS.ACTIVITY_LISTED,
},
select: {
id: true,
activityTitle: true,
sustainabilityScore: true,
totalScore: true,
activityType: {
select: {
energyLevel: {
select: {
id: true,
energyLevelName: true,
energyColor: true,
energyIcon: true,
},
},
},
},
ActivitiesMedia: {
where: { isActive: true },
select: {
id: true,
mediaFileName: true,
mediaType: true,
},
},
// Fetch ranking metadata
ItineraryActivities: {
select: {
ActivityFeedbacks: {
select: { activityStars: true },
},
},
},
ActivityVenues: {
select: {
ActivityPrices: {
select: { sellPrice: true },
},
},
},
},
});
// Sort Most Hyped by the 4 criteria
const mostHypedSorted = mostHypedActivitiesRaw.map(act => {
const feedbacks = act.ItineraryActivities.flatMap(ia => ia.ActivityFeedbacks);
const totalStars = feedbacks.reduce((sum, f) => sum + f.activityStars, 0);
const avgRating = feedbacks.length > 0 ? totalStars / feedbacks.length : 0;
const prices = act.ActivityVenues.flatMap(v => v.ActivityPrices.map(p => p.sellPrice)).filter(p => p !== null) as number[];
const minPrice = prices.length > 0 ? Math.min(...prices) : Infinity;
return {
...act, // Keep original fields for final output
avgRating,
minPrice,
sustainabilityScore: act.sustainabilityScore ?? 0,
totalScore: act.totalScore ?? 0,
hypeCount: mostHypedGrouped.find(g => g.activityXid === act.id)?._count.activityXid ?? 0
};
}).sort((a, b) => {
// 1. Rating (Highest first)
if (b.avgRating !== a.avgRating) return b.avgRating - a.avgRating;
// 2. Price (Lowest first)
if (a.minPrice !== b.minPrice) return a.minPrice - b.minPrice;
// 3. Sustainability Score
if (b.sustainabilityScore !== a.sustainabilityScore) return b.sustainabilityScore - a.sustainabilityScore;
// 4. Quality Score
return b.totalScore - a.totalScore;
});
const mostHypedActivities = await Promise.all(
mostHypedSorted.map(async activity => ({
activityId: activity.id,
activityTitle: activity.activityTitle,
hypeCount: activity.hypeCount,
energyLevel: activity.activityType.energyLevel,
media: await attachMediaWithPresignedUrl(activity.ActivitiesMedia),
}))
);
const formattedMostHypedActivities = {
page,
limit,
totalCount: totalHypedActivities,
hasMore: skip + limit < totalHypedActivities,
activities: mostHypedActivities,
};
/* =====================================================
3⃣ NEW ARRIVALS (RANKED)
===================================================== */
const newArrivalsWhere = {
isActive: true,
activityInternalStatus: ACTIVITY_INTERNAL_STATUS.ACTIVITY_LISTED,
amInternalStatus: ACTIVITY_AM_INTERNAL_STATUS.ACTIVITY_LISTED,
createdAt: { gte: new Date(Date.now() - 31 * 24 * 60 * 60 * 1000) }
};
const formattedNewArrivalsActivities = await rankAndPaginateActivities(tx, newArrivalsWhere, page, limit);
/* =====================================================
4⃣ OTHER STATES ACTIVITIES (RANKED)
===================================================== */
const otherStatesWhere: any = {
isActive: true,
activityInternalStatus: ACTIVITY_INTERNAL_STATUS.ACTIVITY_LISTED,
amInternalStatus: ACTIVITY_AM_INTERNAL_STATUS.ACTIVITY_LISTED,
};
if (effectiveCountryXid) {
otherStatesWhere.checkInCountryXid = effectiveCountryXid;
}
if (effectiveStateXid) {
otherStatesWhere.checkInStateXid = { not: effectiveStateXid };
}
const formattedOtherStatesActivities = await rankAndPaginateActivities(tx, otherStatesWhere, page, limit);
/* =====================================================
5⃣ OVERSEAS ACTIVITIES (RANKED)
===================================================== */
const overseasWhere: any = {
isActive: true,
activityInternalStatus: ACTIVITY_INTERNAL_STATUS.ACTIVITY_LISTED,
amInternalStatus: ACTIVITY_AM_INTERNAL_STATUS.ACTIVITY_LISTED,
};
if (effectiveCountryXid) {
overseasWhere.checkInCountryXid = { not: effectiveCountryXid };
}
const formattedOverSeasActivities = await rankAndPaginateActivities(tx, overseasWhere, page, limit);
const formattedActivities = await Promise.all(
activities.map(async (activity) => {
const cheapestPrice =
activity.ActivityVenues.flatMap(v => v.ActivityPrices)
.map(p => p.sellPrice)
.filter(Boolean)
.sort((a, b) => a - b)[0] ?? null;
return {
interestXid: activity.activityType.interestXid,
activityId: activity.id,
activityTitle: activity.activityTitle,
activityDurationMins: activity.activityDurationMins,
sustainabilityScore: activity.sustainabilityScore,
cheapestPrice,
energyLevel: activity.activityType.energyLevel,
media: await attachMediaWithPresignedUrl(activity.ActivitiesMedia),
};
})
);
const interestsWithActivities = [...userInterests]
.sort((a, b) =>
a.interest.interestName.localeCompare(b.interest.interestName)
)
.map(ui => ({
interestId: ui.interest.id,
interestName: ui.interest.interestName,
interestColor: ui.interest.interestColor,
interestImage: ui.interest.interestImage,
displayOrder: ui.interest.displayOrder,
page,
limit,
hasMore: formattedActivities.length === limit,
activities: formattedActivities
.filter(a => a.interestXid === ui.interestXid)
.map(({ interestXid, ...rest }) => rest),
}));
return {
userAddressDetails,
experiencesLogged: 25,
citiesDiscovered: 10,
loggedInNetworkCount: 0,
citiesInNetworkCount: 0,
pagination: {
page,
limit,
},
interests: interestsWithActivities,
otherStatesActivities: formattedOtherStatesActivities,
overSeasActivities: formattedOverSeasActivities,
newArrivalsActivities: formattedNewArrivalsActivities,
mostHypedActivities: formattedMostHypedActivities,
};
})
return data;
}
async getSurpriseMeDetails(
userId: number,
page: number,
limit: number,
countryName: string,
stateName: string,
cityName: string
) {
const data = await this.prisma.$transaction(async (tx) => {
/* =====================================================
1⃣ USER LOCATION
===================================================== */
const userAddressDetails = await tx.userAddressDetails.findFirst({
where: { userXid: userId },
select: {
stateXid: true,
cityXid: true,
countryXid: true,
},
});
let effectiveLocation: {
countryXid?: number | null;
stateXid?: number | null;
cityXid?: number | null;
} | null = null;
if (countryName && stateName && cityName) {
effectiveLocation = await findOrCreateLocation(tx, {
countryName,
stateName,
cityName,
});
} else if (userAddressDetails) {
effectiveLocation = {
countryXid: userAddressDetails.countryXid,
stateXid: userAddressDetails.stateXid,
cityXid: userAddressDetails.cityXid,
};
}
const effectiveCountryXid = effectiveLocation?.countryXid ?? null;
const effectiveStateXid = effectiveLocation?.stateXid ?? null;
/* =====================================================
2⃣ USER INTERESTS (TO EXCLUDE)
===================================================== */
const userInterests = await tx.userInterests.findMany({
where: { userXid: userId, isActive: true },
select: { interestXid: true },
});
const userInterestTypeIds = await tx.activityTypes.findMany({
where: {
interestXid: { in: userInterests.map(i => i.interestXid) },
isActive: true,
},
select: { id: true },
});
const excludedActivityTypeIds = userInterestTypeIds.map(a => a.id);
const excludeUserInterestCondition =
excludedActivityTypeIds.length > 0
? { activityTypeXid: { notIn: excludedActivityTypeIds } }
: {};
const skip = (page - 1) * limit;
/* =====================================================
3⃣ OTHER INTERESTS (GROUPED WITH ACTIVITIES)
===================================================== */
const otherInterests = await tx.interests.findMany({
where: {
isActive: true,
id: { notIn: userInterests.map(i => i.interestXid) },
},
orderBy: { interestName: 'asc' },
select: {
id: true,
interestName: true,
interestColor: true,
interestImage: true,
},
});
const otherInterestActivities = await tx.activities.findMany({
where: {
isActive: true,
...excludeUserInterestCondition,
},
skip,
take: limit,
orderBy: { id: 'desc' },
select: {
id: true,
activityTitle: true,
activityType: {
select: {
interestXid: true,
energyLevel: true,
},
},
ActivitiesMedia: {
where: { isActive: true },
select: { id: true, mediaFileName: true, mediaType: true },
},
},
});
const formattedOtherInterestActivities = await Promise.all(
otherInterestActivities.map(async a => ({
interestXid: a.activityType.interestXid,
activityId: a.id,
activityTitle: a.activityTitle,
energyLevel: a.activityType.energyLevel,
media: await attachMediaWithPresignedUrl(a.ActivitiesMedia),
}))
);
const interestsWithActivities = otherInterests.map(interest => ({
interestId: interest.id,
interestName: interest.interestName,
interestColor: interest.interestColor,
interestImage: interest.interestImage,
page,
limit,
hasMore: formattedOtherInterestActivities.length === limit,
activities: formattedOtherInterestActivities.filter(
a => a.interestXid === interest.id
).map(({ interestXid, ...rest }) => rest),
}));
/* =====================================================
4⃣ MOST HYPED
===================================================== */
const mostHypedGrouped = await tx.userBucketInterested.groupBy({
by: ['activityXid'],
where: {
isActive: true,
isBucket: false,
Activities: excludeUserInterestCondition,
},
_count: { activityXid: true },
orderBy: { _count: { activityXid: 'desc' } },
skip,
take: limit,
});
const totalHypedCount = (
await tx.userBucketInterested.groupBy({
by: ['activityXid'],
where: {
isActive: true,
isBucket: false,
Activities: excludeUserInterestCondition,
},
})
).length;
const hypedActivities = await tx.activities.findMany({
where: { id: { in: mostHypedGrouped.map(h => h.activityXid) } },
select: {
id: true,
activityTitle: true,
activityType: { select: { energyLevel: true } },
ActivitiesMedia: {
where: { isActive: true },
select: { id: true, mediaFileName: true, mediaType: true },
},
},
});
const mostHypedActivities = await Promise.all(
mostHypedGrouped.map(async g => {
const act = hypedActivities.find(a => a.id === g.activityXid);
if (!act) return null;
return {
activityId: act.id,
activityTitle: act.activityTitle,
hypeCount: g._count.activityXid,
energyLevel: act.activityType.energyLevel,
media: await attachMediaWithPresignedUrl(act.ActivitiesMedia),
};
})
).then(a => a.filter(Boolean));
/* =====================================================
5⃣ NEW ARRIVALS
===================================================== */
const newArrivalsWhere = {
isActive: true,
createdAt: { gte: new Date(Date.now() - 31 * 24 * 60 * 60 * 1000) },
...excludeUserInterestCondition,
};
const newArrivalsCount = await tx.activities.count({
where: newArrivalsWhere,
});
const newArrivalsRaw = await tx.activities.findMany({
where: newArrivalsWhere,
skip,
take: limit,
orderBy: { id: 'desc' },
select: {
activityTitle: true,
activityType: { select: { energyLevel: true } },
ActivitiesMedia: {
where: { isActive: true },
select: { id: true, mediaFileName: true, mediaType: true },
},
},
});
/* =====================================================
6⃣ OTHER STATES & OVERSEAS
===================================================== */
const otherStatesWhere: any = {
isActive: true,
...excludeUserInterestCondition,
};
if (effectiveCountryXid) otherStatesWhere.checkInCountryXid = effectiveCountryXid;
if (effectiveStateXid) otherStatesWhere.checkInStateXid = { not: effectiveStateXid };
const overseasWhere: any = {
isActive: true,
...excludeUserInterestCondition,
};
if (effectiveCountryXid) overseasWhere.checkInCountryXid = { not: effectiveCountryXid };
const [otherStatesCount, overseasCount] = await Promise.all([
tx.activities.count({ where: otherStatesWhere }),
tx.activities.count({ where: overseasWhere }),
]);
const [otherStatesRaw, overseasRaw] = await Promise.all([
tx.activities.findMany({
where: otherStatesWhere,
skip,
take: limit,
select: {
activityTitle: true,
activityType: { select: { energyLevel: true } },
ActivitiesMedia: {
where: { isActive: true },
select: { id: true, mediaFileName: true, mediaType: true },
},
},
}),
tx.activities.findMany({
where: overseasWhere,
skip,
take: limit,
select: {
activityTitle: true,
activityType: { select: { energyLevel: true } },
ActivitiesMedia: {
where: { isActive: true },
select: { id: true, mediaFileName: true, mediaType: true },
},
},
}),
]);
/* =====================================================
7⃣ FINAL RESPONSE
===================================================== */
return {
pagination: { page, limit },
interests: interestsWithActivities,
mostHypedActivities: {
page,
limit,
totalCount: totalHypedCount,
hasMore: skip + limit < totalHypedCount,
activities: mostHypedActivities,
},
newArrivalsActivities: {
page,
limit,
totalCount: newArrivalsCount,
hasMore: skip + limit < newArrivalsCount,
activities: await Promise.all(
newArrivalsRaw.map(async a => ({
activityTitle: a.activityTitle,
energyLevel: a.activityType.energyLevel,
media: await attachMediaWithPresignedUrl(a.ActivitiesMedia),
}))
),
},
otherStatesActivities: {
page,
limit,
totalCount: otherStatesCount,
hasMore: skip + limit < otherStatesCount,
activities: await Promise.all(
otherStatesRaw.map(async a => ({
activityTitle: a.activityTitle,
energyLevel: a.activityType.energyLevel,
media: await attachMediaWithPresignedUrl(a.ActivitiesMedia),
}))
),
},
overSeasActivities: {
page,
limit,
totalCount: overseasCount,
hasMore: skip + limit < overseasCount,
activities: await Promise.all(
overseasRaw.map(async a => ({
activityTitle: a.activityTitle,
energyLevel: a.activityType.energyLevel,
media: await attachMediaWithPresignedUrl(a.ActivitiesMedia),
}))
),
},
};
});
return data;
}
async getActivityDetailsById(
userId: number,
activityXid: number) {
return await this.prisma.$transaction(async (tx) => {
const activity = await tx.activities.findUnique({
where: {
id: activityXid,
isActive: true,
activityInternalStatus: ACTIVITY_INTERNAL_STATUS.ACTIVITY_LISTED,
amInternalStatus: ACTIVITY_AM_INTERNAL_STATUS.ACTIVITY_LISTED
},
select: {
id: true,
activityTitle: true,
activityDurationMins: true,
sustainabilityScore: true,
checkInLat: true,
checkInLong: true,
activityRefNumber: true,
checkInAddress: true,
checkOutAddress: true,
checkOutLat: true,
checkOutLong: true,
activityDescription: true,
foodAvailable: true,
foodIsChargeable: true,
alcoholAvailable: true,
trainerAvailable: true,
trainerIsChargeable: true,
pickUpDropAvailable: true,
pickUpDropIsChargeable: true,
inActivityAvailable: true,
inActivityIsChargeable: true,
isLateCheckingAllowed: true,
equipmentAvailable: true,
equipmentIsChargeable: true,
cancellationAvailable: true,
cancellationAllowedBeforeMins: true,
activityType: {
select: {
interestXid: true, // ✅ VERY IMPORTANT
activityTypeName: true,
energyLevel: {
select: {
id: true,
energyLevelName: true,
energyColor: true,
energyIcon: true,
},
},
},
},
ActivityOtherDetails: {
where: { isActive: true },
select: {
id: true,
exclusiveNotes: true,
dosNotes: true,
dontsNotes: true,
tipsNotes: true,
termsAndCondition: true
}
},
ActivityEligibility: {
where: { isActive: true },
select: {
id: true,
isAgeRestriction: true,
ageRestrictionName: true,
ageEntered: true,
ageIn: true,
minAge: true,
maxAge: true,
isWeightRestriction: true,
weightRestrictionName: true,
weightEntered: true,
weightIn: true,
minWeight: true,
maxWeight: true,
isHeightRestriction: true,
heightRestrictionName: true,
heightEntered: true,
heightIn: true,
minHeight: true,
maxHeight: true
}
},
ActivityTrainers: {
where: { isActive: true },
select: {
id: true,
totalAmount: true
}
},
ActivityAllowedEntry: {
where: { isActive: true },
select: {
id: true,
allowedEntryTypeXid: true,
allowedEntryType: {
select: {
id: true,
allowedEntryTypeName: true
}
}
}
},
ActivityFoodCost: {
where: { isActive: true },
select: {
id: true,
totalAmount: true
}
},
activityFoodTypes: {
where: { isActive: true },
select: {
id: true,
foodTypeXid: true,
foodType: {
select: {
id: true,
foodTypeName: true
}
}
}
},
ActivityEquipments: {
where: { isActive: true },
select: {
id: true,
equipmentName: true,
isEquipmentChargeable: true,
equipmentTotalPrice: true
}
},
ActivityNavigationModes: {
where: { isActive: true },
select: {
id: true,
navigationModeXid: true,
navigationMode: {
select: {
id: true,
navigationModeName: true,
navigationModeIcon: true
}
},
isInActivityChargeable: true,
navigationModesTotalPrice: true
}
},
ActivityAmenities: {
where: { isActive: true },
select: {
id: true,
amenitiesXid: true,
amenities: {
select: {
id: true,
amenitiesName: true
}
}
}
},
ActivityPickUpDetails: {
where: { isActive: true },
select: {
id: true,
isPickUp: true,
locationLat: true,
locationLong: true,
locationAddress: true,
transportTotalPrice: true
}
},
activityCuisines: {
where: { isActive: true },
select: {
id: true,
foodCuisineXid: true,
foodCuisine: {
select: {
id: true,
cuisineName: true
}
}
}
},
ActivityVenues: {
where: {
isActive: true
},
select: {
id: true,
venueName: true,
venueLabel: true,
venueCapacity: true,
availableSeats: true,
isMinPeopleReqMandatory: true,
minPeopleRequired: true,
minReqfullfilledBeforeMins: true,
venueDescription: true,
ActivityPrices: {
select: {
id: true,
sellPrice: true,
},
},
},
},
ActivitiesMedia: {
where: { isActive: true },
select: {
id: true,
mediaFileName: true,
mediaType: true,
},
},
},
})
const interestedCount = await tx.userBucketInterested.count({
where: {
activityXid,
isActive: true,
},
})
return {
activity,
interestedCount,
rating: 0, // ⭐ Placeholder, implement rating logic as needed
distance: 0
}
})
}
async searchActivities(
userId: number,
searchCriteria: {
activityTitle?: string;
activityType?: string;
checkInCity?: string;
}
) {
const { activityTitle, activityType, checkInCity } = searchCriteria;
// Build the where clause dynamically
const where: any = {
isActive: true,
activityInternalStatus: ACTIVITY_INTERNAL_STATUS.ACTIVITY_LISTED,
amInternalStatus: ACTIVITY_AM_INTERNAL_STATUS.ACTIVITY_LISTED,
};
// Add activityTitle filter if provided
if (activityTitle) {
where.activityTitle = {
contains: activityTitle,
mode: 'insensitive',
};
}
// Add activityType filter if provided
if (activityType) {
where.activityType = {
activityTypeName: {
contains: activityType,
mode: 'insensitive',
},
};
}
// Add checkInCity filter if provided
if (checkInCity) {
where.checkInCity = {
cityName: {
contains: checkInCity,
mode: 'insensitive',
},
};
}
const activities = await this.prisma.activities.findMany({
where,
select: {
id: true,
activityTitle: true,
activityDescription: true,
checkInAddress: true,
activityDurationMins: true,
sustainabilityScore: true,
activityRefNumber: true,
activityType: {
select: {
activityTypeName: true,
energyLevel: {
select: {
energyLevelName: true,
energyColor: true,
energyIcon: true,
},
},
},
},
checkInCity: {
select: {
cityName: true,
},
},
ActivitiesMedia: {
where: { isActive: true },
select: {
id: true,
mediaFileName: true,
mediaType: true,
},
take: 1, // Get first media item
},
},
take: 50, // Limit results to prevent too many
});
// Get interested count for each activity
const activitiesWithCounts = await Promise.all(
activities.map(async (activity) => {
const interestedCount = await this.prisma.userBucketInterested.count({
where: {
activityXid: activity.id,
isActive: true,
},
});
// Attach presigned URLs to media
const mediaWithUrls = await attachMediaWithPresignedUrl(activity.ActivitiesMedia);
return {
...activity,
ActivitiesMediaPresignedUrl: mediaWithUrls,
interestedCount,
rating: 0, // Placeholder
distance: 0, // Placeholder
};
})
);
return activitiesWithCounts;
}
}