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:
4
.vscode/settings.json
vendored
4
.vscode/settings.json
vendored
@@ -1,4 +1,4 @@
|
|||||||
{
|
{
|
||||||
"typescript.tsdk": "node_modules/typescript/lib",
|
"typescript.tsdk": "node_modules/typescript/lib",
|
||||||
"typescript.experimental.useTsgo": false
|
"typescript.experimental.useTsgo": false
|
||||||
}
|
}
|
||||||
|
|||||||
139
apps/scandic-web/app/api/destinations/[country]/[city]/route.ts
Normal file
139
apps/scandic-web/app/api/destinations/[country]/[city]/route.ts
Normal 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,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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`
|
||||||
|
}
|
||||||
137
apps/scandic-web/app/api/destinations/[country]/route.ts
Normal file
137
apps/scandic-web/app/api/destinations/[country]/route.ts
Normal 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,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
68
packages/common/utils/stringEquals.test.ts
Normal file
68
packages/common/utils/stringEquals.test.ts
Normal 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)
|
||||||
|
})
|
||||||
|
})
|
||||||
7
packages/common/utils/stringEquals.ts
Normal file
7
packages/common/utils/stringEquals.ts
Normal 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
|
||||||
|
}
|
||||||
@@ -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, "."),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
})
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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),
|
|
||||||
})
|
|
||||||
|
|||||||
52
packages/trpc/lib/routers/hotels/locations/get.ts
Normal file
52
packages/trpc/lib/routers/hotels/locations/get.ts
Normal 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"
|
||||||
|
)
|
||||||
|
})
|
||||||
8
packages/trpc/lib/routers/hotels/locations/index.ts
Normal file
8
packages/trpc/lib/routers/hotels/locations/index.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
import { router } from "../../.."
|
||||||
|
import { get } from "./get"
|
||||||
|
import { urls } from "./urls"
|
||||||
|
|
||||||
|
export const locationsRouter = router({
|
||||||
|
get,
|
||||||
|
urls,
|
||||||
|
})
|
||||||
49
packages/trpc/lib/routers/hotels/locations/urls.ts
Normal file
49
packages/trpc/lib/routers/hotels/locations/urls.ts
Normal 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,
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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"),
|
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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 ?? "",
|
||||||
|
}
|
||||||
|
})
|
||||||
|
),
|
||||||
|
})
|
||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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> {}
|
||||||
|
|||||||
Reference in New Issue
Block a user