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
@@ -0,0 +1,67 @@
import type {
CategorizedFilters,
Filter,
} from "../../../types/destinationFilterAndSort"
import type { DestinationPagesHotelData } from "../../../types/hotel"
const HOTEL_SURROUNDINGS_FILTER_TYPE_NAMES = [
"Hotel surroundings",
"Hotel omgivelser",
"Hotelumgebung",
"Hotellia lähellä",
"Hotellomgivelser",
"Omgivningar",
]
const HOTEL_FACILITIES_FILTER_TYPE_NAMES = [
"Hotel facilities",
"Hotellfaciliteter",
"Hotelfaciliteter",
"Hotel faciliteter",
"Hotel-Infos",
"Hotellin palvelut",
]
export function getFiltersFromHotels(
hotels: DestinationPagesHotelData[]
): CategorizedFilters {
if (hotels.length === 0) {
return { facilityFilters: [], surroundingsFilters: [] }
}
const filters = hotels.flatMap(({ hotel }) => hotel.detailedFacilities)
const uniqueFilterNames = [...new Set(filters.map((filter) => filter.name))]
const filterList = uniqueFilterNames
.map((filterName) => {
const filter = filters.find((filter) => filter.name === filterName)
return filter
? {
name: filter.name,
slug: filter.slug,
filterType: filter.filter,
sortOrder: filter.sortOrder,
}
: null
})
.filter((filter): filter is Filter => !!filter)
const facilityFilters = filterList.filter((filter) =>
HOTEL_FACILITIES_FILTER_TYPE_NAMES.includes(filter.filterType)
)
const surroundingsFilters = filterList.filter((filter) =>
HOTEL_SURROUNDINGS_FILTER_TYPE_NAMES.includes(filter.filterType)
)
return {
facilityFilters: sortFilters(facilityFilters),
surroundingsFilters: sortFilters(surroundingsFilters),
}
}
function sortFilters(filters: Filter[]): Filter[] {
return [...filters].sort((a, b) => {
// First sort by sortOrder
const orderDiff = a.sortOrder - b.sortOrder
// If sortOrder is the same, sort by name as secondary criterion
return orderDiff === 0 ? a.name.localeCompare(b.name) : orderDiff
})
}
@@ -0,0 +1,4 @@
import { mergeRouters } from "../../.."
import { metadataQueryRouter } from "./query"
export const metadataRouter = mergeRouters(metadataQueryRouter)
@@ -0,0 +1,9 @@
import { z } from "zod"
export const getMetadataInput = z.object({
subpage: z.string().optional(),
filterFromUrl: z.string().optional(),
noIndex: z.boolean().default(false),
})
export interface MetadataInputSchema extends z.input<typeof getMetadataInput> {}
@@ -0,0 +1,201 @@
import { z } from "zod"
import { findMyBooking } from "@scandic-hotels/common/constants/routes/findMyBooking"
import { myStay } from "@scandic-hotels/common/constants/routes/myStay"
import { attributesSchema as hotelAttributesSchema } from "../../../routers/hotels/schemas/hotel"
import { Country } from "../../../types/country"
import { RTETypeEnum } from "../../../types/RTEenums"
import { additionalDataAttributesSchema } from "../../hotels/schemas/hotel/include/additionalData"
import { imageSchema } from "../../hotels/schemas/image"
import { tempImageVaultAssetSchema } from "../schemas/imageVault"
import { systemSchema } from "../schemas/system"
import type { Lang } from "@scandic-hotels/common/constants/language"
import type { ImageVaultAsset } from "../../../types/imageVault"
import type { LanguageSwitcherData } from "../../../types/languageSwitcher"
const metaDataJsonSchema = z.object({
children: z.array(
z.object({
type: z.nativeEnum(RTETypeEnum),
children: z.array(
z.object({
text: z.string().optional(),
})
),
})
),
})
const metaDataBlocksSchema = z
.array(
z.object({
content: z
.object({
content: z
.object({
json: metaDataJsonSchema,
})
.optional()
.nullable(),
})
.optional()
.nullable(),
})
)
.optional()
.nullable()
export const rawMetadataSchema = z.object({
web: z
.object({
seo_metadata: z
.object({
title: z.string().nullish(),
description: z.string().nullish(),
noindex: z.boolean().nullish(),
seo_image: tempImageVaultAssetSchema.nullable(),
})
.nullish(),
breadcrumbs: z
.object({
title: z.string().nullish(),
})
.nullish(),
})
.nullish(),
destination_settings: z
.object({
city_denmark: z.string().nullish(),
city_finland: z.string().nullish(),
city_germany: z.string().nullish(),
city_poland: z.string().nullish(),
city_norway: z.string().nullish(),
city_sweden: z.string().nullish(),
country: z.nativeEnum(Country).nullish(),
})
.nullish(),
heading: z.string().nullish(),
preamble: z
.union([
z.string(),
z.object({
first_column: z.string(),
}),
])
.transform((preamble) => {
if (typeof preamble === "string") {
return preamble
}
return preamble?.first_column || null
})
.nullish(),
header: z
.object({
heading: z.string().nullish(),
preamble: z.string().nullish(),
hero_image: tempImageVaultAssetSchema.nullable(),
})
.nullish(),
hero_image: tempImageVaultAssetSchema.nullable(),
images: z
.array(z.object({ image: tempImageVaultAssetSchema }).nullish())
.transform((images) =>
images
.map((image) => image?.image)
.filter((image): image is ImageVaultAsset => !!image)
)
.nullish(),
blocks: metaDataBlocksSchema,
hotel_page_id: z.string().nullish(),
hotelData: hotelAttributesSchema
.pick({
name: true,
address: true,
detailedFacilities: true,
hotelContent: true,
healthFacilities: true,
})
.nullish(),
additionalHotelData: additionalDataAttributesSchema
.pick({
gallery: true,
hotelParking: true,
healthAndFitness: true,
hotelSpecialNeeds: true,
meetingRooms: true,
parkingImages: true,
accessibility: true,
conferencesAndMeetings: true,
})
.nullish(),
hotelRestaurants: z
.array(
z.object({
nameInUrl: z.string().nullish(),
elevatorPitch: z.string().nullish(),
name: z.string().nullish(),
content: z
.object({
images: z.array(imageSchema).nullish(),
})
.nullish(),
})
)
.nullish(),
subpageUrl: z.string().nullish(),
destinationData: z
.object({
location: z.string().nullish(),
filter: z.string().nullish(),
filterType: z.enum(["facility", "surroundings"]).nullish(),
hotelCount: z.number().nullish(),
cities: z.array(z.string()).nullish(),
})
.nullish(),
system: systemSchema,
})
export interface RawMetadataSchema extends z.output<typeof rawMetadataSchema> {}
// Several pages are not currently routed within contentstack context.
// This function is used to generate the urls for these pages.
export function getNonContentstackUrls(lang: Lang, pathName: string) {
if (Object.values(findMyBooking).includes(pathName)) {
const urls: LanguageSwitcherData = {}
return Object.entries(findMyBooking).reduce((acc, [lang, url]) => {
acc[lang as Lang] = { url }
return urls
}, urls)
}
if (Object.values(myStay).includes(pathName)) {
const urls: LanguageSwitcherData = {}
return Object.entries(myStay).reduce((acc, [lang, url]) => {
acc[lang as Lang] = { url }
return urls
}, urls)
}
if (pathName.startsWith(hotelreservation(lang))) {
return baseUrls
}
return { [lang]: { url: pathName } }
}
const baseUrls: LanguageSwitcherData = {
da: { url: "/da/" },
de: { url: "/de/" },
en: { url: "/en/" },
fi: { url: "/fi/" },
no: { url: "/no/" },
sv: { url: "/sv/" },
}
function hotelreservation(lang: Lang) {
return `/${lang}/hotelreservation`
}
@@ -0,0 +1,251 @@
import { cache } from "react"
import { createCounter } from "@scandic-hotels/common/telemetry"
import { notFound } from "@scandic-hotels/trpc/errors"
import { contentStackUidWithServiceProcedure } from "@scandic-hotels/trpc/procedures"
import { router } from "../../.."
import { PageContentTypeEnum } from "../../../enums/contentType"
import { GetAccountPageMetadata } from "../../../graphql/Query/AccountPage/Metadata.graphql"
import { GetCampaignOverviewPageMetadata } from "../../../graphql/Query/CampaignOverviewPage/Metadata.graphql"
import { GetCampaignPageMetadata } from "../../../graphql/Query/CampaignPage/Metadata.graphql"
import { GetCollectionPageMetadata } from "../../../graphql/Query/CollectionPage/Metadata.graphql"
import { GetContentPageMetadata } from "../../../graphql/Query/ContentPage/Metadata.graphql"
import { GetDestinationCityPageMetadata } from "../../../graphql/Query/DestinationCityPage/Metadata.graphql"
import { GetDestinationCountryPageMetadata } from "../../../graphql/Query/DestinationCountryPage/Metadata.graphql"
import { GetDestinationOverviewPageMetadata } from "../../../graphql/Query/DestinationOverviewPage/Metadata.graphql"
import { GetHotelPageMetadata } from "../../../graphql/Query/HotelPage/Metadata.graphql"
import { GetLoyaltyPageMetadata } from "../../../graphql/Query/LoyaltyPage/Metadata.graphql"
import { GetStartPageMetadata } from "../../../graphql/Query/StartPage/Metadata.graphql"
import { request } from "../../../graphql/request"
import { generateTag } from "../../../utils/generateTag"
import { getHotel } from "../../hotels/utils"
import { getUrlsOfAllLanguages } from "../languageSwitcher/utils"
import { getMetadataInput } from "./input"
import { getNonContentstackUrls, rawMetadataSchema } from "./output"
import { affix, getCityData, getCountryData } from "./utils"
import type { Lang } from "@scandic-hotels/common/constants/language"
import type { LanguageSwitcherData } from "../../../types/languageSwitcher"
import type { RawMetadataSchema } from "./output"
const fetchMetadata = cache(async function fetchMemoizedMetadata<T>(
query: string,
{ uid, lang }: { uid: string; lang: Lang }
) {
const getMetadataCounter = createCounter("trpc.contentstack", "metadata.get")
const metricsGetMetadata = getMetadataCounter.init({ lang, uid })
metricsGetMetadata.start()
const response = await request<T>(
query,
{ locale: lang, uid },
{
key: generateTag(lang, uid, affix),
ttl: "max",
}
)
if (!response.data) {
const notFoundError = notFound(response)
metricsGetMetadata.noDataError()
throw notFoundError
}
metricsGetMetadata.success()
return response.data
})
type Alternates = {
canonical?: string | null
languages?: Record<string, string>
}
type Robots = {
index?: boolean
follow?: boolean
}
export const metadataQueryRouter = router({
get: contentStackUidWithServiceProcedure
.input(getMetadataInput)
.query(async ({ ctx, input }) => {
const variables = {
lang: ctx.lang,
uid: ctx.uid,
}
let urls: LanguageSwitcherData | null = null
if (
input.subpage ||
input.filterFromUrl ||
!ctx.uid ||
!ctx.contentType
) {
urls = getNonContentstackUrls(ctx.lang, `${ctx.lang}/${ctx.pathname}`)
} else {
urls = await getUrlsOfAllLanguages(ctx.lang, ctx.uid, ctx.contentType)
}
let data: unknown = null
switch (ctx.contentType) {
case PageContentTypeEnum.accountPage:
const accountPageResponse = await fetchMetadata<{
account_page: RawMetadataSchema
}>(GetAccountPageMetadata, variables)
data = accountPageResponse.account_page
break
case PageContentTypeEnum.campaignOverviewPage:
const campaignOverviewPageResponse = await fetchMetadata<{
campaign_overview_page: RawMetadataSchema
}>(GetCampaignOverviewPageMetadata, variables)
data = campaignOverviewPageResponse.campaign_overview_page
break
case PageContentTypeEnum.campaignPage:
const campaignPageResponse = await fetchMetadata<{
campaign_page: RawMetadataSchema
}>(GetCampaignPageMetadata, variables)
data = campaignPageResponse.campaign_page
break
case PageContentTypeEnum.collectionPage:
const collectionPageResponse = await fetchMetadata<{
collection_page: RawMetadataSchema
}>(GetCollectionPageMetadata, variables)
data = collectionPageResponse.collection_page
break
case PageContentTypeEnum.contentPage:
const contentPageResponse = await fetchMetadata<{
content_page: RawMetadataSchema
}>(GetContentPageMetadata, variables)
data = contentPageResponse.content_page
break
case PageContentTypeEnum.destinationOverviewPage:
const destinationOverviewPageResponse = await fetchMetadata<{
destination_overview_page: RawMetadataSchema
}>(GetDestinationOverviewPageMetadata, variables)
data = destinationOverviewPageResponse.destination_overview_page
break
case PageContentTypeEnum.destinationCountryPage:
const destinationCountryPageResponse = await fetchMetadata<{
destination_country_page: RawMetadataSchema
}>(GetDestinationCountryPageMetadata, variables)
const countryData = await getCountryData(
destinationCountryPageResponse.destination_country_page,
input,
ctx.serviceToken,
ctx.lang
)
data = {
...destinationCountryPageResponse.destination_country_page,
destinationData: countryData,
}
break
case PageContentTypeEnum.destinationCityPage:
const destinationCityPageResponse = await fetchMetadata<{
destination_city_page: RawMetadataSchema
}>(GetDestinationCityPageMetadata, variables)
const cityData = await getCityData(
destinationCityPageResponse.destination_city_page,
input,
ctx.serviceToken,
ctx.lang
)
data = {
...destinationCityPageResponse.destination_city_page,
destinationData: cityData,
}
break
case PageContentTypeEnum.loyaltyPage:
const loyaltyPageResponse = await fetchMetadata<{
loyalty_page: RawMetadataSchema
}>(GetLoyaltyPageMetadata, variables)
data = loyaltyPageResponse.loyalty_page
break
case PageContentTypeEnum.hotelPage:
const hotelPageResponse = await fetchMetadata<{
hotel_page: RawMetadataSchema
}>(GetHotelPageMetadata, variables)
const hotelPageData = hotelPageResponse.hotel_page
const hotelData = hotelPageData.hotel_page_id
? await getHotel(
{
hotelId: hotelPageData.hotel_page_id,
isCardOnlyPayment: false,
language: ctx.lang,
},
ctx.serviceToken
)
: null
data = {
...hotelPageData,
...(hotelData
? {
hotelData: hotelData.hotel,
additionalHotelData: hotelData.additionalData,
hotelRestaurants: hotelData.restaurants,
}
: {}),
subpageUrl: input.subpage,
}
break
case PageContentTypeEnum.startPage:
const startPageResponse = await fetchMetadata<{
start_page: RawMetadataSchema
}>(GetStartPageMetadata, variables)
data = startPageResponse.start_page
break
default:
break
}
let alternates: Alternates | null = null
let robots: Robots | null = null
if (urls) {
const languages: Record<string, string> = {}
Object.entries(urls).forEach(([lang, { url }]) => {
languages[lang] = url
})
const canonical = urls[ctx.lang]?.url
alternates = {
canonical,
languages,
}
}
if (!data) {
alternates = null
}
if (input.noIndex) {
robots = {
index: false,
follow: false,
}
}
const transformMetadataCounter = createCounter(
"trpc.contentstack",
"metadata.transform"
)
const metricsTransformMetadata = transformMetadataCounter.init()
metricsTransformMetadata.start()
const rawMetadataResult = await rawMetadataSchema.safeParseAsync(data)
if (!rawMetadataResult.success) {
metricsTransformMetadata.validationError(rawMetadataResult.error)
return { rawMetadata: null, robots: null, alternates: null }
}
metricsTransformMetadata.success()
return { rawMetadata: rawMetadataResult.data, robots, alternates }
}),
})
@@ -0,0 +1,136 @@
import { SortOption } from "../../../enums/destinationFilterAndSort"
import { ApiCountry } from "../../../types/country"
import { getSortedCities } from "../../../utils/getSortedCities"
import {
getCityByCityIdentifier,
getHotelIdsByCityIdentifier,
getHotelIdsByCountry,
getHotelsByHotelIds,
} from "../../hotels/utils"
import { getCityPages } from "../destinationCountryPage/utils"
import { getFiltersFromHotels } from "./helpers"
import type { Lang } from "@scandic-hotels/common/constants/language"
import type { MetadataInputSchema } from "./input"
import type { RawMetadataSchema } from "./output"
export const affix = "metadata"
export async function getCityData(
data: RawMetadataSchema,
input: MetadataInputSchema,
serviceToken: string,
lang: Lang
) {
const destinationSettings = data.destination_settings
const filter = input.filterFromUrl
if (destinationSettings) {
const {
city_sweden,
city_norway,
city_denmark,
city_finland,
city_germany,
city_poland,
} = destinationSettings
const cities = [
city_denmark,
city_finland,
city_germany,
city_poland,
city_norway,
city_sweden,
].filter((city): city is string => Boolean(city))
const cityIdentifier = cities[0]
if (cityIdentifier) {
const cityData = await getCityByCityIdentifier({
cityIdentifier,
serviceToken,
lang,
})
const hotelIds = await getHotelIdsByCityIdentifier(
cityIdentifier,
serviceToken
)
const hotels = await getHotelsByHotelIds({ hotelIds, lang, serviceToken })
let filterType
if (filter) {
const allFilters = getFiltersFromHotels(hotels)
const facilityFilter = allFilters.facilityFilters.find(
(f) => f.slug === filter
)
const surroudingsFilter = allFilters.surroundingsFilters.find(
(f) => f.slug === filter
)
if (facilityFilter) {
filterType = "facility"
} else if (surroudingsFilter) {
filterType = "surroundings"
}
}
return {
location: cityData?.name,
filter,
filterType,
hotelCount: hotelIds.length,
}
}
}
return null
}
export async function getCountryData(
data: RawMetadataSchema,
input: MetadataInputSchema,
serviceToken: string,
lang: Lang
) {
const country = data.destination_settings?.country
const filter = input.filterFromUrl
if (country) {
const translatedCountry = ApiCountry[lang][country]
let filterType
const cities = await getCityPages(lang, serviceToken, country)
const sortedCities = getSortedCities(cities, SortOption.Recommended)
const hotelIds = await getHotelIdsByCountry({
country,
serviceToken,
})
const hotels = await getHotelsByHotelIds({ hotelIds, lang, serviceToken })
if (filter) {
const allFilters = getFiltersFromHotels(hotels)
const facilityFilter = allFilters.facilityFilters.find(
(f) => f.slug === filter
)
const surroudingsFilter = allFilters.surroundingsFilters.find(
(f) => f.slug === filter
)
if (facilityFilter) {
filterType = "facility"
} else if (surroudingsFilter) {
filterType = "surroundings"
}
}
return {
location: translatedCountry,
filter,
filterType,
cities: sortedCities.slice(0, 2).map(({ cityName }) => cityName),
hotelCount: hotelIds.length,
}
}
return null
}