SW-3572 API route for listing hotels per city or country * wip hotel data endpoint * Correct route params type * wip * skip static paths call * timeout when getting destinations take too long * call noStore when we get a timeout * add cache-control headers * . * . * . * wip * wip * wip * wip * add route for getting hotels per country * include city when listing by country * fix distance SI unit * fix sorting * Merge branch 'master' of bitbucket.org:scandic-swap/web into feature/SW-3572-hotel-data-endpoint * packages/tracking passWithNoTests * revalidate must be static value * remove oxc reference * cleanup * cleanup hotel api route * feat(SW-3572): cleanup error handling Approved-by: Anton Gunnarsson
648 lines
20 KiB
TypeScript
648 lines
20 KiB
TypeScript
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
|
|
}),
|
|
}),
|
|
getDestinationsMapData: 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<string, string | number> = {
|
|
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<string, string | string[]> = {
|
|
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<string, string | string[]> = {
|
|
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
|
|
}),
|
|
}),
|
|
})
|