diff --git a/src/app/(frontend)/[locale]/about/page.tsx b/src/app/(frontend)/[locale]/about/page.tsx index a7f4f1d..7233b33 100644 --- a/src/app/(frontend)/[locale]/about/page.tsx +++ b/src/app/(frontend)/[locale]/about/page.tsx @@ -3,6 +3,7 @@ import { setRequestLocale } from 'next-intl/server' import type { Locale } from '@/i18n/config' import { AboutContactTabs } from '@/components/site/about-contact-tabs' import { PageHero } from '@/components/site/page-hero' +import { cacheCmsQuery } from '@/lib/cms-cache' import { getPayloadClient } from '@/lib/payload' import { buildAbsoluteUrl, buildLocaleAlternates, extractRichTextPlainText, trimDescription } from '@/lib/seo' import { normalizeUploadUrl } from '@/lib/upload-url' @@ -15,13 +16,17 @@ function getAboutPageTitle(rawTitle: string | null | undefined) { return normalized } -async function getAboutPageData(locale: Locale) { - const payload = await getPayloadClient() - return payload.findGlobal({ - slug: 'aboutPage', - locale, - }) -} +const getAboutPageData = cacheCmsQuery( + ['about-page-global'], + async (locale: Locale) => { + const payload = await getPayloadClient() + + return payload.findGlobal({ + slug: 'aboutPage', + locale, + }) + }, +) export async function generateMetadata({ params, diff --git a/src/app/(frontend)/[locale]/contact/page.tsx b/src/app/(frontend)/[locale]/contact/page.tsx index b2003b6..19f37d2 100644 --- a/src/app/(frontend)/[locale]/contact/page.tsx +++ b/src/app/(frontend)/[locale]/contact/page.tsx @@ -4,6 +4,7 @@ import { setRequestLocale } from 'next-intl/server' import type { Locale } from '@/i18n/config' import { AboutContactTabs } from '@/components/site/about-contact-tabs' import { PageHero } from '@/components/site/page-hero' +import { cacheCmsQuery } from '@/lib/cms-cache' import { getPayloadClient } from '@/lib/payload' import { buildAbsoluteUrl, buildLocaleAlternates } from '@/lib/seo' import { normalizeUploadUrl } from '@/lib/upload-url' @@ -158,15 +159,18 @@ function normalizeContactGroups(groups: unknown, locale: Locale) { return normalized.length > 0 ? normalized : getDefaultContactGroups(locale) } -async function getContactUsPageData(locale: Locale) { - const payload = await getPayloadClient() +const getContactUsPageData = cacheCmsQuery( + ['contact-us-page-global'], + async (locale: Locale) => { + const payload = await getPayloadClient() - return payload.findGlobal({ - depth: 1, - locale, - slug: 'contactUsPage', - }) -} + return payload.findGlobal({ + depth: 1, + locale, + slug: 'contactUsPage', + }) + }, +) export async function generateMetadata({ params, diff --git a/src/app/(frontend)/[locale]/downloads/[slug]/page.tsx b/src/app/(frontend)/[locale]/downloads/[slug]/page.tsx index 39fa957..64b9648 100644 --- a/src/app/(frontend)/[locale]/downloads/[slug]/page.tsx +++ b/src/app/(frontend)/[locale]/downloads/[slug]/page.tsx @@ -5,6 +5,7 @@ import { setRequestLocale } from 'next-intl/server' import type { Locale } from '@/i18n/config' import { PageHero } from '@/components/site/page-hero' import { SiteIcon } from '@/components/site/site-icons' +import { cacheCmsQuery } from '@/lib/cms-cache' import { getPayloadClient } from '@/lib/payload' import { buildLocaleAlternates, extractRichTextPlainText, trimDescription } from '@/lib/seo' import { renderRichText } from '@/lib/render-rich-text' @@ -122,7 +123,7 @@ function hasRichTextContent(value: Download['notes'] | Download['changelog'] | n return Boolean(extractRichTextPlainText(value)) } -async function getDownloadProductData(slug: string, locale: Locale) { +async function getDownloadProductDataUncached(slug: string, locale: Locale) { const payload = await getPayloadClient() const products = await payload.find({ collection: 'products', @@ -231,6 +232,11 @@ async function getDownloadProductData(slug: string, locale: Locale) { } } +const getDownloadProductData = cacheCmsQuery< + [string, Locale], + Awaited> +>(['download-product-data'], getDownloadProductDataUncached) + function getRelatedProducts(download: Download) { const multi = Array.isArray((download as Download & { products?: DownloadProductReference[] }).products) ? ((download as Download & { products?: DownloadProductReference[] }).products as DownloadProductReference[]) diff --git a/src/app/(frontend)/[locale]/downloads/page.tsx b/src/app/(frontend)/[locale]/downloads/page.tsx index a0e2170..d7c6516 100644 --- a/src/app/(frontend)/[locale]/downloads/page.tsx +++ b/src/app/(frontend)/[locale]/downloads/page.tsx @@ -4,6 +4,7 @@ import { setRequestLocale } from 'next-intl/server' import type { Locale } from '@/i18n/config' import { PageHero } from '@/components/site/page-hero' import { SiteIcon } from '@/components/site/site-icons' +import { cacheCmsQuery } from '@/lib/cms-cache' import { getPayloadClient } from '@/lib/payload' import { isTemplateProduct } from '@/lib/product-template-navigation' import { buildAbsoluteUrl, buildLocaleAlternates, extractRichTextPlainText } from '@/lib/seo' @@ -224,7 +225,7 @@ async function getSeedDownloadGroups(locale: Locale): Promise { .sort((left, right) => left.productLabel.localeCompare(right.productLabel)) } -async function getDownloadsPageData(locale: Locale) { +async function getDownloadsPageDataUncached(locale: Locale) { const payload = await getPayloadClient() const downloadsResult = await payload.find({ collection: 'downloads', @@ -337,15 +338,23 @@ async function getDownloadsPageData(locale: Locale) { return getSeedDownloadGroups(locale) } -async function getDownloadsPageSettings(locale: Locale): Promise { - const payload = await getPayloadClient() +const getDownloadsPageData = cacheCmsQuery<[Locale], Awaited>>( + ['downloads-page-data'], + getDownloadsPageDataUncached, +) - return (await payload.findGlobal({ - depth: 1, - locale, - slug: 'downloadsPage', - })) as DownloadsPageSettings -} +const getDownloadsPageSettings = cacheCmsQuery<[Locale], DownloadsPageSettings>( + ['downloads-page-global'], + async (locale) => { + const payload = await getPayloadClient() + + return (await payload.findGlobal({ + depth: 1, + locale, + slug: 'downloadsPage', + })) as DownloadsPageSettings + }, +) function getRelatedProducts(download: Download) { const multi = Array.isArray((download as Download & { products?: DownloadProductReference[] }).products) diff --git a/src/app/(frontend)/[locale]/news/[slug]/page.tsx b/src/app/(frontend)/[locale]/news/[slug]/page.tsx index 3fea666..725762a 100644 --- a/src/app/(frontend)/[locale]/news/[slug]/page.tsx +++ b/src/app/(frontend)/[locale]/news/[slug]/page.tsx @@ -3,6 +3,7 @@ import Link from 'next/link' import { notFound } from 'next/navigation' import { setRequestLocale } from 'next-intl/server' import type { Locale } from '@/i18n/config' +import { cacheCmsQuery } from '@/lib/cms-cache' import { getPayloadClient } from '@/lib/payload' import { renderRichText } from '@/lib/render-rich-text' import { buildAbsoluteUrl, buildLocaleAlternates, extractRichTextPlainText, trimDescription } from '@/lib/seo' @@ -21,31 +22,34 @@ function formatPublishedAt(value: string | null | undefined, locale: Locale) { }).format(new Date(value)) } -async function getNewsArticle(locale: Locale, slug: string) { - const payload = await getPayloadClient() - const result = await payload.find({ - collection: 'news', - depth: 1, - limit: 1, - locale, - where: { - and: [ - { - docLocale: { - equals: locale, +const getNewsArticle = cacheCmsQuery( + ['news-article'], + async (locale: Locale, slug: string) => { + const payload = await getPayloadClient() + const result = await payload.find({ + collection: 'news', + depth: 1, + limit: 1, + locale, + where: { + and: [ + { + docLocale: { + equals: locale, + }, }, - }, - { - slug: { - equals: slug, + { + slug: { + equals: slug, + }, }, - }, - ], - }, - }) + ], + }, + }) - return result.docs[0] ?? null -} + return result.docs[0] ?? null + }, +) function getRelatedProduct(product: number | Product) { return typeof product === 'object' ? product : null diff --git a/src/app/(frontend)/[locale]/news/page.tsx b/src/app/(frontend)/[locale]/news/page.tsx index fbf44a4..dd8c9e0 100644 --- a/src/app/(frontend)/[locale]/news/page.tsx +++ b/src/app/(frontend)/[locale]/news/page.tsx @@ -3,6 +3,7 @@ import Link from 'next/link' import { setRequestLocale } from 'next-intl/server' import type { Locale } from '@/i18n/config' import { getBlogPosts } from '@/lib/blog-api' +import { cacheCmsQuery } from '@/lib/cms-cache' import { getPayloadClient } from '@/lib/payload' import { buildAbsoluteUrl, buildLocaleAlternates } from '@/lib/seo' import { normalizeUploadUrl } from '@/lib/upload-url' @@ -31,35 +32,41 @@ type NewsListItem = { title: string } -async function getNewsPageSettings() { - const payload = await getPayloadClient() +const getNewsPageSettings = cacheCmsQuery( + ['news-page-global'], + async () => { + const payload = await getPayloadClient() - try { - return await payload.findGlobal({ - slug: 'newsPage', - }) - } catch { - return { newsSource: 'externalBlog' as const } - } -} + try { + return await payload.findGlobal({ + slug: 'newsPage', + }) + } catch { + return { newsSource: 'externalBlog' as const } + } + }, +) -async function getLocalNewsPage(locale: Locale, page: number) { - const payload = await getPayloadClient() +const getLocalNewsPage = cacheCmsQuery( + ['local-news-page'], + async (locale: Locale, page: number) => { + const payload = await getPayloadClient() - return payload.find({ - collection: 'news', - depth: 1, - limit: 15, - locale, - page, - sort: '-publishedAt,-updatedAt', - where: { - docLocale: { - equals: locale, + return payload.find({ + collection: 'news', + depth: 1, + limit: 15, + locale, + page, + sort: '-publishedAt,-updatedAt', + where: { + docLocale: { + equals: locale, + }, }, - }, - }) -} + }) + }, +) function getCoverImageUrl(article: News) { if (!article.coverImage || typeof article.coverImage === 'number') return null diff --git a/src/app/(frontend)/[locale]/page.tsx b/src/app/(frontend)/[locale]/page.tsx index fdf24ea..2a5c1ee 100644 --- a/src/app/(frontend)/[locale]/page.tsx +++ b/src/app/(frontend)/[locale]/page.tsx @@ -9,6 +9,7 @@ import { HomeNews } from '@/components/home/home-news' import { HomeRecognition } from '@/components/home/home-recognition' import { HomeStatement } from '@/components/home/home-statement' import { getBlogPosts } from '@/lib/blog-api' +import { cacheCmsQuery } from '@/lib/cms-cache' import { getSiteChromeSettings } from '@/lib/global-settings' import { getPayloadClient } from '@/lib/payload' import { VISIBLE_PRODUCT_STATUS } from '@/lib/product-status' @@ -110,37 +111,45 @@ function normalizeHomepageModuleOrder(items: Homepage['moduleOrder']): HomepageM return [...new Set([...ordered, ...validModules])] } -async function getHomepageData(locale: Locale) { - const payload = await getPayloadClient() - return payload.findGlobal({ - depth: 2, - slug: 'homepage', - locale, - }) -} +const getHomepageData = cacheCmsQuery<[Locale], Homepage>( + ['homepage-global'], + async (locale) => { + const payload = await getPayloadClient() -async function getPublishedProducts(locale: Locale) { - const payload = await getPayloadClient() - const published = await payload.find({ - collection: 'products', - depth: 1, - limit: 100, - locale, - sort: 'order', - where: { - and: [ - { docLocale: { equals: locale } }, - { status: { equals: VISIBLE_PRODUCT_STATUS } }, - { name: { exists: true } }, - ], - }, - }) + return payload.findGlobal({ + depth: 2, + slug: 'homepage', + locale, + }) as Promise + }, +) - if (published.docs.length > 0) { - return published.docs - } - return [] -} +const getPublishedProducts = cacheCmsQuery<[Locale], Product[]>( + ['published-products'], + async (locale) => { + const payload = await getPayloadClient() + const published = await payload.find({ + collection: 'products', + depth: 1, + limit: 100, + locale, + sort: 'order', + where: { + and: [ + { docLocale: { equals: locale } }, + { status: { equals: VISIBLE_PRODUCT_STATUS } }, + { name: { exists: true } }, + ], + }, + }) + + if (published.docs.length > 0) { + return published.docs + } + + return [] + }, +) function getTemplateProductHrefs(products: Product[], locale: Locale) { return products @@ -148,23 +157,26 @@ function getTemplateProductHrefs(products: Product[], locale: Locale) { .map((product) => `/${locale}/products/${product.slug}`) } -async function getLatestLocalNews(locale: Locale) { - const payload = await getPayloadClient() - const result = await payload.find({ - collection: 'news', - depth: 1, - limit: 6, - locale, - sort: '-publishedAt', - where: { - docLocale: { - equals: locale, +const getLatestLocalNews = cacheCmsQuery<[Locale], News[]>( + ['latest-local-news'], + async (locale) => { + const payload = await getPayloadClient() + const result = await payload.find({ + collection: 'news', + depth: 1, + limit: 6, + locale, + sort: '-publishedAt', + where: { + docLocale: { + equals: locale, + }, }, - }, - }) + }) - return result.docs -} + return result.docs + }, +) function getLocalNewsHighlights(items: Homepage['newsHighlights']) { return (items || []).filter((item): item is News => typeof item === 'object') diff --git a/src/app/(frontend)/[locale]/products/[slug]/page.tsx b/src/app/(frontend)/[locale]/products/[slug]/page.tsx index f675ca2..3ec4874 100644 --- a/src/app/(frontend)/[locale]/products/[slug]/page.tsx +++ b/src/app/(frontend)/[locale]/products/[slug]/page.tsx @@ -3,6 +3,7 @@ import { notFound } from 'next/navigation' import { setRequestLocale } from 'next-intl/server' import type { Locale } from '@/i18n/config' import { ALL_PRODUCTS_FALLBACK } from '@/lib/fallback-data' +import { cacheCmsQuery } from '@/lib/cms-cache' import { getPayloadClient } from '@/lib/payload' import { ACCESSIBLE_PRODUCT_STATUSES } from '@/lib/product-status' import { getProductSeriesDefault } from '@/lib/product-series-defaults' @@ -336,7 +337,7 @@ function ProductSpecificationsSection({ ) } -async function getProductPageData(slug: string, locale: Locale) { +async function getProductPageDataUncached(slug: string, locale: Locale) { const payload = await getPayloadClient() const products = await payload.find({ @@ -455,6 +456,11 @@ async function getProductPageData(slug: string, locale: Locale) { return { downloads, news: relatedNews, overviewMedia, product, reviews: reviews.docs, videos: videos.docs } } +const getProductPageData = cacheCmsQuery<[string, Locale], Awaited>>( + ['product-page-data'], + getProductPageDataUncached, +) + export async function generateMetadata({ params, }: { diff --git a/src/app/(frontend)/[locale]/products/page.tsx b/src/app/(frontend)/[locale]/products/page.tsx index 2113ed9..f202bac 100644 --- a/src/app/(frontend)/[locale]/products/page.tsx +++ b/src/app/(frontend)/[locale]/products/page.tsx @@ -3,6 +3,7 @@ import Link from 'next/link' import { setRequestLocale } from 'next-intl/server' import type { Locale } from '@/i18n/config' import { PageHero } from '@/components/site/page-hero' +import { cacheCmsQuery } from '@/lib/cms-cache' import { getPayloadClient } from '@/lib/payload' import { getProductSeriesDefault } from '@/lib/product-series-defaults' import { VISIBLE_PRODUCT_STATUS } from '@/lib/product-status' @@ -100,7 +101,7 @@ function normalizeCategory(category: number | ProductCategory | null | undefined return getDefaultSeriesCopy(getCategoryKeyFromSlug(slug), locale) } -async function getProductsPageData(locale: Locale) { +async function getProductsPageDataUncached(locale: Locale) { const payload = await getPayloadClient() const result = await payload.find({ collection: 'products', @@ -162,6 +163,43 @@ async function getProductsPageData(locale: Locale) { })) } +const getProductsPageData = cacheCmsQuery<[Locale], Awaited>>( + ['products-page-data'], + getProductsPageDataUncached, +) + +const getProductsPageSettings = cacheCmsQuery( + ['products-page-global'], + async (locale: Locale) => { + const payload = await getPayloadClient() + + return payload.findGlobal({ + depth: 1, + slug: 'productsPage', + locale, + }) + }, +) + +const getProductCategories = cacheCmsQuery<[Locale], ProductCategory[]>( + ['product-categories'], + async (locale) => { + const payload = await getPayloadClient() + const categories = await payload.find({ + collection: 'productCategories', + depth: 0, + limit: 50, + locale, + sort: 'order', + where: { + and: [{ docLocale: { equals: locale } }, { name: { exists: true } }], + }, + }) + + return categories.docs + }, +) + export async function generateMetadata({ params, }: { @@ -187,25 +225,13 @@ export default async function ProductsPage({ const { locale } = await params setRequestLocale(locale) - const products = await getProductsPageData(locale) - const payload = await getPayloadClient() - const page = await payload.findGlobal({ - depth: 1, - slug: 'productsPage', - locale, - }) - const categories = await payload.find({ - collection: 'productCategories', - depth: 0, - limit: 50, - locale, - sort: 'order', - where: { - and: [{ docLocale: { equals: locale } }, { name: { exists: true } }], - }, - }) + const [products, page, categories] = await Promise.all([ + getProductsPageData(locale), + getProductsPageSettings(locale), + getProductCategories(locale), + ]) const categoryMap = new Map( - categories.docs.map((category) => [category.slug, normalizeCategory(category, category.slug, locale)]), + categories.map((category) => [category.slug, normalizeCategory(category, category.slug, locale)]), ) const groups = new Map() diff --git a/src/app/(frontend)/[locale]/reviews/page.tsx b/src/app/(frontend)/[locale]/reviews/page.tsx index 416c98f..1453592 100644 --- a/src/app/(frontend)/[locale]/reviews/page.tsx +++ b/src/app/(frontend)/[locale]/reviews/page.tsx @@ -4,6 +4,7 @@ import { setRequestLocale } from 'next-intl/server' import type { Locale } from '@/i18n/config' import { PageHero } from '@/components/site/page-hero' import { VideoModalGrid, type VideoModalGridItem } from '@/components/site/video-modal-grid' +import { cacheCmsQuery } from '@/lib/cms-cache' import { getPayloadClient } from '@/lib/payload' import { buildAbsoluteUrl, buildLocaleAlternates, trimDescription } from '@/lib/seo' import { normalizeUploadUrl } from '@/lib/upload-url' @@ -58,7 +59,7 @@ function truncateSourceName(value: string | null | undefined) { return chars.length > 20 ? `${chars.slice(0, 20).join('')}...` : source } -async function getReviewsPageData(locale: Locale) { +async function getReviewsPageDataUncached(locale: Locale) { const payload = await getPayloadClient() const [videos, reviews] = await Promise.all([ payload.find({ @@ -95,7 +96,12 @@ async function getReviewsPageData(locale: Locale) { } } -async function getReviewsPageSettings(locale: Locale) { +const getReviewsPageData = cacheCmsQuery<[Locale], Awaited>>( + ['reviews-page-data'], + getReviewsPageDataUncached, +) + +async function getReviewsPageSettingsUncached(locale: Locale) { const payload = await getPayloadClient() const fallback = { description: @@ -141,6 +147,11 @@ async function getReviewsPageSettings(locale: Locale) { } } +const getReviewsPageSettings = cacheCmsQuery<[Locale], Awaited>>( + ['reviews-page-global'], + getReviewsPageSettingsUncached, +) + export async function generateMetadata({ params, }: { diff --git a/src/app/(frontend)/[locale]/support/contact/page.tsx b/src/app/(frontend)/[locale]/support/contact/page.tsx index d1e064c..10dedbc 100644 --- a/src/app/(frontend)/[locale]/support/contact/page.tsx +++ b/src/app/(frontend)/[locale]/support/contact/page.tsx @@ -3,6 +3,7 @@ import Link from 'next/link' import { setRequestLocale } from 'next-intl/server' import type { Locale } from '@/i18n/config' import { PageHero } from '@/components/site/page-hero' +import { cacheCmsQuery } from '@/lib/cms-cache' import { getPayloadClient } from '@/lib/payload' import { VISIBLE_PRODUCT_STATUS } from '@/lib/product-status' import { renderRichText } from '@/lib/render-rich-text' @@ -13,7 +14,7 @@ import { SupportContactForm } from './support-contact-form' export const revalidate = 300 -async function getContactPageData(locale: Locale) { +async function getContactPageDataUncached(locale: Locale) { const payload = await getPayloadClient() const [page, products] = await Promise.all([ @@ -40,6 +41,11 @@ async function getContactPageData(locale: Locale) { return { page, products: products.docs } } +const getContactPageData = cacheCmsQuery<[Locale], Awaited>>( + ['support-contact-page-data'], + getContactPageDataUncached, +) + function getContactPageTitle(rawTitle: string | null | undefined, locale: Locale) { const normalized = rawTitle?.trim() if (!normalized) return '' diff --git a/src/app/(frontend)/[locale]/support/faq/page.tsx b/src/app/(frontend)/[locale]/support/faq/page.tsx index f3502f4..8b71954 100644 --- a/src/app/(frontend)/[locale]/support/faq/page.tsx +++ b/src/app/(frontend)/[locale]/support/faq/page.tsx @@ -2,6 +2,7 @@ import type { Metadata } from 'next' import Link from 'next/link' import { setRequestLocale } from 'next-intl/server' import type { Locale } from '@/i18n/config' +import { cacheCmsQuery } from '@/lib/cms-cache' import { getPayloadClient } from '@/lib/payload' import { buildAbsoluteUrl, buildLocaleAlternates, extractRichTextPlainText } from '@/lib/seo' import { normalizeUploadUrl } from '@/lib/upload-url' @@ -125,22 +126,25 @@ function getFaqHeroImageUrl(settings: FaqPageSettings) { return normalizeUploadUrl(settings.hero?.imageUrl?.trim() || null) } -async function getFaqPageSettings(locale: Locale): Promise { - const payload = await getPayloadClient() +const getFaqPageSettings = cacheCmsQuery<[Locale], FaqPageSettings>( + ['faq-page-global'], + async (locale) => { + const payload = await getPayloadClient() - try { - const faqPayload = payload as unknown as PayloadWithFaqGlobal - return await faqPayload.findGlobal({ - depth: 1, - locale, - slug: 'faqPage', - }) - } catch { - return {} - } -} + try { + const faqPayload = payload as unknown as PayloadWithFaqGlobal + return await faqPayload.findGlobal({ + depth: 1, + locale, + slug: 'faqPage', + }) + } catch { + return {} + } + }, +) -async function getFaqPageData(locale: Locale) { +async function getFaqPageDataUncached(locale: Locale) { const payload = await getPayloadClient() const result = await payload.find({ collection: 'faqs', @@ -164,6 +168,11 @@ async function getFaqPageData(locale: Locale) { return [...groups.entries()].map(([group, faqs]) => ({ faqs, group })) } +const getFaqPageData = cacheCmsQuery<[Locale], Awaited>>( + ['faq-page-data'], + getFaqPageDataUncached, +) + export async function generateMetadata({ params, }: { diff --git a/src/app/(frontend)/[locale]/support/page.tsx b/src/app/(frontend)/[locale]/support/page.tsx index 2c671b1..412fbbe 100644 --- a/src/app/(frontend)/[locale]/support/page.tsx +++ b/src/app/(frontend)/[locale]/support/page.tsx @@ -4,6 +4,7 @@ import { setRequestLocale } from 'next-intl/server' import type { Locale } from '@/i18n/config' import { PageHero } from '@/components/site/page-hero' import { SiteIcon } from '@/components/site/site-icons' +import { cacheCmsQuery } from '@/lib/cms-cache' import { getPayloadClient } from '@/lib/payload' import { renderRichText } from '@/lib/render-rich-text' import { buildAbsoluteUrl, buildLocaleAlternates, extractRichTextPlainText, trimDescription } from '@/lib/seo' @@ -17,14 +18,17 @@ type SupportEntry = NonNullable[number] & { iconKey?: string | null } -async function getSupportPageData(locale: Locale) { - const payload = await getPayloadClient() +const getSupportPageData = cacheCmsQuery( + ['support-page-global'], + async (locale: Locale) => { + const payload = await getPayloadClient() - return payload.findGlobal({ - slug: 'supportPage', - locale, - }) -} + return payload.findGlobal({ + slug: 'supportPage', + locale, + }) + }, +) function getSupportPageTitle(rawTitle: string | null | undefined, locale: Locale) { const normalized = rawTitle?.trim() diff --git a/src/app/(frontend)/[locale]/support/tutorial/page.tsx b/src/app/(frontend)/[locale]/support/tutorial/page.tsx index fefc0ae..24d2b44 100644 --- a/src/app/(frontend)/[locale]/support/tutorial/page.tsx +++ b/src/app/(frontend)/[locale]/support/tutorial/page.tsx @@ -3,6 +3,7 @@ import { setRequestLocale } from 'next-intl/server' import type { Locale } from '@/i18n/config' import { PageHero } from '@/components/site/page-hero' import { VideoModalGrid, type VideoModalGridItem } from '@/components/site/video-modal-grid' +import { cacheCmsQuery } from '@/lib/cms-cache' import { getPayloadClient } from '@/lib/payload' import { buildAbsoluteUrl, buildLocaleAlternates, trimDescription } from '@/lib/seo' import { normalizeUploadUrl } from '@/lib/upload-url' @@ -54,7 +55,7 @@ const FALLBACK_VIDEOS: TutorialVideo[] = [ }, ] -async function getTutorialPageData(locale: Locale) { +async function getTutorialPageDataUncached(locale: Locale) { const payload = await getPayloadClient() const [page, videos] = await Promise.all([ payload.findGlobal({ @@ -87,6 +88,11 @@ async function getTutorialPageData(locale: Locale) { } } +const getTutorialPageData = cacheCmsQuery<[Locale], Awaited>>( + ['support-tutorial-page-data'], + getTutorialPageDataUncached, +) + function getTutorialTitle(tutorial: TutorialSettings | null | undefined, locale: Locale) { return tutorial?.hero?.title?.trim() || (locale === 'zh' ? '视频教程' : 'Video Tutorials') } diff --git a/src/app/(frontend)/[locale]/support/warranty/page.tsx b/src/app/(frontend)/[locale]/support/warranty/page.tsx index a1f3279..adc44e3 100644 --- a/src/app/(frontend)/[locale]/support/warranty/page.tsx +++ b/src/app/(frontend)/[locale]/support/warranty/page.tsx @@ -3,6 +3,7 @@ import Link from 'next/link' import { setRequestLocale } from 'next-intl/server' import type { Locale } from '@/i18n/config' import { PageHero } from '@/components/site/page-hero' +import { cacheCmsQuery } from '@/lib/cms-cache' import { getPayloadClient } from '@/lib/payload' import { renderRichText } from '@/lib/render-rich-text' import { buildLocaleAlternates, extractRichTextPlainText, trimDescription } from '@/lib/seo' @@ -11,14 +12,17 @@ import type { WarrantyPage as WarrantyPageType } from '@/payload-types' export const revalidate = 300 -async function getWarrantyPageData(locale: Locale) { - const payload = await getPayloadClient() +const getWarrantyPageData = cacheCmsQuery( + ['warranty-page-global'], + async (locale: Locale) => { + const payload = await getPayloadClient() - return payload.findGlobal({ - slug: 'warrantyPage', - locale, - }) -} + return payload.findGlobal({ + slug: 'warrantyPage', + locale, + }) + }, +) function getWarrantyPageTitle(rawTitle: string | null | undefined, locale: Locale) { const normalized = rawTitle?.trim() diff --git a/src/app/(frontend)/[locale]/where-to-buy/page.tsx b/src/app/(frontend)/[locale]/where-to-buy/page.tsx index db62e8d..73d821c 100644 --- a/src/app/(frontend)/[locale]/where-to-buy/page.tsx +++ b/src/app/(frontend)/[locale]/where-to-buy/page.tsx @@ -3,6 +3,7 @@ import Link from 'next/link' import { setRequestLocale } from 'next-intl/server' import type { Locale } from '@/i18n/config' import { PageHero } from '@/components/site/page-hero' +import { cacheCmsQuery } from '@/lib/cms-cache' import { getPayloadClient } from '@/lib/payload' import { buildAbsoluteUrl, buildLocaleAlternates } from '@/lib/seo' import { normalizeUploadUrl } from '@/lib/upload-url' @@ -292,7 +293,7 @@ function getMapPoints(dealers: Dealer[]) { return [...grouped.values()].sort((left, right) => left.region.localeCompare(right.region)) } -async function getDealersPageSettings(locale: Locale) { +async function getDealersPageSettingsUncached(locale: Locale) { const payload = await getPayloadClient() try { @@ -348,7 +349,12 @@ async function getDealersPageSettings(locale: Locale) { } } -async function getDealersPageData(locale: Locale) { +const getDealersPageSettings = cacheCmsQuery< + [Locale], + Awaited> +>(['dealers-page-global'], getDealersPageSettingsUncached) + +async function getDealersPageDataUncached(locale: Locale) { const payload = await getPayloadClient() const result = await payload.find({ collection: 'dealers', @@ -381,6 +387,11 @@ async function getDealersPageData(locale: Locale) { } } +const getDealersPageData = cacheCmsQuery<[Locale], Awaited>>( + ['dealers-page-data'], + getDealersPageDataUncached, +) + function DealerTabs({ activeTab, locale, @@ -677,8 +688,10 @@ export default async function WhereToBuyPage({ const query = await searchParams setRequestLocale(locale) - const { dealerDocs, dealerGroups, storeDocs } = await getDealersPageData(locale) - const settings = await getDealersPageSettings(locale) + const [{ dealerDocs, dealerGroups, storeDocs }, settings] = await Promise.all([ + getDealersPageData(locale), + getDealersPageSettings(locale), + ]) const hasDealers = dealerDocs.length > 0 const hasStores = storeDocs.length > 0 const shouldShowTabs = hasDealers && hasStores diff --git a/src/app/(payload)/admin/_components/admin-cache-actions.ts b/src/app/(payload)/admin/_components/admin-cache-actions.ts index 936f3eb..6aa8758 100644 --- a/src/app/(payload)/admin/_components/admin-cache-actions.ts +++ b/src/app/(payload)/admin/_components/admin-cache-actions.ts @@ -1,7 +1,8 @@ 'use server' -import { revalidatePath } from 'next/cache' +import { revalidatePath, revalidateTag } from 'next/cache' import { redirect } from 'next/navigation' +import { CMS_CACHE_TAG } from '@/lib/cms-cache' const CACHE_PATHS = [ '/sitemap.xml', @@ -45,6 +46,8 @@ function normalizeReturnPath(value: FormDataEntryValue | null) { export async function clearFrontendCache(formData: FormData) { const returnTo = normalizeReturnPath(formData.get('returnTo')) + revalidateTag(CMS_CACHE_TAG) + for (const path of CACHE_PATHS) { revalidatePath(path) } @@ -59,6 +62,7 @@ export async function clearFrontendCache(formData: FormData) { export async function refreshSitemapCache(formData: FormData) { const returnTo = normalizeReturnPath(formData.get('returnTo')) + revalidateTag(CMS_CACHE_TAG) revalidatePath('/sitemap.xml') redirect(`${returnTo}${returnTo.includes('?') ? '&' : '?'}sitemap=refreshed`) diff --git a/src/app/robots.ts b/src/app/robots.ts index 75dcf90..c9f6a56 100644 --- a/src/app/robots.ts +++ b/src/app/robots.ts @@ -1,9 +1,18 @@ import type { MetadataRoute } from 'next' import { getSiteUrl } from '@/lib/seo' +function isPublicEversoloHost(siteUrl: string) { + try { + const hostname = new URL(siteUrl).hostname.toLowerCase() + return hostname === 'eversolo.com' || hostname.endsWith('.eversolo.com') + } catch { + return false + } +} + export default function robots(): MetadataRoute.Robots { const siteUrl = getSiteUrl() - const isProduction = /^https?:\/\/(www\.)?eversolo\./i.test(siteUrl) + const isProduction = isPublicEversoloHost(siteUrl) return { host: siteUrl, diff --git a/src/lib/cms-cache.ts b/src/lib/cms-cache.ts new file mode 100644 index 0000000..029999a --- /dev/null +++ b/src/lib/cms-cache.ts @@ -0,0 +1,14 @@ +import { unstable_cache } from 'next/cache' + +export const CMS_CACHE_TAG = 'cms-public-content' +export const PUBLIC_REVALIDATE_SECONDS = 300 + +export function cacheCmsQuery( + keyParts: string[], + callback: (...args: Args) => Promise, +): (...args: Args) => Promise { + return unstable_cache(callback, ['cms', ...keyParts], { + revalidate: PUBLIC_REVALIDATE_SECONDS, + tags: [CMS_CACHE_TAG], + }) as (...args: Args) => Promise +} diff --git a/src/lib/global-settings.ts b/src/lib/global-settings.ts index 8aa1470..9932687 100644 --- a/src/lib/global-settings.ts +++ b/src/lib/global-settings.ts @@ -1,4 +1,5 @@ import type { Locale } from '@/i18n/config' +import { cacheCmsQuery } from '@/lib/cms-cache' import { getPayloadClient } from '@/lib/payload' import { normalizeUploadUrl } from '@/lib/upload-url' import type { GlobalSetting, Media } from '@/payload-types' @@ -187,63 +188,66 @@ function ensureReviewsFooterLink(columns: FooterColumn[], locale: Locale): Foote ) } -export async function getSiteChromeSettings(locale: Locale): Promise { - const payload = await getPayloadClient() - const settings = (await payload.findGlobal({ - slug: 'globalSettings', - locale, - depth: 2, - })) as GlobalSetting +export const getSiteChromeSettings = cacheCmsQuery<[Locale], SiteChromeSettings>( + ['site-chrome-settings'], + async (locale) => { + const payload = await getPayloadClient() + const settings = (await payload.findGlobal({ + slug: 'globalSettings', + locale, + depth: 2, + })) as GlobalSetting - const fallback = fallbackSettings(locale) + const fallback = fallbackSettings(locale) - const footerColumns = - settings.footer?.columns - ?.map((column) => ({ - title: column.title || '', - items: - column.items?.map((item) => ({ - forceDocumentNavigation: shouldForceDocumentNavigation(ensureInternalHref(item.href, locale, fallback.appLink)), - href: ensureInternalHref(item.href, locale, fallback.appLink), - label: item.label, - external: item.external, - openInNewTab: item.openInNewTab, - })) || [], - })) - .filter((column) => column.title || column.items.length) || fallback.footerColumns - - return { - appLink: ensureInternalHref(settings.navigation?.app?.href, locale, fallback.appLink), - appLabel: cmsValue(settings.navigation?.app?.label, fallback.appLabel), - appOpenInNewTab: settings.navigation?.app?.openInNewTab ?? true, - footerColumns: ensureReviewsFooterLink(footerColumns, locale), - footerCopyright: cmsValue(settings.footer?.copyright, fallback.footerCopyright), - legalLinks: - settings.footer?.legalLinks?.map((item) => ({ - forceDocumentNavigation: shouldForceDocumentNavigation(ensureInternalHref(item.href, locale, '/')), - href: ensureInternalHref(item.href, locale, '/'), - label: item.label, - })) || fallback.legalLinks, - logoUrl: getMediaUrl(settings.site?.logoLight) ?? fallback.logoUrl, - navItems: - settings.navigation?.primary?.map((item) => ({ - forceDocumentNavigation: shouldForceDocumentNavigation(ensureInternalHref(item.href, locale, '/')), - href: ensureInternalHref(item.href, locale, '/'), - label: item.label, - external: item.external, - openInNewTab: item.openInNewTab, - })) || fallback.navItems, - socialLinks: - settings.social - ?.map((item) => ({ - iconKey: normalizeSocialIconKey(item.platform, item.iconKey), - platform: item.platform, - url: item.url, + const footerColumns = + settings.footer?.columns + ?.map((column) => ({ + title: column.title || '', + items: + column.items?.map((item) => ({ + forceDocumentNavigation: shouldForceDocumentNavigation(ensureInternalHref(item.href, locale, fallback.appLink)), + href: ensureInternalHref(item.href, locale, fallback.appLink), + label: item.label, + external: item.external, + openInNewTab: item.openInNewTab, + })) || [], })) - .filter((item) => item.platform && item.url) || fallback.socialLinks, - seoDescription: cmsValue(settings.seoDefaults?.description, fallback.seoDescription), - seoTitleSuffix: cmsValue(settings.seoDefaults?.titleSuffix, fallback.seoTitleSuffix), - siteName: cmsValue(settings.site?.name, fallback.siteName), - siteTagline: cmsValue(settings.site?.tagline, fallback.siteTagline), - } -} + .filter((column) => column.title || column.items.length) || fallback.footerColumns + + return { + appLink: ensureInternalHref(settings.navigation?.app?.href, locale, fallback.appLink), + appLabel: cmsValue(settings.navigation?.app?.label, fallback.appLabel), + appOpenInNewTab: settings.navigation?.app?.openInNewTab ?? true, + footerColumns: ensureReviewsFooterLink(footerColumns, locale), + footerCopyright: cmsValue(settings.footer?.copyright, fallback.footerCopyright), + legalLinks: + settings.footer?.legalLinks?.map((item) => ({ + forceDocumentNavigation: shouldForceDocumentNavigation(ensureInternalHref(item.href, locale, '/')), + href: ensureInternalHref(item.href, locale, '/'), + label: item.label, + })) || fallback.legalLinks, + logoUrl: getMediaUrl(settings.site?.logoLight) ?? fallback.logoUrl, + navItems: + settings.navigation?.primary?.map((item) => ({ + forceDocumentNavigation: shouldForceDocumentNavigation(ensureInternalHref(item.href, locale, '/')), + href: ensureInternalHref(item.href, locale, '/'), + label: item.label, + external: item.external, + openInNewTab: item.openInNewTab, + })) || fallback.navItems, + socialLinks: + settings.social + ?.map((item) => ({ + iconKey: normalizeSocialIconKey(item.platform, item.iconKey), + platform: item.platform, + url: item.url, + })) + .filter((item) => item.platform && item.url) || fallback.socialLinks, + seoDescription: cmsValue(settings.seoDefaults?.description, fallback.seoDescription), + seoTitleSuffix: cmsValue(settings.seoDefaults?.titleSuffix, fallback.seoTitleSuffix), + siteName: cmsValue(settings.site?.name, fallback.siteName), + siteTagline: cmsValue(settings.site?.tagline, fallback.siteTagline), + } + }, +) diff --git a/src/lib/site-product-menu.ts b/src/lib/site-product-menu.ts index aebb198..4de5b7b 100644 --- a/src/lib/site-product-menu.ts +++ b/src/lib/site-product-menu.ts @@ -1,4 +1,5 @@ import type { Locale } from '@/i18n/config' +import { cacheCmsQuery } from '@/lib/cms-cache' import { getPayloadClient } from '@/lib/payload' import { getProductSeriesDefault } from '@/lib/product-series-defaults' import { VISIBLE_PRODUCT_STATUS } from '@/lib/product-status' @@ -74,50 +75,53 @@ function getCategorySeries( } } -export async function getSiteProductMenuData(locale: Locale): Promise { - const payload = await getPayloadClient() - const result = await payload.find({ - collection: 'products', - depth: 1, - limit: 100, - locale, - sort: 'order', - where: { - and: [ - { docLocale: { equals: locale } }, - { status: { equals: VISIBLE_PRODUCT_STATUS } }, - { name: { exists: true } }, - ], - }, - }) - const groups = new Map() +export const getSiteProductMenuData = cacheCmsQuery<[Locale], SiteProductMenuSeries[]>( + ['site-product-menu'], + async (locale) => { + const payload = await getPayloadClient() + const result = await payload.find({ + collection: 'products', + depth: 1, + limit: 100, + locale, + sort: 'order', + where: { + and: [ + { docLocale: { equals: locale } }, + { status: { equals: VISIBLE_PRODUCT_STATUS } }, + { name: { exists: true } }, + ], + }, + }) + const groups = new Map() - for (const product of result.docs) { - const series = getCategorySeries(product.category, product.slug, locale) - const existing = groups.get(series.id) || { - id: series.id, - order: series.order, - products: [], - title: series.title, + for (const product of result.docs) { + const series = getCategorySeries(product.category, product.slug, locale) + const existing = groups.get(series.id) || { + id: series.id, + order: series.order, + products: [], + title: series.title, + } + + existing.products.push({ + forceDocumentNavigation: isTemplateProduct(product.template), + href: `/${locale}/products/${product.slug}`, + id: product.id, + imageAlt: product.name || product.model, + imageUrl: getProductImage(product), + model: product.model || product.name, + name: product.name || product.model, + order: typeof product.order === 'number' ? product.order : 0, + }) + groups.set(series.id, existing) } - existing.products.push({ - forceDocumentNavigation: isTemplateProduct(product.template), - href: `/${locale}/products/${product.slug}`, - id: product.id, - imageAlt: product.name || product.model, - imageUrl: getProductImage(product), - model: product.model || product.name, - name: product.name || product.model, - order: typeof product.order === 'number' ? product.order : 0, - }) - groups.set(series.id, existing) - } - - return [...groups.values()] - .map((group) => ({ - ...group, - products: group.products.sort((left, right) => left.order - right.order), - })) - .sort((left, right) => left.order - right.order) -} + return [...groups.values()] + .map((group) => ({ + ...group, + products: group.products.sort((left, right) => left.order - right.order), + })) + .sort((left, right) => left.order - right.order) + }, +) diff --git a/src/lib/static-file-response.ts b/src/lib/static-file-response.ts new file mode 100644 index 0000000..5447d03 --- /dev/null +++ b/src/lib/static-file-response.ts @@ -0,0 +1,171 @@ +import { createReadStream } from 'node:fs' +import fs from 'node:fs/promises' +import path from 'node:path' +import { Readable } from 'node:stream' + +const IMMUTABLE_CACHE_CONTROL = 'public, max-age=31536000, immutable' + +type ServeStaticFileOptions = { + contentDisposition?: (filePath: string) => string | null + contentType: (filePath: string) => string + headOnly?: boolean + parts: string[] + request: Request + root: string +} + +type ByteRange = { + end: number + start: number +} + +async function resolveExistingFile(root: string, requestedPath: string) { + const normalizedRoot = path.resolve(root) + const resolved = path.resolve(path.join(root, requestedPath)) + + if (!resolved.startsWith(`${normalizedRoot}${path.sep}`) && resolved !== normalizedRoot) { + return null + } + + try { + await fs.access(resolved) + return resolved + } catch { + const dir = path.dirname(resolved) + const target = path.basename(resolved).toLowerCase() + + try { + const entries = await fs.readdir(dir) + const match = entries.find((entry) => entry.toLowerCase() === target) + return match ? path.join(dir, match) : null + } catch { + return null + } + } +} + +function parseRangeHeader(value: string | null, size: number): ByteRange | 'invalid' | null { + if (!value) return null + const match = /^bytes=(\d*)-(\d*)$/.exec(value.trim()) + if (!match) return 'invalid' + + const [, rawStart, rawEnd] = match + if (!rawStart && !rawEnd) return 'invalid' + + if (!rawStart) { + const suffixLength = Number(rawEnd) + if (!Number.isFinite(suffixLength) || suffixLength <= 0) return 'invalid' + return { + end: size - 1, + start: Math.max(size - suffixLength, 0), + } + } + + const start = Number(rawStart) + const end = rawEnd ? Number(rawEnd) : size - 1 + + if (!Number.isFinite(start) || !Number.isFinite(end) || start < 0 || end < start || start >= size) { + return 'invalid' + } + + return { + end: Math.min(end, size - 1), + start, + } +} + +function toWebStream(filePath: string, range?: ByteRange) { + const stream = createReadStream(filePath, range) + return Readable.toWeb(stream) as ReadableStream +} + +function createBaseHeaders(options: { + contentDisposition?: string | null + contentType: string + etag: string + lastModified: string +}) { + const headers = new Headers({ + 'Accept-Ranges': 'bytes', + 'Cache-Control': IMMUTABLE_CACHE_CONTROL, + 'Content-Type': options.contentType, + ETag: options.etag, + 'Last-Modified': options.lastModified, + }) + + if (options.contentDisposition) { + headers.set('Content-Disposition', options.contentDisposition) + } + + return headers +} + +function isFresh(request: Request, etag: string, mtimeMs: number) { + const ifNoneMatch = request.headers.get('if-none-match') + if (ifNoneMatch?.split(',').map((value) => value.trim()).includes(etag)) return true + + const ifModifiedSince = request.headers.get('if-modified-since') + if (!ifModifiedSince) return false + + const since = Date.parse(ifModifiedSince) + return Number.isFinite(since) && since >= Math.floor(mtimeMs) +} + +export async function serveStaticFile({ + contentDisposition, + contentType, + headOnly = false, + parts, + request, + root, +}: ServeStaticFileOptions) { + const requestedPath = parts.join('/') + const filePath = await resolveExistingFile(root, requestedPath) + if (!filePath) return new Response('Not Found', { status: 404 }) + + const stats = await fs.stat(filePath) + if (!stats.isFile()) return new Response('Not Found', { status: 404 }) + + const etag = `W/"${stats.size}-${Math.floor(stats.mtimeMs)}"` + const lastModified = stats.mtime.toUTCString() + const headers = createBaseHeaders({ + contentDisposition: contentDisposition?.(filePath), + contentType: contentType(filePath), + etag, + lastModified, + }) + const range = parseRangeHeader(request.headers.get('range'), stats.size) + + if (range === 'invalid') { + return new Response(null, { + headers: { + 'Content-Range': `bytes */${stats.size}`, + }, + status: 416, + }) + } + + if (!range && isFresh(request, etag, stats.mtimeMs)) { + return new Response(null, { + headers, + status: 304, + }) + } + + if (range) { + headers.set('Content-Length', String(range.end - range.start + 1)) + headers.set('Content-Range', `bytes ${range.start}-${range.end}/${stats.size}`) + + return new Response(headOnly ? null : toWebStream(filePath, range), { + headers, + status: 206, + }) + } + + headers.set('Content-Length', String(stats.size)) + + return new Response(headOnly ? null : toWebStream(filePath), { + headers, + status: 200, + }) +} diff --git a/src/migrations/20260429_070000_add_public_query_indexes.ts b/src/migrations/20260429_070000_add_public_query_indexes.ts new file mode 100644 index 0000000..e93294f --- /dev/null +++ b/src/migrations/20260429_070000_add_public_query_indexes.ts @@ -0,0 +1,159 @@ +import { MigrateDownArgs, MigrateUpArgs, sql } from '@payloadcms/db-postgres' + +export async function up({ db }: MigrateUpArgs): Promise { + await db.execute(sql` + DO $$ + BEGIN + IF to_regclass('public.products') IS NOT NULL + AND EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = 'public' AND table_name = 'products' AND column_name = 'doc_locale') + AND EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = 'public' AND table_name = 'products' AND column_name = 'status') + AND EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = 'public' AND table_name = 'products' AND column_name = 'order') + THEN + EXECUTE 'CREATE INDEX IF NOT EXISTS "products_doc_locale_status_order_idx" ON "products" USING btree ("doc_locale", "status", "order")'; + END IF; + + IF to_regclass('public.products') IS NOT NULL + AND EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = 'public' AND table_name = 'products' AND column_name = 'doc_locale') + AND EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = 'public' AND table_name = 'products' AND column_name = 'model') + THEN + EXECUTE 'CREATE INDEX IF NOT EXISTS "products_doc_locale_model_idx" ON "products" USING btree ("doc_locale", "model")'; + END IF; + + IF to_regclass('public.products_locales') IS NOT NULL + AND EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = 'public' AND table_name = 'products_locales' AND column_name = '_locale') + AND EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = 'public' AND table_name = 'products_locales' AND column_name = 'slug') + AND EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = 'public' AND table_name = 'products_locales' AND column_name = '_parent_id') + THEN + EXECUTE 'CREATE INDEX IF NOT EXISTS "products_locales_locale_slug_parent_idx" ON "products_locales" USING btree ("_locale", "slug", "_parent_id")'; + END IF; + + IF to_regclass('public.product_categories') IS NOT NULL + AND EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = 'public' AND table_name = 'product_categories' AND column_name = 'doc_locale') + AND EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = 'public' AND table_name = 'product_categories' AND column_name = 'order') + THEN + EXECUTE 'CREATE INDEX IF NOT EXISTS "product_categories_doc_locale_order_idx" ON "product_categories" USING btree ("doc_locale", "order")'; + END IF; + + IF to_regclass('public.downloads') IS NOT NULL + AND EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = 'public' AND table_name = 'downloads' AND column_name = 'doc_locale') + AND EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = 'public' AND table_name = 'downloads' AND column_name = 'status') + AND EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = 'public' AND table_name = 'downloads' AND column_name = 'visibility_show_in_downloads_center') + AND EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = 'public' AND table_name = 'downloads' AND column_name = 'release_date') + THEN + EXECUTE 'CREATE INDEX IF NOT EXISTS "downloads_doc_locale_status_visibility_release_idx" ON "downloads" USING btree ("doc_locale", "status", "visibility_show_in_downloads_center", "release_date" DESC)'; + END IF; + + IF to_regclass('public.downloads_rels') IS NOT NULL + AND EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = 'public' AND table_name = 'downloads_rels' AND column_name = 'products_id') + AND EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = 'public' AND table_name = 'downloads_rels' AND column_name = 'parent_id') + THEN + EXECUTE 'CREATE INDEX IF NOT EXISTS "downloads_rels_products_parent_idx" ON "downloads_rels" USING btree ("products_id", "parent_id")'; + END IF; + + IF to_regclass('public.news') IS NOT NULL + AND EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = 'public' AND table_name = 'news' AND column_name = 'doc_locale') + AND EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = 'public' AND table_name = 'news' AND column_name = 'published_at') + AND EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = 'public' AND table_name = 'news' AND column_name = 'updated_at') + THEN + EXECUTE 'CREATE INDEX IF NOT EXISTS "news_doc_locale_published_idx" ON "news" USING btree ("doc_locale", "published_at" DESC, "updated_at" DESC)'; + END IF; + + IF to_regclass('public.news_locales') IS NOT NULL + AND EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = 'public' AND table_name = 'news_locales' AND column_name = '_locale') + AND EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = 'public' AND table_name = 'news_locales' AND column_name = 'slug') + AND EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = 'public' AND table_name = 'news_locales' AND column_name = '_parent_id') + THEN + EXECUTE 'CREATE INDEX IF NOT EXISTS "news_locales_locale_slug_parent_idx" ON "news_locales" USING btree ("_locale", "slug", "_parent_id")'; + END IF; + + IF to_regclass('public.news_rels') IS NOT NULL + AND EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = 'public' AND table_name = 'news_rels' AND column_name = 'products_id') + AND EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = 'public' AND table_name = 'news_rels' AND column_name = 'parent_id') + THEN + EXECUTE 'CREATE INDEX IF NOT EXISTS "news_rels_products_parent_idx" ON "news_rels" USING btree ("products_id", "parent_id")'; + END IF; + + IF to_regclass('public.dealers') IS NOT NULL + AND EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = 'public' AND table_name = 'dealers' AND column_name = 'doc_locale') + AND EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = 'public' AND table_name = 'dealers' AND column_name = 'is_authorized') + AND EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = 'public' AND table_name = 'dealers' AND column_name = 'order') + THEN + EXECUTE 'CREATE INDEX IF NOT EXISTS "dealers_doc_locale_authorized_order_idx" ON "dealers" USING btree ("doc_locale", "is_authorized", "order")'; + END IF; + + IF to_regclass('public.faqs') IS NOT NULL + AND EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = 'public' AND table_name = 'faqs' AND column_name = 'doc_locale') + AND EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = 'public' AND table_name = 'faqs' AND column_name = 'group') + AND EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = 'public' AND table_name = 'faqs' AND column_name = 'order') + THEN + EXECUTE 'CREATE INDEX IF NOT EXISTS "faqs_doc_locale_group_order_idx" ON "faqs" USING btree ("doc_locale", "group", "order")'; + END IF; + + IF to_regclass('public.faqs_locales') IS NOT NULL + AND EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = 'public' AND table_name = 'faqs_locales' AND column_name = '_locale') + AND EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = 'public' AND table_name = 'faqs_locales' AND column_name = 'question') + AND EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = 'public' AND table_name = 'faqs_locales' AND column_name = '_parent_id') + THEN + EXECUTE 'CREATE INDEX IF NOT EXISTS "faqs_locales_locale_question_parent_idx" ON "faqs_locales" USING btree ("_locale", "question", "_parent_id")'; + END IF; + + IF to_regclass('public.product_videos') IS NOT NULL + AND EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = 'public' AND table_name = 'product_videos' AND column_name = 'doc_locale') + AND EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = 'public' AND table_name = 'product_videos' AND column_name = 'product_id') + AND EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = 'public' AND table_name = 'product_videos' AND column_name = 'status') + AND EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = 'public' AND table_name = 'product_videos' AND column_name = 'sort_order') + AND EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = 'public' AND table_name = 'product_videos' AND column_name = 'published_at') + THEN + EXECUTE 'CREATE INDEX IF NOT EXISTS "product_videos_doc_locale_product_status_sort_idx" ON "product_videos" USING btree ("doc_locale", "product_id", "status", "sort_order", "published_at" DESC)'; + END IF; + + IF to_regclass('public.product_reviews') IS NOT NULL + AND EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = 'public' AND table_name = 'product_reviews' AND column_name = 'doc_locale') + AND EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = 'public' AND table_name = 'product_reviews' AND column_name = 'product_id') + AND EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = 'public' AND table_name = 'product_reviews' AND column_name = 'status') + AND EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = 'public' AND table_name = 'product_reviews' AND column_name = 'sort_order') + AND EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = 'public' AND table_name = 'product_reviews' AND column_name = 'published_at') + THEN + EXECUTE 'CREATE INDEX IF NOT EXISTS "product_reviews_doc_locale_product_status_sort_idx" ON "product_reviews" USING btree ("doc_locale", "product_id", "status", "sort_order", "published_at" DESC)'; + END IF; + + IF to_regclass('public.support_tutorial_videos') IS NOT NULL + AND EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = 'public' AND table_name = 'support_tutorial_videos' AND column_name = 'doc_locale') + AND EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = 'public' AND table_name = 'support_tutorial_videos' AND column_name = 'status') + AND EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = 'public' AND table_name = 'support_tutorial_videos' AND column_name = 'sort_order') + AND EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = 'public' AND table_name = 'support_tutorial_videos' AND column_name = 'published_at') + THEN + EXECUTE 'CREATE INDEX IF NOT EXISTS "support_tutorial_videos_doc_locale_status_sort_idx" ON "support_tutorial_videos" USING btree ("doc_locale", "status", "sort_order", "published_at" DESC)'; + END IF; + + IF to_regclass('public.redirects') IS NOT NULL + AND EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = 'public' AND table_name = 'redirects' AND column_name = 'enabled') + AND EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = 'public' AND table_name = 'redirects' AND column_name = 'locale') + AND EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = 'public' AND table_name = 'redirects' AND column_name = 'from') + THEN + EXECUTE 'CREATE INDEX IF NOT EXISTS "redirects_enabled_locale_from_idx" ON "redirects" USING btree ("enabled", "locale", "from")'; + END IF; + END $$; + `) +} + +export async function down({ db }: MigrateDownArgs): Promise { + await db.execute(sql` + DROP INDEX IF EXISTS "redirects_enabled_locale_from_idx"; + DROP INDEX IF EXISTS "support_tutorial_videos_doc_locale_status_sort_idx"; + DROP INDEX IF EXISTS "product_reviews_doc_locale_product_status_sort_idx"; + DROP INDEX IF EXISTS "product_videos_doc_locale_product_status_sort_idx"; + DROP INDEX IF EXISTS "faqs_locales_locale_question_parent_idx"; + DROP INDEX IF EXISTS "faqs_doc_locale_group_order_idx"; + DROP INDEX IF EXISTS "dealers_doc_locale_authorized_order_idx"; + DROP INDEX IF EXISTS "news_rels_products_parent_idx"; + DROP INDEX IF EXISTS "news_locales_locale_slug_parent_idx"; + DROP INDEX IF EXISTS "news_doc_locale_published_idx"; + DROP INDEX IF EXISTS "downloads_rels_products_parent_idx"; + DROP INDEX IF EXISTS "downloads_doc_locale_status_visibility_release_idx"; + DROP INDEX IF EXISTS "product_categories_doc_locale_order_idx"; + DROP INDEX IF EXISTS "products_locales_locale_slug_parent_idx"; + DROP INDEX IF EXISTS "products_doc_locale_model_idx"; + DROP INDEX IF EXISTS "products_doc_locale_status_order_idx"; + `) +} diff --git a/src/migrations/index.ts b/src/migrations/index.ts index e8f1432..156ab52 100644 --- a/src/migrations/index.ts +++ b/src/migrations/index.ts @@ -40,6 +40,7 @@ import * as migration_20260428_043000_add_sitemap_settings from './20260428_0430 import * as migration_20260428_044000_add_homepage_hero_slide_visibility_overlay from './20260428_044000_add_homepage_hero_slide_visibility_overlay'; import * as migration_20260428_045000_add_faq_page_global from './20260428_045000_add_faq_page_global'; import * as migration_20260428_045500_add_faq_page_support_path from './20260428_045500_add_faq_page_support_path'; +import * as migration_20260429_070000_add_public_query_indexes from './20260429_070000_add_public_query_indexes'; export const migrations = [ { @@ -252,4 +253,9 @@ export const migrations = [ down: migration_20260428_045500_add_faq_page_support_path.down, name: '20260428_045500_add_faq_page_support_path' }, + { + up: migration_20260429_070000_add_public_query_indexes.up, + down: migration_20260429_070000_add_public_query_indexes.down, + name: '20260429_070000_add_public_query_indexes' + }, ];