524 lines
19 KiB
TypeScript
524 lines
19 KiB
TypeScript
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 addSchedulingForActivity(data: ScheduleActivityDTO) {
|
||
const {
|
||
activityXid,
|
||
listNow,
|
||
scheduleType,
|
||
dateRange,
|
||
rules,
|
||
venues,
|
||
earlyCheckInMins,
|
||
bookingCutOffMins,
|
||
} = 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[] = [];
|
||
|
||
for (const venue of venues) {
|
||
|
||
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) {
|
||
await tx.scheduleRecurrence.createMany({
|
||
data: rules.weekdays!.map(day => ({
|
||
scheduleHeaderXid: header.id,
|
||
weekDay: day,
|
||
isActive: true,
|
||
})),
|
||
});
|
||
}
|
||
|
||
// MONTHLY
|
||
if (scheduleType === SCHEDULING_TYPE.MONTHLY) {
|
||
await tx.scheduleRecurrence.createMany({
|
||
data: rules.monthDates!.map(day => ({
|
||
scheduleHeaderXid: header.id,
|
||
dayOfMonth: day,
|
||
isActive: true,
|
||
})),
|
||
});
|
||
}
|
||
|
||
// CUSTOM / ONCE
|
||
if (scheduleType === SCHEDULING_TYPE.CUSTOM || scheduleType === SCHEDULING_TYPE.ONCE) {
|
||
await tx.scheduleOccurences.createMany({
|
||
data: rules.customDates!.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,
|
||
},
|
||
});
|
||
}
|
||
|
||
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
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
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: {
|
||
select: {
|
||
id: true,
|
||
mediaFileName: true,
|
||
mediaType: true,
|
||
},
|
||
},
|
||
},
|
||
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,
|
||
}));
|
||
}
|
||
|
||
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,
|
||
ActivityVenues: {
|
||
select: {
|
||
id: true,
|
||
venueName: true,
|
||
venueLabel: true,
|
||
ScheduleHeader: {
|
||
select: {
|
||
id: true,
|
||
scheduleType: true,
|
||
startDate: true,
|
||
endDate: true,
|
||
earlyCheckInMins: true,
|
||
bookingCutOffMins: true,
|
||
ScheduleDetails: {
|
||
select: {
|
||
id: true,
|
||
occurenceDate: true,
|
||
weekDay: true,
|
||
dayOfMonth: true,
|
||
startTime: true,
|
||
endTime: true,
|
||
}
|
||
},
|
||
Cancellations: {
|
||
select: {
|
||
id: true,
|
||
slotXid: true,
|
||
cancellationReason: true,
|
||
slot: {
|
||
select: {
|
||
id: true,
|
||
occurenceDate: true,
|
||
startTime: true,
|
||
endTime: true,
|
||
weekDay: true,
|
||
dayOfMonth: true,
|
||
}
|
||
}
|
||
}
|
||
},
|
||
scheduleOccurences: {
|
||
select: {
|
||
id: true,
|
||
occurenceDate: true
|
||
}
|
||
},
|
||
scheduleRecurrences: {
|
||
select: {
|
||
id: true,
|
||
weekDay: true,
|
||
dayOfMonth: true
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
},
|
||
ActivitiesMedia: {
|
||
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
|
||
}
|
||
})
|
||
}
|
||
} |