feat: implement user service for managing personal information, retrieving interests, and ranking activities.
This commit is contained in:
@@ -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,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user