diff --git a/src/modules/user/services/itinerary.service.ts b/src/modules/user/services/itinerary.service.ts index 52d3b7d..4ce6124 100644 --- a/src/modules/user/services/itinerary.service.ts +++ b/src/modules/user/services/itinerary.service.ts @@ -1,5 +1,5 @@ import { Injectable } from '@nestjs/common'; -import { PrismaClient } from '@prisma/client'; +import { Prisma, PrismaClient } from '@prisma/client'; import { getPresignedUrl } from '../../../common/middlewares/aws/getPreSignedUrl'; import ApiError from '../../../common/utils/helper/ApiError'; import { @@ -144,6 +144,18 @@ const parseTimeValue = (value: string) => { 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); @@ -577,29 +589,31 @@ export class ItineraryService { `${itineraryType} must fall inside the itinerary date range.`, ); } + const customActivityData: Prisma.ItineraryActivitiesCreateInput = { + itineraryHeader: { + connect: { id: itineraryHeader.id }, + }, + 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: { - itineraryHeaderXid: itineraryHeader.id, - itineraryType, - activityXid: null, - scheduledHeaderXid: null, - occurenceDate: startOfDay(customStartDateTime), - startTime: activityItem.selectedStartTime, - endTime: activityItem.selectedEndTime, - endDate: customEndDateTime, - venueXid: null, - locationLat: activityItem.locationLat ?? null, - locationLong: activityItem.locationLong ?? null, - locationAddress: (activityItem.locationAddress as string | null) ?? null, - travelMode: activityItem.modeOfTravel, - kmForNextPoint: activityItem.kmForNextPoint, - timeForNextPointMins: activityItem.travelTimeBetweenPointsMins, - paxCount: activityItem.paxCount ?? 1, - totalAmount: activityItem.totalAmount ?? null, - bookingStatus: 'pending', - isActive: true, - }, + data: customActivityData, select: { id: true, itineraryType: true, @@ -718,7 +732,17 @@ export class ItineraryService { }).filter(Boolean) as string[], ); - const candidateSlots = scheduleHeader.ScheduleDetails.flatMap((slot) => + 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, @@ -727,72 +751,128 @@ export class ItineraryService { }, scheduleRangeStart, scheduleRangeEnd, - ) - .map((slotDate) => { - const slotStart = combineDateAndTime(slotDate, slot.startTime); - - if (!slotStart) { - return null; - } - - const slotEnd = scheduleHeader.activity.activityDurationMins + ).map((slotDate) => { + const slotStart = combineDateAndTime(slotDate, slot.startTime); + const slotEnd = slotStart + ? combineDateAndTime(slotDate, slot.endTime) ?? + (scheduleHeader.activity.activityDurationMins ? addMinutes( slotStart, scheduleHeader.activity.activityDurationMins, ) - : combineDateAndTime(slotDate, slot.endTime); + : null) + : null; - if (!slotEnd) { - return null; - } + const normalizedSlotDate = formatDateKey(slotDate); + const normalizedSlotStartTime = normalizeTimeValue(slot.startTime); + const normalizedSlotEndTime = normalizeTimeValue(slot.endTime); + const cancellationKey = `${normalizedSlotDate}|${slot.startTime}|${slot.endTime}`; - const cancellationKey = `${formatDateKey(slotDate)}|${slot.startTime}|${slot.endTime}`; + const mismatchReasons: string[] = []; - if (cancelledSlots.has(cancellationKey)) { - return null; - } + if (!slotStart) { + mismatchReasons.push('invalid_slot_start_time'); + } + if (!slotEnd) { + mismatchReasons.push('invalid_slot_end_time'); + } + + if (slotStart && slotEnd) { if ( slotStart < itineraryStartDateTime || slotEnd > itineraryEndDateTime ) { - return null; + mismatchReasons.push('outside_itinerary_window'); } + } - if ( - activityItem.occurenceDate && - formatDateKey(slotDate) !== - formatDateKey(parseDateValue(activityItem.occurenceDate)) - ) { - return null; - } + if (cancelledSlots.has(cancellationKey)) { + mismatchReasons.push('slot_cancelled'); + } - if ( - activityItem.selectedStartTime && - slot.startTime !== activityItem.selectedStartTime - ) { - return null; - } + if ( + requestedOccurrenceDate && + normalizedSlotDate !== requestedOccurrenceDate + ) { + mismatchReasons.push('occurrence_date_mismatch'); + } - if ( - activityItem.selectedEndTime && - slot.endTime !== activityItem.selectedEndTime - ) { - return null; - } + if ( + requestedStartTime && + normalizedSlotStartTime !== requestedStartTime + ) { + mismatchReasons.push('start_time_mismatch'); + } - return { - slotId: slot.id, - occurenceDate: startOfDay(slotDate), - startTime: slot.startTime, - endTime: slot.endTime, - endDate: slotEnd, - }; - }) - .filter(Boolean), + if ( + requestedEndTime && + normalizedSlotEndTime !== requestedEndTime + ) { + mismatchReasons.push('end_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.`, @@ -808,37 +888,47 @@ export class ItineraryService { const selectedSlot = candidateSlots[0]!; - return tx.itineraryActivities.create({ - data: { - itineraryHeaderXid: itineraryHeader.id, - itineraryType, - activityXid: activityItem.activityXid, - scheduledHeaderXid: activityItem.scheduleHeaderXid, - occurenceDate: selectedSlot.occurenceDate, - startTime: selectedSlot.startTime, - endTime: selectedSlot.endTime, - endDate: selectedSlot.endDate, - venueXid: activityItem.venueXid, - locationLat: - activityItem.locationLat ?? - scheduleHeader.activity.checkInLat ?? - null, - locationLong: - activityItem.locationLong ?? - scheduleHeader.activity.checkInLong ?? - null, - locationAddress: - activityItem.locationAddress ?? - (scheduleHeader.activity.checkInAddress as any) ?? - undefined, - travelMode: activityItem.modeOfTravel, - kmForNextPoint: activityItem.kmForNextPoint, - timeForNextPointMins: activityItem.travelTimeBetweenPointsMins, - paxCount: activityItem.paxCount ?? 1, - totalAmount: activityItem.totalAmount ?? null, - bookingStatus: 'pending', - isActive: true, + const activityData: Prisma.ItineraryActivitiesCreateInput = { + itineraryHeader: { + connect: { id: itineraryHeader.id }, }, + 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, activityXid: true,