From f1801a3210d2a894c94e4046d0e359dd0d40a47b Mon Sep 17 00:00:00 2001 From: Mayank Mishra Date: Tue, 17 Mar 2026 16:22:03 +0530 Subject: [PATCH] made getMatchingBucketInterestedActivities api --- serverless/functions/user.yml | 15 + .../minglaradmin/services/minglar.service.ts | 71 ++- .../getMatchingBucketInterestedActivities.ts | 86 +++ .../user/services/itinerary.service.ts | 580 ++++++++++++++++++ 4 files changed, 738 insertions(+), 14 deletions(-) create mode 100644 src/modules/user/handlers/itinerary/getMatchingBucketInterestedActivities.ts diff --git a/serverless/functions/user.yml b/serverless/functions/user.yml index f7f178b..d26ee52 100644 --- a/serverless/functions/user.yml +++ b/serverless/functions/user.yml @@ -422,3 +422,18 @@ getUserItineraryDetails: - httpApi: path: /itinerary/get-user-itinerary-details method: get + +getMatchingBucketInterestedActivities: + handler: src/modules/user/handlers/itinerary/getMatchingBucketInterestedActivities.handler + memorySize: 512 + package: + patterns: + - 'src/modules/user/**' + - ${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: /itinerary/get-matching-bucket-interested-activities + method: post diff --git a/src/modules/minglaradmin/services/minglar.service.ts b/src/modules/minglaradmin/services/minglar.service.ts index 7b27b24..9bc04d6 100644 --- a/src/modules/minglaradmin/services/minglar.service.ts +++ b/src/modules/minglaradmin/services/minglar.service.ts @@ -34,7 +34,7 @@ const bucket = config.aws.bucketName; @Injectable() export class MinglarService { - constructor(private prisma: PrismaService | PrismaClient) {} + constructor(private prisma: PrismaService | PrismaClient) { } async createPassword(user_xid: number, password: string): Promise { // Find user by id @@ -314,6 +314,8 @@ export class MinglarService { companyName: true, user: { select: { + firstName: true, + lastName: true, userRefNumber: true, }, }, @@ -375,11 +377,52 @@ export class MinglarService { const { paginationService, } = require('@/common/utils/pagination/pagination.service'); - return paginationService.createPaginatedResponse( + + let hostDetails = null; + + if (hostXid) { + hostDetails = await this.prisma.hostHeader.findUnique({ + where: { id: hostXid }, + select: { + companyName: true, + user: { + select: { + firstName: true, + lastName: true, + userRefNumber: true, + }, + }, + }, + }); + } + + const paginatedResponse = paginationService.createPaginatedResponse( hostActivities, totalCount, paginationOptions || { page: 1, limit: 10, skip: 0 }, ); + + // 👇 ADD THIS BLOCK + if (hostActivities.length === 0 && hostDetails) { + paginatedResponse.data = [ + { + id: null, + activityRefNumber: null, + activityTitle: null, + totalScore: null, + activityInternalStatus: null, + activityDisplayStatus: null, + amInternalStatus: null, + amDisplayStatus: null, + createdAt: null, + host: hostDetails, + ActivityAmDetails: [], + activityType: null, + }, + ]; + } + + return paginatedResponse; } async createUserRevenue( @@ -818,7 +861,7 @@ export class MinglarService { if ( userStatus && userStatus.trim().toLowerCase() === - MINGLAR_STATUS_DISPLAY.NEW.toLowerCase() + MINGLAR_STATUS_DISPLAY.NEW.toLowerCase() ) { filters.adminStatusInternal = MINGLAR_STATUS_INTERNAL.ADMIN_TO_REVIEW; } @@ -1188,15 +1231,15 @@ export class MinglarService { // Build search filter if search term is provided const searchFilter = search ? { - OR: [ - { email: { contains: search, mode: 'insensitive' as const } }, - { firstName: { contains: search, mode: 'insensitive' as const } }, - { lastName: { contains: search, mode: 'insensitive' as const } }, - { - userRefNumber: { contains: search, mode: 'insensitive' as const }, - }, - ], - } + OR: [ + { email: { contains: search, mode: 'insensitive' as const } }, + { firstName: { contains: search, mode: 'insensitive' as const } }, + { lastName: { contains: search, mode: 'insensitive' as const } }, + { + userRefNumber: { contains: search, mode: 'insensitive' as const }, + }, + ], + } : {}; // 1. Fetch all required users (Admin, Co-Admin, AM) @@ -1827,8 +1870,8 @@ export class MinglarService { }); }); } - - + + async rejectActivityApplicationByAM(activityId: number, user_xid: number) { return await this.prisma.$transaction(async (tx) => { await tx.activities.update({ diff --git a/src/modules/user/handlers/itinerary/getMatchingBucketInterestedActivities.ts b/src/modules/user/handlers/itinerary/getMatchingBucketInterestedActivities.ts new file mode 100644 index 0000000..94deb7e --- /dev/null +++ b/src/modules/user/handlers/itinerary/getMatchingBucketInterestedActivities.ts @@ -0,0 +1,86 @@ +import { APIGatewayProxyEvent, APIGatewayProxyResult, Context } 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 { ItineraryService } from '../../services/itinerary.service'; + +const itineraryService = new ItineraryService(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 || Number.isNaN(userId)) { + throw new ApiError(400, 'Invalid user ID'); + } + + let body: Record = {}; + if (event.body) { + try { + body = JSON.parse(event.body); + } catch { + throw new ApiError(400, 'Invalid JSON body'); + } + } + + const payload = { + userLat: Number(body.userLat), + userLong: Number(body.userLong), + startDate: body.startDate, + endDate: body.endDate, + startTime: body.startTime, + endTime: body.endTime, + energyLevelXid: Number(body.energyLevelXid), + entryTypeXid: Number(body.entryTypeXid), + page: body.page !== undefined ? Number(body.page) : 1, + limit: body.limit !== undefined ? Number(body.limit) : 20, + }; + + if ( + Number.isNaN(payload.userLat) || + Number.isNaN(payload.userLong) || + !payload.startDate || + !payload.endDate || + !payload.startTime || + !payload.endTime || + Number.isNaN(payload.energyLevelXid) || + Number.isNaN(payload.entryTypeXid) || + Number.isNaN(payload.page) || + Number.isNaN(payload.limit) + ) { + throw new ApiError( + 400, + 'userLat, userLong, startDate, endDate, startTime, endTime, energyLevelXid, entryTypeXid, page and limit are required.', + ); + } + + const result = await itineraryService.getMatchingBucketInterestedActivities( + userId, + payload, + ); + + return { + statusCode: 200, + headers: { + 'Content-Type': 'application/json', + 'Access-Control-Allow-Origin': '*', + }, + body: JSON.stringify({ + success: true, + message: 'Matching itinerary activities retrieved successfully', + data: result, + }), + }; +}); diff --git a/src/modules/user/services/itinerary.service.ts b/src/modules/user/services/itinerary.service.ts index 3793c21..1e19d8b 100644 --- a/src/modules/user/services/itinerary.service.ts +++ b/src/modules/user/services/itinerary.service.ts @@ -1,6 +1,7 @@ import { Injectable } from '@nestjs/common'; import { PrismaClient } from '@prisma/client'; import { getPresignedUrl } from '../../../common/middlewares/aws/getPreSignedUrl'; +import ApiError from '../../../common/utils/helper/ApiError'; import { ACTIVITY_AM_INTERNAL_STATUS, ACTIVITY_INTERNAL_STATUS, @@ -9,6 +10,15 @@ import { import config from '@/config/config'; const bucket = config.aws.bucketName; +const WEEKDAY_NAMES = [ + 'SUNDAY', + 'MONDAY', + 'TUESDAY', + 'WEDNESDAY', + 'THURSDAY', + 'FRIDAY', + 'SATURDAY', +] as const; const attachPresignedUrl = async (file: string | null | undefined) => { if (!file) return null; @@ -41,6 +51,140 @@ const attachMediaWithPresignedUrl = async ( ); }; +const calculateDistance = ( + lat1: number | null, + lon1: number | null, + lat2: number | null, + lon2: number | null, +) => { + if ( + lat1 === null || + lon1 === null || + lat2 === null || + lon2 === null || + Number.isNaN(lat1) || + Number.isNaN(lon1) || + Number.isNaN(lat2) || + Number.isNaN(lon2) + ) { + return null; + } + + const R = 6371; + const dLat = ((lat2 - lat1) * Math.PI) / 180; + const dLon = ((lon2 - lon1) * Math.PI) / 180; + + const a = + Math.sin(dLat / 2) * Math.sin(dLat / 2) + + Math.cos((lat1 * Math.PI) / 180) * + Math.cos((lat2 * Math.PI) / 180) * + Math.sin(dLon / 2) * + Math.sin(dLon / 2); + + const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); + + return Number((R * c).toFixed(2)); +}; + +const parseDateValue = (value: string | Date) => { + if (value instanceof Date) { + return new Date(value.getTime()); + } + + const trimmedValue = value.trim(); + const isoMatch = trimmedValue.match(/^(\d{4})-(\d{2})-(\d{2})$/); + if (isoMatch) { + const [, year, month, day] = isoMatch; + return new Date(Number(year), Number(month) - 1, Number(day)); + } + + const slashMatch = trimmedValue.match(/^(\d{4})\/(\d{2})\/(\d{2})$/); + if (slashMatch) { + const [, year, month, day] = slashMatch; + return new Date(Number(year), Number(month) - 1, Number(day)); + } + + const parsed = new Date(trimmedValue); + return parsed; +}; + +const parseTimeValue = (value: string) => { + const trimmedValue = value.trim().toUpperCase().replace(/\s+/g, ' '); + const match = trimmedValue.match( + /^(\d{1,2})(?::(\d{2}))?(?::(\d{2}))?\s*(AM|PM)?$/, + ); + + if (!match) { + return null; + } + + let hours = Number(match[1]); + const minutes = Number(match[2] ?? '0'); + const seconds = Number(match[3] ?? '0'); + const meridiem = match[4]; + + if (minutes > 59 || seconds > 59) { + return null; + } + + if (meridiem) { + if (hours < 1 || hours > 12) { + return null; + } + + if (meridiem === 'AM') { + hours = hours === 12 ? 0 : hours; + } else { + hours = hours === 12 ? 12 : hours + 12; + } + } else if (hours > 23) { + return null; + } + + return { hours, minutes, seconds }; +}; + +const combineDateAndTime = (dateValue: string | Date, timeValue: string) => { + const date = parseDateValue(dateValue); + const time = parseTimeValue(timeValue); + + if (Number.isNaN(date.getTime()) || !time) { + return null; + } + + date.setHours(time.hours, time.minutes, time.seconds, 0); + return date; +}; + +const startOfDay = (date: Date) => { + const value = new Date(date.getTime()); + value.setHours(0, 0, 0, 0); + return value; +}; + +const formatDateKey = (date: Date) => { + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, '0'); + const day = String(date.getDate()).padStart(2, '0'); + return `${year}-${month}-${day}`; +}; + +const addMinutes = (date: Date, minutes: number) => + new Date(date.getTime() + minutes * 60 * 1000); + +const getDateRange = (fromDate: Date, toDate: Date) => { + const dates: Date[] = []; + const cursor = startOfDay(fromDate); + const end = startOfDay(toDate); + + while (cursor <= end) { + dates.push(new Date(cursor.getTime())); + cursor.setDate(cursor.getDate() + 1); + } + + return dates; +}; + @Injectable() export class ItineraryService { constructor(private prisma: PrismaClient) {} @@ -204,4 +348,440 @@ export class ItineraryService { interestedActivities: formattedActivities.filter((item) => !item.isBucket), }; } + + async getMatchingBucketInterestedActivities( + userXid: number, + payload: { + userLat: number; + userLong: number; + startDate: string; + endDate: string; + startTime: string; + endTime: string; + energyLevelXid: number; + entryTypeXid: number; + page: number; + limit: number; + }, + ) { + const requestedStart = combineDateAndTime( + payload.startDate, + payload.startTime, + ); + const requestedEnd = combineDateAndTime(payload.endDate, payload.endTime); + + if (!requestedStart || !requestedEnd) { + throw new ApiError(400, 'Invalid start/end date or time values.'); + } + + if (requestedStart >= requestedEnd) { + throw new ApiError( + 400, + 'Start date and time must be earlier than end date and time.', + ); + } + + const rangeStartDay = startOfDay(requestedStart); + const rangeEndDay = startOfDay(requestedEnd); + + const activityEntries = await this.prisma.userBucketInterested.findMany({ + where: { + userXid, + isActive: true, + deletedAt: null, + Activities: { + isActive: true, + deletedAt: null, + activityInternalStatus: ACTIVITY_INTERNAL_STATUS.ACTIVITY_LISTED, + amInternalStatus: ACTIVITY_AM_INTERNAL_STATUS.ACTIVITY_LISTED, + activityType: { + isActive: true, + deletedAt: null, + energyLevelXid: payload.energyLevelXid, + }, + ActivityAllowedEntry: { + some: { + isActive: true, + deletedAt: null, + allowedEntryTypeXid: payload.entryTypeXid, + }, + }, + ActivityVenues: { + some: { + isActive: true, + deletedAt: null, + ScheduleHeader: { + some: { + isActive: true, + deletedAt: null, + startDate: { lte: rangeEndDay }, + OR: [ + { endDate: null }, + { endDate: { gte: rangeStartDay } }, + ], + }, + }, + }, + }, + }, + }, + orderBy: { + createdAt: 'desc', + }, + select: { + id: true, + isBucket: true, + bucketTypeName: true, + activityXid: true, + Activities: { + select: { + id: true, + activityTitle: true, + activityDurationMins: true, + checkInLat: true, + checkInLong: true, + checkInAddress: true, + activityType: { + select: { + id: true, + energyLevelXid: true, + energyLevel: { + select: { + id: true, + energyLevelName: true, + energyIcon: true, + }, + }, + }, + }, + ActivityAllowedEntry: { + where: { + isActive: true, + deletedAt: null, + allowedEntryTypeXid: payload.entryTypeXid, + }, + select: { + allowedEntryTypeXid: true, + allowedEntryType: { + select: { + id: true, + allowedEntryTypeName: true, + }, + }, + }, + }, + ActivitiesMedia: { + where: { + isActive: true, + deletedAt: null, + }, + orderBy: { + displayOrder: 'asc', + }, + select: { + id: true, + mediaType: true, + mediaFileName: true, + isCoverImage: true, + displayOrder: true, + }, + }, + ActivityVenues: { + where: { + isActive: true, + deletedAt: null, + ScheduleHeader: { + some: { + isActive: true, + deletedAt: null, + startDate: { lte: rangeEndDay }, + OR: [ + { endDate: null }, + { endDate: { gte: rangeStartDay } }, + ], + }, + }, + }, + select: { + id: true, + venueName: true, + venueLabel: true, + venueCapacity: true, + availableSeats: true, + ScheduleHeader: { + where: { + isActive: true, + deletedAt: null, + startDate: { lte: rangeEndDay }, + OR: [ + { endDate: null }, + { endDate: { gte: rangeStartDay } }, + ], + }, + select: { + id: true, + scheduleType: true, + startDate: true, + endDate: true, + ScheduleDetails: { + where: { + isActive: true, + deletedAt: null, + maxCapacity: { gt: 0 }, + }, + select: { + id: true, + occurenceDate: true, + weekDay: true, + dayOfMonth: true, + startTime: true, + endTime: true, + maxCapacity: true, + }, + }, + Cancellations: { + where: { + isActive: true, + deletedAt: null, + occurenceDate: { + gte: rangeStartDay, + lte: rangeEndDay, + }, + }, + select: { + occurenceDate: true, + startTime: true, + endTime: true, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }); + + const formattedActivities = await Promise.all( + activityEntries.map(async (entry) => { + const activity = entry.Activities; + const activityDurationMins = activity?.activityDurationMins ?? 0; + const distance = calculateDistance( + payload.userLat, + payload.userLong, + activity?.checkInLat ?? null, + activity?.checkInLong ?? null, + ); + + const availableSlots = activity.ActivityVenues.flatMap((venue) => + venue.ScheduleHeader.flatMap((header) => { + const effectiveRangeStart = + header.startDate > rangeStartDay ? header.startDate : rangeStartDay; + const headerEndDate = header.endDate ?? rangeEndDay; + const effectiveRangeEnd = + headerEndDate < rangeEndDay ? headerEndDate : rangeEndDay; + + if (effectiveRangeStart > effectiveRangeEnd) { + return []; + } + + const cancelledSlots = new Set( + header.Cancellations.map((cancellation) => { + if (!cancellation.occurenceDate) { + return null; + } + + return `${formatDateKey(cancellation.occurenceDate)}|${cancellation.startTime}|${cancellation.endTime}`; + }).filter(Boolean) as string[], + ); + + return header.ScheduleDetails.flatMap((slot) => { + const slotDates: Date[] = []; + + if (slot.occurenceDate) { + const occurrenceDay = startOfDay(slot.occurenceDate); + if ( + occurrenceDay >= startOfDay(effectiveRangeStart) && + occurrenceDay <= startOfDay(effectiveRangeEnd) + ) { + slotDates.push(occurrenceDay); + } + } else { + for (const currentDate of getDateRange( + effectiveRangeStart, + effectiveRangeEnd, + )) { + const weekDayName = WEEKDAY_NAMES[currentDate.getDay()]; + + if (slot.weekDay && slot.weekDay !== weekDayName) { + continue; + } + + if ( + slot.dayOfMonth !== null && + slot.dayOfMonth !== undefined && + slot.dayOfMonth !== currentDate.getDate() + ) { + continue; + } + + slotDates.push(currentDate); + } + } + + return slotDates + .map((slotDate) => { + const slotStart = combineDateAndTime(slotDate, slot.startTime); + if (!slotStart) { + return null; + } + + const slotEnd = activityDurationMins + ? addMinutes(slotStart, activityDurationMins) + : combineDateAndTime(slotDate, slot.endTime); + + if (!slotEnd) { + return null; + } + + const cancellationKey = `${formatDateKey(slotDate)}|${slot.startTime}|${slot.endTime}`; + + if (cancelledSlots.has(cancellationKey)) { + return null; + } + + if (slotStart < requestedStart || slotEnd > requestedEnd) { + return null; + } + + return { + scheduleHeaderXid: header.id, + slotId: slot.id, + venueXid: venue.id, + venueName: venue.venueName, + venueLabel: venue.venueLabel, + venueCapacity: venue.venueCapacity, + availableSeats: venue.availableSeats, + slotDate: formatDateKey(slotDate), + startTime: slot.startTime, + endTime: slotEnd.toLocaleTimeString('en-US', { + hour: 'numeric', + minute: '2-digit', + hour12: true, + }), + startDateTime: slotStart.toISOString(), + endDateTime: slotEnd.toISOString(), + maxCapacity: slot.maxCapacity, + }; + }) + .filter(Boolean); + }); + }), + ); + + if (!availableSlots.length) { + return null; + } + + availableSlots.sort( + (first, second) => + new Date(first!.startDateTime).getTime() - + new Date(second!.startDateTime).getTime(), + ); + + const coverImage = + activity.ActivitiesMedia.find((media) => media.isCoverImage) ?? + activity.ActivitiesMedia[0] ?? + null; + + const energyLevel = activity.activityType?.energyLevel ?? null; + + return { + userBucketInterestedXid: entry.id, + activityXid: entry.activityXid, + isBucket: entry.isBucket, + bucketTypeName: entry.bucketTypeName, + distance, + activityTitle: activity.activityTitle, + activityDurationMins, + activityCoverImage: coverImage?.mediaFileName ?? null, + activityCoverImagePresignedUrl: await attachPresignedUrl( + coverImage?.mediaFileName, + ), + venue: availableSlots[0] + ? { + venueXid: availableSlots[0].venueXid, + venueName: availableSlots[0].venueName, + venueLabel: availableSlots[0].venueLabel, + } + : null, + availableSlots, + entryType: activity.ActivityAllowedEntry[0]?.allowedEntryType ?? null, + energyLevel: energyLevel + ? { + energyLevelXid: energyLevel.id, + energyLevelName: energyLevel.energyLevelName, + energyLevelIcon: energyLevel.energyIcon, + energyLevelIconPresignedUrl: await attachPresignedUrl( + energyLevel.energyIcon, + ), + } + : null, + checkInAddress: activity.checkInAddress, + }; + }), + ); + + const activities = formattedActivities + .filter(Boolean) + .sort((first, second) => { + const firstDistance = + first!.distance === null ? Number.POSITIVE_INFINITY : first!.distance; + const secondDistance = + second!.distance === null ? Number.POSITIVE_INFINITY : second!.distance; + + if (firstDistance !== secondDistance) { + return firstDistance - secondDistance; + } + + return first!.activityXid - second!.activityXid; + }); + + const totalCount = activities.length; + const sanitizedLimit = Math.min(Math.max(payload.limit, 1), 20); + const sanitizedPage = Math.max(payload.page, 1); + const totalPages = totalCount ? Math.ceil(totalCount / sanitizedLimit) : 0; + const startIndex = (sanitizedPage - 1) * sanitizedLimit; + const paginatedActivities = activities.slice( + startIndex, + startIndex + sanitizedLimit, + ); + + return { + filters: { + userLat: payload.userLat, + userLong: payload.userLong, + startDate: payload.startDate, + endDate: payload.endDate, + startTime: payload.startTime, + endTime: payload.endTime, + energyLevelXid: payload.energyLevelXid, + entryTypeXid: payload.entryTypeXid, + page: sanitizedPage, + limit: sanitizedLimit, + }, + pagination: { + page: sanitizedPage, + limit: sanitizedLimit, + totalCount, + totalPages, + hasNextPage: sanitizedPage < totalPages, + hasPreviousPage: sanitizedPage > 1 && totalPages > 0, + }, + count: paginatedActivities.length, + totalCount, + activities: paginatedActivities, + }; + } }