From 15a2da333df1a4cedaf1c90406e3c54bcf71d81c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joakim=20J=C3=A4derberg?= Date: Mon, 3 Nov 2025 12:10:22 +0000 Subject: [PATCH] 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 --- .vscode/settings.json | 4 +- .../destinations/[country]/[city]/route.ts | 139 ++++++ .../[country]/createDataResponse.ts | 59 +++ .../app/api/destinations/[country]/route.ts | 137 ++++++ .../scandic-web/components/NotFound/index.tsx | 1 - .../lib/misc/getHotelSearchDetails.ts | 21 +- packages/common/package.json | 1 + packages/common/utils/stringEquals.test.ts | 68 +++ packages/common/utils/stringEquals.ts | 7 + packages/tracking/vitest.config.ts | 7 +- packages/trpc/lib/errors.ts | 30 ++ .../lib/routers/autocomplete/destinations.ts | 4 +- packages/trpc/lib/routers/hotels/input.ts | 9 +- .../trpc/lib/routers/hotels/locations/get.ts | 52 +++ .../lib/routers/hotels/locations/getByCity.ts | 0 .../lib/routers/hotels/locations/index.ts | 8 + .../trpc/lib/routers/hotels/locations/urls.ts | 49 +++ packages/trpc/lib/routers/hotels/query.ts | 106 +---- .../routers/hotels/schemas/location/hotel.ts | 6 +- .../services/getCityByCityIdentifier.ts | 11 +- .../routers/hotels/services/getLocations.ts | 0 .../services/getLocationsByCountries.test.ts | 398 ++++++++++++++++++ .../services/getLocationsByCountries.ts | 226 ++++++++++ packages/trpc/lib/routers/hotels/utils.ts | 121 ------ packages/trpc/lib/types/locations.ts | 12 +- 25 files changed, 1227 insertions(+), 249 deletions(-) create mode 100644 apps/scandic-web/app/api/destinations/[country]/[city]/route.ts create mode 100644 apps/scandic-web/app/api/destinations/[country]/createDataResponse.ts create mode 100644 apps/scandic-web/app/api/destinations/[country]/route.ts create mode 100644 packages/common/utils/stringEquals.test.ts create mode 100644 packages/common/utils/stringEquals.ts create mode 100644 packages/trpc/lib/routers/hotels/locations/get.ts create mode 100644 packages/trpc/lib/routers/hotels/locations/getByCity.ts create mode 100644 packages/trpc/lib/routers/hotels/locations/index.ts create mode 100644 packages/trpc/lib/routers/hotels/locations/urls.ts create mode 100644 packages/trpc/lib/routers/hotels/services/getLocations.ts create mode 100644 packages/trpc/lib/routers/hotels/services/getLocationsByCountries.test.ts create mode 100644 packages/trpc/lib/routers/hotels/services/getLocationsByCountries.ts diff --git a/.vscode/settings.json b/.vscode/settings.json index 7b762d2d8..bb1ea9114 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,4 +1,4 @@ { - "typescript.tsdk": "node_modules/typescript/lib", - "typescript.experimental.useTsgo": false + "typescript.tsdk": "node_modules/typescript/lib", + "typescript.experimental.useTsgo": false } diff --git a/apps/scandic-web/app/api/destinations/[country]/[city]/route.ts b/apps/scandic-web/app/api/destinations/[country]/[city]/route.ts new file mode 100644 index 000000000..f445a4b7d --- /dev/null +++ b/apps/scandic-web/app/api/destinations/[country]/[city]/route.ts @@ -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, + }, + } + ) + } +} diff --git a/apps/scandic-web/app/api/destinations/[country]/createDataResponse.ts b/apps/scandic-web/app/api/destinations/[country]/createDataResponse.ts new file mode 100644 index 000000000..bcf6784a5 --- /dev/null +++ b/apps/scandic-web/app/api/destinations/[country]/createDataResponse.ts @@ -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` +} diff --git a/apps/scandic-web/app/api/destinations/[country]/route.ts b/apps/scandic-web/app/api/destinations/[country]/route.ts new file mode 100644 index 000000000..506d86ba7 --- /dev/null +++ b/apps/scandic-web/app/api/destinations/[country]/route.ts @@ -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, + }, + } + ) + } +} diff --git a/apps/scandic-web/components/NotFound/index.tsx b/apps/scandic-web/components/NotFound/index.tsx index 7d485928f..6d6195c8a 100644 --- a/apps/scandic-web/components/NotFound/index.tsx +++ b/apps/scandic-web/components/NotFound/index.tsx @@ -1,5 +1,4 @@ /* eslint-disable formatjs/no-literal-string-in-jsx */ - import { getLang } from "@/i18n/serverContext" import { texts } from "./Texts" diff --git a/packages/booking-flow/lib/misc/getHotelSearchDetails.ts b/packages/booking-flow/lib/misc/getHotelSearchDetails.ts index 299e80a06..c14b659bf 100644 --- a/packages/booking-flow/lib/misc/getHotelSearchDetails.ts +++ b/packages/booking-flow/lib/misc/getHotelSearchDetails.ts @@ -2,6 +2,7 @@ import { safeTry } from "@scandic-hotels/common/utils/safeTry" import { SEARCH_TYPE_REDEMPTION } from "@scandic-hotels/trpc/constants/booking" import { type HotelLocation, + isCityLocation, isHotelLocation, type Location, } from "@scandic-hotels/trpc/types/locations" @@ -37,12 +38,9 @@ export async function getHotelSearchDetails(params: { } const hotel = params.hotelId - ? ((locations.find( - (location) => - isHotelLocation(location) && - "operaId" in location && - location.operaId === params.hotelId - ) as HotelLocation | undefined) ?? null) + ? (locations + .filter(isHotelLocation) + .find((location) => location.operaId === params.hotelId) ?? null) : null if (params.isAlternativeHotels && !hotel) { @@ -54,12 +52,13 @@ export async function getHotelSearchDetails(params: { : params.city const city = cityIdentifier - ? (locations.find( - (location) => - "cityIdentifier" in location && - location.cityIdentifier?.toLowerCase() === + ? (locations + .filter(isCityLocation) + .find( + (location) => + location.cityIdentifier?.toLowerCase() === cityIdentifier.toLowerCase() - ) ?? null) + ) ?? null) : null if (!city && !hotel) return null diff --git a/packages/common/package.json b/packages/common/package.json index 4bf2c3d43..0eb2e5731 100644 --- a/packages/common/package.json +++ b/packages/common/package.json @@ -48,6 +48,7 @@ "./tracking/useFormTracking": "./tracking/useFormTracking.ts", "./tracking/useTrackHardNavigation": "./tracking/useTrackHardNavigation.ts", "./tracking/useTrackSoftNavigation": "./tracking/useTrackSoftNavigation.ts", + "./utils/stringEquals": "./utils/stringEquals.ts", "./utils/chunk": "./utils/chunk.ts", "./utils/dateFormatting": "./utils/dateFormatting.ts", "./utils/debounce": "./utils/debounce.ts", diff --git a/packages/common/utils/stringEquals.test.ts b/packages/common/utils/stringEquals.test.ts new file mode 100644 index 000000000..8b38b0726 --- /dev/null +++ b/packages/common/utils/stringEquals.test.ts @@ -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) + }) +}) diff --git a/packages/common/utils/stringEquals.ts b/packages/common/utils/stringEquals.ts new file mode 100644 index 000000000..35adba6d7 --- /dev/null +++ b/packages/common/utils/stringEquals.ts @@ -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 +} diff --git a/packages/tracking/vitest.config.ts b/packages/tracking/vitest.config.ts index dd7db7046..f87c28b89 100644 --- a/packages/tracking/vitest.config.ts +++ b/packages/tracking/vitest.config.ts @@ -1,10 +1,13 @@ import path from "path" import { fileURLToPath } from "url" +import { defineConfig } from "vitest/config" + const __filename = fileURLToPath(import.meta.url) const __dirname = path.dirname(__filename) -export default { +export default defineConfig({ test: { + passWithNoTests: true, globals: true, environment: "jsdom", setupFiles: ["./vitest-setup.ts"], @@ -14,4 +17,4 @@ export default { "@": path.resolve(__dirname, "."), }, }, -} +}) diff --git a/packages/trpc/lib/errors.ts b/packages/trpc/lib/errors.ts index 70fd5f308..5a3b71688 100644 --- a/packages/trpc/lib/errors.ts +++ b/packages/trpc/lib/errors.ts @@ -1,5 +1,13 @@ 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) { return new TRPCError({ 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) { switch (status) { case 401: diff --git a/packages/trpc/lib/routers/autocomplete/destinations.ts b/packages/trpc/lib/routers/autocomplete/destinations.ts index 86f5241b5..bd3924776 100644 --- a/packages/trpc/lib/routers/autocomplete/destinations.ts +++ b/packages/trpc/lib/routers/autocomplete/destinations.ts @@ -10,10 +10,10 @@ 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 { getLocations } from "../../routers/hotels/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" @@ -136,7 +136,7 @@ export async function getAutoCompleteDestinationsData({ } const [locations, locationsError] = await safeTry( - getLocations({ + getLocationsByCountries({ lang: lang, serviceToken: serviceToken, citiesByCountry: citiesByCountry, diff --git a/packages/trpc/lib/routers/hotels/input.ts b/packages/trpc/lib/routers/hotels/input.ts index 2434191ac..c88e4ec5c 100644 --- a/packages/trpc/lib/routers/hotels/input.ts +++ b/packages/trpc/lib/routers/hotels/input.ts @@ -143,16 +143,9 @@ export const getAdditionalDataInputSchema = z.object({ export const getHotelsByCountryInput = z.object({ country: z.nativeEnum(Country), + lang: z.nativeEnum(Lang).optional(), }) export const getHotelsByCityIdentifierInput = z.object({ cityIdentifier: z.string(), }) - -export const getLocationsInput = z.object({ - lang: z.nativeEnum(Lang), -}) - -export const getLocationsUrlsInput = z.object({ - lang: z.nativeEnum(Lang), -}) diff --git a/packages/trpc/lib/routers/hotels/locations/get.ts b/packages/trpc/lib/routers/hotels/locations/get.ts new file mode 100644 index 000000000..189f5e218 --- /dev/null +++ b/packages/trpc/lib/routers/hotels/locations/get.ts @@ -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" + ) + }) diff --git a/packages/trpc/lib/routers/hotels/locations/getByCity.ts b/packages/trpc/lib/routers/hotels/locations/getByCity.ts new file mode 100644 index 000000000..e69de29bb diff --git a/packages/trpc/lib/routers/hotels/locations/index.ts b/packages/trpc/lib/routers/hotels/locations/index.ts new file mode 100644 index 000000000..1f7793171 --- /dev/null +++ b/packages/trpc/lib/routers/hotels/locations/index.ts @@ -0,0 +1,8 @@ +import { router } from "../../.." +import { get } from "./get" +import { urls } from "./urls" + +export const locationsRouter = router({ + get, + urls, +}) diff --git a/packages/trpc/lib/routers/hotels/locations/urls.ts b/packages/trpc/lib/routers/hotels/locations/urls.ts new file mode 100644 index 000000000..14a5b96a4 --- /dev/null +++ b/packages/trpc/lib/routers/hotels/locations/urls.ts @@ -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, + } + }) diff --git a/packages/trpc/lib/routers/hotels/query.ts b/packages/trpc/lib/routers/hotels/query.ts index e33df0b8a..896bc06de 100644 --- a/packages/trpc/lib/routers/hotels/query.ts +++ b/packages/trpc/lib/routers/hotels/query.ts @@ -11,11 +11,9 @@ import { BreakfastPackageEnum } from "../../enums/breakfast" import { badRequestError } from "../../errors" import { contentStackBaseWithServiceProcedure, - publicProcedure, safeProtectedServiceProcedure, serviceProcedure, } from "../../procedures" -import { getCityPageUrls } from "../../routers/contentstack/destinationCityPage/utils" import { getHotelPageUrls } from "../../routers/contentstack/hotelPage/utils" import { ancillaryPackageInputSchema, @@ -26,8 +24,6 @@ import { getHotelsByCityIdentifierInput, getHotelsByCountryInput, getHotelsByCSFilterInput, - getLocationsInput, - getLocationsUrlsInput, getMeetingRoomsInputSchema, hotelInputSchema, nearbyHotelIdsInput, @@ -38,22 +34,22 @@ import { breakfastPackagesSchema, getNearbyHotelIdsSchema, } from "../../routers/hotels/output" +import { isCityLocation } from "../../types/locations" import { toApiLang } from "../../utils" import { additionalDataSchema } from "./schemas/hotel/include/additionalData" import { meetingRoomsSchema } from "./schemas/meetingRoom" -import { getCitiesByCountry } from "./services/getCitiesByCountry" import { getHotelIdsByCityIdentifier } from "./services/getCityByCityIdentifier" import { getCountries } from "./services/getCountries" import { getHotel } from "./services/getHotel" import { getHotelIdsByCityId } from "./services/getHotelIdsByCityId" import { getHotelIdsByCountry } from "./services/getHotelIdsByCountry" import { getHotelsByHotelIds } from "./services/getHotelsByHotelIds" +import { getLocationsByCountries } from "./services/getLocationsByCountries" import { getPackages } from "./services/getPackages" import { availability } from "./availability" -import { getLocations } from "./utils" +import { locationsRouter } from "./locations" import type { HotelListingHotelData } from "../../types/hotel" -import type { CityLocation } from "../../types/locations" const hotelQueryLogger = createLogger("hotelQueryRouter") @@ -82,9 +78,11 @@ export const hotelQueryRouter = router({ get: contentStackBaseWithServiceProcedure .input(getHotelsByCountryInput) .query(async ({ ctx, input }) => { - const { lang, serviceToken } = ctx + const { serviceToken } = ctx const { country } = input + const lang = input.lang ?? ctx.lang + const hotelIds = await getHotelIdsByCountry({ country, serviceToken: ctx.serviceToken, @@ -133,20 +131,18 @@ export const hotelQueryRouter = router({ hotelsToFetch = hotelsToInclude shouldSortByDistance = false } else if (locationFilter?.city) { - const locations = await getLocations({ + const locations = await getLocationsByCountries({ lang: language, serviceToken: ctx.serviceToken, citiesByCountry: null, }) - if (!locations || "error" in locations) { + + if (!locations || locations.length === 0) { return [] } const cityId = locations - .filter( - (loc): loc is CityLocation => - "type" in loc && loc.type === "cities" - ) + .filter(isCityLocation) .find((loc) => loc.cityIdentifier === locationFilter.city)?.id if (!cityId) { @@ -339,87 +335,7 @@ export const hotelQueryRouter = router({ env.CACHE_TIME_HOTELS ) }), - locations: router({ - 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, - } - }), - }), + locations: locationsRouter, map: router({ city: serviceProcedure .input(cityCoordinatesInputSchema) diff --git a/packages/trpc/lib/routers/hotels/schemas/location/hotel.ts b/packages/trpc/lib/routers/hotels/schemas/location/hotel.ts index 6dd162c36..3c9a532ba 100644 --- a/packages/trpc/lib/routers/hotels/schemas/location/hotel.ts +++ b/packages/trpc/lib/routers/hotels/schemas/location/hotel.ts @@ -1,6 +1,9 @@ import { z } from "zod" export const locationHotelSchema = z.object({ + id: z.string().optional().default(""), + type: z.literal("hotels"), + attributes: z.object({ distanceToCentre: z.number().optional(), images: z @@ -17,7 +20,7 @@ export const locationHotelSchema = z.object({ name: z.string().optional().default(""), operaId: z.coerce.string().optional(), }), - id: z.string().optional().default(""), + relationships: z .object({ city: z @@ -31,5 +34,4 @@ export const locationHotelSchema = z.object({ .optional(), }) .optional(), - type: z.literal("hotels"), }) diff --git a/packages/trpc/lib/routers/hotels/services/getCityByCityIdentifier.ts b/packages/trpc/lib/routers/hotels/services/getCityByCityIdentifier.ts index a13623cb4..bc1f31033 100644 --- a/packages/trpc/lib/routers/hotels/services/getCityByCityIdentifier.ts +++ b/packages/trpc/lib/routers/hotels/services/getCityByCityIdentifier.ts @@ -1,7 +1,8 @@ import { Lang } from "@scandic-hotels/common/constants/language" -import { getLocations } from "../utils" +import { isCityLocation } from "../../../types/locations" import { getHotelIdsByCityId } from "./getHotelIdsByCityId" +import { getLocationsByCountries } from "./getLocationsByCountries" export async function getCityByCityIdentifier({ cityIdentifier, @@ -12,17 +13,18 @@ export async function getCityByCityIdentifier({ lang: Lang serviceToken: string }) { - const locations = await getLocations({ + const locations = await getLocationsByCountries({ lang, citiesByCountry: null, serviceToken, }) - if (!locations || "error" in locations) { + + if (!locations || locations.length === 0) { return null } const city = locations - .filter((loc) => loc.type === "cities") + .filter(isCityLocation) .find((loc) => loc.cityIdentifier === cityIdentifier) return city ?? null @@ -46,5 +48,6 @@ export async function getHotelIdsByCityIdentifier( cityId: city.id, serviceToken, }) + return hotelIds } diff --git a/packages/trpc/lib/routers/hotels/services/getLocations.ts b/packages/trpc/lib/routers/hotels/services/getLocations.ts new file mode 100644 index 000000000..e69de29bb diff --git a/packages/trpc/lib/routers/hotels/services/getLocationsByCountries.test.ts b/packages/trpc/lib/routers/hotels/services/getLocationsByCountries.test.ts new file mode 100644 index 000000000..b0b8e7461 --- /dev/null +++ b/packages/trpc/lib/routers/hotels/services/getLocationsByCountries.test.ts @@ -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> = { + 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> = { + 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 +} diff --git a/packages/trpc/lib/routers/hotels/services/getLocationsByCountries.ts b/packages/trpc/lib/routers/hotels/services/getLocationsByCountries.ts new file mode 100644 index 000000000..be684aa61 --- /dev/null +++ b/packages/trpc/lib/routers/hotels/services/getLocationsByCountries.ts @@ -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["data"] = [ + ...cities, + ...hotels, + ] + + return locations + }, + "1d" + ) +} + +async function enrichHotel( + hotel: Extract< + z.infer["data"][number], + { type: "hotels" } + >, + serviceToken: string +): Promise< + Extract["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["data"][number], + { type: "cities" } + >, + citiesByCountry: CitiesNamesByCountry | null +): Extract< + z.infer["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["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 ?? "", + } + }) + ), +}) diff --git a/packages/trpc/lib/routers/hotels/utils.ts b/packages/trpc/lib/routers/hotels/utils.ts index 64cebeec6..1abca433c 100644 --- a/packages/trpc/lib/routers/hotels/utils.ts +++ b/packages/trpc/lib/routers/hotels/utils.ts @@ -1,24 +1,11 @@ -import deepmerge from "deepmerge" - 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 { AvailabilityEnum } from "../../enums/selectHotel" -import { toApiLang } from "../../utils" 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 { Room as RoomCategory } from "../../types/hotel" -import type { CitiesGroupedByCountry } from "../../types/locations" import type { Product, Products, @@ -29,114 +16,6 @@ import type { import type { RoomsAvailabilityExtendedInputSchema } from "./availability/enterDetails" 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["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 function findProduct(product: Products, rateDefinition: RateDefinition) { diff --git a/packages/trpc/lib/types/locations.ts b/packages/trpc/lib/types/locations.ts index 1fac5fe37..b39cc935b 100644 --- a/packages/trpc/lib/types/locations.ts +++ b/packages/trpc/lib/types/locations.ts @@ -5,6 +5,7 @@ import type { countriesSchema, locationsSchema, } from "../routers/hotels/output" +import type { Country } from "./country" export interface LocationSchema extends z.output {} @@ -18,8 +19,17 @@ export function isHotelLocation( ): location is HotelLocation { return location?.type === "hotels" } + +export function isCityLocation( + location: Location | null +): location is CityLocation { + return location?.type === "cities" +} export interface CitiesByCountry extends z.output {} -export type CitiesGroupedByCountry = Record +export type CitiesGroupedByCountry = Record< + Country | (string & {}), + NonNullable +> export interface Countries extends z.output {}