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

@@ -3,6 +3,7 @@ CMS_ACCESS_TOKEN=""
CMS_BRANCH="development"
CMS_API_KEY=""
CMS_ENVIRONMENT="development"
CMS_MANAGEMENT_TOKEN="csddab1f1520ce77a1312af5d3"
CMS_PREVIEW_TOKEN=""
CMS_PREVIEW_URL=""
CMS_URL="https://eu-graphql.contentstack.com/stacks/${CMS_API_KEY}?environment=${CMS_ENVIRONMENT}"

View File

@@ -11,13 +11,10 @@ import { languageSwitcherAffix } from "@scandic-hotels/trpc/routers/contentstack
import { affix as metadataAffix } from "@scandic-hotels/trpc/routers/contentstack/metadata/utils"
import { affix as pageSettingsAffix } from "@scandic-hotels/trpc/routers/contentstack/pageSettings/utils"
import { resolveEntryCacheKey } from "@scandic-hotels/trpc/utils/entry"
import {
generateRefsResponseTag,
generateRefTag,
generateTag,
} from "@scandic-hotels/trpc/utils/generateTag"
import { generateTag } from "@scandic-hotels/trpc/utils/generateTag"
import { env } from "@/env/server"
import { fetchEntryReferences } from "@/lib/contentstack/fetchEntryReferences"
import { badRequest, internalServerError } from "@/server/errors/next"
import type { NextRequest } from "next/server"
@@ -28,11 +25,6 @@ const validateJsonBody = z.object({
uid: z.string(),
}),
entry: z.object({
breadcrumbs: z
.object({
title: z.string(),
})
.optional(),
locale: z.nativeEnum(Lang),
publish_details: z.object({ locale: z.nativeEnum(Lang) }).optional(),
uid: z.string(),
@@ -44,6 +36,15 @@ const validateJsonBody = z.object({
.optional(),
destination_settings:
destinationCityPageDestinationSettingsSchema.optional(),
web: z
.object({
breadcrumbs: z
.object({
title: z.string(),
})
.optional(),
})
.optional(),
}),
}),
})
@@ -81,12 +82,6 @@ export async function POST(request: NextRequest) {
// The publish_details.locale is the locale that the entry is published in, regardless if it is "localized" or not
const entryLocale = entry.publish_details?.locale ?? entry.locale
const refsTag = generateRefsResponseTag(entryLocale, entry.uid)
const contentEntryTag = generateRefsResponseTag(
entryLocale,
content_type.uid
)
const refTag = generateRefTag(entryLocale, content_type.uid, entry.uid)
const tag = generateTag(entryLocale, entry.uid)
const languageSwitcherTag = generateTag(
"" as Lang, // We want to clear all languages when unpublishing a page
@@ -106,14 +101,6 @@ export async function POST(request: NextRequest) {
revalidateTag(contentTypeUidTag, { expire: 0 })
keysToDelete.push(contentTypeUidTag)
revalidateLogger.debug(`Revalidating refsTag: ${refsTag}`)
revalidateTag(refsTag, { expire: 0 })
keysToDelete.push(refsTag)
revalidateLogger.debug(`Revalidating refTag: ${refTag}`)
revalidateTag(refTag, { expire: 0 })
keysToDelete.push(refTag)
revalidateLogger.debug(`Revalidating tag: ${tag}`)
revalidateTag(tag, { expire: 0 })
keysToDelete.push(tag)
@@ -126,9 +113,6 @@ export async function POST(request: NextRequest) {
revalidateLogger.debug(`Revalidating metadataTag: ${metadataTag}`)
revalidateTag(metadataTag, { expire: 0 })
keysToDelete.push(metadataTag)
revalidateLogger.debug(`Revalidating contentEntryTag: ${contentEntryTag}`)
revalidateTag(contentEntryTag, { expire: 0 })
keysToDelete.push(contentEntryTag)
if (entry.url) {
const resolveEntryTag = resolveEntryCacheKey(entryLocale, entry.url)
@@ -136,24 +120,13 @@ export async function POST(request: NextRequest) {
keysToDelete.push(resolveEntryTag)
}
if (entry.breadcrumbs) {
const breadcrumbsRefsTag = generateRefsResponseTag(
entryLocale,
entry.uid,
breadcrumbsAffix
)
if (entry.web?.breadcrumbs) {
const breadcrumbsTag = generateTag(
entryLocale,
entry.uid,
breadcrumbsAffix
)
revalidateLogger.debug(
`Revalidating breadcrumbsRefsTag: ${breadcrumbsRefsTag}`
)
revalidateTag(breadcrumbsRefsTag, { expire: 0 })
keysToDelete.push(breadcrumbsRefsTag)
revalidateLogger.debug(`Revalidating breadcrumbsTag: ${breadcrumbsTag}`)
revalidateTag(breadcrumbsTag, { expire: 0 })
keysToDelete.push(breadcrumbsTag)
@@ -183,6 +156,26 @@ export async function POST(request: NextRequest) {
}
}
// Fetch entries that REFERENCE this entry (parent pages that embed this content)
// This allows us to invalidate parent page caches when a referenced child is updated
const references = await fetchEntryReferences(content_type.uid, entry.uid)
for (const ref of references) {
// Use the entry's locale (from the webhook payload), not the reference's locale
// The API returns references with their base locale, but we only want to
// invalidate caches for the locale that was actually published
const parentTag = generateTag(entryLocale, ref.entry_uid)
revalidateLogger.debug(
`Revalidating parent reference tag: ${parentTag} (from ${ref.content_type_uid})`
)
revalidateTag(parentTag, { expire: 0 })
keysToDelete.push(parentTag)
}
revalidateLogger.debug(
`Deleting ${keysToDelete.length} keys (including ${references.length} parent references)`
)
await cacheClient.deleteKeys(keysToDelete, { fuzzy: true })
return Response.json({ revalidated: true, now: Date.now() })

View File

@@ -24,6 +24,7 @@ export const env = createEnv({
CMS_API_KEY: z.string(),
CMS_ENVIRONMENT: z.enum(["development", "production", "stage", "test"]),
CMS_BRANCH: z.enum(["development", "production"]),
CMS_MANAGEMENT_TOKEN: z.string().optional(),
CURITY_CLIENT_ID_USER: z.string().default("scandichotels-web"),
CURITY_CLIENT_ID_SERVICE: z.string().default("scandichotels-web-backend"),
CURITY_CLIENT_SECRET_SERVICE: z.string(),
@@ -123,6 +124,7 @@ export const env = createEnv({
CMS_API_KEY: process.env.CMS_API_KEY,
CMS_ENVIRONMENT: process.env.CMS_ENVIRONMENT,
CMS_BRANCH: process.env.CMS_BRANCH,
CMS_MANAGEMENT_TOKEN: process.env.CMS_MANAGEMENT_TOKEN,
CURITY_CLIENT_ID_USER: process.env.CURITY_CLIENT_ID_USER,
CURITY_CLIENT_ID_SERVICE: process.env.CURITY_CLIENT_ID_SERVICE,
CURITY_CLIENT_SECRET_SERVICE: process.env.CURITY_CLIENT_SECRET_SERVICE,

View File

@@ -0,0 +1,86 @@
import { z } from "zod"
import { createLogger } from "@scandic-hotels/common/logger/createLogger"
import { env } from "@/env/server"
const logger = createLogger("contentstack-references")
/**
* Schema for validating Contentstack Management API references response
*/
const entryReferenceSchema = z.object({
uid: z.string().optional(),
entry_uid: z.string(),
content_type_uid: z.string(),
locale: z.string(),
title: z.string(),
_version: z.number().optional(),
content_type_title: z.string().optional(),
})
const referencesResponseSchema = z.object({
references: z.array(entryReferenceSchema).optional().default([]),
})
export type EntryReference = z.infer<typeof entryReferenceSchema>
/**
* Fetches all entries that reference the given entry from Contentstack Management API.
*
* This is used during cache invalidation to find parent pages that embed/reference
* the changed entry, so we can invalidate their caches as well.
*
* @see https://www.contentstack.com/docs/developers/apis/content-management-api#entry-references
*/
export async function fetchEntryReferences(
contentTypeUid: string,
entryUid: string
): Promise<EntryReference[]> {
const managementToken = env.CMS_MANAGEMENT_TOKEN
if (!managementToken) {
throw new Error("CMS_MANAGEMENT_TOKEN not configured")
}
// Contentstack EU region base URL
const baseUrl = "https://eu-api.contentstack.com"
const url = `${baseUrl}/v3/content_types/${contentTypeUid}/entries/${entryUid}/references`
const response = await fetch(url, {
method: "GET",
headers: {
api_key: env.CMS_API_KEY,
authorization: managementToken,
branch: env.CMS_BRANCH,
"Content-Type": "application/json",
},
// Don't cache this request - we need fresh data
cache: "no-store",
})
if (!response.ok) {
const errorText = await response.text()
throw new Error(
`Failed to fetch entry references: ${response.status} ${errorText}`,
{
cause: response,
}
)
}
const data = await response.json()
const parsed = referencesResponseSchema.safeParse(data)
if (!parsed.success) {
throw new Error("Invalid response from Contentstack references API", {
cause: parsed.error,
})
}
logger.debug(
`Found ${parsed.data.references.length} references for ${contentTypeUid}/${entryUid}`
)
return parsed.data.references
}