import { Lang } from "@scandic-hotels/common/constants/language" import { getCacheClient } from "@scandic-hotels/common/dataCache" import { dt } from "@scandic-hotels/common/dt" import { createLogger } from "@scandic-hotels/common/logger/createLogger" import { createCounter } from "@scandic-hotels/common/telemetry" import { env } from "../../../env/server" import { router } from "../.." import * as api from "../../api" import { BreakfastPackageEnum } from "../../enums/breakfast" import { badRequestError } from "../../errors" import { contentStackBaseWithServiceProcedure, safeProtectedServiceProcedure, serviceProcedure, } from "../../procedures" import { getHotelPageUrls } from "../../routers/contentstack/hotelPage/utils" import { ancillaryPackageInputSchema, breakfastPackageInputSchema, cityCoordinatesInputSchema, getAdditionalDataInputSchema, getDestinationsMapDataInput, getHotelsByCityIdentifierInput, getHotelsByCountryInput, getHotelsByCSFilterInput, getMeetingRoomsInputSchema, hotelInputSchema, nearbyHotelIdsInput, roomPackagesInputSchema, } from "../../routers/hotels/input" import { ancillaryPackagesSchema, breakfastPackagesSchema, getNearbyHotelIdsSchema, } from "../../routers/hotels/output" import { isCityLocation } from "../../types/locations" import { toApiLang } from "../../utils" import { additionalDataSchema } from "./schemas/hotel/include/additionalData" import { meetingRoomsSchema } from "./schemas/meetingRoom" import { getHotelIdsByCityIdentifier } from "./services/getCityByCityIdentifier" import { getCountries } from "./services/getCountries" import { getHotel } from "./services/getHotel" import { getHotelIdsByCityId } from "./services/getHotelIdsByCityId" import { getHotelIdsByCountry } from "./services/getHotelIdsByCountry" import { getHotelsByHotelIds } from "./services/getHotelsByHotelIds" import { getLocationsByCountries } from "./services/getLocationsByCountries" import { getPackages } from "./services/getPackages" import { availability } from "./availability" import { locationsRouter } from "./locations" import type { HotelListingHotelData } from "../../types/hotel" const hotelQueryLogger = createLogger("hotelQueryRouter") export const hotelQueryRouter = router({ availability, get: serviceProcedure .input(hotelInputSchema) .query(async ({ ctx, input }) => { const { hotelId, language } = input const [hotelData, hotelPages] = await Promise.all([ getHotel(input, ctx.serviceToken), getHotelPageUrls(language), ]) const hotelPage = hotelPages.find((page) => page.hotelId === hotelId) return hotelData ? { ...hotelData, url: hotelPage?.url ?? null, } : null }), hotels: router({ byCountry: router({ get: contentStackBaseWithServiceProcedure .input(getHotelsByCountryInput) .query(async ({ ctx, input }) => { const { serviceToken } = ctx const { country } = input const lang = input.lang ?? ctx.lang 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, contentType } = input let shouldSortByDistance = true const language = ctx.lang let hotelsToFetch: string[] = [] const getHotelsByCSFilterCounter = createCounter( "trpc.hotel.hotels", "byCSFilter" ) const metricsGetHotelsByCSFilter = getHotelsByCSFilterCounter.init({ input, language, }) metricsGetHotelsByCSFilter.start() if (hotelsToInclude.length) { hotelsToFetch = hotelsToInclude shouldSortByDistance = false } else if (locationFilter?.city) { const locations = await getLocationsByCountries({ lang: language, serviceToken: ctx.serviceToken, citiesByCountry: null, }) if (!locations || locations.length === 0) { return [] } const cityId = locations .filter(isCityLocation) .find((loc) => loc.cityIdentifier === locationFilter.city)?.id if (!cityId) { metricsGetHotelsByCSFilter.dataError( `CityId not found for cityIdentifier: ${locationFilter.city}`, { cityIdentifier: locationFilter.city, } ) return [] } const hotelIds = await getHotelIdsByCityId({ cityId, serviceToken: ctx.serviceToken, }) if (!hotelIds?.length) { metricsGetHotelsByCSFilter.dataError( `No hotelIds found for cityId: ${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) { metricsGetHotelsByCSFilter.dataError( `No hotelIds found for country: ${locationFilter.country}`, { country: locationFilter.country, } ) return [] } const filteredHotelIds = hotelIds.filter( (id) => !locationFilter.excluded.includes(id) ) hotelsToFetch = filteredHotelIds } if (!hotelsToFetch.length) { metricsGetHotelsByCSFilter.dataError( `Couldn't find any hotels for given input: ${JSON.stringify(input)}`, input ) return [] } const hotels = await getHotelsByHotelIds({ hotelIds: hotelsToFetch, lang: language, serviceToken: ctx.serviceToken, contentType, }) metricsGetHotelsByCSFilter.success() if (shouldSortByDistance) { hotels.sort( (a, b) => a.hotel.location.distanceToCentre - b.hotel.location.distanceToCentre ) } return hotels }), }), getAllHotelData: serviceProcedure .input(getDestinationsMapDataInput) .query(async function ({ input, ctx }) { const lang = input?.lang ?? ctx.lang const warmup = input?.warmup ?? false const fetchHotels = async () => { const countries = await getCountries({ // Countries need to be in English regardless of incoming lang because // we use the names as input for API endpoints. lang: Lang.en, serviceToken: ctx.serviceToken, }) if (!countries) { throw new Error("Unable to fetch countries") } const countryNames = countries.data.map((country) => country.name) const hotelData: HotelListingHotelData[] = ( await Promise.all( countryNames.map(async (country) => { const hotelIds = await getHotelIdsByCountry({ country, serviceToken: ctx.serviceToken, }) const hotels = await getHotelsByHotelIds({ hotelIds, lang: lang, serviceToken: ctx.serviceToken, }) return hotels }) ) ).flat() return hotelData } const cacheClient = await getCacheClient() return await cacheClient.cacheOrGet( `${lang}:getDestinationsMapData`, fetchHotels, "max", { cacheStrategy: warmup ? "fetch-then-cache" : "cache-first", } ) }), }), nearbyHotelIds: serviceProcedure .input(nearbyHotelIdsInput) .query(async function ({ ctx, input }) { const { lang } = ctx const apiLang = toApiLang(lang) const { hotelId } = input const params: Record = { language: apiLang, } const cacheClient = await getCacheClient() return cacheClient.cacheOrGet( `${apiLang}:nearbyHotels:${hotelId}`, async () => { const nearbyHotelsCounter = createCounter( "trpc.hotel", "nearbyHotelIds" ) const metricsNearbyHotels = nearbyHotelsCounter.init({ params, hotelId, }) metricsNearbyHotels.start() const apiResponse = await api.get( api.endpoints.v1.Hotel.Hotels.nearbyHotels(hotelId), { headers: { Authorization: `Bearer ${ctx.serviceToken}`, }, }, params ) if (!apiResponse.ok) { await metricsNearbyHotels.httpError(apiResponse) return null } const apiJson = await apiResponse.json() const validateHotelData = getNearbyHotelIdsSchema.safeParse(apiJson) if (!validateHotelData.success) { metricsNearbyHotels.validationError(validateHotelData.error) throw badRequestError() } metricsNearbyHotels.success() return validateHotelData.data.map((id: string) => parseInt(id, 10)) }, env.CACHE_TIME_HOTELS ) }), locations: locationsRouter, 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 cacheClient = await getCacheClient() return await cacheClient.cacheOrGet( `coordinates:${address}`, async function () { const url = `https://maps.googleapis.com/maps/api/geocode/json?address=${encodeURIComponent(address)}&key=${apiKey}` const response = await fetch(url, { signal: AbortSignal.timeout(15_000), }) const data = await response.json() if (data.status !== "OK") { hotelQueryLogger.error(`Geocode error: ${data.status}`) return null } const location = data.results[0]?.geometry?.location if (!location) { hotelQueryLogger.error("No location found in geocode response") return null } return location }, "1d" ) } 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 meetingRoomsCounter = createCounter("trpc.hotel", "meetingRooms") const metricsMeetingRooms = meetingRoomsCounter.init({ params, }) metricsMeetingRooms.start() 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) { if (apiResponse.status === 404) { // This is expected when the hotel does not have meeting rooms metricsMeetingRooms.success() return [] } await metricsMeetingRooms.httpError(apiResponse) throw new Error("Failed to fetch meeting rooms", { cause: apiResponse, }) } const apiJson = await apiResponse.json() const validatedMeetingRooms = meetingRoomsSchema.safeParse(apiJson) if (!validatedMeetingRooms.success) { metricsMeetingRooms.validationError(validatedMeetingRooms.error) throw badRequestError() } metricsMeetingRooms.success() return validatedMeetingRooms.data.data.sort( (a, b) => a.attributes.sortOrder - b.attributes.sortOrder ) }, env.CACHE_TIME_HOTELS ) }), additionalData: safeProtectedServiceProcedure .input(getAdditionalDataInputSchema) .query(async function ({ ctx, input }) { const { hotelId, language } = input const params: Record = { hotelId, language, } const additionalDataCounter = createCounter( "trpc.hotel", "additionalData" ) const metricsAdditionalData = additionalDataCounter.init({ params, }) metricsAdditionalData.start() 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) { await metricsAdditionalData.httpError(apiResponse) throw new Error("Unable to fetch additional data for hotel") } const apiJson = await apiResponse.json() const validatedAdditionalData = additionalDataSchema.safeParse(apiJson) if (!validatedAdditionalData.success) { metricsAdditionalData.validationError(validatedAdditionalData.error) throw badRequestError() } metricsAdditionalData.success() return validatedAdditionalData.data }, env.CACHE_TIME_HOTELS ) }), packages: router({ get: serviceProcedure .input(roomPackagesInputSchema) .query(async ({ ctx, input }) => { return getPackages(input, ctx.serviceToken) }), 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 breakfastCounter = createCounter( "trpc.hotel.packages", "breakfast" ) const metricsBreakfast = breakfastCounter.init({ params, hotelId: input.hotelId, }) metricsBreakfast.start() const cacheClient = await getCacheClient() const breakfastPackages = await cacheClient.cacheOrGet( `${apiLang}:adults${input.adults}:startDate:${params.StartDate}:endDate:${params.EndDate}:hotel:${input.hotelId}`, async () => { const apiResponse = await api.get( api.endpoints.v1.Package.Breakfast.hotel(input.hotelId), { headers: { Authorization: `Bearer ${ctx.serviceToken}`, }, }, params ) if (!apiResponse.ok) { await metricsBreakfast.httpError(apiResponse) throw new Error("Unable to fetch breakfast packages") } const apiJson = await apiResponse.json() const breakfastPackages = breakfastPackagesSchema.safeParse(apiJson) if (!breakfastPackages.success) { metricsBreakfast.validationError(breakfastPackages.error) throw new Error("Unable to parse breakfast packages") } return breakfastPackages.data }, "1h" ) // Since the BRF0 package is out of scope for release we'll disable this handling // of membership levels for now, to be reanabled once needed // 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] // } // } // } // } metricsBreakfast.success() 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 ancillaryCounter = createCounter( "trpc.hotel.packages", "ancillary" ) const metricsAncillary = ancillaryCounter.init({ params, hotelId: input.hotelId, }) metricsAncillary.start() const cacheClient = await getCacheClient() const result = await cacheClient.cacheOrGet( `${apiLang}:hotel:${input.hotelId}:ancillaries:startDate:${params.StartDate}:endDate:${params.EndDate}`, async () => { const apiResponse = await api.get( api.endpoints.v1.Package.Ancillary.hotel(input.hotelId), { headers: { Authorization: `Bearer ${ctx.serviceToken}`, }, }, params ) if (!apiResponse.ok) { await metricsAncillary.httpError(apiResponse) return null } const apiJson = await apiResponse.json() const ancillaryPackages = ancillaryPackagesSchema.safeParse(apiJson) if (!ancillaryPackages.success) { metricsAncillary.validationError(ancillaryPackages.error) return null } return ancillaryPackages.data }, "1h" ) metricsAncillary.success() return result }), }), })