744 lines
20 KiB
TypeScript
744 lines
20 KiB
TypeScript
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,
|
||
},
|
||
});
|
||
}
|
||
}
|