diff --git a/src/modules/user/handlers/itinerary/saveItineraryActivitySelections.ts b/src/modules/user/handlers/itinerary/saveItineraryActivitySelections.ts index 2d1a3be..64dcec1 100644 --- a/src/modules/user/handlers/itinerary/saveItineraryActivitySelections.ts +++ b/src/modules/user/handlers/itinerary/saveItineraryActivitySelections.ts @@ -35,24 +35,14 @@ export const handler = safeHandler(async ( } } - const itineraryActivityXid = Number(body.itineraryActivityXid); - if (!Number.isInteger(itineraryActivityXid) || itineraryActivityXid <= 0) { - throw new ApiError(400, 'itineraryActivityXid is required.'); - } + const activities = Array.isArray(body.activities) + ? body.activities + : body.itineraryActivityXid !== undefined + ? [body] + : []; - 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.'); + if (!activities.length) { + throw new ApiError(400, 'activities is required and must be a non-empty array.'); } const toOptionalId = (value: unknown) => { @@ -68,21 +58,60 @@ export const handler = safeHandler(async ( 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, + const normalizedActivities = activities.map((activity: any, index: number) => { + const itineraryActivityXid = Number(activity.itineraryActivityXid); + if (!Number.isInteger(itineraryActivityXid) || itineraryActivityXid <= 0) { + throw new ApiError(400, `activities[${index}].itineraryActivityXid is required.`); + } + + const selectedFoodTypeIds: number[] = Array.isArray(activity.selectedFoodTypeIds) + ? Array.from( + new Set( + activity.selectedFoodTypeIds.map((id: unknown): number => Number(id)), + ), + ) + : []; + const selectedEquipmentIds: number[] = Array.isArray(activity.selectedEquipmentIds) + ? Array.from( + new Set( + activity.selectedEquipmentIds.map((id: unknown): number => Number(id)), + ), + ) + : []; + + if (selectedEquipmentIds.some((id) => !Number.isInteger(id) || id <= 0)) { + throw new ApiError( + 400, + `activities[${index}].selectedEquipmentIds must contain valid ids.`, + ); + } + + if (selectedFoodTypeIds.some((id) => !Number.isInteger(id) || id <= 0)) { + throw new ApiError( + 400, + `activities[${index}].selectedFoodTypeIds must contain valid ids.`, + ); + } + + return { + itineraryActivityXid, + isFoodOpted: activity.isFoodOpted === undefined ? false : Boolean(activity.isFoodOpted), + selectedFoodTypeIds, + isTrainerOpted: activity.isTrainerOpted === undefined ? false : Boolean(activity.isTrainerOpted), + isInActivityNavigationOpted: + activity.isInActivityNavigationOpted === undefined + ? false + : Boolean(activity.isInActivityNavigationOpted), + selectedNavigationModeXid: toOptionalId(activity.selectedNavigationModeXid), + selectedEquipmentIds, + }; }); + const result = await itineraryService.saveItineraryActivitySelections( + userId, + normalizedActivities, + ); + return { statusCode: 200, headers: { diff --git a/src/modules/user/services/itinerary.service.ts b/src/modules/user/services/itinerary.service.ts index 0b03cac..dabcfa9 100644 --- a/src/modules/user/services/itinerary.service.ts +++ b/src/modules/user/services/itinerary.service.ts @@ -2393,7 +2393,7 @@ export class ItineraryService { async saveItineraryActivitySelections( userXid: number, - payload: { + payload: Array<{ itineraryActivityXid: number; isFoodOpted?: boolean; selectedFoodTypeIds?: number[]; @@ -2401,64 +2401,356 @@ export class ItineraryService { 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, + const result = await Promise.all( + payload.map(async (item, index) => { + const selectedFoodTypeIds = Array.from( + new Set((item.selectedFoodTypeIds ?? []).map(Number)), + ); + const selectedEquipmentIds = Array.from( + new Set((item.selectedEquipmentIds ?? []).map(Number)), + ); + + const itineraryActivity = await tx.itineraryActivities.findFirst({ + where: { + id: item.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: { + 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 item ${index}.`, + ); + } + + 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, + `activities[${index}].selectedEquipmentIds must contain valid ids.`, + ); + } + + if (selectedFoodTypeIds.some((id) => !Number.isInteger(id) || id <= 0)) { + throw new ApiError( + 400, + `activities[${index}].selectedFoodTypeIds must contain valid ids.`, + ); + } + + const isFoodOpted = Boolean(item.isFoodOpted); + const isTrainerOpted = Boolean(item.isTrainerOpted); + const isInActivityNavigationOpted = Boolean( + item.isInActivityNavigationOpted, + ); + + const selectedNavigationModeXid = + item.selectedNavigationModeXid === undefined || + item.selectedNavigationModeXid === null + ? null + : Number(item.selectedNavigationModeXid); + + if ( + selectedNavigationModeXid !== null && + (!Number.isInteger(selectedNavigationModeXid) || + selectedNavigationModeXid <= 0) + ) { + throw new ApiError( + 400, + `activities[${index}].selectedNavigationModeXid must be a valid id.`, + ); + } + + const availableFoodTypeIds = new Set( + itineraryActivity.activity.activityFoodTypes.map((entry) => entry.id), + ); + const availableNavigationModeIds = new Set( + itineraryActivity.activity.ActivityNavigationModes.map((entry) => entry.id), + ); + const availableEquipmentIds = new Set( + itineraryActivity.activity.ActivityEquipments.map((entry) => entry.id), + ); + + if (isFoodOpted) { + if (!itineraryActivity.activity.foodAvailable) { + throw new ApiError(400, `activities[${index}]: Food is not available for this activity.`); + } + + if ( + itineraryActivity.activity.activityFoodTypes.length > 0 && + !selectedFoodTypeIds.length + ) { + throw new ApiError( + 400, + `activities[${index}].selectedFoodTypeIds is required when food is opted.`, + ); + } + + if (selectedFoodTypeIds.some((id) => !availableFoodTypeIds.has(id))) { + throw new ApiError( + 400, + `activities[${index}]: One or more selected food types do not belong to this activity.`, + ); + } + } else if (selectedFoodTypeIds.length) { + throw new ApiError( + 400, + `activities[${index}].selectedFoodTypeIds cannot be sent when food is not opted.`, + ); + } + + if (isTrainerOpted && !itineraryActivity.activity.trainerAvailable) { + throw new ApiError( + 400, + `activities[${index}]: Trainer is not available for this activity.`, + ); + } + + if (isInActivityNavigationOpted) { + if (!itineraryActivity.activity.inActivityAvailable) { + throw new ApiError( + 400, + `activities[${index}]: In-activity navigation is not available for this activity.`, + ); + } + + if ( + itineraryActivity.activity.ActivityNavigationModes.length > 0 && + !selectedNavigationModeXid + ) { + throw new ApiError( + 400, + `activities[${index}].selectedNavigationModeXid is required when navigation is opted.`, + ); + } + + if ( + selectedNavigationModeXid && + !availableNavigationModeIds.has(selectedNavigationModeXid) + ) { + throw new ApiError( + 400, + `activities[${index}]: Selected navigation mode does not belong to this activity.`, + ); + } + } else if (selectedNavigationModeXid) { + throw new ApiError( + 400, + `activities[${index}].selectedNavigationModeXid cannot be sent when navigation is not opted.`, + ); + } + + if (selectedEquipmentIds.length) { + if (!itineraryActivity.activity.equipmentAvailable) { + throw new ApiError( + 400, + `activities[${index}]: Equipment is not available for this activity.`, + ); + } + + if (selectedEquipmentIds.some((id) => !availableEquipmentIds.has(id))) { + throw new ApiError( + 400, + `activities[${index}]: 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, - foodTypeXid: true, - foodType: { + activityFoodTypeXid: true, + activityFoodType: { select: { id: true, - foodTypeName: true, + foodTypeXid: true, + foodType: { + select: { + id: true, + foodTypeName: true, + }, + }, }, }, }, }, - ActivityNavigationModes: { - where: { - isActive: true, - deletedAt: null, - }, + activityNavigationMode: { select: { id: true, navigationModeName: true, @@ -2467,303 +2759,31 @@ export class ItineraryService { navigationModesTotalPrice: true, }, }, - ActivityEquipments: { + selectedEquipments: { 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: { + activityEquipmentXid: true, + activityEquipment: { select: { id: true, - foodTypeName: true, + equipmentName: true, + isEquipmentChargeable: true, + equipmentBasePrice: true, + equipmentTotalPrice: 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, - }, - }, - }, - }, - }, - }); + }); + }), + ); + + return result; }); }