Files
web/apps/scandic-web/server/routers/hotels/query.ts
Anton Gunnarsson 80100e7631 Merged in monorepo-step-1 (pull request #1080)
Migrate to a monorepo setup - step 1

* Move web to subfolder /apps/scandic-web

* Yarn + transitive deps

- Move to yarn
- design-system package removed for now since yarn doesn't
support the parameter for token (ie project currently broken)
- Add missing transitive dependencies as Yarn otherwise
prevents these imports
- VS Code doesn't pick up TS path aliases unless you open
/apps/scandic-web instead of root (will be fixed with monorepo)

* Pin framer-motion to temporarily fix typing issue

https://github.com/adobe/react-spectrum/issues/7494

* Pin zod to avoid typ error

There seems to have been a breaking change in the types
returned by zod where error is now returned as undefined
instead of missing in the type. We should just handle this
but to avoid merge conflicts just pin the dependency for
now.

* Pin react-intl version

Pin version of react-intl to avoid tiny type issue where formatMessage
does not accept a generic any more. This will be fixed in a future
commit, but to avoid merge conflicts just pin for now.

* Pin typescript version

Temporarily pin version as newer versions as stricter and results in
a type error. Will be fixed in future commit after merge.

* Setup workspaces

* Add design-system as a monorepo package

* Remove unused env var DESIGN_SYSTEM_ACCESS_TOKEN

* Fix husky for monorepo setup

* Update netlify.toml

* Add lint script to root package.json

* Add stub readme

* Fix react-intl formatMessage types

* Test netlify.toml in root

* Remove root toml

* Update netlify.toml publish path

* Remove package-lock.json

* Update build for branch/preview builds


Approved-by: Linus Flood
2025-02-26 10:36:17 +00:00

1788 lines
52 KiB
TypeScript

import { unstable_cache } from "next/cache"
import { ApiLang } from "@/constants/languages"
import { env } from "@/env/server"
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 { generateChildrenString } from "@/components/HotelReservation/utils"
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,
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 { AvailabilityEnum } from "@/types/components/hotelReservation/selectHotel/selectHotel"
import { BreakfastPackageEnum } from "@/types/enums/breakfast"
import { HotelTypeEnum } from "@/types/enums/hotelType"
import type { RequestOptionsWithOutBody } from "@/types/fetch"
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 = unstable_cache(
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}`,
},
// needs to clear default option as only
// cache or next.revalidate is permitted
cache: undefined,
next: {
revalidate: env.CACHE_TIME_HOTELS,
tags: [`${language}:hotel:${hotelId}`],
},
},
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
},
[`${input.language}:hotel:${input.hotelId}:${!!input.isCardOnlyPayment}`],
{
revalidate: env.CACHE_TIME_HOTELS,
tags: [
`${input.language}:hotel:${input.hotelId}:${!!input.isCardOnlyPayment}`,
],
}
)
return callable(input.hotelId, input.language, input.isCardOnlyPayment)
}
)
export const getHotelsAvailabilityByCity = async (
input: HotelsAvailabilityInputSchema,
apiLang: string,
serviceToken: string
) => {
const {
cityId,
roomStayStartDate,
roomStayEndDate,
adults,
children,
bookingCode,
} = input
const params: Record<string, string | number> = {
roomStayStartDate,
roomStayEndDate,
adults,
...(children && { children }),
...(bookingCode && { bookingCode }),
language: apiLang,
}
metrics.hotelsAvailability.counter.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),
{
cache: undefined,
headers: {
Authorization: `Bearer ${serviceToken}`,
},
next: {
revalidate: env.CACHE_TIME_CITY_SEARCH,
},
},
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,
},
})
)
return null
}
const apiJson = await apiResponse.json()
const validateAvailabilityData = hotelsAvailabilitySchema.safeParse(apiJson)
if (!validateAvailabilityData.success) {
metrics.hotelsAvailability.fail.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()
}
metrics.hotelsAvailability.success.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
),
}
}
export const getHotelsAvailabilityByHotelIds = async (
input: HotelsByHotelIdsAvailabilityInputSchema,
apiLang: string,
serviceToken: string
) => {
const {
hotelIds,
roomStayStartDate,
roomStayEndDate,
adults,
children,
bookingCode,
} = input
/**
* 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.)
**/
const params = new URLSearchParams([
["roomStayStartDate", roomStayStartDate],
["roomStayEndDate", roomStayEndDate],
["adults", adults.toString()],
["children", children ?? ""],
["bookingCode", bookingCode],
["language", apiLang],
])
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(),
{
cache: undefined,
headers: {
Authorization: `Bearer ${serviceToken}`,
},
next: {
revalidate: env.CACHE_TIME_CITY_SEARCH,
},
},
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,
},
})
)
return null
}
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
),
}
}
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)
}),
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<string, string | number | undefined> = {
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,
packageCodes,
} = input
const params: Record<string, string | number | undefined> = {
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,
},
})
)
return null
}
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 availableRooms =
validateAvailabilityData.data.roomConfigurations.filter((room) => {
if (packageCodes) {
return (
room.status === AvailabilityEnum.Available &&
room.features.some(
(feature) =>
packageCodes.includes(feature.code) && feature.inventory > 0
)
)
}
return room.status === AvailabilityEnum.Available
})
const selectedRoom = availableRooms.find(
(room) => room.roomTypeCode === roomTypeCode
)
const availableRoomsInCategory = availableRooms.filter(
(room) => room.roomType === selectedRoom?.roomType
)
if (!selectedRoom) {
metrics.selectedRoomAvailability.fail.add(1, {
hotelId,
roomStayStartDate,
roomStayEndDate,
adults,
children,
bookingCode,
error_type: "not_found",
error: `Couldn't find selected room with input: ${roomTypeCode}`,
})
console.error("No matching room found")
return null
}
const rateTypes = selectedRoom.products.find(
(rate) =>
rate.productType.public?.rateCode === rateCode ||
rate.productType.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.productType
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,
cancellationText: rateDefinition?.cancellationText ?? "",
mustBeGuaranteed: !!rateDefinition?.mustBeGuaranteed,
breakfastIncluded: !!rateDefinition?.breakfastIncluded,
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 (!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 options: RequestOptionsWithOutBody = {
// needs to clear default option as only
// cache or next.revalidate is permitted
cache: undefined,
headers: {
Authorization: `Bearer ${serviceToken}`,
},
next: {
revalidate: env.CACHE_TIME_HOTELS,
},
}
const hotelIdsParams = new URLSearchParams({
language: ApiLang.En,
country,
})
const hotelIds = await getHotelIdsByCountry(
country,
options,
hotelIdsParams
)
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
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: env.CACHE_TIME_HOTELS,
},
}
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 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 =>
"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 hotelIdsParams = new URLSearchParams({
language: apiLang,
city: cityId,
})
const hotelIds = await getHotelIdsByCityId(
cityId,
options,
hotelIdsParams
)
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 hotelIdsParams = new URLSearchParams({
language: ApiLang.En,
country: locationFilter.country,
})
const hotelIds = await getHotelIdsByCountry(
locationFilter.country,
options,
hotelIdsParams
)
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 apiLang = toApiLang(ctx.lang)
const params = new URLSearchParams({
language: apiLang,
})
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: env.CACHE_TIME_HOTELS,
},
}
const countries = await getCountries(options, params, ctx.lang)
if (!countries) {
return null
}
const countryNames = countries.data.map((country) => country.name)
const hotelData: HotelDataWithUrl[] = (
await Promise.all(
countryNames.map(async (country) => {
const countryParams = new URLSearchParams({
country: country,
})
const hotelIds = await getHotelIdsByCountry(
country,
options,
countryParams
)
const hotels = await getHotelsByHotelIds(
hotelIds,
ctx.lang,
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<string, string | number> = {
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.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: env.CACHE_TIME_HOTELS,
},
}
const countries = await getCountries(options, searchParams, ctx.lang)
if (!countries) {
return null
}
const countryNames = countries.data.map((country) => country.name)
const citiesByCountry = await getCitiesByCountry(
countryNames,
options,
searchParams,
ctx.lang
)
const locations = await getLocations(
ctx.lang,
options,
searchParams,
citiesByCountry
)
if (Array.isArray(locations)) {
return {
data: locations,
}
}
return locations
}),
}),
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<string, string | string[]> = {
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 apiResponse = await api.get(
api.endpoints.v1.Hotel.Hotels.meetingRooms(input.hotelId),
{
cache: undefined,
headers: {
Authorization: `Bearer ${ctx.serviceToken}`,
},
next: {
revalidate: env.CACHE_TIME_HOTELS,
},
},
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,
},
})
)
return []
}
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
}),
additionalData: safeProtectedServiceProcedure
.input(getAdditionalDataInputSchema)
.query(async function ({ ctx, input }) {
const { hotelId, language } = input
const params: Record<string, string | string[]> = {
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 apiResponse = await api.get(
api.endpoints.v1.Hotel.Hotels.additionalData(input.hotelId),
{
cache: undefined,
headers: {
Authorization: `Bearer ${ctx.serviceToken}`,
},
next: {
revalidate: env.CACHE_TIME_HOTELS,
},
},
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,
},
})
)
return null
}
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
}),
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 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()
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,
},
})
)
return null
}
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,
})
)
return null
}
metrics.breakfastPackage.success.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
)
}),
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 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),
{
cache: undefined,
headers: {
Authorization: `Bearer ${ctx.serviceToken}`,
},
next: {
revalidate: 60,
},
},
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
}),
}),
})