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
12 changes: 5 additions & 7 deletions app/(marketing)/pricing/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,7 @@ import { getPublicStats } from '@/lib/marketing/stats';
import { sampleBriefNumbers } from '@/lib/marketing/handled-sample';
import { getPublishedCaseStudies } from '@/lib/marketing/reputation-server';
import { SwitchingBand } from '@/components/marketing/switching-band';
import { createServiceClient } from '@/lib/supabase/server';
import { getResolvedFeatureTiers } from '@/lib/server/feature-tiers';
import { getPricingFeatureTiers } from '@/lib/marketing/pricing-tiers';
import { FEATURE_CATALOG, FEATURE_SECTIONS } from '@/lib/constants/feature-catalog';
import type { FeatureTier } from '@/lib/constants/feature-catalog';
import { MarketingAeoSection } from '@/components/marketing/marketing-aeo-section';
Expand All @@ -30,16 +29,15 @@ export default async function PricingPage() {
// brief composer over a fixed week) and the admin-published case studies.
// pricing-content.tsx is a client module and can reach none of them itself.
//
// Neither cached reader can reject — each catches its own failure and
// returns zeros or an empty list, which the page then HIDES rather than
// prints — so nothing here rides in settleAll and nothing is orphaned if
// the feature-tier read throws.
// Optional database reads have deadlines. Their public fallbacks stay outside the
// caches, so an outage cannot replace valid counts or stories with a cached
// zero/empty result. The page HIDES those fallbacks rather than printing them.
const [stats, caseStudies, resolved] = await Promise.all([
getPublicStats(),
getPublishedCaseStudies(),
// Build the admin-controlled feature matrix (Off/Free/Basic/Plus per service)
// so the pricing page reflects the Tier & Features admin live.
getResolvedFeatureTiers(createServiceClient()),
getPricingFeatureTiers(),
]);
const matrix = FEATURE_SECTIONS.map((section) => ({
section,
Expand Down
28 changes: 28 additions & 0 deletions lib/marketing/pricing-tiers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import 'server-only';
import { createServiceClient } from '@/lib/supabase/server';
import { resolveFeatureTiers, type FeatureOverrides } from '@/lib/features/tiers';
import type { FeatureTier } from '@/lib/constants/feature-catalog';
import { withPublicReadBudget } from '@/lib/marketing/public-read';

/**
* The public comparison matrix has the existing catalog-default fallback.
* Its deadline is deliberately separate from shared entitlement/admin reads.
* Neither a failed read nor its fallback is cached as live admin configuration.
*/
export async function getPricingFeatureTiers(): Promise<Record<string, FeatureTier>> {
try {
return await withPublicReadBudget(async (signal) => {
const { data, error } = await createServiceClient()
.from('app_settings').select('value').eq('key', 'feature_tiers')
.abortSignal(signal).maybeSingle();
if (error) throw error;
const value = data?.value;
const overrides = value && typeof value === 'object' && !Array.isArray(value)
? value as FeatureOverrides : undefined;
return resolveFeatureTiers(overrides);
});
} catch (err) {
console.error('[marketing-pricing] feature tiers unavailable (using catalog defaults)', err);
return resolveFeatureTiers(undefined);
}
}
31 changes: 31 additions & 0 deletions lib/marketing/public-read.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import 'server-only';

export const PUBLIC_READ_BUDGET_MS = 4_000;

/**
* A deadline for optional public presentation data, never an authorization gate.
* Abort alone cannot bound an SDK token lookup or a transport/body that ignores
* the signal, so the caller also races the complete read. A late result cannot
* turn this rejected read into a successful unstable_cache value.
* This bounds the read callback, not Next's cache backend or an entire page.
*/
export async function withPublicReadBudget<T>(read: (signal: AbortSignal) => PromiseLike<T>): Promise<T> {
const controller = new AbortController();
let timer: ReturnType<typeof setTimeout> | undefined;
const deadline = new Promise<never>((_, reject) => {
timer = setTimeout(() => {
const error = new Error('Optional public data exceeded its read budget');
reject(error);
controller.abort(error);
}, PUBLIC_READ_BUDGET_MS);
});
try {
return await Promise.race([
Promise.resolve().then(() => read(controller.signal)),
deadline,
]);
} finally {
clearTimeout(timer);
controller.abort();
}
}
70 changes: 45 additions & 25 deletions lib/marketing/reputation-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { CASE_STUDIES_CACHE_TAG } from './case-study';
import { unstable_cache } from 'next/cache';
import { createServiceClient } from '@/lib/supabase/server';
import { clampRating, publishedOnly } from '@/lib/marketing/reputation';
import { withPublicReadBudget } from '@/lib/marketing/public-read';

export type PublicTestimonial = {
id: string;
Expand Down Expand Up @@ -37,6 +38,16 @@ export type PublicCaseStudy = {
const TESTIMONIAL_LIMIT = 6;
const CASE_STUDY_LIMIT = 3;

function isCaseStudyRow(value: unknown): boolean {
if (!value || typeof value !== 'object' || Array.isArray(value)) return false;
const row = value as Record<string, unknown>;
return ['id', 'title', 'slug'].every(key => typeof row[key] === 'string')
&& ['customer_name', 'summary', 'result_metric'].every(key => row[key] === null || typeof row[key] === 'string')
&& typeof row.is_published === 'boolean'
// This column is absent before its migration; absence never earns a badge.
&& (row.verified_at === undefined || row.verified_at === null || typeof row.verified_at === 'string');
}

export const getPublishedTestimonials = unstable_cache(
async (): Promise<PublicTestimonial[]> => {
try {
Expand Down Expand Up @@ -64,30 +75,39 @@ export const getPublishedTestimonials = unstable_cache(
{ revalidate: 3600 },
);

export const getPublishedCaseStudies = unstable_cache(
async (): Promise<PublicCaseStudy[]> => {
try {
const { data, error } = await createServiceClient()
.from('case_studies')
.select('*')
.eq('is_published', true)
.order('created_at', { ascending: false })
.limit(CASE_STUDY_LIMIT);
if (error) throw error;
return publishedOnly(data ?? []).map((row) => ({
id: row.id,
title: row.title,
slug: row.slug,
customerName: row.customer_name,
summary: row.summary,
resultMetric: row.result_metric,
verifiedAt: (row as { verified_at?: string | null }).verified_at ?? null,
}));
} catch (err) {
console.error('[marketing-reputation] case studies read failed', err);
return [];
}
},
['public-case-studies'],
const cachedPublishedCaseStudies = unstable_cache(
(): Promise<PublicCaseStudy[]> => withPublicReadBudget(async (signal) => {
const { data, error } = await createServiceClient()
.from('case_studies')
.select('*')
.eq('is_published', true)
.order('created_at', { ascending: false })
.limit(CASE_STUDY_LIMIT)
.abortSignal(signal);
if (error) throw error;
// Validate before filtering: a malformed row must not masquerade as a
// legitimately empty publication list and become successful cached data.
if (!Array.isArray(data) || !data.every(isCaseStudyRow)) throw new Error('Invalid published case studies response');
return publishedOnly(data).map((row) => ({
id: row.id,
title: row.title,
slug: row.slug,
customerName: row.customer_name,
summary: row.summary,
resultMetric: row.result_metric,
verifiedAt: (row as { verified_at?: string | null }).verified_at ?? null,
}));
}),
['public-case-studies-validated-v2'],
{ revalidate: 3600, tags: [CASE_STUDIES_CACHE_TAG] },
);

/** An outage is not a successfully empty publication list. */
export async function getPublishedCaseStudies(): Promise<PublicCaseStudy[]> {
try {
return await cachedPublishedCaseStudies();
} catch (err) {
console.error('[marketing-reputation] case studies read failed', err);
return [];
}
}
75 changes: 40 additions & 35 deletions lib/marketing/stats.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import 'server-only';
import { unstable_cache } from 'next/cache';
import { createClient, type SupabaseClient } from '@supabase/supabase-js';
import type { Database } from '@/lib/database.types';
import { withPublicReadBudget } from '@/lib/marketing/public-read';

export type PublicStats = {
families: number;
Expand Down Expand Up @@ -59,15 +60,18 @@ function num(row: Row, key: string): number {
* directly: the RPC is the only door, and it is SECURITY DEFINER + aggregate.
*/
type HandledStatsRpc = {
rpc: (fn: 'public_handled_stats') => PromiseLike<{ data: unknown; error: unknown }>;
rpc: (fn: 'public_handled_stats') => {
abortSignal: (signal: AbortSignal) => PromiseLike<{ data: unknown; error: unknown }>;
};
};

type HandledStats = Pick<PublicStats, 'handledCompleted' | 'handled30d' | 'familiesWithRuns'>;
type AccountStats = Pick<PublicStats, 'families' | 'members' | 'tasksCompleted'>;

async function readHandledStats(client: SupabaseClient<Database>): Promise<HandledStats> {
try {
const { data, error } = await (client as unknown as HandledStatsRpc).rpc('public_handled_stats');
async function readHandledStats(): Promise<HandledStats> {
return withPublicReadBudget(async (signal) => {
const client = anonClient();
const { data, error } = await (client as unknown as HandledStatsRpc).rpc('public_handled_stats').abortSignal(signal);
if (error) throw error;
const row = firstRow(data);
const counts = {
Expand All @@ -79,47 +83,48 @@ async function readHandledStats(client: SupabaseClient<Database>): Promise<Handl
throw new Error('Inconsistent aggregate counts');
}
return counts;
} catch (err) {
// Zeros are what the formatters HIDE, so a failed read shows nothing
// rather than a number — but it is still logged, because "the RPC is not
// applied yet" and "Supabase is down" look identical from the page.
console.error('[marketing-stats] public_handled_stats read failed (rendering no handled aggregates)', err);
return { handledCompleted: 0, handled30d: 0, familiesWithRuns: 0 };
}
});
}

async function readAccountStats(client: SupabaseClient<Database>): Promise<AccountStats> {
try {
const { data, error } = await client.rpc('public_stats');
async function readAccountStats(): Promise<AccountStats> {
return withPublicReadBudget(async (signal) => {
const client = anonClient();
const { data, error } = await client.rpc('public_stats').abortSignal(signal);
if (error) throw error;
const row = firstRow(data);
return {
families: num(row, 'families'),
members: num(row, 'members'),
tasksCompleted: num(row, 'tasks_completed'),
};
} catch (err) {
console.error('[marketing-stats] public_stats read failed (rendering no family counts)', err);
return { families: 0, members: 0, tasksCompleted: 0 };
}
});
}

/** Real aggregate counts for the public site, cached for an hour. Degrades to
* zeros if Supabase is unreachable so marketing pages always render — and the
* two RPCs fail independently, so a missing handled-stats function never
* hides the registered-family count. */
export const getPublicStats = unstable_cache(
async (): Promise<PublicStats> => {
let client: SupabaseClient<Database>;
try {
client = anonClient();
} catch (err) {
console.error('[marketing-stats] public client unavailable (rendering no aggregates)', err);
return { ...EMPTY_PUBLIC_STATS };
}
const [accounts, handled] = await Promise.all([readAccountStats(client), readHandledStats(client)]);
return { ...accounts, ...handled };
},
['public-stats-validated-v2'],
// Each successful group is cached independently. Throwing before the cache
// write keeps a failed refresh from replacing last-good data with hidden zeros.
// New keys exclude fallback values previously cached as successful evidence.
const cachedAccountStats = unstable_cache(
readAccountStats,
['public-account-stats-validated-v3'],
{ revalidate: 3600 },
);
const cachedHandledStats = unstable_cache(
readHandledStats,
['public-handled-stats-validated-v3'],
{ revalidate: 3600 },
);

/** Validated public counts, with per-group hidden-zero fallbacks outside cache. */
export async function getPublicStats(): Promise<PublicStats> {
const [accounts, handled] = await Promise.all([
cachedAccountStats().catch((err): AccountStats => {
console.error('[marketing-stats] public_stats read failed (rendering no family counts)', err);
return { families: 0, members: 0, tasksCompleted: 0 };
}),
cachedHandledStats().catch((err): HandledStats => {
console.error('[marketing-stats] public_handled_stats read failed (rendering no handled aggregates)', err);
return { handledCompleted: 0, handled30d: 0, familiesWithRuns: 0 };
}),
]);
return { ...accounts, ...handled };
}
Loading
Loading