import { Injectable } from '@nestjs/common'; import { PrismaClient } from '@prisma/client'; import { ACTIVITY_AM_DISPLAY_STATUS, ACTIVITY_AM_INTERNAL_STATUS, ACTIVITY_DISPLAY_STATUS, ACTIVITY_INTERNAL_STATUS, SCHEDULING_TYPE } from '../../../common/utils/constants/host.constant'; import ApiError from '../../../common/utils/helper/ApiError'; import { ScheduleActivityDTO } from '../../../common/utils/validation/host/createSchedulingOfAct.validation'; import { getPresignedUrl } from '../../../common/middlewares/aws/getPreSignedUrl'; import config from '../../../config/config'; const bucket = config.aws.bucketName; @Injectable() export class SchedulingService { constructor(private prisma: PrismaClient) { } async getHostIdByUserId(userId: number) { const host = await this.prisma.hostHeader.findFirst({ where: { userXid: userId, isActive: true }, select: { id: true } }) if (!host) { throw new ApiError(404, 'Host not found for the given user.'); } return host.id; } async addSchedulingForActivity(data: ScheduleActivityDTO) { const { activityXid, listNow, scheduleType, dateRange, rules, venues, earlyCheckInMins, bookingCutOffMins, isLateCheckingAllowed, isInstantBooking } = data; return this.prisma.$transaction(async (tx) => { const venueXids = venues.map(v => v.venueXid); /* ---------------------------------- 🧹 0️⃣ DELETE OLD SCHEDULING (PER ACTIVITY + VENUES) ---------------------------------- */ const oldHeaders = await tx.scheduleHeader.findMany({ where: { activityXid, activityVenueXid: { in: venueXids }, isActive: true, }, select: { id: true }, }); const headerIds = oldHeaders.map(h => h.id); if (headerIds.length) { // Delete in correct FK order await tx.cancellations.deleteMany({ where: { scheduleHeaderXid: { in: headerIds } }, }); await tx.scheduleDetails.deleteMany({ where: { scheduleHeaderXid: { in: headerIds } }, }); await tx.scheduleOccurences.deleteMany({ where: { scheduleHeaderXid: { in: headerIds } }, }); await tx.scheduleRecurrence.deleteMany({ where: { scheduleHeaderXid: { in: headerIds } }, }); await tx.scheduleHeader.deleteMany({ where: { id: { in: headerIds } }, }); } /* ---------------------------------- ➕ 1️⃣ CREATE NEW SCHEDULING ---------------------------------- */ const createdHeaders: number[] = []; if (isInstantBooking !== undefined || isLateCheckingAllowed !== undefined) { await tx.activities.update({ where: { id: activityXid, isActive: true }, data: { isInstantBooking, isLateCheckingAllowed }, }); } if (listNow) { await tx.activities.update({ where: { id: activityXid, isActive: true }, data: { activityInternalStatus: ACTIVITY_INTERNAL_STATUS.ACTIVITY_LISTED, activityDisplayStatus: ACTIVITY_DISPLAY_STATUS.ACTIVITY_LISTED, amInternalStatus: ACTIVITY_AM_INTERNAL_STATUS.ACTIVITY_LISTED, amDisplayStatus: ACTIVITY_AM_DISPLAY_STATUS.ACTIVITY_LISTED } }) } for (const venue of venues) { if (!venue.slots || venue.slots.length === 0) { continue; } const header = await tx.scheduleHeader.create({ data: { activityXid, activityVenueXid: venue.venueXid, scheduleType, startDate: new Date(dateRange.startDate), endDate: dateRange.endDate ? new Date(dateRange.endDate) : null, earlyCheckInMins, bookingCutOffMins, isActive: true, }, }); createdHeaders.push(header.id); // WEEKLY if (scheduleType === SCHEDULING_TYPE.WEEKLY) { const uniqueWeekdays = [ ...new Set( venue.slots .map(s => s.weekDay) .filter((d): d is "MONDAY" | "TUESDAY" | "WEDNESDAY" | "THURSDAY" | "FRIDAY" | "SATURDAY" | "SUNDAY" => !!d) ), ]; if (!uniqueWeekdays.length) { throw new ApiError(400, 'Weekly schedule requires weekDay in slots'); } await tx.scheduleRecurrence.createMany({ data: uniqueWeekdays.map(day => ({ scheduleHeaderXid: header.id, weekDay: day, isActive: true, })), }); } // MONTHLY if (scheduleType === SCHEDULING_TYPE.MONTHLY) { const uniqueDays = [ ...new Set( venue.slots .map(s => s.dayOfMonth) .filter((d): d is number => d !== null && d !== undefined) ), ]; if (!uniqueDays.length) { throw new ApiError(400, 'Monthly schedule requires dayOfMonth in slots'); } await tx.scheduleRecurrence.createMany({ data: uniqueDays.map(day => ({ scheduleHeaderXid: header.id, dayOfMonth: day, isActive: true, })), }); } // CUSTOM / ONCE if (scheduleType === SCHEDULING_TYPE.CUSTOM || scheduleType === SCHEDULING_TYPE.ONCE) { const uniqueDates = [ ...new Set( venue.slots .map(s => s.occurrenceDate) .filter(Boolean) ), ]; await tx.scheduleOccurences.createMany({ data: uniqueDates.map(d => ({ scheduleHeaderXid: header.id, occurenceDate: new Date(d!), isActive: true, })), }); } // Slots for (const slot of venue.slots) { await tx.scheduleDetails.create({ data: { scheduleHeaderXid: header.id, occurenceDate: slot.occurrenceDate ? new Date(slot.occurrenceDate) : null, weekDay: slot.weekDay ?? null, dayOfMonth: slot.dayOfMonth ?? null, startTime: slot.startTime, endTime: slot.endTime, maxCapacity: slot.maxCapacity, isActive: true, }, }); } } return { success: true, scheduleHeaderIds: createdHeaders }; }); } async getAvailableSlotsForDate( activityXid: number, selectedDate: string ) { const date = new Date(selectedDate); if (isNaN(date.getTime())) { throw new ApiError(400, 'Invalid date format'); } const weekDay = date.toLocaleDateString('en-US', { weekday: 'long' }).toUpperCase(); const dayOfMonth = date.getDate(); /* -------------------------------- 1️⃣ FETCH ACTIVE SCHEDULE HEADERS -------------------------------- */ const scheduleHeaders = await this.prisma.scheduleHeader.findMany({ where: { activityXid, isActive: true, startDate: { lte: date }, OR: [ { endDate: null }, { endDate: { gte: date } }, ], }, include: { activityVenue: { select: { id: true, venueName: true, venueLabel: true, venueCapacity: true, }, }, scheduleRecurrences: { where: { isActive: true }, }, ScheduleDetails: { where: { isActive: true, OR: [ { occurenceDate: date }, // ONLY_ONCE / CUSTOM { weekDay: weekDay }, // WEEKLY { dayOfMonth: dayOfMonth }, // MONTHLY ], }, }, Cancellations: { where: { occurenceDate: date, isActive: true, }, }, }, }); if (!scheduleHeaders.length) { return []; } /* -------------------------------- 2️⃣ BUILD RESPONSE -------------------------------- */ const response = []; for (const header of scheduleHeaders) { const cancelledSlotIds = new Set( header.Cancellations.map(c => c.slotXid) ); const slots = header.ScheduleDetails .filter(slot => !cancelledSlotIds.has(slot.id)) .map(slot => ({ slotId: slot.id, startTime: slot.startTime, endTime: slot.endTime, maxCapacity: slot.maxCapacity, })); if (!slots.length) continue; response.push({ venueXid: header.activityVenue.id, venueName: header.activityVenue.venueName, venueLabel: header.activityVenue.venueLabel, slots, }); } return response; } async getVenueFromVenueXid(venueXid: number, activityXid: number) { return await this.prisma.activityVenues.findUnique({ where: { id: venueXid, activityXid: activityXid, isActive: true }, select: { id: true, venueName: true, venueLabel: true, venueCapacity: true, }, }); } async getActivityByXid(activityXid: number) { return await this.prisma.activities.findUnique({ where: { id: activityXid, isActive: true }, select: { id: true, activityTitle: true, } }); } /** * Get activities by status and host ID * @param hostId - ID of the host * @param status - Filter by status (Listed, Unlisted, Not_Listed) - optional * @returns Array of activities matching the criteria */ async getActivitiesByStatus(hostId: number, status?: string) { // Build where clause const whereClause: any = { hostXid: hostId, isActive: true, deletedAt: null, activityInternalStatus: { in: [ACTIVITY_INTERNAL_STATUS.ACTIVITY_APPROVED, ACTIVITY_INTERNAL_STATUS.ACTIVITY_UNLISTED, ACTIVITY_INTERNAL_STATUS.ACTIVITY_LISTED] } }; // Add status filter if provided if (status) { whereClause.activityInternalStatus = status; } // Query activities const activities = await this.prisma.activities.findMany({ where: whereClause, select: { id: true, activityRefNumber: true, activityTitle: true, activityDescription: true, activityDisplayStatus: true, activityInternalStatus: true, ActivitiesMedia: { where: { isActive: true }, select: { id: true, mediaFileName: true, mediaType: true, }, }, ScheduleHeader: { where: { isActive: true }, select: { id: true, scheduleType: true, startDate: true }, orderBy: { createdAt: 'desc' } } }, orderBy: { createdAt: 'desc', }, }); for (const activity of activities) { if (activity.ActivitiesMedia?.length) { for (const media of activity.ActivitiesMedia) { if (!media.mediaFileName) continue; const key = media.mediaFileName.startsWith('http') ? media.mediaFileName.split('.com/')[1] : media.mediaFileName; (media as any).presignedUrl = await getPresignedUrl(bucket, key); } } } // Transform response return activities.map((activity) => ({ activityId: activity.id, activityRefNumber: activity.activityRefNumber, activityName: activity.activityTitle, activityDescription: activity.activityDescription, activityInternalStatus: activity.activityInternalStatus, activityDisplayStatus: activity.activityDisplayStatus, media: activity.ActivitiesMedia, scheduleType: activity.ScheduleHeader?.length ? activity.ScheduleHeader[0].scheduleType : null, scheduleStartDate: activity.ScheduleHeader?.length ? activity.ScheduleHeader[0].startDate : null, })); } async getVenueDurationByAct(activityXid: number, hostId: number) { const result = await this.prisma.activities.findUnique({ where: { id: activityXid, hostXid: hostId, isActive: true }, select: { id: true, activityDurationMins: true, activityTitle: true, activityRefNumber: true, isLateCheckingAllowed: true, isInstantBooking: true, frequenciesXid: true, frequency: { where: { isActive: true }, select: { id: true, frequencyName: true, } }, ActivityVenues: { where: { isActive: true }, select: { id: true, venueName: true, venueLabel: true, ScheduleHeader: { where: { isActive: true }, select: { id: true, scheduleType: true, startDate: true, endDate: true, earlyCheckInMins: true, bookingCutOffMins: true, ScheduleDetails: { where: { isActive: true }, select: { id: true, occurenceDate: true, weekDay: true, dayOfMonth: true, startTime: true, endTime: true, } }, Cancellations: { where: { isActive: true }, select: { id: true, slotXid: true, cancellationReason: true, slot: { select: { id: true, occurenceDate: true, startTime: true, endTime: true, weekDay: true, dayOfMonth: true, } } } }, scheduleOccurences: { where: { isActive: true }, select: { id: true, occurenceDate: true } }, scheduleRecurrences: { where: { isActive: true }, select: { id: true, weekDay: true, dayOfMonth: true } } } } } }, ActivitiesMedia: { where: { isActive: true }, select: { id: true, mediaFileName: true, mediaType: true, } } } }) if (!result) return null; if (result.ActivitiesMedia?.length) { for (const media of result.ActivitiesMedia) { if (!media.mediaFileName) continue; const key = media.mediaFileName.startsWith('http') ? media.mediaFileName.split('.com/')[1] : media.mediaFileName; (media as any).presignedUrl = await getPresignedUrl(bucket, key); } } for (const venue of result.ActivityVenues ?? []) { for (const header of venue.ScheduleHeader ?? []) { /* ------------------------------- 🚫 SLOT CANCELLATION FLAG -------------------------------- */ const cancelledSlotIds = new Set( header.Cancellations?.map(c => c.slotXid) ); for (const slot of header.ScheduleDetails ?? []) { (slot as any).isCancelled = cancelledSlotIds.has(slot.id); } /* ------------------------------- 📅 FRONTEND FRIENDLY META -------------------------------- */ // WEEKLY → send weekdays if (header.scheduleType === 'WEEKLY') { (header as any).scheduleDays = [ ...new Set( header.scheduleRecurrences ?.map(r => r.weekDay) .filter(Boolean) ), ]; } // MONTHLY → send dates (1–31) if (header.scheduleType === 'MONTHLY') { (header as any).scheduleDates = [ ...new Set( header.scheduleRecurrences ?.map(r => r.dayOfMonth) .filter(Boolean) ), ]; } // CUSTOM / ONCE → send exact dates if ( header.scheduleType === 'CUSTOM' || header.scheduleType === 'ONCE' ) { (header as any).scheduleDates = [ ...new Set( header.scheduleOccurences ?.map(o => o.occurenceDate ?.toISOString() .split('T')[0] ) .filter(Boolean) ), ]; } } } (result as any).availableScheduleTypes = [ 'ONCE', 'WEEKLY', 'MONTHLY', 'CUSTOM', ]; return result; } async cancelSlotForActivity( scheduleHeaderXid: number, slotXid?: number, cancellationReason?: string ) { return await this.prisma.cancellations.create({ data: { scheduleHeader: { connect: { id: scheduleHeaderXid }, }, slot: { connect: { id: slotXid }, }, cancellationReason } }) } async openCanceledSlot( cancellationXid: number, slotXid?: number, ) { return await this.prisma.cancellations.update({ where: { id: cancellationXid, slotXid: slotXid }, data: { isActive: false } }) } }