Merged master into fix/add-missing-cache

This commit is contained in:
Linus Flood
2024-11-27 18:10:24 +00:00
308 changed files with 7537 additions and 4415 deletions

View File

@@ -11,7 +11,6 @@ import {
shortcutsSchema,
} from "../schemas/blocks/shortcuts"
import { textContentSchema } from "../schemas/blocks/textContent"
import { page } from "../schemas/metadata"
import { systemSchema } from "../schemas/system"
import { AccountPageEnum } from "@/types/enums/accountPage"
@@ -81,7 +80,3 @@ export const accountPageRefsSchema = z.object({
system: systemSchema,
}),
})
export const accountPageMetadataSchema = z.object({
account_page: page,
})

View File

@@ -5,7 +5,6 @@ import {
GetAccountPage,
GetAccountPageRefs,
} from "@/lib/graphql/Query/AccountPage/AccountPage.graphql"
import { GetMyPagesMetaData } from "@/lib/graphql/Query/AccountPage/MetaData.graphql"
import { request } from "@/lib/graphql/request"
import { notFound } from "@/server/errors/trpc"
import { contentstackExtendedProcedureUID, router } from "@/server/trpc"
@@ -16,13 +15,7 @@ import {
generateTagsFromSystem,
} from "@/utils/generateTag"
import { removeEmptyObjects } from "../../utils"
import { getMetaData, getResponse } from "../metadata/utils"
import {
accountPageMetadataSchema,
accountPageRefsSchema,
accountPageSchema,
} from "./output"
import { accountPageRefsSchema, accountPageSchema } from "./output"
import { getConnections } from "./utils"
import {
@@ -30,7 +23,6 @@ import {
type TrackingSDKPageData,
} from "@/types/components/tracking"
import type {
GetAccountpageMetadata,
GetAccountPageRefsSchema,
GetAccountPageSchema,
} from "@/types/trpc/routers/contentstack/accountPage"
@@ -203,30 +195,4 @@ export const accountPageQueryRouter = router({
tracking,
}
}),
metadata: router({
get: contentstackExtendedProcedureUID.query(async ({ ctx }) => {
const variables = {
locale: ctx.lang,
uid: ctx.uid,
}
const response = await getResponse<GetAccountpageMetadata>(
GetMyPagesMetaData,
variables
)
const validatedMetadata = accountPageMetadataSchema.safeParse(
response.data
)
if (!validatedMetadata.success) {
console.error(
`Failed to validate My Page MetaData Data - (uid: ${variables.uid})`
)
console.error(validatedMetadata.error)
return null
}
return getMetaData(validatedMetadata.data.account_page)
}),
}),
})

View File

@@ -82,6 +82,8 @@ export function getSiteConfigConnections(refs: GetSiteConfigRefData) {
const siteConfigData = refs.all_site_config.items[0]
const connections: System["system"][] = []
if (!siteConfigData) return connections
const alertConnection = siteConfigData.sitewide_alert.alertConnection
alertConnection.edges.forEach(({ node }) => {

View File

@@ -1,16 +1,11 @@
import { z } from "zod"
import { removeMultipleSlashes } from "@/utils/url"
import { systemSchema } from "../schemas/system"
import { homeBreadcrumbs } from "./utils"
export const getBreadcrumbsSchema = z.array(
z.object({
href: z.string().optional(),
title: z.string(),
uid: z.string(),
})
)
const breadcrumbsRefs = z.object({
export const breadcrumbsRefsSchema = z.object({
web: z
.object({
breadcrumbs: z
@@ -32,43 +27,7 @@ const breadcrumbsRefs = z.object({
system: systemSchema,
})
export type BreadcrumbsRefs = z.infer<typeof breadcrumbsRefs>
export const validateMyPagesBreadcrumbsRefsContentstackSchema = z.object({
account_page: breadcrumbsRefs,
})
export type GetMyPagesBreadcrumbsRefsData = z.infer<
typeof validateMyPagesBreadcrumbsRefsContentstackSchema
>
export const validateLoyaltyPageBreadcrumbsRefsContentstackSchema = z.object({
loyalty_page: breadcrumbsRefs,
})
export type GetLoyaltyPageBreadcrumbsRefsData = z.infer<
typeof validateLoyaltyPageBreadcrumbsRefsContentstackSchema
>
export const validateCollectionPageBreadcrumbsRefsContentstackSchema = z.object(
{
collection_page: breadcrumbsRefs,
}
)
export type GetCollectionPageBreadcrumbsRefsData = z.infer<
typeof validateCollectionPageBreadcrumbsRefsContentstackSchema
>
export const validateContentPageBreadcrumbsRefsContentstackSchema = z.object({
content_page: breadcrumbsRefs,
})
export type GetContentPageBreadcrumbsRefsData = z.infer<
typeof validateContentPageBreadcrumbsRefsContentstackSchema
>
const page = z.object({
export const rawBreadcrumbsDataSchema = z.object({
web: z.object({
breadcrumbs: z.object({
title: z.string(),
@@ -92,36 +51,24 @@ const page = z.object({
system: systemSchema,
})
export type Page = z.infer<typeof page>
export const breadcrumbsSchema = rawBreadcrumbsDataSchema.transform((data) => {
const { parentsConnection, title } = data.web.breadcrumbs
const parentBreadcrumbs = parentsConnection.edges.map((breadcrumb) => {
return {
href: removeMultipleSlashes(
`/${breadcrumb.node.system.locale}/${breadcrumb.node.url}`
),
title: breadcrumb.node.web.breadcrumbs.title,
uid: breadcrumb.node.system.uid,
}
})
export const validateMyPagesBreadcrumbsContentstackSchema = z.object({
account_page: page,
const pageBreadcrumb = {
title,
uid: data.system.uid,
href: undefined,
}
const homeBreadcrumb = homeBreadcrumbs[data.system.locale]
return [homeBreadcrumb, parentBreadcrumbs, pageBreadcrumb].flat()
})
export type GetMyPagesBreadcrumbsData = z.infer<
typeof validateMyPagesBreadcrumbsContentstackSchema
>
export const validateLoyaltyPageBreadcrumbsContentstackSchema = z.object({
loyalty_page: page,
})
export type GetLoyaltyPageBreadcrumbsData = z.infer<
typeof validateLoyaltyPageBreadcrumbsContentstackSchema
>
export const validateContentPageBreadcrumbsContentstackSchema = z.object({
content_page: page,
})
export type GetContentPageBreadcrumbsData = z.infer<
typeof validateContentPageBreadcrumbsContentstackSchema
>
export const validateCollectionPageBreadcrumbsContentstackSchema = z.object({
collection_page: page,
})
export type GetCollectionPageBreadcrumbsData = z.infer<
typeof validateCollectionPageBreadcrumbsContentstackSchema
>

View File

@@ -1,3 +1,6 @@
import { metrics } from "@opentelemetry/api"
import { cache } from "react"
import {
GetMyPagesBreadcrumbs,
GetMyPagesBreadcrumbsRefs,
@@ -14,237 +17,199 @@ import {
GetLoyaltyPageBreadcrumbs,
GetLoyaltyPageBreadcrumbsRefs,
} from "@/lib/graphql/Query/Breadcrumbs/LoyaltyPage.graphql"
import { request } from "@/lib/graphql/request"
import { notFound } from "@/server/errors/trpc"
import { contentstackExtendedProcedureUID, router } from "@/server/trpc"
import {
GetCollectionPageBreadcrumbsData,
GetCollectionPageBreadcrumbsRefsData,
type GetContentPageBreadcrumbsData,
type GetContentPageBreadcrumbsRefsData,
type GetLoyaltyPageBreadcrumbsData,
type GetLoyaltyPageBreadcrumbsRefsData,
type GetMyPagesBreadcrumbsData,
type GetMyPagesBreadcrumbsRefsData,
validateCollectionPageBreadcrumbsContentstackSchema,
validateCollectionPageBreadcrumbsRefsContentstackSchema,
validateContentPageBreadcrumbsContentstackSchema,
validateContentPageBreadcrumbsRefsContentstackSchema,
validateLoyaltyPageBreadcrumbsContentstackSchema,
validateLoyaltyPageBreadcrumbsRefsContentstackSchema,
validateMyPagesBreadcrumbsContentstackSchema,
validateMyPagesBreadcrumbsRefsContentstackSchema,
} from "./output"
import {
getBreadcrumbs,
getRefsResponse,
getResponse,
getTags,
Variables,
} from "./utils"
import { breadcrumbsRefsSchema, breadcrumbsSchema } from "./output"
import { getTags } from "./utils"
import { PageTypeEnum } from "@/types/requests/pageType"
import type {
BreadcrumbsRefsSchema,
RawBreadcrumbsSchema,
} from "@/types/trpc/routers/contentstack/breadcrumbs"
import type { Lang } from "@/constants/languages"
async function getLoyaltyPageBreadcrumbs(variables: Variables) {
const refsResponse = await getRefsResponse<GetLoyaltyPageBreadcrumbsRefsData>(
GetLoyaltyPageBreadcrumbsRefs,
variables
)
const meter = metrics.getMeter("trpc.breadcrumbs")
const validatedRefsData =
validateLoyaltyPageBreadcrumbsRefsContentstackSchema.safeParse(
refsResponse.data
)
// OpenTelemetry metrics
const getBreadcrumbsRefsCounter = meter.createCounter(
"trpc.contentstack.breadcrumbs.refs.get"
)
const getBreadcrumbsRefsSuccessCounter = meter.createCounter(
"trpc.contentstack.breadcrumbs.refs.get-success"
)
const getBreadcrumbsRefsFailCounter = meter.createCounter(
"trpc.contentstack.breadcrumbs.refs.get-fail"
)
const getBreadcrumbsCounter = meter.createCounter(
"trpc.contentstack.breadcrumbs.get"
)
const getBreadcrumbsSuccessCounter = meter.createCounter(
"trpc.contentstack.breadcrumbs.get-success"
)
const getBreadcrumbsFailCounter = meter.createCounter(
"trpc.contentstack.breadcrumbs.get-fail"
)
if (!validatedRefsData.success) {
console.error(
`Failed to validate Loyaltypage Breadcrumbs Refs - (uid: ${variables.uid})`
)
console.error(validatedRefsData.error)
return null
}
const tags = getTags(validatedRefsData.data.loyalty_page, variables)
const response = await getResponse<GetLoyaltyPageBreadcrumbsData>(
GetLoyaltyPageBreadcrumbs,
variables,
tags
)
if (!response.data.loyalty_page.web?.breadcrumbs?.title) {
return null
}
const validatedBreadcrumbsData =
validateLoyaltyPageBreadcrumbsContentstackSchema.safeParse(response.data)
if (!validatedBreadcrumbsData.success) {
console.error(
`Failed to validate Loyaltypage Breadcrumbs Data - (uid: ${variables.uid})`
)
console.error(validatedBreadcrumbsData.error)
return null
}
return getBreadcrumbs(
validatedBreadcrumbsData.data.loyalty_page,
variables.locale
)
interface BreadcrumbsPageData<T> {
dataKey: keyof T
refQuery: string
query: string
}
async function getCollectionPageBreadcrumbs(variables: Variables) {
const refsResponse =
await getRefsResponse<GetCollectionPageBreadcrumbsRefsData>(
GetCollectionPageBreadcrumbsRefs,
variables
)
const validatedRefsData =
validateCollectionPageBreadcrumbsRefsContentstackSchema.safeParse(
refsResponse.data
)
const getBreadcrumbs = cache(async function fetchMemoizedBreadcrumbs<T>(
{ dataKey, refQuery, query }: BreadcrumbsPageData<T>,
{ uid, lang }: { uid: string; lang: Lang }
) {
getBreadcrumbsRefsCounter.add(1, { lang, uid })
console.info(
"contentstack.breadcrumbs refs get start",
JSON.stringify({ query: { lang, uid } })
)
const refsResponse = await request<{ [K in keyof T]: BreadcrumbsRefsSchema }>(
refQuery,
{ locale: lang, uid }
)
const validatedRefsData = breadcrumbsRefsSchema.safeParse(
refsResponse.data[dataKey]
)
if (!validatedRefsData.success) {
getBreadcrumbsRefsFailCounter.add(1, {
error_type: "validation_error",
error: JSON.stringify(validatedRefsData.error),
})
console.error(
`Failed to validate CollectionPpage Breadcrumbs Refs - (uid: ${variables.uid})`
"contentstack.breadcrumbs refs validation error",
JSON.stringify({
error: validatedRefsData.error,
})
)
console.error(validatedRefsData.error)
return null
}
const tags = getTags(validatedRefsData.data.collection_page, variables)
const response = await getResponse<GetCollectionPageBreadcrumbsData>(
GetCollectionPageBreadcrumbs,
variables,
tags
)
if (!response.data.collection_page.web?.breadcrumbs?.title) {
return null
}
const validatedBreadcrumbsData =
validateCollectionPageBreadcrumbsContentstackSchema.safeParse(response.data)
if (!validatedBreadcrumbsData.success) {
console.error(
`Failed to validate Collectionpage Breadcrumbs Data - (uid: ${variables.uid})`
)
console.error(validatedBreadcrumbsData.error)
return null
}
return getBreadcrumbs(
validatedBreadcrumbsData.data.collection_page,
variables.locale
)
}
async function getContentPageBreadcrumbs(variables: Variables) {
const refsResponse = await getRefsResponse<GetContentPageBreadcrumbsRefsData>(
GetContentPageBreadcrumbsRefs,
variables
)
const validatedRefsData =
validateContentPageBreadcrumbsRefsContentstackSchema.safeParse(
refsResponse.data
)
if (!validatedRefsData.success) {
console.error(
`Failed to validate Contentpage Breadcrumbs Refs - (uid: ${variables.uid})`
)
console.error(validatedRefsData.error)
return null
}
const tags = getTags(validatedRefsData.data.content_page, variables)
const response = await getResponse<GetContentPageBreadcrumbsData>(
GetContentPageBreadcrumbs,
variables,
tags
)
if (!response.data.content_page.web?.breadcrumbs?.title) {
return null
}
const validatedBreadcrumbsData =
validateContentPageBreadcrumbsContentstackSchema.safeParse(response.data)
if (!validatedBreadcrumbsData.success) {
console.error(
`Failed to validate Contentpage Breadcrumbs Data - (uid: ${variables.uid})`
)
console.error(validatedBreadcrumbsData.error)
return null
}
return getBreadcrumbs(
validatedBreadcrumbsData.data.content_page,
variables.locale
)
}
async function getMyPagesBreadcrumbs(variables: Variables) {
const refsResponse = await getRefsResponse<GetMyPagesBreadcrumbsRefsData>(
GetMyPagesBreadcrumbsRefs,
variables
)
const validatedRefsData =
validateMyPagesBreadcrumbsRefsContentstackSchema.safeParse(
refsResponse.data
)
if (!validatedRefsData.success) {
console.error(
`Failed to validate My Page Breadcrumbs Refs - (uid: ${variables.uid})`
)
console.error(validatedRefsData.error)
return null
}
const tags = getTags(validatedRefsData.data.account_page, variables)
const response = await getResponse<GetMyPagesBreadcrumbsData>(
GetMyPagesBreadcrumbs,
variables,
tags
)
if (!response.data.account_page.web?.breadcrumbs?.title) {
return []
}
const validatedBreadcrumbsData =
validateMyPagesBreadcrumbsContentstackSchema.safeParse(response.data)
getBreadcrumbsRefsSuccessCounter.add(1, { lang, uid })
console.info(
"contentstack.breadcrumbs refs get success",
JSON.stringify({ query: { lang, uid } })
)
if (!validatedBreadcrumbsData.success) {
const tags = getTags(validatedRefsData.data, lang)
getBreadcrumbsCounter.add(1, { lang, uid })
console.info(
"contentstack.breadcrumbs get start",
JSON.stringify({ query: { lang, uid } })
)
const response = await request<T>(
query,
{ locale: lang, uid },
{
cache: "force-cache",
next: { tags },
}
)
if (!response.data) {
const notFoundError = notFound(response)
getBreadcrumbsFailCounter.add(1, {
lang,
uid,
error_type: "not_found",
error: JSON.stringify({ code: notFoundError.code }),
})
console.error(
`Failed to validate My Page Breadcrumbs Data - (uid: ${variables.uid})`
"contentstack.breadcrumbs get not found error",
JSON.stringify({
query: { lang, uid },
error: { code: notFoundError.code },
})
)
console.error(validatedBreadcrumbsData.error)
return null
throw notFoundError
}
return getBreadcrumbs(
validatedBreadcrumbsData.data.account_page,
variables.locale
const validatedBreadcrumbs = breadcrumbsSchema.safeParse(
response.data[dataKey]
)
}
if (!validatedBreadcrumbs.success) {
getBreadcrumbsFailCounter.add(1, {
error_type: "validation_error",
error: JSON.stringify(validatedBreadcrumbs.error),
})
console.error(
"contentstack.breadcrumbs validation error",
JSON.stringify({
error: validatedBreadcrumbs.error,
})
)
return []
}
getBreadcrumbsSuccessCounter.add(1, { lang, uid })
console.info(
"contentstack.breadcrumbs get success",
JSON.stringify({ query: { lang, uid } })
)
return validatedBreadcrumbs.data
})
export const breadcrumbsQueryRouter = router({
get: contentstackExtendedProcedureUID.query(async ({ ctx }) => {
const variables = {
locale: ctx.lang,
lang: ctx.lang,
uid: ctx.uid,
}
switch (ctx.contentType) {
case PageTypeEnum.accountPage:
return await getMyPagesBreadcrumbs(variables)
return await getBreadcrumbs<{
account_page: RawBreadcrumbsSchema
}>(
{
dataKey: "account_page",
refQuery: GetMyPagesBreadcrumbsRefs,
query: GetMyPagesBreadcrumbs,
},
variables
)
case PageTypeEnum.collectionPage:
return await getCollectionPageBreadcrumbs(variables)
return await getBreadcrumbs<{
collection_page: RawBreadcrumbsSchema
}>(
{
dataKey: "collection_page",
refQuery: GetCollectionPageBreadcrumbsRefs,
query: GetCollectionPageBreadcrumbs,
},
variables
)
case PageTypeEnum.contentPage:
return await getContentPageBreadcrumbs(variables)
return await getBreadcrumbs<{
content_page: RawBreadcrumbsSchema
}>(
{
dataKey: "content_page",
refQuery: GetContentPageBreadcrumbsRefs,
query: GetContentPageBreadcrumbs,
},
variables
)
case PageTypeEnum.loyaltyPage:
return await getLoyaltyPageBreadcrumbs(variables)
return await getBreadcrumbs<{
loyalty_page: RawBreadcrumbsSchema
}>(
{
dataKey: "loyalty_page",
refQuery: GetLoyaltyPageBreadcrumbsRefs,
query: GetLoyaltyPageBreadcrumbs,
},
variables
)
default:
return []
}

View File

@@ -1,33 +1,17 @@
import { Lang } from "@/constants/languages"
import { request } from "@/lib/graphql/request"
import { internalServerError, notFound } from "@/server/errors/trpc"
import {
generateRefsResponseTag,
generateTag,
generateTags,
} from "@/utils/generateTag"
import { removeMultipleSlashes } from "@/utils/url"
import { type BreadcrumbsRefs, getBreadcrumbsSchema, Page } from "./output"
import { generateTag, generateTags } from "@/utils/generateTag"
import type { Edges } from "@/types/requests/utils/edges"
import type { NodeRefs } from "@/types/requests/utils/refs"
export function getConnections(refs: BreadcrumbsRefs) {
const connections: Edges<NodeRefs>[] = []
if (refs.web?.breadcrumbs) {
connections.push(refs.web.breadcrumbs.parentsConnection)
}
return connections
}
import type { BreadcrumbsRefsSchema } from "@/types/trpc/routers/contentstack/breadcrumbs"
export const affix = "breadcrumbs"
// TODO: Make these editable in CMS?
export const homeBreadcrumbs = {
export const homeBreadcrumbs: {
[key in keyof typeof Lang]: { href: string; title: string; uid: string }
} = {
[Lang.da]: {
href: "/da",
title: "Hjem",
@@ -60,76 +44,19 @@ export const homeBreadcrumbs = {
},
}
export type Variables = {
locale: Lang
uid: string
}
export function getConnections(data: BreadcrumbsRefsSchema) {
const connections: Edges<NodeRefs>[] = []
export async function getRefsResponse<T>(query: string, variables: Variables) {
const refsResponse = await request<T>(query, variables, {
cache: "force-cache",
next: {
tags: [generateRefsResponseTag(variables.locale, variables.uid, affix)],
},
})
if (!refsResponse.data) {
throw notFound(refsResponse)
if (data.web?.breadcrumbs) {
connections.push(data.web.breadcrumbs.parentsConnection)
}
return refsResponse
return connections
}
export function getTags(page: BreadcrumbsRefs, variables: Variables) {
const connections = getConnections(page)
const tags = generateTags(variables.locale, connections)
tags.push(generateTag(variables.locale, page.system.uid, affix))
export function getTags(data: BreadcrumbsRefsSchema, lang: Lang) {
const connections = getConnections(data)
const tags = generateTags(lang, connections)
tags.push(generateTag(lang, data.system.uid, affix))
return tags
}
export async function getResponse<T>(
query: string,
variables: Variables,
tags: string[]
) {
const response = await request<T>(query, variables, {
cache: "force-cache",
next: { tags },
})
if (!response.data) {
throw notFound(response)
}
return response
}
export function getBreadcrumbs(page: Page, lang: Lang) {
const parentBreadcrumbs = page.web.breadcrumbs.parentsConnection.edges.map(
(breadcrumb) => {
return {
href: removeMultipleSlashes(
`/${breadcrumb.node.system.locale}/${breadcrumb.node.url}`
),
title: breadcrumb.node.web.breadcrumbs.title,
uid: breadcrumb.node.system.uid,
}
}
)
const pageBreadcrumb = {
title: page.web.breadcrumbs.title,
uid: page.system.uid,
}
const breadcrumbs = [
homeBreadcrumbs[lang],
parentBreadcrumbs,
pageBreadcrumb,
].flat()
const validatedBreadcrumbs = getBreadcrumbsSchema.safeParse(breadcrumbs)
if (!validatedBreadcrumbs.success) {
throw internalServerError(validatedBreadcrumbs.error)
}
return validatedBreadcrumbs.data
}

View File

@@ -10,7 +10,7 @@ import { hotelPageRouter } from "./hotelPage"
import { languageSwitcherRouter } from "./languageSwitcher"
import { loyaltyLevelRouter } from "./loyaltyLevel"
import { loyaltyPageRouter } from "./loyaltyPage"
import { metaDataRouter } from "./metadata"
import { metadataRouter } from "./metadata"
import { myPagesRouter } from "./myPages"
import { rewardRouter } from "./reward"
@@ -25,7 +25,7 @@ export const contentstackRouter = router({
collectionPage: collectionPageRouter,
contentPage: contentPageRouter,
myPages: myPagesRouter,
metaData: metaDataRouter,
metadata: metadataRouter,
rewards: rewardRouter,
loyaltyLevels: loyaltyLevelRouter,
})

View File

@@ -1,5 +1,5 @@
import { mergeRouters } from "@/server/trpc"
import { metaDataQueryRouter } from "./query"
import { metadataQueryRouter } from "./query"
export const metaDataRouter = mergeRouters(metaDataQueryRouter)
export const metadataRouter = mergeRouters(metadataQueryRouter)

View File

@@ -1,11 +1,100 @@
import { z } from "zod"
import { page } from "../schemas/metadata"
import { hotelAttributesSchema } from "../../hotels/output"
import { tempImageVaultAssetSchema } from "../schemas/imageVault"
import { getDescription, getImage, getTitle } from "./utils"
export const getLoyaltyPageMetadataSchema = z.object({
loyalty_page: page,
import type { Metadata } from "next"
import { RTETypeEnum } from "@/types/rte/enums"
const metaDataJsonSchema = z.object({
children: z.array(
z.object({
type: z.nativeEnum(RTETypeEnum),
children: z.array(
z.object({
text: z.string().optional(),
})
),
})
),
})
export type GetLoyaltyPageMetaDataData = z.infer<
typeof getLoyaltyPageMetadataSchema
>
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().optional().nullable(),
description: z.string().optional().nullable(),
noindex: z.boolean().optional().nullable(),
seo_image: tempImageVaultAssetSchema.nullable(),
})
.optional()
.nullable(),
breadcrumbs: z
.object({
title: z.string().optional().nullable(),
})
.optional()
.nullable(),
})
.optional()
.nullable(),
heading: z.string().optional().nullable(),
preamble: z.string().optional().nullable(),
header: z
.object({
heading: z.string().optional().nullable(),
preamble: z.string().optional().nullable(),
})
.optional()
.nullable(),
hero_image: tempImageVaultAssetSchema.nullable(),
blocks: metaDataBlocksSchema,
hotel_page_id: z.string().optional().nullable(),
hotelData: hotelAttributesSchema
.pick({ name: true, address: true, hotelContent: true, gallery: true })
.optional()
.nullable(),
})
export const metadataSchema = rawMetadataSchema.transform(async (data) => {
const noIndex = !!data.web?.seo_metadata?.noindex
const metadata: Metadata = {
title: await getTitle(data),
description: getDescription(data),
openGraph: {
images: getImage(data),
},
}
if (noIndex) {
metadata.robots = {
index: false,
follow: true,
}
}
return metadata
})

View File

@@ -1,45 +1,162 @@
import { GetLoyaltyPageMetaData } from "@/lib/graphql/Query/LoyaltyPage/MetaData.graphql"
import { contentstackExtendedProcedureUID, router } from "@/server/trpc"
import { metrics } from "@opentelemetry/api"
import { cache } from "react"
import {
type GetLoyaltyPageMetaDataData,
getLoyaltyPageMetadataSchema,
} from "./output"
import { getMetaData, getResponse, type Variables } from "./utils"
import { GetAccountPageMetadata } from "@/lib/graphql/Query/AccountPage/Metadata.graphql"
import { GetCollectionPageMetadata } from "@/lib/graphql/Query/CollectionPage/Metadata.graphql"
import { GetContentPageMetadata } from "@/lib/graphql/Query/ContentPage/Metadata.graphql"
import { GetHotelPageMetadata } from "@/lib/graphql/Query/HotelPage/Metadata.graphql"
import { GetLoyaltyPageMetadata } from "@/lib/graphql/Query/LoyaltyPage/Metadata.graphql"
import { request } from "@/lib/graphql/request"
import { notFound } from "@/server/errors/trpc"
import { contentStackUidWithServiceProcedure, router } from "@/server/trpc"
import { generateTag } from "@/utils/generateTag"
import { getHotelData } from "../../hotels/query"
import { metadataSchema } from "./output"
import { affix } from "./utils"
import { PageTypeEnum } from "@/types/requests/pageType"
import type { RawMetadataSchema } from "@/types/trpc/routers/contentstack/metadata"
import type { Lang } from "@/constants/languages"
async function getLoyaltyPageMetaData(variables: Variables) {
const response = await getResponse<GetLoyaltyPageMetaDataData>(
GetLoyaltyPageMetaData,
variables
const meter = metrics.getMeter("trpc.metadata")
// OpenTelemetry metrics
const fetchMetadataCounter = meter.createCounter(
"trpc.contentstack.metadata.get"
)
const fetchMetadataSuccessCounter = meter.createCounter(
"trpc.contentstack.metadata.get-success"
)
const fetchMetadataFailCounter = meter.createCounter(
"trpc.contentstack.metadata.get-fail"
)
const transformMetadataCounter = meter.createCounter(
"trpc.contentstack.metadata.transform"
)
const transformMetadataSuccessCounter = meter.createCounter(
"trpc.contentstack.metadata.transform-success"
)
const transformMetadataFailCounter = meter.createCounter(
"trpc.contentstack.metadata.transform-fail"
)
const fetchMetadata = cache(async function fetchMemoizedMetadata<T>(
query: string,
{ uid, lang }: { uid: string; lang: Lang }
) {
fetchMetadataCounter.add(1, { lang, uid })
console.info(
"contentstack.metadata fetch start",
JSON.stringify({ query: { lang, uid } })
)
const response = await request<T>(
query,
{ locale: lang, uid },
{
cache: "force-cache",
next: {
tags: [generateTag(lang, uid, affix)],
},
}
)
if (!response.data) {
const notFoundError = notFound(response)
fetchMetadataFailCounter.add(1, {
lang,
uid,
error_type: "not_found",
error: JSON.stringify({ code: notFoundError.code }),
})
console.error(
"contentstack.metadata fetch not found error",
JSON.stringify({
query: { lang, uid },
error: { code: notFoundError.code },
})
)
throw notFoundError
}
fetchMetadataSuccessCounter.add(1, { lang, uid })
console.info(
"contentstack.metadata fetch success",
JSON.stringify({ query: { lang, uid } })
)
const validatedMetadata = getLoyaltyPageMetadataSchema.safeParse(
response.data
)
return response.data
})
async function getTransformedMetadata(data: unknown) {
transformMetadataCounter.add(1)
console.info("contentstack.metadata transform start")
const validatedMetadata = await metadataSchema.safeParseAsync(data)
if (!validatedMetadata.success) {
transformMetadataFailCounter.add(1, {
error_type: "validation_error",
error: JSON.stringify(validatedMetadata.error),
})
console.error(
`Failed to validate Loyaltypage MetaData Data - (uid: ${variables.uid})`
"contentstack.metadata validation error",
JSON.stringify({
error: validatedMetadata.error,
})
)
console.error(validatedMetadata.error)
return null
}
return getMetaData(validatedMetadata.data.loyalty_page)
transformMetadataSuccessCounter.add(1)
console.info("contentstack.metadata transform success")
return validatedMetadata.data
}
export const metaDataQueryRouter = router({
get: contentstackExtendedProcedureUID.query(async ({ ctx }) => {
export const metadataQueryRouter = router({
get: contentStackUidWithServiceProcedure.query(async ({ ctx }) => {
const variables = {
locale: ctx.lang,
lang: ctx.lang,
uid: ctx.uid,
}
switch (ctx.contentType) {
case PageTypeEnum.accountPage:
const accountPageResponse = await fetchMetadata<{
account_page: RawMetadataSchema
}>(GetAccountPageMetadata, variables)
return getTransformedMetadata(accountPageResponse.account_page)
case PageTypeEnum.collectionPage:
const collectionPageResponse = await fetchMetadata<{
collection_page: RawMetadataSchema
}>(GetCollectionPageMetadata, variables)
return getTransformedMetadata(collectionPageResponse.collection_page)
case PageTypeEnum.contentPage:
const contentPageResponse = await fetchMetadata<{
content_page: RawMetadataSchema
}>(GetContentPageMetadata, variables)
return getTransformedMetadata(contentPageResponse.content_page)
case PageTypeEnum.loyaltyPage:
return await getLoyaltyPageMetaData(variables)
const loyaltyPageResponse = await fetchMetadata<{
loyalty_page: RawMetadataSchema
}>(GetLoyaltyPageMetadata, variables)
return getTransformedMetadata(loyaltyPageResponse.loyalty_page)
case PageTypeEnum.hotelPage:
const hotelPageResponse = await fetchMetadata<{
hotel_page: RawMetadataSchema
}>(GetHotelPageMetadata, variables)
const hotelPageData = hotelPageResponse.hotel_page
const hotelData = hotelPageData.hotel_page_id
? await getHotelData(
{ hotelId: hotelPageData.hotel_page_id, language: ctx.lang },
ctx.serviceToken
)
: null
return getTransformedMetadata({
...hotelPageData,
hotelData: hotelData?.data.attributes,
})
default:
return null
}

View File

@@ -1,44 +1,151 @@
import { Lang } from "@/constants/languages"
import { request } from "@/lib/graphql/request"
import { internalServerError, notFound } from "@/server/errors/trpc"
import { getIntl } from "@/i18n"
import { generateTag } from "@/utils/generateTag"
import { getMetaDataSchema, Page } from "../schemas/metadata"
export type Variables = {
locale: Lang
uid: string
}
import { RTETypeEnum } from "@/types/rte/enums"
import type { RawMetadataSchema } from "@/types/trpc/routers/contentstack/metadata"
export const affix = "metadata"
export async function getResponse<T>(query: string, variables: Variables) {
const response = await request<T>(query, variables, {
cache: "force-cache",
next: {
tags: [generateTag(variables.locale, variables.uid, affix)],
},
})
if (!response.data) {
throw notFound(response)
/**
* Truncates the given text "intelligently" based on the last period found near the max length.
*
* - If a period exists within the extended range (`maxLength` to `maxLength + maxExtension`),
* the function truncates after the closest period to `maxLength`.
* - If no period is found in the range, it truncates the text after the last period found in the max length of the text.
* - If no periods exist at all, it truncates at `maxLength` and appends ellipsis (`...`).
*
* @param {string} text - The input text to be truncated.
* @param {number} [maxLength=150] - The desired maximum length of the truncated text.
* @param {number} [minLength=120] - The minimum allowable length for the truncated text.
* @param {number} [maxExtension=10] - The maximum number of characters to extend beyond `maxLength` to find a period.
* @returns {string} - The truncated text.
*/
function truncateTextAfterLastPeriod(
text: string,
maxLength: number = 150,
minLength: number = 120,
maxExtension: number = 10
): string {
if (text.length <= maxLength) {
return text
}
return response
// Define the extended range
const extendedEnd = Math.min(text.length, maxLength + maxExtension)
const extendedText = text.slice(0, extendedEnd)
// Find all periods within the extended range and filter after minLength to get valid periods
const periodsInRange = [...extendedText.matchAll(/\./g)].map(
({ index }) => index
)
const validPeriods = periodsInRange.filter((index) => index + 1 >= minLength)
if (validPeriods.length > 0) {
// Find the period closest to maxLength
const closestPeriod = validPeriods.reduce((closest, index) =>
Math.abs(index + 1 - maxLength) < Math.abs(closest + 1 - maxLength)
? index
: closest
)
return extendedText.slice(0, closestPeriod + 1)
}
// Fallback: If no period is found within the valid range, look for the last period in the truncated text
const maxLengthText = text.slice(0, maxLength)
const lastPeriodIndex = maxLengthText.lastIndexOf(".")
if (lastPeriodIndex !== -1) {
return text.slice(0, lastPeriodIndex + 1)
}
// Final fallback: Return maxLength text including ellipsis
return `${maxLengthText}...`
}
export function getMetaData(page: Page) {
const pageMetaData = {
breadcrumbsTitle: page.web.breadcrumbs.title,
title: page.web.seo_metadata.title,
description: page.web.seo_metadata.description,
imageConnection: page.web.seo_metadata.imageConnection,
uid: page.system.uid,
export async function getTitle(data: RawMetadataSchema) {
const intl = await getIntl()
const metadata = data.web?.seo_metadata
if (metadata?.title) {
return metadata.title
}
const validatedMetaData = getMetaDataSchema.safeParse(pageMetaData)
if (!validatedMetaData.success) {
throw internalServerError(validatedMetaData.error)
if (data.hotelData) {
return intl.formatMessage(
{ id: "Stay at HOTEL_NAME | Hotel in DESTINATION" },
{
hotelName: data.hotelData.name,
destination: data.hotelData.address.city,
}
)
}
return validatedMetaData.data
if (data.web?.breadcrumbs?.title) {
return data.web.breadcrumbs.title
}
if (data.heading) {
return data.heading
}
if (data.header?.heading) {
return data.header.heading
}
return ""
}
export function getDescription(data: RawMetadataSchema) {
const metadata = data.web?.seo_metadata
if (metadata?.description) {
return metadata.description
}
if (data.hotelData) {
return data.hotelData.hotelContent.texts.descriptions.short
}
if (data.preamble) {
return truncateTextAfterLastPeriod(data.preamble)
}
if (data.header?.preamble) {
return truncateTextAfterLastPeriod(data.header.preamble)
}
if (data.blocks?.length) {
const jsonData = data.blocks[0].content?.content?.json
// Finding the first paragraph with text
const firstParagraph = jsonData?.children?.find(
(child) => child.type === RTETypeEnum.p && child.children[0].text
)
if (firstParagraph?.children?.length) {
return firstParagraph.children[0].text
? truncateTextAfterLastPeriod(firstParagraph.children[0].text)
: ""
}
}
return ""
}
export function getImage(data: RawMetadataSchema) {
const metadataImage = data.web?.seo_metadata?.seo_image
const heroImage = data.hero_image
const hotelImage =
data.hotelData?.gallery?.heroImages?.[0] ||
data.hotelData?.gallery?.smallerImages?.[0]
// Currently we don't have the possibility to get smaller images from ImageVault (2024-11-15)
if (metadataImage) {
return {
url: metadataImage.url,
alt: metadataImage.meta.alt || undefined,
width: metadataImage.dimensions.width,
height: metadataImage.dimensions.height,
}
}
if (hotelImage) {
return {
url: hotelImage.imageSizes.small,
alt: hotelImage.metaData.altText || undefined,
}
}
if (heroImage) {
return {
url: heroImage.url,
alt: heroImage.meta.alt || undefined,
width: heroImage.dimensions.width,
height: heroImage.dimensions.height,
}
}
return undefined
}

View File

@@ -18,6 +18,9 @@ export const rewardsCurrentInput = z.object({
lang: z.nativeEnum(Lang).optional(),
})
export const rewardsUpdateInput = z.object({
id: z.string(),
})
export const rewardsUpdateInput = z.array(
z.object({
rewardId: z.string(),
couponCode: z.string(),
})
)

View File

@@ -4,6 +4,7 @@ import { notFound } from "@/server/errors/trpc"
import {
contentStackBaseWithProtectedProcedure,
contentStackBaseWithServiceProcedure,
protectedProcedure,
router,
} from "@/server/trpc"
@@ -16,7 +17,6 @@ import {
} from "./input"
import {
Reward,
SurpriseReward,
validateApiRewardSchema,
validateCategorizedRewardsSchema,
} from "./output"
@@ -34,10 +34,11 @@ import {
getCurrentRewardFailCounter,
getCurrentRewardSuccessCounter,
getUniqueRewardIds,
getUnwrapSurpriseCounter,
getUnwrapSurpriseFailCounter,
getUnwrapSurpriseSuccessCounter,
} from "./utils"
import { Surprise } from "@/types/components/blocks/surprises"
const ONE_HOUR = 60 * 60
export const rewardQueryRouter = router({
@@ -329,44 +330,100 @@ export const rewardQueryRouter = router({
getCurrentRewardSuccessCounter.add(1)
const surprises =
validatedApiRewards.data
.filter(
(reward): reward is SurpriseReward =>
reward?.type === "coupon" && reward?.rewardType === "Surprise"
const surprises = validatedApiRewards.data
// TODO: Add predicates once legacy endpoints are removed
.filter((reward) => {
if (reward?.rewardType !== "Surprise") {
return false
}
if (!("coupon" in reward)) {
return false
}
const unwrappedCoupons =
reward.coupon?.filter((coupon) => !coupon.unwrapped) || []
if (unwrappedCoupons.length === 0) {
return false
}
return true
})
.map((surprise) => {
const reward = cmsRewards.find(
({ reward_id }) => surprise.rewardId === reward_id
)
.map((surprise) => {
const reward = cmsRewards.find(
({ reward_id }) => surprise.rewardId === reward_id
)
if (!reward) {
return null
}
if (!reward) {
return null
}
return {
...reward,
id: surprise.id,
endsAt: surprise.endsAt,
}
})
.filter((surprise): surprise is Surprise => !!surprise) ?? []
return {
...reward,
id: surprise.id,
coupons: "coupon" in surprise ? surprise.coupon || [] : [],
}
})
.flatMap((surprises) => (surprises ? [surprises] : []))
return surprises
}),
update: contentStackBaseWithProtectedProcedure
unwrap: protectedProcedure
.input(rewardsUpdateInput)
.mutation(async ({ input, ctx }) => {
const response = await Promise.resolve({ ok: true })
// const response = await api.post(api.endpoints.v1.rewards, {
// body: {
// ids: [input.id],
// },
// })
if (!response.ok) {
return false
getUnwrapSurpriseCounter.add(1)
const promises = input.map(({ rewardId, couponCode }) => {
return api.post(api.endpoints.v1.Profile.Reward.unwrap, {
body: {
rewardId,
couponCode,
},
headers: {
Authorization: `Bearer ${ctx.session.token.access_token}`,
},
})
})
const responses = await Promise.all(promises)
const errors = await Promise.all(
responses.map(async (apiResponse) => {
if (!apiResponse.ok) {
const text = await apiResponse.text()
getUnwrapSurpriseFailCounter.add(1, {
error_type: "http_error",
error: JSON.stringify({
status: apiResponse.status,
statusText: apiResponse.statusText,
text,
}),
})
console.error(
"contentstack.unwrap API error",
JSON.stringify({
error: {
status: apiResponse.status,
statusText: apiResponse.statusText,
text,
},
query: {},
})
)
return false
}
return true
})
)
if (errors.filter((ok) => !ok).length > 0) {
return null
}
getUnwrapSurpriseSuccessCounter.add(1)
return true
}),
})

View File

@@ -44,6 +44,15 @@ export const getByLevelRewardFailCounter = meter.createCounter(
export const getByLevelRewardSuccessCounter = meter.createCounter(
"trpc.contentstack.reward.byLevel-success"
)
export const getUnwrapSurpriseCounter = meter.createCounter(
"trpc.contentstack.reward.unwrap"
)
export const getUnwrapSurpriseFailCounter = meter.createCounter(
"trpc.contentstack.reward.unwrap-fail"
)
export const getUnwrapSurpriseSuccessCounter = meter.createCounter(
"trpc.contentstack.reward.unwrap-success"
)
const ONE_HOUR = 60 * 60

View File

@@ -25,7 +25,7 @@ export const tableSchema = z.object({
data: z.array(z.object({}).catchall(z.string())),
skipReset: z.boolean(),
tableActionEnabled: z.boolean(),
headerRowAdded: z.boolean(),
headerRowAdded: z.boolean().optional().default(false),
}),
}),
})

View File

@@ -1,46 +0,0 @@
import { z } from "zod"
import { systemSchema } from "./system"
export const getMetaDataSchema = z.object({
breadcrumbsTitle: z.string().optional(),
title: z.string().optional(),
description: z.string().optional(),
imageConnection: z
.object({
edges: z.array(
z.object({
node: z.object({
url: z.string(),
}),
})
),
})
.optional(),
})
export const page = z.object({
web: z.object({
seo_metadata: z.object({
title: z.string().optional(),
description: z.string().optional(),
imageConnection: z
.object({
edges: z.array(
z.object({
node: z.object({
url: z.string(),
}),
})
),
})
.optional(),
}),
breadcrumbs: z.object({
title: z.string(),
}),
}),
system: systemSchema,
})
export type Page = z.infer<typeof page>