Merged in fix/destinations-speed-test (pull request #1704)

Feat(destination pages): Performance improvements

* fix/destinations: try cache full response

* Added more caching

* Removed unsed env car

* wip

* merge master

* wip

* wip

* wip

* Renaming


Approved-by: Michael Zetterberg
This commit is contained in:
Linus Flood
2025-04-02 11:37:22 +00:00
parent 961e8aea91
commit e4907d4b47
34 changed files with 381 additions and 290 deletions

View File

@@ -96,9 +96,10 @@ export const nearbyHotelIdsInput = z.object({
hotelId: z.string(),
})
export const getAllHotelsInput = z
export const getDestinationsMapDataInput = z
.object({
lang: z.nativeEnum(Lang),
warmup: z.boolean().optional(),
})
.optional()

View File

@@ -12,6 +12,10 @@ import {
includedSchema,
relationshipsSchema as hotelRelationshipsSchema,
} from "./schemas/hotel"
import { addressSchema } from "./schemas/hotel/address"
import { detailedFacilitiesSchema } from "./schemas/hotel/detailedFacility"
import { locationSchema } from "./schemas/hotel/location"
import { imageSchema } from "./schemas/image"
import { locationCitySchema } from "./schemas/location/city"
import { locationHotelSchema } from "./schemas/location/hotel"
import {
@@ -657,3 +661,40 @@ export const roomFeaturesSchema = z
.transform((data) => {
return data.data.attributes.roomFeatures
})
export const destinationPagesHotelDataSchema = z
.object({
data: z.object({
id: z.string(),
name: z.string(),
location: locationSchema,
cityIdentifier: z.string().optional(),
tripadvisor: z.number().optional(),
detailedFacilities: detailedFacilitiesSchema,
galleryImages: z
.array(imageSchema)
.nullish()
.transform((arr) => (arr ? arr.filter(Boolean) : [])),
address: addressSchema,
hotelType: z.string(),
type: z.literal("hotels"), // No enum here but the standard return appears to be "hotels".
url: z.string().optional(),
hotelContent: z
.object({
texts: z.object({
descriptions: z.object({
short: z.string().optional(),
}),
}),
})
.optional(),
}),
})
.transform(({ data: { ...data } }) => {
return {
hotel: {
...data,
},
url: data.url ?? "",
}
})

View File

@@ -26,7 +26,7 @@ import {
breakfastPackageInputSchema,
cityCoordinatesInputSchema,
getAdditionalDataInputSchema,
getAllHotelsInput,
getDestinationsMapDataInput,
getHotelsByCityIdentifierInput,
getHotelsByCountryInput,
getHotelsByCSFilterInput,
@@ -70,7 +70,7 @@ import type { BedTypeSelection } from "@/types/components/hotelReservation/enter
import { BreakfastPackageEnum } from "@/types/enums/breakfast"
import { HotelTypeEnum } from "@/types/enums/hotelType"
import { RateTypeEnum } from "@/types/enums/rateType"
import type { HotelDataWithUrl } from "@/types/hotel"
import type { DestinationPagesHotelData, HotelDataWithUrl } from "@/types/hotel"
import type {
HotelsAvailabilityInputSchema,
HotelsByHotelIdsAvailabilityInputSchema,
@@ -1292,45 +1292,59 @@ export const hotelQueryRouter = router({
return hotels.filter((hotel): hotel is HotelDataWithUrl => !!hotel)
}),
}),
getAllHotels: router({
get: serviceProcedure.input(getAllHotelsInput).query(async function ({
input,
ctx,
}) {
getDestinationsMapData: serviceProcedure
.input(getDestinationsMapDataInput)
.query(async function ({ input, ctx }) {
const lang = input?.lang ?? ctx.lang
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,
})
const warmup = input?.warmup ?? false
if (!countries) {
throw new Error("Unable to fetch countries")
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 countryNames = countries.data.map((country) => country.name)
const hotelData: HotelDataWithUrl[] = (
await Promise.all(
countryNames.map(async (country) => {
const hotelIds = await getHotelIdsByCountry({
country,
serviceToken: ctx.serviceToken,
})
if (warmup) {
return await fetchHotels()
}
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"
)
}),
}),
}),
nearbyHotelIds: serviceProcedure
.input(nearbyHotelIdsInput)
.query(async function ({ ctx, input }) {
@@ -1341,74 +1355,81 @@ export const hotelQueryRouter = router({
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,
const cacheClient = await getCacheClient()
return cacheClient.cacheOrGet(
`${apiLang}:nearbyHotels:${hotelId}`,
async () => {
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,
})
)
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 },
})
)
console.info(
"api.hotels.nearbyHotelIds success",
JSON.stringify({
query: { hotelId, params },
})
)
return validateHotelData.data.map((id: string) => parseInt(id, 10))
return validateHotelData.data.map((id: string) => parseInt(id, 10))
},
env.CACHE_TIME_HOTELS
)
}),
locations: router({
get: serviceProcedure.input(getLocationsInput).query(async function ({
@@ -1459,22 +1480,29 @@ export const hotelQueryRouter = router({
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()
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)
const data = await response.json()
if (data.status !== "OK") {
console.error(`Geocode error: ${data.status}`)
return null
}
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
}
const location = data.results[0]?.geometry?.location
if (!location) {
console.error("No location found in geocode response")
return null
}
return location
return location
},
"1d"
)
}
let location = await fetchCoordinates(city)

View File

@@ -24,7 +24,7 @@ import { getHotel } from "./query"
import type { z } from "zod"
import { PointOfInterestGroupEnum } from "@/types/enums/pointOfInterest"
import type { HotelDataWithUrl } from "@/types/hotel"
import type { DestinationPagesHotelData } from "@/types/hotel"
import type {
CitiesGroupedByCountry,
CityLocation,
@@ -529,19 +529,54 @@ export async function getHotelsByHotelIds({
lang: Lang
serviceToken: string
}) {
const hotelPages = await getHotelPageUrls(lang)
const hotels = await Promise.all(
hotelIds.map(async (hotelId) => {
const hotelData = await getHotel(
{ hotelId, language: lang, isCardOnlyPayment: false },
serviceToken
)
const hotelPage = hotelPages.find((page) => page.hotelId === hotelId)
return hotelData ? { ...hotelData, url: hotelPage?.url ?? null } : null
})
)
const cacheClient = await getCacheClient()
const cacheKey = `${lang}:getHotelsByHotelIds:hotels:${hotelIds.sort().join(",")}`
return hotels.filter((hotel): hotel is HotelDataWithUrl => !!hotel)
return await cacheClient.cacheOrGet(
cacheKey,
async () => {
const hotelPages = await getHotelPageUrls(lang)
const hotels = await Promise.all(
hotelIds.map(async (hotelId) => {
const hotelResponse = await getHotel(
{ hotelId, language: lang, isCardOnlyPayment: false },
serviceToken
)
if (!hotelResponse) {
throw new Error(`Hotel not found: ${hotelId}`)
}
const hotelPage = hotelPages.find((page) => page.hotelId === hotelId)
const { hotel, cities } = hotelResponse
const data: DestinationPagesHotelData = {
hotel: {
id: hotel.id,
galleryImages: hotel.galleryImages?.length
? [hotel.galleryImages[0]]
: [],
name: hotel.name,
tripadvisor: hotel.ratings?.tripAdvisor?.rating,
detailedFacilities: hotel.detailedFacilities?.slice(0, 3) || [],
location: hotel.location,
hotelType: hotel.hotelType,
type: hotel.type,
address: hotel.address,
cityIdentifier: cities?.[0]?.cityIdentifier,
},
url: hotelPage?.url ?? "",
} satisfies DestinationPagesHotelData
return { ...data, url: hotelPage?.url ?? null }
})
)
return hotels.filter(
(hotel): hotel is DestinationPagesHotelData => !!hotel
)
},
"1d"
)
}
function findProduct(product: Products, rateDefinition: RateDefinition) {
@@ -697,10 +732,13 @@ export async function getSelectedRoomAvailability(
}
if (Array.isArray(product)) {
const redemptionProduct = userPoints ? product.find(
(r) => r.redemption.rateCode === rateDefinition.rateCode &&
r.redemption.localPrice.pointsPerStay <= userPoints
) : undefined
const redemptionProduct = userPoints
? product.find(
(r) =>
r.redemption.rateCode === rateDefinition.rateCode &&
r.redemption.localPrice.pointsPerStay <= userPoints
)
: undefined
if (!redemptionProduct) {
return null
}