import { Injectable } from '@nestjs/common'; import { PrismaClient } from '@prisma/client'; import { getPresignedUrl } from '../../../common/middlewares/aws/getPreSignedUrl'; 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 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) { // Build cancellation set using time matching const cancelledSlots = new Set( header.Cancellations.map( (c) => `${c.startTime}-${c.endTime}` ) ); const slots = header.ScheduleDetails .filter( (slot) => !cancelledSlots.has(`${slot.startTime}-${slot.endTime}`) ) .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, venueCapacity: header.activityVenue.venueCapacity, slots, }); } return response; } /** * Return full schedule header + venue + slots for a given activity and date */ async getScheduleDetailsForDate(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(); const scheduleHeaders = await this.prisma.scheduleHeader.findMany({ where: { activityXid, isActive: true, startDate: { lte: date }, OR: [{ endDate: null }, { endDate: { gte: date } }], ScheduleDetails: { some: {} } }, include: { activityVenue: { select: { id: true, venueName: true, venueLabel: true, venueCapacity: true, }, }, scheduleRecurrences: { where: { isActive: true }, }, ScheduleDetails: { where: { isActive: true, OR: [ { occurenceDate: date }, { weekDay: weekDay }, { dayOfMonth: dayOfMonth }, ], }, }, Cancellations: { where: { occurenceDate: date, isActive: true, }, }, }, }); if (!scheduleHeaders.length) return []; const response = scheduleHeaders.map((header) => { // Match cancelled slots using startTime + endTime const cancelledSlots = new Set( header.Cancellations.map( (c) => `${c.startTime}-${c.endTime}` ) ); const slots = header.ScheduleDetails .filter( (slot) => !cancelledSlots.has(`${slot.startTime}-${slot.endTime}`) ) .map((slot) => ({ slotId: slot.id, occurenceDate: slot.occurenceDate, weekDay: slot.weekDay, dayOfMonth: slot.dayOfMonth, startTime: slot.startTime, endTime: slot.endTime, maxCapacity: slot.maxCapacity, })); return { scheduleHeaderXid: header.id, scheduleType: header.scheduleType, startDate: header.startDate, endDate: header.endDate, earlyCheckInMins: header.earlyCheckInMins, bookingCutOffMins: header.bookingCutOffMins, activityVenue: { venueXid: header.activityVenue.id, venueName: header.activityVenue.venueName, venueLabel: header.activityVenue.venueLabel, venueCapacity: header.activityVenue.venueCapacity, }, slots, // only active slots, no cancellation flag }; }); 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, cancellationReason: true, occurenceDate: true, startTime: true, endTime: 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 ?? []) { /* ------------------------------- 📅 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 cancelMultipleSlotsForActivity( cancellations: { scheduleHeaderXid: number; occurenceDate: string; startTime: string; endTime: string; cancellationReason?: string; }[], ) { return await this.prisma.cancellations.createMany({ data: cancellations.map((item) => ({ scheduleHeaderXid: item.scheduleHeaderXid, occurenceDate: item.occurenceDate, startTime: item.startTime, endTime: item.endTime, cancellationReason: item.cancellationReason || 'No reason provided', })), skipDuplicates: true, }); } async openCanceledSlot( cancellations: { cancellationXid: number; }[], ) { return await this.prisma.cancellations.updateMany({ where: { id: { in: cancellations.map((c) => c.cancellationXid) }, }, data: { isActive: false, }, }); } }