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
This commit is contained in:
Joakim Jäderberg
2025-11-03 12:10:22 +00:00
parent e8626d56af
commit 15a2da333d
25 changed files with 1227 additions and 249 deletions

View File

@@ -1,4 +1,4 @@
{ {
"typescript.tsdk": "node_modules/typescript/lib", "typescript.tsdk": "node_modules/typescript/lib",
"typescript.experimental.useTsgo": false "typescript.experimental.useTsgo": false
} }

View File

@@ -0,0 +1,139 @@
import * as Sentry from "@sentry/nextjs"
import { TRPCError } from "@trpc/server"
import { unstable_noStore as noStore } from "next/cache"
import { NextResponse } from "next/server"
import { Lang } from "@scandic-hotels/common/constants/language"
import { equalsIgnoreCaseAndAccents } from "@scandic-hotels/common/utils/stringEquals"
import {
gatewayTimeout,
httpStatusByErrorCode,
notFound,
} from "@scandic-hotels/trpc/errors"
import { Country } from "@scandic-hotels/trpc/types/country"
import {
isCityLocation,
isHotelLocation,
} from "@scandic-hotels/trpc/types/locations"
import { serverClient } from "@/lib/trpc/server"
import { timeout } from "@/utils/timeout"
import { createDataResponse } from "../createDataResponse"
export const revalidate = 28_800 // 8 hours
export async function GET(
_request: Request,
{ params }: { params: Promise<{ country: string; city: string }> }
) {
try {
const { country: countryParam, city: cityParam } = await params
const country = Object.values(Country).find((c) =>
equalsIgnoreCaseAndAccents(c, countryParam)
)
if (!country) {
throw notFound(`Country "${countryParam.toLowerCase()}" not found`)
}
const caller = await serverClient()
const locations = await Promise.any([
timeout(3_000).then(() => {
throw gatewayTimeout("Fetching locations timed out")
}),
caller.hotel.locations.get({ lang: Lang.en }),
])
const city = locations.filter(isCityLocation).find((c) => {
return (
equalsIgnoreCaseAndAccents(c.name, cityParam) &&
equalsIgnoreCaseAndAccents(c.countryName, countryParam)
)
})
if (!city) {
throw notFound(
`City "${cityParam.toLowerCase()}" not found in country "${countryParam.toLowerCase()}"`
)
}
const hotels = locations
.filter(isHotelLocation)
.filter(
(x) =>
x.isActive &&
x.isPublished &&
equalsIgnoreCaseAndAccents(x.relationships.city.name, cityParam)
)
if (hotels.length === 0) {
throw notFound(
`No hotels found in city "${cityParam.toLowerCase()}" and country "${countryParam.toLowerCase()}"`
)
}
return NextResponse.json(
createDataResponse({ countryParam, cityParam, hotels }),
{
status: 200,
headers: {
"Cache-Control": `public, max-age=${revalidate}, stale-while-revalidate=86400`,
"Netlify-CDN-Cache-Control": `public, max-age=${revalidate}, stale-while-revalidate=86400`,
},
}
)
} catch (error) {
noStore()
const noCacheHeader: HeadersInit = {
"Cache-Control": `no-store, max-age=0`,
}
if (error instanceof TRPCError) {
switch (error.code) {
case "GATEWAY_TIMEOUT":
case "INTERNAL_SERVER_ERROR": {
return NextResponse.json(
{
message: error.cause?.toString() || error.message,
},
{
status: httpStatusByErrorCode(error),
headers: {
...noCacheHeader,
},
}
)
}
case "NOT_FOUND": {
return NextResponse.json(
{
message: error.cause?.toString() || error.message,
},
{
status: 404,
headers: {
"Cache-Control": `public, max-age=${revalidate}, stale-while-revalidate=86400`,
"Netlify-CDN-Cache-Control": `public, max-age=${revalidate}, stale-while-revalidate=86400`,
},
}
)
}
}
}
Sentry.captureException(error)
return NextResponse.json(
{ message: "Internal Server Error" },
{
status: 500,
headers: {
...noCacheHeader,
},
}
)
}
}

View File

@@ -0,0 +1,59 @@
export function createDataResponse(
{
countryParam,
cityParam,
hotels,
}: {
countryParam: string
cityParam?: string
hotels: Array<{
name: string
distanceToCentre?: number | undefined
relationships: { city: { name: string } }
images?: { large?: string } | undefined
}>
},
options?: { includeCity: boolean }
) {
const { includeCity = false } = options || {}
return {
country: countryParam.toLowerCase(),
city: cityParam?.toLowerCase(),
hotels: hotels
.map((h) => ({
name: h.name,
city: includeCity ? h.relationships.city.name : undefined,
metersToCityCentre: h.distanceToCentre,
images: {
tiny: createImageUrl({ src: h.images?.large, width: 300 }),
small: createImageUrl({ src: h.images?.large, width: 500 }),
medium: createImageUrl({ src: h.images?.large, width: 1080 }),
large: createImageUrl({ src: h.images?.large, width: 1920 }),
},
}))
.toSorted((a, b) => a.name.localeCompare(b.name))
.toSorted((a, b) => {
return (
(a.metersToCityCentre ?? Infinity) -
(b.metersToCityCentre ?? Infinity)
)
})
.toSorted((a, b) => {
if (!includeCity) return 0
if (!a.city || !b.city) return 0
return a.city.localeCompare(b.city)
}),
}
}
function createImageUrl({
src,
width,
}: {
src: string | null | undefined
width: number
}) {
if (!src) return undefined
return `https://img.scandichotels.com/.netlify/images?url=${encodeURIComponent(src)}&w=${width}&q=90`
}

View File

@@ -0,0 +1,137 @@
import * as Sentry from "@sentry/nextjs"
import { TRPCError } from "@trpc/server"
import { unstable_noStore as noStore } from "next/cache"
import { NextResponse } from "next/server"
import { Lang } from "@scandic-hotels/common/constants/language"
import { equalsIgnoreCaseAndAccents } from "@scandic-hotels/common/utils/stringEquals"
import {
gatewayTimeout,
httpStatusByErrorCode,
notFound,
} from "@scandic-hotels/trpc/errors"
import { Country } from "@scandic-hotels/trpc/types/country"
import {
isCityLocation,
isHotelLocation,
} from "@scandic-hotels/trpc/types/locations"
import { serverClient } from "@/lib/trpc/server"
import { timeout } from "@/utils/timeout"
import { createDataResponse } from "./createDataResponse"
export const revalidate = 28_800 // 8 hours
export async function GET(
_request: Request,
{ params }: { params: Promise<{ country: string }> }
) {
try {
const { country: countryParam } = await params
const country = Object.values(Country).find((c) =>
equalsIgnoreCaseAndAccents(c, countryParam)
)
if (!country) {
throw notFound(`Country "${countryParam.toLowerCase()}" not found`)
}
const caller = await serverClient()
const locations = await Promise.any([
timeout(3_000).then(() => {
throw gatewayTimeout("Fetching locations timed out")
}),
caller.hotel.locations.get({ lang: Lang.en }),
])
const cities = locations.filter(isCityLocation).filter((c) => {
return equalsIgnoreCaseAndAccents(c.countryName, countryParam)
})
if (cities.length === 0) {
throw notFound(
`No cities found in country "${countryParam.toLowerCase()}"`
)
}
const hotels = locations
.filter(isHotelLocation)
.filter(
(x) =>
x.isActive &&
x.isPublished &&
cities.some((c) =>
equalsIgnoreCaseAndAccents(x.relationships.city.name, c.name)
)
)
if (hotels.length === 0) {
throw notFound(
`No hotels found in country "${countryParam.toLowerCase()}"`
)
}
return NextResponse.json(
createDataResponse({ countryParam, hotels }, { includeCity: true }),
{
status: 200,
headers: {
"Cache-Control": `public, max-age=${revalidate}, stale-while-revalidate=86400`,
"Netlify-CDN-Cache-Control": `public, max-age=${revalidate}, stale-while-revalidate=86400`,
},
}
)
} catch (error) {
noStore()
const noCacheHeader: HeadersInit = {
"Cache-Control": `no-store, max-age=0`,
}
if (error instanceof TRPCError) {
switch (error.code) {
case "GATEWAY_TIMEOUT":
case "INTERNAL_SERVER_ERROR": {
return NextResponse.json(
{
message: error.cause?.toString() || error.message,
},
{
status: httpStatusByErrorCode(error),
headers: {
...noCacheHeader,
},
}
)
}
case "NOT_FOUND": {
return NextResponse.json(
{
message: error.cause?.toString() || error.message,
},
{
status: 404,
headers: {
"Cache-Control": `public, max-age=${revalidate}, stale-while-revalidate=86400`,
"Netlify-CDN-Cache-Control": `public, max-age=${revalidate}, stale-while-revalidate=86400`,
},
}
)
}
}
}
Sentry.captureException(error)
return NextResponse.json(
{ message: "Internal Server Error" },
{
status: 500,
headers: {
...noCacheHeader,
},
}
)
}
}

View File

@@ -1,5 +1,4 @@
/* eslint-disable formatjs/no-literal-string-in-jsx */ /* eslint-disable formatjs/no-literal-string-in-jsx */
import { getLang } from "@/i18n/serverContext" import { getLang } from "@/i18n/serverContext"
import { texts } from "./Texts" import { texts } from "./Texts"

View File

@@ -2,6 +2,7 @@ import { safeTry } from "@scandic-hotels/common/utils/safeTry"
import { SEARCH_TYPE_REDEMPTION } from "@scandic-hotels/trpc/constants/booking" import { SEARCH_TYPE_REDEMPTION } from "@scandic-hotels/trpc/constants/booking"
import { import {
type HotelLocation, type HotelLocation,
isCityLocation,
isHotelLocation, isHotelLocation,
type Location, type Location,
} from "@scandic-hotels/trpc/types/locations" } from "@scandic-hotels/trpc/types/locations"
@@ -37,12 +38,9 @@ export async function getHotelSearchDetails(params: {
} }
const hotel = params.hotelId const hotel = params.hotelId
? ((locations.find( ? (locations
(location) => .filter(isHotelLocation)
isHotelLocation(location) && .find((location) => location.operaId === params.hotelId) ?? null)
"operaId" in location &&
location.operaId === params.hotelId
) as HotelLocation | undefined) ?? null)
: null : null
if (params.isAlternativeHotels && !hotel) { if (params.isAlternativeHotels && !hotel) {
@@ -54,12 +52,13 @@ export async function getHotelSearchDetails(params: {
: params.city : params.city
const city = cityIdentifier const city = cityIdentifier
? (locations.find( ? (locations
(location) => .filter(isCityLocation)
"cityIdentifier" in location && .find(
location.cityIdentifier?.toLowerCase() === (location) =>
location.cityIdentifier?.toLowerCase() ===
cityIdentifier.toLowerCase() cityIdentifier.toLowerCase()
) ?? null) ) ?? null)
: null : null
if (!city && !hotel) return null if (!city && !hotel) return null

View File

@@ -48,6 +48,7 @@
"./tracking/useFormTracking": "./tracking/useFormTracking.ts", "./tracking/useFormTracking": "./tracking/useFormTracking.ts",
"./tracking/useTrackHardNavigation": "./tracking/useTrackHardNavigation.ts", "./tracking/useTrackHardNavigation": "./tracking/useTrackHardNavigation.ts",
"./tracking/useTrackSoftNavigation": "./tracking/useTrackSoftNavigation.ts", "./tracking/useTrackSoftNavigation": "./tracking/useTrackSoftNavigation.ts",
"./utils/stringEquals": "./utils/stringEquals.ts",
"./utils/chunk": "./utils/chunk.ts", "./utils/chunk": "./utils/chunk.ts",
"./utils/dateFormatting": "./utils/dateFormatting.ts", "./utils/dateFormatting": "./utils/dateFormatting.ts",
"./utils/debounce": "./utils/debounce.ts", "./utils/debounce": "./utils/debounce.ts",

View File

@@ -0,0 +1,68 @@
import { describe, expect, it } from "vitest"
import { equalsIgnoreCase, equalsIgnoreCaseAndAccents } from "./stringEquals"
describe("equalsIgnoreCase", () => {
it("returns true for identical strings", () => {
expect(equalsIgnoreCase("Hello", "Hello")).toBe(true)
})
it("is case-insensitive for ASCII letters", () => {
expect(equalsIgnoreCase("Hello", "hello")).toBe(true)
expect(equalsIgnoreCase("TEST", "test")).toBe(true)
})
it("returns false for different strings", () => {
expect(equalsIgnoreCase("apple", "apricot")).toBe(false)
})
it("handles empty strings", () => {
expect(equalsIgnoreCase("", "")).toBe(true)
expect(equalsIgnoreCase("", " ")).toBe(false)
})
it("takes diacritics into account", () => {
expect(equalsIgnoreCase("resume", "résumé")).toBe(false)
})
it("treats composed and decomposed forms as equal", () => {
// composed vs decomposed (e + combining acute)
expect(equalsIgnoreCase("é", "e\u0301")).toBe(true)
})
it("considers whitespace and length differences significant", () => {
expect(equalsIgnoreCase(" hello", "hello")).toBe(false)
expect(equalsIgnoreCase("hello", "hello ")).toBe(false)
})
})
describe("equalsIgnoreCaseAndAccents", () => {
it("returns true for identical strings", () => {
expect(equalsIgnoreCaseAndAccents("Hello", "Hello")).toBe(true)
})
it("is case-insensitive for ASCII letters", () => {
expect(equalsIgnoreCaseAndAccents("Hello", "hello")).toBe(true)
expect(equalsIgnoreCaseAndAccents("TEST", "test")).toBe(true)
})
it("returns false for different strings", () => {
expect(equalsIgnoreCaseAndAccents("apple", "apricot")).toBe(false)
})
it("handles empty strings", () => {
expect(equalsIgnoreCaseAndAccents("", "")).toBe(true)
expect(equalsIgnoreCaseAndAccents("", " ")).toBe(false)
})
it("ignores diacritics / treats composed and decomposed forms as equal (sensitivity: base)", () => {
expect(equalsIgnoreCaseAndAccents("resume", "résumé")).toBe(true)
// composed vs decomposed (e + combining acute)
expect(equalsIgnoreCaseAndAccents("é", "e\u0301")).toBe(true)
})
it("considers whitespace and length differences significant", () => {
expect(equalsIgnoreCaseAndAccents(" hello", "hello")).toBe(false)
expect(equalsIgnoreCaseAndAccents("hello", "hello ")).toBe(false)
})
})

View File

@@ -0,0 +1,7 @@
export function equalsIgnoreCase(a: string, b: string) {
return a.localeCompare(b, undefined, { sensitivity: "accent" }) === 0
}
export function equalsIgnoreCaseAndAccents(a: string, b: string) {
return a.localeCompare(b, undefined, { sensitivity: "base" }) === 0
}

View File

@@ -1,10 +1,13 @@
import path from "path" import path from "path"
import { fileURLToPath } from "url" import { fileURLToPath } from "url"
import { defineConfig } from "vitest/config"
const __filename = fileURLToPath(import.meta.url) const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename) const __dirname = path.dirname(__filename)
export default { export default defineConfig({
test: { test: {
passWithNoTests: true,
globals: true, globals: true,
environment: "jsdom", environment: "jsdom",
setupFiles: ["./vitest-setup.ts"], setupFiles: ["./vitest-setup.ts"],
@@ -14,4 +17,4 @@ export default {
"@": path.resolve(__dirname, "."), "@": path.resolve(__dirname, "."),
}, },
}, },
} })

View File

@@ -1,5 +1,13 @@
import { TRPCError } from "@trpc/server" import { TRPCError } from "@trpc/server"
export function gatewayTimeout(cause?: unknown) {
return new TRPCError({
code: "GATEWAY_TIMEOUT",
message: `Gateway Timeout`,
cause,
})
}
export function unauthorizedError(cause?: unknown) { export function unauthorizedError(cause?: unknown) {
return new TRPCError({ return new TRPCError({
code: "UNAUTHORIZED", code: "UNAUTHORIZED",
@@ -76,6 +84,28 @@ export function publicUnauthorizedError() {
}) })
} }
export function httpStatusByErrorCode(error: TRPCError) {
switch (error.code) {
case "BAD_REQUEST":
return 400
case "UNAUTHORIZED":
return 401
case "FORBIDDEN":
return 403
case "NOT_FOUND":
return 404
case "CONFLICT":
return 409
case "UNPROCESSABLE_CONTENT":
return 422
case "GATEWAY_TIMEOUT":
return 504
case "INTERNAL_SERVER_ERROR":
default:
return 500
}
}
export function serverErrorByStatus(status: number, cause?: unknown) { export function serverErrorByStatus(status: number, cause?: unknown) {
switch (status) { switch (status) {
case 401: case 401:

View File

@@ -10,10 +10,10 @@ import { safeProtectedServiceProcedure } from "../../procedures"
import { getCityPageUrls } from "../../routers/contentstack/destinationCityPage/utils" import { getCityPageUrls } from "../../routers/contentstack/destinationCityPage/utils"
import { getCountryPageUrls } from "../../routers/contentstack/destinationCountryPage/utils" import { getCountryPageUrls } from "../../routers/contentstack/destinationCountryPage/utils"
import { getHotelPageUrls } from "../../routers/contentstack/hotelPage/utils" import { getHotelPageUrls } from "../../routers/contentstack/hotelPage/utils"
import { getLocations } from "../../routers/hotels/utils"
import { ApiCountry, type Country } from "../../types/country" import { ApiCountry, type Country } from "../../types/country"
import { getCitiesByCountry } from "../hotels/services/getCitiesByCountry" import { getCitiesByCountry } from "../hotels/services/getCitiesByCountry"
import { getCountries } from "../hotels/services/getCountries" import { getCountries } from "../hotels/services/getCountries"
import { getLocationsByCountries } from "../hotels/services/getLocationsByCountries"
import { filterAndCategorizeAutoComplete } from "./util/filterAndCategorizeAutoComplete" import { filterAndCategorizeAutoComplete } from "./util/filterAndCategorizeAutoComplete"
import { mapLocationToAutoCompleteLocation } from "./util/mapLocationToAutoCompleteLocation" import { mapLocationToAutoCompleteLocation } from "./util/mapLocationToAutoCompleteLocation"
@@ -136,7 +136,7 @@ export async function getAutoCompleteDestinationsData({
} }
const [locations, locationsError] = await safeTry( const [locations, locationsError] = await safeTry(
getLocations({ getLocationsByCountries({
lang: lang, lang: lang,
serviceToken: serviceToken, serviceToken: serviceToken,
citiesByCountry: citiesByCountry, citiesByCountry: citiesByCountry,

View File

@@ -143,16 +143,9 @@ export const getAdditionalDataInputSchema = z.object({
export const getHotelsByCountryInput = z.object({ export const getHotelsByCountryInput = z.object({
country: z.nativeEnum(Country), country: z.nativeEnum(Country),
lang: z.nativeEnum(Lang).optional(),
}) })
export const getHotelsByCityIdentifierInput = z.object({ export const getHotelsByCityIdentifierInput = z.object({
cityIdentifier: z.string(), cityIdentifier: z.string(),
}) })
export const getLocationsInput = z.object({
lang: z.nativeEnum(Lang),
})
export const getLocationsUrlsInput = z.object({
lang: z.nativeEnum(Lang),
})

View File

@@ -0,0 +1,52 @@
import { z } from "zod"
import { Lang } from "@scandic-hotels/common/constants/language"
import { getCacheClient } from "@scandic-hotels/common/dataCache"
import { serviceProcedure } from "../../../procedures"
import { getCitiesByCountry } from "../services/getCitiesByCountry"
import { getCountries } from "../services/getCountries"
import { getLocationsByCountries } from "../services/getLocationsByCountries"
const getLocationsInput = z.object({
lang: z.nativeEnum(Lang),
})
export const get = serviceProcedure
.input(getLocationsInput)
.query(async function ({ ctx, input }) {
const lang = input.lang ?? ctx.lang
const cacheClient = await getCacheClient()
return await cacheClient.cacheOrGet(
`${lang}:getLocations`,
async () => {
const countries = await getCountries({
lang: lang,
serviceToken: ctx.serviceToken,
})
if (!countries) {
throw new Error("Unable to fetch countries")
}
const countryNames = countries.data.map((country) => country.name)
const citiesByCountry = await getCitiesByCountry({
countries: countryNames,
serviceToken: ctx.serviceToken,
lang,
})
const locations = await getLocationsByCountries({
lang,
serviceToken: ctx.serviceToken,
citiesByCountry,
})
if (!locations) {
throw new Error("Unable to fetch locations")
}
return locations
},
"max"
)
})

View File

@@ -0,0 +1,8 @@
import { router } from "../../.."
import { get } from "./get"
import { urls } from "./urls"
export const locationsRouter = router({
get,
urls,
})

View File

@@ -0,0 +1,49 @@
import { z } from "zod"
import { Lang } from "@scandic-hotels/common/constants/language"
import { createCounter } from "@scandic-hotels/common/telemetry"
import { publicProcedure } from "../../../procedures"
import { getCityPageUrls } from "../../contentstack/destinationCityPage/utils"
import { getHotelPageUrls } from "../../contentstack/hotelPage/utils"
const getLocationsUrlsInput = z.object({
lang: z.nativeEnum(Lang),
})
export const urls = publicProcedure
.input(getLocationsUrlsInput)
.query(async ({ input }) => {
const { lang } = input
const locationsUrlsCounter = createCounter("trpc.hotel.locations", "urls")
const metricsLocationsUrls = locationsUrlsCounter.init({
lang,
})
metricsLocationsUrls.start()
const [hotelPageUrlsResult, cityPageUrlsResult] = await Promise.allSettled([
getHotelPageUrls(lang),
getCityPageUrls(lang),
])
if (
hotelPageUrlsResult.status === "rejected" ||
cityPageUrlsResult.status === "rejected"
) {
metricsLocationsUrls.dataError(`Failed to get data for page URLs`, {
hotelPageUrlsResult,
cityPageUrlsResult,
})
return null
}
metricsLocationsUrls.success()
return {
hotels: hotelPageUrlsResult.value,
cities: cityPageUrlsResult.value,
}
})

View File

@@ -11,11 +11,9 @@ import { BreakfastPackageEnum } from "../../enums/breakfast"
import { badRequestError } from "../../errors" import { badRequestError } from "../../errors"
import { import {
contentStackBaseWithServiceProcedure, contentStackBaseWithServiceProcedure,
publicProcedure,
safeProtectedServiceProcedure, safeProtectedServiceProcedure,
serviceProcedure, serviceProcedure,
} from "../../procedures" } from "../../procedures"
import { getCityPageUrls } from "../../routers/contentstack/destinationCityPage/utils"
import { getHotelPageUrls } from "../../routers/contentstack/hotelPage/utils" import { getHotelPageUrls } from "../../routers/contentstack/hotelPage/utils"
import { import {
ancillaryPackageInputSchema, ancillaryPackageInputSchema,
@@ -26,8 +24,6 @@ import {
getHotelsByCityIdentifierInput, getHotelsByCityIdentifierInput,
getHotelsByCountryInput, getHotelsByCountryInput,
getHotelsByCSFilterInput, getHotelsByCSFilterInput,
getLocationsInput,
getLocationsUrlsInput,
getMeetingRoomsInputSchema, getMeetingRoomsInputSchema,
hotelInputSchema, hotelInputSchema,
nearbyHotelIdsInput, nearbyHotelIdsInput,
@@ -38,22 +34,22 @@ import {
breakfastPackagesSchema, breakfastPackagesSchema,
getNearbyHotelIdsSchema, getNearbyHotelIdsSchema,
} from "../../routers/hotels/output" } from "../../routers/hotels/output"
import { isCityLocation } from "../../types/locations"
import { toApiLang } from "../../utils" import { toApiLang } from "../../utils"
import { additionalDataSchema } from "./schemas/hotel/include/additionalData" import { additionalDataSchema } from "./schemas/hotel/include/additionalData"
import { meetingRoomsSchema } from "./schemas/meetingRoom" import { meetingRoomsSchema } from "./schemas/meetingRoom"
import { getCitiesByCountry } from "./services/getCitiesByCountry"
import { getHotelIdsByCityIdentifier } from "./services/getCityByCityIdentifier" import { getHotelIdsByCityIdentifier } from "./services/getCityByCityIdentifier"
import { getCountries } from "./services/getCountries" import { getCountries } from "./services/getCountries"
import { getHotel } from "./services/getHotel" import { getHotel } from "./services/getHotel"
import { getHotelIdsByCityId } from "./services/getHotelIdsByCityId" import { getHotelIdsByCityId } from "./services/getHotelIdsByCityId"
import { getHotelIdsByCountry } from "./services/getHotelIdsByCountry" import { getHotelIdsByCountry } from "./services/getHotelIdsByCountry"
import { getHotelsByHotelIds } from "./services/getHotelsByHotelIds" import { getHotelsByHotelIds } from "./services/getHotelsByHotelIds"
import { getLocationsByCountries } from "./services/getLocationsByCountries"
import { getPackages } from "./services/getPackages" import { getPackages } from "./services/getPackages"
import { availability } from "./availability" import { availability } from "./availability"
import { getLocations } from "./utils" import { locationsRouter } from "./locations"
import type { HotelListingHotelData } from "../../types/hotel" import type { HotelListingHotelData } from "../../types/hotel"
import type { CityLocation } from "../../types/locations"
const hotelQueryLogger = createLogger("hotelQueryRouter") const hotelQueryLogger = createLogger("hotelQueryRouter")
@@ -82,9 +78,11 @@ export const hotelQueryRouter = router({
get: contentStackBaseWithServiceProcedure get: contentStackBaseWithServiceProcedure
.input(getHotelsByCountryInput) .input(getHotelsByCountryInput)
.query(async ({ ctx, input }) => { .query(async ({ ctx, input }) => {
const { lang, serviceToken } = ctx const { serviceToken } = ctx
const { country } = input const { country } = input
const lang = input.lang ?? ctx.lang
const hotelIds = await getHotelIdsByCountry({ const hotelIds = await getHotelIdsByCountry({
country, country,
serviceToken: ctx.serviceToken, serviceToken: ctx.serviceToken,
@@ -133,20 +131,18 @@ export const hotelQueryRouter = router({
hotelsToFetch = hotelsToInclude hotelsToFetch = hotelsToInclude
shouldSortByDistance = false shouldSortByDistance = false
} else if (locationFilter?.city) { } else if (locationFilter?.city) {
const locations = await getLocations({ const locations = await getLocationsByCountries({
lang: language, lang: language,
serviceToken: ctx.serviceToken, serviceToken: ctx.serviceToken,
citiesByCountry: null, citiesByCountry: null,
}) })
if (!locations || "error" in locations) {
if (!locations || locations.length === 0) {
return [] return []
} }
const cityId = locations const cityId = locations
.filter( .filter(isCityLocation)
(loc): loc is CityLocation =>
"type" in loc && loc.type === "cities"
)
.find((loc) => loc.cityIdentifier === locationFilter.city)?.id .find((loc) => loc.cityIdentifier === locationFilter.city)?.id
if (!cityId) { if (!cityId) {
@@ -339,87 +335,7 @@ export const hotelQueryRouter = router({
env.CACHE_TIME_HOTELS env.CACHE_TIME_HOTELS
) )
}), }),
locations: router({ locations: locationsRouter,
get: serviceProcedure.input(getLocationsInput).query(async function ({
ctx,
input,
}) {
const lang = input.lang ?? ctx.lang
const cacheClient = await getCacheClient()
return await cacheClient.cacheOrGet(
`${lang}:getLocations`,
async () => {
const countries = await getCountries({
lang: lang,
serviceToken: ctx.serviceToken,
})
if (!countries) {
throw new Error("Unable to fetch countries")
}
const countryNames = countries.data.map((country) => country.name)
const citiesByCountry = await getCitiesByCountry({
countries: countryNames,
serviceToken: ctx.serviceToken,
lang,
})
const locations = await getLocations({
lang,
serviceToken: ctx.serviceToken,
citiesByCountry,
})
if (!locations || "error" in locations) {
throw new Error("Unable to fetch locations")
}
return locations
},
"max"
)
}),
urls: publicProcedure
.input(getLocationsUrlsInput)
.query(async ({ input }) => {
const { lang } = input
const locationsUrlsCounter = createCounter(
"trpc.hotel.locations",
"urls"
)
const metricsLocationsUrls = locationsUrlsCounter.init({
lang,
})
metricsLocationsUrls.start()
const [hotelPageUrlsResult, cityPageUrlsResult] =
await Promise.allSettled([
getHotelPageUrls(lang),
getCityPageUrls(lang),
])
if (
hotelPageUrlsResult.status === "rejected" ||
cityPageUrlsResult.status === "rejected"
) {
metricsLocationsUrls.dataError(`Failed to get data for page URLs`, {
hotelPageUrlsResult,
cityPageUrlsResult,
})
return null
}
metricsLocationsUrls.success()
return {
hotels: hotelPageUrlsResult.value,
cities: cityPageUrlsResult.value,
}
}),
}),
map: router({ map: router({
city: serviceProcedure city: serviceProcedure
.input(cityCoordinatesInputSchema) .input(cityCoordinatesInputSchema)

View File

@@ -1,6 +1,9 @@
import { z } from "zod" import { z } from "zod"
export const locationHotelSchema = z.object({ export const locationHotelSchema = z.object({
id: z.string().optional().default(""),
type: z.literal("hotels"),
attributes: z.object({ attributes: z.object({
distanceToCentre: z.number().optional(), distanceToCentre: z.number().optional(),
images: z images: z
@@ -17,7 +20,7 @@ export const locationHotelSchema = z.object({
name: z.string().optional().default(""), name: z.string().optional().default(""),
operaId: z.coerce.string().optional(), operaId: z.coerce.string().optional(),
}), }),
id: z.string().optional().default(""),
relationships: z relationships: z
.object({ .object({
city: z city: z
@@ -31,5 +34,4 @@ export const locationHotelSchema = z.object({
.optional(), .optional(),
}) })
.optional(), .optional(),
type: z.literal("hotels"),
}) })

View File

@@ -1,7 +1,8 @@
import { Lang } from "@scandic-hotels/common/constants/language" import { Lang } from "@scandic-hotels/common/constants/language"
import { getLocations } from "../utils" import { isCityLocation } from "../../../types/locations"
import { getHotelIdsByCityId } from "./getHotelIdsByCityId" import { getHotelIdsByCityId } from "./getHotelIdsByCityId"
import { getLocationsByCountries } from "./getLocationsByCountries"
export async function getCityByCityIdentifier({ export async function getCityByCityIdentifier({
cityIdentifier, cityIdentifier,
@@ -12,17 +13,18 @@ export async function getCityByCityIdentifier({
lang: Lang lang: Lang
serviceToken: string serviceToken: string
}) { }) {
const locations = await getLocations({ const locations = await getLocationsByCountries({
lang, lang,
citiesByCountry: null, citiesByCountry: null,
serviceToken, serviceToken,
}) })
if (!locations || "error" in locations) {
if (!locations || locations.length === 0) {
return null return null
} }
const city = locations const city = locations
.filter((loc) => loc.type === "cities") .filter(isCityLocation)
.find((loc) => loc.cityIdentifier === cityIdentifier) .find((loc) => loc.cityIdentifier === cityIdentifier)
return city ?? null return city ?? null
@@ -46,5 +48,6 @@ export async function getHotelIdsByCityIdentifier(
cityId: city.id, cityId: city.id,
serviceToken, serviceToken,
}) })
return hotelIds return hotelIds
} }

View File

@@ -0,0 +1,398 @@
import { beforeEach, describe, expect, it, type Mock, vi } from "vitest"
import { Lang } from "@scandic-hotels/common/constants/language"
import { getCacheClient } from "@scandic-hotels/common/dataCache"
import * as api from "../../../api"
import { getCity } from "./getCity"
import { getLocationsByCountries } from "./getLocationsByCountries"
import type { CitiesGroupedByCountry } from "../../../types/locations"
// Mocks
vi.mock("@scandic-hotels/common/dataCache", () => {
return {
getCacheClient: vi.fn(),
}
})
vi.mock("../../../api", () => {
return {
get: vi.fn(),
endpoints: {
v1: {
Hotel: {
locations: "/locations",
},
},
},
}
})
vi.mock("./getCity", () => {
return {
getCity: vi.fn(),
}
})
vi.mock("@scandic-hotels/common/logger/createLogger", () => {
return {
createLogger: () => ({
error: vi.fn(),
info: vi.fn(),
}),
}
})
const mockedGetCacheClient = getCacheClient as unknown as Mock
const mockedApiGet = api.get as unknown as Mock
const mockedGetCity = getCity as unknown as Mock
describe("getLocationsByCountries", () => {
const mockedCacheClient = {
cacheOrGet: vi.fn().mockImplementation(async (_key: string, cb: any) => {
return cb()
}),
}
beforeEach(() => {
vi.clearAllMocks()
})
it("returns cached value when cache has data", async () => {
const cacheClient = {
cacheOrGet: vi.fn().mockResolvedValueOnce("CACHED_VALUE"),
}
mockedGetCacheClient.mockResolvedValueOnce(cacheClient)
const result = await getLocationsByCountries({
lang: Lang.en,
citiesByCountry: null,
serviceToken: "token",
} as any)
expect(result).toBe("CACHED_VALUE")
expect(cacheClient.cacheOrGet).toHaveBeenCalled()
expect(mockedApiGet).not.toHaveBeenCalled()
})
it("throws unauthorized on 401 response", async () => {
mockedGetCacheClient.mockResolvedValueOnce(mockedCacheClient)
mockedApiGet.mockResolvedValueOnce({
ok: false,
status: 401,
json: async () => ({}),
})
await expect(
getLocationsByCountries({
lang: Lang.en,
citiesByCountry: null,
serviceToken: "token",
})
).rejects.toThrow("Unauthorized")
expect(mockedApiGet).toHaveBeenCalled()
})
it("throws forbidden on 403 response", async () => {
mockedGetCacheClient.mockResolvedValueOnce(mockedCacheClient)
mockedApiGet.mockResolvedValueOnce({
ok: false,
status: 403,
json: async () => ({}),
})
await expect(
getLocationsByCountries({
lang: Lang.en,
citiesByCountry: null,
serviceToken: "token",
})
).rejects.toThrow("Forbidden")
})
it("parses locations and enriches city country and hotel city via getCity", async () => {
mockedGetCacheClient.mockResolvedValueOnce(mockedCacheClient)
const apiPayload = mockApiData({
numberOfCities: 1,
numberOfHotels: 1,
})
mockedApiGet.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => apiPayload,
})
// getCity returns enriched city object for hotel relationship
const mockedCity: Awaited<ReturnType<typeof getCity>> = {
cityIdentifier: "remote-ci-1",
ianaTimeZoneId: "Europe/Stockholm",
id: "remote-city-id",
isPublished: true,
keywords: [],
name: "RemoteCity",
timeZoneId: "Europe/Stockholm",
type: "cities",
}
mockedGetCity.mockResolvedValueOnce(mockedCity)
const citiesByCountry = {
CountryX: [{ name: "CityAA" }],
} as unknown as CitiesGroupedByCountry
const result = await getLocationsByCountries({
lang: Lang.en,
citiesByCountry,
serviceToken: "token",
})
// Result should be an array with two entries
expect(Array.isArray(result)).toBe(true)
expect(result).toHaveLength(2)
const cityNode = result
.filter((n) => n.type === "cities")
.find((n) => n.name === "City1")
const hotelNode = result
.filter((n) => n.type === "hotels")
.find((n) => n.name === "Hotel1")
expect(cityNode).toBeDefined()
expect(cityNode!.country).toBe("CountryX") // country assigned based on citiesByCountry
expect(hotelNode).toBeDefined()
expect(mockedGetCity).toHaveBeenCalledWith({
cityUrl: "https://api/cities/city1",
serviceToken: "token",
})
// hotel relationships.city should be the object returned by getCity (merged)
expect(hotelNode?.relationships).toBeDefined()
expect(hotelNode?.relationships.city).toEqual(
expect.objectContaining({
id: mockedCity.id,
name: mockedCity.name,
})
)
})
it("parses locations and enriches city country and hotel city via getCity", async () => {
mockedGetCacheClient.mockResolvedValueOnce(mockedCacheClient)
const apiPayload = mockApiData({
numberOfCities: 2,
numberOfHotels: 2,
})
mockedApiGet.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => apiPayload,
})
// getCity returns enriched city object for hotel relationship
const mockedCity: Awaited<ReturnType<typeof getCity>> = {
cityIdentifier: "remote-ci-1",
ianaTimeZoneId: "Europe/Stockholm",
id: "remote-city-id",
isPublished: true,
keywords: [],
name: "RemoteCity",
timeZoneId: "Europe/Stockholm",
type: "cities",
}
mockedGetCity.mockResolvedValue(mockedCity)
const citiesByCountry = {
CountryX: [{ name: "CityAA" }],
} as unknown as CitiesGroupedByCountry
const result = await getLocationsByCountries({
lang: Lang.en,
citiesByCountry,
serviceToken: "token",
})
// Result should be an array with two entries
expect(Array.isArray(result)).toBe(true)
expect(result).toHaveLength(4)
const city1 = result
.filter((n) => n.type === "cities")
.find((n) => n.name === "City1")
expect(city1).toBeDefined()
expect(city1?.country).toBe("CountryX")
const hotel1 = result
.filter((n) => n.type === "hotels")
.find((n) => n.name === "Hotel1")
expect(hotel1).toBeDefined()
expect(mockedGetCity).toHaveBeenCalledWith({
cityUrl: "https://api/cities/city1",
serviceToken: "token",
})
// hotel relationships.city should be the object returned by getCity (merged)
expect(hotel1?.relationships).toBeDefined()
expect(hotel1?.relationships.city).toEqual(
expect.objectContaining({
id: mockedCity.id,
name: mockedCity.name,
})
)
const hotel2 = result
.filter((n) => n.type === "hotels")
.find((n) => n.name === "Hotel2")
expect(hotel2).toBeDefined()
expect(mockedGetCity).toHaveBeenCalledWith({
cityUrl: "https://api/cities/city2",
serviceToken: "token",
})
// hotel relationships.city should be the object returned by getCity (merged)
expect(hotel2?.relationships).toBeDefined()
expect(hotel2?.relationships.city).toEqual(
expect.objectContaining({
id: mockedCity.id,
name: mockedCity.name,
})
)
expect(mockedGetCity).toHaveBeenCalledTimes(2)
})
it("filters out unpublished cities", async () => {
mockedGetCacheClient.mockResolvedValueOnce(mockedCacheClient)
const apiPayload = mockApiData({
numberOfCities: 2,
numberOfHotels: 2,
})
apiPayload.data[0].attributes.isPublished = false
mockedApiGet.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => apiPayload,
})
const citiesByCountry = {
CountryX: [{ name: "CityAA" }],
} as unknown as CitiesGroupedByCountry
const result = await getLocationsByCountries({
lang: Lang.en,
citiesByCountry,
serviceToken: "token",
})
// Result should be an array with two entries
expect(Array.isArray(result)).toBe(true)
expect(result).toHaveLength(3)
const city1 = result
.filter((n) => n.type === "cities")
.find((n) => n.name === "City1")
expect(city1).toBeUndefined()
const hotel1 = result
.filter((n) => n.type === "hotels")
.find((n) => n.name === "Hotel1")
expect(hotel1).toBeDefined()
const hotel2 = result
.filter((n) => n.type === "hotels")
.find((n) => n.name === "Hotel2")
expect(hotel2).toBeDefined()
})
it("sorts the result with cities first", async () => {
mockedGetCacheClient.mockResolvedValueOnce(mockedCacheClient)
const apiPayload = mockApiData({
numberOfCities: 9,
numberOfHotels: 9,
})
apiPayload.data = apiPayload.data.sort(() => Math.random() - 0.5) // shuffle
mockedApiGet.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => apiPayload,
})
const citiesByCountry = {
CountryX: [{ name: "CityAA" }],
} as unknown as CitiesGroupedByCountry
const result = await getLocationsByCountries({
lang: Lang.en,
citiesByCountry,
serviceToken: "token",
})
expect(result.at(0)?.type).toBe("cities")
expect(result.at(-1)?.type).toBe("hotels")
expect(result[0].type === "cities" ? result[0].name : undefined).toBe(
"City1"
)
expect(result[1].type === "cities" ? result[1].name : undefined).toBe(
"City2"
)
expect(result[9].type === "hotels" ? result[9].name : undefined).toBe(
"Hotel1"
)
expect(result[10].type === "hotels" ? result[10].name : undefined).toBe(
"Hotel2"
)
})
})
function mockApiData({
numberOfCities,
numberOfHotels,
}: {
numberOfCities: number
numberOfHotels: number
}) {
const cities = Array.from({ length: numberOfCities }, (_, i) => ({
id: `city${i + 1}`,
type: "cities" as const,
attributes: {
name: `City${i + 1}`,
countryName: `CountryX`,
cityIdentifier: `ci-${i + 1}`,
isPublished: true,
},
}))
const hotels = Array.from({ length: numberOfHotels }, (_, i) => ({
id: `hotel${i + 1}`,
type: "hotels" as const,
attributes: {
isActive: true,
name: `Hotel${i + 1}`,
operaId: `op-${i + 1}`,
isPublished: true,
},
relationships: {
city: {
links: {
related: `https://api/cities/city${i + 1}`,
},
},
},
}))
const apiPayload = {
data: [...cities, ...hotels],
}
return apiPayload
}

View File

@@ -0,0 +1,226 @@
import deepmerge from "deepmerge"
import { z } from "zod"
import { getCacheClient } from "@scandic-hotels/common/dataCache"
import { createLogger } from "@scandic-hotels/common/logger/createLogger"
import { chunk } from "@scandic-hotels/common/utils/chunk"
import * as api from "../../../api"
import { serverErrorByStatus } from "../../../errors"
import { toApiLang } from "../../../utils"
import { locationCitySchema } from "../schemas/location/city"
import { locationHotelSchema } from "../schemas/location/hotel"
import { getCity } from "./getCity"
import type { Lang } from "@scandic-hotels/common/constants/language"
import type { Country } from "../../../types/country"
const hotelUtilsLogger = createLogger("getLocationsByCountries")
type CitiesNamesByCountry = Record<
Country | (string & {}),
Array<{ name: string }>
> | null
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, { apiResponse })
}
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((x) => enrichCity(x, citiesByCountry))
const chunkedHotels = chunk(
data.filter((x) => x.type === "hotels"),
10
)
const hotels = (
await Promise.all(
chunkedHotels.flatMap(async (chunk) => {
return await Promise.all(
chunk.flatMap(async (hotel) => enrichHotel(hotel, serviceToken))
)
})
)
).flat()
let locations: z.infer<typeof locationsSchema>["data"] = [
...cities,
...hotels,
]
return locations
},
"1d"
)
}
async function enrichHotel(
hotel: Extract<
z.infer<typeof locationsSchema>["data"][number],
{ type: "hotels" }
>,
serviceToken: string
): Promise<
Extract<z.infer<typeof locationsSchema>["data"][number], { type: "hotels" }>
> {
if (hotel.type !== "hotels") {
return hotel
}
if (!hotel.relationships.city?.url) {
return hotel
}
const city = await getCity({
cityUrl: hotel.relationships.city.url,
serviceToken,
})
if (!city) {
return hotel
}
return deepmerge(hotel, {
relationships: {
city,
},
})
}
function enrichCity(
city: Extract<
z.infer<typeof locationsSchema>["data"][number],
{ type: "cities" }
>,
citiesByCountry: CitiesNamesByCountry | null
): Extract<
z.infer<typeof locationsSchema>["data"][number],
{ type: "cities" }
> {
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: "",
ianaTimeZoneId: "",
id: "",
isPublished: false,
keywords: [],
name: "",
timeZoneId: "",
type: "cities",
url: location?.relationships?.city?.links?.related ?? "",
},
},
type: location.type,
operaId: location.attributes.operaId ?? "",
}
})
),
})

View File

@@ -1,24 +1,11 @@
import deepmerge from "deepmerge"
import { selectRate } from "@scandic-hotels/common/constants/routes/hotelReservation" import { selectRate } from "@scandic-hotels/common/constants/routes/hotelReservation"
import { getCacheClient } from "@scandic-hotels/common/dataCache"
import { createLogger } from "@scandic-hotels/common/logger/createLogger"
import { chunk } from "@scandic-hotels/common/utils/chunk"
import * as api from "../../api"
import { BookingErrorCodeEnum } from "../../enums/bookingErrorCode" import { BookingErrorCodeEnum } from "../../enums/bookingErrorCode"
import { AvailabilityEnum } from "../../enums/selectHotel" import { AvailabilityEnum } from "../../enums/selectHotel"
import { toApiLang } from "../../utils"
import { sortRoomConfigs } from "../../utils/sortRoomConfigs" import { sortRoomConfigs } from "../../utils/sortRoomConfigs"
import { getCity } from "./services/getCity"
import { locationsSchema } from "./output"
import type { Lang } from "@scandic-hotels/common/constants/language"
import type { z } from "zod"
import type { BedTypeSelection } from "../../types/bedTypeSelection" import type { BedTypeSelection } from "../../types/bedTypeSelection"
import type { Room as RoomCategory } from "../../types/hotel" import type { Room as RoomCategory } from "../../types/hotel"
import type { CitiesGroupedByCountry } from "../../types/locations"
import type { import type {
Product, Product,
Products, Products,
@@ -29,114 +16,6 @@ import type {
import type { RoomsAvailabilityExtendedInputSchema } from "./availability/enterDetails" import type { RoomsAvailabilityExtendedInputSchema } from "./availability/enterDetails"
export const locationsAffix = "locations" export const locationsAffix = "locations"
const hotelUtilsLogger = createLogger("hotelUtils")
export async function getLocations({
lang,
citiesByCountry,
serviceToken,
}: {
lang: Lang
citiesByCountry: CitiesGroupedByCountry | null
serviceToken: string
}) {
const cacheClient = await getCacheClient()
const countryKeys = Object.keys(citiesByCountry ?? {})
let cacheKey = `${lang}:locations`
if (countryKeys.length) {
cacheKey += `:${countryKeys.join(",")}`
}
return await cacheClient.cacheOrGet(
cacheKey.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) {
hotelUtilsLogger.error(
`Locations Verification Failed`,
verifiedLocations.error
)
throw new Error("Unable to parse locations")
}
const chunkedLocations = chunk(verifiedLocations.data.data, 10)
let locations: z.infer<typeof locationsSchema>["data"] = []
for (const chunk of chunkedLocations) {
const chunkLocations = await Promise.all(
chunk.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 {
hotelUtilsLogger.error(
`Location cannot be found in any of the countries cities`,
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
})
)
locations.push(...chunkLocations)
}
return locations
},
"1d"
)
}
export const TWENTYFOUR_HOURS = 60 * 60 * 24 export const TWENTYFOUR_HOURS = 60 * 60 * 24
function findProduct(product: Products, rateDefinition: RateDefinition) { function findProduct(product: Products, rateDefinition: RateDefinition) {

View File

@@ -5,6 +5,7 @@ import type {
countriesSchema, countriesSchema,
locationsSchema, locationsSchema,
} from "../routers/hotels/output" } from "../routers/hotels/output"
import type { Country } from "./country"
export interface LocationSchema extends z.output<typeof locationsSchema> {} export interface LocationSchema extends z.output<typeof locationsSchema> {}
@@ -18,8 +19,17 @@ export function isHotelLocation(
): location is HotelLocation { ): location is HotelLocation {
return location?.type === "hotels" return location?.type === "hotels"
} }
export function isCityLocation(
location: Location | null
): location is CityLocation {
return location?.type === "cities"
}
export interface CitiesByCountry export interface CitiesByCountry
extends z.output<typeof citiesByCountrySchema> {} extends z.output<typeof citiesByCountrySchema> {}
export type CitiesGroupedByCountry = Record<string, CitiesByCountry["data"]> export type CitiesGroupedByCountry = Record<
Country | (string & {}),
NonNullable<CitiesByCountry["data"]>
>
export interface Countries extends z.output<typeof countriesSchema> {} export interface Countries extends z.output<typeof countriesSchema> {}