Merged in feat/sw-2863-move-contentstack-router-to-trpc-package (pull request #2389)

feat(SW-2863): Move contentstack router to trpc package

* Add exports to packages and lint rule to prevent relative imports

* Add env to trpc package

* Add eslint to trpc package

* Apply lint rules

* Use direct imports from trpc package

* Add lint-staged config to trpc

* Move lang enum to common

* Restructure trpc package folder structure

* WIP first step

* update internal imports in trpc

* Fix most errors in scandic-web

Just 100 left...

* Move Props type out of trpc

* Fix CategorizedFilters types

* Move more schemas in hotel router

* Fix deps

* fix getNonContentstackUrls

* Fix import error

* Fix entry error handling

* Fix generateMetadata metrics

* Fix alertType enum

* Fix duplicated types

* lint:fix

* Merge branch 'master' into feat/sw-2863-move-contentstack-router-to-trpc-package

* Fix broken imports

* Merge branch 'master' into feat/sw-2863-move-contentstack-router-to-trpc-package


Approved-by: Linus Flood
This commit is contained in:
Anton Gunnarsson
2025-06-26 07:53:01 +00:00
parent 0263ab8c87
commit 002d093af4
921 changed files with 3112 additions and 3008 deletions

View File

@@ -1,49 +1,37 @@
import deepmerge from "deepmerge"
import stringify from "json-stable-stringify-without-jsonify"
import { Lang } from "@scandic-hotels/common/constants/language"
import { getCacheClient } from "@scandic-hotels/common/dataCache"
import { createCounter } from "@scandic-hotels/common/telemetry"
import * as api from "@scandic-hotels/trpc/api"
import { RoomPackageCodeEnum } from "@scandic-hotels/trpc/enums/roomFilter"
import { AvailabilityEnum } from "@scandic-hotels/trpc/enums/selectHotel"
import { badRequestError } from "@scandic-hotels/trpc/errors"
import { type RoomFeaturesInput } from "@scandic-hotels/trpc/routers/hotels/input"
import {
hotelsAvailabilitySchema,
packagesSchema,
roomFeaturesSchema,
roomsAvailabilitySchema,
} from "@scandic-hotels/trpc/routers/hotels/output"
import { toApiLang } from "@scandic-hotels/trpc/utils"
import { sortRoomConfigs } from "@scandic-hotels/trpc/utils/sortRoomConfigs"
import { BookingErrorCodeEnum, REDEMPTION } from "@/constants/booking"
import { selectRate } from "@/constants/routes/hotelReservation"
import { env } from "@/env/server"
import * as api from "@/lib/api"
import { toApiLang } from "@/server/utils"
import { generateChildrenString } from "@/components/HotelReservation/utils"
import { cache } from "@/utils/cache"
import { chunk } from "@/utils/chunk"
import { sortRoomConfigs } from "@/utils/sort"
import { getHotelPageUrls } from "../contentstack/hotelPage/utils"
import { type RoomFeaturesInput } from "./input"
import {
type Cities,
citiesByCountrySchema,
citiesSchema,
countriesSchema,
getHotelIdsSchema,
hotelsAvailabilitySchema,
hotelSchema,
locationsSchema,
packagesSchema,
roomFeaturesSchema,
roomsAvailabilitySchema,
} from "./output"
import type { z } from "zod"
import type { Room as RoomCategory } from "@scandic-hotels/trpc/types/hotel"
import type {
Product,
Products,
RateDefinition,
RedemptionsProduct,
RoomConfiguration,
} from "@scandic-hotels/trpc/types/roomAvailability"
import type { BedTypeSelection } from "@/types/components/hotelReservation/enterDetails/bedType"
import { AvailabilityEnum } from "@/types/components/hotelReservation/selectHotel/selectHotel"
import { RoomPackageCodeEnum } from "@/types/components/hotelReservation/selectRate/roomFilter"
import { HotelTypeEnum } from "@/types/enums/hotelType"
import { PointOfInterestGroupEnum } from "@/types/enums/pointOfInterest"
import type {
DestinationPagesHotelData,
Room as RoomCategory,
} from "@/types/hotel"
import type { PackagesOutput } from "@/types/requests/packages"
import type {
HotelsAvailabilityInputSchema,
@@ -52,529 +40,8 @@ import type {
RoomsAvailabilityInputRoom,
RoomsAvailabilityOutputSchema,
} from "@/types/trpc/routers/hotel/availability"
import type { HotelInput } from "@/types/trpc/routers/hotel/hotel"
import type {
CitiesGroupedByCountry,
CityLocation,
} from "@/types/trpc/routers/hotel/locations"
import type {
Product,
Products,
RateDefinition,
RedemptionsProduct,
RoomConfiguration,
} from "@/types/trpc/routers/hotel/roomAvailability"
import type { Endpoint } from "@/lib/api/endpoints"
export function getPoiGroupByCategoryName(category: string | undefined) {
if (!category) return PointOfInterestGroupEnum.LOCATION
switch (category) {
case "Airport":
case "Bus terminal":
case "Transportations":
return PointOfInterestGroupEnum.PUBLIC_TRANSPORT
case "Amusement park":
case "Museum":
case "Sports":
case "Theatre":
case "Tourist":
case "Zoo":
return PointOfInterestGroupEnum.ATTRACTIONS
case "Nearby companies":
case "Fair":
return PointOfInterestGroupEnum.BUSINESS
case "Parking / Garage":
return PointOfInterestGroupEnum.PARKING
case "Shopping":
case "Restaurant":
return PointOfInterestGroupEnum.SHOPPING_DINING
case "Hospital":
default:
return PointOfInterestGroupEnum.LOCATION
}
}
export const locationsAffix = "locations"
export const TWENTYFOUR_HOURS = 60 * 60 * 24
export async function getCity({
cityUrl,
serviceToken,
}: {
cityUrl: string
serviceToken: string
}): Promise<Cities> {
const cacheClient = await getCacheClient()
return await cacheClient.cacheOrGet(
cityUrl,
async () => {
const url = new URL(cityUrl)
const cityResponse = await api.get(
url.pathname as Endpoint,
{ headers: { Authorization: `Bearer ${serviceToken}` } },
url.searchParams
)
if (!cityResponse.ok) {
return null
}
const cityJson = await cityResponse.json()
const city = citiesSchema.safeParse(cityJson)
if (!city.success) {
console.info(`Validation of city failed`)
console.info(`cityUrl: ${cityUrl}`)
console.error(city.error)
return null
}
return city.data
},
"1d"
)
}
export async function getCountries({
lang,
serviceToken,
warmup = false,
}: {
lang: Lang
serviceToken: string
warmup?: boolean
}) {
const cacheClient = await getCacheClient()
return await cacheClient.cacheOrGet(
`${lang}:${locationsAffix}:countries`,
async () => {
const params = new URLSearchParams({
language: toApiLang(lang),
})
const countryResponse = await api.get(
api.endpoints.v1.Hotel.countries,
{
headers: {
Authorization: `Bearer ${serviceToken}`,
},
},
params
)
if (!countryResponse.ok) {
throw new Error("Unable to fetch countries")
}
const countriesJson = await countryResponse.json()
const countries = countriesSchema.safeParse(countriesJson)
if (!countries.success) {
console.info(`Validation for countries failed`)
console.error(countries.error)
return null
}
return countries.data
},
"1d",
{
cacheStrategy: warmup ? "fetch-then-cache" : "cache-first",
}
)
}
export async function getCitiesByCountry({
countries,
lang,
affix = locationsAffix,
serviceToken,
}: {
countries: string[]
lang: Lang
affix?: string
serviceToken: string
}): Promise<CitiesGroupedByCountry> {
const cacheClient = await getCacheClient()
const allCitiesByCountries = await Promise.all(
countries.map(async (country) => {
return cacheClient.cacheOrGet(
`${lang}:${affix}:cities-by-country:${country}`,
async () => {
const params = new URLSearchParams({
language: toApiLang(lang),
})
const countryResponse = await api.get(
api.endpoints.v1.Hotel.Cities.country(country),
{
headers: {
Authorization: `Bearer ${serviceToken}`,
},
},
params
)
if (!countryResponse.ok) {
throw new Error(`Unable to fetch cities by country ${country}`)
}
const countryJson = await countryResponse.json()
const citiesByCountry = citiesByCountrySchema.safeParse(countryJson)
if (!citiesByCountry.success) {
console.error(`Unable to parse cities by country ${country}`)
console.error(citiesByCountry.error)
throw new Error(`Unable to parse cities by country ${country}`)
}
return { ...citiesByCountry.data, country }
},
"1d"
)
})
)
const filteredCitiesByCountries = allCitiesByCountries.map((country) => ({
...country,
data: country.data.filter((city) => city.isPublished),
}))
const groupedCitiesByCountry: CitiesGroupedByCountry =
filteredCitiesByCountries.reduce((acc, { country, data }) => {
acc[country] = data
return acc
}, {} as CitiesGroupedByCountry)
return groupedCitiesByCountry
}
export async function getLocations({
lang,
citiesByCountry,
serviceToken,
}: {
lang: Lang
citiesByCountry: CitiesGroupedByCountry | null
serviceToken: string
}) {
const cacheClient = await getCacheClient()
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) {
console.info(`Locations Verification Failed`)
console.error(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 {
console.info(
`Location cannot be found in any of the countries cities`
)
console.info(location)
}
}
} else if (location.type === "hotels") {
if (location.relationships.city?.url) {
const city = await getCity({
cityUrl: location.relationships.city.url,
serviceToken,
})
if (city) {
return deepmerge(location, {
relationships: {
city,
},
})
}
}
}
return location
})
)
locations.push(...chunkLocations)
}
return locations
},
"1d"
)
}
export async function getHotelIdsByCityId({
cityId,
serviceToken,
}: {
cityId: string
serviceToken: string
}) {
const getHotelIdsByCityIdCounter = createCounter(
"hotel",
"getHotelIdsByCityId"
)
const metricsGetHotelIdsByCityId = getHotelIdsByCityIdCounter.init({
cityId,
})
metricsGetHotelIdsByCityId.start()
const cacheClient = await getCacheClient()
const result = await cacheClient.cacheOrGet(
`${cityId}:hotelsByCityId`,
async () => {
const searchParams = new URLSearchParams({
city: cityId,
})
const apiResponse = await api.get(
api.endpoints.v1.Hotel.hotels,
{
headers: {
Authorization: `Bearer ${serviceToken}`,
},
},
searchParams
)
if (!apiResponse.ok) {
await metricsGetHotelIdsByCityId.httpError(apiResponse)
throw new Error("Unable to fetch hotelIds by cityId")
}
const apiJson = await apiResponse.json()
const validatedHotelIds = getHotelIdsSchema.safeParse(apiJson)
if (!validatedHotelIds.success) {
metricsGetHotelIdsByCityId.validationError(validatedHotelIds.error)
throw new Error("Unable to parse data for hotelIds by cityId")
}
return validatedHotelIds.data
},
env.CACHE_TIME_HOTELS
)
metricsGetHotelIdsByCityId.success()
return result
}
export async function getHotelIdsByCountry({
country,
serviceToken,
}: {
country: string
serviceToken: string
}) {
const getHotelIdsByCountryCounter = createCounter(
"hotel",
"getHotelIdsByCountry"
)
const metricsGetHotelIdsByCountry = getHotelIdsByCountryCounter.init({
country,
})
metricsGetHotelIdsByCountry.start()
const cacheClient = await getCacheClient()
const result = await cacheClient.cacheOrGet(
`${country}:hotelsByCountry`,
async () => {
const hotelIdsParams = new URLSearchParams({
country,
})
const apiResponse = await api.get(
api.endpoints.v1.Hotel.hotels,
{
headers: {
Authorization: `Bearer ${serviceToken}`,
},
},
hotelIdsParams
)
if (!apiResponse.ok) {
await metricsGetHotelIdsByCountry.httpError(apiResponse)
throw new Error("Unable to fetch hotelIds by country")
}
const apiJson = await apiResponse.json()
const validatedHotelIds = getHotelIdsSchema.safeParse(apiJson)
if (!validatedHotelIds.success) {
metricsGetHotelIdsByCountry.validationError(validatedHotelIds.error)
throw new Error("Unable to parse hotelIds by country")
}
return validatedHotelIds.data
},
env.CACHE_TIME_HOTELS
)
metricsGetHotelIdsByCountry.success()
return result
}
export async function getHotelIdsByCityIdentifier(
cityIdentifier: string,
serviceToken: string
) {
const city = await getCityByCityIdentifier({
cityIdentifier,
lang: Lang.en,
serviceToken,
})
if (!city) {
return []
}
const hotelIds = await getHotelIdsByCityId({
cityId: city.id,
serviceToken,
})
return hotelIds
}
export async function getCityByCityIdentifier({
cityIdentifier,
lang,
serviceToken,
}: {
cityIdentifier: string
lang: Lang
serviceToken: string
}) {
const locations = await getLocations({
lang,
citiesByCountry: null,
serviceToken,
})
if (!locations || "error" in locations) {
return null
}
const city = locations
.filter((loc): loc is CityLocation => loc.type === "cities")
.find((loc) => loc.cityIdentifier === cityIdentifier)
return city ?? null
}
export async function getHotelsByHotelIds({
hotelIds,
lang,
serviceToken,
}: {
hotelIds: string[]
lang: Lang
serviceToken: string
}) {
const cacheClient = await getCacheClient()
const cacheKey = `${lang}:getHotelsByHotelIds:hotels:${hotelIds.sort().join(",")}`
return await cacheClient.cacheOrGet(
cacheKey,
async () => {
const hotelPages = await getHotelPageUrls(lang)
const chunkedHotelIds = chunk(hotelIds, 10)
const hotels: DestinationPagesHotelData[] = []
for (const hotelIdChunk of chunkedHotelIds) {
const chunkedHotels = await Promise.all(
hotelIdChunk.map(async (hotelId) => {
const hotelResponse = await getHotel(
{ hotelId, language: lang, isCardOnlyPayment: false },
serviceToken
)
if (!hotelResponse) {
throw new Error(`Hotel not found: ${hotelId}`)
}
const hotelPage = hotelPages.find(
(page) => page.hotelId === hotelId
)
const { hotel, cities } = hotelResponse
const data: DestinationPagesHotelData = {
hotel: {
id: hotel.id,
galleryImages: hotel.galleryImages,
name: hotel.name,
tripadvisor: hotel.ratings?.tripAdvisor?.rating,
detailedFacilities: hotel.detailedFacilities || [],
location: hotel.location,
hotelType: hotel.hotelType,
type: hotel.type,
address: hotel.address,
cityIdentifier: cities?.[0]?.cityIdentifier,
hotelDescription: hotel.hotelContent?.texts.descriptions?.short,
},
url: hotelPage?.url ?? "",
}
return data
})
)
hotels.push(...chunkedHotels)
}
return hotels.filter(
(hotel): hotel is DestinationPagesHotelData => !!hotel
)
},
"1d"
)
}
function findProduct(product: Products, rateDefinition: RateDefinition) {
if ("corporateCheque" in product) {
@@ -604,89 +71,6 @@ function findProduct(product: Products, rateDefinition: RateDefinition) {
}
}
export const getHotel = cache(
async (input: HotelInput, serviceToken: string) => {
const { hotelId, language, isCardOnlyPayment } = input
const getHotelCounter = createCounter("hotel", "getHotel")
const metricsGetHotel = getHotelCounter.init({
hotelId,
language,
isCardOnlyPayment,
})
metricsGetHotel.start()
const cacheClient = await getCacheClient()
const result = await cacheClient.cacheOrGet(
`${input.language}:hotel:${input.hotelId}:${!!input.isCardOnlyPayment}`,
async () => {
/**
* Since API expects the params appended and not just
* a comma separated string we need to initialize the
* SearchParams with a sequence of pairs
* (include=City&include=NearbyHotels&include=Restaurants etc.)
**/
const params = new URLSearchParams([
["include", "AdditionalData"],
["include", "City"],
["include", "NearbyHotels"],
["include", "Restaurants"],
["include", "RoomCategories"],
["language", toApiLang(language)],
])
const apiResponse = await api.get(
api.endpoints.v1.Hotel.Hotels.hotel(hotelId),
{
headers: {
Authorization: `Bearer ${serviceToken}`,
},
},
params
)
if (!apiResponse.ok) {
await metricsGetHotel.httpError(apiResponse)
return null
}
const apiJson = await apiResponse.json()
const validateHotelData = hotelSchema.safeParse(apiJson)
if (!validateHotelData.success) {
metricsGetHotel.validationError(validateHotelData.error)
throw badRequestError()
}
const hotelData = validateHotelData.data
if (isCardOnlyPayment) {
hotelData.hotel.merchantInformationData.alternatePaymentOptions = []
}
const gallery = hotelData.additionalData?.gallery
if (gallery) {
const smallerImages = gallery.smallerImages
const hotelGalleryImages =
hotelData.hotel.hotelType === HotelTypeEnum.Signature
? smallerImages.slice(0, 10)
: smallerImages.slice(0, 6)
hotelData.hotel.galleryImages = hotelGalleryImages
}
return hotelData
},
env.CACHE_TIME_HOTELS
)
metricsGetHotel.success()
return result
}
)
export async function getHotelsAvailabilityByCity(
input: HotelsAvailabilityInputSchema,
apiLang: string,