3913 lines
132 KiB
TypeScript
3913 lines
132 KiB
TypeScript
import { Injectable } from '@nestjs/common';
|
|
import { Prisma, PrismaClient } from '@prisma/client';
|
|
import crypto from 'crypto';
|
|
import { getPresignedUrl } from '../../../common/middlewares/aws/getPreSignedUrl';
|
|
import ApiError from '../../../common/utils/helper/ApiError';
|
|
import {
|
|
ACTIVITY_AM_INTERNAL_STATUS,
|
|
ACTIVITY_INTERNAL_STATUS,
|
|
} from '../../../common/utils/constants/host.constant';
|
|
|
|
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;
|
|
|
|
const key = file.startsWith('http')
|
|
? new URL(file).pathname.replace(/^\/+/, '')
|
|
: file;
|
|
|
|
return getPresignedUrl(bucket, key);
|
|
};
|
|
|
|
const generateCheckInCode = () => `#${crypto.randomInt(1000000, 10000000)}`;
|
|
|
|
type CheckoutTaxRow = {
|
|
id: number;
|
|
taxName: string;
|
|
taxPer: number;
|
|
taxAmount: number;
|
|
};
|
|
|
|
type CheckoutChargeItem = {
|
|
id: number;
|
|
baseAmount: number;
|
|
totalAmount: number | null;
|
|
taxes: CheckoutTaxRow[];
|
|
};
|
|
|
|
type CheckoutChargeSummary = {
|
|
items: Array<{
|
|
id: number;
|
|
baseAmount: number;
|
|
totalAmount: number;
|
|
taxAmount: number;
|
|
taxes: CheckoutTaxRow[];
|
|
}>;
|
|
baseAmount: number;
|
|
taxAmount: number;
|
|
totalAmount: number;
|
|
};
|
|
|
|
const normalizeCheckoutKind = (kind?: string | null) =>
|
|
(kind ?? '')
|
|
.trim()
|
|
.toUpperCase()
|
|
.replace(/\s+/g, '_');
|
|
|
|
const sumCheckoutValues = (values: Array<number | null | undefined>) =>
|
|
values.reduce((acc, value) => acc + (Number(value) || 0), 0);
|
|
|
|
const mapCheckoutTaxes = (rows: any[] = []): CheckoutTaxRow[] =>
|
|
rows.map((row) => ({
|
|
id: row.id,
|
|
taxName: row.taxes?.taxName ?? '',
|
|
taxPer: Number(row.taxPer) || Number(row.taxes?.taxPer) || 0,
|
|
taxAmount: Number(row.taxAmount) || 0,
|
|
}));
|
|
|
|
const summarizeCheckoutRows = (
|
|
rows: CheckoutChargeItem[],
|
|
): CheckoutChargeSummary => {
|
|
const items = rows.map((row) => {
|
|
const taxAmount = sumCheckoutValues(row.taxes.map((tax) => tax.taxAmount));
|
|
const totalAmount = row.totalAmount ?? row.baseAmount + taxAmount;
|
|
|
|
return {
|
|
id: row.id,
|
|
baseAmount: row.baseAmount,
|
|
totalAmount,
|
|
taxAmount,
|
|
taxes: row.taxes,
|
|
};
|
|
});
|
|
|
|
return {
|
|
items,
|
|
baseAmount: sumCheckoutValues(items.map((item) => item.baseAmount)),
|
|
taxAmount: sumCheckoutValues(items.map((item) => item.taxAmount)),
|
|
totalAmount: sumCheckoutValues(items.map((item) => item.totalAmount)),
|
|
};
|
|
};
|
|
|
|
const pickCheckoutSummary = (
|
|
groups: Map<string, CheckoutChargeItem[]>,
|
|
aliases: string[],
|
|
) => {
|
|
for (const alias of aliases) {
|
|
const rows = groups.get(alias);
|
|
if (rows?.length) {
|
|
return summarizeCheckoutRows(rows);
|
|
}
|
|
}
|
|
|
|
return null;
|
|
};
|
|
|
|
const attachMediaWithPresignedUrl = async (
|
|
mediaArr: Array<{
|
|
id: number;
|
|
mediaType: string;
|
|
mediaFileName: string;
|
|
isCoverImage: boolean;
|
|
displayOrder: number;
|
|
}> = [],
|
|
) => {
|
|
return Promise.all(
|
|
mediaArr.map(async (media) => ({
|
|
id: media.id,
|
|
mediaType: media.mediaType,
|
|
mediaFileName: media.mediaFileName,
|
|
isCoverImage: media.isCoverImage,
|
|
displayOrder: media.displayOrder,
|
|
presignedUrl: await attachPresignedUrl(media.mediaFileName),
|
|
})),
|
|
);
|
|
};
|
|
|
|
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 normalizeTimeValue = (value: string) => {
|
|
const parsed = parseTimeValue(value);
|
|
|
|
if (!parsed) {
|
|
return null;
|
|
}
|
|
|
|
return `${String(parsed.hours).padStart(2, '0')}:${String(
|
|
parsed.minutes,
|
|
).padStart(2, '0')}:${String(parsed.seconds).padStart(2, '0')}`;
|
|
};
|
|
|
|
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;
|
|
};
|
|
|
|
const getUniqueDatesForScheduleDetail = (
|
|
slot: {
|
|
occurenceDate: Date | null;
|
|
weekDay: string | null;
|
|
dayOfMonth: number | null;
|
|
},
|
|
fromDate: Date,
|
|
toDate: Date,
|
|
) => {
|
|
if (slot.occurenceDate) {
|
|
const occurrenceDay = startOfDay(slot.occurenceDate);
|
|
|
|
if (
|
|
occurrenceDay >= startOfDay(fromDate) &&
|
|
occurrenceDay <= startOfDay(toDate)
|
|
) {
|
|
return [occurrenceDay];
|
|
}
|
|
|
|
return [];
|
|
}
|
|
|
|
const dates: Date[] = [];
|
|
|
|
for (const currentDate of getDateRange(fromDate, toDate)) {
|
|
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;
|
|
}
|
|
|
|
dates.push(currentDate);
|
|
}
|
|
|
|
return dates;
|
|
};
|
|
|
|
@Injectable()
|
|
export class ItineraryService {
|
|
constructor(private prisma: PrismaClient) { }
|
|
|
|
async getUserItineraryDetails(userXid: number) {
|
|
const [userLocation, activityEntries, travellerType, energyLevel] = await Promise.all([
|
|
this.prisma.userAddressDetails.findFirst({
|
|
where: {
|
|
userXid,
|
|
isActive: true,
|
|
deletedAt: null,
|
|
},
|
|
orderBy: {
|
|
createdAt: 'desc',
|
|
},
|
|
select: {
|
|
id: true,
|
|
address1: true,
|
|
address2: true,
|
|
pinCode: true,
|
|
locationName: true,
|
|
locationAddress: true,
|
|
locationLat: true,
|
|
locationLong: true,
|
|
countryXid: true,
|
|
stateXid: true,
|
|
cityXid: true,
|
|
country: {
|
|
select: {
|
|
id: true,
|
|
countryName: true,
|
|
countryCode: true,
|
|
},
|
|
},
|
|
states: {
|
|
select: {
|
|
id: true,
|
|
stateName: true,
|
|
},
|
|
},
|
|
cities: {
|
|
select: {
|
|
id: true,
|
|
cityName: true,
|
|
},
|
|
},
|
|
},
|
|
}),
|
|
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,
|
|
},
|
|
},
|
|
orderBy: {
|
|
createdAt: 'desc',
|
|
},
|
|
select: {
|
|
id: true,
|
|
isBucket: true,
|
|
bucketTypeName: true,
|
|
activityStatus: true,
|
|
createdAt: true,
|
|
activityXid: true,
|
|
Activities: {
|
|
select: {
|
|
id: true,
|
|
activityTitle: true,
|
|
activityDescription: true,
|
|
activityDurationMins: true,
|
|
checkInAddress: true,
|
|
checkInLat: true,
|
|
checkInLong: true,
|
|
activityType: {
|
|
select: {
|
|
energyLevel: {
|
|
select: {
|
|
id: true,
|
|
energyLevelName: true,
|
|
energyIcon: true,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
ActivitiesMedia: {
|
|
where: {
|
|
isActive: true,
|
|
deletedAt: null,
|
|
},
|
|
orderBy: {
|
|
displayOrder: 'asc',
|
|
},
|
|
select: {
|
|
id: true,
|
|
mediaType: true,
|
|
mediaFileName: true,
|
|
isCoverImage: true,
|
|
displayOrder: true,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}),
|
|
this.prisma.allowedEntryTypes.findMany({
|
|
where: {
|
|
isActive: true,
|
|
deletedAt: null,
|
|
},
|
|
select: {
|
|
id: true,
|
|
allowedEntryTypeName: true,
|
|
},
|
|
orderBy: {
|
|
id: 'asc',
|
|
},
|
|
}),
|
|
this.prisma.energyLevels.findMany({
|
|
where: {
|
|
isActive: true,
|
|
deletedAt: null,
|
|
},
|
|
select: {
|
|
id: true,
|
|
energyLevelName: true,
|
|
energyIcon: true,
|
|
energyColor: true,
|
|
},
|
|
orderBy: {
|
|
id: 'asc',
|
|
}
|
|
})
|
|
]);
|
|
|
|
const formattedActivities = await Promise.all(
|
|
activityEntries.map(async (entry) => {
|
|
const coverImage =
|
|
entry.Activities?.ActivitiesMedia.find((media) => media.isCoverImage) ??
|
|
entry.Activities?.ActivitiesMedia[0] ??
|
|
null;
|
|
|
|
return {
|
|
id: entry.id,
|
|
activityXid: entry.activityXid,
|
|
isBucket: entry.isBucket,
|
|
bucketTypeName: entry.bucketTypeName,
|
|
activityStatus: entry.activityStatus,
|
|
addedOn: entry.createdAt,
|
|
activityDetails: {
|
|
id: entry.Activities?.id ?? null,
|
|
activityTitle: entry.Activities?.activityTitle ?? null,
|
|
activityDescription: entry.Activities?.activityDescription ?? null,
|
|
activityDurationMins: entry.Activities?.activityDurationMins ?? null,
|
|
checkInAddress: entry.Activities?.checkInAddress ?? null,
|
|
checkInLat: entry.Activities?.checkInLat ?? null,
|
|
checkInLong: entry.Activities?.checkInLong ?? null,
|
|
coverImage: coverImage?.mediaFileName ?? null,
|
|
coverImagePresignedUrl: await attachPresignedUrl(
|
|
coverImage?.mediaFileName,
|
|
),
|
|
energyLevel: entry.Activities?.activityType?.energyLevel
|
|
? {
|
|
id: entry.Activities.activityType.energyLevel.id,
|
|
energyLevelName:
|
|
entry.Activities.activityType.energyLevel.energyLevelName,
|
|
energyIcon:
|
|
entry.Activities.activityType.energyLevel.energyIcon,
|
|
energyIconPresignedUrl: await attachPresignedUrl(
|
|
entry.Activities.activityType.energyLevel.energyIcon,
|
|
),
|
|
}
|
|
: null,
|
|
media: await attachMediaWithPresignedUrl(
|
|
entry.Activities?.ActivitiesMedia ?? [],
|
|
),
|
|
},
|
|
};
|
|
}),
|
|
);
|
|
|
|
const latestAddedActivity = formattedActivities[0] ?? null;
|
|
const formattedMasterEnergyLevels = await Promise.all(
|
|
energyLevel.map(async (item) => ({
|
|
...item,
|
|
energyIconPresignedUrl: await attachPresignedUrl(item.energyIcon),
|
|
})),
|
|
);
|
|
|
|
return {
|
|
userLocation,
|
|
travellerType,
|
|
energyLevel: formattedMasterEnergyLevels,
|
|
bucketCount: formattedActivities.filter((item) => item.isBucket).length,
|
|
interestedCount: formattedActivities.filter((item) => !item.isBucket).length,
|
|
latestAddedActivityCoverImage:
|
|
latestAddedActivity?.activityDetails.coverImage ?? null,
|
|
latestAddedActivityCoverImagePresignedUrl:
|
|
latestAddedActivity?.activityDetails.coverImagePresignedUrl ?? null,
|
|
bucketActivities: formattedActivities.filter((item) => item.isBucket),
|
|
interestedActivities: formattedActivities.filter((item) => !item.isBucket),
|
|
};
|
|
}
|
|
|
|
async getItineraryCheckoutDetails(userXid: number, itineraryHeaderXid: number) {
|
|
const itinerary = await this.prisma.itineraryHeader.findFirst({
|
|
where: {
|
|
id: itineraryHeaderXid,
|
|
ownerXid: userXid,
|
|
isActive: true,
|
|
deletedAt: null,
|
|
},
|
|
select: {
|
|
id: true,
|
|
itineraryNo: true,
|
|
title: true,
|
|
fromDate: true,
|
|
fromTime: true,
|
|
toDate: true,
|
|
toTime: true,
|
|
itineraryStatus: true,
|
|
ItineraryActivities: {
|
|
where: { isActive: true, deletedAt: null },
|
|
orderBy: { displayOrder: 'asc' },
|
|
select: {
|
|
id: true,
|
|
displayOrder: true,
|
|
itineraryType: true,
|
|
activityXid: true,
|
|
venueXid: true,
|
|
scheduledHeaderXid: true,
|
|
occurenceDate: true,
|
|
startTime: true,
|
|
endTime: true,
|
|
endDate: true,
|
|
paxCount: true,
|
|
totalAmount: true,
|
|
bookingStatus: true,
|
|
activity: {
|
|
select: {
|
|
id: true,
|
|
activityTitle: true,
|
|
activityDescription: true,
|
|
ActivitiesMedia: {
|
|
where: { isActive: true, isCoverImage: true, deletedAt: null },
|
|
orderBy: { displayOrder: 'asc' },
|
|
take: 1,
|
|
select: { id: true, mediaFileName: true, mediaType: true, displayOrder: true },
|
|
},
|
|
activityFoodTypes: {
|
|
where: { isActive: true, deletedAt: null },
|
|
select: {
|
|
id: true,
|
|
foodTypeXid: true,
|
|
foodType: { select: { id: true, foodTypeName: true } },
|
|
},
|
|
},
|
|
ActivityFoodCost: {
|
|
where: { isActive: true, deletedAt: null },
|
|
select: {
|
|
id: true,
|
|
foodTypesId: true,
|
|
baseAmount: true,
|
|
totalAmount: true,
|
|
ActivityFoodTaxes: {
|
|
where: { isActive: true, deletedAt: null },
|
|
select: {
|
|
id: true,
|
|
taxPer: true,
|
|
taxAmount: true,
|
|
taxes: { select: { id: true, taxName: true, taxPer: true } },
|
|
},
|
|
},
|
|
},
|
|
},
|
|
ActivityTrainers: {
|
|
where: { isActive: true, deletedAt: null },
|
|
select: {
|
|
id: true,
|
|
baseAmount: true,
|
|
totalAmount: true,
|
|
ActivityTrainerTaxes: {
|
|
where: { isActive: true, deletedAt: null },
|
|
select: {
|
|
id: true,
|
|
taxPer: true,
|
|
taxAmount: true,
|
|
taxes: { select: { id: true, taxName: true, taxPer: true } },
|
|
},
|
|
},
|
|
},
|
|
},
|
|
ActivityNavigationModes: {
|
|
where: { isActive: true, deletedAt: null },
|
|
select: {
|
|
id: true,
|
|
navigationModeName: true,
|
|
navigationModesBasePrice: true,
|
|
navigationModesTotalPrice: true,
|
|
ActivityNavigationModesTaxes: {
|
|
where: { isActive: true, deletedAt: null },
|
|
select: {
|
|
id: true,
|
|
taxPer: true,
|
|
taxAmount: true,
|
|
taxes: { select: { id: true, taxName: true, taxPer: true } },
|
|
},
|
|
},
|
|
},
|
|
},
|
|
ActivityPickUpDetails: {
|
|
where: { isActive: true, deletedAt: null },
|
|
select: {
|
|
id: true,
|
|
isPickUp: true,
|
|
locationLat: true,
|
|
locationLong: true,
|
|
locationAddress: true,
|
|
transportBasePrice: true,
|
|
transportTotalPrice: true,
|
|
activityPickUpTransportTaxes: {
|
|
where: { isActive: true, deletedAt: null },
|
|
select: {
|
|
id: true,
|
|
taxPer: true,
|
|
taxAmount: true,
|
|
taxes: { select: { id: true, taxName: true, taxPer: true } },
|
|
},
|
|
},
|
|
},
|
|
},
|
|
activityPickUpTransports: {
|
|
where: { isActive: true, deletedAt: null },
|
|
select: {
|
|
id: true,
|
|
transportModeXid: true,
|
|
transportMode: {
|
|
select: {
|
|
id: true,
|
|
transportModeName: true,
|
|
transportModeIcon: true,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
ActivityEquipments: {
|
|
where: { isActive: true, deletedAt: null },
|
|
select: {
|
|
id: true,
|
|
equipmentName: true,
|
|
equipmentBasePrice: true,
|
|
equipmentTotalPrice: true,
|
|
ActivityEquipmentTaxes: {
|
|
where: { isActive: true, deletedAt: null },
|
|
select: {
|
|
id: true,
|
|
taxPer: true,
|
|
taxAmount: true,
|
|
taxes: { select: { id: true, taxName: true, taxPer: true } },
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
itineraryActivitySelections: {
|
|
where: { isActive: true, deletedAt: null },
|
|
select: {
|
|
id: true,
|
|
itineraryMemberXid: true,
|
|
isFoodOpted: true,
|
|
isTrainerOpted: true,
|
|
isInActivityNavigationOpted: true,
|
|
itineraryMember: {
|
|
select: {
|
|
id: true,
|
|
memberXid: true,
|
|
memberRole: true,
|
|
memberStatus: true,
|
|
member: {
|
|
select: {
|
|
id: true,
|
|
firstName: true,
|
|
lastName: true,
|
|
mobileNumber: true,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
selectedFoodTypes: {
|
|
where: { isActive: true, deletedAt: null },
|
|
select: {
|
|
id: true,
|
|
activityFoodTypeXid: true,
|
|
activityFoodType: {
|
|
select: {
|
|
id: true,
|
|
foodTypeXid: true,
|
|
foodType: {
|
|
select: {
|
|
id: true,
|
|
foodTypeName: true,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
activityNavigationMode: {
|
|
select: {
|
|
id: true,
|
|
navigationModeName: true,
|
|
navigationModesBasePrice: true,
|
|
navigationModesTotalPrice: true,
|
|
ActivityNavigationModesTaxes: {
|
|
where: { isActive: true, deletedAt: null },
|
|
select: {
|
|
id: true,
|
|
taxPer: true,
|
|
taxAmount: true,
|
|
taxes: { select: { id: true, taxName: true, taxPer: true } },
|
|
},
|
|
},
|
|
},
|
|
},
|
|
selectedEquipments: {
|
|
where: { isActive: true, deletedAt: null },
|
|
select: {
|
|
id: true,
|
|
activityEquipmentXid: true,
|
|
activityEquipment: {
|
|
select: {
|
|
id: true,
|
|
equipmentName: true,
|
|
equipmentBasePrice: true,
|
|
equipmentTotalPrice: true,
|
|
ActivityEquipmentTaxes: {
|
|
where: { isActive: true, deletedAt: null },
|
|
select: {
|
|
id: true,
|
|
taxPer: true,
|
|
taxAmount: true,
|
|
taxes: { select: { id: true, taxName: true, taxPer: true } },
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
ItineraryDetails: {
|
|
where: { isActive: true, deletedAt: null },
|
|
select: {
|
|
id: true,
|
|
itineraryMemberXid: true,
|
|
itineraryKind: true,
|
|
hasOpted: true,
|
|
baseAmount: true,
|
|
totalAmount: true,
|
|
description1: true,
|
|
description2: true,
|
|
offlineCode: true,
|
|
activityStatus: true,
|
|
isChargeable: true,
|
|
itineraryMember: {
|
|
select: {
|
|
id: true,
|
|
memberXid: true,
|
|
memberRole: true,
|
|
memberStatus: true,
|
|
member: {
|
|
select: {
|
|
id: true,
|
|
firstName: true,
|
|
lastName: true,
|
|
mobileNumber: true,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
ItineraryDetailTaxes: {
|
|
where: { isActive: true, deletedAt: null },
|
|
select: {
|
|
id: true,
|
|
taxPer: true,
|
|
taxAmount: true,
|
|
taxes: { select: { id: true, taxName: true, taxPer: true } },
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
if (!itinerary) {
|
|
throw new ApiError(404, 'Itinerary not found.');
|
|
}
|
|
|
|
const activities = await Promise.all(
|
|
(itinerary.ItineraryActivities ?? []).map(async (item) => {
|
|
const coverImage = item.activity?.ActivitiesMedia?.[0]?.mediaFileName ?? null;
|
|
const coverImagePresignedUrl = await attachPresignedUrl(coverImage);
|
|
|
|
const details = item.ItineraryDetails ?? [];
|
|
const memberSelectionGroups = new Map<number, any[]>();
|
|
for (const detail of details) {
|
|
const list = memberSelectionGroups.get(detail.itineraryMemberXid) ?? [];
|
|
list.push(detail);
|
|
memberSelectionGroups.set(detail.itineraryMemberXid, list);
|
|
}
|
|
|
|
const memberSelections = (item.itineraryActivitySelections ?? []).map((selection) => {
|
|
const memberDetails = memberSelectionGroups.get(selection.itineraryMemberXid) ?? [];
|
|
const detailGroups = new Map<string, CheckoutChargeItem[]>();
|
|
for (const detail of memberDetails) {
|
|
const key = normalizeCheckoutKind(detail.itineraryKind);
|
|
const list = detailGroups.get(key) ?? [];
|
|
list.push({
|
|
id: detail.id,
|
|
baseAmount: Number(detail.baseAmount) || 0,
|
|
totalAmount: detail.totalAmount === null ? null : Number(detail.totalAmount),
|
|
taxes: mapCheckoutTaxes(detail.ItineraryDetailTaxes ?? []),
|
|
});
|
|
detailGroups.set(key, list);
|
|
}
|
|
|
|
const activityCharge =
|
|
pickCheckoutSummary(detailGroups, ['ACTIVITY']) ??
|
|
{
|
|
items: [],
|
|
baseAmount: Number(item.totalAmount) || 0,
|
|
taxAmount: 0,
|
|
totalAmount: Number(item.totalAmount) || 0,
|
|
};
|
|
|
|
const foodCharge =
|
|
pickCheckoutSummary(detailGroups, ['FOOD']) ??
|
|
summarizeCheckoutRows(
|
|
selection.isFoodOpted
|
|
? selection.selectedFoodTypes
|
|
.map((selectedFoodType) => {
|
|
const matchedCost =
|
|
item.activity?.ActivityFoodCost.find(
|
|
(cost) => cost.foodTypesId === selectedFoodType.activityFoodType.foodTypeXid,
|
|
) ?? item.activity?.ActivityFoodCost?.[0];
|
|
|
|
return matchedCost
|
|
? {
|
|
id: matchedCost.id,
|
|
baseAmount: Number(matchedCost.baseAmount) || 0,
|
|
totalAmount:
|
|
matchedCost.totalAmount === null
|
|
? null
|
|
: Number(matchedCost.totalAmount),
|
|
taxes: mapCheckoutTaxes(matchedCost.ActivityFoodTaxes ?? []),
|
|
}
|
|
: null;
|
|
})
|
|
.filter(Boolean) as CheckoutChargeItem[]
|
|
: [],
|
|
);
|
|
|
|
const trainerCharge =
|
|
pickCheckoutSummary(detailGroups, ['TRAINER']) ??
|
|
(selection.isTrainerOpted && item.activity?.ActivityTrainers?.[0]
|
|
? summarizeCheckoutRows([
|
|
{
|
|
id: item.activity.ActivityTrainers[0].id,
|
|
baseAmount: Number(item.activity.ActivityTrainers[0].baseAmount) || 0,
|
|
totalAmount:
|
|
item.activity.ActivityTrainers[0].totalAmount === null
|
|
? null
|
|
: Number(item.activity.ActivityTrainers[0].totalAmount),
|
|
taxes: mapCheckoutTaxes(
|
|
item.activity.ActivityTrainers[0].ActivityTrainerTaxes ?? [],
|
|
),
|
|
},
|
|
])
|
|
: { items: [], baseAmount: 0, taxAmount: 0, totalAmount: 0 });
|
|
|
|
const navigationCharge =
|
|
pickCheckoutSummary(detailGroups, ['NAVIGATION', 'IN_ACTIVITY_NAVIGATION']) ??
|
|
(selection.isInActivityNavigationOpted && selection.activityNavigationMode
|
|
? summarizeCheckoutRows([
|
|
{
|
|
id: selection.activityNavigationMode.id,
|
|
baseAmount:
|
|
Number(selection.activityNavigationMode.navigationModesBasePrice) || 0,
|
|
totalAmount:
|
|
selection.activityNavigationMode.navigationModesTotalPrice === null
|
|
? null
|
|
: Number(selection.activityNavigationMode.navigationModesTotalPrice),
|
|
taxes: mapCheckoutTaxes(
|
|
selection.activityNavigationMode.ActivityNavigationModesTaxes ?? [],
|
|
),
|
|
},
|
|
])
|
|
: { items: [], baseAmount: 0, taxAmount: 0, totalAmount: 0 });
|
|
|
|
const pickupCharge =
|
|
pickCheckoutSummary(detailGroups, ['PICKUP', 'PICK_UP', 'PICKUP_DROP']) ??
|
|
summarizeCheckoutRows(
|
|
(item.activity?.ActivityPickUpDetails ?? []).map((pickUpDetail) => ({
|
|
id: pickUpDetail.id,
|
|
baseAmount: Number(pickUpDetail.transportBasePrice) || 0,
|
|
totalAmount:
|
|
pickUpDetail.transportTotalPrice === null
|
|
? null
|
|
: Number(pickUpDetail.transportTotalPrice),
|
|
taxes: mapCheckoutTaxes(pickUpDetail.activityPickUpTransportTaxes ?? []),
|
|
})),
|
|
);
|
|
|
|
const equipmentCharge =
|
|
pickCheckoutSummary(detailGroups, ['EQUIPMENT']) ??
|
|
summarizeCheckoutRows(
|
|
selection.selectedEquipments.map((selectedEquipment) => ({
|
|
id: selectedEquipment.activityEquipment.id,
|
|
baseAmount: Number(selectedEquipment.activityEquipment.equipmentBasePrice) || 0,
|
|
totalAmount:
|
|
selectedEquipment.activityEquipment.equipmentTotalPrice === null
|
|
? null
|
|
: Number(selectedEquipment.activityEquipment.equipmentTotalPrice),
|
|
taxes: mapCheckoutTaxes(
|
|
selectedEquipment.activityEquipment.ActivityEquipmentTaxes ?? [],
|
|
),
|
|
})),
|
|
);
|
|
|
|
return {
|
|
id: selection.id,
|
|
itineraryMemberXid: selection.itineraryMemberXid,
|
|
member: selection.itineraryMember,
|
|
selectedFoodTypes: selection.selectedFoodTypes.map((selectedFoodType) => ({
|
|
id: selectedFoodType.id,
|
|
activityFoodTypeXid: selectedFoodType.activityFoodTypeXid,
|
|
foodTypeXid: selectedFoodType.activityFoodType.foodTypeXid,
|
|
foodTypeName: selectedFoodType.activityFoodType.foodType.foodTypeName,
|
|
})),
|
|
selectedNavigationMode: selection.activityNavigationMode
|
|
? {
|
|
id: selection.activityNavigationMode.id,
|
|
navigationModeName: selection.activityNavigationMode.navigationModeName,
|
|
baseAmount:
|
|
Number(selection.activityNavigationMode.navigationModesBasePrice) || 0,
|
|
totalAmount:
|
|
Number(selection.activityNavigationMode.navigationModesTotalPrice) || 0,
|
|
taxes: mapCheckoutTaxes(
|
|
selection.activityNavigationMode.ActivityNavigationModesTaxes ?? [],
|
|
),
|
|
}
|
|
: null,
|
|
selectedEquipments: selection.selectedEquipments.map((selectedEquipment) => ({
|
|
id: selectedEquipment.id,
|
|
activityEquipmentXid: selectedEquipment.activityEquipmentXid,
|
|
equipmentName: selectedEquipment.activityEquipment.equipmentName,
|
|
baseAmount: Number(selectedEquipment.activityEquipment.equipmentBasePrice) || 0,
|
|
totalAmount: Number(selectedEquipment.activityEquipment.equipmentTotalPrice) || 0,
|
|
taxes: mapCheckoutTaxes(
|
|
selectedEquipment.activityEquipment.ActivityEquipmentTaxes ?? [],
|
|
),
|
|
})),
|
|
pricing: {
|
|
activity: activityCharge,
|
|
food: foodCharge,
|
|
trainer: trainerCharge,
|
|
navigation: navigationCharge,
|
|
pickup: pickupCharge,
|
|
equipment: equipmentCharge,
|
|
totalAmount:
|
|
activityCharge.totalAmount +
|
|
foodCharge.totalAmount +
|
|
trainerCharge.totalAmount +
|
|
navigationCharge.totalAmount +
|
|
pickupCharge.totalAmount +
|
|
equipmentCharge.totalAmount,
|
|
},
|
|
pricingSource: memberDetails.length ? 'itinerary_details' : 'fallback',
|
|
detailRows: memberDetails.map((detail) => ({
|
|
id: detail.id,
|
|
itineraryKind: detail.itineraryKind,
|
|
hasOpted: detail.hasOpted,
|
|
baseAmount: detail.baseAmount,
|
|
totalAmount: detail.totalAmount,
|
|
description1: detail.description1,
|
|
description2: detail.description2,
|
|
offlineCode: detail.offlineCode,
|
|
activityStatus: detail.activityStatus,
|
|
isChargeable: detail.isChargeable,
|
|
taxes: mapCheckoutTaxes(detail.ItineraryDetailTaxes ?? []),
|
|
member: detail.itineraryMember,
|
|
})),
|
|
};
|
|
});
|
|
|
|
const activityTotalAmount = sumCheckoutValues(
|
|
memberSelections.map((selection) => selection.pricing.totalAmount),
|
|
);
|
|
|
|
return {
|
|
itineraryActivityXid: item.id,
|
|
displayOrder: item.displayOrder,
|
|
itineraryType: item.itineraryType,
|
|
activityXid: item.activityXid,
|
|
venueXid: item.venueXid,
|
|
scheduledHeaderXid: item.scheduledHeaderXid,
|
|
occurenceDate: item.occurenceDate,
|
|
startTime: item.startTime,
|
|
endTime: item.endTime,
|
|
endDate: item.endDate,
|
|
paxCount: item.paxCount,
|
|
bookingStatus: item.bookingStatus,
|
|
activity: {
|
|
id: item.activity?.id ?? null,
|
|
activityTitle: item.activity?.activityTitle ?? null,
|
|
activityDescription: item.activity?.activityDescription ?? null,
|
|
coverImage,
|
|
coverImagePresignedUrl,
|
|
foodOptions: (item.activity?.activityFoodTypes ?? []).map((foodType) => ({
|
|
id: foodType.id,
|
|
foodTypeXid: foodType.foodTypeXid,
|
|
foodTypeName: foodType.foodType.foodTypeName,
|
|
})),
|
|
trainerOptions: (item.activity?.ActivityTrainers ?? []).map((trainer) => ({
|
|
id: trainer.id,
|
|
baseAmount: trainer.baseAmount,
|
|
totalAmount: trainer.totalAmount,
|
|
taxes: mapCheckoutTaxes(trainer.ActivityTrainerTaxes ?? []),
|
|
})),
|
|
navigationOptions: (item.activity?.ActivityNavigationModes ?? []).map((navigationMode) => ({
|
|
id: navigationMode.id,
|
|
navigationModeName: navigationMode.navigationModeName,
|
|
navigationModesBasePrice: navigationMode.navigationModesBasePrice,
|
|
navigationModesTotalPrice: navigationMode.navigationModesTotalPrice,
|
|
taxes: mapCheckoutTaxes(navigationMode.ActivityNavigationModesTaxes ?? []),
|
|
})),
|
|
pickupOptions: (item.activity?.ActivityPickUpDetails ?? []).map((pickUpDetail) => ({
|
|
id: pickUpDetail.id,
|
|
isPickUp: pickUpDetail.isPickUp,
|
|
locationLat: pickUpDetail.locationLat,
|
|
locationLong: pickUpDetail.locationLong,
|
|
locationAddress: pickUpDetail.locationAddress,
|
|
transportBasePrice: pickUpDetail.transportBasePrice,
|
|
transportTotalPrice: pickUpDetail.transportTotalPrice,
|
|
taxes: mapCheckoutTaxes(pickUpDetail.activityPickUpTransportTaxes ?? []),
|
|
})),
|
|
pickupTransportModes: (item.activity?.activityPickUpTransports ?? []).map((transport) => ({
|
|
id: transport.id,
|
|
transportModeXid: transport.transportModeXid,
|
|
transportModeName: transport.transportMode.transportModeName,
|
|
transportModeIcon: transport.transportMode.transportModeIcon,
|
|
})),
|
|
equipmentOptions: (item.activity?.ActivityEquipments ?? []).map((equipment) => ({
|
|
id: equipment.id,
|
|
equipmentName: equipment.equipmentName,
|
|
equipmentBasePrice: equipment.equipmentBasePrice,
|
|
equipmentTotalPrice: equipment.equipmentTotalPrice,
|
|
taxes: mapCheckoutTaxes(equipment.ActivityEquipmentTaxes ?? []),
|
|
})),
|
|
},
|
|
memberSelections,
|
|
activityTotalAmount,
|
|
};
|
|
}),
|
|
);
|
|
|
|
const summary = {
|
|
activityTotal: sumCheckoutValues(activities.map((activity) => activity.activityTotalAmount)),
|
|
foodTotal: sumCheckoutValues(
|
|
activities.flatMap((activity) => activity.memberSelections.map((selection) => selection.pricing.food.totalAmount)),
|
|
),
|
|
trainerTotal: sumCheckoutValues(
|
|
activities.flatMap((activity) => activity.memberSelections.map((selection) => selection.pricing.trainer.totalAmount)),
|
|
),
|
|
navigationTotal: sumCheckoutValues(
|
|
activities.flatMap((activity) => activity.memberSelections.map((selection) => selection.pricing.navigation.totalAmount)),
|
|
),
|
|
pickupTotal: sumCheckoutValues(
|
|
activities.flatMap((activity) => activity.memberSelections.map((selection) => selection.pricing.pickup.totalAmount)),
|
|
),
|
|
equipmentTotal: sumCheckoutValues(
|
|
activities.flatMap((activity) => activity.memberSelections.map((selection) => selection.pricing.equipment.totalAmount)),
|
|
),
|
|
taxTotal: sumCheckoutValues(
|
|
activities.flatMap((activity) =>
|
|
activity.memberSelections.flatMap((selection) => [
|
|
selection.pricing.activity.taxAmount,
|
|
selection.pricing.food.taxAmount,
|
|
selection.pricing.trainer.taxAmount,
|
|
selection.pricing.navigation.taxAmount,
|
|
selection.pricing.pickup.taxAmount,
|
|
selection.pricing.equipment.taxAmount,
|
|
]),
|
|
),
|
|
),
|
|
};
|
|
|
|
const grandTotal =
|
|
summary.activityTotal +
|
|
summary.foodTotal +
|
|
summary.trainerTotal +
|
|
summary.navigationTotal +
|
|
summary.pickupTotal +
|
|
summary.equipmentTotal;
|
|
|
|
return {
|
|
itineraryHeaderXid: itinerary.id,
|
|
itineraryNo: itinerary.itineraryNo,
|
|
title: itinerary.title,
|
|
fromDate: itinerary.fromDate,
|
|
fromTime: itinerary.fromTime,
|
|
toDate: itinerary.toDate,
|
|
toTime: itinerary.toTime,
|
|
itineraryStatus: itinerary.itineraryStatus,
|
|
summary: {
|
|
...summary,
|
|
grandTotal,
|
|
},
|
|
activities,
|
|
};
|
|
}
|
|
|
|
async saveUserItinerary(
|
|
ownerXid: number,
|
|
payload: {
|
|
title?: string;
|
|
startDate: string;
|
|
endDate: string;
|
|
startTime: string;
|
|
endTime: string;
|
|
startLocationAddress: unknown;
|
|
startLocationLat: number;
|
|
startLocationLong: number;
|
|
endLocationAddress: unknown;
|
|
endLocationLat: number;
|
|
endLocationLong: number;
|
|
activities: Array<{
|
|
activityXid?: number;
|
|
venueXid?: number;
|
|
scheduleHeaderXid?: number;
|
|
modeOfTravel: string;
|
|
travelTimeBetweenPointsMins: number;
|
|
kmForNextPoint?: number;
|
|
startDate?: string;
|
|
endDate?: string;
|
|
occurenceDate?: string;
|
|
selectedStartTime?: string;
|
|
selectedEndTime?: string;
|
|
itineraryType?: string;
|
|
paxCount?: number;
|
|
totalAmount?: number;
|
|
locationLat?: number;
|
|
locationLong?: number;
|
|
locationAddress?: unknown;
|
|
}>;
|
|
},
|
|
) {
|
|
const itineraryStartDate = parseDateValue(payload.startDate);
|
|
const itineraryEndDate = parseDateValue(payload.endDate);
|
|
const itineraryStartDateTime = combineDateAndTime(
|
|
payload.startDate,
|
|
payload.startTime,
|
|
);
|
|
const itineraryEndDateTime = combineDateAndTime(
|
|
payload.endDate,
|
|
payload.endTime,
|
|
);
|
|
|
|
if (
|
|
Number.isNaN(itineraryStartDate.getTime()) ||
|
|
Number.isNaN(itineraryEndDate.getTime()) ||
|
|
!itineraryStartDateTime ||
|
|
!itineraryEndDateTime
|
|
) {
|
|
throw new ApiError(400, 'Invalid itinerary start or end date/time.');
|
|
}
|
|
|
|
if (itineraryStartDateTime >= itineraryEndDateTime) {
|
|
throw new ApiError(
|
|
400,
|
|
'Itinerary start date and time must be earlier than itinerary end date and time.',
|
|
);
|
|
}
|
|
|
|
if (!payload.activities.length) {
|
|
throw new ApiError(400, 'At least one itinerary activity is required.');
|
|
}
|
|
|
|
const itineraryNo = `ITN-${Date.now()}`;
|
|
const itineraryTitle = payload.title?.trim() || itineraryNo;
|
|
|
|
return this.prisma.$transaction(async (tx) => {
|
|
const itineraryHeader = await tx.itineraryHeader.create({
|
|
data: {
|
|
itineraryNo,
|
|
title: itineraryTitle,
|
|
ownerXid,
|
|
fromDate: itineraryStartDate,
|
|
fromTime: payload.startTime,
|
|
toDate: itineraryEndDate,
|
|
toTime: payload.endTime,
|
|
itineraryStatus: 'draft',
|
|
isActive: true,
|
|
},
|
|
});
|
|
|
|
const ownerMember = await tx.itineraryMembers.create({
|
|
data: {
|
|
itineraryHeaderXid: itineraryHeader.id,
|
|
memberXid: ownerXid,
|
|
memberRole: 'OWNER',
|
|
memberStatus: 'accepted',
|
|
invitedByXid: ownerXid,
|
|
isActive: true,
|
|
},
|
|
});
|
|
|
|
const startLocationDetails = await tx.itineraryStartStopDetails.create({
|
|
data: {
|
|
itineraryHeaderXid: itineraryHeader.id,
|
|
itineraryMemberXid: ownerMember.id,
|
|
dateValue: itineraryStartDate,
|
|
timeValue: payload.startTime,
|
|
isStartPoint: true,
|
|
locationLat: payload.startLocationLat,
|
|
locationLong: payload.startLocationLong,
|
|
locationAddress:
|
|
payload.startLocationAddress as Prisma.InputJsonValue,
|
|
travelMode: null,
|
|
kmForNextPoint: null,
|
|
timeForNextPointMins: null,
|
|
isActive: true,
|
|
},
|
|
});
|
|
|
|
const endLocationDetails = await tx.itineraryStartStopDetails.create({
|
|
data: {
|
|
itineraryHeaderXid: itineraryHeader.id,
|
|
itineraryMemberXid: ownerMember.id,
|
|
dateValue: itineraryEndDate,
|
|
timeValue: payload.endTime,
|
|
isStartPoint: false,
|
|
locationLat: payload.endLocationLat,
|
|
locationLong: payload.endLocationLong,
|
|
locationAddress: payload.endLocationAddress as Prisma.InputJsonValue,
|
|
travelMode: null,
|
|
kmForNextPoint: null,
|
|
timeForNextPointMins: null,
|
|
isActive: true,
|
|
},
|
|
});
|
|
|
|
const createdActivities = await Promise.all(
|
|
payload.activities.map(async (activityItem, activityIndex) => {
|
|
const itineraryType =
|
|
activityItem.itineraryType?.trim().toUpperCase() || 'ACTIVITY';
|
|
const isCustomItineraryType =
|
|
itineraryType === 'STAY' || itineraryType === 'FREE_TIME';
|
|
|
|
if (isCustomItineraryType) {
|
|
const customStartDate =
|
|
activityItem.startDate || activityItem.occurenceDate;
|
|
const customEndDate =
|
|
activityItem.endDate || activityItem.occurenceDate;
|
|
|
|
if (
|
|
!customStartDate ||
|
|
!customEndDate ||
|
|
!activityItem.selectedStartTime ||
|
|
!activityItem.selectedEndTime
|
|
) {
|
|
throw new ApiError(
|
|
400,
|
|
`${itineraryType} items must include startDate, endDate, selectedStartTime and selectedEndTime.`,
|
|
);
|
|
}
|
|
|
|
const customStartDateTime = combineDateAndTime(
|
|
customStartDate,
|
|
activityItem.selectedStartTime,
|
|
);
|
|
const customEndDateTime = combineDateAndTime(
|
|
customEndDate,
|
|
activityItem.selectedEndTime,
|
|
);
|
|
|
|
if (!customStartDateTime || !customEndDateTime) {
|
|
throw new ApiError(
|
|
400,
|
|
`Invalid date or time supplied for ${itineraryType}.`,
|
|
);
|
|
}
|
|
|
|
if (customStartDateTime >= customEndDateTime) {
|
|
throw new ApiError(
|
|
400,
|
|
`${itineraryType} selectedStartTime must be earlier than selectedEndTime.`,
|
|
);
|
|
}
|
|
|
|
if (
|
|
customStartDateTime < itineraryStartDateTime ||
|
|
customEndDateTime > itineraryEndDateTime
|
|
) {
|
|
throw new ApiError(
|
|
400,
|
|
`${itineraryType} must fall inside the itinerary date range.`,
|
|
);
|
|
}
|
|
const customActivityData: Prisma.ItineraryActivitiesCreateInput = {
|
|
itineraryHeader: {
|
|
connect: { id: itineraryHeader.id },
|
|
},
|
|
displayOrder: activityIndex,
|
|
itineraryType,
|
|
occurenceDate: startOfDay(customStartDateTime),
|
|
startTime: activityItem.selectedStartTime,
|
|
endTime: activityItem.selectedEndTime,
|
|
endDate: customEndDateTime,
|
|
locationLat: activityItem.locationLat ?? null,
|
|
locationLong: activityItem.locationLong ?? null,
|
|
locationAddress:
|
|
(activityItem.locationAddress as Prisma.InputJsonValue | null) ??
|
|
null,
|
|
travelMode: activityItem.modeOfTravel,
|
|
kmForNextPoint: activityItem.kmForNextPoint,
|
|
timeForNextPointMins: activityItem.travelTimeBetweenPointsMins,
|
|
paxCount: activityItem.paxCount ?? 1,
|
|
totalAmount: activityItem.totalAmount ?? null,
|
|
bookingStatus: 'pending',
|
|
isActive: true,
|
|
};
|
|
|
|
return tx.itineraryActivities.create({
|
|
data: customActivityData,
|
|
select: {
|
|
id: true,
|
|
displayOrder: true,
|
|
itineraryType: true,
|
|
activityXid: true,
|
|
scheduledHeaderXid: true,
|
|
venueXid: true,
|
|
occurenceDate: true,
|
|
startTime: true,
|
|
endTime: true,
|
|
endDate: true,
|
|
},
|
|
});
|
|
}
|
|
|
|
if (
|
|
!activityItem.activityXid ||
|
|
!activityItem.scheduleHeaderXid ||
|
|
!activityItem.venueXid
|
|
) {
|
|
throw new ApiError(
|
|
400,
|
|
'ACTIVITY items must include activityXid, scheduleHeaderXid and venueXid.',
|
|
);
|
|
}
|
|
|
|
const scheduleHeader = await tx.scheduleHeader.findFirst({
|
|
where: {
|
|
id: activityItem.scheduleHeaderXid,
|
|
activityXid: activityItem.activityXid,
|
|
activityVenueXid: activityItem.venueXid,
|
|
isActive: true,
|
|
deletedAt: null,
|
|
},
|
|
select: {
|
|
id: true,
|
|
startDate: true,
|
|
endDate: true,
|
|
activityVenueXid: true,
|
|
activity: {
|
|
select: {
|
|
id: true,
|
|
activityTitle: true,
|
|
activityDurationMins: true,
|
|
checkInLat: true,
|
|
checkInLong: true,
|
|
checkInAddress: true,
|
|
},
|
|
},
|
|
activityVenue: {
|
|
select: {
|
|
id: true,
|
|
venueName: true,
|
|
venueLabel: 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,
|
|
},
|
|
select: {
|
|
occurenceDate: true,
|
|
startTime: true,
|
|
endTime: true,
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
if (!scheduleHeader) {
|
|
throw new ApiError(
|
|
404,
|
|
`Schedule header ${activityItem.scheduleHeaderXid} not found for activity ${activityItem.activityXid}.`,
|
|
);
|
|
}
|
|
|
|
const scheduleRangeStart =
|
|
scheduleHeader.startDate > itineraryStartDate
|
|
? scheduleHeader.startDate
|
|
: itineraryStartDate;
|
|
const scheduleRangeEnd =
|
|
(scheduleHeader.endDate ?? itineraryEndDate) < itineraryEndDate
|
|
? (scheduleHeader.endDate ?? itineraryEndDate)
|
|
: itineraryEndDate;
|
|
|
|
if (scheduleRangeStart > scheduleRangeEnd) {
|
|
throw new ApiError(
|
|
400,
|
|
`Selected schedule for activity ${activityItem.activityXid} does not fall inside the itinerary date range.`,
|
|
);
|
|
}
|
|
|
|
const cancelledSlots = new Set(
|
|
scheduleHeader.Cancellations.map((cancellation) => {
|
|
if (!cancellation.occurenceDate) {
|
|
return null;
|
|
}
|
|
|
|
return `${formatDateKey(cancellation.occurenceDate)}|${cancellation.startTime}|${cancellation.endTime}`;
|
|
}).filter(Boolean) as string[],
|
|
);
|
|
|
|
const requestedOccurrenceDate = activityItem.occurenceDate
|
|
? formatDateKey(parseDateValue(activityItem.occurenceDate))
|
|
: null;
|
|
const requestedStartTime = activityItem.selectedStartTime
|
|
? normalizeTimeValue(activityItem.selectedStartTime)
|
|
: null;
|
|
const requestedEndTime = activityItem.selectedEndTime
|
|
? normalizeTimeValue(activityItem.selectedEndTime)
|
|
: null;
|
|
|
|
const expandedSlots = scheduleHeader.ScheduleDetails.flatMap((slot) =>
|
|
getUniqueDatesForScheduleDetail(
|
|
{
|
|
occurenceDate: slot.occurenceDate,
|
|
weekDay: slot.weekDay,
|
|
dayOfMonth: slot.dayOfMonth,
|
|
},
|
|
scheduleRangeStart,
|
|
scheduleRangeEnd,
|
|
).map((slotDate) => {
|
|
const slotStart = combineDateAndTime(slotDate, slot.startTime);
|
|
const slotEnd = slotStart
|
|
? combineDateAndTime(slotDate, slot.endTime) ??
|
|
(scheduleHeader.activity.activityDurationMins
|
|
? addMinutes(
|
|
slotStart,
|
|
scheduleHeader.activity.activityDurationMins,
|
|
)
|
|
: null)
|
|
: null;
|
|
|
|
const normalizedSlotDate = formatDateKey(slotDate);
|
|
const normalizedSlotStartTime = normalizeTimeValue(slot.startTime);
|
|
const normalizedSlotEndTime = normalizeTimeValue(slot.endTime);
|
|
const cancellationKey = `${normalizedSlotDate}|${slot.startTime}|${slot.endTime}`;
|
|
|
|
const mismatchReasons: string[] = [];
|
|
|
|
if (!slotStart) {
|
|
mismatchReasons.push('invalid_slot_start_time');
|
|
}
|
|
|
|
if (!slotEnd) {
|
|
mismatchReasons.push('invalid_slot_end_time');
|
|
}
|
|
|
|
if (slotStart && slotEnd) {
|
|
if (
|
|
slotStart < itineraryStartDateTime ||
|
|
slotEnd > itineraryEndDateTime
|
|
) {
|
|
mismatchReasons.push('outside_itinerary_window');
|
|
}
|
|
}
|
|
|
|
if (cancelledSlots.has(cancellationKey)) {
|
|
mismatchReasons.push('slot_cancelled');
|
|
}
|
|
|
|
if (
|
|
requestedOccurrenceDate &&
|
|
normalizedSlotDate !== requestedOccurrenceDate
|
|
) {
|
|
mismatchReasons.push('occurrence_date_mismatch');
|
|
}
|
|
|
|
if (
|
|
requestedStartTime &&
|
|
normalizedSlotStartTime !== requestedStartTime
|
|
) {
|
|
mismatchReasons.push('start_time_mismatch');
|
|
}
|
|
|
|
return {
|
|
slotId: slot.id,
|
|
occurenceDate: startOfDay(slotDate),
|
|
startTime: slot.startTime,
|
|
endTime: slot.endTime,
|
|
endDate: slotEnd,
|
|
debug: {
|
|
slotDate: normalizedSlotDate,
|
|
normalizedStartTime: normalizedSlotStartTime,
|
|
normalizedEndTime: normalizedSlotEndTime,
|
|
mismatchReasons,
|
|
},
|
|
};
|
|
}),
|
|
);
|
|
|
|
const candidateSlots = expandedSlots
|
|
.filter((slot) => slot.endDate && slot.debug.mismatchReasons.length === 0)
|
|
.map(({ debug, ...slot }) => slot);
|
|
|
|
if (!candidateSlots.length) {
|
|
if (
|
|
requestedOccurrenceDate ||
|
|
requestedStartTime ||
|
|
requestedEndTime
|
|
) {
|
|
const availableSlots = expandedSlots
|
|
.filter(
|
|
(slot) =>
|
|
slot.endDate &&
|
|
!slot.debug.mismatchReasons.includes('outside_itinerary_window') &&
|
|
!slot.debug.mismatchReasons.includes('slot_cancelled'),
|
|
)
|
|
.map((slot) => ({
|
|
slotId: slot.slotId,
|
|
occurenceDate: formatDateKey(slot.occurenceDate),
|
|
startTime: slot.startTime,
|
|
endTime: slot.endTime,
|
|
}));
|
|
|
|
throw new ApiError(
|
|
400,
|
|
`Requested slot does not exist for activity ${activityItem.activityXid}. Please choose a valid occurenceDate/startTime/endTime combination.`,
|
|
[],
|
|
true,
|
|
undefined,
|
|
undefined,
|
|
{
|
|
activityXid: activityItem.activityXid,
|
|
venueXid: activityItem.venueXid,
|
|
scheduleHeaderXid: activityItem.scheduleHeaderXid,
|
|
requestedSlot: {
|
|
occurenceDate: activityItem.occurenceDate ?? null,
|
|
selectedStartTime: activityItem.selectedStartTime ?? null,
|
|
selectedEndTime: activityItem.selectedEndTime ?? null,
|
|
},
|
|
availableSlots,
|
|
},
|
|
);
|
|
}
|
|
|
|
throw new ApiError(
|
|
400,
|
|
`No valid slot found for activity ${activityItem.activityXid} in the selected itinerary range.`,
|
|
);
|
|
}
|
|
|
|
if (!activityItem.occurenceDate && candidateSlots.length > 1) {
|
|
throw new ApiError(
|
|
400,
|
|
`Multiple slots are available for activity ${activityItem.activityXid}. Please send occurenceDate and selectedStartTime for the chosen slot.`,
|
|
);
|
|
}
|
|
|
|
const selectedSlot = candidateSlots[0]!;
|
|
|
|
const activityData: Prisma.ItineraryActivitiesCreateInput = {
|
|
itineraryHeader: {
|
|
connect: { id: itineraryHeader.id },
|
|
},
|
|
displayOrder: activityIndex,
|
|
itineraryType,
|
|
activity: {
|
|
connect: { id: activityItem.activityXid },
|
|
},
|
|
scheduledHeader: {
|
|
connect: { id: activityItem.scheduleHeaderXid },
|
|
},
|
|
venue: {
|
|
connect: { id: activityItem.venueXid },
|
|
},
|
|
occurenceDate: selectedSlot.occurenceDate,
|
|
startTime: selectedSlot.startTime,
|
|
endTime: selectedSlot.endTime,
|
|
endDate: selectedSlot.endDate,
|
|
locationLat:
|
|
activityItem.locationLat ??
|
|
scheduleHeader.activity.checkInLat ??
|
|
null,
|
|
locationLong:
|
|
activityItem.locationLong ??
|
|
scheduleHeader.activity.checkInLong ??
|
|
null,
|
|
locationAddress:
|
|
(activityItem.locationAddress as Prisma.InputJsonValue | undefined) ??
|
|
((scheduleHeader.activity.checkInAddress as Prisma.InputJsonValue | null) ??
|
|
undefined),
|
|
travelMode: activityItem.modeOfTravel,
|
|
kmForNextPoint: activityItem.kmForNextPoint,
|
|
timeForNextPointMins: activityItem.travelTimeBetweenPointsMins,
|
|
paxCount: activityItem.paxCount ?? 1,
|
|
totalAmount: activityItem.totalAmount ?? null,
|
|
bookingStatus: 'pending',
|
|
isActive: true,
|
|
};
|
|
|
|
return tx.itineraryActivities.create({
|
|
data: activityData,
|
|
select: {
|
|
id: true,
|
|
displayOrder: true,
|
|
activityXid: true,
|
|
scheduledHeaderXid: true,
|
|
venueXid: true,
|
|
occurenceDate: true,
|
|
startTime: true,
|
|
endTime: true,
|
|
endDate: true,
|
|
},
|
|
});
|
|
}),
|
|
);
|
|
|
|
return {
|
|
itineraryHeaderXid: itineraryHeader.id,
|
|
itineraryNo: itineraryHeader.itineraryNo,
|
|
itineraryTitle: itineraryHeader.title,
|
|
title: itineraryHeader.title,
|
|
itineraryStatus: itineraryHeader.itineraryStatus,
|
|
ownerMemberXid: ownerMember.id,
|
|
membersCount: 1,
|
|
activitiesCount: createdActivities.length,
|
|
members: [
|
|
{
|
|
id: ownerMember.id,
|
|
memberXid: ownerMember.memberXid,
|
|
memberRole: ownerMember.memberRole,
|
|
memberStatus: ownerMember.memberStatus,
|
|
},
|
|
],
|
|
startLocationDetails,
|
|
endLocationDetails,
|
|
activities: createdActivities,
|
|
};
|
|
});
|
|
}
|
|
|
|
async getAllUserSavedItineraries(
|
|
userXid: number,
|
|
itineraryHeaderXid?: number,
|
|
) {
|
|
const itineraries = await this.prisma.itineraryHeader.findMany({
|
|
where: {
|
|
...(itineraryHeaderXid ? { id: itineraryHeaderXid } : {}),
|
|
isActive: true,
|
|
deletedAt: null,
|
|
OR: [
|
|
{ ownerXid: userXid },
|
|
{
|
|
ItineraryMembers: {
|
|
some: {
|
|
memberXid: userXid,
|
|
isActive: true,
|
|
deletedAt: null,
|
|
},
|
|
},
|
|
},
|
|
],
|
|
},
|
|
orderBy: {
|
|
createdAt: 'desc',
|
|
},
|
|
select: {
|
|
id: true,
|
|
itineraryNo: true,
|
|
title: true,
|
|
ownerXid: true,
|
|
fromDate: true,
|
|
fromTime: true,
|
|
toDate: true,
|
|
toTime: true,
|
|
itineraryStatus: true,
|
|
createdAt: true,
|
|
owner: {
|
|
select: {
|
|
id: true,
|
|
firstName: true,
|
|
lastName: true,
|
|
profileImage: true,
|
|
},
|
|
},
|
|
ItineraryMembers: {
|
|
where: {
|
|
isActive: true,
|
|
deletedAt: null,
|
|
},
|
|
orderBy: {
|
|
createdAt: 'asc',
|
|
},
|
|
select: {
|
|
id: true,
|
|
memberXid: true,
|
|
memberRole: true,
|
|
memberStatus: true,
|
|
invitedByXid: true,
|
|
member: {
|
|
select: {
|
|
id: true,
|
|
firstName: true,
|
|
lastName: true,
|
|
profileImage: true,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
ItineraryStartStopDetails: {
|
|
where: {
|
|
isActive: true,
|
|
deletedAt: null,
|
|
},
|
|
orderBy: {
|
|
createdAt: 'asc',
|
|
},
|
|
select: {
|
|
id: true,
|
|
itineraryMemberXid: true,
|
|
dateValue: true,
|
|
timeValue: true,
|
|
isStartPoint: true,
|
|
locationLat: true,
|
|
locationLong: true,
|
|
locationAddress: true,
|
|
travelMode: true,
|
|
kmForNextPoint: true,
|
|
timeForNextPointMins: true,
|
|
createdAt: true,
|
|
},
|
|
},
|
|
ItineraryActivities: {
|
|
where: {
|
|
isActive: true,
|
|
deletedAt: null,
|
|
},
|
|
orderBy: [
|
|
{ displayOrder: 'asc' },
|
|
{ createdAt: 'asc' },
|
|
],
|
|
select: {
|
|
id: true,
|
|
displayOrder: true,
|
|
itineraryType: true,
|
|
activityXid: true,
|
|
scheduledHeaderXid: true,
|
|
occurenceDate: true,
|
|
startTime: true,
|
|
endTime: true,
|
|
endDate: true,
|
|
venueXid: true,
|
|
locationLat: true,
|
|
locationLong: true,
|
|
locationAddress: true,
|
|
travelMode: true,
|
|
kmForNextPoint: true,
|
|
timeForNextPointMins: true,
|
|
paxCount: true,
|
|
totalAmount: true,
|
|
bookingStatus: true,
|
|
itineraryActivitySelections: {
|
|
where: {
|
|
isActive: true,
|
|
deletedAt: null,
|
|
itineraryMember: {
|
|
memberXid: userXid,
|
|
isActive: true,
|
|
deletedAt: null,
|
|
},
|
|
},
|
|
take: 1,
|
|
select: {
|
|
id: true,
|
|
itineraryMemberXid: true,
|
|
isFoodOpted: true,
|
|
isTrainerOpted: true,
|
|
isInActivityNavigationOpted: true,
|
|
activityNavigationModeXid: true,
|
|
selectedFoodTypes: {
|
|
where: {
|
|
isActive: true,
|
|
deletedAt: null,
|
|
},
|
|
select: {
|
|
id: true,
|
|
activityFoodTypeXid: true,
|
|
activityFoodType: {
|
|
select: {
|
|
id: true,
|
|
foodTypeXid: true,
|
|
foodType: {
|
|
select: {
|
|
id: true,
|
|
foodTypeName: true,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
activityNavigationMode: {
|
|
select: {
|
|
id: true,
|
|
navigationModeName: true,
|
|
isInActivityChargeable: true,
|
|
navigationModesBasePrice: true,
|
|
navigationModesTotalPrice: true,
|
|
},
|
|
},
|
|
selectedEquipments: {
|
|
where: {
|
|
isActive: true,
|
|
deletedAt: null,
|
|
},
|
|
select: {
|
|
id: true,
|
|
activityEquipmentXid: true,
|
|
activityEquipment: {
|
|
select: {
|
|
id: true,
|
|
equipmentName: true,
|
|
isEquipmentChargeable: true,
|
|
equipmentBasePrice: true,
|
|
equipmentTotalPrice: true,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
activity: {
|
|
select: {
|
|
id: true,
|
|
activityTitle: true,
|
|
activityDescription: true,
|
|
activityDurationMins: true,
|
|
checkInAddress: true,
|
|
checkInLat: true,
|
|
checkInLong: true,
|
|
checkInCity: {
|
|
select: {
|
|
id: true,
|
|
cityName: true,
|
|
},
|
|
},
|
|
checkOutAddress: true,
|
|
checkOutLat: true,
|
|
checkOutLong: true,
|
|
checkOutCity: {
|
|
select: {
|
|
id: true,
|
|
cityName: true,
|
|
},
|
|
},
|
|
foodAvailable: true,
|
|
foodIsChargeable: true,
|
|
trainerAvailable: true,
|
|
trainerIsChargeable: true,
|
|
inActivityAvailable: true,
|
|
inActivityIsChargeable: true,
|
|
pickUpDropAvailable: true,
|
|
pickUpDropIsChargeable: true,
|
|
equipmentAvailable: true,
|
|
equipmentIsChargeable: true,
|
|
activityFoodTypes: {
|
|
where: {
|
|
isActive: true,
|
|
deletedAt: null,
|
|
},
|
|
select: {
|
|
id: true,
|
|
foodTypeXid: true,
|
|
foodType: {
|
|
select: {
|
|
id: true,
|
|
foodTypeName: true,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
ActivityFoodCost: {
|
|
where: {
|
|
isActive: true,
|
|
deletedAt: null,
|
|
},
|
|
select: {
|
|
id: true,
|
|
baseAmount: true,
|
|
totalAmount: true,
|
|
},
|
|
},
|
|
ActivityTrainers: {
|
|
where: {
|
|
isActive: true,
|
|
deletedAt: null,
|
|
},
|
|
select: {
|
|
id: true,
|
|
baseAmount: true,
|
|
totalAmount: true,
|
|
},
|
|
},
|
|
ActivityNavigationModes: {
|
|
where: {
|
|
isActive: true,
|
|
deletedAt: null,
|
|
},
|
|
select: {
|
|
id: true,
|
|
navigationModeName: true,
|
|
isInActivityChargeable: true,
|
|
navigationModesBasePrice: true,
|
|
navigationModesTotalPrice: true,
|
|
},
|
|
},
|
|
ActivityEquipments: {
|
|
where: {
|
|
isActive: true,
|
|
deletedAt: null,
|
|
},
|
|
select: {
|
|
id: true,
|
|
equipmentName: true,
|
|
isEquipmentChargeable: true,
|
|
equipmentBasePrice: true,
|
|
equipmentTotalPrice: true,
|
|
},
|
|
},
|
|
ActivityPickUpDetails: {
|
|
where: {
|
|
isActive: true,
|
|
deletedAt: null,
|
|
},
|
|
select: {
|
|
id: true,
|
|
isPickUp: true,
|
|
locationLat: true,
|
|
locationLong: true,
|
|
locationAddress: true,
|
|
transportBasePrice: true,
|
|
transportTotalPrice: true,
|
|
},
|
|
},
|
|
activityPickUpTransports: {
|
|
where: {
|
|
isActive: true,
|
|
deletedAt: null,
|
|
},
|
|
select: {
|
|
id: true,
|
|
transportModeXid: true,
|
|
transportMode: {
|
|
select: {
|
|
id: true,
|
|
transportModeName: true,
|
|
transportModeIcon: true,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
ActivityVenues: {
|
|
where: {
|
|
isActive: true,
|
|
deletedAt: null,
|
|
},
|
|
select: {
|
|
id: true,
|
|
venueName: true,
|
|
venueLabel: true,
|
|
ActivityPrices: {
|
|
where: {
|
|
isActive: true,
|
|
deletedAt: null,
|
|
},
|
|
orderBy: {
|
|
createdAt: 'asc',
|
|
},
|
|
select: {
|
|
id: true,
|
|
noOfSession: true,
|
|
isPackage: true,
|
|
sessionValidity: true,
|
|
sessionValidityFrequency: true,
|
|
basePrice: true,
|
|
sellPrice: true,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
ActivitiesMedia: {
|
|
where: {
|
|
isActive: true,
|
|
deletedAt: null,
|
|
},
|
|
orderBy: {
|
|
displayOrder: 'asc',
|
|
},
|
|
select: {
|
|
id: true,
|
|
mediaFileName: true,
|
|
mediaType: true,
|
|
isCoverImage: true,
|
|
displayOrder: true,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
venue: {
|
|
select: {
|
|
id: true,
|
|
venueName: true,
|
|
venueLabel: true,
|
|
venueCapacity: true,
|
|
availableSeats: true,
|
|
},
|
|
},
|
|
scheduledHeader: {
|
|
select: {
|
|
id: true,
|
|
scheduleType: true,
|
|
startDate: true,
|
|
endDate: true,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
const formattedItineraries = await Promise.all(
|
|
itineraries.map(async (itinerary) => {
|
|
const ownerFullName = `${itinerary.owner.firstName ?? ''} ${itinerary.owner.lastName ?? ''
|
|
}`.trim();
|
|
|
|
const members = await Promise.all(
|
|
itinerary.ItineraryMembers.map(async (member) => ({
|
|
id: member.id,
|
|
memberXid: member.memberXid,
|
|
memberRole: member.memberRole,
|
|
memberStatus: member.memberStatus,
|
|
invitedByXid: member.invitedByXid,
|
|
fullName: `${member.member.firstName ?? ''} ${member.member.lastName ?? ''
|
|
}`.trim(),
|
|
firstName: member.member.firstName,
|
|
lastName: member.member.lastName,
|
|
profileImage: member.member.profileImage,
|
|
profileImagePresignedUrl: await attachPresignedUrl(
|
|
member.member.profileImage,
|
|
),
|
|
})),
|
|
);
|
|
|
|
const activities = await Promise.all(
|
|
itinerary.ItineraryActivities.map(async (item) => {
|
|
const coverImage = item.activity
|
|
? item.activity.ActivitiesMedia.find((media) => media.isCoverImage) ??
|
|
item.activity.ActivitiesMedia[0] ??
|
|
null
|
|
: null;
|
|
const userSelection = item.itineraryActivitySelections[0] ?? null;
|
|
const selectedVenue = item.activity?.ActivityVenues.find(
|
|
(venue) => venue.id === item.venueXid,
|
|
);
|
|
const venueForPricing =
|
|
selectedVenue ?? item.activity?.ActivityVenues[0] ?? null;
|
|
const venuePrices = venueForPricing?.ActivityPrices ?? [];
|
|
const activityPriceDetails = venuePrices.reduce(
|
|
(lowest, current) =>
|
|
!lowest || current.sellPrice < lowest.sellPrice ? current : lowest,
|
|
null as (typeof venuePrices)[number] | null,
|
|
);
|
|
|
|
return {
|
|
id: item.id,
|
|
displayOrder: item.displayOrder,
|
|
itineraryType: item.itineraryType,
|
|
activityXid: item.activityXid,
|
|
scheduledHeaderXid: item.scheduledHeaderXid,
|
|
occurenceDate: item.occurenceDate,
|
|
startTime: item.startTime,
|
|
endTime: item.endTime,
|
|
endDate: item.endDate,
|
|
venueXid: item.venueXid,
|
|
locationLat: item.locationLat,
|
|
locationLong: item.locationLong,
|
|
locationAddress: item.locationAddress,
|
|
travelMode: item.travelMode,
|
|
kmForNextPoint: item.kmForNextPoint,
|
|
timeForNextPointMins: item.timeForNextPointMins,
|
|
paxCount: item.paxCount,
|
|
totalAmount: item.totalAmount,
|
|
bookingStatus: item.bookingStatus,
|
|
activity: item.activity
|
|
? {
|
|
id: item.activity.id,
|
|
activityTitle: item.activity.activityTitle,
|
|
activityDescription: item.activity.activityDescription,
|
|
activityDurationMins: item.activity.activityDurationMins,
|
|
activityPrice: activityPriceDetails?.sellPrice ?? null,
|
|
activityPriceDetails,
|
|
checkInAddress: item.activity.checkInAddress,
|
|
checkInLat: item.activity.checkInLat,
|
|
checkInLong: item.activity.checkInLong,
|
|
checkInCityName: item.activity.checkInCity?.cityName ?? null,
|
|
checkOutAddress: item.activity.checkOutAddress,
|
|
checkOutLat: item.activity.checkOutLat,
|
|
checkOutLong: item.activity.checkOutLong,
|
|
checkOutCityName:
|
|
item.activity.checkOutCity?.cityName ?? null,
|
|
pickUpLocation: item.activity.pickUpDropAvailable
|
|
? {
|
|
address: item.activity.checkInAddress,
|
|
cityName: item.activity.checkInCity?.cityName ?? null,
|
|
lat: item.activity.checkInLat,
|
|
long: item.activity.checkInLong,
|
|
}
|
|
: null,
|
|
dropLocation: item.activity.pickUpDropAvailable
|
|
? {
|
|
address: item.activity.checkOutAddress,
|
|
cityName: item.activity.checkOutCity?.cityName ?? null,
|
|
lat: item.activity.checkOutLat,
|
|
long: item.activity.checkOutLong,
|
|
}
|
|
: null,
|
|
coverImage: coverImage?.mediaFileName ?? null,
|
|
coverImagePresignedUrl: await attachPresignedUrl(
|
|
coverImage?.mediaFileName,
|
|
),
|
|
media: await attachMediaWithPresignedUrl(
|
|
item.activity.ActivitiesMedia,
|
|
),
|
|
foodDetails: {
|
|
foodAvailable: item.activity.foodAvailable,
|
|
foodIsChargeable: item.activity.foodIsChargeable,
|
|
foodTypes: item.activity.activityFoodTypes.map((foodType) => ({
|
|
id: foodType.id,
|
|
foodTypeXid: foodType.foodTypeXid,
|
|
foodType: foodType.foodType,
|
|
})),
|
|
foodCost: item.activity.ActivityFoodCost.map((foodCost) => ({
|
|
id: foodCost.id,
|
|
baseAmount: foodCost.baseAmount,
|
|
totalAmount: foodCost.totalAmount,
|
|
})),
|
|
},
|
|
trainerDetails: {
|
|
trainerAvailable: item.activity.trainerAvailable,
|
|
trainerIsChargeable: item.activity.trainerIsChargeable,
|
|
trainerCost: item.activity.ActivityTrainers.map((trainer) => ({
|
|
id: trainer.id,
|
|
baseAmount: trainer.baseAmount,
|
|
totalAmount: trainer.totalAmount,
|
|
})),
|
|
},
|
|
navigationDetails: {
|
|
inActivityAvailable: item.activity.inActivityAvailable,
|
|
inActivityIsChargeable: item.activity.inActivityIsChargeable,
|
|
navigationModes: item.activity.ActivityNavigationModes.map(
|
|
(navigationMode) => ({
|
|
id: navigationMode.id,
|
|
navigationModeName: navigationMode.navigationModeName,
|
|
isInActivityChargeable:
|
|
navigationMode.isInActivityChargeable,
|
|
navigationModesBasePrice:
|
|
navigationMode.navigationModesBasePrice,
|
|
navigationModesTotalPrice:
|
|
navigationMode.navigationModesTotalPrice,
|
|
}),
|
|
),
|
|
},
|
|
equipmentDetails: {
|
|
equipmentAvailable: item.activity.equipmentAvailable,
|
|
equipmentIsChargeable: item.activity.equipmentIsChargeable,
|
|
equipments: item.activity.ActivityEquipments.map((equipment) => ({
|
|
id: equipment.id,
|
|
equipmentName: equipment.equipmentName,
|
|
isEquipmentChargeable: equipment.isEquipmentChargeable,
|
|
equipmentBasePrice: equipment.equipmentBasePrice,
|
|
equipmentTotalPrice: equipment.equipmentTotalPrice,
|
|
})),
|
|
},
|
|
pickUpDetails: {
|
|
pickUpDropAvailable: item.activity.pickUpDropAvailable,
|
|
pickUpDropIsChargeable:
|
|
item.activity.pickUpDropIsChargeable,
|
|
details: item.activity.ActivityPickUpDetails.map(
|
|
(pickUpDetail) => ({
|
|
id: pickUpDetail.id,
|
|
isPickUp: pickUpDetail.isPickUp,
|
|
locationLat: pickUpDetail.locationLat,
|
|
locationLong: pickUpDetail.locationLong,
|
|
locationAddress: pickUpDetail.locationAddress,
|
|
cityName: pickUpDetail.isPickUp
|
|
? item.activity.checkInCity?.cityName ?? null
|
|
: item.activity.checkOutCity?.cityName ?? null,
|
|
transportBasePrice: pickUpDetail.transportBasePrice,
|
|
transportTotalPrice: pickUpDetail.transportTotalPrice,
|
|
}),
|
|
),
|
|
transportModes: await Promise.all(
|
|
item.activity.activityPickUpTransports.map(
|
|
async (transport) => ({
|
|
id: transport.id,
|
|
transportModeXid: transport.transportModeXid,
|
|
transportMode: {
|
|
id: transport.transportMode.id,
|
|
transportModeName:
|
|
transport.transportMode.transportModeName,
|
|
transportModeIcon:
|
|
transport.transportMode.transportModeIcon,
|
|
transportModeIconPresignedUrl:
|
|
await attachPresignedUrl(
|
|
transport.transportMode.transportModeIcon,
|
|
),
|
|
},
|
|
}),
|
|
),
|
|
),
|
|
},
|
|
}
|
|
: null,
|
|
userSelections: userSelection
|
|
? {
|
|
id: userSelection.id,
|
|
itineraryMemberXid: userSelection.itineraryMemberXid,
|
|
isFoodOpted: userSelection.isFoodOpted,
|
|
selectedFoodTypeIds: userSelection.selectedFoodTypes.map(
|
|
(foodType) => foodType.activityFoodTypeXid,
|
|
),
|
|
selectedFoodTypes: userSelection.selectedFoodTypes.map(
|
|
(foodType) => ({
|
|
id: foodType.id,
|
|
activityFoodTypeXid: foodType.activityFoodTypeXid,
|
|
activityFoodType: foodType.activityFoodType,
|
|
}),
|
|
),
|
|
isTrainerOpted: userSelection.isTrainerOpted,
|
|
isInActivityNavigationOpted:
|
|
userSelection.isInActivityNavigationOpted,
|
|
selectedNavigationModeXid:
|
|
userSelection.activityNavigationModeXid,
|
|
selectedNavigationMode:
|
|
userSelection.activityNavigationMode ?? null,
|
|
selectedEquipmentIds: userSelection.selectedEquipments.map(
|
|
(equipment) => equipment.activityEquipmentXid,
|
|
),
|
|
selectedEquipments: userSelection.selectedEquipments.map(
|
|
(equipment) => ({
|
|
id: equipment.id,
|
|
activityEquipmentXid: equipment.activityEquipmentXid,
|
|
activityEquipment: equipment.activityEquipment,
|
|
}),
|
|
),
|
|
}
|
|
: null,
|
|
venue: item.venue,
|
|
scheduleHeader: item.scheduledHeader,
|
|
};
|
|
}),
|
|
);
|
|
|
|
const startStopDetails = itinerary.ItineraryStartStopDetails.map(
|
|
(detail) => ({
|
|
id: detail.id,
|
|
itineraryMemberXid: detail.itineraryMemberXid,
|
|
dateValue: detail.dateValue,
|
|
timeValue: detail.timeValue,
|
|
isStartPoint: detail.isStartPoint,
|
|
locationLat: detail.locationLat,
|
|
locationLong: detail.locationLong,
|
|
locationAddress: detail.locationAddress,
|
|
travelMode: detail.travelMode,
|
|
kmForNextPoint: detail.kmForNextPoint,
|
|
timeForNextPointMins: detail.timeForNextPointMins,
|
|
createdAt: detail.createdAt,
|
|
}),
|
|
);
|
|
|
|
return {
|
|
itineraryHeaderXid: itinerary.id,
|
|
itineraryNo: itinerary.itineraryNo,
|
|
title: itinerary.title,
|
|
ownerXid: itinerary.ownerXid,
|
|
owner: {
|
|
id: itinerary.owner.id,
|
|
fullName: ownerFullName,
|
|
firstName: itinerary.owner.firstName,
|
|
lastName: itinerary.owner.lastName,
|
|
profileImage: itinerary.owner.profileImage,
|
|
profileImagePresignedUrl: await attachPresignedUrl(
|
|
itinerary.owner.profileImage,
|
|
),
|
|
},
|
|
fromDate: itinerary.fromDate,
|
|
fromTime: itinerary.fromTime,
|
|
toDate: itinerary.toDate,
|
|
toTime: itinerary.toTime,
|
|
itineraryStatus: itinerary.itineraryStatus,
|
|
createdAt: itinerary.createdAt,
|
|
membersCount: members.length,
|
|
activitiesCount: activities.length,
|
|
members,
|
|
startStopDetails,
|
|
activities,
|
|
};
|
|
}),
|
|
);
|
|
|
|
return {
|
|
count: formattedItineraries.length,
|
|
itineraries: formattedItineraries,
|
|
};
|
|
}
|
|
|
|
async bookItineraryAfterPayment(
|
|
tx: Prisma.TransactionClient,
|
|
userXid: number,
|
|
itineraryHeaderXid: number,
|
|
) {
|
|
const itinerary = await tx.itineraryHeader.findFirst({
|
|
where: {
|
|
id: itineraryHeaderXid,
|
|
ownerXid: userXid,
|
|
isActive: true,
|
|
deletedAt: null,
|
|
},
|
|
select: {
|
|
id: true,
|
|
itineraryNo: true,
|
|
title: true,
|
|
ownerXid: true,
|
|
fromDate: true,
|
|
toDate: true,
|
|
ItineraryMembers: {
|
|
where: {
|
|
memberXid: userXid,
|
|
isActive: true,
|
|
deletedAt: null,
|
|
},
|
|
select: {
|
|
id: true,
|
|
memberXid: true,
|
|
memberRole: true,
|
|
memberStatus: true,
|
|
},
|
|
},
|
|
ItineraryActivities: {
|
|
where: {
|
|
isActive: true,
|
|
deletedAt: null,
|
|
},
|
|
orderBy: [
|
|
{ displayOrder: 'asc' },
|
|
{ createdAt: 'asc' },
|
|
],
|
|
select: {
|
|
id: true,
|
|
displayOrder: true,
|
|
itineraryType: true,
|
|
activityXid: true,
|
|
scheduledHeaderXid: true,
|
|
occurenceDate: true,
|
|
startTime: true,
|
|
endTime: true,
|
|
paxCount: true,
|
|
totalAmount: true,
|
|
bookingStatus: true,
|
|
activity: {
|
|
select: {
|
|
id: true,
|
|
activityTitle: true,
|
|
},
|
|
},
|
|
scheduledHeader: {
|
|
select: {
|
|
id: true,
|
|
startDate: true,
|
|
endDate: true,
|
|
activity: {
|
|
select: {
|
|
activityDurationMins: true,
|
|
},
|
|
},
|
|
ScheduleDetails: {
|
|
where: {
|
|
isActive: true,
|
|
deletedAt: null,
|
|
},
|
|
select: {
|
|
id: true,
|
|
occurenceDate: true,
|
|
weekDay: true,
|
|
dayOfMonth: true,
|
|
startTime: true,
|
|
endTime: true,
|
|
maxCapacity: true,
|
|
},
|
|
},
|
|
Cancellations: {
|
|
where: {
|
|
isActive: true,
|
|
deletedAt: null,
|
|
},
|
|
select: {
|
|
occurenceDate: true,
|
|
startTime: true,
|
|
endTime: true,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
if (!itinerary) {
|
|
throw new ApiError(404, 'Itinerary not found for booking.');
|
|
}
|
|
|
|
const itineraryMember = itinerary.ItineraryMembers[0] ?? null;
|
|
if (!itineraryMember) {
|
|
throw new ApiError(404, 'Itinerary member record not found for booking.');
|
|
}
|
|
|
|
const bookedActivities = await Promise.all(
|
|
itinerary.ItineraryActivities.map(async (activity) => {
|
|
if (
|
|
activity.itineraryType !== 'ACTIVITY' ||
|
|
!activity.activityXid ||
|
|
!activity.scheduledHeaderXid ||
|
|
!activity.scheduledHeader
|
|
) {
|
|
return {
|
|
itineraryActivityXid: activity.id,
|
|
skipped: true,
|
|
reason: 'not_bookable_activity',
|
|
};
|
|
}
|
|
|
|
const existingDetail = await tx.itineraryDetails.findFirst({
|
|
where: {
|
|
itineraryActivityXid: activity.id,
|
|
itineraryMemberXid: itineraryMember.id,
|
|
itineraryKind: 'ACTIVITY',
|
|
isActive: true,
|
|
deletedAt: null,
|
|
},
|
|
select: {
|
|
id: true,
|
|
offlineCode: true,
|
|
},
|
|
});
|
|
|
|
if (existingDetail) {
|
|
return {
|
|
itineraryActivityXid: activity.id,
|
|
skipped: false,
|
|
alreadyBooked: true,
|
|
checkInCode: existingDetail.offlineCode,
|
|
remainingCapacity: null,
|
|
};
|
|
}
|
|
|
|
const scheduleRangeStart =
|
|
activity.scheduledHeader.startDate > itinerary.fromDate
|
|
? activity.scheduledHeader.startDate
|
|
: itinerary.fromDate;
|
|
const scheduleRangeEnd =
|
|
(activity.scheduledHeader.endDate ?? itinerary.toDate) < itinerary.toDate
|
|
? (activity.scheduledHeader.endDate ?? itinerary.toDate)
|
|
: itinerary.toDate;
|
|
|
|
const cancelledSlots = new Set(
|
|
activity.scheduledHeader.Cancellations.map((cancellation) => {
|
|
if (!cancellation.occurenceDate) {
|
|
return null;
|
|
}
|
|
|
|
return `${formatDateKey(cancellation.occurenceDate)}|${cancellation.startTime}|${cancellation.endTime}`;
|
|
}).filter(Boolean) as string[],
|
|
);
|
|
|
|
const requestedOccurrenceDate = formatDateKey(activity.occurenceDate);
|
|
const requestedStartTime = normalizeTimeValue(activity.startTime);
|
|
const requestedEndTime = normalizeTimeValue(activity.endTime);
|
|
|
|
const expandedSlots = activity.scheduledHeader.ScheduleDetails.flatMap((slot) =>
|
|
getUniqueDatesForScheduleDetail(
|
|
{
|
|
occurenceDate: slot.occurenceDate,
|
|
weekDay: slot.weekDay,
|
|
dayOfMonth: slot.dayOfMonth,
|
|
},
|
|
scheduleRangeStart,
|
|
scheduleRangeEnd,
|
|
).map((slotDate) => {
|
|
const slotStart = combineDateAndTime(slotDate, slot.startTime);
|
|
const slotEnd = slotStart
|
|
? combineDateAndTime(slotDate, slot.endTime) ??
|
|
(activity.scheduledHeader?.activity?.activityDurationMins
|
|
? addMinutes(
|
|
slotStart,
|
|
activity.scheduledHeader.activity.activityDurationMins,
|
|
)
|
|
: null)
|
|
: null;
|
|
|
|
const normalizedSlotDate = formatDateKey(slotDate);
|
|
const normalizedSlotStartTime = normalizeTimeValue(slot.startTime);
|
|
const normalizedSlotEndTime = normalizeTimeValue(slot.endTime);
|
|
const cancellationKey = `${normalizedSlotDate}|${slot.startTime}|${slot.endTime}`;
|
|
|
|
const mismatchReasons: string[] = [];
|
|
|
|
if (!slotStart || !slotEnd) {
|
|
mismatchReasons.push('invalid_slot_time');
|
|
}
|
|
|
|
if (cancelledSlots.has(cancellationKey)) {
|
|
mismatchReasons.push('slot_cancelled');
|
|
}
|
|
|
|
if (normalizedSlotDate !== requestedOccurrenceDate) {
|
|
mismatchReasons.push('occurrence_date_mismatch');
|
|
}
|
|
|
|
if (requestedStartTime && normalizedSlotStartTime !== requestedStartTime) {
|
|
mismatchReasons.push('start_time_mismatch');
|
|
}
|
|
|
|
if (requestedEndTime && normalizedSlotEndTime !== requestedEndTime) {
|
|
mismatchReasons.push('end_time_mismatch');
|
|
}
|
|
|
|
return {
|
|
slotId: slot.id,
|
|
occurenceDate: startOfDay(slotDate),
|
|
startTime: slot.startTime,
|
|
endTime: slot.endTime,
|
|
maxCapacity: slot.maxCapacity,
|
|
debug: {
|
|
mismatchReasons,
|
|
},
|
|
};
|
|
}),
|
|
);
|
|
|
|
const selectedSlot = expandedSlots.find(
|
|
(slot) => slot.debug.mismatchReasons.length === 0,
|
|
);
|
|
|
|
if (!selectedSlot) {
|
|
throw new ApiError(
|
|
400,
|
|
`Unable to match a valid schedule slot for itinerary activity ${activity.id}.`,
|
|
);
|
|
}
|
|
|
|
const bookedSeats = Math.max(1, activity.paxCount ?? 1);
|
|
if (selectedSlot.maxCapacity < bookedSeats) {
|
|
throw new ApiError(
|
|
409,
|
|
`Insufficient capacity for itinerary activity ${activity.id}.`,
|
|
);
|
|
}
|
|
|
|
const capacityUpdate = await tx.scheduleDetails.updateMany({
|
|
where: {
|
|
id: selectedSlot.slotId,
|
|
maxCapacity: {
|
|
gte: bookedSeats,
|
|
},
|
|
isActive: true,
|
|
deletedAt: null,
|
|
},
|
|
data: {
|
|
maxCapacity: {
|
|
decrement: bookedSeats,
|
|
},
|
|
},
|
|
});
|
|
|
|
if (!capacityUpdate.count) {
|
|
throw new ApiError(
|
|
409,
|
|
`Unable to reserve capacity for itinerary activity ${activity.id}.`,
|
|
);
|
|
}
|
|
|
|
const refreshedSlot = await tx.scheduleDetails.findUnique({
|
|
where: {
|
|
id: selectedSlot.slotId,
|
|
},
|
|
select: {
|
|
maxCapacity: true,
|
|
},
|
|
});
|
|
|
|
let checkInCode = existingDetail?.offlineCode ?? null;
|
|
if (!checkInCode) {
|
|
for (let attempt = 0; attempt < 5; attempt += 1) {
|
|
const candidate = generateCheckInCode();
|
|
const duplicate = await tx.itineraryDetails.findFirst({
|
|
where: {
|
|
offlineCode: candidate,
|
|
isActive: true,
|
|
deletedAt: null,
|
|
},
|
|
select: {
|
|
id: true,
|
|
},
|
|
});
|
|
|
|
if (!duplicate) {
|
|
checkInCode = candidate;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!checkInCode) {
|
|
throw new ApiError(500, 'Unable to generate a check-in code.');
|
|
}
|
|
|
|
const detail = existingDetail
|
|
? await tx.itineraryDetails.update({
|
|
where: {
|
|
id: existingDetail.id,
|
|
},
|
|
data: {
|
|
hasOpted: true,
|
|
updatedOn: new Date(),
|
|
itineraryKind: 'ACTIVITY',
|
|
offlineCode: checkInCode,
|
|
activityStatus: 'booked',
|
|
isChargeable: Number(activity.totalAmount) > 0,
|
|
baseAmount: Number(activity.totalAmount) || 0,
|
|
totalAmount: Number(activity.totalAmount) || 0,
|
|
itineraryStatus: 'paid',
|
|
isPaid: true,
|
|
paidByXid: userXid,
|
|
paidOn: new Date(),
|
|
},
|
|
select: {
|
|
id: true,
|
|
offlineCode: true,
|
|
},
|
|
})
|
|
: await tx.itineraryDetails.create({
|
|
data: {
|
|
itineraryActivityXid: activity.id,
|
|
itineraryMemberXid: itineraryMember.id,
|
|
hasOpted: true,
|
|
updatedOn: new Date(),
|
|
itineraryKind: 'ACTIVITY',
|
|
offlineCode: checkInCode,
|
|
activityStatus: 'booked',
|
|
isChargeable: Number(activity.totalAmount) > 0,
|
|
baseAmount: Number(activity.totalAmount) || 0,
|
|
totalAmount: Number(activity.totalAmount) || 0,
|
|
itineraryStatus: 'paid',
|
|
isPaid: true,
|
|
paidByXid: userXid,
|
|
paidOn: new Date(),
|
|
isActive: true,
|
|
},
|
|
select: {
|
|
id: true,
|
|
offlineCode: true,
|
|
},
|
|
});
|
|
|
|
await tx.itineraryActivities.update({
|
|
where: {
|
|
id: activity.id,
|
|
},
|
|
data: {
|
|
bookingStatus: 'booked',
|
|
},
|
|
});
|
|
|
|
return {
|
|
itineraryActivityXid: activity.id,
|
|
skipped: false,
|
|
alreadyBooked: false,
|
|
checkInCode: detail.offlineCode,
|
|
bookedSeats,
|
|
remainingCapacity: refreshedSlot?.maxCapacity ?? null,
|
|
};
|
|
}),
|
|
);
|
|
|
|
return {
|
|
itineraryHeaderXid: itinerary.id,
|
|
itineraryNo: itinerary.itineraryNo,
|
|
itineraryTitle: itinerary.title,
|
|
bookedActivities,
|
|
};
|
|
}
|
|
|
|
async saveItineraryActivitySelections(
|
|
userXid: number,
|
|
payload: Array<{
|
|
itineraryActivityXid: number;
|
|
isFoodOpted?: boolean;
|
|
selectedFoodTypeIds?: number[];
|
|
isTrainerOpted?: boolean;
|
|
isInActivityNavigationOpted?: boolean;
|
|
selectedNavigationModeXid?: number | null;
|
|
selectedEquipmentIds?: number[];
|
|
}>,
|
|
) {
|
|
return this.prisma.$transaction(async (tx) => {
|
|
const result = await Promise.all(
|
|
payload.map(async (item, index) => {
|
|
const selectedFoodTypeIds = Array.from(
|
|
new Set((item.selectedFoodTypeIds ?? []).map(Number)),
|
|
);
|
|
const selectedEquipmentIds = Array.from(
|
|
new Set((item.selectedEquipmentIds ?? []).map(Number)),
|
|
);
|
|
|
|
const itineraryActivity = await tx.itineraryActivities.findFirst({
|
|
where: {
|
|
id: item.itineraryActivityXid,
|
|
isActive: true,
|
|
deletedAt: null,
|
|
itineraryHeader: {
|
|
ItineraryMembers: {
|
|
some: {
|
|
memberXid: userXid,
|
|
isActive: true,
|
|
deletedAt: null,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
select: {
|
|
id: true,
|
|
itineraryHeaderXid: true,
|
|
itineraryType: true,
|
|
activityXid: true,
|
|
activity: {
|
|
select: {
|
|
id: true,
|
|
foodAvailable: true,
|
|
trainerAvailable: true,
|
|
inActivityAvailable: true,
|
|
equipmentAvailable: true,
|
|
activityFoodTypes: {
|
|
where: {
|
|
isActive: true,
|
|
deletedAt: null,
|
|
},
|
|
select: {
|
|
id: true,
|
|
foodTypeXid: true,
|
|
foodType: {
|
|
select: {
|
|
id: true,
|
|
foodTypeName: true,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
ActivityNavigationModes: {
|
|
where: {
|
|
isActive: true,
|
|
deletedAt: null,
|
|
},
|
|
select: {
|
|
id: true,
|
|
navigationModeName: true,
|
|
isInActivityChargeable: true,
|
|
navigationModesBasePrice: true,
|
|
navigationModesTotalPrice: true,
|
|
},
|
|
},
|
|
ActivityEquipments: {
|
|
where: {
|
|
isActive: true,
|
|
deletedAt: null,
|
|
},
|
|
select: {
|
|
id: true,
|
|
equipmentName: true,
|
|
isEquipmentChargeable: true,
|
|
equipmentBasePrice: true,
|
|
equipmentTotalPrice: true,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
if (!itineraryActivity) {
|
|
throw new ApiError(
|
|
404,
|
|
`activities[${index}] with itineraryActivityXid=${item.itineraryActivityXid} was not found.`,
|
|
);
|
|
}
|
|
|
|
if (itineraryActivity.itineraryType !== 'ACTIVITY') {
|
|
throw new ApiError(
|
|
400,
|
|
`activities[${index}] with itineraryActivityXid=${item.itineraryActivityXid} is not an ACTIVITY row. Actual itineraryType=${itineraryActivity.itineraryType}.`,
|
|
);
|
|
}
|
|
|
|
if (!itineraryActivity.activityXid) {
|
|
throw new ApiError(
|
|
400,
|
|
`activities[${index}] with itineraryActivityXid=${item.itineraryActivityXid} is missing activityXid.`,
|
|
);
|
|
}
|
|
|
|
if (!itineraryActivity.activity) {
|
|
throw new ApiError(
|
|
400,
|
|
`activities[${index}] with itineraryActivityXid=${item.itineraryActivityXid} could not load linked activity details.`,
|
|
);
|
|
}
|
|
|
|
const itineraryMember = await tx.itineraryMembers.findFirst({
|
|
where: {
|
|
itineraryHeaderXid: itineraryActivity.itineraryHeaderXid,
|
|
memberXid: userXid,
|
|
isActive: true,
|
|
deletedAt: null,
|
|
},
|
|
select: {
|
|
id: true,
|
|
},
|
|
});
|
|
|
|
if (!itineraryMember) {
|
|
throw new ApiError(404, 'Itinerary member record not found.');
|
|
}
|
|
|
|
if (selectedEquipmentIds.some((id) => !Number.isInteger(id) || id <= 0)) {
|
|
throw new ApiError(
|
|
400,
|
|
`activities[${index}].selectedEquipmentIds must contain valid ids.`,
|
|
);
|
|
}
|
|
|
|
if (selectedFoodTypeIds.some((id) => !Number.isInteger(id) || id <= 0)) {
|
|
throw new ApiError(
|
|
400,
|
|
`activities[${index}].selectedFoodTypeIds must contain valid ids.`,
|
|
);
|
|
}
|
|
|
|
const isFoodOpted = Boolean(item.isFoodOpted);
|
|
const isTrainerOpted = Boolean(item.isTrainerOpted);
|
|
const isInActivityNavigationOpted = Boolean(
|
|
item.isInActivityNavigationOpted,
|
|
);
|
|
|
|
const selectedNavigationModeXid =
|
|
item.selectedNavigationModeXid === undefined ||
|
|
item.selectedNavigationModeXid === null
|
|
? null
|
|
: Number(item.selectedNavigationModeXid);
|
|
|
|
if (
|
|
selectedNavigationModeXid !== null &&
|
|
(!Number.isInteger(selectedNavigationModeXid) ||
|
|
selectedNavigationModeXid <= 0)
|
|
) {
|
|
throw new ApiError(
|
|
400,
|
|
`activities[${index}].selectedNavigationModeXid must be a valid id.`,
|
|
);
|
|
}
|
|
|
|
const availableFoodTypes = itineraryActivity.activity.activityFoodTypes.map((entry) => ({
|
|
id: entry.id,
|
|
name: entry.foodType.foodTypeName,
|
|
}));
|
|
const availableFoodTypeIds = new Set(
|
|
availableFoodTypes.map((entry) => entry.id),
|
|
);
|
|
const availableNavigationModeIds = new Set(
|
|
itineraryActivity.activity.ActivityNavigationModes.map((entry) => entry.id),
|
|
);
|
|
const availableEquipments = itineraryActivity.activity.ActivityEquipments.map((entry) => ({
|
|
id: entry.id,
|
|
name: entry.equipmentName,
|
|
}));
|
|
const availableEquipmentIds = new Set(
|
|
availableEquipments.map((entry) => entry.id),
|
|
);
|
|
|
|
if (isFoodOpted) {
|
|
if (!itineraryActivity.activity.foodAvailable) {
|
|
throw new ApiError(400, `activities[${index}]: Food is not available for this activity.`);
|
|
}
|
|
|
|
if (
|
|
itineraryActivity.activity.activityFoodTypes.length > 0 &&
|
|
!selectedFoodTypeIds.length
|
|
) {
|
|
throw new ApiError(
|
|
400,
|
|
`activities[${index}].selectedFoodTypeIds is required when food is opted.`,
|
|
);
|
|
}
|
|
|
|
const invalidFoodTypeIds = selectedFoodTypeIds.filter(
|
|
(id) => !availableFoodTypeIds.has(id),
|
|
);
|
|
|
|
if (invalidFoodTypeIds.length) {
|
|
throw new ApiError(
|
|
400,
|
|
`activities[${index}] with itineraryActivityXid=${item.itineraryActivityXid} has invalid selectedFoodTypeIds=${invalidFoodTypeIds.join(', ')}. Allowed food types for this activity are: ${availableFoodTypes.length ? availableFoodTypes.map((entry) => `${entry.id}:${entry.name}`).join(', ') : 'none'}.`,
|
|
);
|
|
}
|
|
} else if (selectedFoodTypeIds.length) {
|
|
throw new ApiError(
|
|
400,
|
|
`activities[${index}].selectedFoodTypeIds cannot be sent when food is not opted.`,
|
|
);
|
|
}
|
|
|
|
if (isTrainerOpted && !itineraryActivity.activity.trainerAvailable) {
|
|
throw new ApiError(
|
|
400,
|
|
`activities[${index}]: Trainer is not available for this activity.`,
|
|
);
|
|
}
|
|
|
|
if (isInActivityNavigationOpted) {
|
|
if (!itineraryActivity.activity.inActivityAvailable) {
|
|
throw new ApiError(
|
|
400,
|
|
`activities[${index}]: In-activity navigation is not available for this activity.`,
|
|
);
|
|
}
|
|
|
|
if (
|
|
itineraryActivity.activity.ActivityNavigationModes.length > 0 &&
|
|
!selectedNavigationModeXid
|
|
) {
|
|
throw new ApiError(
|
|
400,
|
|
`activities[${index}].selectedNavigationModeXid is required when navigation is opted.`,
|
|
);
|
|
}
|
|
|
|
if (
|
|
selectedNavigationModeXid &&
|
|
!availableNavigationModeIds.has(selectedNavigationModeXid)
|
|
) {
|
|
throw new ApiError(
|
|
400,
|
|
`activities[${index}]: Selected navigation mode does not belong to this activity.`,
|
|
);
|
|
}
|
|
} else if (selectedNavigationModeXid) {
|
|
throw new ApiError(
|
|
400,
|
|
`activities[${index}].selectedNavigationModeXid cannot be sent when navigation is not opted.`,
|
|
);
|
|
}
|
|
|
|
if (selectedEquipmentIds.length) {
|
|
if (!itineraryActivity.activity.equipmentAvailable) {
|
|
throw new ApiError(
|
|
400,
|
|
`activities[${index}]: Equipment is not available for this activity.`,
|
|
);
|
|
}
|
|
|
|
const invalidEquipmentIds = selectedEquipmentIds.filter(
|
|
(id) => !availableEquipmentIds.has(id),
|
|
);
|
|
|
|
if (invalidEquipmentIds.length) {
|
|
throw new ApiError(
|
|
400,
|
|
`activities[${index}] with itineraryActivityXid=${item.itineraryActivityXid} has invalid selectedEquipmentIds=${invalidEquipmentIds.join(', ')}. Allowed equipments for this activity are: ${availableEquipments.length ? availableEquipments.map((entry) => `${entry.id}:${entry.name}`).join(', ') : 'none'}.`,
|
|
);
|
|
}
|
|
}
|
|
|
|
const selection = await tx.itineraryActivitySelection.upsert({
|
|
where: {
|
|
itineraryActivityXid_itineraryMemberXid: {
|
|
itineraryActivityXid: itineraryActivity.id,
|
|
itineraryMemberXid: itineraryMember.id,
|
|
},
|
|
},
|
|
create: {
|
|
itineraryActivityXid: itineraryActivity.id,
|
|
itineraryMemberXid: itineraryMember.id,
|
|
isFoodOpted,
|
|
isTrainerOpted,
|
|
isInActivityNavigationOpted,
|
|
activityNavigationModeXid: isInActivityNavigationOpted
|
|
? selectedNavigationModeXid
|
|
: null,
|
|
isActive: true,
|
|
},
|
|
update: {
|
|
isFoodOpted,
|
|
isTrainerOpted,
|
|
isInActivityNavigationOpted,
|
|
activityNavigationModeXid: isInActivityNavigationOpted
|
|
? selectedNavigationModeXid
|
|
: null,
|
|
isActive: true,
|
|
deletedAt: null,
|
|
},
|
|
select: {
|
|
id: true,
|
|
},
|
|
});
|
|
|
|
await tx.itineraryActivitySelectionFoodType.deleteMany({
|
|
where: {
|
|
itineraryActivitySelectionXid: selection.id,
|
|
},
|
|
});
|
|
|
|
await tx.itineraryActivitySelectionEquipment.deleteMany({
|
|
where: {
|
|
itineraryActivitySelectionXid: selection.id,
|
|
},
|
|
});
|
|
|
|
if (selectedFoodTypeIds.length) {
|
|
await tx.itineraryActivitySelectionFoodType.createMany({
|
|
data: selectedFoodTypeIds.map((activityFoodTypeXid) => ({
|
|
itineraryActivitySelectionXid: selection.id,
|
|
activityFoodTypeXid,
|
|
})),
|
|
});
|
|
}
|
|
|
|
if (selectedEquipmentIds.length) {
|
|
await tx.itineraryActivitySelectionEquipment.createMany({
|
|
data: selectedEquipmentIds.map((activityEquipmentXid) => ({
|
|
itineraryActivitySelectionXid: selection.id,
|
|
activityEquipmentXid,
|
|
})),
|
|
});
|
|
}
|
|
|
|
return tx.itineraryActivitySelection.findUnique({
|
|
where: {
|
|
id: selection.id,
|
|
},
|
|
select: {
|
|
id: true,
|
|
itineraryActivityXid: true,
|
|
itineraryMemberXid: true,
|
|
isFoodOpted: true,
|
|
isTrainerOpted: true,
|
|
isInActivityNavigationOpted: true,
|
|
activityNavigationModeXid: true,
|
|
selectedFoodTypes: {
|
|
where: {
|
|
isActive: true,
|
|
deletedAt: null,
|
|
},
|
|
select: {
|
|
id: true,
|
|
activityFoodTypeXid: true,
|
|
activityFoodType: {
|
|
select: {
|
|
id: true,
|
|
foodTypeXid: true,
|
|
foodType: {
|
|
select: {
|
|
id: true,
|
|
foodTypeName: true,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
activityNavigationMode: {
|
|
select: {
|
|
id: true,
|
|
navigationModeName: true,
|
|
isInActivityChargeable: true,
|
|
navigationModesBasePrice: true,
|
|
navigationModesTotalPrice: true,
|
|
},
|
|
},
|
|
selectedEquipments: {
|
|
where: {
|
|
isActive: true,
|
|
deletedAt: null,
|
|
},
|
|
select: {
|
|
id: true,
|
|
activityEquipmentXid: true,
|
|
activityEquipment: {
|
|
select: {
|
|
id: true,
|
|
equipmentName: true,
|
|
isEquipmentChargeable: true,
|
|
equipmentBasePrice: true,
|
|
equipmentTotalPrice: true,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
});
|
|
}),
|
|
);
|
|
|
|
return result;
|
|
});
|
|
}
|
|
|
|
async getMatchingBucketInterestedActivities(
|
|
userXid: number,
|
|
payload: {
|
|
userLat: number;
|
|
userLong: number;
|
|
startDate: string;
|
|
endDate: string;
|
|
startTime: string;
|
|
endTime: string;
|
|
energyLevelXid?: number;
|
|
entryTypeXid: number;
|
|
groupCount?: 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.',
|
|
);
|
|
}
|
|
|
|
if (payload.groupCount !== undefined) {
|
|
if (!Number.isInteger(payload.groupCount) || payload.groupCount <= 0) {
|
|
throw new ApiError(400, 'groupCount must be a positive integer.');
|
|
}
|
|
}
|
|
|
|
const rangeStartDay = startOfDay(requestedStart);
|
|
const rangeEndDay = startOfDay(requestedEnd);
|
|
const requestedEntryType = await this.prisma.allowedEntryTypes.findFirst({
|
|
where: {
|
|
id: payload.entryTypeXid,
|
|
isActive: true,
|
|
deletedAt: null,
|
|
},
|
|
select: {
|
|
id: true,
|
|
allowedEntryTypeName: true,
|
|
},
|
|
});
|
|
|
|
if (!requestedEntryType) {
|
|
throw new ApiError(404, 'Selected entry type not found.');
|
|
}
|
|
|
|
const isGroupEntryType =
|
|
requestedEntryType.allowedEntryTypeName.trim().toLowerCase() === 'group';
|
|
|
|
if (isGroupEntryType && payload.groupCount === undefined) {
|
|
throw new ApiError(
|
|
400,
|
|
'groupCount is required when entryTypeXid is for group.',
|
|
);
|
|
}
|
|
|
|
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,
|
|
...(payload.energyLevelXid !== undefined
|
|
? { 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,
|
|
activityDescription: true,
|
|
checkInLat: true,
|
|
checkInLong: true,
|
|
checkInAddress: true,
|
|
checkInCity: {
|
|
select: {
|
|
id: true,
|
|
cityName: true,
|
|
},
|
|
},
|
|
checkInState: {
|
|
select: {
|
|
id: true,
|
|
stateName: true,
|
|
},
|
|
},
|
|
checkInCountry: {
|
|
select: {
|
|
id: true,
|
|
countryName: true,
|
|
countryCode: true,
|
|
},
|
|
},
|
|
activityType: {
|
|
select: {
|
|
activityTypeName: true,
|
|
interestXid: true,
|
|
interests: {
|
|
select: {
|
|
id: true,
|
|
interestName: true,
|
|
},
|
|
},
|
|
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,
|
|
ActivityPrices: {
|
|
where: {
|
|
isActive: true,
|
|
deletedAt: null,
|
|
},
|
|
orderBy: {
|
|
createdAt: 'asc',
|
|
},
|
|
select: {
|
|
id: true,
|
|
noOfSession: true,
|
|
isPackage: true,
|
|
sessionValidity: true,
|
|
sessionValidityFrequency: true,
|
|
basePrice: true,
|
|
sellPrice: true,
|
|
},
|
|
},
|
|
ActivityVenueArtifacts: {
|
|
where: {
|
|
isActive: true,
|
|
deletedAt: null,
|
|
},
|
|
select: {
|
|
id: true,
|
|
mediaType: true,
|
|
mediaFileName: 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:
|
|
isGroupEntryType && payload.groupCount !== undefined
|
|
? { gte: payload.groupCount }
|
|
: { 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;
|
|
}
|
|
|
|
if (
|
|
isGroupEntryType &&
|
|
payload.groupCount !== undefined &&
|
|
slot.maxCapacity < payload.groupCount
|
|
) {
|
|
return null;
|
|
}
|
|
|
|
return {
|
|
scheduleHeaderXid: header.id,
|
|
slotId: slot.id,
|
|
venueXid: venue.id,
|
|
venueName: venue.venueName,
|
|
venueLabel: venue.venueLabel,
|
|
activityPrices: venue.ActivityPrices,
|
|
mediaFileName: venue.ActivityVenueArtifacts[0]?.mediaFileName ?? null,
|
|
mediaType: venue.ActivityVenueArtifacts[0]?.mediaType ?? null,
|
|
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);
|
|
});
|
|
}),
|
|
);
|
|
|
|
const sanitizedAvailableSlots = availableSlots.filter(
|
|
(
|
|
slot,
|
|
): slot is NonNullable<(typeof availableSlots)[number]> =>
|
|
Boolean(slot),
|
|
);
|
|
|
|
const availableSlotsWithPresignedUrl = await Promise.all(
|
|
sanitizedAvailableSlots.map(async (slot) => ({
|
|
...slot,
|
|
mediaFileNamePresignedUrl: await attachPresignedUrl(
|
|
slot.mediaFileName,
|
|
),
|
|
})),
|
|
);
|
|
|
|
if (!availableSlotsWithPresignedUrl.length) {
|
|
return null;
|
|
}
|
|
|
|
availableSlotsWithPresignedUrl.sort(
|
|
(first, second) =>
|
|
new Date(first!.startDateTime).getTime() -
|
|
new Date(second!.startDateTime).getTime(),
|
|
);
|
|
|
|
const venuePriceDetails = activity.ActivityVenues.map((venue) => {
|
|
const prices = venue.ActivityPrices.map((price) => ({
|
|
id: price.id,
|
|
noOfSession: price.noOfSession,
|
|
isPackage: price.isPackage,
|
|
sessionValidity: price.sessionValidity,
|
|
sessionValidityFrequency: price.sessionValidityFrequency,
|
|
basePrice: price.basePrice,
|
|
sellPrice: price.sellPrice,
|
|
}));
|
|
|
|
const lowestPrice = venue.ActivityPrices.reduce(
|
|
(lowest, current) =>
|
|
!lowest || current.sellPrice < lowest.sellPrice ? current : lowest,
|
|
null as (typeof venue.ActivityPrices)[number] | null,
|
|
);
|
|
|
|
return {
|
|
venueXid: venue.id,
|
|
venueName: venue.venueName,
|
|
venueLabel: venue.venueLabel,
|
|
venueCapacity: venue.venueCapacity,
|
|
availableSeats: venue.availableSeats,
|
|
activityPrice: lowestPrice?.sellPrice ?? null,
|
|
activityBasePrice: lowestPrice?.basePrice ?? null,
|
|
activityPriceDetails: lowestPrice,
|
|
prices,
|
|
};
|
|
});
|
|
|
|
const lowestPriceVenue = venuePriceDetails.reduce(
|
|
(lowest, current) => {
|
|
if (!lowest) {
|
|
return current;
|
|
}
|
|
|
|
if (lowest.activityPrice === null) {
|
|
return current;
|
|
}
|
|
|
|
if (current.activityPrice === null) {
|
|
return lowest;
|
|
}
|
|
|
|
return current.activityPrice < lowest.activityPrice ? current : lowest;
|
|
},
|
|
null as (typeof venuePriceDetails)[number] | null,
|
|
);
|
|
|
|
const coverImage =
|
|
activity.ActivitiesMedia.find((media) => media.isCoverImage) ??
|
|
activity.ActivitiesMedia[0] ??
|
|
null;
|
|
|
|
const energyLevel = activity.activityType?.energyLevel ?? null;
|
|
const lowestActivityPrice = lowestPriceVenue?.activityPrice ?? null;
|
|
const lowestActivityBasePrice = lowestPriceVenue?.activityBasePrice ?? null;
|
|
const lowestActivityPriceDetails = lowestPriceVenue?.activityPriceDetails ?? null;
|
|
|
|
return {
|
|
userBucketInterestedXid: entry.id,
|
|
activityXid: entry.activityXid,
|
|
isBucket: entry.isBucket,
|
|
bucketTypeName: entry.bucketTypeName,
|
|
distance,
|
|
activityPrice: lowestActivityPrice,
|
|
activityBasePrice: lowestActivityBasePrice,
|
|
activityPriceDetails: lowestActivityPriceDetails,
|
|
lowestActivityPrice,
|
|
lowestActivityBasePrice,
|
|
lowestActivityPriceDetails,
|
|
activityTitle: activity.activityTitle,
|
|
activityDescription: activity.activityDescription,
|
|
activityDurationMins,
|
|
activityCoverImage: coverImage?.mediaFileName ?? null,
|
|
activityCoverImagePresignedUrl: await attachPresignedUrl(
|
|
coverImage?.mediaFileName,
|
|
),
|
|
venue: lowestPriceVenue
|
|
? {
|
|
venueXid: lowestPriceVenue.venueXid,
|
|
venueName: lowestPriceVenue.venueName,
|
|
venueLabel: lowestPriceVenue.venueLabel,
|
|
venueCapacity: lowestPriceVenue.venueCapacity,
|
|
availableSeats: lowestPriceVenue.availableSeats,
|
|
activityPrice: lowestPriceVenue.activityPrice,
|
|
activityBasePrice: lowestPriceVenue.activityBasePrice,
|
|
activityPriceDetails: lowestPriceVenue.activityPriceDetails,
|
|
}
|
|
: null,
|
|
venuePrices: venuePriceDetails,
|
|
availableSlots: availableSlotsWithPresignedUrl,
|
|
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,
|
|
checkInCity: activity.checkInCity,
|
|
checkInState: activity.checkInState,
|
|
checkInCountry: activity.checkInCountry,
|
|
checkInLat: activity.checkInLat,
|
|
checkInLong: activity.checkInLong,
|
|
interest: activity.activityType?.interests
|
|
? {
|
|
id: activity.activityType.interests.id,
|
|
interestName: activity.activityType.interests.interestName,
|
|
}
|
|
: null,
|
|
};
|
|
}),
|
|
);
|
|
|
|
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 uniqueById = <T extends { id: number }>(items: T[]) =>
|
|
Array.from(new Map(items.map((item) => [item.id, item])).values());
|
|
|
|
const distinctInterests = uniqueById(
|
|
activities
|
|
.map((activity) => activity!.interest)
|
|
.filter((item): item is NonNullable<(typeof activities)[number]['interest']> =>
|
|
Boolean(item),
|
|
),
|
|
).sort((first, second) => first.interestName.localeCompare(second.interestName));
|
|
|
|
const distinctBucketTypeNames = Array.from(
|
|
new Set(
|
|
activities
|
|
.map((activity) => activity!.bucketTypeName)
|
|
.filter((item): item is string => Boolean(item?.trim())),
|
|
),
|
|
).sort((first, second) => first.localeCompare(second));
|
|
|
|
const distinctEnergyLevels = uniqueById(
|
|
activities
|
|
.map((activity) => activity!.energyLevel)
|
|
.filter((item): item is NonNullable<(typeof activities)[number]['energyLevel']> =>
|
|
Boolean(item),
|
|
)
|
|
.map((item) => ({
|
|
id: item.energyLevelXid,
|
|
energyLevelName: item.energyLevelName,
|
|
energyIcon: item.energyLevelIcon,
|
|
energyIconPresignedUrl: item.energyLevelIconPresignedUrl,
|
|
})),
|
|
).sort((first, second) =>
|
|
first.energyLevelName.localeCompare(second.energyLevelName),
|
|
);
|
|
|
|
const distinctCities = uniqueById(
|
|
activities
|
|
.map((activity) => activity!.checkInCity)
|
|
.filter((item): item is NonNullable<(typeof activities)[number]['checkInCity']> =>
|
|
Boolean(item),
|
|
),
|
|
).sort((first, second) => first.cityName.localeCompare(second.cityName));
|
|
|
|
const distinctStates = uniqueById(
|
|
activities
|
|
.map((activity) => activity!.checkInState)
|
|
.filter((item): item is NonNullable<(typeof activities)[number]['checkInState']> =>
|
|
Boolean(item),
|
|
),
|
|
).sort((first, second) => first.stateName.localeCompare(second.stateName));
|
|
|
|
const distinctCountries = uniqueById(
|
|
activities
|
|
.map((activity) => activity!.checkInCountry)
|
|
.filter(
|
|
(item): item is NonNullable<(typeof activities)[number]['checkInCountry']> =>
|
|
Boolean(item),
|
|
),
|
|
).sort((first, second) => first.countryName.localeCompare(second.countryName));
|
|
|
|
const durations = activities
|
|
.map((activity) => activity!.activityDurationMins)
|
|
.filter((item): item is number => typeof item === 'number');
|
|
|
|
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,
|
|
groupCount: payload.groupCount,
|
|
page: sanitizedPage,
|
|
limit: sanitizedLimit,
|
|
interestTypes: distinctInterests,
|
|
bucketTypeNames: distinctBucketTypeNames,
|
|
energyLevels: distinctEnergyLevels,
|
|
cities: distinctCities,
|
|
states: distinctStates,
|
|
countries: distinctCountries,
|
|
minActivityDurationMins: durations.length ? Math.min(...durations) : null,
|
|
maxActivityDurationMins: durations.length ? Math.max(...durations) : null,
|
|
},
|
|
pagination: {
|
|
page: sanitizedPage,
|
|
limit: sanitizedLimit,
|
|
totalCount,
|
|
totalPages,
|
|
hasNextPage: sanitizedPage < totalPages,
|
|
hasPreviousPage: sanitizedPage > 1 && totalPages > 0,
|
|
},
|
|
count: paginatedActivities.length,
|
|
activityCount: totalCount,
|
|
totalCount,
|
|
activities: paginatedActivities,
|
|
};
|
|
}
|
|
}
|