import { env } from "@/env/server" import * as api from "@/lib/api" import { dt } from "@/lib/dt" import { badRequestError } from "@/server/errors/trpc" import { contentStackBaseWithServiceProcedure, protectedProcedure, publicProcedure, router, safeProtectedServiceProcedure, serviceProcedure, } from "@/server/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 { getVerifiedUser, parsedUser } from "../user/query" import { additionalDataSchema } from "./schemas/hotel/include/additionalData" import { meetingRoomsSchema } from "./schemas/meetingRoom" import { ancillaryPackageInputSchema, breakfastPackageInputSchema, cityCoordinatesInputSchema, getAdditionalDataInputSchema, getHotelsByCityIdentifierInput, getHotelsByCountryInput, getHotelsByCSFilterInput, getHotelsByHotelIdsAvailabilityInputSchema, getLocationsInput, getMeetingRoomsInputSchema, hotelInputSchema, hotelsAvailabilityInputSchema, nearbyHotelIdsInput, ratesInputSchema, roomPackagesInputSchema, roomsCombinedAvailabilityInputSchema, selectedRoomAvailabilityInputSchema, } from "./input" import { metrics } from "./metrics" import { ancillaryPackagesSchema, breakfastPackagesSchema, getNearbyHotelIdsSchema, hotelsAvailabilitySchema, hotelSchema, packagesSchema, ratesSchema, roomsAvailabilitySchema, } from "./output" import tempRatesData from "./tempRatesData.json" import { getCitiesByCountry, getCountries, getHotelIdsByCityId, getHotelIdsByCityIdentifier, getHotelIdsByCountry, getHotelsByHotelIds, getLocations, } from "./utils" import type { BedTypeSelection } from "@/types/components/hotelReservation/enterDetails/bedType" import { BreakfastPackageEnum } from "@/types/enums/breakfast" import { HotelTypeEnum } from "@/types/enums/hotelType" import { RateTypeEnum } from "@/types/enums/rateType" import type { HotelDataWithUrl } from "@/types/hotel" import type { HotelsAvailabilityInputSchema, HotelsByHotelIdsAvailabilityInputSchema, } from "@/types/trpc/routers/hotel/availability" import type { HotelInput } from "@/types/trpc/routers/hotel/hotel" import type { CityLocation } from "@/types/trpc/routers/hotel/locations" 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) }, "1d" ) } ) export const getHotelsAvailabilityByCity = async ( input: HotelsAvailabilityInputSchema, apiLang: string, token: string // Either service token or user access token in case of redemption search ) => { const { cityId, roomStayStartDate, roomStayEndDate, adults, children, bookingCode, redemption, } = input const cacheClient = await getCacheClient() return await cacheClient.cacheOrGet( `${cityId}:${roomStayStartDate}:${roomStayEndDate}:${adults}:${children}:${bookingCode}`, async () => { 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 }, }) ) return { availability: validateAvailabilityData.data.data.flatMap( (hotels) => hotels.attributes ), } }, "1h" ) } export const getHotelsAvailabilityByHotelIds = async ( 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 ) } export const hotelQueryRouter = router({ availability: router({ hotelsByCity: serviceProcedure .input(hotelsAvailabilityInputSchema) .query(async ({ input, ctx }) => { const { lang } = ctx const apiLang = toApiLang(lang) return getHotelsAvailabilityByCity(input, apiLang, ctx.serviceToken) }), hotelsByCityWithRedemption: protectedProcedure .input(hotelsAvailabilityInputSchema) .query(async ({ input, ctx }) => { const { lang } = ctx const apiLang = toApiLang(lang) return getHotelsAvailabilityByCity( input, apiLang, ctx.session.token.access_token ) }), hotelsByHotelIds: serviceProcedure .input(getHotelsByHotelIdsAvailabilityInputSchema) .query(async ({ input, ctx }) => { const { lang } = ctx const apiLang = toApiLang(lang) return getHotelsAvailabilityByHotelIds(input, apiLang, ctx.serviceToken) }), roomsCombinedAvailability: serviceProcedure .input(roomsCombinedAvailabilityInputSchema) .query(async ({ input, ctx }) => { const { lang } = input const apiLang = toApiLang(lang) const { hotelId, roomStayStartDate, roomStayEndDate, uniqueAdultsCount, childArray, bookingCode, rateCode, } = input const metricsData = { hotelId, roomStayStartDate, roomStayEndDate, uniqueAdultsCount, childArray: childArray ? JSON.stringify(childArray) : undefined, bookingCode, } metrics.roomsCombinedAvailability.counter.add(1, metricsData) console.info( "api.hotels.roomsCombinedAvailability start", JSON.stringify({ query: { hotelId, params: metricsData } }) ) const availabilityResponses = await Promise.allSettled( uniqueAdultsCount.map(async (adultCount: number) => { const params: Record = { roomStayStartDate, roomStayEndDate, adults: adultCount, ...(childArray && childArray.length > 0 && { children: generateChildrenString(childArray), }), ...(bookingCode && { bookingCode }), language: apiLang, } const apiResponse = await api.get( api.endpoints.v1.Availability.hotel(hotelId.toString()), { headers: { Authorization: `Bearer ${ctx.serviceToken}`, }, }, params ) if (!apiResponse.ok) { const text = await apiResponse.text() metrics.roomsCombinedAvailability.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.roomsCombinedAvailability.fail.add(1, metricsData) return { error: "validation_error", details: validateAvailabilityData.error, } } if (rateCode) { validateAvailabilityData.data.mustBeGuaranteed = validateAvailabilityData.data.rateDefinitions.find( (rate) => rate.rateCode === rateCode )?.mustBeGuaranteed } return validateAvailabilityData.data }) ) metrics.roomsCombinedAvailability.success.add(1, metricsData) return availabilityResponses }), room: serviceProcedure .input(selectedRoomAvailabilityInputSchema) .query(async ({ input, ctx }) => { const { hotelId, roomStayStartDate, roomStayEndDate, adults, children, bookingCode, rateCode, roomTypeCode, } = input const params: Record = { roomStayStartDate, roomStayEndDate, adults, ...(children && { children }), ...(bookingCode && { bookingCode }), language: toApiLang(ctx.lang), } metrics.selectedRoomAvailability.counter.add(1, { hotelId, roomStayStartDate, roomStayEndDate, adults, children, bookingCode, }) console.info( "api.hotels.selectedRoomAvailability start", JSON.stringify({ query: { hotelId, params } }) ) const apiResponseAvailability = await api.get( api.endpoints.v1.Availability.hotel(hotelId.toString()), { headers: { Authorization: `Bearer ${ctx.serviceToken}`, }, }, params ) if (!apiResponseAvailability.ok) { const text = await apiResponseAvailability.text() metrics.selectedRoomAvailability.fail.add(1, { hotelId, roomStayStartDate, roomStayEndDate, adults, children, bookingCode, error_type: "http_error", error: JSON.stringify({ status: apiResponseAvailability.status, statusText: apiResponseAvailability.statusText, text, }), }) console.error( "api.hotels.selectedRoomAvailability error", JSON.stringify({ query: { hotelId, params }, error: { status: apiResponseAvailability.status, statusText: apiResponseAvailability.statusText, text, }, }) ) throw new Error("Failed to fetch selected room availability") } const apiJsonAvailability = await apiResponseAvailability.json() const validateAvailabilityData = roomsAvailabilitySchema.safeParse(apiJsonAvailability) if (!validateAvailabilityData.success) { metrics.selectedRoomAvailability.fail.add(1, { hotelId, roomStayStartDate, roomStayEndDate, adults, children, bookingCode, error_type: "validation_error", error: JSON.stringify(validateAvailabilityData.error), }) console.error( "api.hotels.selectedRoomAvailability validation error", JSON.stringify({ query: { hotelId, params }, error: validateAvailabilityData.error, }) ) throw badRequestError() } const hotelData = await getHotel( { hotelId, isCardOnlyPayment: false, language: ctx.lang, }, ctx.serviceToken ) const rooms = validateAvailabilityData.data.roomConfigurations const selectedRoom = rooms.find( (room) => room.roomTypeCode === roomTypeCode ) if (!selectedRoom) { metrics.selectedRoomAvailability.fail.add(1, { hotelId, roomStayStartDate, roomStayEndDate, adults, children, bookingCode, roomTypeCode, error_type: "not_found", error: `Couldn't find selected room with input: ${roomTypeCode}`, }) console.error("No matching room found") return null } const availableRoomsInCategory = rooms.filter( (room) => room.roomType === selectedRoom?.roomType ) const rateTypes = selectedRoom.products.find( (rate) => rate.public?.rateCode === rateCode || rate.member?.rateCode === rateCode ) if (!rateTypes) { metrics.selectedRoomAvailability.fail.add(1, { hotelId, roomStayStartDate, roomStayEndDate, adults, children, bookingCode, error_type: "not_found", error: `Couldn't find rateTypes for selected room: ${JSON.stringify(selectedRoom)}`, }) console.error("No matching rate found") return null } const rates = rateTypes const rateDefinition = validateAvailabilityData.data.rateDefinitions.find( (rate) => rate.rateCode === rateCode ) const bedTypes = availableRoomsInCategory .map((availRoom) => { const matchingRoom = hotelData?.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)) metrics.selectedRoomAvailability.success.add(1, { hotelId, roomStayStartDate, roomStayEndDate, adults, children, bookingCode, }) console.info( "api.hotels.selectedRoomAvailability success", JSON.stringify({ query: { hotelId, params: params }, }) ) return { selectedRoom, rateDetails: rateDefinition?.generalTerms, cancellationRule: rateDefinition?.cancellationRule, cancellationText: rateDefinition?.cancellationText ?? "", mustBeGuaranteed: !!rateDefinition?.mustBeGuaranteed, breakfastIncluded: !!rateDefinition?.breakfastIncluded, // Send rate Title when it is a booking code rate rateTitle: rateDefinition?.rateType !== RateTypeEnum.Regular ? rateDefinition?.title : undefined, memberRate: rates?.member, publicRate: rates?.public, bedTypes, } }), hotelsByCityWithBookingCode: serviceProcedure .input(hotelsAvailabilityInputSchema) .query(async ({ input, ctx }) => { const { lang } = ctx const apiLang = toApiLang(lang) metrics.hotelsAvailabilityBookingCode.counter.add(1, { ...input, }) const bookingCodeAvailabilityResponse = await getHotelsAvailabilityByCity(input, apiLang, ctx.serviceToken) // If API or network failed with no response if (!bookingCodeAvailabilityResponse) { metrics.hotelsAvailabilityBookingCode.fail.add(1, { ...input, error_type: "unknown", }) return null } // Get regular availability of hotels which don't have availability with booking code. const unavailableHotelIds = bookingCodeAvailabilityResponse?.availability .filter((hotel) => { return hotel.status === "NotAvailable" }) .flatMap((hotel) => { return hotel.hotelId }) // All hotels have availability with booking code no need to fetch regular prices. // return response as is without any filtering as below. if (!unavailableHotelIds || !unavailableHotelIds.length) { return bookingCodeAvailabilityResponse } const unavailableHotelsInput = { ...input, bookingCode: "", hotelIds: unavailableHotelIds, } const unavailableHotels = await getHotelsAvailabilityByHotelIds( unavailableHotelsInput, apiLang, ctx.serviceToken ) metrics.hotelsAvailabilityBookingCode.success.add(1, { ...input, }) console.info("api.hotels.hotelsAvailabilityBookingCode success") // No regular rates available due to network or API failure (no need to filter & merge). if (!unavailableHotels) { return bookingCodeAvailabilityResponse } // Filtering the response hotels to merge bookingCode rates and regular rates in single response. return { availability: bookingCodeAvailabilityResponse.availability .filter((hotel) => { return hotel.status === "Available" }) .concat(unavailableHotels.availability), } }), }), rates: router({ get: publicProcedure.input(ratesInputSchema).query(async () => { // TODO: Do a real API call when the endpoint is ready // const { hotelId } = input // const params = new URLSearchParams() // const apiLang = toApiLang(language) // params.set("hotelId", hotelId.toString()) // params.set("language", apiLang) console.info("api.hotels.rates start", JSON.stringify({})) const validatedHotelData = ratesSchema.safeParse(tempRatesData) if (!tempRatesData) { console.error("api.hotels.rates error", JSON.stringify({ error: null })) //Can't return null here since consuming component does not handle null yet // return null } if (!validatedHotelData.success) { console.error( "api.hotels.rates validation error", JSON.stringify({ error: validatedHotelData.error, }) ) throw badRequestError() } console.info("api.hotels.rates success", JSON.stringify({})) return validatedHotelData.data }), }), get: serviceProcedure .input(hotelInputSchema) .query(async ({ ctx, input }) => { return getHotel(input, ctx.serviceToken) }), hotels: router({ byCountry: router({ get: contentStackBaseWithServiceProcedure .input(getHotelsByCountryInput) .query(async ({ ctx, input }) => { const { lang, serviceToken } = ctx const { country } = input const hotelIds = await getHotelIdsByCountry({ country, serviceToken: ctx.serviceToken, }) return await getHotelsByHotelIds({ hotelIds, lang, serviceToken }) }), }), byCityIdentifier: router({ get: contentStackBaseWithServiceProcedure .input(getHotelsByCityIdentifierInput) .query(async ({ ctx, input }) => { const { lang, serviceToken } = ctx const { cityIdentifier } = input const hotelIds = await getHotelIdsByCityIdentifier( cityIdentifier, serviceToken ) return await getHotelsByHotelIds({ hotelIds, lang, serviceToken }) }), }), byCSFilter: router({ get: contentStackBaseWithServiceProcedure .input(getHotelsByCSFilterInput) .query(async function ({ ctx, input }) { const { locationFilter, hotelsToInclude } = input const language = ctx.lang let hotelsToFetch: string[] = [] metrics.hotels.counter.add(1, { input: JSON.stringify(input), language, }) console.info( "api.hotel.hotels start", JSON.stringify({ query: { ...input, language, }, }) ) if (hotelsToInclude.length) { hotelsToFetch = hotelsToInclude } else if (locationFilter?.city) { const locations = await getLocations({ lang: language, serviceToken: ctx.serviceToken, citiesByCountry: null, }) if (!locations || "error" in locations) { return [] } const cityId = locations .filter( (loc): loc is CityLocation => "type" in loc && loc.type === "cities" ) .find((loc) => loc.cityIdentifier === locationFilter.city)?.id if (!cityId) { metrics.hotels.fail.add(1, { input: JSON.stringify(input), language, error_type: "not_found", error: `CityId not found for cityIdentifier: ${locationFilter.city}`, }) console.error( "api.hotel.hotels not found error", JSON.stringify({ query: { ...input, language }, error: `CityId not found for cityIdentifier: ${locationFilter.city}`, }) ) return [] } const hotelIds = await getHotelIdsByCityId({ cityId, serviceToken: ctx.serviceToken, }) if (!hotelIds?.length) { metrics.hotels.fail.add(1, { cityId, language, error_type: "not_found", error: `No hotelIds found for cityId: ${cityId}`, }) console.error( "api.hotel.hotels not found error", JSON.stringify({ query: { cityId, language }, error: `No hotelIds found for cityId: ${cityId}`, }) ) return [] } const filteredHotelIds = hotelIds.filter( (id) => !locationFilter.excluded.includes(id) ) hotelsToFetch = filteredHotelIds } else if (locationFilter?.country) { const hotelIds = await getHotelIdsByCountry({ country: locationFilter.country, serviceToken: ctx.serviceToken, }) if (!hotelIds?.length) { metrics.hotels.fail.add(1, { country: locationFilter.country, language, error_type: "not_found", error: `No hotelIds found for country: ${locationFilter.country}`, }) console.error( "api.hotel.hotels not found error", JSON.stringify({ query: { country: locationFilter.country, language }, error: `No hotelIds found for cityId: ${locationFilter.country}`, }) ) return [] } const filteredHotelIds = hotelIds.filter( (id) => !locationFilter.excluded.includes(id) ) hotelsToFetch = filteredHotelIds } if (!hotelsToFetch.length) { metrics.hotels.fail.add(1, { input: JSON.stringify(input), language, error_type: "not_found", error: `Couldn't find any hotels for given input: ${JSON.stringify(input)}`, }) console.error( "api.hotel.hotels not found error", JSON.stringify({ query: JSON.stringify(input), error: `Couldn't find any hotels for given input: ${JSON.stringify(input)}`, }) ) return [] } const hotelPages = await getHotelPageUrls(language) const hotels = await Promise.all( hotelsToFetch.map(async (hotelId) => { const hotelData = await getHotel( { hotelId, isCardOnlyPayment: false, language }, ctx.serviceToken ) const hotelPage = hotelPages.find( (page) => page.hotelId === hotelId ) return hotelData ? { ...hotelData, url: hotelPage?.url ?? null, } : null }) ) metrics.hotels.success.add(1, { input: JSON.stringify(input), language, }) console.info( "api.hotels success", JSON.stringify({ query: { input: JSON.stringify(input), language, }, }) ) return hotels.filter((hotel): hotel is HotelDataWithUrl => !!hotel) }), }), getAllHotels: router({ get: serviceProcedure.query(async function ({ ctx }) { const countries = await getCountries({ lang: ctx.lang, serviceToken: ctx.serviceToken, }) if (!countries) { return null } const countryNames = countries.data.map((country) => country.name) const hotelData: HotelDataWithUrl[] = ( await Promise.all( countryNames.map(async (country) => { const hotelIds = await getHotelIdsByCountry({ country, serviceToken: ctx.serviceToken, }) const hotels = await getHotelsByHotelIds({ hotelIds, lang: ctx.lang, serviceToken: ctx.serviceToken, }) return hotels }) ) ).flat() return hotelData }), }), }), nearbyHotelIds: serviceProcedure .input(nearbyHotelIdsInput) .query(async function ({ ctx, input }) { const { lang } = ctx const apiLang = toApiLang(lang) const { hotelId } = input const params: Record = { language: apiLang, } metrics.nearbyHotelIds.counter.add(1, { hotelId, }) console.info( "api.hotels.nearbyHotelIds start", JSON.stringify({ query: { hotelId, params } }) ) const apiResponse = await api.get( api.endpoints.v1.Hotel.Hotels.nearbyHotels(hotelId), { headers: { Authorization: `Bearer ${ctx.serviceToken}`, }, }, params ) if (!apiResponse.ok) { const text = await apiResponse.text() metrics.nearbyHotelIds.fail.add(1, { hotelId, error_type: "http_error", error: JSON.stringify({ status: apiResponse.status, statusText: apiResponse.statusText, text, }), }) console.error( "api.hotels.nearbyHotelIds error", JSON.stringify({ query: { hotelId, params }, error: { status: apiResponse.status, statusText: apiResponse.statusText, text, }, }) ) return null } const apiJson = await apiResponse.json() const validateHotelData = getNearbyHotelIdsSchema.safeParse(apiJson) if (!validateHotelData.success) { metrics.nearbyHotelIds.fail.add(1, { hotelId, error_type: "validation_error", error: JSON.stringify(validateHotelData.error), }) console.error( "api.hotels.nearbyHotelIds validation error", JSON.stringify({ query: { hotelId, params }, error: validateHotelData.error, }) ) throw badRequestError() } metrics.nearbyHotelIds.success.add(1, { hotelId, }) console.info( "api.hotels.nearbyHotelIds success", JSON.stringify({ query: { hotelId, params }, }) ) return validateHotelData.data.map((id: string) => parseInt(id, 10)) }), locations: router({ get: serviceProcedure.input(getLocationsInput).query(async function ({ ctx, input, }) { const lang = input.lang ?? ctx.lang const cacheClient = await getCacheClient() return await cacheClient.cacheOrGet( `${ctx.lang}:getLocations`, async () => { const countries = await getCountries({ lang: lang, serviceToken: ctx.serviceToken, }) if (!countries) { throw new Error("Unable to fetch countries") } const countryNames = countries.data.map((country) => country.name) const citiesByCountry = await getCitiesByCountry({ countries: countryNames, serviceToken: ctx.serviceToken, lang, }) const locations = await getLocations({ lang, serviceToken: ctx.serviceToken, citiesByCountry, }) if (!locations || "error" in locations) { throw new Error("Unable to fetch locations") } return locations }, "max" ) }), }), map: router({ city: serviceProcedure .input(cityCoordinatesInputSchema) .query(async function ({ input }) { const apiKey = process.env.GOOGLE_STATIC_MAP_KEY const { city, hotel } = input async function fetchCoordinates(address: string) { const url = `https://maps.googleapis.com/maps/api/geocode/json?address=${encodeURIComponent(address)}&key=${apiKey}` const response = await fetch(url) const data = await response.json() if (data.status !== "OK") { console.error(`Geocode error: ${data.status}`) return null } const location = data.results[0]?.geometry?.location if (!location) { console.error("No location found in geocode response") return null } return location } let location = await fetchCoordinates(city) if (!location) { location = await fetchCoordinates(`${city}, ${hotel.address}`) } if (!location) { throw new Error("Unable to fetch coordinates") } return location }), }), meetingRooms: safeProtectedServiceProcedure .input(getMeetingRoomsInputSchema) .query(async function ({ ctx, input }) { const { hotelId, language } = input const params: Record = { hotelId, language, } const metricsData = { ...params, hotelId: input.hotelId } metrics.meetingRooms.counter.add(1, metricsData) console.info( "api.hotels.meetingRooms start", JSON.stringify({ query: { hotelId, params } }) ) const cacheClient = await getCacheClient() return cacheClient.cacheOrGet( `${language}:hotels:meetingRooms:${hotelId}`, async () => { const apiResponse = await api.get( api.endpoints.v1.Hotel.Hotels.meetingRooms(input.hotelId), { headers: { Authorization: `Bearer ${ctx.serviceToken}`, }, }, params ) if (!apiResponse.ok) { const text = await apiResponse.text() metrics.meetingRooms.fail.add(1, { ...metricsData, error_type: "http_error", error: JSON.stringify({ status: apiResponse.status, statusText: apiResponse.statusText, text, }), }) console.error( "api.hotels.meetingRooms error", JSON.stringify({ query: { params }, error: { status: apiResponse.status, statusText: apiResponse.statusText, text, }, }) ) throw new Error("Failed to fetch meeting rooms") } const apiJson = await apiResponse.json() const validatedMeetingRooms = meetingRoomsSchema.safeParse(apiJson) if (!validatedMeetingRooms.success) { console.error( "api.hotels.meetingRooms validation error", JSON.stringify({ query: { params }, error: validatedMeetingRooms.error, }) ) throw badRequestError() } metrics.meetingRooms.success.add(1, { hotelId, }) console.info( "api.hotels.meetingRooms success", JSON.stringify({ query: { params } }) ) return validatedMeetingRooms.data.data }, env.CACHE_TIME_HOTELS ) }), additionalData: safeProtectedServiceProcedure .input(getAdditionalDataInputSchema) .query(async function ({ ctx, input }) { const { hotelId, language } = input const params: Record = { hotelId, language, } const metricsData = { ...params, hotelId: input.hotelId } metrics.additionalData.counter.add(1, metricsData) console.info( "api.hotels.additionalData start", JSON.stringify({ query: { hotelId, params } }) ) const cacheClient = await getCacheClient() return cacheClient.cacheOrGet( `${language}:hotels:additionalData:${hotelId}`, async () => { const apiResponse = await api.get( api.endpoints.v1.Hotel.Hotels.additionalData(input.hotelId), { headers: { Authorization: `Bearer ${ctx.serviceToken}`, }, }, params ) if (!apiResponse.ok) { const text = await apiResponse.text() metrics.additionalData.fail.add(1, { ...metricsData, error_type: "http_error", error: JSON.stringify({ status: apiResponse.status, statusText: apiResponse.statusText, text, }), }) console.error( "api.hotels.additionalData error", JSON.stringify({ query: { params }, error: { status: apiResponse.status, statusText: apiResponse.statusText, text, }, }) ) throw new Error("Unable to fetch additional data for hotel") } const apiJson = await apiResponse.json() const validatedAdditionalData = additionalDataSchema.safeParse(apiJson) if (!validatedAdditionalData.success) { console.error( "api.hotels.additionalData validation error", JSON.stringify({ query: { params }, error: validatedAdditionalData.error, }) ) throw badRequestError() } metrics.additionalData.success.add(1, { hotelId, }) console.info( "api.hotels.additionalData success", JSON.stringify({ query: { params } }) ) return validatedAdditionalData.data }, env.CACHE_TIME_HOTELS ) }), packages: router({ get: serviceProcedure .input(roomPackagesInputSchema) .query(async ({ input, ctx }) => { const { hotelId, startDate, endDate, adults, children, packageCodes } = input const { lang } = input const apiLang = toApiLang(lang) const searchParams = new URLSearchParams({ startDate, endDate, adults: adults.toString(), children: children.toString(), language: apiLang, }) 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 ${ctx.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 }), breakfast: safeProtectedServiceProcedure .input(breakfastPackageInputSchema) .query(async function ({ ctx, input }) { const { lang } = ctx const apiLang = toApiLang(lang) const params = { Adults: input.adults, EndDate: dt(input.toDate).format("YYYY-MM-DD"), StartDate: dt(input.fromDate).format("YYYY-MM-DD"), language: apiLang, } const metricsData = { ...params, hotelId: input.hotelId } metrics.breakfastPackage.counter.add(1, metricsData) console.info( "api.package.breakfast start", JSON.stringify({ query: metricsData }) ) const cacheClient = await getCacheClient() const breakfastPackages = await cacheClient.cacheOrGet( `${apiLang}:adults${input.adults}:startDate:${params.StartDate}:endDate:${params.EndDate}`, async () => { const apiResponse = await api.get( api.endpoints.v1.Package.Breakfast.hotel(input.hotelId), { headers: { Authorization: `Bearer ${ctx.serviceToken}`, }, }, params ) if (!apiResponse.ok) { const text = await apiResponse.text() metrics.breakfastPackage.fail.add(1, { ...metricsData, error_type: "http_error", error: JSON.stringify({ status: apiResponse.status, statusText: apiResponse.statusText, text, }), }) console.error( "api.package.breakfast error", JSON.stringify({ query: metricsData, error: { status: apiResponse.status, statusText: apiResponse.statusText, text, }, }) ) throw new Error("Unable to fetch breakfast packages") } const apiJson = await apiResponse.json() const breakfastPackages = breakfastPackagesSchema.safeParse(apiJson) if (!breakfastPackages.success) { metrics.breakfastPackage.fail.add(1, { ...metricsData, error_type: "validation_error", error: JSON.stringify(breakfastPackages.error), }) console.error( "api.package.breakfast validation error", JSON.stringify({ query: metricsData, error: breakfastPackages.error, }) ) throw new Error("Unable to parse breakfast packages") } metrics.breakfastPackage.success.add(1, metricsData) console.info( "api.package.breakfast success", JSON.stringify({ query: metricsData, }) ) return breakfastPackages.data }, "1h" ) if (ctx.session?.token) { const apiUser = await getVerifiedUser({ session: ctx.session }) if (apiUser && !("error" in apiUser)) { const user = parsedUser(apiUser.data, false) if ( user.membership && ["L6", "L7"].includes(user.membership.membershipLevel) ) { const freeBreakfastPackage = breakfastPackages.find( (pkg) => pkg.code === BreakfastPackageEnum.FREE_MEMBER_BREAKFAST ) if (freeBreakfastPackage?.localPrice) { return [freeBreakfastPackage] } } } } return breakfastPackages.filter( (pkg) => pkg.code !== BreakfastPackageEnum.FREE_MEMBER_BREAKFAST ) }), ancillary: safeProtectedServiceProcedure .input(ancillaryPackageInputSchema) .query(async function ({ ctx, input }) { const { lang } = ctx const apiLang = toApiLang(lang) const params = { EndDate: dt(input.toDate).format("YYYY-MM-DD"), StartDate: dt(input.fromDate).format("YYYY-MM-DD"), language: apiLang, } const cacheClient = await getCacheClient() return await cacheClient.cacheOrGet( `${apiLang}:hotel:${input.hotelId}:ancillaries:startDate:${params.StartDate}:endDate:${params.EndDate}`, async () => { const metricsData = { ...params, hotelId: input.hotelId } metrics.ancillaryPackage.counter.add(1, metricsData) console.info( "api.package.ancillary start", JSON.stringify({ query: metricsData }) ) const apiResponse = await api.get( api.endpoints.v1.Package.Ancillary.hotel(input.hotelId), { headers: { Authorization: `Bearer ${ctx.serviceToken}`, }, }, params ) if (!apiResponse.ok) { const text = await apiResponse.text() metrics.ancillaryPackage.fail.add(1, { ...metricsData, error_type: "http_error", error: JSON.stringify({ status: apiResponse.status, statusText: apiResponse.statusText, text, }), }) console.error( "api.package.ancillary start error", JSON.stringify({ query: metricsData, error: { status: apiResponse.status, statusText: apiResponse.statusText, text, }, }) ) return null } const apiJson = await apiResponse.json() const ancillaryPackages = ancillaryPackagesSchema.safeParse(apiJson) if (!ancillaryPackages.success) { metrics.ancillaryPackage.fail.add(1, { ...metricsData, error_type: "validation_error", error: JSON.stringify(ancillaryPackages.error), }) console.error( "api.package.ancillary validation error", JSON.stringify({ query: metricsData, error: ancillaryPackages.error, }) ) return null } metrics.ancillaryPackage.success.add(1, metricsData) console.info( "api.package.ancillary success", JSON.stringify({ query: metricsData, }) ) return ancillaryPackages.data }, "1h" ) }), }), })