From ca4def4695262b50f0ce6506f52384f45926a1d8 Mon Sep 17 00:00:00 2001 From: paritosh18 Date: Mon, 23 Feb 2026 12:05:52 +0530 Subject: [PATCH 1/3] Add getRandomActiveActivity API to fetch random active activities with token validation --- serverless/functions/user.yml | 15 ++++ .../activities/getRandomActiveActivity.ts | 59 ++++++++++++ src/modules/user/services/user.service.ts | 90 +++++++++++++++++++ 3 files changed, 164 insertions(+) create mode 100644 src/modules/user/handlers/activities/getRandomActiveActivity.ts diff --git a/serverless/functions/user.yml b/serverless/functions/user.yml index 93377eb..1a4cde9 100644 --- a/serverless/functions/user.yml +++ b/serverless/functions/user.yml @@ -301,4 +301,19 @@ viewMoreActivitiesByInterest: events: - httpApi: path: /user/activities/view-more-activities + method: get + +getRandomActiveActivity: + handler: src/modules/user/handlers/activities/getRandomActiveActivity.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/get-random-active-activity method: get \ No newline at end of file diff --git a/src/modules/user/handlers/activities/getRandomActiveActivity.ts b/src/modules/user/handlers/activities/getRandomActiveActivity.ts new file mode 100644 index 0000000..3778d8d --- /dev/null +++ b/src/modules/user/handlers/activities/getRandomActiveActivity.ts @@ -0,0 +1,59 @@ +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 => { + // Extract token from headers + const token = event.headers['x-auth-token'] || event.headers['X-Auth-Token']; + if (!token) { + throw new ApiError(400, 'This is a protected route. Please provide a valid token.'); + } + + // Verify token and get user info + const userInfo = await verifyUserToken(token); + const userId = Number(userInfo.id); + + if (!userId || Number.isNaN(userId)) { + throw new ApiError(400, 'Invalid user ID'); + } + + // Fetch 50 random active activities + const result = await userService.getRandomActiveActivity(); + + if (!result || result.length === 0) { + return { + statusCode: 404, + headers: { + 'Content-Type': 'application/json', + 'Access-Control-Allow-Origin': '*', + }, + body: JSON.stringify({ + success: false, + message: 'No active activities found', + data: [], + }), + }; + } + + return { + statusCode: 200, + headers: { + 'Content-Type': 'application/json', + 'Access-Control-Allow-Origin': '*', + }, + body: JSON.stringify({ + success: true, + message: 'Random active activities retrieved successfully', + data: result, + count: result.length, + }), + }; +}); diff --git a/src/modules/user/services/user.service.ts b/src/modules/user/services/user.service.ts index 21b96c2..4cb7d67 100644 --- a/src/modules/user/services/user.service.ts +++ b/src/modules/user/services/user.service.ts @@ -2616,4 +2616,94 @@ export class UserService { return true; } + async getRandomActiveActivity() { + return await this.prisma.$transaction(async (tx) => { + // Get count of active activities + const count = await tx.activities.count({ + where: { + isActive: true, + activityInternalStatus: ACTIVITY_INTERNAL_STATUS.ACTIVITY_LISTED, + amInternalStatus: ACTIVITY_AM_INTERNAL_STATUS.ACTIVITY_LISTED, + deletedAt: null, + }, + }); + + if (count === 0) { + return []; + } + + // Determine how many activities to fetch (50 or less if count is smaller) + const takeCount = Math.min(50, count); + + // Fetch random activities - using ORDER BY RANDOM() equivalent approach + // Get all IDs first, shuffle, then take 50 + const allActivityIds = await tx.activities.findMany({ + where: { + isActive: true, + activityInternalStatus: ACTIVITY_INTERNAL_STATUS.ACTIVITY_LISTED, + amInternalStatus: ACTIVITY_AM_INTERNAL_STATUS.ACTIVITY_LISTED, + deletedAt: null, + }, + select: { id: true }, + }); + + // Shuffle array and take first 50 + const shuffled = allActivityIds.sort(() => Math.random() - 0.5); + const selectedIds = shuffled.slice(0, takeCount).map(a => a.id); + + // Fetch activities with only activityTitle and ActivitiesMedia + const activities = await tx.activities.findMany({ + where: { + id: { in: selectedIds }, + isActive: true, + activityInternalStatus: ACTIVITY_INTERNAL_STATUS.ACTIVITY_LISTED, + amInternalStatus: ACTIVITY_AM_INTERNAL_STATUS.ACTIVITY_LISTED, + deletedAt: null, + }, + select: { + id: true, + activityTitle: true, + ActivitiesMedia: { + where: { + isActive: true, + }, + select: { + id: true, + mediaFileName: true, + mediaType: true, + }, + orderBy: { + displayOrder: 'asc', // Get the first image by display order + }, + take: 1, // Get only the first image + }, + }, + }); + + // Process activities to attach presigned URLs and format response + const result = await Promise.all( + activities.map(async (activity) => { + let activityImage = null; + let activityImagePresignedUrl = null; + + // Get the first image and attach presigned URL + if (Array.isArray(activity.ActivitiesMedia) && activity.ActivitiesMedia.length > 0) { + const firstImage = activity.ActivitiesMedia[0]; + activityImage = firstImage.mediaFileName; + activityImagePresignedUrl = await attachPresignedUrl(firstImage.mediaFileName); + } + + return { + id: activity.id, + activityName: activity.activityTitle, + activityImage: activityImage, + activityImagePresignedUrl: activityImagePresignedUrl, + }; + }) + ); + + return result; + }); + } + } \ No newline at end of file From 5ec07d448084fd213c700fa46e17eed78b89a547 Mon Sep 17 00:00:00 2001 From: paritosh18 Date: Mon, 23 Feb 2026 18:56:08 +0530 Subject: [PATCH 2/3] Refactor user ID validation and optimize city search results. Updated user ID check to use Number.isNaN for better clarity. Added a comment indicating that city results are capped at 50 in the database query to reduce latency. --- src/modules/user/handlers/connections/searchCities.ts | 4 ++-- src/modules/user/services/user.service.ts | 6 ++++-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/src/modules/user/handlers/connections/searchCities.ts b/src/modules/user/handlers/connections/searchCities.ts index 1b1ba7d..f25acb7 100644 --- a/src/modules/user/handlers/connections/searchCities.ts +++ b/src/modules/user/handlers/connections/searchCities.ts @@ -30,7 +30,7 @@ export const handler = safeHandler( const userInfo = await verifyUserToken(token); const userId = Number(userInfo.id); - if (!userId || isNaN(userId)) { + if (!userId || Number.isNaN(userId)) { throw new ApiError(400, 'Invalid user ID'); } @@ -78,7 +78,7 @@ export const handler = safeHandler( body: JSON.stringify({ success: true, message: 'Cities found successfully', - data: results, + data: results, // already capped at 50 in DB query count: results.length, }), }; diff --git a/src/modules/user/services/user.service.ts b/src/modules/user/services/user.service.ts index 1273ed6..4d3aae6 100644 --- a/src/modules/user/services/user.service.ts +++ b/src/modules/user/services/user.service.ts @@ -1932,9 +1932,11 @@ export class UserService { id: true, cityName: true, stateXid: true, - isActive: true, - createdAt: true, }, + orderBy: { + cityName: 'asc', + }, + take: 50, // reduce latency by limiting results at DB level }); return results; From ced8bdcbadd8d4611b97ec010f680c50101a32ef Mon Sep 17 00:00:00 2001 From: paritosh18 Date: Mon, 23 Feb 2026 20:00:50 +0530 Subject: [PATCH 3/3] Add getNearbyActivities API to retrieve activities based on user location and radius. Implemented token validation and pagination support. Enhanced UserService with distance calculation and filtering logic for nearby activities. --- serverless/functions/user.yml | 15 ++ .../activities/getNearbyActivities.ts | 71 ++++++++ src/modules/user/services/user.service.ts | 167 ++++++++++++++++++ 3 files changed, 253 insertions(+) create mode 100644 src/modules/user/handlers/activities/getNearbyActivities.ts diff --git a/serverless/functions/user.yml b/serverless/functions/user.yml index 0257948..14904ae 100644 --- a/serverless/functions/user.yml +++ b/serverless/functions/user.yml @@ -316,4 +316,19 @@ getRandomActiveActivity: events: - httpApi: path: /user/activities/get-random-active-activity + method: get + +getNearbyActivities: + handler: src/modules/user/handlers/activities/getNearbyActivities.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/get-nearby-activities method: get \ No newline at end of file diff --git a/src/modules/user/handlers/activities/getNearbyActivities.ts b/src/modules/user/handlers/activities/getNearbyActivities.ts new file mode 100644 index 0000000..4f25cad --- /dev/null +++ b/src/modules/user/handlers/activities/getNearbyActivities.ts @@ -0,0 +1,71 @@ +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. Please provide a valid token.'); + } + + const userInfo = await verifyUserToken(token); + const userId = Number(userInfo.id); + + if (!userId || isNaN(userId)) { + throw new ApiError(400, 'Invalid user ID'); + } + + const latParam = event.queryStringParameters?.lat ?? event.queryStringParameters?.latitude; + const longParam = event.queryStringParameters?.long ?? event.queryStringParameters?.lng ?? event.queryStringParameters?.longitude; + const radiusParam = event.queryStringParameters?.radiusKm ?? event.queryStringParameters?.radius; + + if (!latParam || !longParam || !radiusParam) { + throw new ApiError(400, 'lat, long and radiusKm (in km) are required as query parameters'); + } + + const userLat = Number(latParam); + const userLong = Number(longParam); + const radiusKm = Number(radiusParam); + + if (Number.isNaN(userLat) || Number.isNaN(userLong) || Number.isNaN(radiusKm)) { + throw new ApiError(400, 'lat, long and radiusKm must be valid numbers'); + } + + const page = Number(event.queryStringParameters?.page ?? 1); + const limit = Number(event.queryStringParameters?.limit ?? 20); + + if (page < 1 || limit < 1) { + throw new ApiError(400, 'Invalid pagination values'); + } + + const result = await userService.getNearbyActivities( + userId, + userLat, + userLong, + radiusKm, + page, + limit, + ); + + return { + statusCode: 200, + headers: { + 'Content-Type': 'application/json', + 'Access-Control-Allow-Origin': '*', + }, + body: JSON.stringify({ + success: true, + message: 'Nearby activities retrieved successfully', + data: result, + }), + }; +}); + diff --git a/src/modules/user/services/user.service.ts b/src/modules/user/services/user.service.ts index 4d3aae6..23a842f 100644 --- a/src/modules/user/services/user.service.ts +++ b/src/modules/user/services/user.service.ts @@ -157,6 +157,32 @@ const attachMediaWithPresignedUrl = async (mediaArr = []) => { ).filter(Boolean); }; +function deg2rad(deg: number): number { + return deg * (Math.PI / 180); +} + +function getDistanceFromLatLon( + userLat1: number, + userLon1: number, + activityLat2: number, + activityLon2: number, +): number { + const R = 6371; // Earth radius in km + + const dLat = deg2rad(activityLat2 - userLat1); + const dLon = deg2rad(activityLon2 - userLon1); + + const a = + Math.sin(dLat / 2) * Math.sin(dLat / 2) + + Math.cos(deg2rad(userLat1)) * + Math.cos(deg2rad(activityLat2)) * + Math.sin(dLon / 2) * + Math.sin(dLon / 2); + + const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); + return R * c; +} + const bucket = config.aws.bucketName; /* ===================================================== @@ -1849,6 +1875,147 @@ export class UserService { return activitiesWithCounts; } + async getNearbyActivities( + userId: number, + userLat: number, + userLong: number, + radiusKm: number, + page: number, + limit: number, + ) { + if (userLat === undefined || userLong === undefined || radiusKm === undefined) { + throw new ApiError( + 400, + 'Latitude, longitude and radius are required to find nearby activities', + ); + } + + if (radiusKm <= 0) { + throw new ApiError(400, 'Radius must be greater than 0'); + } + + const skip = (page - 1) * limit; + + // Rough bounding box in degrees to reduce DB scan + const earthRadiusKm = 6371; + const latDelta = (radiusKm / earthRadiusKm) * (180 / Math.PI); + const lonDelta = + (radiusKm / (earthRadiusKm * Math.cos(deg2rad(userLat)))) * + (180 / Math.PI); + + const candidates = await this.prisma.activities.findMany({ + where: { + isActive: true, + activityInternalStatus: ACTIVITY_INTERNAL_STATUS.ACTIVITY_LISTED, + amInternalStatus: ACTIVITY_AM_INTERNAL_STATUS.ACTIVITY_LISTED, + checkInLat: { + not: null, + gte: userLat - latDelta, + lte: userLat + latDelta, + }, + checkInLong: { + not: null, + gte: userLong - lonDelta, + lte: userLong + lonDelta, + }, + }, + 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 withDistance = candidates + .map((activity: any) => { + const distanceKm = getDistanceFromLatLon( + userLat, + userLong, + activity.checkInLat, + activity.checkInLong, + ); + + return { + ...activity, + distanceKm, + }; + }) + .filter((a) => a.distanceKm <= radiusKm) + .sort((a, b) => a.distanceKm - b.distanceKm); + + const totalCount = withDistance.length; + const paged = withDistance.slice(skip, skip + limit); + + const formattedActivities = await Promise.all( + paged.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 { + activityId: activity.id, + activityTitle: activity.activityTitle, + activityDurationMins: activity.activityDurationMins, + sustainabilityScore: activity.sustainabilityScore, + distanceKm: activity.distanceKm, + cheapestPrice, + energyLevel: activity.activityType.energyLevel + ? { + ...activity.activityType.energyLevel, + presignedUrl: await attachPresignedUrl( + activity.activityType.energyLevel.energyIcon, + ), + } + : null, + media: await attachMediaWithPresignedUrl(activity.ActivitiesMedia), + }; + }), + ); + + return { + page, + limit, + totalCount, + hasMore: skip + limit < totalCount, + activities: formattedActivities, + }; + } + // CONNECTIONS async getAllConnectionDetailsOfUser(userXid: number) {