import deepmerge from "deepmerge" import { Lang } from "@/constants/languages" import { env } from "@/env/server" import * as api from "@/lib/api" import { toApiLang } from "@/server/utils" import { getCacheClient } from "@/services/dataCache" import { getHotelPageUrls } from "../contentstack/hotelPage/utils" import { metrics } from "./metrics" import { type Cities, citiesByCountrySchema, citiesSchema, countriesSchema, getHotelIdsSchema, locationsSchema, } from "./output" import { getHotel } from "./query" import { PointOfInterestGroupEnum } from "@/types/enums/pointOfInterest" import type { HotelDataWithUrl } from "@/types/hotel" import type { CitiesGroupedByCountry, CityLocation, } from "@/types/trpc/routers/hotel/locations" import type { Endpoint } from "@/lib/api/endpoints" export function getPoiGroupByCategoryName(category: string | undefined) { if (!category) return PointOfInterestGroupEnum.LOCATION switch (category) { case "Airport": case "Bus terminal": case "Transportations": return PointOfInterestGroupEnum.PUBLIC_TRANSPORT case "Amusement park": case "Museum": case "Sports": case "Theatre": case "Tourist": case "Zoo": return PointOfInterestGroupEnum.ATTRACTIONS case "Nearby companies": case "Fair": return PointOfInterestGroupEnum.BUSINESS case "Parking / Garage": return PointOfInterestGroupEnum.PARKING case "Shopping": case "Restaurant": return PointOfInterestGroupEnum.SHOPPING_DINING case "Hospital": default: return PointOfInterestGroupEnum.LOCATION } } export const locationsAffix = "locations" export const TWENTYFOUR_HOURS = 60 * 60 * 24 export async function getCity({ cityUrl, serviceToken, }: { cityUrl: string serviceToken: string }): Promise { const cacheClient = await getCacheClient() return await cacheClient.cacheOrGet( cityUrl, async () => { const url = new URL(cityUrl) const cityResponse = await api.get( url.pathname as Endpoint, { headers: { Authorization: `Bearer ${serviceToken}` } }, url.searchParams ) if (!cityResponse.ok) { return null } const cityJson = await cityResponse.json() const city = citiesSchema.safeParse(cityJson) if (!city.success) { console.info(`Validation of city failed`) console.info(`cityUrl: ${cityUrl}`) console.error(city.error) return null } return city.data }, "1d" ) } export async function getCountries({ lang, serviceToken, }: { lang: Lang serviceToken: string }) { const cacheClient = await getCacheClient() return await cacheClient.cacheOrGet( `${lang}:${locationsAffix}:countries`, async () => { const params = new URLSearchParams({ language: toApiLang(lang), }) const countryResponse = await api.get( api.endpoints.v1.Hotel.countries, { headers: { Authorization: `Bearer ${serviceToken}`, }, }, params ) if (!countryResponse.ok) { throw new Error("Unable to fetch countries") } const countriesJson = await countryResponse.json() const countries = countriesSchema.safeParse(countriesJson) if (!countries.success) { console.info(`Validation for countries failed`) console.error(countries.error) return null } return countries.data }, "1d" ) } export async function getCitiesByCountry({ countries, lang, onlyPublished = false, affix = locationsAffix, serviceToken, }: { countries: string[] lang: Lang onlyPublished?: boolean // false by default as it might be used in other places affix?: string serviceToken: string }): Promise { const cacheClient = await getCacheClient() const allCitiesByCountries = await Promise.all( countries.map(async (country) => { return cacheClient.cacheOrGet( `${lang}:${affix}:cities-by-country:${country}`, async () => { const params = new URLSearchParams({ language: toApiLang(lang), }) const countryResponse = await api.get( api.endpoints.v1.Hotel.Cities.country(country), { headers: { Authorization: `Bearer ${serviceToken}`, }, }, params ) if (!countryResponse.ok) { throw new Error(`Unable to fetch cities by country ${country}`) } const countryJson = await countryResponse.json() const citiesByCountry = citiesByCountrySchema.safeParse(countryJson) if (!citiesByCountry.success) { console.error(`Unable to parse cities by country ${country}`) console.error(citiesByCountry.error) throw new Error(`Unable to parse cities by country ${country}`) } return { ...citiesByCountry.data, country } }, "1d" ) }) ) const filteredCitiesByCountries = allCitiesByCountries.map((country) => ({ ...country, data: onlyPublished ? country.data.filter((city) => city.isPublished) : country.data, })) const groupedCitiesByCountry: CitiesGroupedByCountry = filteredCitiesByCountries.reduce((acc, { country, data }) => { acc[country] = data return acc }, {} as CitiesGroupedByCountry) return groupedCitiesByCountry } export async function getLocations({ lang, citiesByCountry, serviceToken, }: { lang: Lang citiesByCountry: CitiesGroupedByCountry | null serviceToken: string }) { const cacheClient = await getCacheClient() return await cacheClient.cacheOrGet( `${lang}:locations`.toLowerCase(), async () => { const params = new URLSearchParams({ language: toApiLang(lang), }) const apiResponse = await api.get( api.endpoints.v1.Hotel.locations, { headers: { Authorization: `Bearer ${serviceToken}`, }, }, params ) if (!apiResponse.ok) { if (apiResponse.status === 401) { throw new Error("unauthorized") } else if (apiResponse.status === 403) { throw new Error("forbidden") } throw new Error("downstream error") } const apiJson = await apiResponse.json() const verifiedLocations = locationsSchema.safeParse(apiJson) if (!verifiedLocations.success) { console.info(`Locations Verification Failed`) console.error(verifiedLocations.error) throw new Error("Unable to parse locations") } return await Promise.all( verifiedLocations.data.data.map(async (location) => { if (location.type === "cities") { if (citiesByCountry) { const country = Object.keys(citiesByCountry).find((country) => citiesByCountry[country].find( (loc) => loc.name === location.name ) ) if (country) { return { ...location, country, } } else { console.info( `Location cannot be found in any of the countries cities` ) console.info(location) } } } else if (location.type === "hotels") { if (location.relationships.city?.url) { const city = await getCity({ cityUrl: location.relationships.city.url, serviceToken, }) if (city) { return deepmerge(location, { relationships: { city, }, }) } } } return location }) ) }, "1d" ) } export async function getHotelIdsByCityId({ cityId, serviceToken, }: { cityId: string serviceToken: string }) { const cacheClient = await getCacheClient() return await cacheClient.cacheOrGet( `${cityId}:hotelsByCityId`, async () => { const searchParams = new URLSearchParams({ city: cityId, }) metrics.hotelIds.counter.add(1, { params: searchParams.toString() }) console.info( "api.hotel.hotel-ids start", JSON.stringify({ params: searchParams.toString() }) ) const apiResponse = await api.get( api.endpoints.v1.Hotel.hotels, { headers: { Authorization: `Bearer ${serviceToken}`, }, }, searchParams ) if (!apiResponse.ok) { const responseMessage = await apiResponse.text() metrics.hotelIds.fail.add(1, { params: searchParams.toString(), error_type: "http_error", error: responseMessage, }) console.error( "api.hotel.hotel-ids fetch error", JSON.stringify({ params: searchParams.toString(), error: { status: apiResponse.status, statusText: apiResponse.statusText, text: responseMessage, }, }) ) throw new Error("Unable to fetch hotelIds by cityId") } const apiJson = await apiResponse.json() const validatedHotelIds = getHotelIdsSchema.safeParse(apiJson) if (!validatedHotelIds.success) { metrics.hotelIds.fail.add(1, { params: searchParams.toString(), error_type: "validation_error", error: JSON.stringify(validatedHotelIds.error), }) console.error( "api.hotel.hotel-ids validation error", JSON.stringify({ params: searchParams.toString(), error: validatedHotelIds.error, }) ) throw new Error("Unable to parse data for hotelIds by cityId") } metrics.hotelIds.success.add(1, { cityId }) console.info( "api.hotel.hotel-ids success", JSON.stringify({ params: searchParams.toString(), response: validatedHotelIds.data, }) ) return validatedHotelIds.data }, env.CACHE_TIME_HOTELS ) } export async function getHotelIdsByCountry({ country, serviceToken, }: { country: string serviceToken: string }) { const cacheClient = await getCacheClient() return await cacheClient.cacheOrGet( `${country}:hotelsByCountry`, async () => { metrics.hotelIds.counter.add(1, { country }) console.info( "api.hotel.hotel-ids start", JSON.stringify({ query: { country } }) ) const hotelIdsParams = new URLSearchParams({ country, }) const apiResponse = await api.get( api.endpoints.v1.Hotel.hotels, { headers: { Authorization: `Bearer ${serviceToken}`, }, }, hotelIdsParams ) if (!apiResponse.ok) { const responseMessage = await apiResponse.text() metrics.hotelIds.fail.add(1, { country, error_type: "http_error", error: responseMessage, }) console.error( "api.hotel.hotel-ids fetch error", JSON.stringify({ query: { country }, error: { status: apiResponse.status, statusText: apiResponse.statusText, text: responseMessage, }, }) ) throw new Error("Unable to fetch hotelIds by country") } const apiJson = await apiResponse.json() const validatedHotelIds = getHotelIdsSchema.safeParse(apiJson) if (!validatedHotelIds.success) { metrics.hotelIds.fail.add(1, { country, error_type: "validation_error", error: JSON.stringify(validatedHotelIds.error), }) console.error( "api.hotel.hotel-ids validation error", JSON.stringify({ query: { country }, error: validatedHotelIds.error, }) ) throw new Error("Unable to parse hotelIds by country") } metrics.hotelIds.success.add(1, { country }) console.info( "api.hotel.hotel-ids success", JSON.stringify({ query: { country } }) ) return validatedHotelIds.data }, env.CACHE_TIME_HOTELS ) } export async function getHotelIdsByCityIdentifier( cityIdentifier: string, serviceToken: string ) { const city = await getCityByCityIdentifier({ cityIdentifier, lang: Lang.en, serviceToken, }) if (!city) { return [] } const hotelIds = await getHotelIdsByCityId({ cityId: city.id, serviceToken, }) return hotelIds } export async function getCityByCityIdentifier({ cityIdentifier, lang, serviceToken, }: { cityIdentifier: string lang: Lang serviceToken: string }) { const locations = await getLocations({ lang, citiesByCountry: null, serviceToken, }) if (!locations || "error" in locations) { return null } const city = locations .filter((loc): loc is CityLocation => loc.type === "cities") .find((loc) => loc.cityIdentifier === cityIdentifier) return city ?? null } export async function getHotelsByHotelIds({ hotelIds, lang, serviceToken, }: { hotelIds: string[] 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 }) ) return hotels.filter((hotel): hotel is HotelDataWithUrl => !!hotel) }