Files
web/packages/trpc/lib/routers/contentstack/base/output.ts
Linus Flood 5fc93472f4 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
2026-01-27 12:38:36 +00:00

594 lines
14 KiB
TypeScript

import { z, ZodError, ZodIssueCode } from "zod"
import {
AlertTypeEnum,
AlertVisibleOnEnum,
} from "@scandic-hotels/common/constants/alert"
import { Lang } from "@scandic-hotels/common/constants/language"
import { logger } from "@scandic-hotels/common/logger"
import { removeMultipleSlashes } from "@scandic-hotels/common/utils/url"
import { nullableStringValidator } from "@scandic-hotels/common/utils/zod/stringValidator"
import { discriminatedUnion } from "../../../utils/discriminatedUnion"
import {
infoCardBlockSchema,
transformInfoCardBlock,
} from "../schemas/blocks/cardsGrid"
import {
linkUnionSchema,
rawLinkUnionSchema,
transformPageLink,
} from "../schemas/pageLinks"
// Help me write this zod schema based on the type ContactConfig
export const validateContactConfigSchema = z.object({
all_contact_config: z.object({
items: z.array(
z.object({
email: z.object({
name: z.string().nullable(),
address: z.string().nullable(),
}),
email_loyalty: z.object({
name: z.string().nullable(),
address: z.string().nullable(),
}),
mailing_address: z.object({
zip: z.string().nullable(),
street: z.string().nullable(),
name: z.string().nullable(),
city: z.string().nullable(),
country: z.string().nullable(),
}),
phone: z.object({
number: z.string().nullable(),
name: z.string().nullable(),
footnote: z.string().nullable(),
}),
phone_loyalty: z.object({
number: z.string().nullable(),
name: z.string().nullable(),
footnote: z.string().nullable(),
}),
visiting_address: z.object({
zip: z.string().nullable(),
country: z.string().nullable(),
city: z.string().nullable(),
street: z.string().nullable(),
}),
})
),
}),
})
export enum ContactFieldGroupsEnum {
email = "email",
email_loyalty = "email_loyalty",
mailing_address = "mailing_address",
phone = "phone",
phone_loyalty = "phone_loyalty",
visiting_address = "visiting_address",
}
export type ContactFieldGroups = keyof typeof ContactFieldGroupsEnum
export type ContactConfigData = z.infer<typeof validateContactConfigSchema>
export type ContactConfig = ContactConfigData["all_contact_config"]["items"][0]
export type ContactFields = {
display_text: string | null
contact_field: string
footnote?: string | null
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const validateNavigationItem = z.object({
links: z.array(z.object({ href: z.string(), title: z.string() })),
title: z.string(),
})
export type NavigationItem = z.infer<typeof validateNavigationItem>
const validateExternalLink = z
.object({
href: z.string(),
title: z.string(),
})
.optional()
const validateInternalLink = z
.object({
edges: z
.array(
z.object({
node: z.object({
system: z.object({
uid: z.string(),
locale: z.nativeEnum(Lang),
}),
url: z.string(),
title: z.string(),
web: z
.object({
original_url: z.string(),
})
.optional(),
}),
})
)
.max(1),
})
.transform((data) => {
const node = data.edges[0]?.node
if (!node) {
return null
}
const url = node.url
const originalUrl = node.web?.original_url
const lang = node.system.locale
return {
url: originalUrl || removeMultipleSlashes(`/${lang}/${url}`),
title: node.title,
}
})
.optional()
export const validateLinkItem = z
.object({
title: z.string(),
open_in_new_tab: z.boolean(),
link: validateExternalLink,
pageConnection: validateInternalLink,
})
.transform((data) => {
return {
url: data.pageConnection?.url ?? data.link?.href ?? "",
title: data?.title ?? data.link?.title,
openInNewTab: data.open_in_new_tab,
isExternal: !data.pageConnection?.url,
}
})
const validateLinks = z
.array(validateLinkItem)
.transform((data) => data.filter((item) => item.url))
export const validateSecondaryLinks = z.array(
z.object({
title: z.string(),
links: validateLinks,
})
)
export const validateLinksWithType = z.array(
z.object({
type: z.string(),
href: validateExternalLink,
})
)
export const validateFooterConfigSchema = z
.object({
all_footer: z.object({
items: z.array(
z.object({
main_links: validateLinks.nullish().transform((val) => val ?? []),
app_downloads: z.object({
title: z.string(),
links: validateLinksWithType
.nullish()
.transform((val) => val ?? []),
}),
secondary_links: validateSecondaryLinks
.nullish()
.transform((val) => val ?? []),
social_media: z.object({
links: validateLinksWithType
.nullish()
.transform((val) => val ?? []),
}),
tertiary_links: validateLinks.nullish().transform((val) => val ?? []),
})
),
}),
})
.transform((data) => {
const {
main_links,
app_downloads,
secondary_links,
social_media,
tertiary_links,
} = data.all_footer.items[0]
return {
mainLinks: main_links,
appDownloads: app_downloads,
secondaryLinks: secondary_links,
socialMedia: social_media,
tertiaryLinks: tertiary_links,
}
})
const internalOrExternalLinkSchema = z
.object({
is_contentstack_link: z.boolean().nullish(),
external_link: z
.object({
href: nullableStringValidator,
title: z.string().nullish(),
})
.nullish(),
title: nullableStringValidator,
linkConnection: z.object({
edges: z.array(
z.object({
node: linkUnionSchema.transform((data) => {
const link = transformPageLink(data)
if (link) {
return link
}
return data
}),
})
),
}),
})
.transform(
({ is_contentstack_link, external_link, linkConnection, title }) => {
if (is_contentstack_link !== false && linkConnection.edges.length) {
const linkRef = linkConnection.edges[0].node
return {
title: title || linkRef.title,
url: linkRef.url,
}
} else if (is_contentstack_link === false && external_link?.href) {
return {
title: title || external_link.title || "",
url: external_link.href,
}
} else {
return {
title,
}
}
}
)
const linkSchema = z
.object({
linkConnection: z.object({
edges: z.array(
z.object({
node: discriminatedUnion(rawLinkUnionSchema.options),
})
),
}),
})
.transform((data) => {
if (data.linkConnection.edges.length) {
const linkNode = data.linkConnection.edges[0].node
if (linkNode) {
const link = transformPageLink(linkNode)
if (link) {
return {
link,
}
}
}
}
return {
link: null,
}
})
const titleSchema = z.object({
title: nullableStringValidator,
})
/**
* Intersection has to be used since you are not
* allowed to merge two schemas where one uses
* transform
*/
const linkAndTitleSchema = z.intersection(linkSchema, titleSchema)
/**
* Same as above 👆
*/
export const menuItemSchema = z
.intersection(
internalOrExternalLinkSchema,
z
.object({
cardConnection: z.object({
edges: z.array(
z.object({
node: infoCardBlockSchema,
})
),
}),
see_all_link: internalOrExternalLinkSchema,
submenu: z.array(
z.object({
links: z.array(internalOrExternalLinkSchema),
title: nullableStringValidator,
})
),
})
.transform((data) => {
let card = null
if (data.cardConnection.edges.length) {
card = transformInfoCardBlock(data.cardConnection.edges[0].node)
}
return {
card,
seeAllLink: data.see_all_link,
submenu: data.submenu,
}
})
)
.transform(({ title, url, card, seeAllLink, submenu }) => {
return {
title,
link: submenu.length ? null : { url },
seeAllLink: submenu.length ? seeAllLink : null,
card,
submenu,
}
})
// TODO When original IconName enum is moved to common we should use it
enum IconName {
Gift = "Gift",
InfoCircle = "InfoCircle",
PriceTag = "PriceTag",
}
const topLinkItemSchema = z.intersection(
internalOrExternalLinkSchema,
z.object({
icon: z
.enum(["loyalty", "info", "offer"])
.nullable()
.transform((icon) => {
switch (icon) {
case "loyalty":
return IconName.Gift
case "info":
return IconName.InfoCircle
case "offer":
return IconName.PriceTag
default:
return null
}
}),
})
)
export const topLinkSchema = z.object({
logged_in: topLinkItemSchema.nullable(),
logged_out: topLinkItemSchema.nullable(),
})
export const headerSchema = z
.object({
all_header: z.object({
items: z
.array(
z.object({
menu_items: z.array(menuItemSchema),
top_link: topLinkSchema,
})
)
.max(1),
}),
})
.transform((data) => {
if (!data.all_header.items.length) {
logger.error(`Zod Error - No header returned in request`)
throw new ZodError([
{
code: ZodIssueCode.custom,
fatal: true,
message: "No header returned",
path: ["all_header.items"],
},
])
}
const header = data.all_header.items[0]
return {
header: {
menuItems: header.menu_items,
topLink: header.top_link,
},
}
})
export const alertSchema = z
.object({
type: z.nativeEnum(AlertTypeEnum),
text: z.string(),
heading: z.string(),
phone_contact: z.object({
display_text: z.string(),
phone_number: z.string().nullable(),
footnote: z.string().nullable(),
}),
has_link: z.boolean(),
link: linkAndTitleSchema,
has_sidepeek_button: z.boolean(),
sidepeek_button: z.object({
cta_text: z.string(),
}),
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
}),
})
),
}),
}),
}),
visible_on: z.array(z.string()).nullish().default([]),
})
.transform(
({
type,
heading,
text,
phone_contact,
has_link,
link,
has_sidepeek_button,
sidepeek_button,
sidepeek_content,
visible_on,
}) => {
const hasLink = has_link && link.link
return {
type,
text,
heading,
visible_on,
phoneContact:
phone_contact.display_text && phone_contact.phone_number
? {
displayText: phone_contact.display_text,
phoneNumber: phone_contact.phone_number,
footnote: phone_contact.footnote,
}
: null,
hasSidepeekButton: !!has_sidepeek_button,
link: hasLink
? {
url: link.link.url,
title: link.title,
}
: null,
sidepeekButton:
!hasLink && has_sidepeek_button ? sidepeek_button : null,
sidepeekContent:
!hasLink && has_sidepeek_button ? sidepeek_content : null,
}
}
)
export const siteConfigSchema = z
.object({
all_site_config: z.object({
items: z
.array(
z.object({
sitewide_alert: z.object({
alerts: z
.array(
z.object({
booking_widget_disabled: z.boolean(),
alertConnection: z.object({
edges: z.array(
z.object({
node: alertSchema,
})
),
}),
})
)
.nullable(),
}),
})
)
.max(1),
}),
})
.transform((data) => {
if (!data.all_site_config.items.length) {
return {
sitewideAlert: null,
bookingWidgetDisabled: false,
}
}
const sitewideAlertWeb =
data.all_site_config.items[0].sitewide_alert.alerts?.find((alert) =>
alert.alertConnection.edges[0]?.node.visible_on?.includes(
AlertVisibleOnEnum.WEB
)
)
return {
sitewideAlert: sitewideAlertWeb?.alertConnection.edges[0]?.node || null,
bookingWidgetDisabled: sitewideAlertWeb?.booking_widget_disabled,
}
})
const bannerSchema = z
.object({
tag: z.string(),
text: z.string(),
link: linkAndTitleSchema,
booking_code: z.string().nullish(),
visible_on: z.array(z.string()).nullish().default([]),
})
.transform(({ tag, text, link, visible_on, booking_code }) => {
const linkUrl = link.link?.url || null
return {
tag,
text,
link: linkUrl
? {
url: linkUrl,
title: link.title,
}
: null,
booking_code,
visible_on,
}
})
export const sitewideCampaignBannerSchema = z
.object({
all_sitewide_campaign_banner: z.object({
items: z
.array(
z.object({
bannerConnection: z.object({
edges: z.array(
z.object({
node: bannerSchema,
})
),
}),
})
)
.max(1),
}),
})
.transform((data) => {
if (!data.all_sitewide_campaign_banner.items.length) {
return null
}
const sitewideCampaignBannerWeb =
data.all_sitewide_campaign_banner.items[0].bannerConnection.edges.find(
(banner) => banner.node.visible_on?.includes(AlertVisibleOnEnum.WEB)
)
return sitewideCampaignBannerWeb?.node ?? null
})