diff --git a/serverless/functions/user.yml b/serverless/functions/user.yml index e19d7da..46cd536 100644 --- a/serverless/functions/user.yml +++ b/serverless/functions/user.yml @@ -271,4 +271,19 @@ getActivityFromConnectionsInterest: events: - httpApi: path: /user/connections/get-activity-from-connections-interest + method: get + +viewMoreActivitiesByInterest: + handler: src/modules/user/handlers/activities/viewMoreActivities.handler + memorySize: 384 + package: + patterns: + - 'src/modules/user/handlers/activities/**' + - ${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/activities/view-more-activities method: get \ No newline at end of file diff --git a/src/modules/user/handlers/activities/viewMoreActivities.ts b/src/modules/user/handlers/activities/viewMoreActivities.ts new file mode 100644 index 0000000..e746c2c --- /dev/null +++ b/src/modules/user/handlers/activities/viewMoreActivities.ts @@ -0,0 +1,54 @@ +import { APIGatewayProxyEvent, APIGatewayProxyResult, Context } from 'aws-lambda'; +import { safeHandler } from '../../../../common/utils/handlers/safeHandler'; +import { prismaClient } from '../../../../common/database/prisma.lambda.service'; +import ApiError from '../../../../common/utils/helper/ApiError'; +import { UserService } from '../../services/user.service'; +import { verifyUserToken } from '../../../../common/middlewares/jwt/authForUser'; + +const userService = new UserService(prismaClient); + +export const handler = safeHandler(async ( + event: APIGatewayProxyEvent, + context?: Context +): Promise => { + + const token = event.headers['x-auth-token'] || event.headers['X-Auth-Token']; + if (!token) { + throw new ApiError(400, 'This is a protected route.'); + } + + const userInfo = await verifyUserToken(token); + const userId = Number(userInfo.id); + + const interestId = Number(event.queryStringParameters?.interestId); + const page = Number(event.queryStringParameters?.page ?? 1); + const limit = Number(event.queryStringParameters?.limit ?? 20); + + if (!interestId) { + throw new ApiError(400, 'Interest ID is required'); + } + + if (page < 1 || limit < 1) { + throw new ApiError(400, 'Invalid pagination values'); + } + + const result = await userService.viewMoreActivitiesByInterest( + userId, + interestId, + page, + limit + ); + + return { + statusCode: 200, + headers: { + 'Content-Type': 'application/json', + 'Access-Control-Allow-Origin': '*', + }, + body: JSON.stringify({ + success: true, + message: 'Interest activities fetched successfully', + data: result, + }), + }; +}); diff --git a/src/modules/user/services/user.service.ts b/src/modules/user/services/user.service.ts index 26499fd..64b3337 100644 --- a/src/modules/user/services/user.service.ts +++ b/src/modules/user/services/user.service.ts @@ -306,11 +306,11 @@ async function rankAndPaginateActivities( // cheapestPrice, energyLevel: activity.activityType.energyLevel ? { - ...activity.activityType.energyLevel, - presignedUrl: await attachPresignedUrl( - activity.activityType.energyLevel.energyIcon, - ), - } + ...activity.activityType.energyLevel, + presignedUrl: await attachPresignedUrl( + activity.activityType.energyLevel.energyIcon, + ), + } : null, media: await attachMediaWithPresignedUrl(activity.ActivitiesMedia), }; @@ -328,7 +328,7 @@ async function rankAndPaginateActivities( @Injectable() export class UserService { - constructor(private prisma: PrismaClient) {} + constructor(private prisma: PrismaClient) { } async getUserById(userId: number) { return this.prisma.user.findUnique({ @@ -865,11 +865,11 @@ export class UserService { hypeCount: activity.hypeCount, energyLevel: activity.activityType.energyLevel ? { - ...activity.activityType.energyLevel, - presignedUrl: await attachPresignedUrl( - activity.activityType.energyLevel.energyIcon, - ), - } + ...activity.activityType.energyLevel, + presignedUrl: await attachPresignedUrl( + activity.activityType.energyLevel.energyIcon, + ), + } : null, media: await attachMediaWithPresignedUrl(activity.ActivitiesMedia), })), @@ -960,11 +960,11 @@ export class UserService { cheapestPrice, energyLevel: activity.activityType.energyLevel ? { - ...activity.activityType.energyLevel, - presignedUrl: await attachPresignedUrl( - activity.activityType.energyLevel.energyIcon, - ), - } + ...activity.activityType.energyLevel, + presignedUrl: await attachPresignedUrl( + activity.activityType.energyLevel.energyIcon, + ), + } : null, media: await attachMediaWithPresignedUrl(activity.ActivitiesMedia), }; @@ -1735,7 +1735,7 @@ export class UserService { }); } - + async searchActivities( userId: number, searchCriteria: { @@ -1861,20 +1861,20 @@ export class UserService { id: true, schoolCompanyName: true, isSchool: true, - cityXid: true, - cities: { - select: { - id: true, - cityName: true, - stateXid: true, - states: { - select: { - id: true, - stateName: true - } - } - } - } + cityXid: true, + cities: { + select: { + id: true, + cityName: true, + stateXid: true, + states: { + select: { + id: true, + stateName: true + } + } + } + } }, }, }, @@ -1940,13 +1940,13 @@ export class UserService { 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: { @@ -1955,11 +1955,11 @@ export class UserService { deletedAt: null, }, }); - + if (!cityExists) { throw new ApiError(404, "City not found"); } - + // ✅ 2. Check existing (lowercase match) const existing = await this.prisma.schoolCompany.findFirst({ where: { @@ -1970,7 +1970,7 @@ export class UserService { deletedAt: null, }, }); - + if (existing) { return { isNew: false, @@ -1978,7 +1978,7 @@ export class UserService { message: "Already exists", }; } - + // ✅ 3. Create new (store lowercase) const created = await this.prisma.schoolCompany.create({ data: { @@ -1987,469 +1987,600 @@ export class UserService { cityXid, }, }); - + return { isNew: true, data: created, message: "Created successfully", }; } - - async getAllActivitiesFromConnectionsUserInterests( - userId: number, - schoolCompanyXids: number[], - page: number, - limit: number, - countryName: string, - stateName: string, - cityName: string, - ) { - const data = await this.prisma.$transaction(async (tx) => { - const networkUsers = await tx.connectDetails.findMany({ - where: { - isActive: true, - schoolCompanyXid: { - in: schoolCompanyXids, - }, - userXid: { - not: userId, - }, - }, - select: { - userXid: true, - }, - }); + async getAllActivitiesFromConnectionsUserInterests( + userId: number, + schoolCompanyXids: number[], + page: number, + limit: number, + countryName: string, + stateName: string, + cityName: string, + ) { + const data = await this.prisma.$transaction(async (tx) => { - if (!networkUsers.length) { - return { - interests: [], - mostHypedActivities: null, - newArrivalsActivities: null, - otherStatesActivities: null, - overSeasActivities: null, - }; + const networkUsers = await tx.connectDetails.findMany({ + where: { + isActive: true, + schoolCompanyXid: { + in: schoolCompanyXids, + }, + userXid: { + not: userId, + }, + }, + select: { + userXid: true, + }, + }); + + if (!networkUsers.length) { + return { + interests: [], + mostHypedActivities: null, + newArrivalsActivities: null, + otherStatesActivities: null, + overSeasActivities: null, + }; + } + + const networkUserIds = [...new Set(networkUsers.map(u => u.userXid))]; + + const networkUserInterests = await tx.userInterests.findMany({ + where: { + userXid: { in: networkUserIds }, + isActive: true, + }, + distinct: ['interestXid'], + select: { + interestXid: true, + interest: { + select: { + id: true, + interestName: true, + interestColor: true, + interestImage: true, + displayOrder: true, } + } + } + }); - const networkUserIds = [...new Set(networkUsers.map(u => u.userXid))]; + const distinctInterests = networkUserInterests.map(i => i.interestXid); - const networkUserInterests = await tx.userInterests.findMany({ - where: { - userXid: { in: networkUserIds }, - isActive: true, - }, - distinct: ['interestXid'], - select: { - interestXid: true, - interest: { - select: { - id: true, - interestName: true, - interestColor: true, - interestImage: true, - displayOrder: true, - } - } - } - }); + if (!distinctInterests.length) { + return { + interests: [], + mostHypedActivities: null, + newArrivalsActivities: null, + otherStatesActivities: null, + overSeasActivities: null, + }; + } - const distinctInterests = networkUserInterests.map(i => i.interestXid); + const activityTypes = await tx.activityTypes.findMany({ + where: { + interestXid: { + in: distinctInterests, + }, + isActive: true, + }, + select: { id: true } + }); - if (!distinctInterests.length) { - return { - interests: [], - mostHypedActivities: null, - newArrivalsActivities: null, - otherStatesActivities: null, - overSeasActivities: null, - }; - } + if (!activityTypes.length) { + return { + interests: [], + mostHypedActivities: null, + newArrivalsActivities: null, + otherStatesActivities: null, + overSeasActivities: null, + }; + } - const activityTypes = await tx.activityTypes.findMany({ - where: { - interestXid: { - in: distinctInterests, - }, - isActive: true, - }, - select: { id: true } - }); + const userAddressDetails = await tx.userAddressDetails.findFirst({ + where: { userXid: userId }, + select: { + stateXid: true, + cityXid: true, + countryXid: true, + }, + }); - if (!activityTypes.length) { - return { - interests: [], - mostHypedActivities: null, - newArrivalsActivities: null, - otherStatesActivities: null, - overSeasActivities: null, - }; - } - - const userAddressDetails = await tx.userAddressDetails.findFirst({ - where: { userXid: userId }, - select: { - stateXid: true, - cityXid: true, - countryXid: true, - }, - }); - - let effectiveLocation: { - countryXid?: number | null; - stateXid?: number | null; - cityXid?: number | null; - } | null = null; - - if (countryName && stateName && cityName) { - effectiveLocation = await findOrCreateLocation(tx, { - countryName, - stateName, - cityName, - }); - } else if (userAddressDetails) { - effectiveLocation = { - countryXid: userAddressDetails.countryXid, - stateXid: userAddressDetails.stateXid, - cityXid: userAddressDetails.cityXid, - }; - } - - const skip = (page - 1) * limit; - - const effectiveCountryXid = effectiveLocation?.countryXid ?? null; - const effectiveStateXid = effectiveLocation?.stateXid ?? null; - - /* ===================================================== - 1️⃣ FETCH ALL CANDIDATES FOR INTERESTS (SIMPLE SORT) - ===================================================== */ - // Reverted to simple ID based sorting for Interest-based activities - const activities = await tx.activities.findMany({ - where: { - isActive: true, - activityInternalStatus: ACTIVITY_INTERNAL_STATUS.ACTIVITY_LISTED, - amInternalStatus: ACTIVITY_AM_INTERNAL_STATUS.ACTIVITY_LISTED, - activityTypeXid: { - in: activityTypes.map(at => at.id), - }, - }, - skip, - take: limit, - orderBy: { id: 'desc' }, - select: { - id: true, - activityTitle: true, - activityDurationMins: true, - sustainabilityScore: true, - checkInLat: true, - checkInLong: true, - activityType: { - select: { - interestXid: true, - energyLevel: { - select: { - id: true, - energyLevelName: true, - energyColor: true, - energyIcon: true, - }, - }, - }, - }, - ActivityVenues: { - select: { - ActivityPrices: { - select: { - sellPrice: true, - }, - }, - }, - }, - ActivitiesMedia: { - where: { isActive: true }, - select: { - id: true, - mediaFileName: true, - mediaType: true, - }, - }, - }, - }); - - const activityTypeIds = activityTypes.map(a => a.id); - - const mostHypedTotalCount = await tx.userBucketInterested.groupBy({ - by: ['activityXid'], - where: { - isActive: true, - isBucket: false, - Activities: { - activityTypeXid: { in: activityTypeIds }, - isActive: true, - activityInternalStatus: ACTIVITY_INTERNAL_STATUS.ACTIVITY_LISTED, - amInternalStatus: ACTIVITY_AM_INTERNAL_STATUS.ACTIVITY_LISTED, - } - } - }); - - const totalHypedActivities = mostHypedTotalCount.length; - - /* ===================================================== - 2️⃣ MOST HYPED ACTIVITIES (RANKED) - ===================================================== */ - const mostHypedGrouped = await tx.userBucketInterested.groupBy({ - by: ['activityXid'], - where: { - isActive: true, - isBucket: false, - Activities: { - activityTypeXid: { in: activityTypeIds }, - isActive: true, - activityInternalStatus: ACTIVITY_INTERNAL_STATUS.ACTIVITY_LISTED, - amInternalStatus: ACTIVITY_AM_INTERNAL_STATUS.ACTIVITY_LISTED, - } - }, - _count: { - activityXid: true, - }, - orderBy: { - _count: { - activityXid: 'desc', - }, - }, - skip, - take: limit, - }); - - const mostHypedActivityIds = mostHypedGrouped.map(a => a.activityXid); - - // Fetch metadata for ranking only for the top hyped ones (optimization: double sorting might be needed if we want to sort BY rating WITHIN the hyped list, but usually Hyped = Count. - // IF user wants the standard 4-step ranking applied TO the most hyped items: - const mostHypedActivitiesRaw = await tx.activities.findMany({ - where: { - id: { in: mostHypedActivityIds } - }, - select: { - id: true, - activityTitle: true, - sustainabilityScore: true, - totalScore: true, - activityType: { - select: { - energyLevel: { - select: { - id: true, - energyLevelName: true, - energyColor: true, - energyIcon: true, - }, - }, - }, - }, - ActivitiesMedia: { - where: { isActive: true }, - select: { - id: true, - mediaFileName: true, - mediaType: true, - }, - }, - // Fetch ranking metadata - ItineraryActivities: { - select: { - ActivityFeedbacks: { - select: { activityStars: true }, - }, - }, - }, - ActivityVenues: { - select: { - ActivityPrices: { - select: { sellPrice: true }, - }, - }, - }, - }, - }); - - const hypeCountMap = new Map( - mostHypedGrouped.map(g => [g.activityXid, g._count.activityXid]) - ); - - // Sort Most Hyped by the 4 criteria - const mostHypedSorted = mostHypedActivitiesRaw.map(act => { - const feedbacks = act.ItineraryActivities.flatMap(ia => ia.ActivityFeedbacks); - const totalStars = feedbacks.reduce((sum, f) => sum + f.activityStars, 0); - const avgRating = feedbacks.length > 0 ? totalStars / feedbacks.length : 0; - const prices = act.ActivityVenues.flatMap(v => v.ActivityPrices.map(p => p.sellPrice)).filter(p => p !== null) as number[]; - const minPrice = prices.length > 0 ? Math.min(...prices) : Infinity; - - return { - ...act, // Keep original fields for final output - avgRating, - minPrice, - sustainabilityScore: act.sustainabilityScore ?? 0, - totalScore: act.totalScore ?? 0, - hypeCount: hypeCountMap.get(act.id) ?? 0 - }; - }).sort((a, b) => { - // 1. Rating (Highest first) - if (b.avgRating !== a.avgRating) return b.avgRating - a.avgRating; - // 2. Price (Lowest first) - if (a.minPrice !== b.minPrice) return a.minPrice - b.minPrice; - // 3. Sustainability Score - if (b.sustainabilityScore !== a.sustainabilityScore) return b.sustainabilityScore - a.sustainabilityScore; - // 4. Quality Score - return b.totalScore - a.totalScore; - }); - - const mostHypedActivities = await Promise.all( - mostHypedSorted.map(async activity => ({ - activityId: activity.id, - activityTitle: activity.activityTitle, - hypeCount: activity.hypeCount, - energyLevel: activity.activityType.energyLevel - ? { - ...activity.activityType.energyLevel, - presignedUrl: await attachPresignedUrl( - activity.activityType.energyLevel.energyIcon - ), - } - : null, - media: await attachMediaWithPresignedUrl(activity.ActivitiesMedia), - })) - ); - - const formattedMostHypedActivities = { - page, - limit, - totalCount: totalHypedActivities, - hasMore: skip + limit < totalHypedActivities, - activities: mostHypedActivities, - }; - - - /* ===================================================== - 3️⃣ NEW ARRIVALS (RANKED) - ===================================================== */ - const newArrivalsWhere = { - isActive: true, - activityInternalStatus: ACTIVITY_INTERNAL_STATUS.ACTIVITY_LISTED, - amInternalStatus: ACTIVITY_AM_INTERNAL_STATUS.ACTIVITY_LISTED, - activityTypeXid: { in: activityTypeIds }, - createdAt: { gte: new Date(Date.now() - 31 * 24 * 60 * 60 * 1000) } - }; - - const formattedNewArrivalsActivities = await rankAndPaginateActivities(tx, newArrivalsWhere, page, limit); - - /* ===================================================== - 4️⃣ OTHER STATES ACTIVITIES (RANKED) - ===================================================== */ - const otherStatesWhere: any = { - isActive: true, - activityInternalStatus: ACTIVITY_INTERNAL_STATUS.ACTIVITY_LISTED, - amInternalStatus: ACTIVITY_AM_INTERNAL_STATUS.ACTIVITY_LISTED, - activityTypeXid: { in: activityTypeIds }, - }; - - if (effectiveCountryXid) { - otherStatesWhere.checkInCountryXid = effectiveCountryXid; - } - if (effectiveStateXid) { - otherStatesWhere.checkInStateXid = { not: effectiveStateXid }; - } - - const formattedOtherStatesActivities = await rankAndPaginateActivities(tx, otherStatesWhere, page, limit); - - - /* ===================================================== - 5️⃣ OVERSEAS ACTIVITIES (RANKED) - ===================================================== */ - const overseasWhere: any = { - isActive: true, - activityInternalStatus: ACTIVITY_INTERNAL_STATUS.ACTIVITY_LISTED, - amInternalStatus: ACTIVITY_AM_INTERNAL_STATUS.ACTIVITY_LISTED, - activityTypeXid: { in: activityTypeIds }, - }; - - if (effectiveCountryXid) { - overseasWhere.checkInCountryXid = { not: effectiveCountryXid }; - } - - const formattedOverSeasActivities = await rankAndPaginateActivities(tx, overseasWhere, page, limit); - - - const formattedActivities = await Promise.all( - activities.map(async (activity) => { - const cheapestPrice = - activity.ActivityVenues.flatMap(v => v.ActivityPrices) - .map(p => p.sellPrice) - .filter(Boolean) - .sort((a, b) => a - b)[0] ?? null; - - return { - interestXid: activity.activityType.interestXid, - activityId: activity.id, - activityTitle: activity.activityTitle, - activityDurationMins: activity.activityDurationMins, - sustainabilityScore: activity.sustainabilityScore, - cheapestPrice, - energyLevel: activity.activityType.energyLevel - ? { - ...activity.activityType.energyLevel, - presignedUrl: await attachPresignedUrl( - activity.activityType.energyLevel.energyIcon - ), - } - : null, - media: await attachMediaWithPresignedUrl(activity.ActivitiesMedia), - }; - }) - ); - - const interestsWithActivities = await Promise.all( - networkUserInterests - .sort((a, b) => - a.interest.interestName.localeCompare(b.interest.interestName) - ) - .map(async (ui) => ({ - interestId: ui.interest.id, - interestName: ui.interest.interestName, - interestColor: ui.interest.interestColor, - interestImage: ui.interest.interestImage, - interestImagePresignedUrl: await attachPresignedUrl( - ui.interest.interestImage - ), - displayOrder: ui.interest.displayOrder, - page, - limit, - hasMore: formattedActivities.length === limit, - activities: formattedActivities - .filter(a => a.interestXid === ui.interestXid) - .map(({ interestXid, ...rest }) => rest), - })) - ); - - - - - return { - experiencesLogged: 25, - citiesDiscovered: 10, - loggedInNetworkCount: 0, - citiesInNetworkCount: 0, - pagination: { - page, - limit, - }, - interests: interestsWithActivities, - otherStatesActivities: formattedOtherStatesActivities, - overSeasActivities: formattedOverSeasActivities, - newArrivalsActivities: formattedNewArrivalsActivities, - mostHypedActivities: formattedMostHypedActivities, - }; + let effectiveLocation: { + countryXid?: number | null; + stateXid?: number | null; + cityXid?: number | null; + } | null = null; + if (countryName && stateName && cityName) { + effectiveLocation = await findOrCreateLocation(tx, { + countryName, + stateName, + cityName, }); - return data; + } else if (userAddressDetails) { + effectiveLocation = { + countryXid: userAddressDetails.countryXid, + stateXid: userAddressDetails.stateXid, + cityXid: userAddressDetails.cityXid, + }; + } + + const skip = (page - 1) * limit; + + const effectiveCountryXid = effectiveLocation?.countryXid ?? null; + const effectiveStateXid = effectiveLocation?.stateXid ?? null; + + /* ===================================================== + 1️⃣ FETCH ALL CANDIDATES FOR INTERESTS (SIMPLE SORT) + ===================================================== */ + // Reverted to simple ID based sorting for Interest-based activities + const activities = await tx.activities.findMany({ + where: { + isActive: true, + activityInternalStatus: ACTIVITY_INTERNAL_STATUS.ACTIVITY_LISTED, + amInternalStatus: ACTIVITY_AM_INTERNAL_STATUS.ACTIVITY_LISTED, + activityTypeXid: { + in: activityTypes.map(at => at.id), + }, + }, + skip, + take: limit, + orderBy: { id: 'desc' }, + select: { + id: true, + activityTitle: true, + activityDurationMins: true, + sustainabilityScore: true, + checkInLat: true, + checkInLong: true, + activityType: { + select: { + interestXid: true, + energyLevel: { + select: { + id: true, + energyLevelName: true, + energyColor: true, + energyIcon: true, + }, + }, + }, + }, + ActivityVenues: { + select: { + ActivityPrices: { + select: { + sellPrice: true, + }, + }, + }, + }, + ActivitiesMedia: { + where: { isActive: true }, + select: { + id: true, + mediaFileName: true, + mediaType: true, + }, + }, + }, + }); + + const activityTypeIds = activityTypes.map(a => a.id); + + const mostHypedTotalCount = await tx.userBucketInterested.groupBy({ + by: ['activityXid'], + where: { + isActive: true, + isBucket: false, + Activities: { + activityTypeXid: { in: activityTypeIds }, + isActive: true, + activityInternalStatus: ACTIVITY_INTERNAL_STATUS.ACTIVITY_LISTED, + amInternalStatus: ACTIVITY_AM_INTERNAL_STATUS.ACTIVITY_LISTED, + } + } + }); + + const totalHypedActivities = mostHypedTotalCount.length; + + /* ===================================================== + 2️⃣ MOST HYPED ACTIVITIES (RANKED) + ===================================================== */ + const mostHypedGrouped = await tx.userBucketInterested.groupBy({ + by: ['activityXid'], + where: { + isActive: true, + isBucket: false, + Activities: { + activityTypeXid: { in: activityTypeIds }, + isActive: true, + activityInternalStatus: ACTIVITY_INTERNAL_STATUS.ACTIVITY_LISTED, + amInternalStatus: ACTIVITY_AM_INTERNAL_STATUS.ACTIVITY_LISTED, + } + }, + _count: { + activityXid: true, + }, + orderBy: { + _count: { + activityXid: 'desc', + }, + }, + skip, + take: limit, + }); + + const mostHypedActivityIds = mostHypedGrouped.map(a => a.activityXid); + + // Fetch metadata for ranking only for the top hyped ones (optimization: double sorting might be needed if we want to sort BY rating WITHIN the hyped list, but usually Hyped = Count. + // IF user wants the standard 4-step ranking applied TO the most hyped items: + const mostHypedActivitiesRaw = await tx.activities.findMany({ + where: { + id: { in: mostHypedActivityIds } + }, + select: { + id: true, + activityTitle: true, + sustainabilityScore: true, + totalScore: true, + activityType: { + select: { + energyLevel: { + select: { + id: true, + energyLevelName: true, + energyColor: true, + energyIcon: true, + }, + }, + }, + }, + ActivitiesMedia: { + where: { isActive: true }, + select: { + id: true, + mediaFileName: true, + mediaType: true, + }, + }, + // Fetch ranking metadata + ItineraryActivities: { + select: { + ActivityFeedbacks: { + select: { activityStars: true }, + }, + }, + }, + ActivityVenues: { + select: { + ActivityPrices: { + select: { sellPrice: true }, + }, + }, + }, + }, + }); + + const hypeCountMap = new Map( + mostHypedGrouped.map(g => [g.activityXid, g._count.activityXid]) + ); + + // Sort Most Hyped by the 4 criteria + const mostHypedSorted = mostHypedActivitiesRaw.map(act => { + const feedbacks = act.ItineraryActivities.flatMap(ia => ia.ActivityFeedbacks); + const totalStars = feedbacks.reduce((sum, f) => sum + f.activityStars, 0); + const avgRating = feedbacks.length > 0 ? totalStars / feedbacks.length : 0; + const prices = act.ActivityVenues.flatMap(v => v.ActivityPrices.map(p => p.sellPrice)).filter(p => p !== null) as number[]; + const minPrice = prices.length > 0 ? Math.min(...prices) : Infinity; + + return { + ...act, // Keep original fields for final output + avgRating, + minPrice, + sustainabilityScore: act.sustainabilityScore ?? 0, + totalScore: act.totalScore ?? 0, + hypeCount: hypeCountMap.get(act.id) ?? 0 + }; + }).sort((a, b) => { + // 1. Rating (Highest first) + if (b.avgRating !== a.avgRating) return b.avgRating - a.avgRating; + // 2. Price (Lowest first) + if (a.minPrice !== b.minPrice) return a.minPrice - b.minPrice; + // 3. Sustainability Score + if (b.sustainabilityScore !== a.sustainabilityScore) return b.sustainabilityScore - a.sustainabilityScore; + // 4. Quality Score + return b.totalScore - a.totalScore; + }); + + const mostHypedActivities = await Promise.all( + mostHypedSorted.map(async activity => ({ + activityId: activity.id, + activityTitle: activity.activityTitle, + hypeCount: activity.hypeCount, + energyLevel: activity.activityType.energyLevel + ? { + ...activity.activityType.energyLevel, + presignedUrl: await attachPresignedUrl( + activity.activityType.energyLevel.energyIcon + ), + } + : null, + media: await attachMediaWithPresignedUrl(activity.ActivitiesMedia), + })) + ); + + const formattedMostHypedActivities = { + page, + limit, + totalCount: totalHypedActivities, + hasMore: skip + limit < totalHypedActivities, + activities: mostHypedActivities, + }; + + + /* ===================================================== + 3️⃣ NEW ARRIVALS (RANKED) + ===================================================== */ + const newArrivalsWhere = { + isActive: true, + activityInternalStatus: ACTIVITY_INTERNAL_STATUS.ACTIVITY_LISTED, + amInternalStatus: ACTIVITY_AM_INTERNAL_STATUS.ACTIVITY_LISTED, + activityTypeXid: { in: activityTypeIds }, + createdAt: { gte: new Date(Date.now() - 31 * 24 * 60 * 60 * 1000) } + }; + + const formattedNewArrivalsActivities = await rankAndPaginateActivities(tx, newArrivalsWhere, page, limit); + + /* ===================================================== + 4️⃣ OTHER STATES ACTIVITIES (RANKED) + ===================================================== */ + const otherStatesWhere: any = { + isActive: true, + activityInternalStatus: ACTIVITY_INTERNAL_STATUS.ACTIVITY_LISTED, + amInternalStatus: ACTIVITY_AM_INTERNAL_STATUS.ACTIVITY_LISTED, + activityTypeXid: { in: activityTypeIds }, + }; + + if (effectiveCountryXid) { + otherStatesWhere.checkInCountryXid = effectiveCountryXid; + } + if (effectiveStateXid) { + otherStatesWhere.checkInStateXid = { not: effectiveStateXid }; + } + + const formattedOtherStatesActivities = await rankAndPaginateActivities(tx, otherStatesWhere, page, limit); + + + /* ===================================================== + 5️⃣ OVERSEAS ACTIVITIES (RANKED) + ===================================================== */ + const overseasWhere: any = { + isActive: true, + activityInternalStatus: ACTIVITY_INTERNAL_STATUS.ACTIVITY_LISTED, + amInternalStatus: ACTIVITY_AM_INTERNAL_STATUS.ACTIVITY_LISTED, + activityTypeXid: { in: activityTypeIds }, + }; + + if (effectiveCountryXid) { + overseasWhere.checkInCountryXid = { not: effectiveCountryXid }; + } + + const formattedOverSeasActivities = await rankAndPaginateActivities(tx, overseasWhere, page, limit); + + + const formattedActivities = await Promise.all( + activities.map(async (activity) => { + const cheapestPrice = + activity.ActivityVenues.flatMap(v => v.ActivityPrices) + .map(p => p.sellPrice) + .filter(Boolean) + .sort((a, b) => a - b)[0] ?? null; + + return { + interestXid: activity.activityType.interestXid, + activityId: activity.id, + activityTitle: activity.activityTitle, + activityDurationMins: activity.activityDurationMins, + sustainabilityScore: activity.sustainabilityScore, + cheapestPrice, + energyLevel: activity.activityType.energyLevel + ? { + ...activity.activityType.energyLevel, + presignedUrl: await attachPresignedUrl( + activity.activityType.energyLevel.energyIcon + ), + } + : null, + media: await attachMediaWithPresignedUrl(activity.ActivitiesMedia), + }; + }) + ); + + const interestsWithActivities = await Promise.all( + networkUserInterests + .sort((a, b) => + a.interest.interestName.localeCompare(b.interest.interestName) + ) + .map(async (ui) => ({ + interestId: ui.interest.id, + interestName: ui.interest.interestName, + interestColor: ui.interest.interestColor, + interestImage: ui.interest.interestImage, + interestImagePresignedUrl: await attachPresignedUrl( + ui.interest.interestImage + ), + displayOrder: ui.interest.displayOrder, + page, + limit, + hasMore: formattedActivities.length === limit, + activities: formattedActivities + .filter(a => a.interestXid === ui.interestXid) + .map(({ interestXid, ...rest }) => rest), + })) + ); + + + + + return { + experiencesLogged: 25, + citiesDiscovered: 10, + loggedInNetworkCount: 0, + citiesInNetworkCount: 0, + pagination: { + page, + limit, + }, + interests: interestsWithActivities, + otherStatesActivities: formattedOtherStatesActivities, + overSeasActivities: formattedOverSeasActivities, + newArrivalsActivities: formattedNewArrivalsActivities, + mostHypedActivities: formattedMostHypedActivities, + }; + + }); + return data; + + } + + async viewMoreActivitiesByInterest( + userId: number, + interestId: number, + page: number, + limit: number + ) { + return await this.prisma.$transaction(async (tx) => { + + const skip = (page - 1) * limit; + + // 1️⃣ Get activity types under this interest + const activityTypes = await tx.activityTypes.findMany({ + where: { + interestXid: interestId, + isActive: true, + }, + select: { id: true }, + }); + + if (!activityTypes.length) { + return { + interestId, + page, + limit, + totalCount: 0, + hasMore: false, + activities: [], + }; + } + + // 2️⃣ Total Count + const totalCount = await tx.activities.count({ + where: { + isActive: true, + activityInternalStatus: ACTIVITY_INTERNAL_STATUS.ACTIVITY_LISTED, + amInternalStatus: ACTIVITY_AM_INTERNAL_STATUS.ACTIVITY_LISTED, + activityTypeXid: { + in: activityTypes.map((a) => a.id), + }, + }, + }); + + // 3️⃣ Fetch Paginated Activities + const activities = await tx.activities.findMany({ + where: { + isActive: true, + activityInternalStatus: ACTIVITY_INTERNAL_STATUS.ACTIVITY_LISTED, + amInternalStatus: ACTIVITY_AM_INTERNAL_STATUS.ACTIVITY_LISTED, + activityTypeXid: { + in: activityTypes.map((a) => a.id), + }, + }, + skip, + take: limit, + orderBy: { id: 'desc' }, + select: { + id: true, + activityTitle: true, + activityDurationMins: true, + sustainabilityScore: true, + totalScore: true, + activityType: { + select: { + energyLevel: { + select: { + id: true, + energyLevelName: true, + energyColor: true, + energyIcon: true, + }, + }, + }, + }, + ActivityVenues: { + select: { + ActivityPrices: { + select: { sellPrice: true }, + }, + }, + }, + ActivitiesMedia: { + where: { isActive: true }, + select: { + id: true, + mediaFileName: true, + mediaType: true, + }, + }, + }, + }); + + // 4️⃣ Format Response + const formattedActivities = await Promise.all( + activities.map(async (activity) => { + const cheapestPrice = + activity.ActivityVenues.flatMap((v) => v.ActivityPrices) + .map((p) => p.sellPrice) + .filter(Boolean) + .sort((a, b) => a - b)[0] ?? null; + + return { + activityId: activity.id, + activityTitle: activity.activityTitle, + activityDurationMins: activity.activityDurationMins, + sustainabilityScore: activity.sustainabilityScore, + cheapestPrice, + energyLevel: activity.activityType.energyLevel + ? { + ...activity.activityType.energyLevel, + presignedUrl: await attachPresignedUrl( + activity.activityType.energyLevel.energyIcon + ), + } + : null, + media: await attachMediaWithPresignedUrl(activity.ActivitiesMedia), + }; + }) + ); + + return { + interestId, + page, + limit, + totalCount, + hasMore: skip + limit < totalCount, + activities: formattedActivities, + }; + }); + } - } } \ No newline at end of file