Merged in feat/rework-contentstack (pull request #3493)

Feat(SW-3708): refactor contentstack fetching (removing all refs) and cache invalidation

* Remove all REFS

* Revalidate correct language

* PR fixes

* PR fixes

* Throw when errors from contentstack api


Approved-by: Joakim Jäderberg
This commit is contained in:
Linus Flood
2026-01-27 12:38:36 +00:00
parent a5e214f783
commit 5fc93472f4
193 changed files with 489 additions and 9018 deletions

View File

@@ -11,20 +11,14 @@ import { nullableStringValidator } from "@scandic-hotels/common/utils/zod/string
import { discriminatedUnion } from "../../../utils/discriminatedUnion"
import {
infoCardBlockRefsSchema,
infoCardBlockSchema,
transformCardBlockRefs,
transformInfoCardBlock,
} from "../schemas/blocks/cardsGrid"
import { linkConnectionRefsSchema } from "../schemas/blocks/utils/linkConnection"
import {
linkRefsUnionSchema,
linkUnionSchema,
rawLinkUnionSchema,
transformPageLink,
transformPageLinkRef,
} from "../schemas/pageLinks"
import { systemSchema } from "../schemas/system"
// Help me write this zod schema based on the type ContactConfig
export const validateContactConfigSchema = z.object({
@@ -218,150 +212,6 @@ export const validateFooterConfigSchema = z
}
})
const pageConnectionRefs = z.object({
edges: z
.array(
z.object({
node: z.object({
system: systemSchema,
}),
})
)
.max(1),
})
export const validateFooterRefConfigSchema = z.object({
all_footer: z.object({
items: z
.array(
z.object({
main_links: z
.array(
z.object({
pageConnection: pageConnectionRefs,
})
)
.nullable(),
secondary_links: z
.array(
z.object({
links: z.array(
z.object({
pageConnection: pageConnectionRefs,
})
),
})
)
.nullable(),
tertiary_links: z
.array(
z.object({
pageConnection: pageConnectionRefs,
})
)
.nullable(),
system: systemSchema,
})
)
.length(1),
}),
})
/**
* New Header Validation
*/
const linkRefsSchema = z
.object({
linkConnection: z.object({
edges: z.array(
z.object({
node: linkRefsUnionSchema,
})
),
}),
})
.transform((data) => {
if (data.linkConnection.edges.length) {
const link = transformPageLinkRef(data.linkConnection.edges[0].node)
if (link) {
return {
link,
}
}
}
return { link: null }
})
const menuItemsRefsSchema = z.intersection(
linkRefsSchema,
z
.object({
cardConnection: z.object({
edges: z.array(
z.object({
node: infoCardBlockRefsSchema,
})
),
}),
see_all_link: linkRefsSchema,
submenu: z.array(
z.object({
links: z.array(linkRefsSchema),
})
),
})
.transform((data) => {
let card = null
if (data.cardConnection.edges.length) {
card = transformCardBlockRefs(data.cardConnection.edges[0].node)
}
return {
card,
see_all_link: data.see_all_link,
submenu: data.submenu,
}
})
)
const topLinkRefsSchema = z.object({
logged_in: linkRefsSchema.nullable(),
logged_out: linkRefsSchema.nullable(),
})
export const headerRefsSchema = z
.object({
all_header: z.object({
items: z
.array(
z.object({
menu_items: z.array(menuItemsRefsSchema),
system: systemSchema,
top_link: topLinkRefsSchema,
})
)
.max(1),
}),
})
.transform((data) => {
if (!data.all_header.items.length) {
logger.error(`Zod Error - No header returned in refs request`)
throw new ZodError([
{
code: ZodIssueCode.custom,
fatal: true,
message: "No header returned (Refs)",
path: ["all_header.items"],
},
])
}
return {
header: data.all_header.items[0],
}
})
const internalOrExternalLinkSchema = z
.object({
is_contentstack_link: z.boolean().nullish(),
@@ -687,57 +537,6 @@ export const siteConfigSchema = z
}
})
const sidepeekContentRefSchema = z.object({
content: z.object({
embedded_itemsConnection: z.object({
edges: z.array(
z.object({
node: linkRefsUnionSchema,
})
),
}),
}),
})
const alertConnectionRefSchema = z.object({
edges: z.array(
z.object({
node: z.object({
link: linkRefsSchema,
sidepeek_content: sidepeekContentRefSchema,
system: systemSchema,
}),
})
),
visible_on: z.array(z.string()).nullish().default([]),
})
export const siteConfigRefSchema = z.object({
all_site_config: z.object({
items: z.array(
z.object({
sitewide_alert: z
.object({
alerts: z
.array(
z.object({
alertConnection: alertConnectionRefSchema,
})
)
.nullable(),
})
.transform((data) => {
const sitewideAlertWeb = data.alerts?.find((alert) =>
alert.alertConnection.visible_on?.includes(AlertVisibleOnEnum.WEB)
)
return { alert: sitewideAlertWeb || null }
}),
system: systemSchema,
})
),
}),
})
const bannerSchema = z
.object({
tag: z.string(),
@@ -762,20 +561,6 @@ const bannerSchema = z
}
})
const bannerRefSchema = z
.object({
link: linkConnectionRefsSchema,
visible_on: z.array(z.string()).nullish().default([]),
system: systemSchema,
})
.transform(({ link, visible_on, system }) => {
return {
linkSystem: link,
visible_on,
system,
}
})
export const sitewideCampaignBannerSchema = z
.object({
all_sitewide_campaign_banner: z.object({
@@ -806,29 +591,3 @@ export const sitewideCampaignBannerSchema = z
return sitewideCampaignBannerWeb?.node ?? null
})
export const sitewideCampaignBannerRefSchema = z.object({
all_sitewide_campaign_banner: z
.object({
items: z.array(
z.object({
bannerConnection: z.object({
edges: z.array(
z.object({
node: bannerRefSchema,
})
),
}),
system: systemSchema,
})
),
})
.transform((data) => {
const webBanner = data.items.find((item) => {
const bannerNode = item.bannerConnection.edges[0]?.node
return bannerNode?.visible_on?.includes(AlertVisibleOnEnum.WEB)
})
return webBanner ?? null
}),
})

View File

@@ -5,57 +5,31 @@ import { createCounter } from "@scandic-hotels/common/telemetry"
import { router } from "../../.."
import { notFoundError } from "../../../errors"
import { GetContactConfig } from "../../../graphql/Query/ContactConfig.graphql"
import { GetFooter, GetFooterRef } from "../../../graphql/Query/Footer.graphql"
import { GetHeader, GetHeaderRef } from "../../../graphql/Query/Header.graphql"
import {
GetSiteConfig,
GetSiteConfigRef,
} from "../../../graphql/Query/SiteConfig.graphql"
import {
GetSitewideCampaignBanner,
GetSitewideCampaignBannerRef,
} from "../../../graphql/Query/SitewideCampaignBanner.graphql"
import { GetFooter } from "../../../graphql/Query/Footer.graphql"
import { GetHeader } from "../../../graphql/Query/Header.graphql"
import { GetSiteConfig } from "../../../graphql/Query/SiteConfig.graphql"
import { GetSitewideCampaignBanner } from "../../../graphql/Query/SitewideCampaignBanner.graphql"
import { request } from "../../../graphql/request"
import { contentstackBaseProcedure } from "../../../procedures"
import { langInput } from "../../../utils"
import {
generateRefsResponseTag,
generateTag,
generateTags,
generateTagsFromSystem,
} from "../../../utils/generateTag"
import { generateTag } from "../../../utils/generateTag"
import {
type ContactConfigData,
headerRefsSchema,
headerSchema,
siteConfigRefSchema,
siteConfigSchema,
sitewideCampaignBannerRefSchema,
sitewideCampaignBannerSchema,
validateContactConfigSchema,
validateFooterConfigSchema,
validateFooterRefConfigSchema,
} from "./output"
import {
getAlertPhoneContactData,
getConnections,
getFooterConnections,
getSiteConfigConnections,
getSitewideCampaignBannerConnections,
} from "./utils"
import { getAlertPhoneContactData } from "./utils"
import type { Lang } from "@scandic-hotels/common/constants/language"
import type { FooterDataRaw, FooterRefDataRaw } from "../../../types/footer"
import type {
GetHeader as GetHeaderData,
GetHeaderRefs,
} from "../../../types/header"
import type { FooterDataRaw } from "../../../types/footer"
import type { GetHeader as GetHeaderData } from "../../../types/header"
import type {
GetSiteConfigData,
GetSiteConfigRefData,
GetSitewideCampaignBannerData,
GetSitewideCampaignBannerRefData,
} from "../../../types/siteConfig"
const getContactConfig = cache(async (lang: Lang) => {
@@ -103,51 +77,13 @@ export const baseQueryRouter = router({
}),
header: contentstackBaseProcedure.query(async ({ ctx }) => {
const { lang } = ctx
const getHeaderRefsCounter = createCounter(
"trpc.contentstack.header.get.refs"
)
const metricsGetHeaderRefs = getHeaderRefsCounter.init({ lang })
metricsGetHeaderRefs.start()
const variables = { locale: lang }
const responseRef = await request<GetHeaderRefs>(GetHeaderRef, variables, {
key: generateRefsResponseTag(lang, "header"),
ttl: "max",
})
if (!responseRef.data) {
metricsGetHeaderRefs.noDataError()
throw notFoundError({
message: "GetHeaderRef returned no data",
errorDetails: variables,
})
}
const validatedHeaderRefs = headerRefsSchema.safeParse(responseRef.data)
if (!validatedHeaderRefs.success) {
metricsGetHeaderRefs.validationError(validatedHeaderRefs.error)
return null
}
metricsGetHeaderRefs.success()
const connections = getConnections(validatedHeaderRefs.data)
const getHeaderCounter = createCounter("trpc.contentstack.header.get")
const metricsGetHeader = getHeaderCounter.init({ lang })
metricsGetHeader.start()
const tags = [
generateTagsFromSystem(lang, connections),
generateTag(lang, validatedHeaderRefs.data.header.system.uid),
].flat()
const variables = { locale: lang }
const response = await request<GetHeaderData>(GetHeader, variables, {
key: tags,
key: generateTag(lang, "header"),
ttl: "max",
})
@@ -168,64 +104,17 @@ export const baseQueryRouter = router({
metricsGetHeader.success()
return {
data: validatedHeaderConfig.data.header,
}
return { data: validatedHeaderConfig.data.header }
}),
footer: contentstackBaseProcedure.query(async ({ ctx }) => {
const { lang } = ctx
const getFooterRefsCounter = createCounter(
"trpc.contentstack.footer.get.refs"
)
const metricsGetFooterRefs = getFooterRefsCounter.init({ lang })
metricsGetFooterRefs.start()
const variables = { locale: lang }
const responseRef = await request<FooterRefDataRaw>(
GetFooterRef,
variables,
{
key: generateRefsResponseTag(lang, "footer"),
ttl: "max",
}
)
if (!responseRef.data) {
metricsGetFooterRefs.noDataError()
throw notFoundError({
message: "GetFooterRef returned no data",
errorDetails: variables,
})
}
const validatedFooterRefs = validateFooterRefConfigSchema.safeParse(
responseRef.data
)
if (!validatedFooterRefs.success) {
metricsGetFooterRefs.validationError(validatedFooterRefs.error)
return null
}
metricsGetFooterRefs.success()
const connections = getFooterConnections(validatedFooterRefs.data)
const footerUID = responseRef.data.all_footer.items[0].system.uid
const getFooterCounter = createCounter("trpc.contentstack.footer.get")
const metricsGetFooter = getFooterCounter.init({ lang })
metricsGetFooter.start()
const tags = [
generateTags(lang, connections),
generateTag(lang, footerUID),
].flat()
const variables = { locale: lang }
const response = await request<FooterDataRaw>(GetFooter, variables, {
key: tags,
key: generateTag(lang, "footer"),
ttl: "max",
})
@@ -256,52 +145,6 @@ export const baseQueryRouter = router({
.query(async ({ input, ctx }) => {
const lang = input.lang ?? ctx.lang
const getSitewideCampaignBannerRefsCounter = createCounter(
"trpc.contentstack.sitewideCampaignBanner.get.refs"
)
const metricsGetSitewideCampaignBannerRefs =
getSitewideCampaignBannerRefsCounter.init({
lang,
})
metricsGetSitewideCampaignBannerRefs.start()
const refVariables = { locale: lang }
const responseRef = await request<GetSitewideCampaignBannerRefData>(
GetSitewideCampaignBannerRef,
refVariables,
{
key: generateRefsResponseTag(lang, "sitewide_campaign_banner"),
ttl: "max",
}
)
if (!responseRef.data) {
metricsGetSitewideCampaignBannerRefs.noDataError()
throw notFoundError({
message: "GetSitewideCampaignBannerRef returned no data",
errorDetails: refVariables,
})
}
const validatedSitewideCampaignBannerRef =
sitewideCampaignBannerRefSchema.safeParse(responseRef.data)
if (!validatedSitewideCampaignBannerRef.success) {
metricsGetSitewideCampaignBannerRefs.validationError(
validatedSitewideCampaignBannerRef.error
)
return null
}
const connections = getSitewideCampaignBannerConnections(
validatedSitewideCampaignBannerRef.data
)
const tags = [generateTagsFromSystem(lang, connections)].flat()
metricsGetSitewideCampaignBannerRefs.success()
const getSitewideCampaignBannerCounter = createCounter(
"trpc.contentstack.sitewideCampaignBanner.get"
)
@@ -317,7 +160,7 @@ export const baseQueryRouter = router({
await request<GetSitewideCampaignBannerData>(
GetSitewideCampaignBanner,
variables,
{ key: tags, ttl: "max" }
{ key: generateTag(lang, "sitewide_campaign_banner"), ttl: "max" }
)
if (!sitewideCampaignBannerResponse.data) {
@@ -351,52 +194,6 @@ export const baseQueryRouter = router({
.query(async ({ input, ctx }) => {
const lang = input.lang ?? ctx.lang
const getSiteConfigRefsCounter = createCounter(
"trpc.contentstack.siteConfig.get.refs"
)
const metricsGetSiteConfigRefs = getSiteConfigRefsCounter.init({ lang })
metricsGetSiteConfigRefs.start()
const refVariables = {
locale: lang,
}
const responseRef = await request<GetSiteConfigRefData>(
GetSiteConfigRef,
refVariables,
{
key: generateRefsResponseTag(lang, "site_config"),
ttl: "max",
}
)
if (!responseRef.data) {
metricsGetSiteConfigRefs.noDataError()
throw notFoundError({
message: "SiteConfigRefs returned no data",
errorDetails: refVariables,
})
}
const validatedSiteConfigRef = siteConfigRefSchema.safeParse(
responseRef.data
)
if (!validatedSiteConfigRef.success) {
metricsGetSiteConfigRefs.validationError(validatedSiteConfigRef.error)
return null
}
const connections = getSiteConfigConnections(validatedSiteConfigRef.data)
const siteConfigUid = responseRef.data.all_site_config.items[0].system.uid
const tags = [
generateTagsFromSystem(lang, connections),
generateTag(lang, siteConfigUid),
].flat()
metricsGetSiteConfigRefs.success()
const getSiteConfigCounter = createCounter(
"trpc.contentstack.siteConfig.get"
)
@@ -407,7 +204,7 @@ export const baseQueryRouter = router({
const variables = { locale: lang }
const [siteConfigResponse, contactConfig] = await Promise.all([
request<GetSiteConfigData>(GetSiteConfig, variables, {
key: tags,
key: generateTag(lang, "site_config"),
ttl: "max",
}),
getContactConfig(lang),

View File

@@ -5,112 +5,9 @@ import { logger } from "@scandic-hotels/common/logger"
import { getValueFromContactConfig } from "../../../utils/contactConfig"
import type { Edges } from "../../../types/edges"
import type { FooterRefDataRaw } from "../../../types/footer"
import type { HeaderRefs } from "../../../types/header"
import type { NodeRefs } from "../../../types/refs"
import type {
AlertOutput,
GetSiteConfigRefData,
GetSitewideCampaignBannerRefData,
} from "../../../types/siteConfig"
import type { System } from "../schemas/system"
import type { AlertOutput } from "../../../types/siteConfig"
import type { ContactConfig } from "./output"
export function getConnections({ header }: HeaderRefs) {
const connections: System["system"][] = [header.system]
if (header.top_link) {
if (header.top_link.logged_in?.link) {
connections.push(header.top_link.logged_in.link)
}
if (header.top_link.logged_out?.link) {
connections.push(header.top_link.logged_out.link)
}
}
if (header.menu_items.length) {
header.menu_items.forEach((menuItem) => {
if (menuItem.card) {
connections.push(...menuItem.card)
}
if (menuItem.link) {
connections.push(menuItem.link)
}
if (menuItem.see_all_link?.link) {
connections.push(menuItem.see_all_link.link)
}
if (menuItem.submenu.length) {
menuItem.submenu.forEach((subMenuItem) => {
if (subMenuItem.links.length) {
subMenuItem.links.forEach((link) => {
if (link?.link) {
connections.push(link.link)
}
})
}
})
}
})
}
return connections
}
export function getFooterConnections(refs: FooterRefDataRaw) {
const connections: Edges<NodeRefs>[] = []
const footerData = refs.all_footer.items[0]
const mainLinks = footerData.main_links
const secondaryLinks = footerData.secondary_links
const tertiaryLinks = footerData.tertiary_links
if (mainLinks) {
mainLinks.forEach(({ pageConnection }) => {
connections.push(pageConnection)
})
}
secondaryLinks?.forEach(({ links }) => {
if (links) {
links.forEach(({ pageConnection }) => {
connections.push(pageConnection)
})
}
})
if (tertiaryLinks) {
tertiaryLinks.forEach(({ pageConnection }) => {
connections.push(pageConnection)
})
}
return connections
}
export function getSiteConfigConnections(refs: GetSiteConfigRefData) {
const siteConfigData = refs.all_site_config.items[0]
const connections: System["system"][] = []
if (!siteConfigData.sitewide_alert.alert) return connections
const alertConnection = siteConfigData.sitewide_alert.alert.alertConnection
alertConnection.edges.forEach(({ node }) => {
connections.push(node.system)
const link = node.link.link
if (link) {
connections.push(link)
}
node.sidepeek_content.content.embedded_itemsConnection.edges.forEach(
({ node }) => {
if (node.system) {
connections.push(node.system)
}
}
)
})
return connections
}
export function getAlertPhoneContactData(
alert: AlertOutput,
contactConfig: ContactConfig
@@ -139,21 +36,3 @@ export const safeUnion = <T extends z.ZodTypeAny>(schema: T) =>
return null
}
}, schema)
export function getSitewideCampaignBannerConnections(
refs: GetSitewideCampaignBannerRefData
) {
const system = refs.all_sitewide_campaign_banner?.system
const banner =
refs.all_sitewide_campaign_banner?.bannerConnection.edges[0]?.node
const connections: System["system"][] = []
if (system) {
connections.push(system)
}
if (banner?.system) {
connections.push(banner.system)
}
return connections
}