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) {