From 0a2b1816f932cdd6d77d120df5df37fced39bc43 Mon Sep 17 00:00:00 2001 From: Asapteejo Date: Tue, 15 Sep 2026 23:58:39 +0100 Subject: [PATCH] perf: run functions in Dublin and stop re-querying tenant data per render MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Production functions ran in iad1 (Washington) while Supabase is in eu-west-1 (Ireland), so every query crossed the Atlantic. Measured warm TTFB on tenant pages was 7.2-9.3s against 21-29 queries per request — roughly 300ms per query, because DATABASE_URL uses connection_limit=1 and the queries therefore run one at a time. - Pin functions to dub1 (same region as the database). - Memoize tenant context, branding and published site content per render with React cache(): the layout, generateMetadata, the public shell and the page each resolved them separately (3-5 duplicate queries/render). - Cache published branding, published site content (60s) and the marketer leaderboard (5min) across requests, keyed and tagged by companyId. The branding/site-content publish routes already revalidate that tag. Measured prod-equivalent queries per request (local trace): tenant / 29 -> 10 tenant /about 20 -> 5 tenant /properties 26 -> 7 tenant /contact 21 -> 6 The admin draft branding read stays uncached across requests: saving a draft does not revalidate the tag, so a shared cache would show operators stale drafts. Tenant isolation: cache keys and tags include companyId, and React cache() is request-scoped. The cached leaderboard is computed with a context carrying only companyId — findManyForTenant skips the company filter for super admins, so passing a viewer context through could have cached one tenant's cross-company aggregate and served it to all of that tenant's visitors. Co-Authored-By: Claude Opus 5 --- src/lib/tenancy/context.ts | 20 ++++- src/modules/branding/service.ts | 102 +++++++++++++++++------- src/modules/cms/site-content-service.ts | 85 ++++++++++++++------ src/modules/team/performance.ts | 46 +++++++++-- vercel.json | 3 +- 5 files changed, 196 insertions(+), 60 deletions(-) diff --git a/src/lib/tenancy/context.ts b/src/lib/tenancy/context.ts index 2bfee26..85d5baf 100644 --- a/src/lib/tenancy/context.ts +++ b/src/lib/tenancy/context.ts @@ -1,4 +1,5 @@ import type { AppRole } from "@prisma/client"; +import { cache } from "react"; import { headers } from "next/headers"; import { cookies } from "next/headers"; import { redirect } from "next/navigation"; @@ -126,7 +127,24 @@ function getHostResolution(host: string | null) { }; } -export async function resolveTenantContext( +/** + * Memoized per server render: a page, its layout(s), generateMetadata and + * nested server components each call this, and without memoization every call + * re-ran the company lookups. React's cache() is scoped to a single request, + * and the result depends only on that request's host/cookies, so it cannot + * leak one tenant's context into another request. + */ +const resolveTenantContextCached = cache(resolveTenantContextUncached); + +export function resolveTenantContext( + area: "marketing" | "portal" | "admin" | "superadmin" = "marketing", +): Promise { + // Normalize the default so `resolveTenantContext()` and + // `resolveTenantContext("marketing")` share one cache entry. + return resolveTenantContextCached(area); +} + +async function resolveTenantContextUncached( area: "marketing" | "portal" | "admin" | "superadmin" = "marketing", ): Promise { const requestHeaders = await headers(); diff --git a/src/modules/branding/service.ts b/src/modules/branding/service.ts index beda16a..5bd707d 100644 --- a/src/modules/branding/service.ts +++ b/src/modules/branding/service.ts @@ -1,4 +1,6 @@ import { Prisma } from "@prisma/client"; +import { unstable_cache } from "next/cache"; +import { cache } from "react"; import { writeAuditLog } from "@/lib/audit/service"; import { prisma } from "@/lib/db/prisma"; @@ -106,17 +108,14 @@ export function resolveTenantBrandingPresentation(input: { }; } -export async function getTenantBrandingState(context: TenantContext) { - if (!featureFlags.hasDatabase || !context.companyId) { - return resolveBrandingState({ - published: defaultTenantBranding, - draft: defaultTenantBranding, - publishedAt: null, - }); - } - - const company = await prisma.company.findUnique({ - where: { id: context.companyId }, +/** + * Full branding row including the unpublished draft — admin editor only. + * Request-scoped (React cache) but never cached across requests: saving a draft + * does not revalidate a tag, so a shared cache would show admins stale drafts. + */ +const loadCompanyBrandingRow = cache((companyId: string) => + prisma.company.findUnique({ + where: { id: companyId }, select: { logoUrl: true, primaryColor: true, @@ -129,7 +128,52 @@ export async function getTenantBrandingState(context: TenantContext) { }, }, }, - }); + }), +); + +/** + * Published branding only — what every public page, metadata and app shell + * renders. The layout, generateMetadata, the public shell and the page each ask + * for it; before this they issued 3-5 identical queries per render. + * - unstable_cache (cross-request, 60s) is keyed and tagged by companyId so a + * tenant can only ever read its own row; publishing branding or site content + * revalidates `tenant-presentation:` immediately. + * - React cache() dedupes within one render. + * The selected fields are plain JSON (no Dates), so they survive serialization. + */ +const loadPublishedBrandingRow = cache((companyId: string) => + unstable_cache( + () => + prisma.company.findUnique({ + where: { id: companyId }, + select: { + name: true, + logoUrl: true, + primaryColor: true, + accentColor: true, + siteSetting: { + select: { + companyName: true, + publishedBrandingConfig: true, + }, + }, + }, + }), + ["public-tenant-branding", companyId], + { revalidate: 60, tags: [`tenant-presentation:${companyId}`] }, + )(), +); + +export async function getTenantBrandingState(context: TenantContext) { + if (!featureFlags.hasDatabase || !context.companyId) { + return resolveBrandingState({ + published: defaultTenantBranding, + draft: defaultTenantBranding, + publishedAt: null, + }); + } + + const company = await loadCompanyBrandingRow(context.companyId); const fallback = buildFallbackBranding({ logoUrl: company?.logoUrl, @@ -153,8 +197,22 @@ export async function getTenantBrandingState(context: TenantContext) { export async function getPublishedTenantBranding(context: TenantContext) { try { - const state = await getTenantBrandingState(context); - return state.published; + if (!featureFlags.hasDatabase || !context.companyId) { + return resolveBrandingState({ published: defaultTenantBranding }).published; + } + + // Same derivation as getTenantBrandingState().published, without reading + // the draft, so it can come from the shared published-branding cache. + const company = await loadPublishedBrandingRow(context.companyId); + const { published } = resolveBrandingState({ + published: company?.siteSetting?.publishedBrandingConfig as Partial | null | undefined, + fallback: buildFallbackBranding({ + logoUrl: company?.logoUrl, + primaryColor: company?.primaryColor, + accentColor: company?.accentColor, + }), + }); + return resolveTenantBrandingAssetUrls(published); } catch (error) { logError("Published tenant branding lookup failed; using default branding.", { route: "public-marketing", @@ -182,21 +240,7 @@ async function loadTenantPresentation(context: TenantContext) { }; } - const company = await prisma.company.findUnique({ - where: { id: context.companyId }, - select: { - name: true, - logoUrl: true, - primaryColor: true, - accentColor: true, - siteSetting: { - select: { - companyName: true, - publishedBrandingConfig: true, - }, - }, - }, - }); + const company = await loadPublishedBrandingRow(context.companyId); const presentation = resolveTenantBrandingPresentation({ companyName: company?.siteSetting?.companyName ?? company?.name ?? fallbackName, diff --git a/src/modules/cms/site-content-service.ts b/src/modules/cms/site-content-service.ts index e652e95..777d027 100644 --- a/src/modules/cms/site-content-service.ts +++ b/src/modules/cms/site-content-service.ts @@ -1,4 +1,6 @@ import { Prisma } from "@prisma/client"; +import { unstable_cache } from "next/cache"; +import { cache } from "react"; import { writeAuditLog } from "@/lib/audit/service"; import { prisma } from "@/lib/db/prisma"; @@ -22,6 +24,12 @@ export type TenantSiteContentState = { draft: StoredSiteContent; published: StoredSiteContent; publishedAt: string | null; + /** + * True when the content columns could not be read — in practice a database + * that is behind the deployed code (pending migration). The editor renders + * read-only with an explanatory banner rather than throwing. + */ + unavailable?: boolean; }; function asStored(value: Prisma.JsonValue | null | undefined): StoredSiteContent { @@ -51,26 +59,41 @@ export async function getTenantSiteContentState( return { draft: {}, published: {}, publishedAt: null }; } - const settings = await prisma.siteSettings.findUnique({ - where: { companyId: context.companyId }, - select: { - draftSiteContent: true, - publishedSiteContent: true, - siteContentPublishedAt: true, - }, - }); + try { + const settings = await prisma.siteSettings.findUnique({ + where: { companyId: context.companyId }, + select: { + draftSiteContent: true, + publishedSiteContent: true, + siteContentPublishedAt: true, + }, + }); + + const published = asStored(settings?.publishedSiteContent); + // Before the tenant has edited anything, the draft mirrors the published copy. + const draft = settings?.draftSiteContent ? asStored(settings.draftSiteContent) : published; + + return { + draft, + published, + publishedAt: settings?.siteContentPublishedAt + ? settings.siteContentPublishedAt.toISOString() + : null, + unavailable: false, + }; + } catch (error) { + // A database that is behind the deployed code (missing migration → + // Prisma P2022 "column does not exist") must not 500 the settings page. + // Return empty content flagged as unavailable so the editor renders with + // fallback copy and an explanatory banner instead of an error screen. + logError("Tenant site content state lookup failed; rendering editor as unavailable.", { + route: "/admin/settings/site-content", + companyId: context.companyId, + ...buildSafeErrorLogContext(error), + }); - const published = asStored(settings?.publishedSiteContent); - // Before the tenant has edited anything, the draft mirrors the published copy. - const draft = settings?.draftSiteContent ? asStored(settings.draftSiteContent) : published; - - return { - draft, - published, - publishedAt: settings?.siteContentPublishedAt - ? settings.siteContentPublishedAt.toISOString() - : null, - }; + return { draft: {}, published: {}, publishedAt: null, unavailable: true }; + } } export type TenantPublicContact = { @@ -113,6 +136,25 @@ export async function getPublicTenantContact( } } +// Published content is company-level, identical for every visitor, and changes +// only when an admin publishes. Two layers: +// - unstable_cache (cross-request, 60s) keyed and tagged by companyId, so one +// tenant can never be served another's content; the publish route +// invalidates `tenant-presentation:` immediately. +// - React cache() dedupes the lookup within a single render (layout, shell, +// metadata and page all read it). +const loadPublishedSiteContentRow = cache((companyId: string) => + unstable_cache( + () => + prisma.siteSettings.findUnique({ + where: { companyId }, + select: { publishedSiteContent: true }, + }), + ["public-site-content", companyId], + { revalidate: 60, tags: [`tenant-presentation:${companyId}`] }, + )(), +); + /** Public render entry point. Never throws — returns null so callers fall back. */ export async function getPublishedSiteContent( context: TenantContext, @@ -122,10 +164,7 @@ export async function getPublishedSiteContent( } try { - const settings = await prisma.siteSettings.findUnique({ - where: { companyId: context.companyId }, - select: { publishedSiteContent: true }, - }); + const settings = await loadPublishedSiteContentRow(context.companyId); return settings?.publishedSiteContent ? asStored(settings.publishedSiteContent) : null; } catch (error) { logError("Published tenant site content lookup failed; using fallback copy.", { diff --git a/src/modules/team/performance.ts b/src/modules/team/performance.ts index 5ffe961..8eb07a9 100644 --- a/src/modules/team/performance.ts +++ b/src/modules/team/performance.ts @@ -1,5 +1,6 @@ import { Prisma } from "@prisma/client"; import { subDays } from "date-fns"; +import { unstable_cache } from "next/cache"; import { prisma } from "@/lib/db/prisma"; import { featureFlags } from "@/lib/env"; @@ -780,13 +781,46 @@ export async function getTenantMarketerLeaderboard( limit = 3, period: Extract = "MONTHLY", ): Promise { - const entries = await getTenantMarketerPerformanceEntries(context, now, { - includeInactive: false, - includeUnpublished: false, - period, - }); + if (!featureFlags.hasDatabase || !context.companyId) { + return []; + } + const companyId = context.companyId; - return entries.filter((entry) => entry.score > 0).slice(0, limit).map(toPublicMarketerPerformanceEntry); + // The public leaderboard aggregates ~6 activity tables and is identical for + // every visitor of a tenant, so it is cached across requests for 5 minutes. + // + // Tenant isolation: the computation runs against a context carrying ONLY the + // companyId. Passing the viewer's context through would be unsafe — for a + // super admin, findManyForTenant skips the companyId filter, and that + // cross-tenant result would then be cached under this tenant's key and served + // to all of its visitors. + const publicContext: TenantContext = { + userId: null, + companyId, + companySlug: context.companySlug, + branchId: null, + roles: [], + isSuperAdmin: false, + host: null, + resolutionSource: context.resolutionSource, + }; + // Key on the UTC day, not the window start: WEEKLY is a rolling window whose + // start changes every millisecond and would never produce a cache hit. The day + // bucket rolls over at month boundaries for MONTHLY; the TTL bounds staleness. + const dayBucket = now.toISOString().slice(0, 10); + + return unstable_cache( + async () => { + const entries = await getTenantMarketerPerformanceEntries(publicContext, now, { + includeInactive: false, + includeUnpublished: false, + period, + }); + return entries.filter((entry) => entry.score > 0).slice(0, limit).map(toPublicMarketerPerformanceEntry); + }, + ["public-marketer-leaderboard", companyId, period, dayBucket, String(limit)], + { revalidate: 300, tags: [`marketer-leaderboard:${companyId}`] }, + )(); } export async function getTenantMarketerPerformanceSummary( diff --git a/vercel.json b/vercel.json index a667db8..3c42773 100644 --- a/vercel.json +++ b/vercel.json @@ -1,4 +1,5 @@ { "$schema": "https://openapi.vercel.sh/vercel.json", - "framework": "nextjs" + "framework": "nextjs", + "regions": ["dub1"] }