Chore/cleanup after trpc migration * Clean up TODOs * Rename REDEMPTION constant to SEARCH_TYPE_REDEMPTION * Update dependencies Remove unused deps from scandic-web Add missing deps to trpc package * Update self-referencing imports * Remove unused variables from scandic-web env * Fix missing graphql-tag package * Actually fix * Remove unused env var Approved-by: Christian Andolf Approved-by: Linus Flood
1337 lines
36 KiB
TypeScript
1337 lines
36 KiB
TypeScript
import deepmerge from "deepmerge"
|
|
import stringify from "json-stable-stringify-without-jsonify"
|
|
|
|
import { Lang } from "@scandic-hotels/common/constants/language"
|
|
import { selectRate } from "@scandic-hotels/common/constants/routes/hotelReservation"
|
|
import { getCacheClient } from "@scandic-hotels/common/dataCache"
|
|
import { createCounter } from "@scandic-hotels/common/telemetry"
|
|
import { chunk } from "@scandic-hotels/common/utils/chunk"
|
|
|
|
import { env } from "../../../env/server"
|
|
import * as api from "../../api"
|
|
import { SEARCH_TYPE_REDEMPTION } from "../../constants/booking"
|
|
import { cache } from "../../DUPLICATED/cache"
|
|
import { BookingErrorCodeEnum } from "../../enums/bookingErrorCode"
|
|
import { HotelTypeEnum } from "../../enums/hotelType"
|
|
import { RoomPackageCodeEnum } from "../../enums/roomFilter"
|
|
import { AvailabilityEnum } from "../../enums/selectHotel"
|
|
import { badRequestError } from "../../errors"
|
|
import { type RoomFeaturesInput } from "../../routers/hotels/input"
|
|
import {
|
|
hotelsAvailabilitySchema,
|
|
packagesSchema,
|
|
roomFeaturesSchema,
|
|
roomsAvailabilitySchema,
|
|
} from "../../routers/hotels/output"
|
|
import { toApiLang } from "../../utils"
|
|
import { sortRoomConfigs } from "../../utils/sortRoomConfigs"
|
|
import { getHotelPageUrls } from "../contentstack/hotelPage/utils"
|
|
import { generateChildrenString } from "./helpers"
|
|
import {
|
|
citiesByCountrySchema,
|
|
citiesSchema,
|
|
countriesSchema,
|
|
getHotelIdsSchema,
|
|
hotelSchema,
|
|
locationsSchema,
|
|
} from "./output"
|
|
|
|
import type { z } from "zod"
|
|
|
|
import type { Endpoint } from "../../api/endpoints"
|
|
import type {
|
|
HotelsAvailabilityInputSchema,
|
|
HotelsByHotelIdsAvailabilityInputSchema,
|
|
RoomsAvailabilityExtendedInputSchema,
|
|
RoomsAvailabilityInputRoom,
|
|
RoomsAvailabilityOutputSchema,
|
|
} from "../../types/availability"
|
|
import type { BedTypeSelection } from "../../types/bedTypeSelection"
|
|
import type { Room as RoomCategory } from "../../types/hotel"
|
|
import type { DestinationPagesHotelData, HotelInput } from "../../types/hotel"
|
|
import type {
|
|
CitiesGroupedByCountry,
|
|
CityLocation,
|
|
} from "../../types/locations"
|
|
import type { PackagesOutput } from "../../types/packages"
|
|
import type {
|
|
Product,
|
|
Products,
|
|
RateDefinition,
|
|
RedemptionsProduct,
|
|
RoomConfiguration,
|
|
} from "../../types/roomAvailability"
|
|
import type { Cities } from "./output"
|
|
export const locationsAffix = "locations"
|
|
|
|
export async function getCitiesByCountry({
|
|
countries,
|
|
lang,
|
|
affix = locationsAffix,
|
|
serviceToken,
|
|
}: {
|
|
countries: string[]
|
|
lang: Lang
|
|
affix?: string
|
|
serviceToken: string
|
|
}): Promise<CitiesGroupedByCountry> {
|
|
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: country.data.filter((city) => city.isPublished),
|
|
}))
|
|
|
|
const groupedCitiesByCountry: CitiesGroupedByCountry =
|
|
filteredCitiesByCountries.reduce((acc, { country, data }) => {
|
|
acc[country] = data
|
|
return acc
|
|
}, {} as CitiesGroupedByCountry)
|
|
|
|
return groupedCitiesByCountry
|
|
}
|
|
|
|
export async function getCountries({
|
|
lang,
|
|
serviceToken,
|
|
warmup = false,
|
|
}: {
|
|
lang: Lang
|
|
serviceToken: string
|
|
warmup?: boolean
|
|
}) {
|
|
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",
|
|
{
|
|
cacheStrategy: warmup ? "fetch-then-cache" : "cache-first",
|
|
}
|
|
)
|
|
}
|
|
|
|
export async function getHotelIdsByCityId({
|
|
cityId,
|
|
serviceToken,
|
|
}: {
|
|
cityId: string
|
|
serviceToken: string
|
|
}) {
|
|
const getHotelIdsByCityIdCounter = createCounter(
|
|
"hotel",
|
|
"getHotelIdsByCityId"
|
|
)
|
|
const metricsGetHotelIdsByCityId = getHotelIdsByCityIdCounter.init({
|
|
cityId,
|
|
})
|
|
|
|
metricsGetHotelIdsByCityId.start()
|
|
|
|
const cacheClient = await getCacheClient()
|
|
const result = await cacheClient.cacheOrGet(
|
|
`${cityId}:hotelsByCityId`,
|
|
async () => {
|
|
const searchParams = new URLSearchParams({
|
|
city: cityId,
|
|
})
|
|
|
|
const apiResponse = await api.get(
|
|
api.endpoints.v1.Hotel.hotels,
|
|
{
|
|
headers: {
|
|
Authorization: `Bearer ${serviceToken}`,
|
|
},
|
|
},
|
|
searchParams
|
|
)
|
|
|
|
if (!apiResponse.ok) {
|
|
await metricsGetHotelIdsByCityId.httpError(apiResponse)
|
|
throw new Error("Unable to fetch hotelIds by cityId")
|
|
}
|
|
|
|
const apiJson = await apiResponse.json()
|
|
const validatedHotelIds = getHotelIdsSchema.safeParse(apiJson)
|
|
if (!validatedHotelIds.success) {
|
|
metricsGetHotelIdsByCityId.validationError(validatedHotelIds.error)
|
|
throw new Error("Unable to parse data for hotelIds by cityId")
|
|
}
|
|
|
|
return validatedHotelIds.data
|
|
},
|
|
env.CACHE_TIME_HOTELS
|
|
)
|
|
|
|
metricsGetHotelIdsByCityId.success()
|
|
|
|
return result
|
|
}
|
|
|
|
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 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 getHotelIdsByCountry({
|
|
country,
|
|
serviceToken,
|
|
}: {
|
|
country: string
|
|
serviceToken: string
|
|
}) {
|
|
const getHotelIdsByCountryCounter = createCounter(
|
|
"hotel",
|
|
"getHotelIdsByCountry"
|
|
)
|
|
|
|
const metricsGetHotelIdsByCountry = getHotelIdsByCountryCounter.init({
|
|
country,
|
|
})
|
|
|
|
metricsGetHotelIdsByCountry.start()
|
|
|
|
const cacheClient = await getCacheClient()
|
|
|
|
const result = await cacheClient.cacheOrGet(
|
|
`${country}:hotelsByCountry`,
|
|
async () => {
|
|
const hotelIdsParams = new URLSearchParams({
|
|
country,
|
|
})
|
|
|
|
const apiResponse = await api.get(
|
|
api.endpoints.v1.Hotel.hotels,
|
|
{
|
|
headers: {
|
|
Authorization: `Bearer ${serviceToken}`,
|
|
},
|
|
},
|
|
hotelIdsParams
|
|
)
|
|
|
|
if (!apiResponse.ok) {
|
|
await metricsGetHotelIdsByCountry.httpError(apiResponse)
|
|
throw new Error("Unable to fetch hotelIds by country")
|
|
}
|
|
|
|
const apiJson = await apiResponse.json()
|
|
const validatedHotelIds = getHotelIdsSchema.safeParse(apiJson)
|
|
if (!validatedHotelIds.success) {
|
|
metricsGetHotelIdsByCountry.validationError(validatedHotelIds.error)
|
|
throw new Error("Unable to parse hotelIds by country")
|
|
}
|
|
|
|
return validatedHotelIds.data
|
|
},
|
|
env.CACHE_TIME_HOTELS
|
|
)
|
|
|
|
metricsGetHotelIdsByCountry.success()
|
|
|
|
return result
|
|
}
|
|
|
|
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 chunkedHotelIds = chunk(hotelIds, 10)
|
|
|
|
const hotels: DestinationPagesHotelData[] = []
|
|
for (const hotelIdChunk of chunkedHotelIds) {
|
|
const chunkedHotels = await Promise.all(
|
|
hotelIdChunk.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,
|
|
name: hotel.name,
|
|
tripadvisor: hotel.ratings?.tripAdvisor?.rating,
|
|
detailedFacilities: hotel.detailedFacilities || [],
|
|
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
|
|
})
|
|
)
|
|
|
|
hotels.push(...chunkedHotels)
|
|
}
|
|
return hotels.filter(
|
|
(hotel): hotel is DestinationPagesHotelData => !!hotel
|
|
)
|
|
},
|
|
"1d"
|
|
)
|
|
}
|
|
|
|
export async function getLocations({
|
|
lang,
|
|
citiesByCountry,
|
|
serviceToken,
|
|
}: {
|
|
lang: Lang
|
|
citiesByCountry: CitiesGroupedByCountry | null
|
|
serviceToken: string
|
|
}) {
|
|
const cacheClient = await getCacheClient()
|
|
const countryKeys = Object.keys(citiesByCountry ?? {})
|
|
let cacheKey = `${lang}:locations`
|
|
|
|
if (countryKeys.length) {
|
|
cacheKey += `:${countryKeys.join(",")}`
|
|
}
|
|
|
|
return await cacheClient.cacheOrGet(
|
|
cacheKey.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")
|
|
}
|
|
const chunkedLocations = chunk(verifiedLocations.data.data, 10)
|
|
|
|
let locations: z.infer<typeof locationsSchema>["data"] = []
|
|
|
|
for (const chunk of chunkedLocations) {
|
|
const chunkLocations = await Promise.all(
|
|
chunk.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
|
|
})
|
|
)
|
|
|
|
locations.push(...chunkLocations)
|
|
}
|
|
|
|
return locations
|
|
},
|
|
"1d"
|
|
)
|
|
}
|
|
|
|
export const getHotel = cache(
|
|
async (input: HotelInput, serviceToken: string) => {
|
|
const { hotelId, language, isCardOnlyPayment } = input
|
|
|
|
const getHotelCounter = createCounter("hotel", "getHotel")
|
|
const metricsGetHotel = getHotelCounter.init({
|
|
hotelId,
|
|
language,
|
|
isCardOnlyPayment,
|
|
})
|
|
|
|
metricsGetHotel.start()
|
|
|
|
const cacheClient = await getCacheClient()
|
|
|
|
const result = await cacheClient.cacheOrGet(
|
|
`${input.language}:hotel:${input.hotelId}:${!!input.isCardOnlyPayment}`,
|
|
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
|
|
* (include=City&include=NearbyHotels&include=Restaurants etc.)
|
|
**/
|
|
const params = new URLSearchParams([
|
|
["include", "AdditionalData"],
|
|
["include", "City"],
|
|
["include", "NearbyHotels"],
|
|
["include", "Restaurants"],
|
|
["include", "RoomCategories"],
|
|
["language", toApiLang(language)],
|
|
])
|
|
|
|
const apiResponse = await api.get(
|
|
api.endpoints.v1.Hotel.Hotels.hotel(hotelId),
|
|
{
|
|
headers: {
|
|
Authorization: `Bearer ${serviceToken}`,
|
|
},
|
|
},
|
|
params
|
|
)
|
|
|
|
if (!apiResponse.ok) {
|
|
await metricsGetHotel.httpError(apiResponse)
|
|
return null
|
|
}
|
|
|
|
const apiJson = await apiResponse.json()
|
|
const validateHotelData = hotelSchema.safeParse(apiJson)
|
|
|
|
if (!validateHotelData.success) {
|
|
metricsGetHotel.validationError(validateHotelData.error)
|
|
throw badRequestError()
|
|
}
|
|
|
|
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
|
|
},
|
|
env.CACHE_TIME_HOTELS
|
|
)
|
|
|
|
metricsGetHotel.success()
|
|
|
|
return result
|
|
}
|
|
)
|
|
|
|
export async function getCity({
|
|
cityUrl,
|
|
serviceToken,
|
|
}: {
|
|
cityUrl: string
|
|
serviceToken: string
|
|
}): Promise<Cities> {
|
|
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 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<string, string | number> = {
|
|
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 === SEARCH_TYPE_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<string, number>
|
|
>((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<string, RoomConfiguration>()
|
|
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()}`
|
|
}
|