Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 19 additions & 1 deletion src/lib/tenancy/context.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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<TenantContext> {
// 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<TenantContext> {
const requestHeaders = await headers();
Expand Down
102 changes: 73 additions & 29 deletions src/modules/branding/service.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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,
Expand All @@ -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:<companyId>` 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,
Expand All @@ -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<TenantBrandingConfig> | 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",
Expand Down Expand Up @@ -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,
Expand Down
85 changes: 62 additions & 23 deletions src/modules/cms/site-content-service.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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 {
Expand Down Expand Up @@ -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 = {
Expand Down Expand Up @@ -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:<companyId>` 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,
Expand All @@ -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.", {
Expand Down
46 changes: 40 additions & 6 deletions src/modules/team/performance.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -780,13 +781,46 @@ export async function getTenantMarketerLeaderboard(
limit = 3,
period: Extract<MarketerPerformancePeriod, "WEEKLY" | "MONTHLY"> = "MONTHLY",
): Promise<MarketerPerformanceEntry[]> {
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(
Expand Down
3 changes: 2 additions & 1 deletion vercel.json
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
{
"$schema": "https://openapi.vercel.sh/vercel.json",
"framework": "nextjs"
"framework": "nextjs",
"regions": ["dub1"]
}
Loading