Files
web/packages/trpc/lib/routers/autocomplete/destinations.ts
Joakim Jäderberg 15a2da333d Merged in feature/SW-3572-hotel-data-endpoint (pull request #3051)
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
2025-11-03 12:10:22 +00:00

220 lines
6.8 KiB
TypeScript

import { z } from "zod"
import { Lang } from "@scandic-hotels/common/constants/language"
import { getCacheClient } from "@scandic-hotels/common/dataCache"
import { createLogger } from "@scandic-hotels/common/logger/createLogger"
import { isDefined } from "@scandic-hotels/common/utils/isDefined"
import { safeTry } from "@scandic-hotels/common/utils/safeTry"
import { safeProtectedServiceProcedure } from "../../procedures"
import { getCityPageUrls } from "../../routers/contentstack/destinationCityPage/utils"
import { getCountryPageUrls } from "../../routers/contentstack/destinationCountryPage/utils"
import { getHotelPageUrls } from "../../routers/contentstack/hotelPage/utils"
import { ApiCountry, type Country } from "../../types/country"
import { getCitiesByCountry } from "../hotels/services/getCitiesByCountry"
import { getCountries } from "../hotels/services/getCountries"
import { getLocationsByCountries } from "../hotels/services/getLocationsByCountries"
import { filterAndCategorizeAutoComplete } from "./util/filterAndCategorizeAutoComplete"
import { mapLocationToAutoCompleteLocation } from "./util/mapLocationToAutoCompleteLocation"
import type { AutoCompleteLocation } from "./schema"
const destinationsAutoCompleteInputSchema = z.object({
query: z.string(),
selectedHotelId: z.string().optional(),
selectedCity: z.string().optional(),
lang: z.nativeEnum(Lang),
includeTypes: z.array(z.enum(["hotels", "cities", "countries"])),
})
type DestinationsAutoCompleteOutput = {
hits: {
hotels: AutoCompleteLocation[]
cities: AutoCompleteLocation[]
countries: AutoCompleteLocation[]
}
currentSelection: {
hotel: (AutoCompleteLocation & { type: "hotels" }) | null
city: (AutoCompleteLocation & { type: "cities" }) | null
}
}
export const getDestinationsAutoCompleteRoute = safeProtectedServiceProcedure
.input(destinationsAutoCompleteInputSchema)
.query(async ({ ctx, input }): Promise<DestinationsAutoCompleteOutput> => {
const lang = input.lang || ctx.lang
const [locations, error] = await safeTry(
getAutoCompleteDestinationsData({
lang,
serviceToken: ctx.serviceToken,
})
)
if (error || !locations) {
throw new Error("Unable to fetch autocomplete destinations data", {
cause: error,
})
}
const hits = filterAndCategorizeAutoComplete({
locations: locations,
query: input.query,
includeTypes: input.includeTypes,
})
const selectedHotel = locations.find(
(location) =>
location.type === "hotels" && location.id === input.selectedHotelId
)
const selectedCity = locations.find(
(location) =>
location.type === "cities" &&
location.cityIdentifier === input.selectedCity
)
return {
hits: hits,
currentSelection: {
city: isCity(selectedCity) ? selectedCity : null,
hotel: isHotel(selectedHotel) ? selectedHotel : null,
},
}
})
function isHotel(
location: AutoCompleteLocation | null | undefined
): location is AutoCompleteLocation & { type: "hotels" } {
return !!location && location.type === "hotels"
}
function isCity(
location: AutoCompleteLocation | null | undefined
): location is AutoCompleteLocation & { type: "cities" } {
return !!location && location.type === "cities"
}
export async function getAutoCompleteDestinationsData({
lang,
serviceToken,
warmup = false,
}: {
lang: Lang
serviceToken: string
warmup?: boolean
}) {
const autoCompleteLogger = createLogger("autocomplete-destinations")
const cacheClient = await getCacheClient()
return await cacheClient.cacheOrGet(
`autocomplete:destinations:locations:${lang}`,
async () => {
const hotelUrlsPromise = safeTry(getHotelPageUrls(lang))
const cityUrlsPromise = safeTry(getCityPageUrls(lang))
const countryUrlsPromise = safeTry(getCountryPageUrls(lang))
const countries = await getCountries({
lang: lang,
serviceToken,
})
if (!countries) {
autoCompleteLogger.error("Unable to fetch countries")
throw new Error("Unable to fetch countries")
}
const countryNames = countries.data.map((country) => country.name)
const [citiesByCountry, citiesByCountryError] = await safeTry(
getCitiesByCountry({
countries: countryNames,
serviceToken: serviceToken,
lang,
})
)
if (citiesByCountryError || !citiesByCountry) {
autoCompleteLogger.error("Unable to fetch cities by country")
throw new Error("Unable to fetch cities by country")
}
const [locations, locationsError] = await safeTry(
getLocationsByCountries({
lang: lang,
serviceToken: serviceToken,
citiesByCountry: citiesByCountry,
})
)
if (locationsError || !locations) {
autoCompleteLogger.error("Unable to fetch locations")
throw new Error("Unable to fetch locations")
}
const activeLocations = locations.filter((location) => {
return (
location.type === "cities" ||
(location.type === "hotels" && location.isActive)
)
})
const [hotelUrls, hotelUrlsError] = await hotelUrlsPromise
const [cityUrls, cityUrlsError] = await cityUrlsPromise
const [countryUrls, countryUrlsError] = await countryUrlsPromise
if (
hotelUrlsError ||
cityUrlsError ||
countryUrlsError ||
!hotelUrls ||
!cityUrls ||
!countryUrls
) {
autoCompleteLogger.error("Unable to fetch location URLs")
throw new Error("Unable to fetch location URLs")
}
const hotelsAndCities = activeLocations
.map((location) => {
let url: string | undefined
if (location.type === "cities") {
url = cityUrls.find(
(c) =>
c.city &&
location.cityIdentifier &&
c.city === location.cityIdentifier
)?.url
}
if (location.type === "hotels") {
url = hotelUrls.find(
(h) => h.hotelId && location.id && h.hotelId === location.id
)?.url
}
return { ...location, url }
})
.map(mapLocationToAutoCompleteLocation)
.filter(isDefined)
const countryAutoCompleteLocations = countries.data.map((country) => {
const url = countryUrls.find(
(c) =>
c.country && ApiCountry[lang][c.country as Country] === country.name
)?.url
return {
id: country.id,
name: country.name,
type: "countries",
searchTokens: [country.name],
destination: "",
url,
} satisfies AutoCompleteLocation
})
return [...hotelsAndCities, ...countryAutoCompleteLocations]
},
"1d",
{ cacheStrategy: warmup ? "fetch-then-cache" : "cache-first" }
)
}