Files
web/packages/trpc/lib/routers/hotels/services/getLocationsByCountries.ts
Joakim Jäderberg 99537b13e8 Merged in chore/add-error-details-for-sentry (pull request #3378)
Include more details when throwing errors for debugging in Sentry

* WIP throw errors with more details for debugging in Sentry

* Fix throwing response-data

* Clearer message when a response fails

* Add message to errors

* better typings

* .

* Try to send profileID and membershipNumber to Sentry when we fail to parse the apiResponse

* rename notFound -> notFoundError

* Merge branch 'master' of bitbucket.org:scandic-swap/web into chore/add-error-details-for-sentry


Approved-by: Linus Flood
2026-01-12 09:01:44 +00:00

224 lines
5.6 KiB
TypeScript

import { z } from "zod"
import { getCacheClient } from "@scandic-hotels/common/dataCache"
import { createLogger } from "@scandic-hotels/common/logger/createLogger"
import * as api from "../../../api"
import { extractResponseDetails, serverErrorByStatus } from "../../../errors"
import { toApiLang } from "../../../utils"
import { locationCitySchema } from "../schemas/location/city"
import { locationHotelSchema } from "../schemas/location/hotel"
import type { Country } from "@scandic-hotels/common/constants/country"
import type { Lang } from "@scandic-hotels/common/constants/language"
const hotelUtilsLogger = createLogger("getLocationsByCountries")
type CitiesNamesByCountry = Record<
Country | (string & {}),
Array<{ name: string }>
> | null
type Hotel = Extract<
z.infer<typeof locationsSchema>["data"][number],
{ type: "hotels" }
>
type City = Extract<
z.infer<typeof locationsSchema>["data"][number],
{ type: "cities" }
>
export async function getLocationsByCountries({
lang,
citiesByCountry,
serviceToken,
}: {
lang: Lang
citiesByCountry: CitiesNamesByCountry | null
serviceToken: string
}) {
const cacheClient = await getCacheClient()
const countryKeys = Object.keys(citiesByCountry ?? {})
let cacheKey = `${lang}:locations`
if (countryKeys.length > 0) {
cacheKey += `:${countryKeys.toSorted().join(",")}`
}
return await cacheClient.cacheOrGet(
cacheKey.toLowerCase(),
async () => {
const apiResponse = await api.get(
api.endpoints.v1.Hotel.locations,
{
headers: {
Authorization: `Bearer ${serviceToken}`,
},
},
{
language: toApiLang(lang),
}
)
if (!apiResponse.ok) {
throw serverErrorByStatus(
apiResponse.status,
await extractResponseDetails(apiResponse),
"getLocationsByCountries failed"
)
}
const apiJson = await apiResponse.json()
const verifiedLocations = locationsSchema.safeParse(apiJson)
if (!verifiedLocations.success) {
hotelUtilsLogger.error(
`Locations Verification Failed`,
verifiedLocations.error
)
throw new Error("Unable to parse api response for locations", {
cause: verifiedLocations.error,
})
}
const data = cleanData(verifiedLocations.data.data)
const cities = data
.filter((x) => x.type === "cities")
.map((city) => addCountryDataToCity(city, citiesByCountry))
const hotels = data
.filter((x) => x.type === "hotels")
.map((hotel) => addCityDataToHotel(hotel, cities))
let locations: z.infer<typeof locationsSchema>["data"] = [
...cities,
...hotels,
]
return locations
},
"1d"
)
}
function addCityDataToHotel(hotel: Hotel, cities: City[]) {
const city = cities.find((c) => c.id === hotel.relationships.city.id)
if (!city) {
hotelUtilsLogger.warn(
`City with id ${hotel.relationships.city.id} not found for hotel ${hotel.id}`
)
return hotel
}
return {
...hotel,
relationships: {
city: {
...city,
cityIdentifier: city.cityIdentifier,
url: hotel.relationships.city.url,
keywords: city.keyWords ?? [],
},
},
} satisfies Hotel
}
function addCountryDataToCity(
city: City,
citiesByCountry: CitiesNamesByCountry | null
): City {
if (!citiesByCountry) {
return city
}
const country = Object.keys(citiesByCountry).find((country) =>
citiesByCountry[country].find((loc) => loc.name === city.name)
)
if (!country) {
hotelUtilsLogger.error(
`Location cannot be found in any of the countries cities`,
city
)
return city
}
return {
...city,
country,
}
}
function cleanData(data: z.infer<typeof locationsSchema>["data"]) {
return data
.filter((node) => {
if (node?.isPublished !== true) {
return false
}
if (node.type === "hotels" && !node.operaId) {
return false
}
if (node.type === "cities" && !node.cityIdentifier) {
return false
}
return true
})
.toSorted((a, b) => {
if (a.type === b.type) {
return a.name.localeCompare(b.name)
} else {
return a.type === "cities" ? -1 : 1
}
})
}
export const locationsSchema = z.object({
data: z.array(
z
.discriminatedUnion("type", [locationCitySchema, locationHotelSchema])
.transform((location) => {
if (location.type === "cities") {
return {
...location.attributes,
country: location.attributes.countryName || "",
id: location.id,
type: location.type,
}
}
return {
...location.attributes,
id: location.id,
relationships: {
city: {
cityIdentifier: undefined as string | undefined,
id: extractCityId(
location.relationships?.city?.links?.related ?? ""
),
isPublished: false,
keywords: [] as string[],
name: undefined as string | undefined,
type: "cities",
url: location?.relationships?.city?.links?.related ?? "",
},
},
type: location.type,
operaId: location.attributes.operaId ?? "",
}
})
),
})
function extractCityId(cityUrl: string): string | null {
try {
const url = new URL(cityUrl)
if (!url.pathname.toLowerCase().includes("/cities/")) {
return null
}
const id = url.pathname.split("/").at(-1)
return id ?? null
} catch {
return null
}
}