From ebeb4e5d06170ca1e7d026dd1b3c451db49fc7a6 Mon Sep 17 00:00:00 2001 From: paritosh18 Date: Wed, 11 Feb 2026 18:49:28 +0530 Subject: [PATCH] feat: implement user service for managing personal information, retrieving interests, and ranking activities. --- src/modules/user/services/user.service.ts | 385 +++++++++++++--------- 1 file changed, 226 insertions(+), 159 deletions(-) diff --git a/src/modules/user/services/user.service.ts b/src/modules/user/services/user.service.ts index 6baeed4..f513daf 100644 --- a/src/modules/user/services/user.service.ts +++ b/src/modules/user/services/user.service.ts @@ -145,6 +145,153 @@ const attachMediaWithPresignedUrl = async (mediaArr = []) => { const bucket = config.aws.bucketName; +/* ===================================================== + HELPER: RANK & PAGINATE ACTIVITIES +===================================================== */ +async function rankAndPaginateActivities( + tx: any, + whereClause: any, + page: number, + limit: number +) { + const skip = (page - 1) * limit; + + // 1️⃣ Fetch Metadata for ALL matching activities for in-memory sorting + const allCandidates = await tx.activities.findMany({ + where: whereClause, + select: { + id: true, + sustainabilityScore: true, + totalScore: true, // Quality Score + ItineraryActivities: { + select: { + ActivityFeedbacks: { + select: { activityStars: true }, + }, + }, + }, + ActivityVenues: { + select: { + ActivityPrices: { + select: { sellPrice: true }, + }, + }, + }, + }, + }); + + const totalCount = allCandidates.length; + + // 2️⃣ Calculate Metrics & Sort + const sortedCandidates = allCandidates.map((act: any) => { + // Flatten feedbacks + const feedbacks = act.ItineraryActivities.flatMap((ia: any) => ia.ActivityFeedbacks); + + // Avg Rating + const totalStars = feedbacks.reduce((sum: number, f: any) => sum + f.activityStars, 0); + const avgRating = feedbacks.length > 0 ? totalStars / feedbacks.length : 0; + + // Min Price + const prices = act.ActivityVenues.flatMap((v: any) => v.ActivityPrices.map((p: any) => p.sellPrice)).filter((p: any) => p !== null) as number[]; + const minPrice = prices.length > 0 ? Math.min(...prices) : Infinity; + + return { + id: act.id, + avgRating, + minPrice, + sustainabilityScore: act.sustainabilityScore ?? 0, + totalScore: act.totalScore ?? 0, + }; + }).sort((a: any, b: any) => { + // 1. Rating (Highest first) + if (b.avgRating !== a.avgRating) return b.avgRating - a.avgRating; + + // 2. Price (Lowest first) + if (a.minPrice !== b.minPrice) return a.minPrice - b.minPrice; + + // 3. Sustainability Score (Highest first) + if (b.sustainabilityScore !== a.sustainabilityScore) return b.sustainabilityScore - a.sustainabilityScore; + + // 4. Quality Score (Highest first) + return b.totalScore - a.totalScore; + }); + + // 3️⃣ Paginate IDs + const paginatedCandidates = sortedCandidates.slice(skip, skip + limit); + const targetIds = paginatedCandidates.map((c: any) => c.id); + + // 4️⃣ Fetch Full Details for the page + const activitiesUnsorted = await tx.activities.findMany({ + where: { id: { in: targetIds } }, + select: { + id: true, + activityTitle: true, + activityDurationMins: true, + sustainabilityScore: true, + activityType: { + select: { + interestXid: true, + energyLevel: { + select: { + id: true, + energyLevelName: true, + energyColor: true, + energyIcon: true, + }, + }, + }, + }, + ActivityVenues: { + select: { + ActivityPrices: { + select: { sellPrice: true }, + }, + }, + }, + ActivitiesMedia: { + where: { isActive: true }, + select: { + id: true, + mediaFileName: true, + mediaType: true, + }, + }, + }, + }); + + // Re-sort to match the calculated order + const activities = targetIds + .map((id: number) => activitiesUnsorted.find((a: any) => a.id === id)) + .filter(Boolean); + + // 5️⃣ Format Response + const formattedActivities = await Promise.all( + activities.map(async (activity: any) => { + const prices = activity.ActivityVenues.flatMap((v: any) => v.ActivityPrices.map((p: any) => p.sellPrice)).filter((p: any) => p !== null) as number[]; + const cheapestPrice = prices.length > 0 ? Math.min(...prices) : null; + + return { + // interestXid: activity.activityType.interestXid, + activityId: activity.id, + activityTitle: activity.activityTitle, + // activityDurationMins: activity.activityDurationMins, + // sustainabilityScore: activity.sustainabilityScore, + // cheapestPrice, + energyLevel: activity.activityType.energyLevel, + media: await attachMediaWithPresignedUrl(activity.ActivitiesMedia), + }; + }) + ); + + return { + page, + limit, + totalCount, + hasMore: skip + limit < totalCount, + activities: formattedActivities, + }; +} + @Injectable() export class UserService { @@ -494,6 +641,10 @@ export class UserService { 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, @@ -513,10 +664,9 @@ export class UserService { sustainabilityScore: true, checkInLat: true, checkInLong: true, - activityType: { select: { - interestXid: true, // ✅ VERY IMPORTANT + interestXid: true, energyLevel: { select: { id: true, @@ -527,7 +677,6 @@ export class UserService { }, }, }, - ActivityVenues: { select: { ActivityPrices: { @@ -537,7 +686,6 @@ export class UserService { }, }, }, - ActivitiesMedia: { where: { isActive: true }, select: { @@ -557,6 +705,9 @@ export class UserService { }, }); + /* ===================================================== + 2️⃣ MOST HYPED ACTIVITIES (RANKED) + ===================================================== */ const mostHypedGrouped = await tx.userBucketInterested.groupBy({ by: ['activityXid'], where: { @@ -576,9 +727,10 @@ export class UserService { }); 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 }, @@ -589,6 +741,8 @@ export class UserService { select: { id: true, activityTitle: true, + sustainabilityScore: true, + totalScore: true, activityType: { select: { energyLevel: { @@ -609,23 +763,60 @@ export class UserService { mediaType: true, }, }, + // Fetch ranking metadata + ItineraryActivities: { + select: { + ActivityFeedbacks: { + select: { activityStars: true }, + }, + }, + }, + ActivityVenues: { + select: { + ActivityPrices: { + select: { sellPrice: true }, + }, + }, + }, }, }); - const mostHypedActivities = await Promise.all( - mostHypedGrouped.map(async g => { - const activity = mostHypedActivitiesRaw.find(a => a.id === g.activityXid); - if (!activity) return null; + // 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 { - activityId: activity.id, - activityTitle: activity.activityTitle, - hypeCount: g._count.activityXid, // 🔥 VERY IMPORTANT - energyLevel: activity.activityType.energyLevel, - media: await attachMediaWithPresignedUrl(activity.ActivitiesMedia), - }; - }) - ).then(arr => arr.filter(Boolean)); + return { + ...act, // Keep original fields for final output + avgRating, + minPrice, + sustainabilityScore: act.sustainabilityScore ?? 0, + totalScore: act.totalScore ?? 0, + hypeCount: mostHypedGrouped.find(g => g.activityXid === act.id)?._count.activityXid ?? 0 + }; + }).sort((a, b) => { + // 1. Rating (Highest first) + if (b.avgRating !== a.avgRating) return b.avgRating - a.avgRating; + // 2. Price (Lowest first) + if (a.minPrice !== b.minPrice) return a.minPrice - b.minPrice; + // 3. Sustainability Score + if (b.sustainabilityScore !== a.sustainabilityScore) return b.sustainabilityScore - a.sustainabilityScore; + // 4. Quality Score + return b.totalScore - a.totalScore; + }); + + const mostHypedActivities = await Promise.all( + mostHypedSorted.map(async activity => ({ + activityId: activity.id, + activityTitle: activity.activityTitle, + hypeCount: activity.hypeCount, + energyLevel: activity.activityType.energyLevel, + media: await attachMediaWithPresignedUrl(activity.ActivitiesMedia), + })) + ); const formattedMostHypedActivities = { page, @@ -636,59 +827,21 @@ export class UserService { }; - const newArrivalsCount = await tx.activities.count({ - where: { - isActive: true, - activityInternalStatus: ACTIVITY_INTERNAL_STATUS.ACTIVITY_LISTED, - amInternalStatus: ACTIVITY_AM_INTERNAL_STATUS.ACTIVITY_LISTED, - createdAt: { gte: new Date(Date.now() - 31 * 24 * 60 * 60 * 1000) } - }, - }); + /* ===================================================== + 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 newArrivalsActivities = await tx.activities.findMany({ - where: { - isActive: true, - activityInternalStatus: ACTIVITY_INTERNAL_STATUS.ACTIVITY_LISTED, - amInternalStatus: ACTIVITY_AM_INTERNAL_STATUS.ACTIVITY_LISTED, - - // // ✅ Only user's interest types - // activityTypeXid: { - // in: activitiyTypesOfUserInterests.map(at => at.id), - // }, - createdAt: { gte: new Date(Date.now() - 31 * 24 * 60 * 60 * 1000) } - }, - skip, - take: limit, - orderBy: { id: 'desc' }, - select: { - id: true, - activityTitle: true, - activityType: { - select: { - energyLevel: { - select: { - id: true, - energyLevelName: true, - energyColor: true, - energyIcon: true, - }, - }, - }, - }, - ActivitiesMedia: { - where: { isActive: true }, - select: { - id: true, - mediaFileName: true, - mediaType: true, - }, - }, - }, - }) + const formattedNewArrivalsActivities = await rankAndPaginateActivities(tx, newArrivalsWhere, page, limit); /* ===================================================== - 6️⃣ OTHER STATES ACTIVITIES - ===================================================== */ + 4️⃣ OTHER STATES ACTIVITIES (RANKED) + ===================================================== */ const otherStatesWhere: any = { isActive: true, activityInternalStatus: ACTIVITY_INTERNAL_STATUS.ACTIVITY_LISTED, @@ -702,27 +855,11 @@ export class UserService { otherStatesWhere.checkInStateXid = { not: effectiveStateXid }; } - const otherStatesTotalCount = await tx.activities.count({ - where: otherStatesWhere, - }); + const formattedOtherStatesActivities = await rankAndPaginateActivities(tx, otherStatesWhere, page, limit); - const otherStatesActivities = await tx.activities.findMany({ - where: otherStatesWhere, - skip, - take: limit, - orderBy: { id: 'desc' }, - select: { - activityTitle: true, - activityType: { select: { energyLevel: true } }, - ActivitiesMedia: { - where: { isActive: true }, - select: { id: true, mediaFileName: true, mediaType: true }, - }, - }, - }); /* ===================================================== - 7️⃣ OVERSEAS ACTIVITIES + 5️⃣ OVERSEAS ACTIVITIES (RANKED) ===================================================== */ const overseasWhere: any = { isActive: true, @@ -734,24 +871,8 @@ export class UserService { overseasWhere.checkInCountryXid = { not: effectiveCountryXid }; } - const overSeasTotalCount = await tx.activities.count({ - where: overseasWhere, - }); + const formattedOverSeasActivities = await rankAndPaginateActivities(tx, overseasWhere, page, limit); - const overSeasActivities = await tx.activities.findMany({ - where: overseasWhere, - 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 formattedActivities = await Promise.all( activities.map(async (activity) => { @@ -774,36 +895,6 @@ export class UserService { }) ); - - const formattedOtherStatesActivities = { - page, - limit, - totalCount: otherStatesTotalCount, - hasMore: skip + limit < otherStatesTotalCount, - activities: await Promise.all( - otherStatesActivities.map(async a => ({ - activityTitle: a.activityTitle, - energyLevel: a.activityType.energyLevel, - media: await attachMediaWithPresignedUrl(a.ActivitiesMedia), - })) - ), - }; - - - const formattedNewArrivalsActivities = { - page, - limit, - totalCount: newArrivalsCount, - hasMore: skip + limit < newArrivalsCount, - activities: await Promise.all( - newArrivalsActivities.map(async a => ({ - activityTitle: a.activityTitle, - energyLevel: a.activityType.energyLevel, - media: await attachMediaWithPresignedUrl(a.ActivitiesMedia), - })) - ), - }; - const interestsWithActivities = [...userInterests] .sort((a, b) => a.interest.interestName.localeCompare(b.interest.interestName) @@ -835,32 +926,8 @@ export class UserService { limit, }, interests: interestsWithActivities, - otherStatesActivities: { - page, - limit, - totalCount: otherStatesTotalCount, - hasMore: skip + limit < otherStatesTotalCount, - activities: await Promise.all( - otherStatesActivities.map(async a => ({ - activityTitle: a.activityTitle, - energyLevel: a.activityType.energyLevel, - media: await attachMediaWithPresignedUrl(a.ActivitiesMedia), - })) - ), - }, - overSeasActivities: { - page, - limit, - totalCount: overSeasTotalCount, - hasMore: skip + limit < overSeasTotalCount, - activities: await Promise.all( - overSeasActivities.map(async a => ({ - activityTitle: a.activityTitle, - energyLevel: a.activityType.energyLevel, - media: await attachMediaWithPresignedUrl(a.ActivitiesMedia), - })) - ), - }, + otherStatesActivities: formattedOtherStatesActivities, + overSeasActivities: formattedOverSeasActivities, newArrivalsActivities: formattedNewArrivalsActivities, mostHypedActivities: formattedMostHypedActivities, };