Files
MinglarBackendNestJS/src/modules/host/services/activityScheduling.service.ts

634 lines
22 KiB
TypeScript
Raw Normal View History

2026-01-28 15:49:19 +05:30
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';
2026-01-28 15:49:19 +05:30
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;
2026-01-28 15:49:19 +05:30
@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;
}
2026-01-28 15:49:19 +05:30
async addSchedulingForActivity(data: ScheduleActivityDTO) {
const {
activityXid,
listNow,
2026-01-28 15:49:19 +05:30
scheduleType,
dateRange,
rules,
venues,
earlyCheckInMins,
bookingCutOffMins,
isLateCheckingAllowed,
isInstantBooking
2026-01-28 15:49:19 +05:30
} = 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
---------------------------------- */
2026-01-28 15:49:19 +05:30
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
}
})
}
2026-01-28 15:49:19 +05:30
for (const venue of venues) {
if (!venue.slots || venue.slots.length === 0) {
continue;
}
2026-01-28 15:49:19 +05:30
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');
}
2026-01-28 15:49:19 +05:30
await tx.scheduleRecurrence.createMany({
data: uniqueWeekdays.map(day => ({
2026-01-28 15:49:19 +05:30
scheduleHeaderXid: header.id,
weekDay: day,
isActive: true,
})),
});
}
2026-01-28 15:49:19 +05:30
// 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');
}
2026-01-28 15:49:19 +05:30
await tx.scheduleRecurrence.createMany({
data: uniqueDays.map(day => ({
2026-01-28 15:49:19 +05:30
scheduleHeaderXid: header.id,
dayOfMonth: day,
isActive: true,
})),
});
}
2026-01-28 15:49:19 +05:30
// CUSTOM / ONCE
if (scheduleType === SCHEDULING_TYPE.CUSTOM || scheduleType === SCHEDULING_TYPE.ONCE) {
const uniqueDates = [
...new Set(
venue.slots
.map(s => s.occurrenceDate)
.filter(Boolean)
),
];
2026-01-28 15:49:19 +05:30
await tx.scheduleOccurences.createMany({
data: uniqueDates.map(d => ({
2026-01-28 15:49:19 +05:30
scheduleHeaderXid: header.id,
occurenceDate: new Date(d!),
2026-01-28 15:49:19 +05:30
isActive: true,
})),
});
}
2026-01-28 15:49:19 +05:30
// 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,
},
});
2026-01-28 15:49:19 +05:30
}
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: {
2026-01-28 16:55:14 +05:30
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,
2026-01-30 14:49:43 +05:30
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 (131)
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
}
})
}
2026-01-30 14:49:43 +05:30
async openCanceledSlot(
cancellationXid: number,
slotXid?: number,
) {
return await this.prisma.cancellations.update({
where: {
id: cancellationXid, slotXid: slotXid
},
data: {
isActive: false
}
})
}
2026-01-28 15:49:19 +05:30
}