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

@@ -0,0 +1,6 @@
import { mergeRouters } from "../../.."
import { destinationCityPageQueryRouter } from "./query"
export const destinationCityPageRouter = mergeRouters(
destinationCityPageQueryRouter
)

View File

@@ -0,0 +1,263 @@
import { z } from "zod"
import { removeMultipleSlashes } from "@scandic-hotels/common/utils/url"
import { DestinationCityPageEnum } from "../../../types/destinationCityPage"
import { isDefined } from "../../../utils"
import { discriminatedUnionArray } from "../../../utils/discriminatedUnion"
import {
accordionRefsSchema,
accordionSchema,
} from "../schemas/blocks/accordion"
import { contentRefsSchema, contentSchema } from "../schemas/blocks/content"
import { tempImageVaultAssetSchema } from "../schemas/imageVault"
import { mapLocationSchema } from "../schemas/mapLocation"
import {
linkRefsUnionSchema,
linkUnionSchema,
transformPageLink,
} from "../schemas/pageLinks"
import { systemSchema } from "../schemas/system"
import type { ImageVaultAsset } from "../../../types/imageVault"
const destinationCityPageDestinationSettingsSchema = z
.object({
city_denmark: z.string().optional().nullable(),
city_finland: z.string().optional().nullable(),
city_germany: z.string().optional().nullable(),
city_poland: z.string().optional().nullable(),
city_norway: z.string().optional().nullable(),
city_sweden: z.string().optional().nullable(),
location: mapLocationSchema,
})
.transform(
({
city_denmark,
city_finland,
city_germany,
city_norway,
city_poland,
city_sweden,
location,
}) => {
return {
city:
city_denmark ||
city_finland ||
city_germany ||
city_poland ||
city_norway ||
city_sweden,
location,
}
}
)
export const destinationCityListDataSchema = z
.object({
all_destination_city_page: z.object({
items: z.array(
z
.object({
heading: z.string(),
destination_settings: destinationCityPageDestinationSettingsSchema,
sort_order: z.number().nullable(),
preamble: z.string(),
experiences: z
.object({
destination_experiences: z.array(z.string()),
})
.transform(
({ destination_experiences }) => destination_experiences
)
.nullish(),
images: z
.array(z.object({ image: tempImageVaultAssetSchema }))
.transform((images) =>
images
.map((image) => image.image)
.filter((image): image is ImageVaultAsset => !!image)
)
.nullish(),
url: z.string(),
system: systemSchema,
})
.transform((data) => {
return {
...data,
url: removeMultipleSlashes(`/${data.system.locale}/${data.url}`),
}
})
),
}),
})
.transform(
({ all_destination_city_page }) => all_destination_city_page.items?.[0]
)
export const destinationCityPageContent = z
.object({
__typename: z.literal(DestinationCityPageEnum.ContentStack.blocks.Content),
})
.merge(contentSchema)
export const destinationCityPageAccordion = z
.object({
__typename: z.literal(
DestinationCityPageEnum.ContentStack.blocks.Accordion
),
})
.merge(accordionSchema)
export const blocksSchema = z.discriminatedUnion("__typename", [
destinationCityPageAccordion,
destinationCityPageContent,
])
export const destinationCityPageSchema = z.object({
destination_city_page: z.object({
title: z.string(),
destination_settings: destinationCityPageDestinationSettingsSchema,
heading: z.string(),
preamble: z.string(),
experiences: z
.object({
destination_experiences: z.array(z.string()),
})
.nullish()
.transform((experiences) => experiences?.destination_experiences ?? []),
images: z
.array(z.object({ image: tempImageVaultAssetSchema }))
.transform((images) =>
images
.map((image) => image.image)
.filter((image): image is ImageVaultAsset => !!image)
)
.nullish(),
has_sidepeek: z.boolean().default(false),
sidepeek_button_text: z.string().nullish().default(""),
sidepeek_content: z
.object({
heading: z.string(),
content: z.object({
json: z.any(),
embedded_itemsConnection: z.object({
edges: z.array(
z.object({
node: linkUnionSchema.transform((data) => {
const link = transformPageLink(data)
if (link) {
return link
}
return data
}),
})
),
}),
}),
})
.nullish(),
blocks: discriminatedUnionArray(blocksSchema.options).nullable(),
system: systemSchema.merge(
z.object({
created_at: z.string(),
updated_at: z.string(),
})
),
}),
trackingProps: z.object({
url: z.string(),
}),
})
export const cityPageCountSchema = z
.object({
all_destination_city_page: z.object({
total: z.number(),
}),
})
.transform(({ all_destination_city_page }) => all_destination_city_page.total)
export const cityPageUrlsSchema = z
.object({
all_destination_city_page: z.object({
items: z
.array(
z
.object({
url: z.string().nullish(),
destination_settings:
destinationCityPageDestinationSettingsSchema,
system: systemSchema,
})
.transform((data) => {
if (!data.destination_settings.city || !data.url) {
return null
}
return {
city: data.destination_settings.city,
url: removeMultipleSlashes(
`/${data.system.locale}/${data.url}`
),
}
})
)
.transform((data) => {
return data.filter(isDefined)
}),
}),
})
.transform(({ all_destination_city_page }) => all_destination_city_page.items)
export const batchedCityPageUrlsSchema = z
.array(
z.object({
data: cityPageUrlsSchema,
})
)
.transform((allItems) => {
return allItems.flatMap((item) => item.data)
})
/** REFS */
const destinationCityPageContentRefs = z
.object({
__typename: z.literal(DestinationCityPageEnum.ContentStack.blocks.Content),
})
.merge(contentRefsSchema)
const destinationCityPageAccordionRefs = z
.object({
__typename: z.literal(
DestinationCityPageEnum.ContentStack.blocks.Accordion
),
})
.merge(accordionRefsSchema)
const blocksRefsSchema = z.discriminatedUnion("__typename", [
destinationCityPageAccordionRefs,
destinationCityPageContentRefs,
])
export const destinationCityPageRefsSchema = z.object({
destination_city_page: z.object({
destination_settings: destinationCityPageDestinationSettingsSchema,
sidepeek_content: z
.object({
content: z.object({
embedded_itemsConnection: z.object({
edges: z.array(
z.object({
node: linkRefsUnionSchema,
})
),
}),
}),
})
.nullish(),
blocks: discriminatedUnionArray(blocksRefsSchema.options).nullable(),
system: systemSchema,
}),
})

View File

@@ -0,0 +1,146 @@
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 {
GetDestinationCityPage,
GetDestinationCityPageRefs,
} from "../../../graphql/Query/DestinationCityPage/DestinationCityPage.graphql"
import { request } from "../../../graphql/request"
import { generateRefsResponseTag } from "../../../utils/generateTag"
import { getCityByCityIdentifier } from "../../hotels/utils"
import {
destinationCityPageRefsSchema,
destinationCityPageSchema,
} from "./output"
import { generatePageTags } from "./utils"
import type {
GetDestinationCityPageData,
GetDestinationCityPageRefsSchema,
} from "../../../types/destinationCityPage"
import type { TrackingPageData } from "../../types"
export const destinationCityPageQueryRouter = router({
get: contentStackUidWithServiceProcedure.query(async ({ ctx }) => {
const { lang, uid, serviceToken } = ctx
const getDestinationCityPageRefsCounter = createCounter(
"trpc.contentstack",
"destinationCityPage.get.refs"
)
const metricsGetDestinationCityPageRefs =
getDestinationCityPageRefsCounter.init({ lang, uid })
metricsGetDestinationCityPageRefs.start()
const refsResponse = await request<GetDestinationCityPageRefsSchema>(
GetDestinationCityPageRefs,
{ locale: lang, uid },
{
key: generateRefsResponseTag(lang, uid),
ttl: "max",
}
)
if (!refsResponse.data) {
const notFoundError = notFound(refsResponse)
metricsGetDestinationCityPageRefs.noDataError()
throw notFoundError
}
const validatedRefsData = destinationCityPageRefsSchema.safeParse(
refsResponse.data
)
if (!validatedRefsData.success) {
metricsGetDestinationCityPageRefs.validationError(validatedRefsData.error)
return null
}
metricsGetDestinationCityPageRefs.success()
const tags = generatePageTags(validatedRefsData.data, lang)
const getDestinationCityPageCounter = createCounter(
"trpc.contentstack",
"destinationCityPage.get"
)
const metricsGetDestinationCityPage = getDestinationCityPageCounter.init({
lang,
uid,
})
metricsGetDestinationCityPage.start()
const response = await request<GetDestinationCityPageData>(
GetDestinationCityPage,
{
locale: lang,
uid,
},
{
key: tags,
ttl: "max",
}
)
if (!response.data) {
const notFoundError = notFound(response)
metricsGetDestinationCityPage.noDataError()
throw notFoundError
}
const validatedResponse = destinationCityPageSchema.safeParse(response.data)
if (!validatedResponse.success) {
metricsGetDestinationCityPage.validationError(validatedResponse.error)
return null
}
const destinationCityPage = validatedResponse.data.destination_city_page
const cityIdentifier = destinationCityPage.destination_settings.city
if (!cityIdentifier) {
return null
}
const city = await getCityByCityIdentifier({
cityIdentifier,
lang,
serviceToken,
})
if (!city) {
metricsGetDestinationCityPage.dataError(
`Failed to get city data for ${cityIdentifier}`,
{
cityIdentifier,
}
)
return null
}
metricsGetDestinationCityPage.success()
const system = destinationCityPage.system
const pageName = `destinations|${city.country}|${city.name}`
const tracking: TrackingPageData = {
pageId: system.uid,
domainLanguage: system.locale,
publishDate: system.updated_at,
createDate: system.created_at,
channel: "hotels",
pageType: "citypage",
pageName,
siteSections: pageName,
siteVersion: "new-web",
}
return {
destinationCityPage,
cityIdentifier,
city,
tracking,
}
}),
})

View File

@@ -0,0 +1,151 @@
import { createCounter } from "@scandic-hotels/common/telemetry"
import { GetCityPageCount } from "../../../graphql/Query/DestinationCityPage/DestinationCityPageCount.graphql"
import { GetCityPageUrls } from "../../../graphql/Query/DestinationCityPage/DestinationCityPageUrl.graphql"
import { request } from "../../../graphql/request"
import { DestinationCityPageEnum } from "../../../types/destinationCityPage"
import { generateTag, generateTagsFromSystem } from "../../../utils/generateTag"
import { batchedCityPageUrlsSchema, cityPageCountSchema } from "./output"
import type { Lang } from "@scandic-hotels/common/constants/language"
import type {
DestinationCityPageRefs,
GetCityPageCountData,
GetCityPageUrlsData,
} from "../../../types/destinationCityPage"
import type { System } from "../schemas/system"
export function generatePageTags(
validatedData: DestinationCityPageRefs,
lang: Lang
): string[] {
const connections = getConnections(validatedData)
return [
// This tag is added for the city list data on country pages to invalidate the list when city page changes.
generateTag(
lang,
`city_list_data:${validatedData.destination_city_page.destination_settings.city}`
),
generateTagsFromSystem(lang, connections),
generateTag(lang, validatedData.destination_city_page.system.uid),
].flat()
}
export function getConnections({
destination_city_page,
}: DestinationCityPageRefs) {
const connections: System["system"][] = [destination_city_page.system]
if (destination_city_page.blocks) {
destination_city_page.blocks.forEach((block) => {
switch (block.__typename) {
case DestinationCityPageEnum.ContentStack.blocks.Accordion: {
if (block.accordion.length) {
connections.push(...block.accordion)
}
break
}
case DestinationCityPageEnum.ContentStack.blocks.Content:
{
if (block.content.length) {
// TS has trouble infering the filtered types
// @ts-ignore
connections.push(...block.content)
}
}
break
}
})
}
if (destination_city_page.sidepeek_content) {
destination_city_page.sidepeek_content.content.embedded_itemsConnection.edges.forEach(
({ node }) => {
connections.push(node.system)
}
)
}
return connections
}
export async function getCityPageCount(lang: Lang) {
const getCityPageCountCounter = createCounter(
"trpc.contentstack",
"cityPageCount.get"
)
const metricsGetCityPageCount = getCityPageCountCounter.init({ lang })
metricsGetCityPageCount.start()
const response = await request<GetCityPageCountData>(
GetCityPageCount,
{
locale: lang,
},
{
key: `${lang}:city_page_count`,
ttl: "max",
}
)
if (!response.data) {
metricsGetCityPageCount.dataError(
`Failed to get city pages count for ${lang}`
)
return 0
}
const validatedResponse = cityPageCountSchema.safeParse(response.data)
if (!validatedResponse.success) {
metricsGetCityPageCount.validationError(validatedResponse.error)
return 0
}
metricsGetCityPageCount.success()
return validatedResponse.data
}
export async function getCityPageUrls(lang: Lang) {
const getCityPageUrlsCounter = createCounter(
"trpc.contentstack",
"cityPageUrls.get"
)
const metricsGetCityPageUrls = getCityPageUrlsCounter.init({ lang })
metricsGetCityPageUrls.start()
const count = await getCityPageCount(lang)
if (count === 0) {
return []
}
// Calculating the amount of requests needed to fetch all pages.
// Contentstack has a limit of 100 items per request.
// So we need to make multiple requests to fetch urls to all pages.
// The `batchRequest` function is not working here, because the arrayMerge is
// used for other purposes.
const amountOfRequests = Math.ceil(count / 100)
const batchedResponse = await Promise.all(
Array.from({ length: amountOfRequests }).map((_, i) =>
request<GetCityPageUrlsData>(
GetCityPageUrls,
{ locale: lang, skip: i * 100 },
{ key: `${lang}:city_page_urls_batch_${i}`, ttl: "max" }
)
)
)
const validatedResponse = batchedCityPageUrlsSchema.safeParse(batchedResponse)
if (!validatedResponse.success) {
metricsGetCityPageUrls.validationError(validatedResponse.error)
return []
}
metricsGetCityPageUrls.success()
return validatedResponse.data
}