diff --git a/serverless/functions/user.yml b/serverless/functions/user.yml index a45c2fb..e19d7da 100644 --- a/serverless/functions/user.yml +++ b/serverless/functions/user.yml @@ -198,6 +198,51 @@ searchActivities: path: /user/activities/specific-search method: get +searchSchoolsAndCompanies: + handler: src/modules/user/handlers/connections/getSchoolCompanyName.handler + memorySize: 384 + package: + patterns: + - 'src/modules/user/handlers/connections/**' + - ${file(./serverless/patterns/base.yml):pattern1} + - ${file(./serverless/patterns/base.yml):pattern2} + - ${file(./serverless/patterns/base.yml):pattern3} + - ${file(./serverless/patterns/base.yml):pattern4} + events: + - httpApi: + path: /user/connections/search-schools-companies + method: get + +searchCities: + handler: src/modules/user/handlers/connections/searchCities.handler + memorySize: 384 + package: + patterns: + - 'src/modules/user/handlers/connections/**' + - ${file(./serverless/patterns/base.yml):pattern1} + - ${file(./serverless/patterns/base.yml):pattern2} + - ${file(./serverless/patterns/base.yml):pattern3} + - ${file(./serverless/patterns/base.yml):pattern4} + events: + - httpApi: + path: /user/connections/search-cities + method: get + +addSchoolCompanyDetail: + handler: src/modules/user/handlers/connections/addSchoolCompanyDetail.handler + memorySize: 384 + package: + patterns: + - 'src/modules/user/handlers/connections/**' + - ${file(./serverless/patterns/base.yml):pattern1} + - ${file(./serverless/patterns/base.yml):pattern2} + - ${file(./serverless/patterns/base.yml):pattern3} + - ${file(./serverless/patterns/base.yml):pattern4} + events: + - httpApi: + path: /user/connections/add-school-company + method: post + getAllConnectionOfUser: handler: src/modules/user/handlers/connections/getAllConnectionDetailsOfUser.handler memorySize: 384 diff --git a/src/modules/user/dto/addSchoolCompanyDetail.dto.ts b/src/modules/user/dto/addSchoolCompanyDetail.dto.ts new file mode 100644 index 0000000..97592e6 --- /dev/null +++ b/src/modules/user/dto/addSchoolCompanyDetail.dto.ts @@ -0,0 +1,11 @@ +export class AddSchoolCompanyDetailDTO { + schoolCompanyName: string; + isSchool: boolean; + cityXid: number; + + constructor(schoolCompanyName: string, isSchool: boolean, cityXid: number) { + this.schoolCompanyName = schoolCompanyName; + this.isSchool = isSchool; + this.cityXid = cityXid; + } +} diff --git a/src/modules/user/handlers/connections/addSchoolCompanyDetail.ts b/src/modules/user/handlers/connections/addSchoolCompanyDetail.ts new file mode 100644 index 0000000..8575bf8 --- /dev/null +++ b/src/modules/user/handlers/connections/addSchoolCompanyDetail.ts @@ -0,0 +1,85 @@ +import { + APIGatewayProxyEvent, + APIGatewayProxyResult, + Context, +} from 'aws-lambda'; +import { prismaClient } from '../../../../common/database/prisma.lambda.service'; +import { safeHandler } from '../../../../common/utils/handlers/safeHandler'; +import ApiError from '../../../../common/utils/helper/ApiError'; +import { AddSchoolCompanyDetailDTO } from '../../dto/addSchoolCompanyDetail.dto'; +import { UserService } from '../../services/user.service'; + +const userService = new UserService(prismaClient); + +export const handler = safeHandler( + async ( + event: APIGatewayProxyEvent, + context?: Context, + ): Promise => { + // Extract body parameters + let body; + try { + body = JSON.parse(event.body || '{}'); + } catch (error) { + throw new ApiError(400, 'Invalid JSON in request body'); + } + + const { schoolCompanyName, isSchool, cityXid } = body; + + // Validate required inputs + if (!schoolCompanyName || schoolCompanyName.trim().length === 0) { + throw new ApiError(400, 'schoolCompanyName is required'); + } + + if (schoolCompanyName.trim().length < 2) { + throw new ApiError( + 400, + 'schoolCompanyName must be at least 2 characters long', + ); + } + + if (isSchool === undefined || isSchool === null) { + throw new ApiError( + 400, + 'isSchool is required and must be a boolean value', + ); + } + + if (typeof isSchool !== 'boolean') { + throw new ApiError( + 400, + 'isSchool must be a boolean value (true or false)', + ); + } + + if (!cityXid || typeof cityXid !== 'number') { + throw new ApiError(400, 'cityXid is required and must be a number'); + } + + // Create DTO + const dto = new AddSchoolCompanyDetailDTO( + schoolCompanyName.trim(), + isSchool, + cityXid, + ); + + // Call service to add or find school/company + const result = await userService.addOrFindSchoolCompanyDetail(dto); + + return { + statusCode: 201, + headers: { + 'Content-Type': 'application/json', + 'Access-Control-Allow-Origin': '*', + }, + body: JSON.stringify({ + success: true, + message: result.isNew + ? `${isSchool ? 'School' : 'Company'} created successfully` + : `${isSchool ? 'School' : 'Company'} already exists, returning existing record`, + data: result.data, + isNew: result.isNew, + }), + }; + }, +); diff --git a/src/modules/user/handlers/connections/getSchoolCompanyName.ts b/src/modules/user/handlers/connections/getSchoolCompanyName.ts index a3e43fa..aeeefa4 100644 --- a/src/modules/user/handlers/connections/getSchoolCompanyName.ts +++ b/src/modules/user/handlers/connections/getSchoolCompanyName.ts @@ -1 +1,83 @@ -//#do your code here \ No newline at end of file +import { + APIGatewayProxyEvent, + APIGatewayProxyResult, + Context, +} from 'aws-lambda'; +import { prismaClient } from '../../../../common/database/prisma.lambda.service'; +import { safeHandler } from '../../../../common/utils/handlers/safeHandler'; +import ApiError from '../../../../common/utils/helper/ApiError'; +import { UserService } from '../../services/user.service'; + +const userService = new UserService(prismaClient); + +export const handler = safeHandler( + async ( + event: APIGatewayProxyEvent, + context?: Context, + ): Promise => { + // Extract query parameters + const searchQuery = event.queryStringParameters?.searchQuery?.trim(); + const isSchool = event.queryStringParameters?.isSchool?.toLowerCase(); + + // Validate inputs + if (!searchQuery || searchQuery.length === 0) { + throw new ApiError(400, 'Search query is required'); + } + + if (searchQuery.length < 2) { + throw new ApiError( + 400, + 'Search query must be at least 2 characters long', + ); + } + + // Validate isSchool parameter + if (!isSchool || !['true', 'false'].includes(isSchool)) { + throw new ApiError( + 400, + 'isSchool parameter must be either "true" (for schools) or "false" (for companies)', + ); + } + + // Convert isSchool to boolean + const filterBySchool = isSchool === 'true'; + + // Call service to search + const results = await userService.searchSchoolsAndCompanies( + searchQuery, + filterBySchool, + ); + + // Check if any results found + if (results.length === 0) { + const type = filterBySchool ? 'school' : 'company'; + return { + statusCode: 200, + headers: { + 'Content-Type': 'application/json', + 'Access-Control-Allow-Origin': '*', + }, + body: JSON.stringify({ + success: true, + message: `No ${type}s found matching your search`, + data: [], + count: 0, + }), + }; + } + + return { + statusCode: 200, + headers: { + 'Content-Type': 'application/json', + 'Access-Control-Allow-Origin': '*', + }, + body: JSON.stringify({ + success: true, + message: `${filterBySchool ? 'Schools' : 'Companies'} found successfully`, + data: results, + count: results.length, + }), + }; + }, +); diff --git a/src/modules/user/handlers/connections/searchCities.ts b/src/modules/user/handlers/connections/searchCities.ts new file mode 100644 index 0000000..a7c3d64 --- /dev/null +++ b/src/modules/user/handlers/connections/searchCities.ts @@ -0,0 +1,64 @@ +import { + APIGatewayProxyEvent, + APIGatewayProxyResult, + Context, +} from 'aws-lambda'; +import { prismaClient } from '../../../../common/database/prisma.lambda.service'; +import { safeHandler } from '../../../../common/utils/handlers/safeHandler'; +import ApiError from '../../../../common/utils/helper/ApiError'; +import { UserService } from '../../services/user.service'; + +const userService = new UserService(prismaClient); + +export const handler = safeHandler( + async ( + event: APIGatewayProxyEvent, + context?: Context, + ): Promise => { + // Extract query parameters + const searchQuery = event.queryStringParameters?.searchQuery?.trim(); + + // Validate inputs + if (!searchQuery || searchQuery.length === 0) { + throw new ApiError(400, 'Search query is required'); + } + + if (searchQuery.length < 2) { + throw new ApiError(400, 'Search query must be at least 2 characters long'); + } + + // Call service to search cities + const results = await userService.searchCities(searchQuery); + + // Check if any results found + if (results.length === 0) { + return { + statusCode: 200, + headers: { + 'Content-Type': 'application/json', + 'Access-Control-Allow-Origin': '*', + }, + body: JSON.stringify({ + success: true, + message: 'No cities found matching your search', + data: [], + count: 0, + }), + }; + } + + return { + statusCode: 200, + headers: { + 'Content-Type': 'application/json', + 'Access-Control-Allow-Origin': '*', + }, + body: JSON.stringify({ + success: true, + message: 'Cities found successfully', + data: results, + count: results.length, + }), + }; + }, +); diff --git a/src/modules/user/services/user.service.ts b/src/modules/user/services/user.service.ts index 0d0277e..ea1cbd1 100644 --- a/src/modules/user/services/user.service.ts +++ b/src/modules/user/services/user.service.ts @@ -2,9 +2,13 @@ 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 { + 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) { @@ -28,1801 +32,1835 @@ import config from '@/config/config'; // return R * c; // } +const normalizeName = (name: string) => + name.trim().toLowerCase().replace(/\s+/g, " "); + const attachPresignedUrl = async (file: string | null | undefined) => { - if (!file) return null; + if (!file) return null; - const key = file.startsWith('http') - ? new URL(file).pathname.replace(/^\/+/, '') - : file; + const key = file.startsWith('http') + ? new URL(file).pathname.replace(/^\/+/, '') + : file; - return await getPresignedUrl(bucket, key); + return await getPresignedUrl(bucket, key); }; async function findOrCreateLocation( - tx: any, - { - countryName, - stateName, - cityName, - }: { - countryName: string; - stateName: string; - cityName: string; - } + 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, - }, + 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, + }, }); + } - 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, - }, + 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, + }, }); + } - 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, - }, - }); + 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, + if (!city) { + city = await tx.cities.create({ + data: { + cityName: cityName.trim(), stateXid: state.id, - cityXid: city.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; + 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; + 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); + 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 + tx: any, + whereClause: any, + page: number, + limit: number, ) { - const skip = (page - 1) * limit; + const skip = (page - 1) * limit; - // 1️⃣ Fetch Metadata for ALL matching activities for in-memory sorting - const allCandidates = await tx.activities.findMany({ - where: whereClause, + // 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: { - id: true, - sustainabilityScore: true, - totalScore: true, // Quality Score - ItineraryActivities: { - select: { - ActivityFeedbacks: { - select: { activityStars: true }, - }, - }, - }, - ActivityVenues: { - select: { - ActivityPrices: { - select: { sellPrice: true }, - }, - }, - }, + 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; }); - const totalCount = allCandidates.length; + // 3️⃣ Paginate IDs + const paginatedCandidates = sortedCandidates.slice(skip, skip + limit); + const targetIds = paginatedCandidates.map((c: any) => c.id); - // 2️⃣ Calculate Metrics & Sort - const sortedCandidates = allCandidates.map((act: any) => { - // Flatten feedbacks - const feedbacks = act.ItineraryActivities.flatMap((ia: any) => ia.ActivityFeedbacks); + // 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, + }, + }, + }, + }); - // Avg Rating - const totalStars = feedbacks.reduce((sum: number, f: any) => sum + f.activityStars, 0); - const avgRating = feedbacks.length > 0 ? totalStars / feedbacks.length : 0; + // Re-sort to match the calculated order + const activities = targetIds + .map((id: number) => activitiesUnsorted.find((a: any) => a.id === id)) + .filter(Boolean); - // 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; + // 5️⃣ Format Response + const formattedActivities = await Promise.all( + activities.map(async (activity: any) => { + const prices = activity.ActivityVenues.flatMap((v: any) => + v.ActivityPrices.map((p: any) => p.sellPrice), + ).filter((p: any) => p !== null) as number[]; + const cheapestPrice = prices.length > 0 ? Math.min(...prices) : null; + return { + // interestXid: activity.activityType.interestXid, + activityId: activity.id, + activityTitle: activity.activityTitle, + // activityDurationMins: activity.activityDurationMins, + // sustainabilityScore: activity.sustainabilityScore, + // cheapestPrice, + energyLevel: activity.activityType.energyLevel + ? { + ...activity.activityType.energyLevel, + presignedUrl: await attachPresignedUrl( + activity.activityType.energyLevel.energyIcon, + ), + } + : null, + media: await attachMediaWithPresignedUrl(activity.ActivitiesMedia), + }; + }), + ); + + return { + page, + limit, + totalCount, + hasMore: skip + limit < totalCount, + activities: formattedActivities, + }; +} + +@Injectable() +export class UserService { + constructor(private prisma: PrismaClient) {} + + async getUserById(userId: number) { + return this.prisma.user.findUnique({ + where: { id: userId, isActive: true }, + }); + } + + async addPersonalInfo(userId: number, data: UserPersonalInfoSchema) { + return await this.prisma.$transaction(async (tx) => { + const updatedUser = await tx.user.update({ + where: { id: userId }, + data: { + firstName: data.firstName, + lastName: data.lastName ?? null, + genderName: data.genderName, + dateOfBirth: data.dateOfBirth ? new Date(data.dateOfBirth) : null, + isProfileUpdated: true, + }, + }); + + return updatedUser; + }); + } + + async getAllInterestDetails() { + const interests = await this.prisma.interests.findMany({ + where: { isActive: true }, + select: { + id: true, + interestName: true, + interestColor: true, + interestImage: true, + displayOrder: true, + }, + }); + + for (const interest of interests) { + if (interest.interestImage) { + const key = interest.interestImage.startsWith('http') + ? new URL(interest.interestImage).pathname.replace(/^\/+/, '') + : interest.interestImage; + + (interest as any).presignedUrl = await getPresignedUrl(bucket, key); + } else { + (interest as any).presignedUrl = null; + } + } + + return interests; + } + + async getUserByMobileNumber(mobileNumber: string): Promise { + return this.prisma.user.findFirst({ + where: { mobileNumber: mobileNumber, isActive: true }, + }); + } + + async verifyHostOtp(mobileNumber: string, otp: string): Promise { + 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 { + // 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 { + 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 { + // 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 { + 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 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 { - id: act.id, + 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, - }; - }).sort((a: any, b: any) => { - // 1. Rating (Highest first) - if (b.avgRating !== a.avgRating) return b.avgRating - a.avgRating; - - // 2. Price (Lowest first) - if (a.minPrice !== b.minPrice) return a.minPrice - b.minPrice; - - // 3. Sustainability Score (Highest first) - if (b.sustainabilityScore !== a.sustainabilityScore) return b.sustainabilityScore - a.sustainabilityScore; - - // 4. Quality Score (Highest first) - return b.totalScore - a.totalScore; - }); - - // 3️⃣ Paginate IDs - const paginatedCandidates = sortedCandidates.slice(skip, skip + limit); - const targetIds = paginatedCandidates.map((c: any) => c.id); - - // 4️⃣ Fetch Full Details for the page - const activitiesUnsorted = await tx.activities.findMany({ - where: { id: { in: targetIds } }, - select: { - id: true, - activityTitle: true, - activityDurationMins: true, - sustainabilityScore: true, - activityType: { - select: { - interestXid: true, - energyLevel: { - select: { - id: true, - energyLevelName: true, - energyColor: true, - energyIcon: true, - }, - }, - }, - }, - ActivityVenues: { - select: { - ActivityPrices: { - select: { sellPrice: true }, - }, - }, - }, - ActivitiesMedia: { - where: { isActive: true }, - select: { - id: true, - mediaFileName: true, - mediaType: true, - }, - }, - }, - }); - - // Re-sort to match the calculated order - const activities = targetIds - .map((id: number) => activitiesUnsorted.find((a: any) => a.id === id)) - .filter(Boolean); - - // 5️⃣ Format Response - const formattedActivities = await Promise.all( - activities.map(async (activity: any) => { - const prices = activity.ActivityVenues.flatMap((v: any) => v.ActivityPrices.map((p: any) => p.sellPrice)).filter((p: any) => p !== null) as number[]; - const cheapestPrice = prices.length > 0 ? Math.min(...prices) : null; - - return { - // interestXid: activity.activityType.interestXid, - activityId: activity.id, - activityTitle: activity.activityTitle, - // activityDurationMins: activity.activityDurationMins, - // sustainabilityScore: activity.sustainabilityScore, - // cheapestPrice, - energyLevel: activity.activityType.energyLevel - ? { - ...activity.activityType.energyLevel, - presignedUrl: await attachPresignedUrl( - activity.activityType.energyLevel.energyIcon - ), - } - : null, - media: await attachMediaWithPresignedUrl(activity.ActivitiesMedia), - }; + 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; + }); - return { + const mostHypedActivities = await Promise.all( + mostHypedSorted.map(async (activity) => ({ + activityId: activity.id, + activityTitle: activity.activityTitle, + hypeCount: activity.hypeCount, + energyLevel: activity.activityType.energyLevel + ? { + ...activity.activityType.energyLevel, + presignedUrl: await attachPresignedUrl( + activity.activityType.energyLevel.energyIcon, + ), + } + : null, + media: await attachMediaWithPresignedUrl(activity.ActivitiesMedia), + })), + ); + + const formattedMostHypedActivities = { page, limit, - totalCount, - hasMore: skip + limit < totalCount, - activities: formattedActivities, - }; -} + totalCount: totalHypedActivities, + hasMore: skip + limit < totalHypedActivities, + activities: mostHypedActivities, + }; - -@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 { - return this.prisma.user.findFirst({ - where: { mobileNumber: mobileNumber, isActive: true }, - }); - } - - async verifyHostOtp(mobileNumber: string, otp: string): Promise { - 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 { - // 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 { - 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 { - // 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 { - 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 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 skip = (page - 1) * limit; - - /* ===================================================== - 1️⃣ FETCH ALL CANDIDATES FOR INTERESTS (SIMPLE SORT) - ===================================================== */ - // Reverted to simple ID based sorting for Interest-based activities - const activities = await tx.activities.findMany({ - where: { - isActive: true, - activityInternalStatus: ACTIVITY_INTERNAL_STATUS.ACTIVITY_LISTED, - amInternalStatus: ACTIVITY_AM_INTERNAL_STATUS.ACTIVITY_LISTED, - activityTypeXid: { - in: activitiyTypesOfUserInterests.map(at => at.id), - }, - }, - skip, - take: limit, - orderBy: { id: 'desc' }, - select: { - id: true, - activityTitle: true, - activityDurationMins: true, - sustainabilityScore: true, - checkInLat: true, - checkInLong: true, - activityType: { - select: { - interestXid: true, - energyLevel: { - select: { - id: true, - energyLevelName: true, - energyColor: true, - energyIcon: true, - }, - }, - }, - }, - ActivityVenues: { - select: { - ActivityPrices: { - select: { - sellPrice: true, - }, - }, - }, - }, - ActivitiesMedia: { - where: { isActive: true }, - select: { - id: true, - mediaFileName: true, - mediaType: true, - }, - }, - }, - }); - - const mostHypedTotalCount = await tx.userBucketInterested.groupBy({ - by: ['activityXid'], - where: { - isActive: true, - isBucket: false, - }, - }); - - /* ===================================================== - 2️⃣ MOST HYPED ACTIVITIES (RANKED) - ===================================================== */ - const mostHypedGrouped = await tx.userBucketInterested.groupBy({ - by: ['activityXid'], - where: { - isActive: true, - isBucket: false - }, - _count: { - activityXid: true, - }, - orderBy: { - _count: { - activityXid: 'desc', - }, - }, - skip, - take: limit, - }); - - const totalHypedActivities = mostHypedTotalCount.length; - const mostHypedActivityIds = mostHypedGrouped.map(a => a.activityXid); - - // Fetch metadata for ranking only for the top hyped ones (optimization: double sorting might be needed if we want to sort BY rating WITHIN the hyped list, but usually Hyped = Count. - // IF user wants the standard 4-step ranking applied TO the most hyped items: - const mostHypedActivitiesRaw = await tx.activities.findMany({ - where: { - id: { in: mostHypedActivityIds }, - isActive: true, - activityInternalStatus: ACTIVITY_INTERNAL_STATUS.ACTIVITY_LISTED, - amInternalStatus: ACTIVITY_AM_INTERNAL_STATUS.ACTIVITY_LISTED, - }, - select: { - id: true, - activityTitle: true, - sustainabilityScore: true, - totalScore: true, - activityType: { - select: { - energyLevel: { - select: { - id: true, - energyLevelName: true, - energyColor: true, - energyIcon: true, - }, - }, - }, - }, - ActivitiesMedia: { - where: { isActive: true }, - select: { - id: true, - mediaFileName: true, - mediaType: true, - }, - }, - // Fetch ranking metadata - ItineraryActivities: { - select: { - ActivityFeedbacks: { - select: { activityStars: true }, - }, - }, - }, - ActivityVenues: { - select: { - ActivityPrices: { - select: { sellPrice: true }, - }, - }, - }, - }, - }); - - // Sort Most Hyped by the 4 criteria - const mostHypedSorted = mostHypedActivitiesRaw.map(act => { - const feedbacks = act.ItineraryActivities.flatMap(ia => ia.ActivityFeedbacks); - const totalStars = feedbacks.reduce((sum, f) => sum + f.activityStars, 0); - const avgRating = feedbacks.length > 0 ? totalStars / feedbacks.length : 0; - const prices = act.ActivityVenues.flatMap(v => v.ActivityPrices.map(p => p.sellPrice)).filter(p => p !== null) as number[]; - const minPrice = prices.length > 0 ? Math.min(...prices) : Infinity; - - return { - ...act, // Keep original fields for final output - avgRating, - minPrice, - sustainabilityScore: act.sustainabilityScore ?? 0, - totalScore: act.totalScore ?? 0, - hypeCount: mostHypedGrouped.find(g => g.activityXid === act.id)?._count.activityXid ?? 0 - }; - }).sort((a, b) => { - // 1. Rating (Highest first) - if (b.avgRating !== a.avgRating) return b.avgRating - a.avgRating; - // 2. Price (Lowest first) - if (a.minPrice !== b.minPrice) return a.minPrice - b.minPrice; - // 3. Sustainability Score - if (b.sustainabilityScore !== a.sustainabilityScore) return b.sustainabilityScore - a.sustainabilityScore; - // 4. Quality Score - return b.totalScore - a.totalScore; - }); - - const mostHypedActivities = await Promise.all( - mostHypedSorted.map(async activity => ({ - activityId: activity.id, - activityTitle: activity.activityTitle, - hypeCount: activity.hypeCount, - energyLevel: activity.activityType.energyLevel - ? { - ...activity.activityType.energyLevel, - presignedUrl: await attachPresignedUrl( - activity.activityType.energyLevel.energyIcon - ), - } - : null, - media: await attachMediaWithPresignedUrl(activity.ActivitiesMedia), - })) - ); - - const formattedMostHypedActivities = { - page, - limit, - totalCount: totalHypedActivities, - hasMore: skip + limit < totalHypedActivities, - activities: mostHypedActivities, - }; - - - /* ===================================================== + /* ===================================================== 3️⃣ NEW ARRIVALS (RANKED) ===================================================== */ - const newArrivalsWhere = { - isActive: true, - activityInternalStatus: ACTIVITY_INTERNAL_STATUS.ACTIVITY_LISTED, - amInternalStatus: ACTIVITY_AM_INTERNAL_STATUS.ACTIVITY_LISTED, - createdAt: { gte: new Date(Date.now() - 31 * 24 * 60 * 60 * 1000) } - }; + const 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); + 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, - }; + 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 }; - } + if (effectiveCountryXid) { + otherStatesWhere.checkInCountryXid = effectiveCountryXid; + } + if (effectiveStateXid) { + otherStatesWhere.checkInStateXid = { not: effectiveStateXid }; + } - const formattedOtherStatesActivities = await rankAndPaginateActivities(tx, otherStatesWhere, page, limit); + 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, - }; + const overseasWhere: any = { + isActive: true, + activityInternalStatus: ACTIVITY_INTERNAL_STATUS.ACTIVITY_LISTED, + amInternalStatus: ACTIVITY_AM_INTERNAL_STATUS.ACTIVITY_LISTED, + }; - if (effectiveCountryXid) { - overseasWhere.checkInCountryXid = { not: effectiveCountryXid }; - } + if (effectiveCountryXid) { + overseasWhere.checkInCountryXid = { not: effectiveCountryXid }; + } - const formattedOverSeasActivities = await rankAndPaginateActivities(tx, overseasWhere, page, limit); + 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; - const formattedActivities = await Promise.all( - activities.map(async (activity) => { - const cheapestPrice = - activity.ActivityVenues.flatMap(v => v.ActivityPrices) - .map(p => p.sellPrice) - .filter(Boolean) - .sort((a, b) => a - b)[0] ?? null; + return { + interestXid: activity.activityType.interestXid, + activityId: activity.id, + activityTitle: activity.activityTitle, + activityDurationMins: activity.activityDurationMins, + sustainabilityScore: activity.sustainabilityScore, + cheapestPrice, + energyLevel: activity.activityType.energyLevel + ? { + ...activity.activityType.energyLevel, + presignedUrl: await attachPresignedUrl( + activity.activityType.energyLevel.energyIcon, + ), + } + : null, + media: await attachMediaWithPresignedUrl(activity.ActivitiesMedia), + }; + }), + ); - return { - interestXid: activity.activityType.interestXid, - activityId: activity.id, - activityTitle: activity.activityTitle, - activityDurationMins: activity.activityDurationMins, - sustainabilityScore: activity.sustainabilityScore, - cheapestPrice, - energyLevel: activity.activityType.energyLevel - ? { - ...activity.activityType.energyLevel, - presignedUrl: await attachPresignedUrl( - activity.activityType.energyLevel.energyIcon - ), - } - : null, - media: await attachMediaWithPresignedUrl(activity.ActivitiesMedia), - }; - }) - ); + const interestsWithActivities = await Promise.all( + [...userInterests] + .sort((a, b) => + a.interest.interestName.localeCompare(b.interest.interestName), + ) + .map(async (ui) => ({ + interestId: ui.interest.id, + interestName: ui.interest.interestName, + interestColor: ui.interest.interestColor, + interestImage: ui.interest.interestImage, + interestImagePresignedUrl: await attachPresignedUrl( + ui.interest.interestImage, + ), + displayOrder: ui.interest.displayOrder, + page, + limit, + hasMore: formattedActivities.length === limit, + activities: formattedActivities + .filter((a) => a.interestXid === ui.interestXid) + .map(({ interestXid, ...rest }) => rest), + })), + ); - const interestsWithActivities = await Promise.all( - [...userInterests] - .sort((a, b) => - a.interest.interestName.localeCompare(b.interest.interestName) - ) - .map(async (ui) => ({ - interestId: ui.interest.id, - interestName: ui.interest.interestName, - interestColor: ui.interest.interestColor, - interestImage: ui.interest.interestImage, - interestImagePresignedUrl: await attachPresignedUrl( - ui.interest.interestImage - ), - displayOrder: ui.interest.displayOrder, - page, - limit, - hasMore: formattedActivities.length === limit, - activities: formattedActivities - .filter(a => a.interestXid === ui.interestXid) - .map(({ interestXid, ...rest }) => rest), - })) - ); + return { + userAddressDetails, + experiencesLogged: 25, + citiesDiscovered: 10, + loggedInNetworkCount: 0, + citiesInNetworkCount: 0, + pagination: { + page, + limit, + }, + interests: interestsWithActivities, + otherStatesActivities: formattedOtherStatesActivities, + overSeasActivities: formattedOverSeasActivities, + newArrivalsActivities: formattedNewArrivalsActivities, + mostHypedActivities: formattedMostHypedActivities, + }; + }); + return data; + } - - - 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) => { - - /* ===================================================== + 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, - }, - }); + 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; + 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, - }; - } + 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; + 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 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 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 excludedActivityTypeIds = userInterestTypeIds.map((a) => a.id); - const excludeUserInterestCondition = - excludedActivityTypeIds.length > 0 - ? { activityTypeXid: { notIn: excludedActivityTypeIds } } - : {}; + const excludeUserInterestCondition = + excludedActivityTypeIds.length > 0 + ? { activityTypeXid: { notIn: excludedActivityTypeIds } } + : {}; - const skip = (page - 1) * limit; + 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 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 otherInterestActivities = await tx.activities.findMany({ + where: { + isActive: true, + ...excludeUserInterestCondition, + }, + skip, + take: limit, + orderBy: { id: 'desc' }, + select: { + id: true, + activityTitle: true, + activityType: { + select: { + interestXid: true, + energyLevel: true, + }, + }, + ActivitiesMedia: { + where: { isActive: true }, + select: { id: true, mediaFileName: true, mediaType: true }, + }, + }, + }); - const formattedOtherInterestActivities = await Promise.all( - otherInterestActivities.map(async a => ({ - interestXid: a.activityType.interestXid, - activityId: a.id, - activityTitle: a.activityTitle, - energyLevel: { - ...a.activityType.energyLevel, - presignedUrl: await attachPresignedUrl( - a.activityType.energyLevel?.energyIcon - ), - }, - media: await attachMediaWithPresignedUrl(a.ActivitiesMedia), - })) - ); + const formattedOtherInterestActivities = await Promise.all( + otherInterestActivities.map(async (a) => ({ + interestXid: a.activityType.interestXid, + activityId: a.id, + activityTitle: a.activityTitle, + energyLevel: { + ...a.activityType.energyLevel, + presignedUrl: await attachPresignedUrl( + a.activityType.energyLevel?.energyIcon, + ), + }, + media: await attachMediaWithPresignedUrl(a.ActivitiesMedia), + })), + ); - const interestsWithActivities = await Promise.all( - otherInterests.map(async (interest) => ({ - interestId: interest.id, - interestName: interest.interestName, - interestColor: interest.interestColor, - interestImage: interest.interestImage, - interestImagePresignedUrl: await attachPresignedUrl( - interest.interestImage - ), - page, - limit, - hasMore: formattedOtherInterestActivities.length === limit, - activities: formattedOtherInterestActivities - .filter(a => a.interestXid === interest.id) - .map(({ interestXid, ...rest }) => rest), - })) - ); + 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 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 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 hypedActivities = await tx.activities.findMany({ + where: { id: { in: mostHypedGrouped.map((h) => h.activityXid) } }, + select: { + id: true, + activityTitle: true, + activityType: { select: { energyLevel: true } }, + ActivitiesMedia: { + where: { isActive: true }, + select: { id: true, mediaFileName: true, mediaType: true }, + }, + }, + }); - const mostHypedActivities = await Promise.all( - mostHypedGrouped.map(async g => { - const act = hypedActivities.find(a => a.id === g.activityXid); - if (!act) return null; - return { - activityId: act.id, - activityTitle: act.activityTitle, - hypeCount: g._count.activityXid, - energyLevel: { - ...act.activityType.energyLevel, - presignedUrl: await attachPresignedUrl( - act.activityType.energyLevel?.energyIcon - ), - }, - media: await attachMediaWithPresignedUrl(act.ActivitiesMedia), - }; - }) - ).then(a => a.filter(Boolean)); + const mostHypedActivities = await Promise.all( + mostHypedGrouped.map(async (g) => { + const act = hypedActivities.find((a) => a.id === g.activityXid); + if (!act) return null; + return { + activityId: act.id, + activityTitle: act.activityTitle, + hypeCount: g._count.activityXid, + energyLevel: { + ...act.activityType.energyLevel, + presignedUrl: await attachPresignedUrl( + act.activityType.energyLevel?.energyIcon, + ), + }, + media: await attachMediaWithPresignedUrl(act.ActivitiesMedia), + }; + }), + ).then((a) => a.filter(Boolean)); - /* ===================================================== + /* ===================================================== 5️⃣ NEW ARRIVALS ===================================================== */ - const newArrivalsWhere = { - isActive: true, - createdAt: { gte: new Date(Date.now() - 31 * 24 * 60 * 60 * 1000) }, - ...excludeUserInterestCondition, - }; + const newArrivalsWhere = { + isActive: true, + createdAt: { gte: new Date(Date.now() - 31 * 24 * 60 * 60 * 1000) }, + ...excludeUserInterestCondition, + }; - const newArrivalsCount = await tx.activities.count({ - where: newArrivalsWhere, - }); + 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 }, - }, - }, - }); + 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 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 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 [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 }, - }, - }, - }), - ]); + 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, + return { + pagination: { page, limit }, + interests: interestsWithActivities, - mostHypedActivities: { - page, - limit, - totalCount: totalHypedCount, - hasMore: skip + limit < totalHypedCount, - activities: mostHypedActivities, - }, + mostHypedActivities: { + page, + limit, + totalCount: totalHypedCount, + hasMore: skip + limit < totalHypedCount, + activities: mostHypedActivities, + }, - newArrivalsActivities: { - page, - limit, - totalCount: newArrivalsCount, - hasMore: skip + limit < newArrivalsCount, - activities: await Promise.all( - newArrivalsRaw.map(async a => ({ - activityTitle: a.activityTitle, - energyLevel: { - ...a.activityType.energyLevel, - presignedUrl: await attachPresignedUrl( - a.activityType.energyLevel?.energyIcon - ), - }, - media: await attachMediaWithPresignedUrl(a.ActivitiesMedia), - })) - ), - }, + newArrivalsActivities: { + page, + limit, + totalCount: newArrivalsCount, + hasMore: skip + limit < newArrivalsCount, + activities: await Promise.all( + newArrivalsRaw.map(async (a) => ({ + activityTitle: a.activityTitle, + energyLevel: { + ...a.activityType.energyLevel, + presignedUrl: await attachPresignedUrl( + a.activityType.energyLevel?.energyIcon, + ), + }, + media: await attachMediaWithPresignedUrl(a.ActivitiesMedia), + })), + ), + }, - otherStatesActivities: { - page, - limit, - totalCount: otherStatesCount, - hasMore: skip + limit < otherStatesCount, - activities: await Promise.all( - otherStatesRaw.map(async a => ({ - activityTitle: a.activityTitle, - energyLevel: { - ...a.activityType.energyLevel, - presignedUrl: await attachPresignedUrl( - a.activityType.energyLevel?.energyIcon - ), - }, - media: await attachMediaWithPresignedUrl(a.ActivitiesMedia), - })) - ), - }, + otherStatesActivities: { + page, + limit, + totalCount: otherStatesCount, + hasMore: skip + limit < otherStatesCount, + activities: await Promise.all( + otherStatesRaw.map(async (a) => ({ + activityTitle: a.activityTitle, + energyLevel: { + ...a.activityType.energyLevel, + presignedUrl: await attachPresignedUrl( + a.activityType.energyLevel?.energyIcon, + ), + }, + media: await attachMediaWithPresignedUrl(a.ActivitiesMedia), + })), + ), + }, - overSeasActivities: { - page, - limit, - totalCount: overseasCount, - hasMore: skip + limit < overseasCount, - activities: await Promise.all( - overseasRaw.map(async a => ({ - activityTitle: a.activityTitle, - energyLevel: { - ...a.activityType.energyLevel, - presignedUrl: await attachPresignedUrl( - a.activityType.energyLevel?.energyIcon - ), - }, - media: await attachMediaWithPresignedUrl(a.ActivitiesMedia), - })) - ), - }, - }; - }); + overSeasActivities: { + page, + limit, + totalCount: overseasCount, + hasMore: skip + limit < overseasCount, + activities: await Promise.all( + overseasRaw.map(async (a) => ({ + activityTitle: a.activityTitle, + energyLevel: { + ...a.activityType.energyLevel, + presignedUrl: await attachPresignedUrl( + a.activityType.energyLevel?.energyIcon, + ), + }, + media: await attachMediaWithPresignedUrl(a.ActivitiesMedia), + })), + ), + }, + }; + }); - return data; - } + 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, - async getActivityDetailsById( - userId: number, - activityXid: number) { - return await this.prisma.$transaction(async (tx) => { - const activity = await tx.activities.findUnique({ - where: { - id: activityXid, - isActive: true, - activityInternalStatus: ACTIVITY_INTERNAL_STATUS.ACTIVITY_LISTED, - amInternalStatus: ACTIVITY_AM_INTERNAL_STATUS.ACTIVITY_LISTED - }, - select: { - id: true, - activityTitle: true, - activityDurationMins: true, - sustainabilityScore: true, - checkInLat: true, - checkInLong: true, - activityRefNumber: true, - checkInAddress: true, - checkOutAddress: true, - checkOutLat: true, - checkOutLong: true, - activityDescription: true, - foodAvailable: true, - foodIsChargeable: true, - alcoholAvailable: true, - trainerAvailable: true, - trainerIsChargeable: true, - pickUpDropAvailable: true, - pickUpDropIsChargeable: true, - inActivityAvailable: true, - inActivityIsChargeable: true, - isLateCheckingAllowed: true, - equipmentAvailable: true, - equipmentIsChargeable: true, - cancellationAvailable: true, - cancellationAllowedBeforeMins: true, - - activityType: { - select: { - interestXid: true, // ✅ VERY IMPORTANT - activityTypeName: true, - energyLevel: { - select: { - id: true, - energyLevelName: true, - energyColor: true, - energyIcon: true, - }, - }, - }, - }, - - ActivityOtherDetails: { - where: { isActive: true }, - select: { - id: true, - exclusiveNotes: true, - dosNotes: true, - dontsNotes: true, - tipsNotes: true, - termsAndCondition: true - } - }, - - ActivityEligibility: { - where: { isActive: true }, - select: { - id: true, - isAgeRestriction: true, - ageRestrictionName: true, - ageEntered: true, - ageIn: true, - minAge: true, - maxAge: true, - isWeightRestriction: true, - weightRestrictionName: true, - weightEntered: true, - weightIn: true, - minWeight: true, - maxWeight: true, - isHeightRestriction: true, - heightRestrictionName: true, - heightEntered: true, - heightIn: true, - minHeight: true, - maxHeight: true - } - }, - - ActivityTrainers: { - where: { isActive: true }, - select: { - id: true, - totalAmount: true - } - }, - - ActivityAllowedEntry: { - where: { isActive: true }, - select: { - id: true, - allowedEntryTypeXid: true, - allowedEntryType: { - select: { - id: true, - allowedEntryTypeName: true - } - } - } - }, - - ActivityFoodCost: { - where: { isActive: true }, - select: { - id: true, - totalAmount: true - } - }, - - activityFoodTypes: { - where: { isActive: true }, - select: { - id: true, - foodTypeXid: true, - foodType: { - select: { - id: true, - foodTypeName: true - } - } - } - }, - - ActivityEquipments: { - where: { isActive: true }, - select: { - id: true, - equipmentName: true, - isEquipmentChargeable: true, - equipmentTotalPrice: true - } - }, - - ActivityNavigationModes: { - where: { isActive: true }, - select: { - id: true, - navigationModeXid: true, - navigationMode: { - select: { - id: true, - navigationModeName: true, - navigationModeIcon: true - } - }, - isInActivityChargeable: true, - navigationModesTotalPrice: true - } - }, - - ActivityAmenities: { - where: { isActive: true }, - select: { - id: true, - amenitiesXid: true, - amenities: { - select: { - id: true, - amenitiesName: true, - amenitiesIcon: true - } - } - } - }, - - ActivityPickUpDetails: { - where: { isActive: true }, - select: { - id: true, - isPickUp: true, - locationLat: true, - locationLong: true, - locationAddress: true, - transportTotalPrice: true, - } - }, - - activityPickUpTransports: { - where: { isActive: true }, - select: { - id: true, - transportMode: { - select: { - id: true, - transportModeName: true, - transportModeIcon: true - } - } - } - }, - - activityCuisines: { - where: { isActive: true }, - select: { - id: true, - foodCuisineXid: true, - foodCuisine: { - select: { - id: true, - cuisineName: true - } - } - } - }, - - ActivityVenues: { - where: { - isActive: true - }, - select: { - id: true, - venueName: true, - venueLabel: true, - venueCapacity: true, - availableSeats: true, - isMinPeopleReqMandatory: true, - minPeopleRequired: true, - minReqfullfilledBeforeMins: true, - venueDescription: true, - ActivityVenueArtifacts: { - select: { - id: true, - mediaFileName: true, - mediaType: true - } - }, - ActivityPrices: { - select: { - id: true, - sellPrice: true, - }, - }, - }, - }, - - ActivitiesMedia: { - where: { isActive: true }, - select: { - id: true, - mediaFileName: true, - mediaType: true, - }, - }, - }, - }) - - // ================= PRESIGNED URL SECTION ================= - - // 1️⃣ Activity Media - if (Array.isArray(activity?.ActivitiesMedia)) { - activity.ActivitiesMedia = await Promise.all( - activity.ActivitiesMedia.map(async (m: any) => ({ - ...m, - presignedUrl: await attachPresignedUrl(m.mediaFileName), - })) - ); - } - - // 2️⃣ Energy Level Icon - if (activity?.activityType?.energyLevel?.energyIcon) { - activity.activityType.energyLevel.energyIcon = - await attachPresignedUrl( - activity.activityType.energyLevel.energyIcon - ); - } - - // 5️⃣ PickUp Transport Mode Icons - if (Array.isArray(activity?.activityPickUpTransports)) { - await Promise.all( - activity.activityPickUpTransports.map(async (item: any) => { - if (item?.transportMode?.transportModeIcon) { - item.transportMode.presignedUrl = - await attachPresignedUrl( - item.transportMode.transportModeIcon - ); - } - }) - ); - } - - // 3️⃣ Activity Venue Artifacts - if (Array.isArray(activity?.ActivityVenues)) { - await Promise.all( - activity.ActivityVenues.map(async (venue: any) => { - if (Array.isArray(venue?.ActivityVenueArtifacts)) { - venue.ActivityVenueArtifacts = await Promise.all( - venue.ActivityVenueArtifacts.map(async (artifact: any) => ({ - ...artifact, - presignedUrl: await attachPresignedUrl( - artifact.mediaFileName - ), - })) - ); - } - }) - ); - } - - // 3️⃣ Navigation Mode Icons - if (Array.isArray(activity?.ActivityNavigationModes)) { - await Promise.all( - activity.ActivityNavigationModes.map(async (item: any) => { - if (item?.navigationMode?.navigationModeIcon) { - item.navigationMode.presignedUrl = - await attachPresignedUrl( - item.navigationMode.navigationModeIcon - ); - } - }) - ); - } - - // 4️⃣ Amenities Icons (IMPORTANT: make sure amenitiesIcon is selected in Prisma) - if (Array.isArray(activity?.ActivityAmenities)) { - await Promise.all( - activity.ActivityAmenities.map(async (item: any) => { - if (item?.amenities?.amenitiesIcon) { - item.amenities.presignedUrl = - await attachPresignedUrl( - item.amenities.amenitiesIcon - ); - } - }) - ); - } - - const interestedCount = await tx.userBucketInterested.count({ - where: { - activityXid, - isActive: true, - }, - }) - - const prices = activity.ActivityVenues - .flatMap(v => v.ActivityPrices.map(p => p.sellPrice)) - .filter(p => p !== null) as number[]; - - const cheapestPrice = prices.length > 0 ? Math.min(...prices) : null; - - const totalCapacity = activity.ActivityVenues - .map(v => v.venueCapacity ?? 0) - .reduce((sum, capacity) => sum + capacity, 0); - - return { - activity, - interestedCount, - cheapestPrice, - totalCapacity, - rating: 0, // ⭐ Placeholder, implement rating logic as needed - distance: 0 - } - }) - } - - async searchActivities( - userId: number, - searchCriteria: { - activityTitle?: string; - activityType?: string; - checkInCity?: string; - } - ) { - const { activityTitle, activityType, checkInCity } = searchCriteria; - - // Build the where clause dynamically - const where: any = { - isActive: true, - activityInternalStatus: ACTIVITY_INTERNAL_STATUS.ACTIVITY_LISTED, - amInternalStatus: ACTIVITY_AM_INTERNAL_STATUS.ACTIVITY_LISTED, - }; - - // Add activityTitle filter if provided - if (activityTitle) { - where.activityTitle = { - contains: activityTitle, - mode: 'insensitive', - }; - } - - // Add activityType filter if provided - if (activityType) { - where.activityType = { - activityTypeName: { - contains: activityType, - mode: 'insensitive', - }, - }; - } - - // Add checkInCity filter if provided - if (checkInCity) { - where.checkInCity = { - cityName: { - contains: checkInCity, - mode: 'insensitive', - }, - }; - } - - const activities = await this.prisma.activities.findMany({ - where, + activityType: { 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 + interestXid: true, // ✅ VERY IMPORTANT + activityTypeName: true, + energyLevel: { + select: { + id: true, + energyLevelName: true, + energyColor: true, + energyIcon: true, }, + }, }, - take: 50, // Limit results to prevent too many + }, + + ActivityOtherDetails: { + where: { isActive: true }, + select: { + id: true, + exclusiveNotes: true, + dosNotes: true, + dontsNotes: true, + tipsNotes: true, + termsAndCondition: true, + }, + }, + + ActivityEligibility: { + where: { isActive: true }, + select: { + id: true, + isAgeRestriction: true, + ageRestrictionName: true, + ageEntered: true, + ageIn: true, + minAge: true, + maxAge: true, + isWeightRestriction: true, + weightRestrictionName: true, + weightEntered: true, + weightIn: true, + minWeight: true, + maxWeight: true, + isHeightRestriction: true, + heightRestrictionName: true, + heightEntered: true, + heightIn: true, + minHeight: true, + maxHeight: true, + }, + }, + + ActivityTrainers: { + where: { isActive: true }, + select: { + id: true, + totalAmount: true, + }, + }, + + ActivityAllowedEntry: { + where: { isActive: true }, + select: { + id: true, + allowedEntryTypeXid: true, + allowedEntryType: { + select: { + id: true, + allowedEntryTypeName: true, + }, + }, + }, + }, + + ActivityFoodCost: { + where: { isActive: true }, + select: { + id: true, + totalAmount: true, + }, + }, + + activityFoodTypes: { + where: { isActive: true }, + select: { + id: true, + foodTypeXid: true, + foodType: { + select: { + id: true, + foodTypeName: true, + }, + }, + }, + }, + + ActivityEquipments: { + where: { isActive: true }, + select: { + id: true, + equipmentName: true, + isEquipmentChargeable: true, + equipmentTotalPrice: true, + }, + }, + + ActivityNavigationModes: { + where: { isActive: true }, + select: { + id: true, + navigationModeXid: true, + navigationMode: { + select: { + id: true, + navigationModeName: true, + navigationModeIcon: true, + }, + }, + isInActivityChargeable: true, + navigationModesTotalPrice: true, + }, + }, + + ActivityAmenities: { + where: { isActive: true }, + select: { + id: true, + amenitiesXid: true, + amenities: { + select: { + id: true, + amenitiesName: true, + amenitiesIcon: true, + }, + }, + }, + }, + + ActivityPickUpDetails: { + where: { isActive: true }, + select: { + id: true, + isPickUp: true, + locationLat: true, + locationLong: true, + locationAddress: true, + transportTotalPrice: true, + }, + }, + + activityPickUpTransports: { + where: { isActive: true }, + select: { + id: true, + transportMode: { + select: { + id: true, + transportModeName: true, + transportModeIcon: true, + }, + }, + }, + }, + + activityCuisines: { + where: { isActive: true }, + select: { + id: true, + foodCuisineXid: true, + foodCuisine: { + select: { + id: true, + cuisineName: true, + }, + }, + }, + }, + + ActivityVenues: { + where: { + isActive: true, + }, + select: { + id: true, + venueName: true, + venueLabel: true, + venueCapacity: true, + availableSeats: true, + isMinPeopleReqMandatory: true, + minPeopleRequired: true, + minReqfullfilledBeforeMins: true, + venueDescription: true, + ActivityVenueArtifacts: { + select: { + id: true, + mediaFileName: true, + mediaType: true, + }, + }, + ActivityPrices: { + select: { + id: true, + sellPrice: true, + }, + }, + }, + }, + + ActivitiesMedia: { + where: { isActive: true }, + select: { + id: true, + mediaFileName: true, + mediaType: true, + }, + }, + }, + }); + + // ================= PRESIGNED URL SECTION ================= + + // 1️⃣ Activity Media + if (Array.isArray(activity?.ActivitiesMedia)) { + activity.ActivitiesMedia = await Promise.all( + activity.ActivitiesMedia.map(async (m: any) => ({ + ...m, + presignedUrl: await attachPresignedUrl(m.mediaFileName), + })), + ); + } + + // 2️⃣ Energy Level Icon + if (activity?.activityType?.energyLevel?.energyIcon) { + activity.activityType.energyLevel.energyIcon = await attachPresignedUrl( + activity.activityType.energyLevel.energyIcon, + ); + } + + // 5️⃣ PickUp Transport Mode Icons + if (Array.isArray(activity?.activityPickUpTransports)) { + await Promise.all( + activity.activityPickUpTransports.map(async (item: any) => { + if (item?.transportMode?.transportModeIcon) { + item.transportMode.presignedUrl = await attachPresignedUrl( + item.transportMode.transportModeIcon, + ); + } + }), + ); + } + + // 3️⃣ Activity Venue Artifacts + if (Array.isArray(activity?.ActivityVenues)) { + await Promise.all( + activity.ActivityVenues.map(async (venue: any) => { + if (Array.isArray(venue?.ActivityVenueArtifacts)) { + venue.ActivityVenueArtifacts = await Promise.all( + venue.ActivityVenueArtifacts.map(async (artifact: any) => ({ + ...artifact, + presignedUrl: await attachPresignedUrl( + artifact.mediaFileName, + ), + })), + ); + } + }), + ); + } + + // 3️⃣ Navigation Mode Icons + if (Array.isArray(activity?.ActivityNavigationModes)) { + await Promise.all( + activity.ActivityNavigationModes.map(async (item: any) => { + if (item?.navigationMode?.navigationModeIcon) { + item.navigationMode.presignedUrl = await attachPresignedUrl( + item.navigationMode.navigationModeIcon, + ); + } + }), + ); + } + + // 4️⃣ Amenities Icons (IMPORTANT: make sure amenitiesIcon is selected in Prisma) + if (Array.isArray(activity?.ActivityAmenities)) { + await Promise.all( + activity.ActivityAmenities.map(async (item: any) => { + if (item?.amenities?.amenitiesIcon) { + item.amenities.presignedUrl = await attachPresignedUrl( + item.amenities.amenitiesIcon, + ); + } + }), + ); + } + + const interestedCount = await tx.userBucketInterested.count({ + where: { + activityXid, + isActive: true, + }, + }); + + const prices = activity.ActivityVenues.flatMap((v) => + v.ActivityPrices.map((p) => p.sellPrice), + ).filter((p) => p !== null) as number[]; + + const cheapestPrice = prices.length > 0 ? Math.min(...prices) : null; + + const totalCapacity = activity.ActivityVenues.map( + (v) => v.venueCapacity ?? 0, + ).reduce((sum, capacity) => sum + capacity, 0); + + return { + activity, + interestedCount, + cheapestPrice, + totalCapacity, + rating: 0, // ⭐ Placeholder, implement rating logic as needed + distance: 0, + }; + }); + } + + + async searchActivities( + userId: number, + searchCriteria: { + activityTitle?: string; + activityType?: string; + checkInCity?: string; + }, + ) { + const { activityTitle, activityType, checkInCity } = searchCriteria; + + // Build the where clause dynamically + const where: any = { + isActive: true, + activityInternalStatus: ACTIVITY_INTERNAL_STATUS.ACTIVITY_LISTED, + amInternalStatus: ACTIVITY_AM_INTERNAL_STATUS.ACTIVITY_LISTED, + }; + + // Add activityTitle filter if provided + if (activityTitle) { + where.activityTitle = { + contains: activityTitle, + mode: 'insensitive', + }; + } + + // Add activityType filter if provided + if (activityType) { + where.activityType = { + activityTypeName: { + contains: activityType, + mode: 'insensitive', + }, + }; + } + + // Add checkInCity filter if provided + if (checkInCity) { + where.checkInCity = { + cityName: { + contains: checkInCity, + mode: 'insensitive', + }, + }; + } + + const activities = await this.prisma.activities.findMany({ + where, + select: { + id: true, + activityTitle: true, + activityDescription: true, + checkInAddress: true, + activityDurationMins: true, + sustainabilityScore: true, + activityRefNumber: true, + activityType: { + select: { + activityTypeName: true, + energyLevel: { + select: { + energyLevelName: true, + energyColor: true, + energyIcon: true, + }, + }, + }, + }, + checkInCity: { + select: { + cityName: true, + }, + }, + ActivitiesMedia: { + where: { isActive: true }, + select: { + id: true, + mediaFileName: true, + mediaType: true, + }, + take: 1, // Get first media item + }, + }, + take: 50, // Limit results to prevent too many + }); + + // Get interested count for each activity + const activitiesWithCounts = await Promise.all( + activities.map(async (activity) => { + const interestedCount = await this.prisma.userBucketInterested.count({ + where: { + activityXid: activity.id, + isActive: true, + }, }); - // 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 - }; - }) + // Attach presigned URLs to media + const mediaWithUrls = await attachMediaWithPresignedUrl( + activity.ActivitiesMedia, ); - return activitiesWithCounts; - } + return { + ...activity, + ActivitiesMediaPresignedUrl: mediaWithUrls, + interestedCount, + rating: 0, // Placeholder + distance: 0, // Placeholder + }; + }), + ); + return activitiesWithCounts; + } - // CONNECTIONS + // 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, + 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: { @@ -1837,12 +1875,126 @@ export class UserService { } } } - } - } - } - }) + }, + }, + }, + }); + } + + async searchSchoolsAndCompanies(searchQuery: string, isSchool: boolean) { + if (!searchQuery) { + throw new ApiError( + 400, + 'Search query is required to search for schools or companies', + ); } + const results = await this.prisma.schoolCompany.findMany({ + where: { + schoolCompanyName: { + contains: searchQuery, + mode: 'insensitive', + }, + isSchool: isSchool, + isActive: true, + deletedAt: null, + }, + select: { + id: true, + schoolCompanyName: true, + isSchool: true, + isActive: true, + createdAt: true, + }, + }); + + return results; + } + + async searchCities(searchQuery: string) { + if (!searchQuery || searchQuery.length < 2) { + throw new ApiError( + 400, + 'Search query must be at least 2 characters long', + ); + } + + const results = await this.prisma.cities.findMany({ + where: { + cityName: { + contains: searchQuery, + mode: 'insensitive', + }, + isActive: true, + deletedAt: null, + }, + select: { + id: true, + cityName: true, + stateXid: true, + isActive: true, + createdAt: true, + }, + }); + + return results; + } + + + + async addOrFindSchoolCompanyDetail(dto: AddSchoolCompanyDetailDTO) { + const { schoolCompanyName, isSchool, cityXid } = 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) + const existing = await this.prisma.schoolCompany.findFirst({ + where: { + schoolCompanyName: normalizedName, + cityXid, + isSchool, + isActive: true, + deletedAt: null, + }, + }); + + if (existing) { + return { + isNew: false, + data: existing, + message: "Already exists", + }; + } + + // ✅ 3. Create new (store lowercase) + const created = await this.prisma.schoolCompany.create({ + data: { + schoolCompanyName: normalizedName, + isSchool, + cityXid, + }, + }); + + return { + isNew: true, + data: created, + message: "Created successfully", + }; + } + async getAllActivitiesFromConnectionsUserInterests( userId: number, schoolCompanyXid: number,