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

744 lines
20 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 (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 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,
},
});
}
}