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
224 lines
5.6 KiB
TypeScript
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
|
|
}
|
|
}
|