From a44321044f13df0c6aafbbb926c5243b4e537cf2 Mon Sep 17 00:00:00 2001 From: Mayank Mishra Date: Tue, 7 Apr 2026 19:13:06 +0530 Subject: [PATCH] made the api for storing the selections for the itinerary of the user --- prisma/schema.prisma | 60 +++ serverless/functions/user.yml | 15 + .../saveItineraryActivitySelections.ts | 98 ++++ .../user/services/itinerary.service.ts | 507 ++++++++++++++++++ 4 files changed, 680 insertions(+) create mode 100644 src/modules/user/handlers/itinerary/saveItineraryActivitySelections.ts diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 313fc9c..7b70d12 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -1369,6 +1369,7 @@ model ActivityFoodTypes { createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") deletedAt DateTime? @map("deleted_at") + itineraryActivitySelectionFoodTypes ItineraryActivitySelectionFoodType[] @@map("activity_food_types") @@schema("act") @@ -1419,6 +1420,7 @@ model ActivityEquipments { updatedAt DateTime @updatedAt @map("updated_at") deletedAt DateTime? @map("deleted_at") ActivityEquipmentTaxes ActivityEquipmentTaxes[] + itineraryActivitySelectionEquipments ItineraryActivitySelectionEquipment[] @@map("activity_equipments") @@schema("act") @@ -1454,6 +1456,7 @@ model ActivityNavigationModes { updatedAt DateTime @updatedAt @map("updated_at") deletedAt DateTime? @map("deleted_at") ActivityNavigationModesTaxes ActivityNavigationModesTaxes[] + ItineraryActivitySelections ItineraryActivitySelection[] @@map("activity_navigation_modes") @@schema("act") @@ -1680,6 +1683,7 @@ model ItineraryMembers { User User? @relation(fields: [userId], references: [id]) userId Int? ItineraryDetails ItineraryDetails[] + itineraryActivitySelections ItineraryActivitySelection[] @@map("itinerary_members") @@schema("itn") @@ -1740,11 +1744,67 @@ model ItineraryActivities { ActivitySOSDetails ActivitySOSDetails[] ActivityFeedbacks ActivityFeedbacks[] ItineraryDetails ItineraryDetails[] + itineraryActivitySelections ItineraryActivitySelection[] @@map("itinerary_activities") @@schema("itn") } +model ItineraryActivitySelection { + id Int @id @default(autoincrement()) + itineraryActivityXid Int @map("itinerary_activity_xid") + itineraryActivity ItineraryActivities @relation(fields: [itineraryActivityXid], references: [id], onDelete: Cascade) + itineraryMemberXid Int @map("itinerary_member_xid") + itineraryMember ItineraryMembers @relation(fields: [itineraryMemberXid], references: [id], onDelete: Cascade) + isFoodOpted Boolean @default(false) @map("is_food_opted") + isTrainerOpted Boolean @default(false) @map("is_trainer_opted") + isInActivityNavigationOpted Boolean @default(false) @map("is_in_activity_navigation_opted") + activityNavigationModeXid Int? @map("activity_navigation_mode_xid") + activityNavigationMode ActivityNavigationModes? @relation(fields: [activityNavigationModeXid], references: [id], onDelete: Restrict) + 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") + selectedFoodTypes ItineraryActivitySelectionFoodType[] + selectedEquipments ItineraryActivitySelectionEquipment[] + + @@unique([itineraryActivityXid, itineraryMemberXid]) + @@map("itinerary_activity_selection") + @@schema("itn") +} + +model ItineraryActivitySelectionFoodType { + id Int @id @default(autoincrement()) + itineraryActivitySelectionXid Int @map("itinerary_activity_selection_xid") + itineraryActivitySelection ItineraryActivitySelection @relation(fields: [itineraryActivitySelectionXid], references: [id], onDelete: Cascade) + activityFoodTypeXid Int @map("activity_food_type_xid") + activityFoodType ActivityFoodTypes @relation(fields: [activityFoodTypeXid], references: [id], onDelete: Cascade) + 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") + + @@unique([itineraryActivitySelectionXid, activityFoodTypeXid]) + @@map("itinerary_activity_selection_food_type") + @@schema("itn") +} + +model ItineraryActivitySelectionEquipment { + id Int @id @default(autoincrement()) + itineraryActivitySelectionXid Int @map("itinerary_activity_selection_xid") + itineraryActivitySelection ItineraryActivitySelection @relation(fields: [itineraryActivitySelectionXid], references: [id], onDelete: Cascade) + activityEquipmentXid Int @map("activity_equipment_xid") + activityEquipment ActivityEquipments @relation(fields: [activityEquipmentXid], references: [id], onDelete: Cascade) + 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") + + @@unique([itineraryActivitySelectionXid, activityEquipmentXid]) + @@map("itinerary_activity_selection_equipment") + @@schema("itn") +} + model ActivitySOSDetails { id Int @id @default(autoincrement()) itineraryActivityXid Int @map("itinerary_activity_xid") diff --git a/serverless/functions/user.yml b/serverless/functions/user.yml index 4099a40..e2c3892 100644 --- a/serverless/functions/user.yml +++ b/serverless/functions/user.yml @@ -453,6 +453,21 @@ saveUserItinerary: path: /itinerary/save-user-itinerary method: post +saveItineraryActivitySelections: + handler: src/modules/user/handlers/itinerary/saveItineraryActivitySelections.handler + memorySize: 512 + package: + patterns: + - 'src/modules/user/**' + - ${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: /itinerary/save-itinerary-activity-selections + method: post + getAllUserSavedItineraries: handler: src/modules/user/handlers/itinerary/getAllUserSavedItineraries.handler memorySize: 512 diff --git a/src/modules/user/handlers/itinerary/saveItineraryActivitySelections.ts b/src/modules/user/handlers/itinerary/saveItineraryActivitySelections.ts new file mode 100644 index 0000000..2d1a3be --- /dev/null +++ b/src/modules/user/handlers/itinerary/saveItineraryActivitySelections.ts @@ -0,0 +1,98 @@ +import { APIGatewayProxyEvent, APIGatewayProxyResult, Context } from 'aws-lambda'; +import { prismaClient } from '../../../../common/database/prisma.lambda.service'; +import { verifyUserToken } from '../../../../common/middlewares/jwt/authForUser'; +import { safeHandler } from '../../../../common/utils/handlers/safeHandler'; +import ApiError from '../../../../common/utils/helper/ApiError'; +import { ItineraryService } from '../../services/itinerary.service'; + +const itineraryService = new ItineraryService(prismaClient); + +export const handler = safeHandler(async ( + event: APIGatewayProxyEvent, + context?: Context, +): Promise => { + 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.', + ); + } + + const userInfo = await verifyUserToken(token); + const userId = Number(userInfo.id); + + if (!userId || Number.isNaN(userId)) { + throw new ApiError(400, 'Invalid user ID'); + } + + let body: Record = {}; + if (event.body) { + try { + body = JSON.parse(event.body); + } catch { + throw new ApiError(400, 'Invalid JSON body'); + } + } + + const itineraryActivityXid = Number(body.itineraryActivityXid); + if (!Number.isInteger(itineraryActivityXid) || itineraryActivityXid <= 0) { + throw new ApiError(400, 'itineraryActivityXid is required.'); + } + + const selectedEquipmentIds = Array.isArray(body.selectedEquipmentIds) + ? body.selectedEquipmentIds.map((id: unknown) => Number(id)) + : []; + const selectedFoodTypeIds = Array.isArray(body.selectedFoodTypeIds) + ? body.selectedFoodTypeIds.map((id: unknown) => Number(id)) + : []; + + if (selectedEquipmentIds.some((id) => !Number.isInteger(id) || id <= 0)) { + throw new ApiError(400, 'selectedEquipmentIds must contain valid ids.'); + } + + if (selectedFoodTypeIds.some((id) => !Number.isInteger(id) || id <= 0)) { + throw new ApiError(400, 'selectedFoodTypeIds must contain valid ids.'); + } + + const toOptionalId = (value: unknown) => { + if (value === undefined || value === null || value === '') { + return null; + } + + const parsed = Number(value); + if (!Number.isInteger(parsed) || parsed <= 0) { + throw new ApiError(400, 'One or more selected option ids are invalid.'); + } + + return parsed; + }; + + const result = await itineraryService.saveItineraryActivitySelections(userId, { + itineraryActivityXid, + isFoodOpted: + body.isFoodOpted === undefined ? false : Boolean(body.isFoodOpted), + selectedFoodTypeIds, + isTrainerOpted: + body.isTrainerOpted === undefined ? false : Boolean(body.isTrainerOpted), + isInActivityNavigationOpted: + body.isInActivityNavigationOpted === undefined + ? false + : Boolean(body.isInActivityNavigationOpted), + selectedNavigationModeXid: toOptionalId(body.selectedNavigationModeXid), + selectedEquipmentIds, + }); + + return { + statusCode: 200, + headers: { + 'Content-Type': 'application/json', + 'Access-Control-Allow-Origin': '*', + }, + body: JSON.stringify({ + success: true, + message: 'Itinerary activity selections saved successfully.', + data: result, + }), + }; +}); diff --git a/src/modules/user/services/itinerary.service.ts b/src/modules/user/services/itinerary.service.ts index 0e39164..56f54f3 100644 --- a/src/modules/user/services/itinerary.service.ts +++ b/src/modules/user/services/itinerary.service.ts @@ -1058,6 +1058,76 @@ export class ItineraryService { paxCount: true, totalAmount: true, bookingStatus: true, + itineraryActivitySelections: { + where: { + isActive: true, + deletedAt: null, + itineraryMember: { + memberXid: userXid, + isActive: true, + deletedAt: null, + }, + }, + take: 1, + select: { + id: true, + itineraryMemberXid: true, + isFoodOpted: true, + isTrainerOpted: true, + isInActivityNavigationOpted: true, + activityNavigationModeXid: true, + selectedFoodTypes: { + where: { + isActive: true, + deletedAt: null, + }, + select: { + id: true, + activityFoodTypeXid: true, + activityFoodType: { + select: { + id: true, + foodTypeXid: true, + foodType: { + select: { + id: true, + foodTypeName: true, + }, + }, + }, + }, + }, + }, + activityNavigationMode: { + select: { + id: true, + navigationModeName: true, + isInActivityChargeable: true, + navigationModesBasePrice: true, + navigationModesTotalPrice: true, + }, + }, + selectedEquipments: { + where: { + isActive: true, + deletedAt: null, + }, + select: { + id: true, + activityEquipmentXid: true, + activityEquipment: { + select: { + id: true, + equipmentName: true, + isEquipmentChargeable: true, + equipmentBasePrice: true, + equipmentTotalPrice: true, + }, + }, + }, + }, + }, + }, activity: { select: { id: true, @@ -1075,6 +1145,8 @@ export class ItineraryService { inActivityIsChargeable: true, pickUpDropAvailable: true, pickUpDropIsChargeable: true, + equipmentAvailable: true, + equipmentIsChargeable: true, activityFoodTypes: { where: { isActive: true, @@ -1126,6 +1198,19 @@ export class ItineraryService { navigationModesTotalPrice: true, }, }, + ActivityEquipments: { + where: { + isActive: true, + deletedAt: null, + }, + select: { + id: true, + equipmentName: true, + isEquipmentChargeable: true, + equipmentBasePrice: true, + equipmentTotalPrice: true, + }, + }, ActivityPickUpDetails: { where: { isActive: true, @@ -1228,6 +1313,7 @@ export class ItineraryService { item.activity.ActivitiesMedia[0] ?? null : null; + const userSelection = item.itineraryActivitySelections[0] ?? null; return { id: item.id, @@ -1303,6 +1389,17 @@ export class ItineraryService { }), ), }, + equipmentDetails: { + equipmentAvailable: item.activity.equipmentAvailable, + equipmentIsChargeable: item.activity.equipmentIsChargeable, + equipments: item.activity.ActivityEquipments.map((equipment) => ({ + id: equipment.id, + equipmentName: equipment.equipmentName, + isEquipmentChargeable: equipment.isEquipmentChargeable, + equipmentBasePrice: equipment.equipmentBasePrice, + equipmentTotalPrice: equipment.equipmentTotalPrice, + })), + }, pickUpDetails: { pickUpDropAvailable: item.activity.pickUpDropAvailable, pickUpDropIsChargeable: @@ -1340,6 +1437,40 @@ export class ItineraryService { }, } : null, + userSelections: userSelection + ? { + id: userSelection.id, + itineraryMemberXid: userSelection.itineraryMemberXid, + isFoodOpted: userSelection.isFoodOpted, + selectedFoodTypeIds: userSelection.selectedFoodTypes.map( + (foodType) => foodType.activityFoodTypeXid, + ), + selectedFoodTypes: userSelection.selectedFoodTypes.map( + (foodType) => ({ + id: foodType.id, + activityFoodTypeXid: foodType.activityFoodTypeXid, + activityFoodType: foodType.activityFoodType, + }), + ), + isTrainerOpted: userSelection.isTrainerOpted, + isInActivityNavigationOpted: + userSelection.isInActivityNavigationOpted, + selectedNavigationModeXid: + userSelection.activityNavigationModeXid, + selectedNavigationMode: + userSelection.activityNavigationMode ?? null, + selectedEquipmentIds: userSelection.selectedEquipments.map( + (equipment) => equipment.activityEquipmentXid, + ), + selectedEquipments: userSelection.selectedEquipments.map( + (equipment) => ({ + id: equipment.id, + activityEquipmentXid: equipment.activityEquipmentXid, + activityEquipment: equipment.activityEquipment, + }), + ), + } + : null, venue: item.venue, scheduleHeader: item.scheduledHeader, }; @@ -1381,6 +1512,382 @@ export class ItineraryService { }; } + async saveItineraryActivitySelections( + userXid: number, + payload: { + itineraryActivityXid: number; + isFoodOpted?: boolean; + selectedFoodTypeIds?: number[]; + isTrainerOpted?: boolean; + isInActivityNavigationOpted?: boolean; + selectedNavigationModeXid?: number | null; + selectedEquipmentIds?: number[]; + }, + ) { + const selectedFoodTypeIds = Array.from( + new Set((payload.selectedFoodTypeIds ?? []).map(Number)), + ); + const selectedEquipmentIds = Array.from( + new Set((payload.selectedEquipmentIds ?? []).map(Number)), + ); + + return this.prisma.$transaction(async (tx) => { + const itineraryActivity = await tx.itineraryActivities.findFirst({ + where: { + id: payload.itineraryActivityXid, + isActive: true, + deletedAt: null, + itineraryHeader: { + ItineraryMembers: { + some: { + memberXid: userXid, + isActive: true, + deletedAt: null, + }, + }, + }, + }, + select: { + id: true, + itineraryHeaderXid: true, + itineraryType: true, + activityXid: true, + activity: { + select: { + id: true, + foodAvailable: true, + trainerAvailable: true, + inActivityAvailable: true, + equipmentAvailable: true, + activityFoodTypes: { + where: { + isActive: true, + deletedAt: null, + }, + select: { + id: true, + foodTypeXid: true, + foodType: { + select: { + id: true, + foodTypeName: true, + }, + }, + }, + }, + ActivityNavigationModes: { + where: { + isActive: true, + deletedAt: null, + }, + select: { + id: true, + navigationModeName: true, + isInActivityChargeable: true, + navigationModesBasePrice: true, + navigationModesTotalPrice: true, + }, + }, + ActivityEquipments: { + where: { + isActive: true, + deletedAt: null, + }, + select: { + id: true, + equipmentName: true, + isEquipmentChargeable: true, + equipmentBasePrice: true, + equipmentTotalPrice: true, + }, + }, + }, + }, + }, + }); + + if (!itineraryActivity) { + throw new ApiError(404, 'Itinerary activity not found for this user.'); + } + + if ( + itineraryActivity.itineraryType !== 'ACTIVITY' || + !itineraryActivity.activityXid || + !itineraryActivity.activity + ) { + throw new ApiError( + 400, + 'Selections can only be stored for itinerary items linked to an activity.', + ); + } + + const itineraryMember = await tx.itineraryMembers.findFirst({ + where: { + itineraryHeaderXid: itineraryActivity.itineraryHeaderXid, + memberXid: userXid, + isActive: true, + deletedAt: null, + }, + select: { + id: true, + }, + }); + + if (!itineraryMember) { + throw new ApiError(404, 'Itinerary member record not found.'); + } + + if (selectedEquipmentIds.some((id) => !Number.isInteger(id) || id <= 0)) { + throw new ApiError(400, 'selectedEquipmentIds must contain valid ids.'); + } + + if (selectedFoodTypeIds.some((id) => !Number.isInteger(id) || id <= 0)) { + throw new ApiError(400, 'selectedFoodTypeIds must contain valid ids.'); + } + + const isFoodOpted = Boolean(payload.isFoodOpted); + const isTrainerOpted = Boolean(payload.isTrainerOpted); + const isInActivityNavigationOpted = Boolean( + payload.isInActivityNavigationOpted, + ); + + const selectedNavigationModeXid = + payload.selectedNavigationModeXid === undefined || + payload.selectedNavigationModeXid === null + ? null + : Number(payload.selectedNavigationModeXid); + + if ( + selectedNavigationModeXid !== null && + (!Number.isInteger(selectedNavigationModeXid) || + selectedNavigationModeXid <= 0) + ) { + throw new ApiError(400, 'selectedNavigationModeXid must be a valid id.'); + } + + const availableFoodTypeIds = new Set( + itineraryActivity.activity.activityFoodTypes.map((item) => item.id), + ); + const availableNavigationModeIds = new Set( + itineraryActivity.activity.ActivityNavigationModes.map((item) => item.id), + ); + const availableEquipmentIds = new Set( + itineraryActivity.activity.ActivityEquipments.map((item) => item.id), + ); + + if (isFoodOpted) { + if (!itineraryActivity.activity.foodAvailable) { + throw new ApiError(400, 'Food is not available for this activity.'); + } + + if ( + itineraryActivity.activity.activityFoodTypes.length > 0 && + !selectedFoodTypeIds.length + ) { + throw new ApiError( + 400, + 'selectedFoodTypeIds is required when food is opted.', + ); + } + + if ( + selectedFoodTypeIds.some((id) => !availableFoodTypeIds.has(id)) + ) { + throw new ApiError( + 400, + 'One or more selected food types do not belong to this activity.', + ); + } + } else if (selectedFoodTypeIds.length) { + throw new ApiError( + 400, + 'selectedFoodTypeIds cannot be sent when food is not opted.', + ); + } + + if (isTrainerOpted && !itineraryActivity.activity.trainerAvailable) { + throw new ApiError(400, 'Trainer is not available for this activity.'); + } + + if (isInActivityNavigationOpted) { + if (!itineraryActivity.activity.inActivityAvailable) { + throw new ApiError( + 400, + 'In-activity navigation is not available for this activity.', + ); + } + + if ( + itineraryActivity.activity.ActivityNavigationModes.length > 0 && + !selectedNavigationModeXid + ) { + throw new ApiError( + 400, + 'selectedNavigationModeXid is required when navigation is opted.', + ); + } + + if ( + selectedNavigationModeXid && + !availableNavigationModeIds.has(selectedNavigationModeXid) + ) { + throw new ApiError( + 400, + 'Selected navigation mode does not belong to this activity.', + ); + } + } else if (selectedNavigationModeXid) { + throw new ApiError( + 400, + 'selectedNavigationModeXid cannot be sent when navigation is not opted.', + ); + } + + if (selectedEquipmentIds.length) { + if (!itineraryActivity.activity.equipmentAvailable) { + throw new ApiError(400, 'Equipment is not available for this activity.'); + } + + if ( + selectedEquipmentIds.some((id) => !availableEquipmentIds.has(id)) + ) { + throw new ApiError( + 400, + 'One or more selected equipments do not belong to this activity.', + ); + } + } + + const selection = await tx.itineraryActivitySelection.upsert({ + where: { + itineraryActivityXid_itineraryMemberXid: { + itineraryActivityXid: itineraryActivity.id, + itineraryMemberXid: itineraryMember.id, + }, + }, + create: { + itineraryActivityXid: itineraryActivity.id, + itineraryMemberXid: itineraryMember.id, + isFoodOpted, + isTrainerOpted, + isInActivityNavigationOpted, + activityNavigationModeXid: isInActivityNavigationOpted + ? selectedNavigationModeXid + : null, + isActive: true, + }, + update: { + isFoodOpted, + isTrainerOpted, + isInActivityNavigationOpted, + activityNavigationModeXid: isInActivityNavigationOpted + ? selectedNavigationModeXid + : null, + isActive: true, + deletedAt: null, + }, + select: { + id: true, + }, + }); + + await tx.itineraryActivitySelectionFoodType.deleteMany({ + where: { + itineraryActivitySelectionXid: selection.id, + }, + }); + + await tx.itineraryActivitySelectionEquipment.deleteMany({ + where: { + itineraryActivitySelectionXid: selection.id, + }, + }); + + if (selectedFoodTypeIds.length) { + await tx.itineraryActivitySelectionFoodType.createMany({ + data: selectedFoodTypeIds.map((activityFoodTypeXid) => ({ + itineraryActivitySelectionXid: selection.id, + activityFoodTypeXid, + })), + }); + } + + if (selectedEquipmentIds.length) { + await tx.itineraryActivitySelectionEquipment.createMany({ + data: selectedEquipmentIds.map((activityEquipmentXid) => ({ + itineraryActivitySelectionXid: selection.id, + activityEquipmentXid, + })), + }); + } + + return tx.itineraryActivitySelection.findUnique({ + where: { + id: selection.id, + }, + select: { + id: true, + itineraryActivityXid: true, + itineraryMemberXid: true, + isFoodOpted: true, + isTrainerOpted: true, + isInActivityNavigationOpted: true, + activityNavigationModeXid: true, + selectedFoodTypes: { + where: { + isActive: true, + deletedAt: null, + }, + select: { + id: true, + activityFoodTypeXid: true, + activityFoodType: { + select: { + id: true, + foodTypeXid: true, + foodType: { + select: { + id: true, + foodTypeName: true, + }, + }, + }, + }, + }, + }, + activityNavigationMode: { + select: { + id: true, + navigationModeName: true, + isInActivityChargeable: true, + navigationModesBasePrice: true, + navigationModesTotalPrice: true, + }, + }, + selectedEquipments: { + where: { + isActive: true, + deletedAt: null, + }, + select: { + id: true, + activityEquipmentXid: true, + activityEquipment: { + select: { + id: true, + equipmentName: true, + isEquipmentChargeable: true, + equipmentBasePrice: true, + equipmentTotalPrice: true, + }, + }, + }, + }, + }, + }); + }); + } + async getMatchingBucketInterestedActivities( userXid: number, payload: {