made getMatchingBucketInterestedActivities api

This commit is contained in:
2026-03-17 16:22:03 +05:30
parent 2588ca4317
commit f1801a3210
4 changed files with 738 additions and 14 deletions

View File

@@ -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

View File

@@ -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<boolean> {
// 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({

View File

@@ -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<APIGatewayProxyResult> => {
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<string, any> = {};
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,
}),
};
});

View File

@@ -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,
};
}
}