feat: implement user service for managing personal information, retrieving interests, and ranking activities.

This commit is contained in:
paritosh18
2026-02-11 18:49:28 +05:30
parent 036f7ab130
commit ebeb4e5d06

View File

@@ -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,
};