import stringify from "json-stable-stringify-without-jsonify" import { getCacheClient } from "@scandic-hotels/common/dataCache" import { createCounter } from "@scandic-hotels/common/telemetry" import * as api from "@scandic-hotels/trpc/api" import { RoomPackageCodeEnum } from "@scandic-hotels/trpc/enums/roomFilter" import { AvailabilityEnum } from "@scandic-hotels/trpc/enums/selectHotel" import { badRequestError } from "@scandic-hotels/trpc/errors" import { type RoomFeaturesInput } from "@scandic-hotels/trpc/routers/hotels/input" import { hotelsAvailabilitySchema, packagesSchema, roomFeaturesSchema, roomsAvailabilitySchema, } from "@scandic-hotels/trpc/routers/hotels/output" import { toApiLang } from "@scandic-hotels/trpc/utils" import { sortRoomConfigs } from "@scandic-hotels/trpc/utils/sortRoomConfigs" import { BookingErrorCodeEnum, REDEMPTION } from "@/constants/booking" import { selectRate } from "@/constants/routes/hotelReservation" import { env } from "@/env/server" import { generateChildrenString } from "@/components/HotelReservation/utils" import type { Room as RoomCategory } from "@scandic-hotels/trpc/types/hotel" import type { Product, Products, RateDefinition, RedemptionsProduct, RoomConfiguration, } from "@scandic-hotels/trpc/types/roomAvailability" import type { BedTypeSelection } from "@/types/components/hotelReservation/enterDetails/bedType" import type { PackagesOutput } from "@/types/requests/packages" import type { HotelsAvailabilityInputSchema, HotelsByHotelIdsAvailabilityInputSchema, RoomsAvailabilityExtendedInputSchema, RoomsAvailabilityInputRoom, RoomsAvailabilityOutputSchema, } from "@/types/trpc/routers/hotel/availability" export const TWENTYFOUR_HOURS = 60 * 60 * 24 function findProduct(product: Products, rateDefinition: RateDefinition) { if ("corporateCheque" in product) { return product.corporateCheque.rateCode === rateDefinition.rateCode } if (("member" in product && product.member) || "public" in product) { let isMemberRate = false if (product.member) { isMemberRate = product.member.rateCode === rateDefinition.rateCode } let isPublicRate = false if (product.public) { isPublicRate = product.public.rateCode === rateDefinition.rateCode } return isMemberRate || isPublicRate } if ("voucher" in product) { return product.voucher.rateCode === rateDefinition.rateCode } if (Array.isArray(product)) { return product.find( (r) => r.redemption.rateCode === rateDefinition.rateCode ) } } export async function getHotelsAvailabilityByCity( input: HotelsAvailabilityInputSchema, apiLang: string, token: string, // Either service token or user access token in case of redemption search userPoints: number = 0 ) { const { cityId, roomStayStartDate, roomStayEndDate, adults, children, bookingCode, redemption, } = input const params: Record = { roomStayStartDate, roomStayEndDate, adults, ...(children && { children }), ...(bookingCode && { bookingCode }), ...(redemption ? { isRedemption: "true" } : {}), language: apiLang, } const getHotelsAvailabilityByCityCounter = createCounter( "hotel", "getHotelsAvailabilityByCity" ) const metricsGetHotelsAvailabilityByCity = getHotelsAvailabilityByCityCounter.init({ apiLang, cityId, roomStayStartDate, roomStayEndDate, adults, children, bookingCode, redemption, }) metricsGetHotelsAvailabilityByCity.start() const apiResponse = await api.get( api.endpoints.v1.Availability.city(cityId), { headers: { Authorization: `Bearer ${token}`, }, }, params ) if (!apiResponse.ok) { await metricsGetHotelsAvailabilityByCity.httpError(apiResponse) throw new Error("Failed to fetch hotels availability by city") } const apiJson = await apiResponse.json() const validateAvailabilityData = hotelsAvailabilitySchema.safeParse(apiJson) if (!validateAvailabilityData.success) { metricsGetHotelsAvailabilityByCity.validationError( validateAvailabilityData.error ) throw badRequestError() } if (redemption) { validateAvailabilityData.data.data.forEach((data) => { data.attributes.productType?.redemptions?.forEach((r) => { r.hasEnoughPoints = userPoints >= r.localPrice.pointsPerStay }) }) } const result = { availability: validateAvailabilityData.data.data.flatMap( (hotels) => hotels.attributes ), } metricsGetHotelsAvailabilityByCity.success() return result } export async function getHotelsAvailabilityByHotelIds( input: HotelsByHotelIdsAvailabilityInputSchema, apiLang: string, serviceToken: string ) { const { hotelIds, roomStayStartDate, roomStayEndDate, adults, children, bookingCode, } = input const params = new URLSearchParams([ ["roomStayStartDate", roomStayStartDate], ["roomStayEndDate", roomStayEndDate], ["adults", adults.toString()], ["children", children ?? ""], ["bookingCode", bookingCode], ["language", apiLang], ]) const getHotelsAvailabilityByHotelIdsCounter = createCounter( "hotel", "getHotelsAvailabilityByHotelIds" ) const metricsGetHotelsAvailabilityByHotelIds = getHotelsAvailabilityByHotelIdsCounter.init({ apiLang, hotelIds, roomStayStartDate, roomStayEndDate, adults, children, bookingCode, }) metricsGetHotelsAvailabilityByHotelIds.start() const cacheClient = await getCacheClient() const result = cacheClient.cacheOrGet( `${apiLang}:hotels:availability:${hotelIds.join(",")}:${roomStayStartDate}:${roomStayEndDate}:${adults}:${children}:${bookingCode}`, async () => { /** * Since API expects the params appended and not just * a comma separated string we need to initialize the * SearchParams with a sequence of pairs * (hotelIds=810&hotelIds=879&hotelIds=222 etc.) **/ hotelIds.forEach((hotelId) => params.append("hotelIds", hotelId.toString()) ) const apiResponse = await api.get( api.endpoints.v1.Availability.hotels(), { headers: { Authorization: `Bearer ${serviceToken}`, }, }, params ) if (!apiResponse.ok) { await metricsGetHotelsAvailabilityByHotelIds.httpError(apiResponse) throw new Error("Failed to fetch hotels availability by hotelIds") } const apiJson = await apiResponse.json() const validateAvailabilityData = hotelsAvailabilitySchema.safeParse(apiJson) if (!validateAvailabilityData.success) { metricsGetHotelsAvailabilityByHotelIds.validationError( validateAvailabilityData.error ) throw badRequestError() } return { availability: validateAvailabilityData.data.data.flatMap( (hotels) => hotels.attributes ), } }, env.CACHE_TIME_CITY_SEARCH ) metricsGetHotelsAvailabilityByHotelIds.success() return result } async function getRoomFeaturesInventory( input: RoomFeaturesInput, token: string ) { const { adults, childrenInRoom, endDate, hotelId, roomFeatureCodes, startDate, } = input const params = { adults, hotelId, roomFeatureCode: roomFeatureCodes, roomStayEndDate: endDate, roomStayStartDate: startDate, ...(childrenInRoom?.length && { children: generateChildrenString(childrenInRoom), }), } const getRoomFeaturesInventoryCounter = createCounter( "hotel", "getRoomFeaturesInventory" ) const metricsGetRoomFeaturesInventory = getRoomFeaturesInventoryCounter.init(params) metricsGetRoomFeaturesInventory.start() const cacheClient = await getCacheClient() const result = cacheClient.cacheOrGet( stringify(input), async function () { const apiResponse = await api.get( api.endpoints.v1.Availability.roomFeatures(hotelId), { headers: { Authorization: `Bearer ${token}`, }, }, params ) if (!apiResponse.ok) { await metricsGetRoomFeaturesInventory.httpError(apiResponse) return null } const data = await apiResponse.json() const validatedRoomFeaturesData = roomFeaturesSchema.safeParse(data) if (!validatedRoomFeaturesData.success) { metricsGetRoomFeaturesInventory.validationError( validatedRoomFeaturesData.error ) return null } return validatedRoomFeaturesData.data }, "5m" ) metricsGetRoomFeaturesInventory.success() return result } export async function getPackages(input: PackagesOutput, serviceToken: string) { const { adults, children, endDate, hotelId, lang, packageCodes, startDate } = input const getPackagesCounter = createCounter("hotel", "getPackages") const metricsGetPackages = getPackagesCounter.init({ input, }) metricsGetPackages.start() const cacheClient = await getCacheClient() const result = cacheClient.cacheOrGet( stringify(input), async function () { const apiLang = toApiLang(lang) const searchParams = new URLSearchParams({ adults: adults.toString(), children: children.toString(), endDate, language: apiLang, startDate, }) packageCodes.forEach((code) => { searchParams.append("packageCodes", code) }) const apiResponse = await api.get( api.endpoints.v1.Package.Packages.hotel(hotelId), { headers: { Authorization: `Bearer ${serviceToken}`, }, }, searchParams ) if (!apiResponse.ok) { await metricsGetPackages.httpError(apiResponse) return null } const apiJson = await apiResponse.json() const validatedPackagesData = packagesSchema.safeParse(apiJson) if (!validatedPackagesData.success) { metricsGetPackages.validationError(validatedPackagesData.error) return null } return validatedPackagesData.data }, "3h" ) metricsGetPackages.success() return result } export async function getRoomsAvailability( input: RoomsAvailabilityOutputSchema, token: string, serviceToken: string, userPoints: number | undefined ) { const { booking: { bookingCode, fromDate, hotelId, rooms, searchType, toDate }, lang, } = input const redemption = searchType === REDEMPTION const getRoomsAvailabilityCounter = createCounter( "hotel", "getRoomsAvailability" ) const metricsGetRoomsAvailability = getRoomsAvailabilityCounter.init({ input, redemption, }) metricsGetRoomsAvailability.start() const apiLang = toApiLang(lang) const baseCacheKey = { bookingCode, fromDate, hotelId, lang, searchType, toDate, } const cacheClient = await getCacheClient() const availabilityResponses = await Promise.allSettled( rooms.map((room: RoomsAvailabilityInputRoom) => { const cacheKey = { ...baseCacheKey, room, } const result = cacheClient.cacheOrGet( stringify(cacheKey), async function () { { const params = { adults: room.adults, language: apiLang, roomStayStartDate: fromDate, roomStayEndDate: toDate, ...(room.childrenInRoom?.length && { children: generateChildrenString(room.childrenInRoom), }), ...(room.bookingCode && { bookingCode: room.bookingCode }), ...(redemption && { isRedemption: "true" }), } const apiResponse = await api.get( api.endpoints.v1.Availability.hotel(hotelId), { cache: undefined, // overwrite default headers: { Authorization: `Bearer ${token}`, }, }, params ) if (!apiResponse.ok) { await metricsGetRoomsAvailability.httpError(apiResponse) const text = await apiResponse.text() return { error: "http_error", details: text } } const apiJson = await apiResponse.json() const validateAvailabilityData = roomsAvailabilitySchema.safeParse(apiJson) if (!validateAvailabilityData.success) { metricsGetRoomsAvailability.validationError( validateAvailabilityData.error ) return { error: "validation_error", details: validateAvailabilityData.error, } } if (redemption) { for (const roomConfig of validateAvailabilityData.data .roomConfigurations) { for (const product of roomConfig.redemptions) { if (userPoints) { product.redemption.hasEnoughPoints = userPoints >= product.redemption.localPrice.pointsPerStay } } } } const roomFeatures = await getPackages( { adults: room.adults, children: room.childrenInRoom?.length || 0, endDate: input.booking.toDate, hotelId: input.booking.hotelId, lang, packageCodes: [ RoomPackageCodeEnum.ACCESSIBILITY_ROOM, RoomPackageCodeEnum.ALLERGY_ROOM, RoomPackageCodeEnum.PET_ROOM, ], startDate: input.booking.fromDate, }, serviceToken ) if (roomFeatures) { validateAvailabilityData.data.packages = roomFeatures } // Fetch packages if (room.packages?.length) { const roomFeaturesInventory = await getRoomFeaturesInventory( { adults: room.adults, childrenInRoom: room.childrenInRoom, endDate: input.booking.toDate, hotelId: input.booking.hotelId, lang, roomFeatureCodes: room.packages, startDate: input.booking.fromDate, }, serviceToken ) if (roomFeaturesInventory) { const features = roomFeaturesInventory.reduce< Record >((fts, feat) => { fts[feat.roomTypeCode] = feat.features?.[0]?.inventory ?? 0 return fts }, {}) const updatedRoomConfigurations = validateAvailabilityData.data.roomConfigurations // This filter is needed since we can get availability // back from roomFeatures yet the availability call // says there are no rooms left... .filter((rc) => rc.roomsLeft) .filter((rc) => features?.[rc.roomTypeCode]) .map((rc) => ({ ...rc, roomsLeft: features[rc.roomTypeCode], status: AvailabilityEnum.Available, })) validateAvailabilityData.data.roomConfigurations = updatedRoomConfigurations } } return validateAvailabilityData.data } }, "1m" ) return result }) ) const data = availabilityResponses.map((availability) => { if (availability.status === "fulfilled") { return availability.value } return { details: availability.reason, error: "request_failure", } }) metricsGetRoomsAvailability.success() return data } export function getSelectedRoomAvailability( rateCode: string, rateDefinitions: RateDefinition[], roomConfigurations: RoomConfiguration[], roomTypeCode: string, userPoints: number | undefined ) { const rateDefinition = rateDefinitions.find((rd) => rd.rateCode === rateCode) if (!rateDefinition) { return null } const selectedRoom = roomConfigurations.find( (room) => room.roomTypeCode === roomTypeCode && room.products.find((product) => findProduct(product, rateDefinition)) ) if (!selectedRoom) { return null } let product: Product | RedemptionsProduct | undefined = selectedRoom.products.find((product) => findProduct(product, rateDefinition) ) if (!product) { return null } if (Array.isArray(product)) { const redemptionProduct = userPoints ? product.find( (r) => r.redemption.rateCode === rateDefinition.rateCode && r.redemption.localPrice.pointsPerStay <= userPoints ) : undefined if (!redemptionProduct) { return null } product = redemptionProduct } return { rateDefinition, rateDefinitions, rooms: roomConfigurations, product, selectedRoom, } } export function getBedTypes( rooms: RoomConfiguration[], roomType: string, roomCategories?: RoomCategory[] ) { if (!roomCategories) { return [] } return rooms .filter( (room) => room.status === AvailabilityEnum.Available || room.roomsLeft > 0 ) .filter((room) => room.roomType === roomType) .map((availRoom) => { const matchingRoom = roomCategories ?.find((room) => room.roomTypes .map((roomType) => roomType.code) .includes(availRoom.roomTypeCode) ) ?.roomTypes.find((roomType) => roomType.code === availRoom.roomTypeCode) if (matchingRoom) { return { description: matchingRoom.description, size: matchingRoom.mainBed.widthRange, value: matchingRoom.code, type: matchingRoom.mainBed.type, roomsLeft: availRoom.roomsLeft, extraBed: matchingRoom.fixedExtraBed ? { type: matchingRoom.fixedExtraBed.type, description: matchingRoom.fixedExtraBed.description, } : undefined, } } }) .filter((bed): bed is BedTypeSelection => Boolean(bed)) } export function mergeRoomTypes(roomConfigurations: RoomConfiguration[]) { // Initial sort to guarantee if one bed is NotAvailable and whereas // the other is Available to make sure data is added to the correct // roomConfig roomConfigurations.sort(sortRoomConfigs) const roomConfigs = new Map() for (const roomConfig of roomConfigurations) { if (roomConfigs.has(roomConfig.roomType)) { const currentRoomConf = roomConfigs.get(roomConfig.roomType) if (currentRoomConf) { currentRoomConf.features = roomConfig.features.reduce( (feats, feature) => { const currentFeatureIndex = feats.findIndex( (f) => f.code === feature.code ) if (currentFeatureIndex !== -1) { feats[currentFeatureIndex].inventory = feats[currentFeatureIndex].inventory + feature.inventory } else { feats.push(feature) } return feats }, currentRoomConf.features ) currentRoomConf.roomsLeft = currentRoomConf.roomsLeft + roomConfig.roomsLeft roomConfigs.set(currentRoomConf.roomType, currentRoomConf) } } else { roomConfigs.set(roomConfig.roomType, roomConfig) } } return Array.from(roomConfigs.values()) } export function selectRateRedirectURL( input: RoomsAvailabilityExtendedInputSchema, selectedRooms: boolean[] ) { const searchParams = new URLSearchParams({ errorCode: BookingErrorCodeEnum.AvailabilityError, fromdate: input.booking.fromDate, hotel: input.booking.hotelId, todate: input.booking.toDate, }) if (input.booking.searchType) { searchParams.set("searchtype", input.booking.searchType) } for (const [idx, room] of input.booking.rooms.entries()) { searchParams.set(`room[${idx}].adults`, room.adults.toString()) if (selectedRooms[idx]) { if (room.counterRateCode) { searchParams.set(`room[${idx}].counterratecode`, room.counterRateCode) } searchParams.set(`room[${idx}].ratecode`, room.rateCode) searchParams.set(`room[${idx}].roomtype`, room.roomTypeCode) } else { if (!searchParams.has("modifyRateIndex")) { searchParams.set("modifyRateIndex", idx.toString()) } } if (room.bookingCode) { searchParams.set(`room[${idx}].bookingCode`, room.bookingCode) } if (room.packages) { searchParams.set(`room[${idx}].packages`, room.packages.join(",")) } if (room.childrenInRoom?.length) { for (const [i, kid] of room.childrenInRoom.entries()) { searchParams.set(`room[${idx}].child[${i}].age`, kid.age.toString()) searchParams.set(`room[${idx}].child[${i}].bed`, kid.bed.toString()) } } } return `${selectRate(input.lang)}?${searchParams.toString()}` }