import deepmerge from "deepmerge" import stringify from "json-stable-stringify-without-jsonify" import { REDEMPTION } from "@/constants/booking" import { Lang } from "@/constants/languages" import { env } from "@/env/server" import * as api from "@/lib/api" import { badRequestError } from "@/server/errors/trpc" import { toApiLang } from "@/server/utils" import { generateChildrenString } from "@/components/HotelReservation/utils" import { getCacheClient } from "@/services/dataCache" import { cache } from "@/utils/cache" import { getHotelPageUrls } from "../contentstack/hotelPage/utils" import { type RoomFeaturesInput, roomPackagesInputSchema } from "./input" import { metrics } from "./metrics" import { type Cities, citiesByCountrySchema, citiesSchema, countriesSchema, getHotelIdsSchema, hotelsAvailabilitySchema, hotelSchema, locationsSchema, packagesSchema, roomFeaturesSchema, roomsAvailabilitySchema, } from "./output" import type { BedTypeSelection } from "@/types/components/hotelReservation/enterDetails/bedType" import { AvailabilityEnum } from "@/types/components/hotelReservation/selectHotel/selectHotel" import { RoomPackageCodeEnum } from "@/types/components/hotelReservation/selectRate/roomFilter" import { HotelTypeEnum } from "@/types/enums/hotelType" import { PointOfInterestGroupEnum } from "@/types/enums/pointOfInterest" import type { DestinationPagesHotelData, Room as RoomCategory, } from "@/types/hotel" import type { PackagesInput } from "@/types/requests/packages" import type { HotelsAvailabilityInputSchema, HotelsByHotelIdsAvailabilityInputSchema, RoomsAvailabilityInputRoom, RoomsAvailabilityInputSchema, } from "@/types/trpc/routers/hotel/availability" import type { HotelInput } from "@/types/trpc/routers/hotel/hotel" import type { CitiesGroupedByCountry, CityLocation, } from "@/types/trpc/routers/hotel/locations" import type { Product, Products, RateDefinition, RedemptionsProduct, RoomConfiguration, } from "@/types/trpc/routers/hotel/roomAvailability" import type { Endpoint } from "@/lib/api/endpoints" export function getPoiGroupByCategoryName(category: string | undefined) { if (!category) return PointOfInterestGroupEnum.LOCATION switch (category) { case "Airport": case "Bus terminal": case "Transportations": return PointOfInterestGroupEnum.PUBLIC_TRANSPORT case "Amusement park": case "Museum": case "Sports": case "Theatre": case "Tourist": case "Zoo": return PointOfInterestGroupEnum.ATTRACTIONS case "Nearby companies": case "Fair": return PointOfInterestGroupEnum.BUSINESS case "Parking / Garage": return PointOfInterestGroupEnum.PARKING case "Shopping": case "Restaurant": return PointOfInterestGroupEnum.SHOPPING_DINING case "Hospital": default: return PointOfInterestGroupEnum.LOCATION } } export const locationsAffix = "locations" export const TWENTYFOUR_HOURS = 60 * 60 * 24 export async function getCity({ cityUrl, serviceToken, }: { cityUrl: string serviceToken: string }): Promise { const cacheClient = await getCacheClient() return await cacheClient.cacheOrGet( cityUrl, async () => { const url = new URL(cityUrl) const cityResponse = await api.get( url.pathname as Endpoint, { headers: { Authorization: `Bearer ${serviceToken}` } }, url.searchParams ) if (!cityResponse.ok) { return null } const cityJson = await cityResponse.json() const city = citiesSchema.safeParse(cityJson) if (!city.success) { console.info(`Validation of city failed`) console.info(`cityUrl: ${cityUrl}`) console.error(city.error) return null } return city.data }, "1d" ) } export async function getCountries({ lang, serviceToken, }: { lang: Lang serviceToken: string }) { const cacheClient = await getCacheClient() return await cacheClient.cacheOrGet( `${lang}:${locationsAffix}:countries`, async () => { const params = new URLSearchParams({ language: toApiLang(lang), }) const countryResponse = await api.get( api.endpoints.v1.Hotel.countries, { headers: { Authorization: `Bearer ${serviceToken}`, }, }, params ) if (!countryResponse.ok) { throw new Error("Unable to fetch countries") } const countriesJson = await countryResponse.json() const countries = countriesSchema.safeParse(countriesJson) if (!countries.success) { console.info(`Validation for countries failed`) console.error(countries.error) return null } return countries.data }, "1d" ) } export async function getCitiesByCountry({ countries, lang, onlyPublished = false, affix = locationsAffix, serviceToken, }: { countries: string[] lang: Lang onlyPublished?: boolean // false by default as it might be used in other places affix?: string serviceToken: string }): Promise { const cacheClient = await getCacheClient() const allCitiesByCountries = await Promise.all( countries.map(async (country) => { return cacheClient.cacheOrGet( `${lang}:${affix}:cities-by-country:${country}`, async () => { const params = new URLSearchParams({ language: toApiLang(lang), }) const countryResponse = await api.get( api.endpoints.v1.Hotel.Cities.country(country), { headers: { Authorization: `Bearer ${serviceToken}`, }, }, params ) if (!countryResponse.ok) { throw new Error(`Unable to fetch cities by country ${country}`) } const countryJson = await countryResponse.json() const citiesByCountry = citiesByCountrySchema.safeParse(countryJson) if (!citiesByCountry.success) { console.error(`Unable to parse cities by country ${country}`) console.error(citiesByCountry.error) throw new Error(`Unable to parse cities by country ${country}`) } return { ...citiesByCountry.data, country } }, "1d" ) }) ) const filteredCitiesByCountries = allCitiesByCountries.map((country) => ({ ...country, data: onlyPublished ? country.data.filter((city) => city.isPublished) : country.data, })) const groupedCitiesByCountry: CitiesGroupedByCountry = filteredCitiesByCountries.reduce((acc, { country, data }) => { acc[country] = data return acc }, {} as CitiesGroupedByCountry) return groupedCitiesByCountry } export async function getLocations({ lang, citiesByCountry, serviceToken, }: { lang: Lang citiesByCountry: CitiesGroupedByCountry | null serviceToken: string }) { const cacheClient = await getCacheClient() return await cacheClient.cacheOrGet( `${lang}:locations`.toLowerCase(), async () => { const params = new URLSearchParams({ language: toApiLang(lang), }) const apiResponse = await api.get( api.endpoints.v1.Hotel.locations, { headers: { Authorization: `Bearer ${serviceToken}`, }, }, params ) if (!apiResponse.ok) { if (apiResponse.status === 401) { throw new Error("unauthorized") } else if (apiResponse.status === 403) { throw new Error("forbidden") } throw new Error("downstream error") } const apiJson = await apiResponse.json() const verifiedLocations = locationsSchema.safeParse(apiJson) if (!verifiedLocations.success) { console.info(`Locations Verification Failed`) console.error(verifiedLocations.error) throw new Error("Unable to parse locations") } return await Promise.all( verifiedLocations.data.data.map(async (location) => { if (location.type === "cities") { if (citiesByCountry) { const country = Object.keys(citiesByCountry).find((country) => citiesByCountry[country].find( (loc) => loc.name === location.name ) ) if (country) { return { ...location, country, } } else { console.info( `Location cannot be found in any of the countries cities` ) console.info(location) } } } else if (location.type === "hotels") { if (location.relationships.city?.url) { const city = await getCity({ cityUrl: location.relationships.city.url, serviceToken, }) if (city) { return deepmerge(location, { relationships: { city, }, }) } } } return location }) ) }, "1d" ) } export async function getHotelIdsByCityId({ cityId, serviceToken, }: { cityId: string serviceToken: string }) { const cacheClient = await getCacheClient() return await cacheClient.cacheOrGet( `${cityId}:hotelsByCityId`, async () => { const searchParams = new URLSearchParams({ city: cityId, }) metrics.hotelIds.counter.add(1, { params: searchParams.toString() }) console.info( "api.hotel.hotel-ids start", JSON.stringify({ params: searchParams.toString() }) ) const apiResponse = await api.get( api.endpoints.v1.Hotel.hotels, { headers: { Authorization: `Bearer ${serviceToken}`, }, }, searchParams ) if (!apiResponse.ok) { const responseMessage = await apiResponse.text() metrics.hotelIds.fail.add(1, { params: searchParams.toString(), error_type: "http_error", error: responseMessage, }) console.error( "api.hotel.hotel-ids fetch error", JSON.stringify({ params: searchParams.toString(), error: { status: apiResponse.status, statusText: apiResponse.statusText, text: responseMessage, }, }) ) throw new Error("Unable to fetch hotelIds by cityId") } const apiJson = await apiResponse.json() const validatedHotelIds = getHotelIdsSchema.safeParse(apiJson) if (!validatedHotelIds.success) { metrics.hotelIds.fail.add(1, { params: searchParams.toString(), error_type: "validation_error", error: JSON.stringify(validatedHotelIds.error), }) console.error( "api.hotel.hotel-ids validation error", JSON.stringify({ params: searchParams.toString(), error: validatedHotelIds.error, }) ) throw new Error("Unable to parse data for hotelIds by cityId") } metrics.hotelIds.success.add(1, { cityId }) console.info( "api.hotel.hotel-ids success", JSON.stringify({ params: searchParams.toString(), response: validatedHotelIds.data, }) ) return validatedHotelIds.data }, env.CACHE_TIME_HOTELS ) } export async function getHotelIdsByCountry({ country, serviceToken, }: { country: string serviceToken: string }) { const cacheClient = await getCacheClient() return await cacheClient.cacheOrGet( `${country}:hotelsByCountry`, async () => { metrics.hotelIds.counter.add(1, { country }) console.info( "api.hotel.hotel-ids start", JSON.stringify({ query: { country } }) ) const hotelIdsParams = new URLSearchParams({ country, }) const apiResponse = await api.get( api.endpoints.v1.Hotel.hotels, { headers: { Authorization: `Bearer ${serviceToken}`, }, }, hotelIdsParams ) if (!apiResponse.ok) { const responseMessage = await apiResponse.text() metrics.hotelIds.fail.add(1, { country, error_type: "http_error", error: responseMessage, }) console.error( "api.hotel.hotel-ids fetch error", JSON.stringify({ query: { country }, error: { status: apiResponse.status, statusText: apiResponse.statusText, text: responseMessage, }, }) ) throw new Error("Unable to fetch hotelIds by country") } const apiJson = await apiResponse.json() const validatedHotelIds = getHotelIdsSchema.safeParse(apiJson) if (!validatedHotelIds.success) { metrics.hotelIds.fail.add(1, { country, error_type: "validation_error", error: JSON.stringify(validatedHotelIds.error), }) console.error( "api.hotel.hotel-ids validation error", JSON.stringify({ query: { country }, error: validatedHotelIds.error, }) ) throw new Error("Unable to parse hotelIds by country") } metrics.hotelIds.success.add(1, { country }) console.info( "api.hotel.hotel-ids success", JSON.stringify({ query: { country } }) ) return validatedHotelIds.data }, env.CACHE_TIME_HOTELS ) } export async function getHotelIdsByCityIdentifier( cityIdentifier: string, serviceToken: string ) { const city = await getCityByCityIdentifier({ cityIdentifier, lang: Lang.en, serviceToken, }) if (!city) { return [] } const hotelIds = await getHotelIdsByCityId({ cityId: city.id, serviceToken, }) return hotelIds } export async function getCityByCityIdentifier({ cityIdentifier, lang, serviceToken, }: { cityIdentifier: string lang: Lang serviceToken: string }) { const locations = await getLocations({ lang, citiesByCountry: null, serviceToken, }) if (!locations || "error" in locations) { return null } const city = locations .filter((loc): loc is CityLocation => loc.type === "cities") .find((loc) => loc.cityIdentifier === cityIdentifier) return city ?? null } export async function getHotelsByHotelIds({ hotelIds, lang, serviceToken, }: { hotelIds: string[] lang: Lang serviceToken: string }) { const cacheClient = await getCacheClient() const cacheKey = `${lang}:getHotelsByHotelIds:hotels:${hotelIds.sort().join(",")}` return await cacheClient.cacheOrGet( cacheKey, async () => { const hotelPages = await getHotelPageUrls(lang) const hotels = await Promise.all( hotelIds.map(async (hotelId) => { const hotelResponse = await getHotel( { hotelId, language: lang, isCardOnlyPayment: false }, serviceToken ) if (!hotelResponse) { throw new Error(`Hotel not found: ${hotelId}`) } const hotelPage = hotelPages.find((page) => page.hotelId === hotelId) const { hotel, cities } = hotelResponse const data: DestinationPagesHotelData = { hotel: { id: hotel.id, galleryImages: hotel.galleryImages?.length ? [hotel.galleryImages[0]] : [], name: hotel.name, tripadvisor: hotel.ratings?.tripAdvisor?.rating, detailedFacilities: hotel.detailedFacilities?.slice(0, 3) || [], location: hotel.location, hotelType: hotel.hotelType, type: hotel.type, address: hotel.address, cityIdentifier: cities?.[0]?.cityIdentifier, hotelDescription: hotel.hotelContent?.texts.descriptions?.short, }, url: hotelPage?.url ?? "", } return { ...data, url: hotelPage?.url ?? null } }) ) return hotels.filter( (hotel): hotel is DestinationPagesHotelData => !!hotel ) }, "1d" ) } 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 const getHotel = cache( async (input: HotelInput, serviceToken: string) => { const callable = async function ( hotelId: HotelInput["hotelId"], language: HotelInput["language"], isCardOnlyPayment?: HotelInput["isCardOnlyPayment"] ) { /** * Since API expects the params appended and not just * a comma separated string we need to initialize the * SearchParams with a sequence of pairs * (include=City&include=NearbyHotels&include=Restaurants etc.) **/ const params = new URLSearchParams([ ["include", "AdditionalData"], ["include", "City"], ["include", "NearbyHotels"], ["include", "Restaurants"], ["include", "RoomCategories"], ["language", toApiLang(language)], ]) metrics.hotel.counter.add(1, { hotelId, language, }) console.info( "api.hotels.hotelData start", JSON.stringify({ query: { hotelId, params: params.toString() } }) ) const apiResponse = await api.get( api.endpoints.v1.Hotel.Hotels.hotel(hotelId), { headers: { Authorization: `Bearer ${serviceToken}`, }, }, params ) if (!apiResponse.ok) { const text = await apiResponse.text() metrics.hotel.fail.add(1, { hotelId, language, error_type: "http_error", error: JSON.stringify({ status: apiResponse.status, statusText: apiResponse.statusText, text, }), }) console.error( "api.hotels.hotelData error", JSON.stringify({ query: { hotelId, params: params.toString() }, error: { status: apiResponse.status, statusText: apiResponse.statusText, text, }, }) ) return null } const apiJson = await apiResponse.json() const validateHotelData = hotelSchema.safeParse(apiJson) if (!validateHotelData.success) { metrics.hotel.fail.add(1, { hotelId, language, error_type: "validation_error", error: JSON.stringify(validateHotelData.error), }) console.error( "api.hotels.hotelData validation error", JSON.stringify({ query: { hotelId, params: params.toString() }, error: validateHotelData.error, }) ) throw badRequestError() } metrics.hotel.success.add(1, { hotelId, language, }) console.info( "api.hotels.hotelData success", JSON.stringify({ query: { hotelId, params: params.toString() }, }) ) const hotelData = validateHotelData.data if (isCardOnlyPayment) { hotelData.hotel.merchantInformationData.alternatePaymentOptions = [] } const gallery = hotelData.additionalData?.gallery if (gallery) { const smallerImages = gallery.smallerImages const hotelGalleryImages = hotelData.hotel.hotelType === HotelTypeEnum.Signature ? smallerImages.slice(0, 10) : smallerImages.slice(0, 6) hotelData.hotel.galleryImages = hotelGalleryImages } return hotelData } const cacheClient = await getCacheClient() return await cacheClient.cacheOrGet( `${input.language}:hotel:${input.hotelId}:${!!input.isCardOnlyPayment}`, async () => { return callable(input.hotelId, input.language, input.isCardOnlyPayment) }, env.CACHE_TIME_HOTELS ) } ) 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, } metrics.hotelsAvailability.counter.add(1, { cityId, roomStayStartDate, roomStayEndDate, adults, children, bookingCode, redemption, }) console.info( "api.hotels.hotelsAvailability start", JSON.stringify({ query: { cityId, params } }) ) const apiResponse = await api.get( api.endpoints.v1.Availability.city(cityId), { headers: { Authorization: `Bearer ${token}`, }, }, params ) if (!apiResponse.ok) { const text = await apiResponse.text() metrics.hotelsAvailability.fail.add(1, { cityId, roomStayStartDate, roomStayEndDate, adults, children, bookingCode, error_type: "http_error", error: JSON.stringify({ status: apiResponse.status, statusText: apiResponse.statusText, text, }), }) console.error( "api.hotels.hotelsAvailability error", JSON.stringify({ query: { cityId, params }, error: { status: apiResponse.status, statusText: apiResponse.statusText, text, }, }) ) throw new Error("Failed to fetch hotels availability by city") } const apiJson = await apiResponse.json() const validateAvailabilityData = hotelsAvailabilitySchema.safeParse(apiJson) if (!validateAvailabilityData.success) { metrics.hotelsAvailability.fail.add(1, { cityId, roomStayStartDate, roomStayEndDate, adults, children, bookingCode, redemption, error_type: "validation_error", error: JSON.stringify(validateAvailabilityData.error), }) console.error( "api.hotels.hotelsAvailability validation error", JSON.stringify({ query: { cityId, params }, error: validateAvailabilityData.error, }) ) throw badRequestError() } metrics.hotelsAvailability.success.add(1, { cityId, roomStayStartDate, roomStayEndDate, adults, children, bookingCode, redemption, }) console.info( "api.hotels.hotelsAvailability success", JSON.stringify({ query: { cityId, params: params }, }) ) if (redemption) { validateAvailabilityData.data.data.forEach((data) => { data.attributes.productType?.redemptions?.forEach((r) => { r.hasEnoughPoints = userPoints >= r.localPrice.pointsPerStay }) }) } return { availability: validateAvailabilityData.data.data.flatMap( (hotels) => hotels.attributes ), } } 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 cacheClient = await getCacheClient() return 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()) ) metrics.hotelsByHotelIdAvailability.counter.add(1, { hotelIds, roomStayStartDate, roomStayEndDate, adults, children, bookingCode, }) console.info( "api.hotels.hotelsByHotelIdAvailability start", JSON.stringify({ query: { params } }) ) const apiResponse = await api.get( api.endpoints.v1.Availability.hotels(), { headers: { Authorization: `Bearer ${serviceToken}`, }, }, params ) if (!apiResponse.ok) { const text = await apiResponse.text() metrics.hotelsByHotelIdAvailability.fail.add(1, { hotelIds, roomStayStartDate, roomStayEndDate, adults, children, bookingCode, error_type: "http_error", error: JSON.stringify({ status: apiResponse.status, statusText: apiResponse.statusText, text, }), }) console.error( "api.hotels.hotelsByHotelIdAvailability error", JSON.stringify({ query: { params }, error: { status: apiResponse.status, statusText: apiResponse.statusText, text, }, }) ) throw new Error("Failed to fetch hotels availability by hotelIds") } const apiJson = await apiResponse.json() const validateAvailabilityData = hotelsAvailabilitySchema.safeParse(apiJson) if (!validateAvailabilityData.success) { metrics.hotelsByHotelIdAvailability.fail.add(1, { hotelIds, roomStayStartDate, roomStayEndDate, adults, children, bookingCode, error_type: "validation_error", error: JSON.stringify(validateAvailabilityData.error), }) console.error( "api.hotels.hotelsByHotelIdAvailability validation error", JSON.stringify({ query: { params }, error: validateAvailabilityData.error, }) ) throw badRequestError() } metrics.hotelsByHotelIdAvailability.success.add(1, { hotelIds, roomStayStartDate, roomStayEndDate, adults, children, bookingCode, }) console.info( "api.hotels.hotelsByHotelIdAvailability success", JSON.stringify({ query: { params }, }) ) return { availability: validateAvailabilityData.data.data.flatMap( (hotels) => hotels.attributes ), } }, env.CACHE_TIME_CITY_SEARCH ) } async function getRoomFeaturesInventory( input: RoomFeaturesInput, token: string ) { const { adults, childrenInRoom, endDate, hotelId, roomFeatureCodes, startDate, } = input const cacheClient = await getCacheClient() return cacheClient.cacheOrGet( stringify(input), async function () { const params = { adults, hotelId, roomFeatureCode: roomFeatureCodes, roomStayEndDate: endDate, roomStayStartDate: startDate, ...(childrenInRoom?.length && { children: generateChildrenString(childrenInRoom), }), } metrics.roomFeatures.counter.add(1, params) const apiResponse = await api.get( api.endpoints.v1.Availability.roomFeatures(hotelId), { headers: { Authorization: `Bearer ${token}`, }, }, params ) if (!apiResponse.ok) { const text = apiResponse.text() console.error( "api.availability.roomfeature error", JSON.stringify({ query: { hotelId, params }, error: { status: apiResponse.status, statusText: apiResponse.statusText, text, }, }) ) metrics.roomFeatures.fail.add(1, params) return null } const data = await apiResponse.json() const validatedRoomFeaturesData = roomFeaturesSchema.safeParse(data) if (!validatedRoomFeaturesData.success) { console.error( "api.availability.roomfeature error", JSON.stringify({ query: { hotelId, params }, error: validatedRoomFeaturesData.error, }) ) return null } metrics.roomFeatures.success.add(1, params) return validatedRoomFeaturesData.data }, "5m" ) } export async function getPackages( rawInput: PackagesInput, serviceToken: string ) { const parsedInput = roomPackagesInputSchema.safeParse(rawInput) if (!parsedInput.success) { console.info(`Failed to parse input for Get Packages`) console.error(parsedInput.error) return null } const input = parsedInput.data const cacheClient = await getCacheClient() return cacheClient.cacheOrGet( stringify(input), async function () { const { adults, children, endDate, hotelId, lang, packageCodes, startDate, } = input 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 params = searchParams.toString() metrics.packages.counter.add(1, { hotelId, }) console.info( "api.hotels.packages start", JSON.stringify({ query: { hotelId, params } }) ) const apiResponse = await api.get( api.endpoints.v1.Package.Packages.hotel(hotelId), { headers: { Authorization: `Bearer ${serviceToken}`, }, }, searchParams ) if (!apiResponse.ok) { metrics.packages.fail.add(1, { hotelId, error_type: "http_error", error: JSON.stringify({ status: apiResponse.status, statusText: apiResponse.statusText, }), }) console.error( "api.hotels.packages error", JSON.stringify({ query: { hotelId, params } }) ) return null } const apiJson = await apiResponse.json() const validatedPackagesData = packagesSchema.safeParse(apiJson) if (!validatedPackagesData.success) { metrics.packages.fail.add(1, { hotelId, error_type: "validation_error", error: JSON.stringify(validatedPackagesData.error), }) console.error( "api.hotels.packages validation error", JSON.stringify({ query: { hotelId, params }, error: validatedPackagesData.error, }) ) return null } metrics.packages.success.add(1, { hotelId, }) console.info( "api.hotels.packages success", JSON.stringify({ query: { hotelId, params: params } }) ) return validatedPackagesData.data }, "3h" ) } export async function getRoomsAvailability( input: RoomsAvailabilityInputSchema, token: string, serviceToken: string, userPoints: number | undefined ) { const { booking: { bookingCode, fromDate, hotelId, rooms, searchType, toDate }, lang, } = input const redemption = searchType === REDEMPTION const apiLang = toApiLang(lang) const kids = rooms .map((r) => r.childrenInRoom) .filter(Boolean) .map((kid) => JSON.stringify(kid)) const metricsData = { adultsCount: rooms.map((r) => r.adults), bookingCode, childArray: kids.length ? kids : undefined, hotelId, roomStayEndDate: toDate, roomStayStartDate: fromDate, } metrics.roomsAvailability.counter.add(1, metricsData) console.info( "api.hotels.roomsAvailability start", JSON.stringify({ query: { hotelId, params: metricsData } }) ) 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, } return 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), { headers: { Authorization: `Bearer ${token}`, }, next: { revalidate: 60, }, }, params ) if (!apiResponse.ok) { const text = await apiResponse.text() metrics.roomsAvailability.fail.add(1, metricsData) console.error("Failed API call", { params, text }) return { error: "http_error", details: text } } const apiJson = await apiResponse.json() const validateAvailabilityData = roomsAvailabilitySchema.safeParse(apiJson) if (!validateAvailabilityData.success) { console.error("Validation error", { params, error: validateAvailabilityData.error, }) metrics.roomsAvailability.fail.add(1, metricsData) 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, 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" ) }) ) metrics.roomsAvailability.success.add(1, metricsData) const data = availabilityResponses.map((availability) => { if (availability.status === "fulfilled") { return availability.value } return { details: availability.reason, error: "request_failure", } }) 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, } } // Used to ensure `Available` rooms // are shown before all `NotAvailable` const statusLookup = { [AvailabilityEnum.Available]: 1, [AvailabilityEnum.NotAvailable]: 2, } export function sortRoomConfigs(a: RoomConfiguration, b: RoomConfiguration) { // @ts-expect-error - array indexing return statusLookup[a.status] - statusLookup[b.status] } export function getBedTypes( rooms: RoomConfiguration[], roomType: string, roomCategories?: RoomCategory[] ) { if (!roomCategories) { return [] } return rooms .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, 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()) }