From 68f0dfe124674061cb9d989da844f885335abd68 Mon Sep 17 00:00:00 2001 From: Mayank Mishra Date: Wed, 28 Jan 2026 15:49:19 +0530 Subject: [PATCH] made create scheduling api --- prisma/schema.prisma | 86 +++++-- serverless/functions/host.yml | 15 ++ src/common/utils/constants/host.constant.ts | 9 +- .../host/createSchedulingOfAct.validation.ts | 75 +++++++ .../host/dto/createSchedulingOfAct.dto.ts | 39 ++++ .../Scheduling/createSchedulingOfAct.ts | 89 ++++++++ .../services/activityScheduling.service.ts | 212 ++++++++++++++++++ 7 files changed, 500 insertions(+), 25 deletions(-) create mode 100644 src/common/utils/validation/host/createSchedulingOfAct.validation.ts create mode 100644 src/modules/host/dto/createSchedulingOfAct.dto.ts create mode 100644 src/modules/host/handlers/Activity_Hub/Scheduling/createSchedulingOfAct.ts create mode 100644 src/modules/host/services/activityScheduling.service.ts diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 00f0296..4b5a978 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -1451,9 +1451,11 @@ model ScheduleHeader { activityVenue ActivityVenues @relation(fields: [activityVenueXid], references: [id], onDelete: Cascade) scheduleType String @map("schedule_type") @db.VarChar(30) startDate DateTime @map("start_date") - endDate DateTime @map("end_date") - byWeekday Boolean @default(false) @map("by_weekday") - earlyCheckInMins Int @map("early_check_in_mins") + endDate DateTime? @map("end_date") + earlyCheckInMins Int? @map("early_check_in_mins") + bookingCutOffMins Int? @map("booking_cut_off_mins") + effectiveFromDt String? @map("effective_from_dt") + effectiveToDt String? @map("effective_to_dt") isActive Boolean @default(true) @map("is_active") createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") @@ -1461,41 +1463,77 @@ model ScheduleHeader { ScheduleDetails ScheduleDetails[] Cancellations Cancellations[] ItineraryActivities ItineraryActivities[] + scheduleOccurences ScheduleOccurences[] + scheduleRecurrences ScheduleRecurrence[] @@map("schedule_header") @@schema("sch") } model ScheduleDetails { - id Int @id @default(autoincrement()) - scheduleHeaderXid Int @map("schedule_header_xid") - scheduleHeader ScheduleHeader @relation(fields: [scheduleHeaderXid], references: [id], onDelete: Cascade) - startTime String @map("start_time") @db.VarChar(30) - endTime String @map("end_time") @db.VarChar(30) - isActive Boolean @default(true) @map("is_active") - createdAt DateTime @default(now()) @map("created_at") - updatedAt DateTime @updatedAt @map("updated_at") - deletedAt DateTime? @map("deleted_at") + 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") + weekDay String? @map("week_day") @db.VarChar(30) + dayOfMonth Int? @map("day_of_month") + startTime String @map("start_time") @db.VarChar(30) + endTime String @map("end_time") @db.VarChar(30) + maxCapacity Int @map("max_capacity") + isActive Boolean @default(true) @map("is_active") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + deletedAt DateTime? @map("deleted_at") + cancellations Cancellations[] @@map("schedule_details") @@schema("sch") } +model ScheduleOccurences { + 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") + isActive Boolean @default(true) @map("is_active") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + deletedAt DateTime? @map("deleted_at") + + @@map("schedule_occurences") + @@schema("sch") +} + +model ScheduleRecurrence { + id Int @id @default(autoincrement()) + scheduleHeaderXid Int @map("schedule_header_xid") + scheduleHeader ScheduleHeader @relation(fields: [scheduleHeaderXid], references: [id], onDelete: Cascade) + weekDay String? @map("week_day") @db.VarChar(30) + dayOfMonth Int? @map("day_of_month") + isActive Boolean @default(true) @map("is_active") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + deletedAt DateTime? @map("deleted_at") + + @@map("schedule_recurrence") + @@schema("sch") +} + 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") - startTime String @map("start_time") @db.VarChar(30) - endTime String @map("end_time") @db.VarChar(30) - cancellationReason String @map("cancellation_reason") - isActive Boolean @default(true) @map("is_active") - createdAt DateTime @default(now()) @map("created_at") - updatedAt DateTime @updatedAt @map("updated_at") - deletedAt DateTime? @map("deleted_at") + 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") + slotXid Int @map("slot_xid") + slot ScheduleDetails @relation(fields: [slotXid], references: [id], onDelete: Cascade) + cancellationReason String @map("cancellation_reason") + isActive Boolean @default(true) @map("is_active") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + deletedAt DateTime? @map("deleted_at") @@map("cancellations") - @@schema("act") + @@schema("sch") } // ITINERARY MODELS diff --git a/serverless/functions/host.yml b/serverless/functions/host.yml index 888609c..8941f36 100644 --- a/serverless/functions/host.yml +++ b/serverless/functions/host.yml @@ -446,3 +446,18 @@ mediaDeleteFroms3: - httpApi: path: /media/delete method: delete + +createSchedulingForAct: + handler: src/modules/host/handlers/Activity_Hub/Scheduling/createSchedulingOfAct.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/create + method: post diff --git a/src/common/utils/constants/host.constant.ts b/src/common/utils/constants/host.constant.ts index 51c008a..b85954b 100644 --- a/src/common/utils/constants/host.constant.ts +++ b/src/common/utils/constants/host.constant.ts @@ -99,5 +99,12 @@ export const ACTIVITY_AM_DISPLAY_STATUS = { ACTIVITY_ENHANCING: 'Enhancing', NOT_LISTED: 'Not Listed', ACTIVITY_LISTED: 'Listed', - ACTIVITY_REVISED:'Activity Revised' + ACTIVITY_REVISED: 'Activity Revised' +}; + +export const SCHEDULING_TYPE = { + ONCE: 'ONCE', + WEEKLY: 'WEEKLY', + MONTHLY: 'MONTHLY', + CUSTOM: 'CUSTOM', }; diff --git a/src/common/utils/validation/host/createSchedulingOfAct.validation.ts b/src/common/utils/validation/host/createSchedulingOfAct.validation.ts new file mode 100644 index 0000000..082e538 --- /dev/null +++ b/src/common/utils/validation/host/createSchedulingOfAct.validation.ts @@ -0,0 +1,75 @@ +import { z } from 'zod'; +import { SCHEDULING_TYPE } from '../../constants/host.constant'; + +const WeekdayEnum = z.enum([ + 'MONDAY', + 'TUESDAY', + 'WEDNESDAY', + 'THURSDAY', + 'FRIDAY', + 'SATURDAY', + 'SUNDAY', +]); + +export const scheduleActivity = z.object({ + activityXid: z.number(), + + scheduleType: z.enum([ + SCHEDULING_TYPE.ONCE, + SCHEDULING_TYPE.WEEKLY, + SCHEDULING_TYPE.MONTHLY, + SCHEDULING_TYPE.CUSTOM, + ]), + + dateRange: z.object({ + startDate: z.string(), + endDate: z.string().nullable().optional(), + }), + + rules: z.object({ + weekdays: z.array(WeekdayEnum).optional(), + monthDates: z.array(z.number()).optional(), + customDates: z.array(z.string()).optional(), + }), + + venues: z.array( + z.object({ + venueXid: z.number(), + slots: z.array( + z.object({ + startTime: z.string(), + endTime: z.string(), + weekDay: WeekdayEnum.nullable().optional(), + dayOfMonth: z.number().nullable().optional(), + occurrenceDate: z.string().nullable().optional(), + maxCapacity: z.number(), + }) + ).min(1), + }) + ), + + earlyCheckInMins: z.number().optional(), + bookingCutOffMins: z.number().optional(), + isLateCheckingAllowed: z.boolean().optional(), + isInstantBooking: z.boolean().optional(), +}) + .superRefine((data, ctx) => { + if (data.scheduleType === 'WEEKLY' && !data.rules.weekdays?.length) { + ctx.addIssue({ path: ['rules', 'weekdays'], message: 'Weekdays required for WEEKLY schedule', code: 'custom' }); + } + + if (data.scheduleType === 'MONTHLY' && !data.rules.monthDates?.length) { + ctx.addIssue({ path: ['rules', 'monthDates'], message: 'Month dates required for MONTHLY schedule', code: 'custom' }); + } + + if ( + (data.scheduleType === 'CUSTOM' || data.scheduleType === 'ONCE') && + !data.rules.customDates?.length + ) { + ctx.addIssue({ path: ['rules', 'customDates'], message: 'Custom dates required', code: 'custom' }); + } + }); + +export type ScheduleActivityDTO = z.infer; + + diff --git a/src/modules/host/dto/createSchedulingOfAct.dto.ts b/src/modules/host/dto/createSchedulingOfAct.dto.ts new file mode 100644 index 0000000..88fa740 --- /dev/null +++ b/src/modules/host/dto/createSchedulingOfAct.dto.ts @@ -0,0 +1,39 @@ +export interface ScheduleSlotDTO { + startTime: string; + endTime: string; + weekDay?: 'MONDAY' | 'TUESDAY' | 'WEDNESDAY' | 'THURSDAY' | 'FRIDAY' | 'SATURDAY' | 'SUNDAY' | null; + dayOfMonth?: number | null; // 1–31 + occurrenceDate?: string | null; + maxCapacity: number; +} + +export interface ScheduleVenueDTO { + venueXid: number; + slots: ScheduleSlotDTO[]; +} + +// export interface ScheduleActivityDTO { +// activityXid: number; +// scheduleType: 'ONCE' | 'WEEKLY' | 'MONTHLY' | 'CUSTOM'; + +// dateRange: { +// startDate: string; +// endDate?: string | null; +// }; + +// rules: { +// weekdays?: ( +// 'MONDAY' | 'TUESDAY' | 'WEDNESDAY' | +// 'THURSDAY' | 'FRIDAY' | 'SATURDAY' | 'SUNDAY' +// )[]; +// monthDates?: number[]; +// customDates?: string[]; +// }; + +// venues: ScheduleVenueDTO[]; + +// earlyCheckInMins?: number; +// bookingCutOffMins?: number; +// isLateCheckingAllowed?: boolean; +// isInstantBooking?: boolean; +// } diff --git a/src/modules/host/handlers/Activity_Hub/Scheduling/createSchedulingOfAct.ts b/src/modules/host/handlers/Activity_Hub/Scheduling/createSchedulingOfAct.ts new file mode 100644 index 0000000..47880ca --- /dev/null +++ b/src/modules/host/handlers/Activity_Hub/Scheduling/createSchedulingOfAct.ts @@ -0,0 +1,89 @@ +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: unknown; + try { + body = event.body ? JSON.parse(event.body) : {}; + } catch { + throw new ApiError(400, 'Invalid JSON payload'); + } + + // ✅ Validate payload using Zod + + const parsed = scheduleActivity.safeParse(body); + + if (!parsed.success) { + const msg = parsed.error.issues + .map(e => e.message) + .join(', '); + throw new ApiError(400, `Validation failed: ${msg}`); + } + + const activity = await schedulingService.getActivityByXid(parsed.data.activityXid); + if (!activity) { + throw new ApiError(404, "Activity not found"); + } + + if (parsed.data.venues && parsed.data.venues.length > 0) { + for (const venue of parsed.data.venues) { + const venueExists = await schedulingService.getVenueFromVenueXid( + venue.venueXid, + parsed.data.activityXid + ); + + if (!venueExists) { + throw new ApiError( + 404, + `Venue with xid ${venue.venueXid} not found for this activity` + ); + } + } + } + + + await schedulingService.addSchedulingForActivity(parsed.data); + return { + statusCode: 200, + headers: { + 'Content-Type': 'application/json', + 'Access-Control-Allow-Origin': '*', + }, + body: JSON.stringify({ + success: true, + message: 'Scheduling details updated successfully', + }), + }; +}); \ No newline at end of file diff --git a/src/modules/host/services/activityScheduling.service.ts b/src/modules/host/services/activityScheduling.service.ts new file mode 100644 index 0000000..df5c675 --- /dev/null +++ b/src/modules/host/services/activityScheduling.service.ts @@ -0,0 +1,212 @@ +import { Injectable } from '@nestjs/common'; +import { PrismaClient } from '@prisma/client'; +import { ScheduleActivityDTO } from '../../../common/utils/validation/host/createSchedulingOfAct.validation'; +import ApiError from '../../../common/utils/helper/ApiError'; +import { SCHEDULING_TYPE } from '../../../common/utils/constants/host.constant'; + +@Injectable() +export class SchedulingService { + constructor(private prisma: PrismaClient) { } + + async addSchedulingForActivity(data: ScheduleActivityDTO) { + const { + activityXid, + scheduleType, + dateRange, + rules, + venues, + earlyCheckInMins, + bookingCutOffMins, + } = data; + + return this.prisma.$transaction(async (tx) => { + 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, + }, + }); + } + } + + 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, + } + }); + } +} \ No newline at end of file