feat: Add common package * Add isEdge, safeTry and dataCache to new common package * Add eslint and move prettier config * Fix yarn lock * Clean up tests * Add lint-staged config to common * Add missing dependencies Approved-by: Joakim Jäderberg
1177 lines
36 KiB
TypeScript
1177 lines
36 KiB
TypeScript
import { getCacheClient } from "@scandic-hotels/common/dataCache"
|
|
|
|
import { REDEMPTION } from "@/constants/booking"
|
|
import { Lang } from "@/constants/languages"
|
|
import { env } from "@/env/server"
|
|
import * as api from "@/lib/api"
|
|
import { dt } from "@/lib/dt"
|
|
import { badRequestError, unauthorizedError } from "@/server/errors/trpc"
|
|
import { getCityPageUrls } from "@/server/routers/contentstack/destinationCityPage/utils"
|
|
import { getVerifiedUser } from "@/server/routers/user/utils"
|
|
import { createCounter } from "@/server/telemetry"
|
|
import {
|
|
contentStackBaseWithServiceProcedure,
|
|
publicProcedure,
|
|
router,
|
|
safeProtectedServiceProcedure,
|
|
serviceProcedure,
|
|
} from "@/server/trpc"
|
|
import { toApiLang } from "@/server/utils"
|
|
|
|
import { getHotelPageUrls } from "../contentstack/hotelPage/utils"
|
|
import { additionalDataSchema } from "./schemas/hotel/include/additionalData"
|
|
import { meetingRoomsSchema } from "./schemas/meetingRoom"
|
|
import {
|
|
ancillaryPackageInputSchema,
|
|
breakfastPackageInputSchema,
|
|
cityCoordinatesInputSchema,
|
|
enterDetailsRoomsAvailabilityInputSchema,
|
|
getAdditionalDataInputSchema,
|
|
getDestinationsMapDataInput,
|
|
getHotelsByCityIdentifierInput,
|
|
getHotelsByCountryInput,
|
|
getHotelsByCSFilterInput,
|
|
getHotelsByHotelIdsAvailabilityInputSchema,
|
|
getLocationsInput,
|
|
getLocationsUrlsInput,
|
|
getMeetingRoomsInputSchema,
|
|
hotelInputSchema,
|
|
hotelsAvailabilityInputSchema,
|
|
myStayRoomAvailabilityInputSchema,
|
|
nearbyHotelIdsInput,
|
|
roomPackagesInputSchema,
|
|
selectRateRoomAvailabilityInputSchema,
|
|
selectRateRoomsAvailabilityInputSchema,
|
|
} from "./input"
|
|
import {
|
|
ancillaryPackagesSchema,
|
|
breakfastPackagesSchema,
|
|
getNearbyHotelIdsSchema,
|
|
} from "./output"
|
|
import {
|
|
getBedTypes,
|
|
getCitiesByCountry,
|
|
getCountries,
|
|
getHotel,
|
|
getHotelIdsByCityId,
|
|
getHotelIdsByCityIdentifier,
|
|
getHotelIdsByCountry,
|
|
getHotelsAvailabilityByCity,
|
|
getHotelsAvailabilityByHotelIds,
|
|
getHotelsByHotelIds,
|
|
getLocations,
|
|
getPackages,
|
|
getRoomsAvailability,
|
|
getSelectedRoomAvailability,
|
|
mergeRoomTypes,
|
|
selectRateRedirectURL,
|
|
} from "./utils"
|
|
|
|
import { AvailabilityEnum } from "@/types/components/hotelReservation/selectHotel/selectHotel"
|
|
import { BreakfastPackageEnum } from "@/types/enums/breakfast"
|
|
import { RateEnum } from "@/types/enums/rate"
|
|
import { RateTypeEnum } from "@/types/enums/rateType"
|
|
import type { DestinationPagesHotelData, HotelDataWithUrl } from "@/types/hotel"
|
|
import type { Room } from "@/types/providers/details/room"
|
|
import type { CityLocation } from "@/types/trpc/routers/hotel/locations"
|
|
|
|
export const hotelQueryRouter = router({
|
|
availability: router({
|
|
hotelsByCity: safeProtectedServiceProcedure
|
|
.input(hotelsAvailabilityInputSchema)
|
|
.use(async ({ ctx, input, next }) => {
|
|
if (input.redemption) {
|
|
if (ctx.session?.token.access_token) {
|
|
const verifiedUser = await getVerifiedUser({ session: ctx.session })
|
|
if (!verifiedUser?.error) {
|
|
return next({
|
|
ctx: {
|
|
token: ctx.session.token.access_token,
|
|
userPoints: verifiedUser?.data.membership?.currentPoints ?? 0,
|
|
},
|
|
input,
|
|
})
|
|
}
|
|
}
|
|
throw unauthorizedError()
|
|
}
|
|
return next({
|
|
ctx: {
|
|
token: ctx.serviceToken,
|
|
},
|
|
input,
|
|
})
|
|
})
|
|
.query(async ({ ctx, input }) => {
|
|
const { lang } = ctx
|
|
const apiLang = toApiLang(lang)
|
|
const {
|
|
cityId,
|
|
roomStayStartDate,
|
|
roomStayEndDate,
|
|
adults,
|
|
children,
|
|
bookingCode,
|
|
redemption,
|
|
} = input
|
|
|
|
// In case of redemption do not cache result
|
|
if (redemption) {
|
|
return getHotelsAvailabilityByCity(
|
|
input,
|
|
apiLang,
|
|
ctx.token,
|
|
ctx.userPoints
|
|
)
|
|
}
|
|
|
|
const cacheClient = await getCacheClient()
|
|
return await cacheClient.cacheOrGet(
|
|
`${cityId}:${roomStayStartDate}:${roomStayEndDate}:${adults}:${children}:${bookingCode}`,
|
|
async () => {
|
|
return getHotelsAvailabilityByCity(input, apiLang, ctx.token)
|
|
},
|
|
env.CACHE_TIME_CITY_SEARCH
|
|
)
|
|
}),
|
|
hotelsByHotelIds: serviceProcedure
|
|
.input(getHotelsByHotelIdsAvailabilityInputSchema)
|
|
.query(async ({ input, ctx }) => {
|
|
const { lang } = ctx
|
|
const apiLang = toApiLang(lang)
|
|
return getHotelsAvailabilityByHotelIds(input, apiLang, ctx.serviceToken)
|
|
}),
|
|
|
|
enterDetails: safeProtectedServiceProcedure
|
|
.input(enterDetailsRoomsAvailabilityInputSchema)
|
|
.use(async ({ ctx, input, next }) => {
|
|
if (input.booking.searchType === REDEMPTION) {
|
|
if (ctx.session?.token.access_token) {
|
|
const verifiedUser = await getVerifiedUser({ session: ctx.session })
|
|
if (!verifiedUser?.error) {
|
|
return next({
|
|
ctx: {
|
|
token: ctx.session.token.access_token,
|
|
userPoints: verifiedUser?.data.membership?.currentPoints ?? 0,
|
|
},
|
|
})
|
|
}
|
|
}
|
|
throw unauthorizedError()
|
|
}
|
|
return next({
|
|
ctx: {
|
|
token: ctx.serviceToken,
|
|
},
|
|
})
|
|
})
|
|
.query(async function ({ ctx, input }) {
|
|
const availability = await getRoomsAvailability(
|
|
input,
|
|
ctx.token,
|
|
ctx.serviceToken,
|
|
ctx.userPoints
|
|
)
|
|
|
|
const hotelData = await getHotel(
|
|
{
|
|
hotelId: input.booking.hotelId,
|
|
isCardOnlyPayment: false,
|
|
language: input.lang || ctx.lang,
|
|
},
|
|
ctx.serviceToken
|
|
)
|
|
|
|
const selectedRooms = []
|
|
for (const [idx, room] of availability.entries()) {
|
|
if (!room || "error" in room) {
|
|
console.info(`Availability failed: ${room.error}`)
|
|
console.error(room.details)
|
|
selectedRooms.push(null)
|
|
continue
|
|
}
|
|
const bookingRoom = input.booking.rooms[idx]
|
|
const selected = getSelectedRoomAvailability(
|
|
bookingRoom.rateCode,
|
|
room.rateDefinitions,
|
|
room.roomConfigurations,
|
|
bookingRoom.roomTypeCode,
|
|
ctx.userPoints
|
|
)
|
|
if (!selected) {
|
|
console.error("Unable to find selected room")
|
|
selectedRooms.push(null)
|
|
continue
|
|
}
|
|
|
|
const {
|
|
rateDefinition,
|
|
rateDefinitions,
|
|
product,
|
|
rooms,
|
|
selectedRoom,
|
|
} = selected
|
|
|
|
const bedTypes = getBedTypes(
|
|
rooms,
|
|
selectedRoom.roomType,
|
|
hotelData?.roomCategories
|
|
)
|
|
|
|
const counterRateCode = input.booking.rooms[idx].counterRateCode
|
|
let memberRateDefinition = undefined
|
|
if ("member" in product && product.member && counterRateCode) {
|
|
memberRateDefinition = rateDefinitions.find(
|
|
(rate) => rate.rateCode === counterRateCode && rate.isMemberRate
|
|
)
|
|
}
|
|
|
|
const selectedPackages = input.booking.rooms[idx].packages
|
|
selectedRooms.push({
|
|
bedTypes,
|
|
breakfastIncluded: rateDefinition.breakfastIncluded,
|
|
cancellationText: rateDefinition.cancellationText,
|
|
isAvailable: selectedRoom.status === AvailabilityEnum.Available,
|
|
isFlexRate: product.rate === RateEnum.flex,
|
|
memberMustBeGuaranteed: memberRateDefinition?.mustBeGuaranteed,
|
|
mustBeGuaranteed: rateDefinition.mustBeGuaranteed,
|
|
packages: room.packages.filter((pkg) =>
|
|
selectedPackages?.includes(pkg.code)
|
|
),
|
|
rate: product.rate,
|
|
rateDefinitionTitle: rateDefinition.title,
|
|
rateDetails: rateDefinition.generalTerms,
|
|
memberRateDetails: memberRateDefinition?.generalTerms,
|
|
// Send rate Title when it is a booking code rate
|
|
rateTitle:
|
|
rateDefinition.rateType !== RateTypeEnum.Regular
|
|
? rateDefinition.title
|
|
: undefined,
|
|
rateType: rateDefinition.rateType,
|
|
roomRate: product,
|
|
roomType: selectedRoom.roomType,
|
|
roomTypeCode: selectedRoom.roomTypeCode,
|
|
})
|
|
}
|
|
|
|
const totalBedsAvailableForRoomTypeCode: Record<string, number> = {}
|
|
for (const selectedRoom of selectedRooms) {
|
|
if (selectedRoom) {
|
|
if (!totalBedsAvailableForRoomTypeCode[selectedRoom.roomTypeCode]) {
|
|
totalBedsAvailableForRoomTypeCode[selectedRoom.roomTypeCode] =
|
|
selectedRoom.bedTypes.reduce(
|
|
(total, bedType) => total + bedType.roomsLeft,
|
|
0
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
for (const [idx, selectedRoom] of selectedRooms.entries()) {
|
|
if (selectedRoom) {
|
|
const totalBedsLeft =
|
|
totalBedsAvailableForRoomTypeCode[selectedRoom.roomTypeCode]
|
|
if (totalBedsLeft <= 0) {
|
|
selectedRooms[idx] = null
|
|
continue
|
|
}
|
|
totalBedsAvailableForRoomTypeCode[selectedRoom.roomTypeCode] =
|
|
totalBedsAvailableForRoomTypeCode[selectedRoom.roomTypeCode] - 1
|
|
}
|
|
}
|
|
|
|
if (selectedRooms.some((sr) => !sr)) {
|
|
return selectRateRedirectURL(input, selectedRooms.map(Boolean))
|
|
}
|
|
|
|
// Make TS show appropriate type
|
|
return selectedRooms.filter((sr): sr is Room => !!sr)
|
|
}),
|
|
myStay: safeProtectedServiceProcedure
|
|
.input(myStayRoomAvailabilityInputSchema)
|
|
.use(async ({ ctx, input, next }) => {
|
|
if (input.booking.searchType === REDEMPTION) {
|
|
if (ctx.session?.token.access_token) {
|
|
const verifiedUser = await getVerifiedUser({ session: ctx.session })
|
|
if (!verifiedUser?.error) {
|
|
return next({
|
|
ctx: {
|
|
token: ctx.session.token.access_token,
|
|
userPoints: verifiedUser?.data.membership?.currentPoints ?? 0,
|
|
},
|
|
})
|
|
}
|
|
}
|
|
throw unauthorizedError()
|
|
}
|
|
return next({
|
|
ctx: {
|
|
token: ctx.serviceToken,
|
|
},
|
|
})
|
|
})
|
|
.query(async function ({ ctx, input }) {
|
|
const [availability] = await getRoomsAvailability(
|
|
{
|
|
booking: {
|
|
...input.booking,
|
|
rooms: [input.booking.room],
|
|
},
|
|
lang: input.lang,
|
|
},
|
|
ctx.token,
|
|
ctx.serviceToken,
|
|
ctx.userPoints
|
|
)
|
|
|
|
if (!availability || "error" in availability) {
|
|
return null
|
|
}
|
|
|
|
const bookingRoom = input.booking.room
|
|
const selected = getSelectedRoomAvailability(
|
|
bookingRoom.rateCode,
|
|
availability.rateDefinitions,
|
|
availability.roomConfigurations,
|
|
bookingRoom.roomTypeCode,
|
|
ctx.userPoints
|
|
)
|
|
|
|
if (!selected) {
|
|
console.error("Unable to find selected room")
|
|
return null
|
|
}
|
|
|
|
return {
|
|
product: selected.product,
|
|
selectedRoom: selected.selectedRoom,
|
|
}
|
|
}),
|
|
selectRate: router({
|
|
room: safeProtectedServiceProcedure
|
|
.input(selectRateRoomAvailabilityInputSchema)
|
|
.use(async ({ ctx, input, next }) => {
|
|
if (input.booking.searchType === REDEMPTION) {
|
|
if (ctx.session?.token.access_token) {
|
|
const verifiedUser = await getVerifiedUser({
|
|
session: ctx.session,
|
|
})
|
|
if (!verifiedUser?.error) {
|
|
return next({
|
|
ctx: {
|
|
token: ctx.session.token.access_token,
|
|
userPoints:
|
|
verifiedUser?.data.membership?.currentPoints ?? 0,
|
|
},
|
|
})
|
|
}
|
|
}
|
|
throw unauthorizedError()
|
|
}
|
|
return next({
|
|
ctx: {
|
|
token: ctx.serviceToken,
|
|
},
|
|
})
|
|
})
|
|
.query(async function ({ ctx, input }) {
|
|
const [availability] = await getRoomsAvailability(
|
|
{
|
|
booking: {
|
|
...input.booking,
|
|
rooms: [input.booking.room],
|
|
},
|
|
lang: input.lang,
|
|
},
|
|
ctx.token,
|
|
ctx.serviceToken,
|
|
ctx.userPoints
|
|
)
|
|
|
|
if (!availability || "error" in availability) {
|
|
return null
|
|
}
|
|
|
|
const roomConfigurations = mergeRoomTypes(
|
|
availability.roomConfigurations
|
|
)
|
|
|
|
return {
|
|
...availability,
|
|
roomConfigurations,
|
|
}
|
|
}),
|
|
|
|
rooms: safeProtectedServiceProcedure
|
|
.input(selectRateRoomsAvailabilityInputSchema)
|
|
.use(async ({ ctx, input, next }) => {
|
|
if (input.booking.searchType === REDEMPTION) {
|
|
if (ctx.session?.token.access_token) {
|
|
const verifiedUser = await getVerifiedUser({
|
|
session: ctx.session,
|
|
})
|
|
if (!verifiedUser?.error) {
|
|
return next({
|
|
ctx: {
|
|
token: ctx.session.token.access_token,
|
|
userPoints:
|
|
verifiedUser?.data.membership?.currentPoints ?? 0,
|
|
},
|
|
})
|
|
}
|
|
}
|
|
throw unauthorizedError()
|
|
}
|
|
return next({
|
|
ctx: {
|
|
token: ctx.serviceToken,
|
|
},
|
|
})
|
|
})
|
|
.query(async function ({ ctx, input }) {
|
|
input.booking.rooms = input.booking.rooms.map((room) => ({
|
|
...room,
|
|
bookingCode: room.bookingCode || input.booking.bookingCode,
|
|
}))
|
|
|
|
const availability = await getRoomsAvailability(
|
|
input,
|
|
ctx.token,
|
|
ctx.serviceToken,
|
|
ctx.userPoints
|
|
)
|
|
|
|
for (const room of availability) {
|
|
if (!room || "error" in room) {
|
|
continue
|
|
}
|
|
|
|
room.roomConfigurations = mergeRoomTypes(room.roomConfigurations)
|
|
}
|
|
|
|
return availability
|
|
}),
|
|
}),
|
|
|
|
hotelsByCityWithBookingCode: serviceProcedure
|
|
.input(hotelsAvailabilityInputSchema)
|
|
.query(async ({ input, ctx }) => {
|
|
const { lang } = ctx
|
|
const apiLang = toApiLang(lang)
|
|
|
|
const bookingCodeAvailabilityResponse =
|
|
await getHotelsAvailabilityByCity(input, apiLang, ctx.serviceToken)
|
|
|
|
// 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
|
|
)
|
|
|
|
// 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),
|
|
}
|
|
}),
|
|
}),
|
|
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 { 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[] = []
|
|
|
|
const getHotelsByCSFilterCounter = createCounter(
|
|
"trpc.hotel.hotels",
|
|
"byCSFilter"
|
|
)
|
|
const metricsGetHotelsByCSFilter = getHotelsByCSFilterCounter.init({
|
|
input,
|
|
language,
|
|
})
|
|
|
|
metricsGetHotelsByCSFilter.start()
|
|
|
|
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) {
|
|
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 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
|
|
})
|
|
)
|
|
|
|
metricsGetHotelsByCSFilter.success()
|
|
|
|
return hotels.filter((hotel): hotel is HotelDataWithUrl => !!hotel)
|
|
}),
|
|
}),
|
|
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: DestinationPagesHotelData[] = (
|
|
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: router({
|
|
get: serviceProcedure.input(getLocationsInput).query(async function ({
|
|
ctx,
|
|
input,
|
|
}) {
|
|
const lang = input.lang ?? ctx.lang
|
|
const cacheClient = await getCacheClient()
|
|
return await cacheClient.cacheOrGet(
|
|
`${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"
|
|
)
|
|
}),
|
|
urls: publicProcedure
|
|
.input(getLocationsUrlsInput)
|
|
.query(async ({ input }) => {
|
|
const { lang } = input
|
|
|
|
const locationsUrlsCounter = createCounter(
|
|
"trpc.hotel.locations",
|
|
"urls"
|
|
)
|
|
const metricsLocationsUrls = locationsUrlsCounter.init({
|
|
lang,
|
|
})
|
|
|
|
metricsLocationsUrls.start()
|
|
|
|
const [hotelPageUrlsResult, cityPageUrlsResult] =
|
|
await Promise.allSettled([
|
|
getHotelPageUrls(lang),
|
|
getCityPageUrls(lang),
|
|
])
|
|
|
|
if (
|
|
hotelPageUrlsResult.status === "rejected" ||
|
|
cityPageUrlsResult.status === "rejected"
|
|
) {
|
|
metricsLocationsUrls.dataError(`Failed to get data for page URLs`, {
|
|
hotelPageUrlsResult,
|
|
cityPageUrlsResult,
|
|
})
|
|
|
|
return null
|
|
}
|
|
|
|
metricsLocationsUrls.success()
|
|
|
|
return {
|
|
hotels: hotelPageUrlsResult.value,
|
|
cities: cityPageUrlsResult.value,
|
|
}
|
|
}),
|
|
}),
|
|
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") {
|
|
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
|
|
},
|
|
"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
|
|
},
|
|
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
|
|
}),
|
|
}),
|
|
})
|