import { ApiLang } from "@/constants/languages" import * as api from "@/lib/api" import { dt } from "@/lib/dt" import { badRequestError } from "@/server/errors/trpc" import { contentStackBaseWithServiceProcedure, publicProcedure, router, safeProtectedServiceProcedure, serviceProcedure, } from "@/server/trpc" import { toApiLang } from "@/server/utils" import { cache } from "@/utils/cache" import { getHotelPageUrl } from "../contentstack/hotelPage/utils" import { getVerifiedUser, parsedUser } from "../user/query" import { getBreakfastPackageInputSchema, getCityCoordinatesInputSchema, getHotelDataInputSchema, getHotelsAvailabilityInputSchema, getHotelsInput, getRatesInputSchema, getRoomPackagesInputSchema, getRoomsAvailabilityInputSchema, getSelectedRoomAvailabilityInputSchema, type HotelDataInput, } from "./input" import { breakfastPackagesSchema, getHotelDataSchema, getHotelsAvailabilitySchema, getRatesSchema, getRoomPackagesSchema, getRoomsAvailabilitySchema, } from "./output" import { breakfastPackagesCounter, breakfastPackagesFailCounter, breakfastPackagesSuccessCounter, getHotelCounter, getHotelFailCounter, getHotelsCounter, getHotelsFailCounter, getHotelsSuccessCounter, getHotelSuccessCounter, getPackagesCounter, getPackagesFailCounter, getPackagesSuccessCounter, hotelsAvailabilityCounter, hotelsAvailabilityFailCounter, hotelsAvailabilitySuccessCounter, roomsAvailabilityCounter, roomsAvailabilityFailCounter, roomsAvailabilitySuccessCounter, selectedRoomAvailabilityCounter, selectedRoomAvailabilityFailCounter, selectedRoomAvailabilitySuccessCounter, } from "./telemetry" import tempRatesData from "./tempRatesData.json" import { getCitiesByCountry, getCountries, getHotelIdsByCityId, getHotelIdsByCountry, getLocations, TWENTYFOUR_HOURS, } from "./utils" import type { BedTypeSelection } from "@/types/components/hotelReservation/enterDetails/bedType" import { BreakfastPackageEnum } from "@/types/enums/breakfast" import { HotelTypeEnum } from "@/types/enums/hotelType" import type { RequestOptionsWithOutBody } from "@/types/fetch" import type { Hotel } from "@/types/hotel" import type { HotelPageUrl } from "@/types/trpc/routers/contentstack/hotelPage" import type { CityLocation } from "@/types/trpc/routers/hotel/locations" export const getHotelData = cache( async (input: HotelDataInput, serviceToken: string) => { const { hotelId, language, isCardOnlyPayment } = input const includes = ["RoomCategories", "Restaurants"] // "RoomCategories","NearbyHotels","Restaurants","City", const params = new URLSearchParams({ hotelId, language, }) includes.forEach((include) => params.append("include", include)) getHotelCounter.add(1, { hotelId, language, }) console.info( "api.hotels.hotelData start", JSON.stringify({ query: { hotelId, params } }) ) const apiResponse = await api.get( api.endpoints.v1.Hotel.Hotels.hotel(hotelId), { headers: { Authorization: `Bearer ${serviceToken}`, }, // needs to clear default option as only // cache or next.revalidate is permitted cache: undefined, next: { revalidate: 60 * 30, // 30 minutes }, }, params ) if (!apiResponse.ok) { const text = await apiResponse.text() getHotelFailCounter.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 }, error: { status: apiResponse.status, statusText: apiResponse.statusText, text, }, }) ) return null } const apiJson = await apiResponse.json() const validateHotelData = getHotelDataSchema.safeParse(apiJson) if (!validateHotelData.success) { getHotelFailCounter.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 }, error: validateHotelData.error, }) ) throw badRequestError() } getHotelSuccessCounter.add(1, { hotelId, language, }) console.info( "api.hotels.hotelData success", JSON.stringify({ query: { hotelId, params: params }, }) ) const hotelData = validateHotelData.data if (isCardOnlyPayment) { hotelData.data.attributes.merchantInformationData.alternatePaymentOptions = [] } if (hotelData.data.attributes.gallery) { const smallerImages = hotelData.data.attributes.gallery.smallerImages const hotelGalleryImages = hotelData.data.attributes.hotelType === HotelTypeEnum.Signature ? smallerImages.slice(0, 10) : smallerImages.slice(0, 6) hotelData.data.attributes.galleryImages = hotelGalleryImages } return hotelData } ) export const hotelQueryRouter = router({ availability: router({ hotels: serviceProcedure .input(getHotelsAvailabilityInputSchema) .query(async ({ input, ctx }) => { const { lang } = ctx const apiLang = toApiLang(lang) const { cityId, roomStayStartDate, roomStayEndDate, adults, children, bookingCode, } = input const params: Record = { roomStayStartDate, roomStayEndDate, adults, ...(children && { children }), bookingCode, language: apiLang, } hotelsAvailabilityCounter.add(1, { cityId, roomStayStartDate, roomStayEndDate, adults, children, bookingCode, }) 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 ${ctx.serviceToken}`, }, }, params ) if (!apiResponse.ok) { const text = await apiResponse.text() hotelsAvailabilityFailCounter.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, }, }) ) return null } const apiJson = await apiResponse.json() const validateAvailabilityData = getHotelsAvailabilitySchema.safeParse(apiJson) if (!validateAvailabilityData.success) { hotelsAvailabilityFailCounter.add(1, { cityId, roomStayStartDate, roomStayEndDate, adults, children, bookingCode, 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() } hotelsAvailabilitySuccessCounter.add(1, { cityId, roomStayStartDate, roomStayEndDate, adults, children, bookingCode, }) console.info( "api.hotels.hotelsAvailability success", JSON.stringify({ query: { cityId, params: params }, }) ) return { availability: validateAvailabilityData.data.data.flatMap( (hotels) => hotels.attributes ), } }), rooms: serviceProcedure .input(getRoomsAvailabilityInputSchema) .query(async ({ input, ctx }) => { const { hotelId, roomStayStartDate, roomStayEndDate, adults, children, bookingCode, rateCode, } = input const params: Record = { roomStayStartDate, roomStayEndDate, adults, ...(children && { children }), bookingCode, } roomsAvailabilityCounter.add(1, { hotelId, roomStayStartDate, roomStayEndDate, adults, children, bookingCode, }) console.info( "api.hotels.roomsAvailability start", JSON.stringify({ query: { hotelId, params } }) ) 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() roomsAvailabilityFailCounter.add(1, { hotelId, roomStayStartDate, roomStayEndDate, adults, children, bookingCode, error_type: "http_error", error: JSON.stringify({ status: apiResponse.status, statusText: apiResponse.statusText, text, }), }) console.error( "api.hotels.roomsAvailability error", JSON.stringify({ query: { hotelId, params }, error: { status: apiResponse.status, statusText: apiResponse.statusText, text, }, }) ) return null } const apiJson = await apiResponse.json() const validateAvailabilityData = getRoomsAvailabilitySchema.safeParse(apiJson) if (!validateAvailabilityData.success) { roomsAvailabilityFailCounter.add(1, { hotelId, roomStayStartDate, roomStayEndDate, adults, children, bookingCode, error_type: "validation_error", error: JSON.stringify(validateAvailabilityData.error), }) console.error( "api.hotels.roomsAvailability validation error", JSON.stringify({ query: { hotelId, params }, error: validateAvailabilityData.error, }) ) throw badRequestError() } roomsAvailabilitySuccessCounter.add(1, { hotelId, roomStayStartDate, roomStayEndDate, adults, children, bookingCode, }) console.info( "api.hotels.roomsAvailability success", JSON.stringify({ query: { hotelId, params: params }, }) ) if (rateCode) { validateAvailabilityData.data.mustBeGuaranteed = validateAvailabilityData.data.rateDefinitions.filter( (rate) => rate.rateCode === rateCode )[0].mustBeGuaranteed } return validateAvailabilityData.data }), room: serviceProcedure .input(getSelectedRoomAvailabilityInputSchema) .query(async ({ input, ctx }) => { const { hotelId, roomStayStartDate, roomStayEndDate, adults, children, bookingCode, rateCode, roomTypeCode, packageCodes, } = input const params: Record = { roomStayStartDate, roomStayEndDate, adults, ...(children && { children }), bookingCode, language: toApiLang(ctx.lang), } selectedRoomAvailabilityCounter.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() selectedRoomAvailabilityFailCounter.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, }, }) ) return null } const apiJsonAvailability = await apiResponseAvailability.json() const validateAvailabilityData = getRoomsAvailabilitySchema.safeParse(apiJsonAvailability) if (!validateAvailabilityData.success) { selectedRoomAvailabilityFailCounter.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 getHotelData( { hotelId, language: toApiLang(ctx.lang), }, ctx.serviceToken ) const availableRooms = validateAvailabilityData.data.roomConfigurations.filter((room) => { if (packageCodes) { return ( room.status === "Available" && room.features.some( (feature) => packageCodes.includes(feature.code) && feature.inventory > 0 ) ) } return room.status === "Available" }) const selectedRoom = availableRooms.find( (room) => room.roomTypeCode === roomTypeCode ) const availableRoomsInCategory = availableRooms.filter( (room) => room.roomType === selectedRoom?.roomType ) if (!selectedRoom) { console.error("No matching room found") return null } const rateDetails = validateAvailabilityData.data.rateDefinitions.find( (rateDef) => rateDef.rateCode === rateCode )?.generalTerms const rateTypes = selectedRoom.products.find( (rate) => rate.productType.public?.rateCode === rateCode || rate.productType.member?.rateCode === rateCode ) if (!rateTypes) { console.error("No matching rate found") return null } const rates = rateTypes.productType const mustBeGuaranteed = validateAvailabilityData.data.rateDefinitions.filter( (rate) => rate.rateCode === rateCode )[0].mustBeGuaranteed const cancellationText = validateAvailabilityData.data.rateDefinitions.find( (rate) => rate.rateCode === rateCode )?.cancellationText ?? "" const bedTypes = availableRoomsInCategory .map((availRoom) => { const matchingRoom = hotelData?.included?.rooms ?.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, } } }) .filter((bed): bed is BedTypeSelection => Boolean(bed)) selectedRoomAvailabilitySuccessCounter.add(1, { hotelId, roomStayStartDate, roomStayEndDate, adults, children, bookingCode, }) console.info( "api.hotels.selectedRoomAvailability success", JSON.stringify({ query: { hotelId, params: params }, }) ) return { selectedRoom, rateDetails, mustBeGuaranteed, cancellationText, memberRate: rates?.member, publicRate: rates.public, bedTypes, } }), }), rates: router({ get: publicProcedure .input(getRatesInputSchema) .query(async ({ input, ctx }) => { // 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 = getRatesSchema.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 }), }), hotelData: router({ get: serviceProcedure .input(getHotelDataInputSchema) .query(async ({ ctx, input }) => { return getHotelData(input, ctx.serviceToken) }), }), hotels: router({ get: contentStackBaseWithServiceProcedure .input(getHotelsInput) .query(async function ({ ctx, input }) { const { locationFilter, hotelsToInclude } = input const language = ctx.lang const apiLang = toApiLang(language) const options: RequestOptionsWithOutBody = { // needs to clear default option as only // cache or next.revalidate is permitted cache: undefined, headers: { Authorization: `Bearer ${ctx.serviceToken}`, }, next: { revalidate: TWENTYFOUR_HOURS, }, } let hotelsToFetch: string[] = [] getHotelsCounter.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 locationsParams = new URLSearchParams({ language: apiLang, }) const locations = await getLocations( language, options, locationsParams, null ) if (!locations || "error" in locations) { return [] } const cityId = locations .filter((loc): loc is CityLocation => loc.type === "cities") .find((loc) => loc.cityIdentifier === locationFilter.city)?.id if (!cityId) { getHotelsFailCounter.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 hotelIdsParams = new URLSearchParams({ language: apiLang, city: cityId, onlyBasicInfo: "true", }) const hotelIds = await getHotelIdsByCityId( cityId, options, hotelIdsParams ) if (!hotelIds?.length) { getHotelsFailCounter.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 hotelIdsParams = new URLSearchParams({ language: ApiLang.En, country: locationFilter.country, onlyBasicInfo: "true", }) const hotelIds = await getHotelIdsByCountry( locationFilter.country, options, hotelIdsParams ) if (!hotelIds?.length) { getHotelsFailCounter.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) { getHotelsFailCounter.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 hotels = await Promise.all( hotelsToFetch.map(async (hotelId) => { const [hotelData, url] = await Promise.all([ getHotelData({ hotelId, language }, ctx.serviceToken), getHotelPageUrl(language, hotelId), ]) return { data: hotelData?.data.attributes, url, } }) ) getHotelsSuccessCounter.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 { data: Hotel; url: HotelPageUrl } => !!hotel.data ) }), }), locations: router({ get: serviceProcedure.query(async function ({ ctx }) { const searchParams = new URLSearchParams() searchParams.set("language", toApiLang(ctx.lang)) const options: RequestOptionsWithOutBody = { // needs to clear default option as only // cache or next.revalidate is permitted cache: undefined, headers: { Authorization: `Bearer ${ctx.serviceToken}`, }, next: { revalidate: TWENTYFOUR_HOURS, }, } const countries = await getCountries(options, searchParams, ctx.lang) let citiesByCountry = null if (countries) { citiesByCountry = await getCitiesByCountry( countries, options, searchParams, ctx.lang ) } const locations = await getLocations( ctx.lang, options, searchParams, citiesByCountry ) if (Array.isArray(locations)) { return { data: locations, } } return locations }), }), packages: router({ get: serviceProcedure .input(getRoomPackagesInputSchema) .query(async ({ input, ctx }) => { const { hotelId, startDate, endDate, adults, children, packageCodes } = input const { lang } = ctx 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() getPackagesCounter.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) { getPackagesFailCounter.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 } }) ) } const apiJson = await apiResponse.json() const validatedPackagesData = getRoomPackagesSchema.safeParse(apiJson) if (!validatedPackagesData.success) { getHotelFailCounter.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, }) ) throw badRequestError() } getPackagesSuccessCounter.add(1, { hotelId, }) console.info( "api.hotels.packages success", JSON.stringify({ query: { hotelId, params: params } }) ) return validatedPackagesData.data }), breakfast: safeProtectedServiceProcedure .input(getBreakfastPackageInputSchema) .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 } breakfastPackagesCounter.add(1, metricsData) console.info( "api.package.breakfast start", JSON.stringify({ query: metricsData }) ) const apiResponse = await api.get( api.endpoints.v1.Package.Breakfast.hotel(input.hotelId), { cache: undefined, headers: { Authorization: `Bearer ${ctx.serviceToken}`, }, next: { revalidate: 60, }, }, params ) if (!apiResponse.ok) { const text = await apiResponse.text() breakfastPackagesFailCounter.add(1, { ...metricsData, error_type: "http_error", error: JSON.stringify({ status: apiResponse.status, statusText: apiResponse.statusText, text, }), }) console.error( "api.hotels.hotelsAvailability error", JSON.stringify({ query: metricsData, error: { status: apiResponse.status, statusText: apiResponse.statusText, text, }, }) ) return null } const apiJson = await apiResponse.json() const breakfastPackages = breakfastPackagesSchema.safeParse(apiJson) if (!breakfastPackages.success) { hotelsAvailabilityFailCounter.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, }) ) return null } breakfastPackagesSuccessCounter.add(1, metricsData) console.info( "api.package.breakfast success", JSON.stringify({ query: metricsData, }) ) 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.data.find( (pkg) => pkg.code === BreakfastPackageEnum.FREE_MEMBER_BREAKFAST ) if (freeBreakfastPackage?.localPrice) { return [freeBreakfastPackage] } } } } return breakfastPackages.data.filter( (pkg) => pkg.code !== BreakfastPackageEnum.FREE_MEMBER_BREAKFAST ) }), }), map: router({ city: serviceProcedure .input(getCityCoordinatesInputSchema) .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 }), }), })