From 84678bc00e02d56abc94aceeaf8ec68411e5ee88 Mon Sep 17 00:00:00 2001 From: Mayank Mishra Date: Thu, 19 Feb 2026 17:11:34 +0530 Subject: [PATCH] made getActivityFromConnectionsInterest api --- serverless/functions/user.yml | 15 + .../getActivityFromConnectionsInterest.ts | 60 +++ src/modules/user/services/user.service.ts | 456 ++++++++++++++++++ 3 files changed, 531 insertions(+) create mode 100644 src/modules/user/handlers/connections/getActivityFromConnectionsInterest.ts diff --git a/serverless/functions/user.yml b/serverless/functions/user.yml index 8f3eea0..a45c2fb 100644 --- a/serverless/functions/user.yml +++ b/serverless/functions/user.yml @@ -211,4 +211,19 @@ getAllConnectionOfUser: events: - httpApi: path: /user/connections/get-all-connections-details + method: get + +getActivityFromConnectionsInterest: + handler: src/modules/user/handlers/connections/getActivityFromConnectionsInterest.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/get-activity-from-connections-interest method: get \ No newline at end of file diff --git a/src/modules/user/handlers/connections/getActivityFromConnectionsInterest.ts b/src/modules/user/handlers/connections/getActivityFromConnectionsInterest.ts new file mode 100644 index 0000000..a813111 --- /dev/null +++ b/src/modules/user/handlers/connections/getActivityFromConnectionsInterest.ts @@ -0,0 +1,60 @@ +import { APIGatewayProxyEvent, APIGatewayProxyResult } from 'aws-lambda'; +import { prismaClient } from '../../../../common/database/prisma.lambda.service'; +import { verifyUserToken } from '../../../../common/middlewares/jwt/authForUser'; +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, +): Promise => { + + const token = + event.headers['x-auth-token'] || event.headers['X-Auth-Token']; + + if (!token) { + throw new ApiError(400, 'Token is required'); + } + + const userInfo = await verifyUserToken(token); + const userId = Number(userInfo.id); + + if (!userId) { + throw new ApiError(400, 'Invalid user'); + } + + const schoolCompanyXid = Number(event.queryStringParameters?.schoolCompanyXid); + const page = Number(event.queryStringParameters?.page ?? 1); + const limit = Number(event.queryStringParameters?.limit ?? 20); + const countryName = event.queryStringParameters?.countryName ?? ''; + const stateName = event.queryStringParameters?.stateName ?? ''; + const cityName = event.queryStringParameters?.cityName ?? ''; + + if (!schoolCompanyXid) { + throw new ApiError(400, 'schoolCompanyXid is required'); + } + + const result = await userService.getAllActivitiesFromConnectionsUserInterests( + userId, + schoolCompanyXid, + page, + limit, + countryName, + stateName, + cityName, + ); + + return { + statusCode: 200, + headers: { + 'Content-Type': 'application/json', + 'Access-Control-Allow-Origin': '*', + }, + body: JSON.stringify({ + success: true, + data: result, + }), + }; +}); diff --git a/src/modules/user/services/user.service.ts b/src/modules/user/services/user.service.ts index aa806a7..0d0277e 100644 --- a/src/modules/user/services/user.service.ts +++ b/src/modules/user/services/user.service.ts @@ -1842,4 +1842,460 @@ export class UserService { } }) } + + async getAllActivitiesFromConnectionsUserInterests( + userId: number, + schoolCompanyXid: 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: [schoolCompanyXid], + }, + userXid: { + not: userId, + }, + }, + select: { + userXid: true, + }, + }); + + if (!networkUsers.length) { + return { + interests: [], + mostHypedActivities: null, + newArrivalsActivities: null, + otherStatesActivities: null, + overSeasActivities: null, + }; + } + + const networkUserInterests = await tx.userInterests.findMany({ + where: { + userXid: { in: networkUsers.map(u => u.userXid) }, + isActive: true, + }, + distinct: ['interestXid'], + select: { + interestXid: true, + interest: { + select: { + id: true, + interestName: true, + interestColor: true, + interestImage: true, + displayOrder: true, + } + } + } + }); + + const distinctInterests = networkUserInterests.map(i => i.interestXid); + + if (!distinctInterests.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 } + }); + + 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: activityTypes.map(a => a.id) }, + }; + + 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: activityTypes.map(a => a.id) }, + }; + + 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; + + } } \ No newline at end of file