diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 4b5a978..3bf0505 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -1523,7 +1523,7 @@ model Cancellations { id Int @id @default(autoincrement()) scheduleHeaderXid Int @map("schedule_header_xid") scheduleHeader ScheduleHeader @relation(fields: [scheduleHeaderXid], references: [id], onDelete: Cascade) - occurenceDate DateTime @map("occurence_date") + occurenceDate DateTime? @map("occurence_date") slotXid Int @map("slot_xid") slot ScheduleDetails @relation(fields: [slotXid], references: [id], onDelete: Cascade) cancellationReason String @map("cancellation_reason") diff --git a/serverless/functions/host.yml b/serverless/functions/host.yml index 377fe38..0be9b32 100644 --- a/serverless/functions/host.yml +++ b/serverless/functions/host.yml @@ -492,4 +492,19 @@ getVenueDurationByAct: events: - httpApi: path: /scheduling/get-venue-duration/{activityXid} - method: get \ No newline at end of file + method: get + +cancelSlotForActivity: + handler: src/modules/host/handlers/Activity_Hub/Scheduling/cancelSlot.handler + memorySize: 512 + package: + patterns: + - 'src/modules/host/handlers/Activity_Hub/Scheduling/**' + - ${file(./serverless/patterns/base.yml):pattern1} + - ${file(./serverless/patterns/base.yml):pattern2} + - ${file(./serverless/patterns/base.yml):pattern3} + - ${file(./serverless/patterns/base.yml):pattern4} + events: + - httpApi: + path: /scheduling/cancel-slot + method: post \ No newline at end of file diff --git a/src/common/utils/validation/host/createSchedulingOfAct.validation.ts b/src/common/utils/validation/host/createSchedulingOfAct.validation.ts index 082e538..c769aac 100644 --- a/src/common/utils/validation/host/createSchedulingOfAct.validation.ts +++ b/src/common/utils/validation/host/createSchedulingOfAct.validation.ts @@ -13,6 +13,7 @@ const WeekdayEnum = z.enum([ export const scheduleActivity = z.object({ activityXid: z.number(), + listNow: z.boolean(), scheduleType: z.enum([ SCHEDULING_TYPE.ONCE, diff --git a/src/modules/host/handlers/Activity_Hub/Scheduling/cancelSlot.ts b/src/modules/host/handlers/Activity_Hub/Scheduling/cancelSlot.ts new file mode 100644 index 0000000..b236eb9 --- /dev/null +++ b/src/modules/host/handlers/Activity_Hub/Scheduling/cancelSlot.ts @@ -0,0 +1,83 @@ +import { APIGatewayProxyEvent, APIGatewayProxyResult, Context } from 'aws-lambda'; +import { safeHandler } from '../../../../../common/utils/handlers/safeHandler'; +import { prismaClient } from '../../../../../common/database/prisma.lambda.service'; +import { SchedulingService } from '../../../services/activityScheduling.service'; +import { HostService } from '../../../services/host.service'; +import ApiError from '../../../../../common/utils/helper/ApiError'; +import { verifyHostToken } from '../../../../../common/middlewares/jwt/authForHost'; +import { scheduleActivity } from '../../../../../common/utils/validation/host/createSchedulingOfAct.validation'; +import { z } from 'zod'; + +const schedulingService = new SchedulingService(prismaClient); +const hostService = new HostService(prismaClient); + +export const handler = safeHandler(async ( + event: APIGatewayProxyEvent, + context?: Context +): Promise => { + // Extract token from headers + const token = event.headers['x-auth-token'] || event.headers['X-Auth-Token'] + if (!token) { + throw new ApiError(400, 'This is a protected route. Please provide a valid token.'); + } + + // Authenticate user using the shared authForHost function + const userInfo = await verifyHostToken(token); + const hostId = userInfo.id; + + if (Number.isNaN(hostId)) { + throw new ApiError(400, 'Host id must be a number'); + } + + const host = await hostService.getHostIdByUserXid(hostId); + if (!host) { + throw new ApiError(404, 'Host not found'); + } + + let body: { activityXid: number; venueXid: number; scheduleHeaderXid: number; slotXid?: number; cancellationReason?: string }; + + try { + body = event.body ? JSON.parse(event.body) : {}; + } catch { + throw new ApiError(400, 'Invalid JSON payload'); + } + + const activity = await schedulingService.getActivityByXid(body.activityXid); + if (!activity) { + throw new ApiError(404, "Activity not found"); + } + + const venueExists = await schedulingService.getVenueFromVenueXid( + body.venueXid, + body.activityXid + ); + + if (!venueExists) { + throw new ApiError( + 404, + `Venue not found for this activity` + ) + } + + + await schedulingService.cancelSlotForActivity( + Number(body.scheduleHeaderXid), + Number(body.slotXid), + body.cancellationReason || 'No reason provided' + ); + + const result = await schedulingService.getVenueDurationByAct(Number(body.activityXid), Number(hostId)); + + return { + statusCode: 200, + headers: { + 'Content-Type': 'application/json', + 'Access-Control-Allow-Origin': '*', + }, + body: JSON.stringify({ + success: true, + message: 'Scheduling details updated successfully', + data: result + }), + }; +}); \ No newline at end of file diff --git a/src/modules/host/services/activityScheduling.service.ts b/src/modules/host/services/activityScheduling.service.ts index 428cb99..9e4818c 100644 --- a/src/modules/host/services/activityScheduling.service.ts +++ b/src/modules/host/services/activityScheduling.service.ts @@ -1,6 +1,6 @@ import { Injectable } from '@nestjs/common'; import { PrismaClient } from '@prisma/client'; -import { ACTIVITY_INTERNAL_STATUS, SCHEDULING_TYPE } from '../../../common/utils/constants/host.constant'; +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'; @@ -16,6 +16,7 @@ export class SchedulingService { async addSchedulingForActivity(data: ScheduleActivityDTO) { const { activityXid, + listNow, scheduleType, dateRange, rules, @@ -25,6 +26,50 @@ export class SchedulingService { } = 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) { @@ -92,6 +137,18 @@ export class SchedulingService { }, }); } + + 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 }; @@ -293,15 +350,171 @@ export class SchedulingService { 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: { + scheduleHeaderXid, + slotXid, + cancellationReason + } + }) + } } \ No newline at end of file