mirror of
https://github.com/yusufipk/OpenFrame.git
synced 2026-09-11 09:36:08 +00:00
Merge pull request #50 from yusufipk/feat/acquisition-analytics
feat(analytics): record where paying customers actually came from
This commit is contained in:
@@ -34,6 +34,10 @@ OPENFRAME_R2_MULTIPART_PART_SIZE_BYTES="33554432"
|
||||
# Run once after creating the bucket: bun run r2:configure-cors
|
||||
# Or set CORS manually in Cloudflare R2 -> bucket -> Settings -> CORS policy.
|
||||
OPENFRAME_REQUIRE_INVITE_CODE="true"
|
||||
# Acquisition attribution and funnel events, read back on /admin/growth. Off by
|
||||
# default: the rows only pay for themselves if you are running a signup funnel.
|
||||
# Everything is written to this instance's own database and sent nowhere.
|
||||
OPENFRAME_ENABLE_ANALYTICS="false"
|
||||
SELF_HOSTED_AUTO_CREATE_BUCKET="false"
|
||||
|
||||
# ============================================================================
|
||||
|
||||
@@ -180,6 +180,7 @@ Behavior when disabled:
|
||||
- `OPENFRAME_ENABLE_BUNNY_UPLOADS=false` hides Bunny direct-upload entry points. URL-based providers such as YouTube remain available.
|
||||
- `OPENFRAME_ENABLE_S3_VIDEO_UPLOADS=true` (with `R2_*` configured) enables presigned uploads to your own S3-compatible storage. Set `OPENFRAME_ENABLE_BUNNY_UPLOADS=false` — only one direct-upload backend can be active. The bucket must allow CORS `PUT` from your app origin (for example `http://localhost:3000` in dev and your production URL). For Docker + MinIO, keep `R2_ENDPOINT=http://minio:9000` (app-internal) and set `R2_PRESIGN_ENDPOINT` to the browser-reachable MinIO origin (for example `http://localhost:9000` locally, or `https://minio.example.com` when MinIO is behind a reverse proxy). Use the origin only — no path suffix. The app's Content-Security-Policy is generated from runtime env at request time, so published Docker images pick up custom `R2_PRESIGN_ENDPOINT` values without rebuilding or editing `next.config.ts`.
|
||||
- `OPENFRAME_REQUIRE_INVITE_CODE=false` allows open registration while keeping invitation-link registration intact.
|
||||
- `OPENFRAME_ENABLE_ANALYTICS=true` records first-touch attribution and funnel events into your own database, readable on `/admin/growth`. Off by default, and nothing leaves the instance either way.
|
||||
|
||||
For self-hosted MinIO behind a reverse proxy, choose one of these browser-facing layouts:
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { after } from 'next/server';
|
||||
import { readPageVisitor, recordVisitorEvent } from '@/lib/analytics/visitor';
|
||||
import { isInviteCodeRequired } from '@/lib/feature-flags';
|
||||
import { getInvitationPreviewByToken } from '@/lib/invitations';
|
||||
import { isInvitationPreviewAllowed } from '@/lib/invitation-preview-limit';
|
||||
@@ -8,6 +10,13 @@ interface RegisterPageProps {
|
||||
}
|
||||
|
||||
export default async function RegisterPage({ searchParams }: RegisterPageProps) {
|
||||
// Reaching this page is the funnel step. Recording it here rather than from the
|
||||
// browser also keeps it honest: a prefetch of this route is filtered out by
|
||||
// readPageVisitor, so signup starts can never outnumber the landing views
|
||||
// above them.
|
||||
const visitor = await readPageVisitor();
|
||||
after(() => recordVisitorEvent('SIGNUP_STARTED', visitor));
|
||||
|
||||
const googleEnabled = Boolean(process.env.GOOGLE_CLIENT_ID && process.env.GOOGLE_CLIENT_SECRET);
|
||||
const githubEnabled = Boolean(process.env.GITHUB_CLIENT_ID && process.env.GITHUB_CLIENT_SECRET);
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import type { Metadata } from 'next';
|
||||
import { notFound } from 'next/navigation';
|
||||
import { after } from 'next/server';
|
||||
import { ComparisonPage } from '@/components/marketing/comparison-page';
|
||||
import { readPageVisitor, recordVisitorEvent } from '@/lib/analytics/visitor';
|
||||
import { auth } from '@/lib/auth';
|
||||
import { comparisonPages, getComparisonPage } from '@/lib/marketing/comparison-pages';
|
||||
import { buildComparisonJsonLd, buildComparisonMetadata } from '@/lib/marketing/metadata';
|
||||
@@ -38,6 +40,15 @@ export default async function MarketingSlugPage({ params }: MarketingSlugPagePro
|
||||
}
|
||||
|
||||
const session = await auth();
|
||||
const isLoggedIn = Boolean(session?.user);
|
||||
|
||||
// A comparison page is a landing page: for most of these visitors it is the
|
||||
// first thing they see, so it belongs in the same visitor count as `/`.
|
||||
if (!isLoggedIn) {
|
||||
const visitor = await readPageVisitor();
|
||||
after(() => recordVisitorEvent('LANDING_VIEW', visitor));
|
||||
}
|
||||
|
||||
const structuredData = buildComparisonJsonLd({
|
||||
title: page.title,
|
||||
description: page.metaDescription,
|
||||
@@ -57,7 +68,7 @@ export default async function MarketingSlugPage({ params }: MarketingSlugPagePro
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
<ComparisonPage page={page} isLoggedIn={Boolean(session?.user)} />
|
||||
<ComparisonPage page={page} isLoggedIn={isLoggedIn} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,365 @@
|
||||
import { Metadata } from 'next';
|
||||
import { redirect } from 'next/navigation';
|
||||
import { auth } from '@/lib/auth';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import {
|
||||
AT_RISK_SILENT_DAYS,
|
||||
conversionRates,
|
||||
getScoreboard,
|
||||
type FunnelRates,
|
||||
} from '@/lib/analytics/scoreboard';
|
||||
import { isProductAnalyticsEnabled } from '@/lib/feature-flags';
|
||||
import { AlertTriangle, CreditCard, TrendingUp, Users } from 'lucide-react';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Growth | OpenFrame',
|
||||
description: 'Acquisition funnel and retention scoreboard',
|
||||
};
|
||||
|
||||
function formatMoney(cents: number | null, currency: string) {
|
||||
if (cents === null) return '—';
|
||||
const safeCurrency = /^[a-zA-Z]{3}$/.test(currency) ? currency.toUpperCase() : 'USD';
|
||||
return new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency: safeCurrency,
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 0,
|
||||
}).format(cents / 100);
|
||||
}
|
||||
|
||||
function formatWeek(date: Date) {
|
||||
return date.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function formatDate(date: Date | null) {
|
||||
return date ? date.toISOString().slice(0, 10) : 'never';
|
||||
}
|
||||
|
||||
/** A percentage with the count it was computed from, because n matters here. */
|
||||
function Rate({ rate, of }: { rate: number | null; of: number }) {
|
||||
if (rate === null) return <span className="text-muted-foreground">—</span>;
|
||||
return (
|
||||
<span>
|
||||
{Math.round(rate * 100)}%<span className="text-muted-foreground"> /{of}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
const WEEK_COLUMNS: Array<{ key: string; label: string }> = [
|
||||
{ key: 'visitors', label: 'Visitors' },
|
||||
{ key: 'signups', label: 'Signup' },
|
||||
{ key: 'firstVideo', label: 'Video' },
|
||||
{ key: 'shareLinks', label: 'Share link' },
|
||||
{ key: 'externalFeedback', label: 'Ext. feedback' },
|
||||
{ key: 'trials', label: 'Trial' },
|
||||
{ key: 'newPaid', label: 'New paid' },
|
||||
{ key: 'canceled', label: 'Canceled' },
|
||||
{ key: 'activePaid', label: 'Active paid' },
|
||||
];
|
||||
|
||||
export default async function AdminGrowthPage() {
|
||||
const session = await auth();
|
||||
if (!session?.user?.isAdmin) {
|
||||
redirect('/');
|
||||
}
|
||||
|
||||
if (!isProductAnalyticsEnabled()) {
|
||||
return (
|
||||
<div className="flex-1 space-y-4 px-4 md:px-8">
|
||||
<h2 className="text-3xl font-bold tracking-tight">Growth</h2>
|
||||
<Card>
|
||||
<CardContent className="pt-6 text-sm text-muted-foreground">
|
||||
Acquisition tracking is off on this deployment. Set{' '}
|
||||
<code className="font-mono">OPENFRAME_ENABLE_ANALYTICS=true</code> to start recording
|
||||
the funnel. Nothing is collected until you do, and nothing is ever sent anywhere but
|
||||
this instance's own database.
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const scoreboard = await getScoreboard();
|
||||
const latest = scoreboard.weeks[scoreboard.weeks.length - 1];
|
||||
const window = scoreboard.weeks.reduce(
|
||||
(sum, week) => ({
|
||||
visitors: sum.visitors + week.visitors,
|
||||
signups: sum.signups + week.signups,
|
||||
firstVideo: sum.firstVideo + week.firstVideo,
|
||||
shareLinks: sum.shareLinks + week.shareLinks,
|
||||
externalFeedback: sum.externalFeedback + week.externalFeedback,
|
||||
trials: sum.trials + week.trials,
|
||||
newPaid: sum.newPaid + week.newPaid,
|
||||
}),
|
||||
{
|
||||
visitors: 0,
|
||||
signups: 0,
|
||||
firstVideo: 0,
|
||||
shareLinks: 0,
|
||||
externalFeedback: 0,
|
||||
trials: 0,
|
||||
newPaid: 0,
|
||||
}
|
||||
);
|
||||
const overall: FunnelRates = conversionRates(window);
|
||||
|
||||
return (
|
||||
<div className="flex-1 space-y-4 px-4 md:px-8">
|
||||
<div className="flex items-center justify-between space-y-2">
|
||||
<h2 className="text-3xl font-bold tracking-tight">Growth</h2>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Active paid</CardTitle>
|
||||
<CreditCard className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{scoreboard.currentActivePaid ?? '—'}</div>
|
||||
<p className="text-xs text-muted-foreground">from Stripe, right now</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">MRR</CardTitle>
|
||||
<TrendingUp className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">
|
||||
{formatMoney(scoreboard.currentMrrCents, scoreboard.currency)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">from Stripe, right now</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Visitors this week</CardTitle>
|
||||
<Users className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{latest?.visitors ?? 0}</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{latest ? `week of ${formatWeek(latest.weekStart)}` : 'no data yet'}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">At risk</CardTitle>
|
||||
<AlertTriangle className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{scoreboard.atRisk.length}</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
paid, silent for {AT_RISK_SILENT_DAYS}+ days
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Weekly funnel</CardTitle>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Weeks start Monday, UTC. Active paid is the running net of subscriptions started minus
|
||||
canceled, so it can drift from the Stripe figure above; the difference is the drift.
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardContent className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b text-left text-muted-foreground">
|
||||
<th className="py-2 pr-4 font-medium">Week</th>
|
||||
{WEEK_COLUMNS.map((column) => (
|
||||
<th key={column.key} className="py-2 pr-4 text-right font-medium">
|
||||
{column.label}
|
||||
</th>
|
||||
))}
|
||||
<th className="py-2 pr-4 text-right font-medium">MRR</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{scoreboard.weeks.map((week) => (
|
||||
<tr key={week.weekStart.toISOString()} className="border-b last:border-0">
|
||||
<td className="py-2 pr-4 font-mono text-xs">{formatWeek(week.weekStart)}</td>
|
||||
{WEEK_COLUMNS.map((column) => (
|
||||
<td key={column.key} className="py-2 pr-4 text-right tabular-nums">
|
||||
{week[column.key as keyof typeof week] as number}
|
||||
</td>
|
||||
))}
|
||||
<td className="py-2 pr-4 text-right tabular-nums">
|
||||
{formatMoney(week.mrrCents, scoreboard.currency)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Where it narrows</CardTitle>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Every step over the whole {scoreboard.weeks.length}-week window, with the denominator
|
||||
beside it. The lowest rate is the step to work on.
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-4 sm:grid-cols-3 lg:grid-cols-5 text-sm">
|
||||
<div>
|
||||
<div className="text-muted-foreground">Visitor to signup</div>
|
||||
<div className="text-lg font-semibold">
|
||||
<Rate rate={overall.visitorToSignup} of={window.visitors} />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-muted-foreground">Signup to first video</div>
|
||||
<div className="text-lg font-semibold">
|
||||
<Rate rate={overall.signupToFirstVideo} of={window.signups} />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-muted-foreground">Video to share link</div>
|
||||
<div className="text-lg font-semibold">
|
||||
<Rate rate={overall.firstVideoToShare} of={window.firstVideo} />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-muted-foreground">Share to outside feedback</div>
|
||||
<div className="text-lg font-semibold">
|
||||
<Rate rate={overall.shareToFeedback} of={window.shareLinks} />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-muted-foreground">Trial to paid</div>
|
||||
<div className="text-lg font-semibold">
|
||||
<Rate rate={overall.trialToPaid} of={window.trials} />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">By source</CardTitle>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Rolling {scoreboard.channelWindowDays} days rather than one week: a weekly per-source
|
||||
cell holds single digits at this volume, and a percentage computed from three visits
|
||||
reads exactly as confidently as one computed from three hundred.
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardContent className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b text-left text-muted-foreground">
|
||||
<th className="py-2 pr-4 font-medium">Source</th>
|
||||
<th className="py-2 pr-4 text-right font-medium">Visitors</th>
|
||||
<th className="py-2 pr-4 text-right font-medium">Signup</th>
|
||||
<th className="py-2 pr-4 text-right font-medium">Trial</th>
|
||||
<th className="py-2 pr-4 text-right font-medium">Paid</th>
|
||||
<th className="py-2 pr-4 text-right font-medium">Visitor to signup</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{scoreboard.channels.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={6} className="py-4 text-muted-foreground">
|
||||
Nothing recorded in this window yet.
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{scoreboard.channels.map((row) => (
|
||||
<tr key={row.channel} className="border-b last:border-0">
|
||||
<td className="py-2 pr-4">{row.channel.toLowerCase()}</td>
|
||||
<td className="py-2 pr-4 text-right tabular-nums">{row.visitors}</td>
|
||||
<td className="py-2 pr-4 text-right tabular-nums">{row.signups}</td>
|
||||
<td className="py-2 pr-4 text-right tabular-nums">{row.trials}</td>
|
||||
<td className="py-2 pr-4 text-right tabular-nums">{row.paid}</td>
|
||||
<td className="py-2 pr-4 text-right tabular-nums">
|
||||
<Rate
|
||||
rate={row.visitors > 0 ? row.signups / row.visitors : null}
|
||||
of={row.visitors}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Paid accounts</CardTitle>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Value events are videos, share links, outside feedback, approvals and projects. Rows
|
||||
marked at risk have produced none for {AT_RISK_SILENT_DAYS} days.
|
||||
{scoreboard.paidAccountsTruncated && (
|
||||
<>
|
||||
{' '}
|
||||
Quietest {scoreboard.paidAccountLimit} only; there are more paid accounts than this
|
||||
table shows.
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardContent className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b text-left text-muted-foreground">
|
||||
<th className="py-2 pr-4 font-medium">Account</th>
|
||||
<th className="py-2 pr-4 font-medium">Status</th>
|
||||
<th className="py-2 pr-4 font-medium">Source</th>
|
||||
<th className="py-2 pr-4 text-right font-medium">7d</th>
|
||||
<th className="py-2 pr-4 text-right font-medium">30d</th>
|
||||
<th className="py-2 pr-4 font-medium">Last activity</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{scoreboard.paidAccounts.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={6} className="py-4 text-muted-foreground">
|
||||
No active or trialing accounts.
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{scoreboard.paidAccounts.map((account) => {
|
||||
const atRisk = scoreboard.atRisk.some((row) => row.userId === account.userId);
|
||||
return (
|
||||
<tr key={account.userId} className="border-b last:border-0">
|
||||
<td className="py-2 pr-4">
|
||||
{account.name || account.email || account.userId}
|
||||
{atRisk && (
|
||||
<span className="ml-2 rounded bg-destructive/10 px-1.5 py-0.5 text-xs text-destructive">
|
||||
at risk
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-2 pr-4 text-muted-foreground">
|
||||
{account.status.toLowerCase()}
|
||||
</td>
|
||||
<td className="py-2 pr-4 text-muted-foreground">
|
||||
{account.channel?.toLowerCase() ?? '—'}
|
||||
{account.selfReported && account.selfReported !== account.channel && (
|
||||
<span className="text-xs">
|
||||
{' '}
|
||||
(said {account.selfReported.toLowerCase()})
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-2 pr-4 text-right tabular-nums">{account.valueEvents7}</td>
|
||||
<td className="py-2 pr-4 text-right tabular-nums">{account.valueEvents30}</td>
|
||||
<td className="py-2 pr-4 font-mono text-xs text-muted-foreground">
|
||||
{formatDate(account.lastValueEventAt)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+15
-1
@@ -2,7 +2,7 @@ import { redirect } from 'next/navigation';
|
||||
import { auth } from '@/lib/auth';
|
||||
import { Header } from '@/components/layout';
|
||||
import Link from 'next/link';
|
||||
import { LayoutDashboard, MessageSquareQuote, Users } from 'lucide-react';
|
||||
import { LayoutDashboard, MessageSquareQuote, TrendingUp, Users } from 'lucide-react';
|
||||
|
||||
export default async function AdminLayout({ children }: { children: React.ReactNode }) {
|
||||
const session = await auth();
|
||||
@@ -39,6 +39,13 @@ export default async function AdminLayout({ children }: { children: React.ReactN
|
||||
<MessageSquareQuote className="h-4 w-4" />
|
||||
Feedback
|
||||
</Link>
|
||||
<Link
|
||||
href="/admin/growth"
|
||||
className="flex items-center gap-2 whitespace-nowrap rounded-md px-3 py-2 text-sm font-medium hover:bg-muted/50"
|
||||
>
|
||||
<TrendingUp className="h-4 w-4" />
|
||||
Growth
|
||||
</Link>
|
||||
</nav>
|
||||
</div>
|
||||
{/* Desktop Nav */}
|
||||
@@ -66,6 +73,13 @@ export default async function AdminLayout({ children }: { children: React.ReactN
|
||||
<MessageSquareQuote className="h-4 w-4" />
|
||||
Feedback
|
||||
</Link>
|
||||
<Link
|
||||
href="/admin/growth"
|
||||
className="flex items-center gap-2 rounded-md px-3 py-2 text-sm font-medium hover:bg-muted/50 transition-colors"
|
||||
>
|
||||
<TrendingUp className="h-4 w-4" />
|
||||
Growth
|
||||
</Link>
|
||||
</nav>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { NextRequest } from 'next/server';
|
||||
import { auth } from '@/lib/auth';
|
||||
import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response';
|
||||
import { conversionRates, getScoreboard } from '@/lib/analytics/scoreboard';
|
||||
import { isProductAnalyticsEnabled } from '@/lib/feature-flags';
|
||||
import { logError } from '@/lib/logger';
|
||||
|
||||
// The same numbers /admin/growth renders, as JSON, so the Monday digest can pull
|
||||
// the scoreboard instead of somebody retyping it into a table.
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) {
|
||||
return apiErrors.unauthorized();
|
||||
}
|
||||
if (!session.user.isAdmin) {
|
||||
return apiErrors.forbidden('Admin access required');
|
||||
}
|
||||
|
||||
if (!isProductAnalyticsEnabled()) {
|
||||
return apiErrors.badRequest('Analytics are disabled by this host');
|
||||
}
|
||||
|
||||
const weeksParam = Number(request.nextUrl.searchParams.get('weeks'));
|
||||
const scoreboard = await getScoreboard({
|
||||
weeks: Number.isSafeInteger(weeksParam) && weeksParam > 0 ? weeksParam : undefined,
|
||||
});
|
||||
|
||||
const response = successResponse({
|
||||
...scoreboard,
|
||||
weeks: scoreboard.weeks.map((week) => ({ ...week, rates: conversionRates(week) })),
|
||||
});
|
||||
return withCacheControl(response, 'private, no-store');
|
||||
} catch (error) {
|
||||
logError('Error building the growth scoreboard:', error);
|
||||
return apiErrors.internalError('Failed to build the scoreboard');
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import { notifyUsers } from '@/lib/notifications';
|
||||
import { rateLimit } from '@/lib/rate-limit';
|
||||
import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response';
|
||||
import { logError } from '@/lib/logger';
|
||||
import { eventKey, recordEvent } from '@/lib/analytics/record';
|
||||
|
||||
type RouteParams = { params: Promise<{ requestId: string }> };
|
||||
|
||||
@@ -202,6 +203,12 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
|
||||
});
|
||||
|
||||
if (updated.status === 'APPROVED') {
|
||||
await recordEvent({
|
||||
name: 'APPROVAL_COMPLETED',
|
||||
dedupeKey: eventKey('APPROVAL_COMPLETED', requestId),
|
||||
userId: approvalRequest.version.video.project.ownerId,
|
||||
});
|
||||
|
||||
notifyUsers([updated.requestedById], {
|
||||
type: 'approval_completed',
|
||||
projectName: updated.version.video.project.name,
|
||||
|
||||
@@ -17,6 +17,8 @@ import {
|
||||
sendVerificationEmail,
|
||||
} from '@/lib/email-verification';
|
||||
import { isValidEmailAddress, normalizeEmail } from '@/lib/email-validation';
|
||||
import { recordSignupCompleted } from '@/lib/analytics/signup';
|
||||
import { readRequestVisitor } from '@/lib/analytics/visitor';
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
@@ -132,6 +134,14 @@ export async function POST(request: NextRequest) {
|
||||
}
|
||||
}
|
||||
|
||||
// Ties the account to the first touch stored in this browser's cookie and
|
||||
// claims the visitor events that led here. Recorded after the invitation has
|
||||
// been accepted, so an account that gets rolled back never leaves a signup.
|
||||
await recordSignupCompleted({
|
||||
userId: user.id,
|
||||
visitor: await readRequestVisitor(request),
|
||||
});
|
||||
|
||||
// Send verification email if SMTP is configured
|
||||
if (emailVerificationRequired) {
|
||||
const verificationToken = await createVerificationToken(normalizedEmail);
|
||||
|
||||
@@ -11,6 +11,7 @@ import { isStripeFeatureEnabled } from '@/lib/feature-flags';
|
||||
import { getStripe, getStripePriceId, isStripeConfigured } from '@/lib/stripe';
|
||||
import { isTrustedSameOriginRequest } from '@/lib/request-origin';
|
||||
import { logError } from '@/lib/logger';
|
||||
import { eventKey, recordEvent } from '@/lib/analytics/record';
|
||||
|
||||
function getAppOrigin(request: NextRequest) {
|
||||
if (isTrustedSameOriginRequest(request)) {
|
||||
@@ -84,6 +85,15 @@ export async function POST(request: NextRequest) {
|
||||
throw new Error('Stripe did not return a checkout URL');
|
||||
}
|
||||
|
||||
// Keyed on the Stripe session, so an abandoned checkout followed by a second
|
||||
// attempt counts twice. That is the intent: the gap between checkouts started
|
||||
// and subscriptions started is the number worth watching.
|
||||
await recordEvent({
|
||||
name: 'CHECKOUT_STARTED',
|
||||
dedupeKey: eventKey('CHECKOUT_STARTED', checkoutSession.id),
|
||||
userId: session.user.id,
|
||||
});
|
||||
|
||||
const response = successResponse({ url: checkoutSession.url });
|
||||
return withCacheControl(response, 'private, no-store');
|
||||
} catch (error) {
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { NextRequest } from 'next/server';
|
||||
import { rateLimit } from '@/lib/rate-limit';
|
||||
import { isTrustedSameOriginRequest } from '@/lib/request-origin';
|
||||
import { readRequestVisitor, recordVisitorEvent } from '@/lib/analytics/visitor';
|
||||
import { isProductAnalyticsEnabled } from '@/lib/feature-flags';
|
||||
|
||||
// The one funnel event that cannot be observed from the server: a click on a
|
||||
// call to action, which never reaches us as a request of its own.
|
||||
//
|
||||
// Everything else in the funnel is recorded where it actually happens, so this
|
||||
// endpoint accepts exactly one event name. An anonymous caller must not be able
|
||||
// to post `SUBSCRIPTION_STARTED` into the scoreboard, and the cheapest way to
|
||||
// guarantee that is to make the allowed set a single literal.
|
||||
const ALLOWED_EVENTS = new Set(['cta_clicked']);
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
// Answers 204 whatever happens. This endpoint reports nothing back to the page
|
||||
// that called it, so there is no reason to tell a caller which of their
|
||||
// attempts landed.
|
||||
const noContent = new Response(null, {
|
||||
status: 204,
|
||||
headers: { 'Cache-Control': 'private, no-store' },
|
||||
});
|
||||
|
||||
// Both cheap and both free of side effects, so they come before the limiter.
|
||||
// Checking the flag here rather than only inside the recorder keeps a host who
|
||||
// never turned analytics on from paying a rate-limit write for every anonymous
|
||||
// POST to an endpoint they are not using.
|
||||
if (!isProductAnalyticsEnabled()) return noContent;
|
||||
if (!isTrustedSameOriginRequest(request)) return noContent;
|
||||
|
||||
// 204 rather than the limiter's 429: a beacon has nobody to tell, and a
|
||||
// flooder should not be handed a signal for when the window resets.
|
||||
const limited = await rateLimit(request, 'analytics-beacon');
|
||||
if (limited) return noContent;
|
||||
|
||||
const body = await request.json().catch(() => null);
|
||||
const name = typeof body?.name === 'string' ? body.name : '';
|
||||
if (!ALLOWED_EVENTS.has(name)) return noContent;
|
||||
|
||||
await recordVisitorEvent('CTA_CLICKED', await readRequestVisitor(request));
|
||||
|
||||
return noContent;
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { NextRequest } from 'next/server';
|
||||
import { auth } from '@/lib/auth';
|
||||
import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response';
|
||||
import { RATE_LIMIT_CONFIGS, checkRateLimit, rateLimitHeaders } from '@/lib/rate-limit';
|
||||
import { setSelfReportedSource } from '@/lib/analytics/record';
|
||||
import { isAcquisitionChannel } from '@/lib/analytics/cookies';
|
||||
import { isProductAnalyticsEnabled } from '@/lib/feature-flags';
|
||||
|
||||
// "How did you hear about us?", answered on the first onboarding screen.
|
||||
//
|
||||
// It is stored beside the cookie-derived channel rather than instead of it. The
|
||||
// cookie is precise but loses cross-device visits and cleared browsers; the
|
||||
// answer survives both, and it is the only thing that can name a channel no UTM
|
||||
// tag ever carries, like being told about it by a friend.
|
||||
export async function POST(request: NextRequest) {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) {
|
||||
return apiErrors.unauthorized();
|
||||
}
|
||||
|
||||
// Keyed by account, like /api/onboarding/complete beside it. An IP key would
|
||||
// be the wrong bucket twice over: without TRUSTED_PROXY_MODE every caller
|
||||
// resolves to 127.0.0.1, so five answers an hour would be five for the whole
|
||||
// deployment, and with it a shared office address would lock out everyone
|
||||
// after one colleague answered.
|
||||
const config = RATE_LIMIT_CONFIGS['onboarding-source'];
|
||||
const limit = await checkRateLimit(session.user.id, 'onboarding-source', config);
|
||||
if (!limit.allowed) {
|
||||
return new Response(JSON.stringify({ error: 'Too many requests. Please try again later.' }), {
|
||||
status: 429,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...rateLimitHeaders(limit, config.maxRequests),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (!isProductAnalyticsEnabled()) {
|
||||
return apiErrors.badRequest('Analytics are disabled by this host');
|
||||
}
|
||||
|
||||
const body = await request.json().catch(() => null);
|
||||
const source = body?.source;
|
||||
if (!isAcquisitionChannel(source)) {
|
||||
return apiErrors.badRequest('Unknown source');
|
||||
}
|
||||
|
||||
const note = typeof body?.note === 'string' ? body.note : null;
|
||||
|
||||
await setSelfReportedSource({ userId: session.user.id, selfReported: source, note });
|
||||
|
||||
const response = successResponse({ recorded: true });
|
||||
return withCacheControl(response, 'private, no-store');
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import { db } from '@/lib/db';
|
||||
import { rateLimit } from '@/lib/rate-limit';
|
||||
import { MAX_SHARE_PASSWORD_LENGTH } from '@/lib/share-links';
|
||||
import { logError } from '@/lib/logger';
|
||||
import { eventKey, recordEvent } from '@/lib/analytics/record';
|
||||
|
||||
type RouteParams = { params: Promise<{ projectId: string; videoId: string }> };
|
||||
|
||||
@@ -141,7 +142,11 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
|
||||
}
|
||||
|
||||
const { projectId, videoId } = await params;
|
||||
const { error } = await requireShareManagementAccess(projectId, videoId, session.user.id);
|
||||
const { error, video } = await requireShareManagementAccess(
|
||||
projectId,
|
||||
videoId,
|
||||
session.user.id
|
||||
);
|
||||
if (error) return error;
|
||||
|
||||
const body = await request.json().catch(() => ({}));
|
||||
@@ -244,6 +249,14 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
|
||||
return apiErrors.internalError('Failed to create video share link');
|
||||
}
|
||||
|
||||
// Keyed on the link id, so re-issuing the token for a link that already
|
||||
// exists updates the row and records nothing: the share was created once.
|
||||
await recordEvent({
|
||||
name: 'SHARE_LINK_CREATED',
|
||||
dedupeKey: eventKey('SHARE_LINK_CREATED', link.id),
|
||||
userId: video?.project.ownerId ?? null,
|
||||
});
|
||||
|
||||
const response = successResponse(serializeShareLink(request, videoId, link));
|
||||
|
||||
return withCacheControl(response, 'private, no-store');
|
||||
|
||||
@@ -8,6 +8,7 @@ import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response
|
||||
import { verifyBunnyUploadToken } from '@/lib/bunny-upload-token';
|
||||
import { finalizeR2VideoUpload } from '@/lib/r2-video-finalize';
|
||||
import { logError } from '@/lib/logger';
|
||||
import { eventKey, recordEvent } from '@/lib/analytics/record';
|
||||
|
||||
type RouteParams = { params: Promise<{ projectId: string }> };
|
||||
|
||||
@@ -268,6 +269,12 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
|
||||
}).catch((err) => logError('Notification failed:', err));
|
||||
}
|
||||
|
||||
await recordEvent({
|
||||
name: 'VIDEO_ADDED',
|
||||
dedupeKey: eventKey('VIDEO_ADDED', video.id),
|
||||
userId: project.ownerId,
|
||||
});
|
||||
|
||||
const response = successResponse(video, 201);
|
||||
return withCacheControl(response, 'private, no-store');
|
||||
} catch (error) {
|
||||
|
||||
@@ -7,6 +7,7 @@ import { buildBillingAccessWhereInput } from '@/lib/billing';
|
||||
import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response';
|
||||
import { DEFAULT_COMMENT_TAGS } from '@/lib/comment-tags';
|
||||
import { logError } from '@/lib/logger';
|
||||
import { eventKey, recordEvent } from '@/lib/analytics/record';
|
||||
|
||||
// GET /api/projects - List all projects for the authenticated user
|
||||
export async function GET(request: NextRequest) {
|
||||
@@ -187,6 +188,15 @@ export async function POST(request: NextRequest) {
|
||||
return createdProject;
|
||||
});
|
||||
|
||||
// Attributed to the workspace owner rather than the caller: the funnel asks
|
||||
// which account is progressing, and a team member creating a project moves
|
||||
// the owner's account, not their own.
|
||||
await recordEvent({
|
||||
name: 'PROJECT_CREATED',
|
||||
dedupeKey: eventKey('PROJECT_CREATED', project.id),
|
||||
userId: workspace.ownerId,
|
||||
});
|
||||
|
||||
const response = successResponse(project, 201);
|
||||
return withCacheControl(response, 'private, no-store');
|
||||
} catch (error) {
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
getGuestIdentityFromRequest,
|
||||
setGuestIdentityCookie,
|
||||
} from '@/lib/guest-identity';
|
||||
import { eventKey, recordEvent } from '@/lib/analytics/record';
|
||||
import {
|
||||
extractImageFileNameFromProxyUrl,
|
||||
extractAudioFileNameFromProxyUrl,
|
||||
@@ -547,6 +548,18 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
|
||||
}
|
||||
}
|
||||
|
||||
// Feedback arriving from outside the team is the moment this product
|
||||
// becomes worth paying for, so it is the activation step of the funnel.
|
||||
// Keyed on the account, not the comment: what matters is the first time an
|
||||
// account ever received one.
|
||||
if (isGuest) {
|
||||
await recordEvent({
|
||||
name: 'FIRST_GUEST_COMMENT',
|
||||
dedupeKey: eventKey('FIRST_GUEST_COMMENT', project.workspace.ownerId),
|
||||
userId: project.workspace.ownerId,
|
||||
});
|
||||
}
|
||||
|
||||
const viewerUserId = session?.user?.id ?? null;
|
||||
const viewerGuestIdentityId = viewerUserId
|
||||
? null
|
||||
|
||||
@@ -5,6 +5,7 @@ import { rateLimit } from '@/lib/rate-limit';
|
||||
import { buildBillingAccessWhereInput, getWorkspaceCreationEligibility } from '@/lib/billing';
|
||||
import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response';
|
||||
import { logError } from '@/lib/logger';
|
||||
import { eventKey, recordEvent } from '@/lib/analytics/record';
|
||||
|
||||
// GET /api/workspaces - List all workspaces for the authenticated user
|
||||
export async function GET(request: NextRequest) {
|
||||
@@ -136,6 +137,12 @@ export async function POST(request: NextRequest) {
|
||||
},
|
||||
});
|
||||
|
||||
await recordEvent({
|
||||
name: 'WORKSPACE_CREATED',
|
||||
dedupeKey: eventKey('WORKSPACE_CREATED', workspace.id),
|
||||
userId: workspace.ownerId,
|
||||
});
|
||||
|
||||
const response = successResponse(workspace, 201);
|
||||
return withCacheControl(response, 'private, no-store');
|
||||
} catch (error) {
|
||||
|
||||
@@ -107,7 +107,45 @@ function ToggleButton({
|
||||
|
||||
// ─── Step 1: Welcome ───────────────────────────────────────────────────────────
|
||||
|
||||
function StepWelcome({ userName, onNext }: { userName: string; onNext: () => void }) {
|
||||
// Asked here rather than on the registration form. The whole point of measuring
|
||||
// this funnel is the signup conversion rate, and a question added to the form
|
||||
// would move the number being measured.
|
||||
const SOURCE_OPTIONS: Array<{ value: string; label: string }> = [
|
||||
{ value: 'GITHUB', label: 'GitHub' },
|
||||
{ value: 'YOUTUBE', label: 'YouTube' },
|
||||
{ value: 'GOOGLE', label: 'A search engine' },
|
||||
{ value: 'REVIEW_LINK', label: 'A review or comparison site' },
|
||||
{ value: 'REFERRAL', label: 'Someone recommended it' },
|
||||
{ value: 'COMMUNITY', label: 'Reddit, X, Discord or a forum' },
|
||||
{ value: 'OUTBOUND', label: 'An email from us' },
|
||||
{ value: 'OTHER', label: 'Somewhere else' },
|
||||
];
|
||||
|
||||
function StepWelcome({
|
||||
userName,
|
||||
askSource,
|
||||
onNext,
|
||||
}: {
|
||||
userName: string;
|
||||
askSource: boolean;
|
||||
onNext: () => void;
|
||||
}) {
|
||||
const [source, setSource] = useState<string>('');
|
||||
const [note, setNote] = useState('');
|
||||
|
||||
const handleNext = () => {
|
||||
// Never blocks the wizard. An unanswered or failed question costs one row in
|
||||
// a cross-check column; a broken Get Started button costs the account.
|
||||
if (askSource && source) {
|
||||
void fetch('/api/onboarding/source', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ source, note: source === 'OTHER' ? note : undefined }),
|
||||
}).catch(() => undefined);
|
||||
}
|
||||
onNext();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="text-center space-y-8">
|
||||
<div className="mx-auto w-24 h-24 rounded-full bg-primary/10 flex items-center justify-center">
|
||||
@@ -122,7 +160,36 @@ function StepWelcome({ userName, onNext }: { userName: string; onNext: () => voi
|
||||
manage versions, and streamline approvals — all in one place.
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={onNext} size="lg" className="w-full sm:w-auto px-10 h-12 text-base">
|
||||
|
||||
{askSource && (
|
||||
<div className="mx-auto max-w-sm space-y-3 text-left">
|
||||
<Label htmlFor="acquisition-source" className="text-sm text-muted-foreground">
|
||||
How did you hear about us? (optional)
|
||||
</Label>
|
||||
<Select value={source} onValueChange={setSource}>
|
||||
<SelectTrigger id="acquisition-source" className="w-full">
|
||||
<SelectValue placeholder="Pick one" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{SOURCE_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{source === 'OTHER' && (
|
||||
<Input
|
||||
value={note}
|
||||
onChange={(event) => setNote(event.target.value)}
|
||||
maxLength={200}
|
||||
placeholder="Where, roughly?"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button onClick={handleNext} size="lg" className="w-full sm:w-auto px-10 h-12 text-base">
|
||||
Get Started
|
||||
<ChevronRight className="h-5 w-5 ml-1" />
|
||||
</Button>
|
||||
@@ -691,10 +758,12 @@ export function OnboardingWizard({
|
||||
userName,
|
||||
canCreateWorkspace,
|
||||
availableWorkspaces,
|
||||
askAcquisitionSource,
|
||||
}: {
|
||||
userName: string;
|
||||
canCreateWorkspace: boolean;
|
||||
availableWorkspaces: Array<{ id: string; name: string; isOwner: boolean }>;
|
||||
askAcquisitionSource: boolean;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const [currentStep, setCurrentStep] = useState(1);
|
||||
@@ -761,7 +830,9 @@ export function OnboardingWizard({
|
||||
{/* Step content */}
|
||||
<Card className="border-border/50 shadow-lg">
|
||||
<CardContent className="pt-10 pb-10 px-10">
|
||||
{currentStep === 1 && <StepWelcome userName={userName} onNext={goNext} />}
|
||||
{currentStep === 1 && (
|
||||
<StepWelcome userName={userName} askSource={askAcquisitionSource} onNext={goNext} />
|
||||
)}
|
||||
{currentStep === 2 && (
|
||||
<StepWorkspace
|
||||
canCreateWorkspace={canCreateWorkspace}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { auth } from '@/lib/auth';
|
||||
import { buildBillingAccessWhereInput, getBillingOverview } from '@/lib/billing';
|
||||
import { db } from '@/lib/db';
|
||||
import { isProductAnalyticsEnabled } from '@/lib/feature-flags';
|
||||
import { redirect } from 'next/navigation';
|
||||
import { OnboardingWizard } from './onboarding-wizard';
|
||||
|
||||
@@ -43,6 +44,7 @@ export default async function OnboardingPage() {
|
||||
<OnboardingWizard
|
||||
userName={userName}
|
||||
canCreateWorkspace={billing.workspaceCreation.canCreateWorkspace}
|
||||
askAcquisitionSource={isProductAnalyticsEnabled()}
|
||||
availableWorkspaces={creatableWorkspaces.map((workspace) => ({
|
||||
id: workspace.id,
|
||||
name: workspace.name,
|
||||
|
||||
+11
-1
@@ -1,8 +1,18 @@
|
||||
import { after } from 'next/server';
|
||||
import { LandingPage } from '@/components/LandingPage';
|
||||
import { auth } from '@/lib/auth';
|
||||
import { readPageVisitor, recordVisitorEvent } from '@/lib/analytics/visitor';
|
||||
|
||||
export default async function HomePage() {
|
||||
const session = await auth();
|
||||
const isLoggedIn = Boolean(session?.user);
|
||||
|
||||
return <LandingPage isLoggedIn={Boolean(session?.user)} />;
|
||||
// Signed-in users land here too, and counting them would put existing
|
||||
// customers at the top of the acquisition funnel.
|
||||
if (!isLoggedIn) {
|
||||
const visitor = await readPageVisitor();
|
||||
after(() => recordVisitorEvent('LANDING_VIEW', visitor));
|
||||
}
|
||||
|
||||
return <LandingPage isLoggedIn={isLoggedIn} />;
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import Image from 'next/image';
|
||||
import Link from 'next/link';
|
||||
import { CtaLink } from '@/components/marketing/cta-link';
|
||||
import { MarketingCompareLinks } from '@/components/marketing/marketing-compare-links';
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { gsap } from 'gsap';
|
||||
@@ -258,13 +259,13 @@ export function LandingPage({ isLoggedIn }: LandingPageProps) {
|
||||
data-hero-copy
|
||||
className="mx-auto flex max-w-md flex-col items-center justify-center gap-3"
|
||||
>
|
||||
<Link
|
||||
<CtaLink
|
||||
href={hostedCtaHref}
|
||||
className="group relative isolate inline-flex h-12 min-w-max items-center justify-center overflow-hidden border border-primary bg-primary px-10 text-sm font-medium whitespace-nowrap text-primary-foreground transition-transform duration-300 hover:scale-[1.02]"
|
||||
>
|
||||
Start free trial
|
||||
<MoveRight className="ml-2 h-4 w-4 transition-transform group-hover:translate-x-1" />
|
||||
</Link>
|
||||
</CtaLink>
|
||||
|
||||
<p className="text-xs text-muted-foreground">
|
||||
7-day free trial · Flat $10/mo — no per-seat fees · No client accounts
|
||||
@@ -717,12 +718,12 @@ export function LandingPage({ isLoggedIn }: LandingPageProps) {
|
||||
Need more storage? Add 100 GB for $5/mo.
|
||||
</p>
|
||||
|
||||
<Link
|
||||
<CtaLink
|
||||
href={hostedCtaHref}
|
||||
className="mt-auto group relative isolate inline-flex h-12 w-full items-center justify-center overflow-hidden bg-[#06b6d4] font-medium text-black transition-colors hover:bg-[#06b6d4]/90 text-sm"
|
||||
>
|
||||
Start free trial
|
||||
</Link>
|
||||
</CtaLink>
|
||||
</div>
|
||||
|
||||
{/* Card 2: Fair Source (Self-hosted) */}
|
||||
@@ -901,12 +902,12 @@ export function LandingPage({ isLoggedIn }: LandingPageProps) {
|
||||
Your first review link takes minutes.
|
||||
</p>
|
||||
</div>
|
||||
<Link
|
||||
<CtaLink
|
||||
href={hostedCtaHref}
|
||||
className="group relative isolate inline-flex h-12 min-w-max items-center justify-center overflow-hidden border border-primary bg-primary px-10 text-sm font-medium whitespace-nowrap text-primary-foreground transition-transform duration-300 hover:scale-[1.02] md:min-w-[240px]"
|
||||
>
|
||||
Start free trial
|
||||
</Link>
|
||||
</CtaLink>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import Link from 'next/link';
|
||||
import { ArrowRight, Github, MoveRight } from 'lucide-react';
|
||||
import { CtaLink } from '@/components/marketing/cta-link';
|
||||
import { FeatureComparisonTable } from '@/components/marketing/feature-comparison-table';
|
||||
import { MarketingFooter } from '@/components/marketing/marketing-footer';
|
||||
import { MarketingHeader } from '@/components/marketing/marketing-header';
|
||||
@@ -41,13 +42,13 @@ export function ComparisonPage({ page, isLoggedIn }: ComparisonPageProps) {
|
||||
$10/month hosted plan covers your whole team and every client reviewer link.
|
||||
</p>
|
||||
<div className="mt-8 flex flex-col gap-3 sm:flex-row">
|
||||
<Link
|
||||
<CtaLink
|
||||
href={hostedCtaHref}
|
||||
className="group relative isolate inline-flex h-12 items-center justify-center overflow-hidden border border-primary bg-primary px-8 text-sm font-medium text-primary-foreground transition-transform duration-300 hover:scale-[1.02]"
|
||||
>
|
||||
Start free trial
|
||||
<MoveRight className="ml-2 h-4 w-4 transition-transform group-hover:translate-x-1" />
|
||||
</Link>
|
||||
</CtaLink>
|
||||
<a
|
||||
href={seoConfig.githubUrl}
|
||||
target="_blank"
|
||||
@@ -199,12 +200,12 @@ export function ComparisonPage({ page, isLoggedIn }: ComparisonPageProps) {
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-3 sm:flex-row">
|
||||
<Link
|
||||
<CtaLink
|
||||
href={hostedCtaHref}
|
||||
className="inline-flex h-12 items-center justify-center border border-primary bg-primary px-8 text-sm font-medium text-primary-foreground"
|
||||
>
|
||||
Start free trial
|
||||
</Link>
|
||||
</CtaLink>
|
||||
<a
|
||||
href={seoConfig.githubUrl}
|
||||
target="_blank"
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { trackCtaClick } from '@/lib/client/track';
|
||||
|
||||
interface CtaLinkProps {
|
||||
href: string;
|
||||
className?: string;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* A marketing call to action that reports the click.
|
||||
*
|
||||
* Only the signup CTA counts. The same button reads "Start free trial" and
|
||||
* points at `/dashboard` once you are signed in, and an existing customer
|
||||
* clicking through to their own dashboard is not a step in the acquisition
|
||||
* funnel.
|
||||
*/
|
||||
export function CtaLink({ href, className, children }: CtaLinkProps) {
|
||||
return (
|
||||
<Link
|
||||
href={href}
|
||||
className={className}
|
||||
onClick={href === '/register' ? trackCtaClick : undefined}
|
||||
>
|
||||
{children}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
// Turning Stripe state into funnel events.
|
||||
//
|
||||
// These four events are derived from a before/after comparison inside the sync
|
||||
// that already re-reads every subscription a customer has, rather than from the
|
||||
// webhook event types. That is deliberate: webhooks arrive out of order and get
|
||||
// replayed, and `customer.subscription.updated` fires for changes that mean
|
||||
// nothing here. Comparing the row we are about to overwrite with the row we are
|
||||
// writing is order-independent, and the dedupe keys make a replay a no-op.
|
||||
|
||||
import type { BillingSubscriptionStatus } from '@prisma/client';
|
||||
import { eventKey, recordEvent } from '@/lib/analytics/record';
|
||||
import { isProductAnalyticsEnabled } from '@/lib/feature-flags';
|
||||
|
||||
export interface SubscriptionStateBefore {
|
||||
status: BillingSubscriptionStatus;
|
||||
cancelAtPeriodEnd: boolean;
|
||||
/** Whether this account had already consumed a trial before this sync. */
|
||||
hadTrial: boolean;
|
||||
}
|
||||
|
||||
export interface SubscriptionStateAfter {
|
||||
status: BillingSubscriptionStatus;
|
||||
cancelAtPeriodEnd: boolean;
|
||||
trialEndsAt: Date | null;
|
||||
currentPeriodEnd: Date | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* A cancellation and the reactivation that may follow it both belong to a
|
||||
* billing cycle. Keying them on the period end lets a customer cancel, come
|
||||
* back, and cancel again in a later cycle without the second one being
|
||||
* swallowed as a duplicate, while the two Stripe writes that describe a single
|
||||
* cancellation (the `cancel_at_period_end` flag now, the `canceled` status
|
||||
* later) collapse into one event.
|
||||
*/
|
||||
function cycleMarker(currentPeriodEnd: Date | null): string {
|
||||
return String(currentPeriodEnd ? currentPeriodEnd.getTime() : 0);
|
||||
}
|
||||
|
||||
export async function recordSubscriptionTransition(params: {
|
||||
userId: string;
|
||||
subscriptionId: string;
|
||||
before: SubscriptionStateBefore;
|
||||
after: SubscriptionStateAfter;
|
||||
}): Promise<void> {
|
||||
if (!isProductAnalyticsEnabled()) return;
|
||||
|
||||
const { userId, subscriptionId, before, after } = params;
|
||||
const cycle = cycleMarker(after.currentPeriodEnd);
|
||||
|
||||
// Once per account for its lifetime. A second trial is not a second start of
|
||||
// the funnel, and Stripe will not grant one anyway.
|
||||
if (after.trialEndsAt && !before.hadTrial) {
|
||||
await recordEvent({
|
||||
name: 'TRIAL_STARTED',
|
||||
dedupeKey: eventKey('TRIAL_STARTED', userId),
|
||||
userId,
|
||||
});
|
||||
}
|
||||
|
||||
// The paying moment. With a trial the status goes trialing -> active, so this
|
||||
// fires on conversion rather than on signup for the trial.
|
||||
if (after.status === 'ACTIVE' && before.status !== 'ACTIVE') {
|
||||
await recordEvent({
|
||||
name: 'SUBSCRIPTION_STARTED',
|
||||
dedupeKey: eventKey('SUBSCRIPTION_STARTED', subscriptionId),
|
||||
userId,
|
||||
});
|
||||
}
|
||||
|
||||
const startedCanceling = after.cancelAtPeriodEnd && !before.cancelAtPeriodEnd;
|
||||
const becameCanceled = after.status === 'CANCELED' && before.status !== 'CANCELED';
|
||||
if (startedCanceling || becameCanceled) {
|
||||
await recordEvent({
|
||||
name: 'SUBSCRIPTION_CANCELED',
|
||||
dedupeKey: `SUBSCRIPTION_CANCELED:${subscriptionId}:${cycle}`,
|
||||
userId,
|
||||
});
|
||||
}
|
||||
|
||||
if (!after.cancelAtPeriodEnd && before.cancelAtPeriodEnd && after.status !== 'CANCELED') {
|
||||
await recordEvent({
|
||||
name: 'SUBSCRIPTION_REACTIVATED',
|
||||
dedupeKey: `SUBSCRIPTION_REACTIVATED:${subscriptionId}:${cycle}`,
|
||||
userId,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
// Traffic that is not a person.
|
||||
//
|
||||
// This matters more than it looks. Visitors are the denominator of every
|
||||
// conversion rate in the scoreboard, so counting a crawler as a visit does not
|
||||
// add noise evenly: it quietly makes every channel look worse, and the channels
|
||||
// that attract the most crawling (an indexed landing page, a GitHub README link)
|
||||
// look worst of all.
|
||||
|
||||
const BOT_PATTERN =
|
||||
/bot\b|bots\b|crawler|spider|crawl|slurp|facebookexternalhit|embedly|quora link preview|whatsapp|telegram|discordbot|slackbot|preview|monitor|uptime|pingdom|curl\/|wget\/|python-requests|python-urllib|scrapy|axios\/|node-fetch|go-http-client|okhttp|java\/|headlesschrome|phantomjs|lighthouse|semrush|ahrefs|mj12|dotbot|petalbot|bytespider|gptbot|claudebot|ccbot/i;
|
||||
|
||||
/**
|
||||
* A missing user agent counts as a bot. Every real browser sends one, so the
|
||||
* blank case is a script that did not bother.
|
||||
*/
|
||||
export function isLikelyBot(userAgent: string | null | undefined): boolean {
|
||||
if (typeof userAgent !== 'string') return true;
|
||||
const value = userAgent.trim();
|
||||
if (!value) return true;
|
||||
return BOT_PATTERN.test(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a request is a real page load rather than a prefetch, an asset or a
|
||||
* client-side navigation payload.
|
||||
*
|
||||
* Next prefetches the register page as soon as a CTA scrolls into view, so
|
||||
* without this the funnel would show more signup starts than landing views.
|
||||
*/
|
||||
export function isCountableDocumentRequest(headers: Headers): boolean {
|
||||
if (headers.get('sec-purpose')?.includes('prefetch')) return false;
|
||||
if (headers.get('purpose') === 'prefetch') return false;
|
||||
if (headers.get('next-router-prefetch')) return false;
|
||||
// An RSC navigation is the same visitor moving inside the app, not a new view.
|
||||
if (headers.get('rsc')) return false;
|
||||
|
||||
const dest = headers.get('sec-fetch-dest');
|
||||
if (dest) return dest === 'document';
|
||||
|
||||
// Older browsers and anything behind a proxy that strips fetch metadata.
|
||||
return headers.get('accept')?.includes('text/html') ?? false;
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
// Turns whatever the browser told us about a visit into one of nine buckets.
|
||||
//
|
||||
// Pure and dependency-free on purpose: this runs in the proxy (edge runtime), so
|
||||
// the only Prisma reference here is a type-only import, which the compiler erases.
|
||||
//
|
||||
// Everything that reaches this file is already reduced to a host and a couple of
|
||||
// UTM tags. Nothing here ever sees a full URL, so a query string carrying a share
|
||||
// token or an email address cannot be classified into a stored column by mistake.
|
||||
|
||||
import type { AcquisitionChannel } from '@prisma/client';
|
||||
|
||||
export interface ChannelInput {
|
||||
utmSource?: string | null;
|
||||
utmMedium?: string | null;
|
||||
referrerHost?: string | null;
|
||||
}
|
||||
|
||||
const MAX_TAG_LENGTH = 64;
|
||||
const MAX_PATH_LENGTH = 128;
|
||||
|
||||
/** Lowercases, trims and caps a UTM tag. Returns null for anything empty. */
|
||||
export function sanitizeTag(value: string | null | undefined): string | null {
|
||||
if (typeof value !== 'string') return null;
|
||||
const cleaned = value.trim().toLowerCase().slice(0, MAX_TAG_LENGTH);
|
||||
if (!cleaned) return null;
|
||||
// Campaign names are ours, so anything outside this set is either a typo or
|
||||
// somebody probing what the column accepts. Drop it rather than store it.
|
||||
if (!/^[a-z0-9._%+\- ]+$/.test(cleaned)) return null;
|
||||
return cleaned;
|
||||
}
|
||||
|
||||
/**
|
||||
* Strips the `www.` prefix and the port, lowercases, and caps the length.
|
||||
*
|
||||
* A whole URL is not a host and comes back null. Splitting on the first colon
|
||||
* would otherwise turn `https://github.com` into the host `https`, and every
|
||||
* caller that passed one by mistake would file its traffic under a channel that
|
||||
* does not exist.
|
||||
*/
|
||||
export function normalizeHost(value: string | null | undefined): string | null {
|
||||
if (typeof value !== 'string') return null;
|
||||
const trimmed = value.trim().toLowerCase();
|
||||
const withoutPort = trimmed.replace(/:\d+$/, '');
|
||||
const host = withoutPort.replace(/^www\./, '');
|
||||
if (!host || !/^[a-z0-9.\-]+$/.test(host)) return null;
|
||||
return host.slice(0, MAX_TAG_LENGTH);
|
||||
}
|
||||
|
||||
/**
|
||||
* The referring host, or null when there is no usable one.
|
||||
*
|
||||
* A referrer pointing at our own deployment is not a referrer: it is the visitor
|
||||
* clicking through the site. Treating it as one would file most of the funnel
|
||||
* under whatever page they happened to start on.
|
||||
*/
|
||||
export function extractReferrerHost(
|
||||
referrer: string | null | undefined,
|
||||
selfHost?: string | null
|
||||
): string | null {
|
||||
if (!referrer) return null;
|
||||
let host: string | null;
|
||||
try {
|
||||
const url = new URL(referrer);
|
||||
// Browsers only ever send an http(s) referrer. Anything else is a scheme we
|
||||
// have no host for, such as `android-app://com.example`, and reading its
|
||||
// opaque body as a domain would invent a referring site.
|
||||
if (url.protocol !== 'http:' && url.protocol !== 'https:') return null;
|
||||
host = normalizeHost(url.hostname);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (!host) return null;
|
||||
const self = normalizeHost(selfHost);
|
||||
if (self && host === self) return null;
|
||||
return host;
|
||||
}
|
||||
|
||||
// What a URL path is allowed to be made of, per RFC 3986: unreserved characters,
|
||||
// percent escapes, sub-delims and the separators. Everything a real route can
|
||||
// carry, and nothing that survives being pasted into a page or a log line.
|
||||
const LANDING_PATH_PATTERN = /^\/[A-Za-z0-9\-._~%!$&'()*+,;=:@/]*$/;
|
||||
|
||||
/**
|
||||
* Path only, no query string and no fragment, capped and character-checked.
|
||||
*
|
||||
* The proxy feeds this `request.nextUrl.pathname`, which is already a path. The
|
||||
* cookie reader feeds it whatever the cookie said, which is why the allowlist is
|
||||
* here rather than left to the caller: an unchecked value would put newlines and
|
||||
* markup into a column that some later admin table renders.
|
||||
*/
|
||||
export function sanitizeLandingPath(pathname: string | null | undefined): string {
|
||||
if (typeof pathname !== 'string' || !pathname.startsWith('/')) return '/';
|
||||
const path = (pathname.split('?')[0]?.split('#')[0] ?? '/').slice(0, MAX_PATH_LENGTH);
|
||||
if (!path || !LANDING_PATH_PATTERN.test(path)) return '/';
|
||||
return path;
|
||||
}
|
||||
|
||||
function suffixMatch(host: string, domain: string): boolean {
|
||||
return host === domain || host.endsWith(`.${domain}`);
|
||||
}
|
||||
|
||||
const GITHUB_HOSTS = ['github.com', 'github.blog'];
|
||||
const YOUTUBE_HOSTS = ['youtube.com', 'youtu.be'];
|
||||
|
||||
// The bucket the plan calls "google" is really organic search. Google is the
|
||||
// overwhelming majority of it, and splitting Bing and DuckDuckGo into their own
|
||||
// slivers would make every row in the scoreboard smaller without changing a
|
||||
// single decision.
|
||||
const SEARCH_HOSTS = ['google.com', 'bing.com', 'duckduckgo.com', 'ecosia.org', 'yandex.com'];
|
||||
|
||||
const REVIEW_HOSTS = [
|
||||
'producthunt.com',
|
||||
'g2.com',
|
||||
'capterra.com',
|
||||
'getapp.com',
|
||||
'alternativeto.net',
|
||||
'saashub.com',
|
||||
'slant.co',
|
||||
'trustpilot.com',
|
||||
'sourceforge.net',
|
||||
];
|
||||
|
||||
const COMMUNITY_HOSTS = [
|
||||
'reddit.com',
|
||||
'news.ycombinator.com',
|
||||
'lobste.rs',
|
||||
'discord.com',
|
||||
'discord.gg',
|
||||
'x.com',
|
||||
'twitter.com',
|
||||
't.co',
|
||||
'linkedin.com',
|
||||
'lnkd.in',
|
||||
'bsky.app',
|
||||
'mastodon.social',
|
||||
'dev.to',
|
||||
'indiehackers.com',
|
||||
'facebook.com',
|
||||
'instagram.com',
|
||||
't.me',
|
||||
];
|
||||
|
||||
// utm_source values we set ourselves, plus the ones other people tend to use
|
||||
// when they link us. Matched exactly after sanitizing.
|
||||
const SOURCE_NAMES: ReadonlyMap<string, AcquisitionChannel> = new Map([
|
||||
['github', 'GITHUB'],
|
||||
['youtube', 'YOUTUBE'],
|
||||
['yt', 'YOUTUBE'],
|
||||
['google', 'GOOGLE'],
|
||||
['bing', 'GOOGLE'],
|
||||
['duckduckgo', 'GOOGLE'],
|
||||
['producthunt', 'REVIEW_LINK'],
|
||||
['product-hunt', 'REVIEW_LINK'],
|
||||
['g2', 'REVIEW_LINK'],
|
||||
['capterra', 'REVIEW_LINK'],
|
||||
['alternativeto', 'REVIEW_LINK'],
|
||||
['reddit', 'COMMUNITY'],
|
||||
['hackernews', 'COMMUNITY'],
|
||||
['hn', 'COMMUNITY'],
|
||||
['discord', 'COMMUNITY'],
|
||||
['twitter', 'COMMUNITY'],
|
||||
['x', 'COMMUNITY'],
|
||||
['linkedin', 'COMMUNITY'],
|
||||
['newsletter', 'OUTBOUND'],
|
||||
['coldmail', 'OUTBOUND'],
|
||||
['outreach', 'OUTBOUND'],
|
||||
]);
|
||||
|
||||
// A medium that names the motion beats the source that names the place: an
|
||||
// outbound campaign sent from a LinkedIn account is outbound, not community.
|
||||
const MEDIUM_NAMES: ReadonlyMap<string, AcquisitionChannel> = new Map([
|
||||
['outbound', 'OUTBOUND'],
|
||||
['email', 'OUTBOUND'],
|
||||
['cold-email', 'OUTBOUND'],
|
||||
['coldemail', 'OUTBOUND'],
|
||||
['dm', 'OUTBOUND'],
|
||||
['referral', 'REFERRAL'],
|
||||
['affiliate', 'REFERRAL'],
|
||||
]);
|
||||
|
||||
function classifyHost(host: string): AcquisitionChannel | null {
|
||||
if (GITHUB_HOSTS.some((domain) => suffixMatch(host, domain))) return 'GITHUB';
|
||||
if (YOUTUBE_HOSTS.some((domain) => suffixMatch(host, domain))) return 'YOUTUBE';
|
||||
// google.co.uk, google.de and the rest: the country domains all sit under a
|
||||
// `google.<tld>` label, so match the label rather than listing 190 domains.
|
||||
if (/(^|\.)google\.[a-z.]{2,6}$/.test(host)) return 'GOOGLE';
|
||||
if (SEARCH_HOSTS.some((domain) => suffixMatch(host, domain))) return 'GOOGLE';
|
||||
if (REVIEW_HOSTS.some((domain) => suffixMatch(host, domain))) return 'REVIEW_LINK';
|
||||
if (COMMUNITY_HOSTS.some((domain) => suffixMatch(host, domain))) return 'COMMUNITY';
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The bucket a visit belongs to.
|
||||
*
|
||||
* Precedence: an explicit medium that names the motion, then an explicit source,
|
||||
* then the referring host, then direct. A tagged campaign we do not recognise is
|
||||
* OTHER rather than DIRECT, because somebody deliberately tagged it.
|
||||
*
|
||||
* An unrecognised site that links to us counts as REFERRAL. The raw host is
|
||||
* stored alongside, so a host that turns out to matter can be promoted into one
|
||||
* of the lists above and re-read from history.
|
||||
*/
|
||||
export function classifyChannel(input: ChannelInput): AcquisitionChannel {
|
||||
const source = sanitizeTag(input.utmSource);
|
||||
const medium = sanitizeTag(input.utmMedium);
|
||||
const host = normalizeHost(input.referrerHost);
|
||||
|
||||
const byMedium = medium ? MEDIUM_NAMES.get(medium) : undefined;
|
||||
if (byMedium) return byMedium;
|
||||
|
||||
if (source) {
|
||||
const bySource = SOURCE_NAMES.get(source);
|
||||
if (bySource) return bySource;
|
||||
// A source that looks like a domain (utm_source=github.com) is worth reading
|
||||
// as one before giving up on it.
|
||||
return classifyHost(source) ?? 'OTHER';
|
||||
}
|
||||
|
||||
if (host) {
|
||||
return classifyHost(host) ?? 'REFERRAL';
|
||||
}
|
||||
|
||||
return 'DIRECT';
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
// The two cookies the acquisition system sets, and how to read them back.
|
||||
//
|
||||
// Both are first party, both stay on this deployment's own domain, and neither
|
||||
// is readable from JavaScript. They exist so that a visitor who arrives from a
|
||||
// YouTube link on Tuesday and signs up on Friday is still counted against
|
||||
// YouTube; there is no cross-site identifier and nothing is sent anywhere.
|
||||
//
|
||||
// Both are also signed. Nothing here trusts a cookie it did not issue: read
|
||||
// through `readAnonymousIdCookie` and `readFirstTouchCookie`, never through the
|
||||
// `decode` helpers, which are the unsigned inner layer.
|
||||
//
|
||||
// Imported by the proxy, so this file must stay free of Prisma and of anything
|
||||
// else that cannot run on the edge.
|
||||
|
||||
import type { AcquisitionChannel } from '@prisma/client';
|
||||
import { sanitizeLandingPath, sanitizeTag, normalizeHost } from '@/lib/analytics/channel';
|
||||
import { signCookieValue, unsignCookieValue } from '@/lib/analytics/signing';
|
||||
|
||||
export const ANONYMOUS_ID_COOKIE = 'of_aid';
|
||||
export const FIRST_TOUCH_COOKIE = 'of_ft';
|
||||
|
||||
export const ANONYMOUS_ID_MAX_AGE_SECONDS = 60 * 60 * 24 * 365;
|
||||
|
||||
/** cuid-ish length bound. Values outside it are treated as absent, not repaired. */
|
||||
const ANONYMOUS_ID_PATTERN = /^[a-z0-9]{16,64}$/;
|
||||
|
||||
export interface FirstTouch {
|
||||
channel: AcquisitionChannel;
|
||||
utmSource: string | null;
|
||||
utmMedium: string | null;
|
||||
utmCampaign: string | null;
|
||||
referrerHost: string | null;
|
||||
landingPath: string;
|
||||
}
|
||||
|
||||
/** Short keys: this rides on every request, so the wire form stays compact. */
|
||||
interface EncodedFirstTouch {
|
||||
c: string;
|
||||
s?: string;
|
||||
m?: string;
|
||||
k?: string;
|
||||
r?: string;
|
||||
p: string;
|
||||
}
|
||||
|
||||
const CHANNELS: readonly AcquisitionChannel[] = [
|
||||
'DIRECT',
|
||||
'GITHUB',
|
||||
'YOUTUBE',
|
||||
'GOOGLE',
|
||||
'REVIEW_LINK',
|
||||
'REFERRAL',
|
||||
'OUTBOUND',
|
||||
'COMMUNITY',
|
||||
'OTHER',
|
||||
];
|
||||
|
||||
export function isAcquisitionChannel(value: unknown): value is AcquisitionChannel {
|
||||
return typeof value === 'string' && (CHANNELS as readonly string[]).includes(value);
|
||||
}
|
||||
|
||||
export function isValidAnonymousId(value: string | null | undefined): value is string {
|
||||
return typeof value === 'string' && ANONYMOUS_ID_PATTERN.test(value);
|
||||
}
|
||||
|
||||
/** 128 bits from the Web Crypto API, which the edge has, as 32 base36 characters. */
|
||||
export function generateAnonymousId(): string {
|
||||
const bytes = new Uint8Array(16);
|
||||
crypto.getRandomValues(bytes);
|
||||
let id = '';
|
||||
for (const byte of bytes) {
|
||||
id += byte.toString(36).padStart(2, '0');
|
||||
}
|
||||
return id;
|
||||
}
|
||||
|
||||
// base64url rather than encodeURIComponent, and not for compactness. Cookie
|
||||
// values are percent-encoded on the way out and decoded on the way back, by
|
||||
// several layers that do not all agree on how many times; a payload that already
|
||||
// contains percent escapes comes back subtly different and takes the signature
|
||||
// down with it. base64url has nothing either layer wants to touch.
|
||||
function toBase64Url(text: string): string {
|
||||
let binary = '';
|
||||
for (const byte of new TextEncoder().encode(text)) {
|
||||
binary += String.fromCharCode(byte);
|
||||
}
|
||||
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
||||
}
|
||||
|
||||
function fromBase64Url(value: string): string | null {
|
||||
try {
|
||||
const padded = value.replace(/-/g, '+').replace(/_/g, '/');
|
||||
const binary = atob(padded.padEnd(Math.ceil(padded.length / 4) * 4, '='));
|
||||
const bytes = Uint8Array.from(binary, (character) => character.charCodeAt(0));
|
||||
return new TextDecoder().decode(bytes);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function encodeFirstTouch(touch: FirstTouch): string {
|
||||
const payload: EncodedFirstTouch = { c: touch.channel, p: touch.landingPath };
|
||||
if (touch.utmSource) payload.s = touch.utmSource;
|
||||
if (touch.utmMedium) payload.m = touch.utmMedium;
|
||||
if (touch.utmCampaign) payload.k = touch.utmCampaign;
|
||||
if (touch.referrerHost) payload.r = touch.referrerHost;
|
||||
return toBase64Url(JSON.stringify(payload));
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses the cookie body back, re-sanitizing every field.
|
||||
*
|
||||
* The second line of defence, not the first: callers go through
|
||||
* `readFirstTouchCookie`, which checks the signature before this ever runs. The
|
||||
* re-sanitizing stays because a value that survives both checks can still be one
|
||||
* this deployment signed a year ago, under an older set of rules. Anything that
|
||||
* fails validation makes the whole value null, since a half-trusted first touch
|
||||
* is worse than none.
|
||||
*/
|
||||
export function decodeFirstTouch(raw: string | null | undefined): FirstTouch | null {
|
||||
if (!raw) return null;
|
||||
|
||||
const json = fromBase64Url(raw);
|
||||
if (!json) return null;
|
||||
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(json);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!parsed || typeof parsed !== 'object') return null;
|
||||
const value = parsed as Record<string, unknown>;
|
||||
|
||||
if (!isAcquisitionChannel(value.c)) return null;
|
||||
if (typeof value.p !== 'string') return null;
|
||||
|
||||
return {
|
||||
channel: value.c,
|
||||
utmSource: sanitizeTag(typeof value.s === 'string' ? value.s : null),
|
||||
utmMedium: sanitizeTag(typeof value.m === 'string' ? value.m : null),
|
||||
utmCampaign: sanitizeTag(typeof value.k === 'string' ? value.k : null),
|
||||
referrerHost: normalizeHost(typeof value.r === 'string' ? value.r : null),
|
||||
landingPath: sanitizeLandingPath(value.p),
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// The signed forms, which are the only ones anything outside this file uses.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** The cookie value to set, or null when there is no secret to sign it with. */
|
||||
export function signAnonymousId(anonymousId: string): Promise<string | null> {
|
||||
return signCookieValue(anonymousId);
|
||||
}
|
||||
|
||||
export function signFirstTouch(touch: FirstTouch): Promise<string | null> {
|
||||
return signCookieValue(encodeFirstTouch(touch));
|
||||
}
|
||||
|
||||
/**
|
||||
* The anonymous id this deployment issued, or null.
|
||||
*
|
||||
* Null covers every failure the same way: no cookie, a cookie signed with
|
||||
* another key, one edited by hand, one whose id no longer matches the shape we
|
||||
* mint. A visitor we cannot vouch for is not counted rather than counted wrong.
|
||||
*/
|
||||
export async function readAnonymousIdCookie(
|
||||
raw: string | null | undefined
|
||||
): Promise<string | null> {
|
||||
const anonymousId = await unsignCookieValue(raw);
|
||||
return isValidAnonymousId(anonymousId) ? anonymousId : null;
|
||||
}
|
||||
|
||||
export async function readFirstTouchCookie(
|
||||
raw: string | null | undefined
|
||||
): Promise<FirstTouch | null> {
|
||||
return decodeFirstTouch(await unsignCookieValue(raw));
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
// The only way anything in this repo writes an analytics row.
|
||||
//
|
||||
// Two things are centralised here so that no call site has to remember them:
|
||||
//
|
||||
// 1. The feature flag. Every function below returns without touching the
|
||||
// database when OPENFRAME_ENABLE_ANALYTICS is off, which is why the ~15 call
|
||||
// sites scattered through app/api are unconditional one-liners.
|
||||
// 2. Failure. Measurement must never be able to fail a product request, so
|
||||
// every write is caught and logged. An event that is not recorded is a hole
|
||||
// in a chart; an event that throws is a user who cannot create a project.
|
||||
|
||||
import type { AcquisitionChannel, AnalyticsEventName } from '@prisma/client';
|
||||
import { db } from '@/lib/db';
|
||||
import { isProductAnalyticsEnabled } from '@/lib/feature-flags';
|
||||
import { logError } from '@/lib/logger';
|
||||
import type { FirstTouch } from '@/lib/analytics/cookies';
|
||||
|
||||
export interface RecordEventInput {
|
||||
name: AnalyticsEventName;
|
||||
/**
|
||||
* What makes this event unique. The column is UNIQUE, so a replayed webhook, a
|
||||
* double-submitted form or a refreshed page collide here and the second write
|
||||
* is dropped by the database rather than by a check somebody might forget.
|
||||
*/
|
||||
dedupeKey: string;
|
||||
userId?: string | null;
|
||||
anonymousId?: string | null;
|
||||
/**
|
||||
* Only set for events that happen before there is an account. Once a user
|
||||
* exists their channel lives in user_acquisitions and the scoreboard reads it
|
||||
* from there, so a later correction applies to their whole history.
|
||||
*/
|
||||
channel?: AcquisitionChannel | null;
|
||||
occurredAt?: Date;
|
||||
}
|
||||
|
||||
/** `<event>:<subject>`, for something that can only ever happen once per subject. */
|
||||
export function eventKey(name: AnalyticsEventName, subject: string): string {
|
||||
return `${name}:${subject}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* `<event>:<subject>:<UTC day>`, for something repeatable that should still count
|
||||
* once per visitor per day (a landing view, a CTA click).
|
||||
*/
|
||||
export function dailyEventKey(
|
||||
name: AnalyticsEventName,
|
||||
subject: string,
|
||||
at: Date = new Date()
|
||||
): string {
|
||||
return `${name}:${subject}:${at.toISOString().slice(0, 10)}`;
|
||||
}
|
||||
|
||||
export async function recordEvent(input: RecordEventInput): Promise<void> {
|
||||
if (!isProductAnalyticsEnabled()) return;
|
||||
|
||||
try {
|
||||
await db.analyticsEvent.createMany({
|
||||
data: [
|
||||
{
|
||||
name: input.name,
|
||||
dedupeKey: input.dedupeKey,
|
||||
userId: input.userId ?? null,
|
||||
anonymousId: input.anonymousId ?? null,
|
||||
channel: input.channel ?? null,
|
||||
...(input.occurredAt ? { occurredAt: input.occurredAt } : {}),
|
||||
},
|
||||
],
|
||||
skipDuplicates: true,
|
||||
});
|
||||
} catch (error) {
|
||||
logError('Failed to record analytics event:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stores the first touch for a visitor with no account yet.
|
||||
*
|
||||
* Never updated. A visitor who comes back a week later through a different link
|
||||
* keeps the channel that brought them the first time, which is the question the
|
||||
* scoreboard is asking.
|
||||
*/
|
||||
export async function recordFirstTouch(anonymousId: string, touch: FirstTouch): Promise<void> {
|
||||
if (!isProductAnalyticsEnabled()) return;
|
||||
|
||||
try {
|
||||
await db.acquisitionTouch.createMany({
|
||||
data: [
|
||||
{
|
||||
anonymousId,
|
||||
channel: touch.channel,
|
||||
utmSource: touch.utmSource,
|
||||
utmMedium: touch.utmMedium,
|
||||
utmCampaign: touch.utmCampaign,
|
||||
referrerHost: touch.referrerHost,
|
||||
landingPath: touch.landingPath,
|
||||
},
|
||||
],
|
||||
skipDuplicates: true,
|
||||
});
|
||||
} catch (error) {
|
||||
logError('Failed to record acquisition touch:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Copies the first touch onto a freshly created account and claims the events
|
||||
* that visitor produced before they had one.
|
||||
*
|
||||
* The backfill is what joins the two halves of the funnel: without it a landing
|
||||
* view and the signup it led to are two unrelated rows, and no query can tell
|
||||
* you that GitHub traffic converts and Google traffic does not.
|
||||
*/
|
||||
export async function attachAcquisitionToUser(params: {
|
||||
userId: string;
|
||||
anonymousId: string | null;
|
||||
touch: FirstTouch | null;
|
||||
}): Promise<void> {
|
||||
if (!isProductAnalyticsEnabled()) return;
|
||||
|
||||
const { userId, anonymousId, touch } = params;
|
||||
|
||||
try {
|
||||
await db.userAcquisition.createMany({
|
||||
data: [
|
||||
{
|
||||
userId,
|
||||
anonymousId,
|
||||
channel: touch?.channel ?? 'DIRECT',
|
||||
utmSource: touch?.utmSource ?? null,
|
||||
utmMedium: touch?.utmMedium ?? null,
|
||||
utmCampaign: touch?.utmCampaign ?? null,
|
||||
referrerHost: touch?.referrerHost ?? null,
|
||||
landingPath: touch?.landingPath ?? null,
|
||||
},
|
||||
],
|
||||
skipDuplicates: true,
|
||||
});
|
||||
|
||||
if (anonymousId) {
|
||||
await db.analyticsEvent.updateMany({
|
||||
where: { anonymousId, userId: null },
|
||||
data: { userId },
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
logError('Failed to attach acquisition to user:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The answer to the onboarding question, which is a check on the cookie rather
|
||||
* than a replacement for it: it survives a cleared cookie and a phone-to-laptop
|
||||
* switch, and it is the only signal that can catch a channel the UTM tags miss
|
||||
* entirely ("a friend told me").
|
||||
*/
|
||||
export async function setSelfReportedSource(params: {
|
||||
userId: string;
|
||||
selfReported: AcquisitionChannel;
|
||||
note?: string | null;
|
||||
}): Promise<void> {
|
||||
if (!isProductAnalyticsEnabled()) return;
|
||||
|
||||
const note = params.note?.trim().slice(0, 200) || null;
|
||||
|
||||
try {
|
||||
await db.userAcquisition.upsert({
|
||||
where: { userId: params.userId },
|
||||
create: {
|
||||
userId: params.userId,
|
||||
channel: 'DIRECT',
|
||||
selfReported: params.selfReported,
|
||||
selfReportedNote: note,
|
||||
},
|
||||
update: {
|
||||
selfReported: params.selfReported,
|
||||
selfReportedNote: note,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
logError('Failed to store self-reported acquisition source:', error);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,341 @@
|
||||
// The Monday scoreboard, as queries.
|
||||
//
|
||||
// Two decisions here are worth stating, because they are what make the numbers
|
||||
// readable rather than merely present:
|
||||
//
|
||||
// 1. Rates, not just counts. A funnel is a set of ratios; the step with the
|
||||
// worst ratio is the thing to fix, and a column of absolute numbers hides it.
|
||||
// 2. Every rate carries its denominator. At this volume a weekly per-channel
|
||||
// cell holds single digits, and 1 out of 3 renders as "33%" exactly as
|
||||
// confidently as 340 out of 1020. The channel view therefore runs on a
|
||||
// rolling 28-day window rather than a week, and still reports `n`.
|
||||
|
||||
import type { AcquisitionChannel } from '@prisma/client';
|
||||
import { db } from '@/lib/db';
|
||||
import { getCachedStripeStats } from '@/lib/admin-stats';
|
||||
|
||||
/** What "using the product" means for a paying account. */
|
||||
export const VALUE_EVENT_NAMES = [
|
||||
'VIDEO_ADDED',
|
||||
'SHARE_LINK_CREATED',
|
||||
'FIRST_GUEST_COMMENT',
|
||||
'APPROVAL_COMPLETED',
|
||||
'PROJECT_CREATED',
|
||||
] as const;
|
||||
|
||||
/** A paid account that has produced nothing for this long is drifting away. */
|
||||
export const AT_RISK_SILENT_DAYS = 14;
|
||||
|
||||
const DEFAULT_WEEKS = 12;
|
||||
const CHANNEL_WINDOW_DAYS = 28;
|
||||
|
||||
/**
|
||||
* How many paid accounts the per-account table carries.
|
||||
*
|
||||
* The list is ordered quietest first, so the cap drops the accounts that are
|
||||
* using the product most, which are the ones nobody needs to read a row about.
|
||||
* It is reported rather than applied silently: a truncated table that looks
|
||||
* complete is worse than a smaller one that says so.
|
||||
*/
|
||||
const PAID_ACCOUNT_LIMIT = 500;
|
||||
|
||||
export interface WeeklyRow {
|
||||
weekStart: Date;
|
||||
visitors: number;
|
||||
ctaClicks: number;
|
||||
signupStarted: number;
|
||||
signups: number;
|
||||
emailVerified: number;
|
||||
firstVideo: number;
|
||||
shareLinks: number;
|
||||
externalFeedback: number;
|
||||
trials: number;
|
||||
newPaid: number;
|
||||
canceled: number;
|
||||
/** Running net of started minus canceled. Derived, not a Stripe snapshot. */
|
||||
activePaid: number;
|
||||
mrrCents: number;
|
||||
}
|
||||
|
||||
export interface ChannelRow {
|
||||
channel: AcquisitionChannel;
|
||||
visitors: number;
|
||||
signups: number;
|
||||
trials: number;
|
||||
paid: number;
|
||||
}
|
||||
|
||||
export interface PaidAccountRow {
|
||||
userId: string;
|
||||
name: string | null;
|
||||
email: string | null;
|
||||
status: string;
|
||||
valueEvents7: number;
|
||||
valueEvents30: number;
|
||||
lastValueEventAt: Date | null;
|
||||
channel: AcquisitionChannel | null;
|
||||
selfReported: AcquisitionChannel | null;
|
||||
}
|
||||
|
||||
export interface Scoreboard {
|
||||
weeks: WeeklyRow[];
|
||||
channels: ChannelRow[];
|
||||
channelWindowDays: number;
|
||||
paidAccounts: PaidAccountRow[];
|
||||
/** True when there are more paid accounts than the table shows. */
|
||||
paidAccountsTruncated: boolean;
|
||||
paidAccountLimit: number;
|
||||
atRisk: PaidAccountRow[];
|
||||
currentActivePaid: number | null;
|
||||
currentMrrCents: number | null;
|
||||
currency: string;
|
||||
}
|
||||
|
||||
interface WeeklyQueryRow {
|
||||
week: Date;
|
||||
name: string;
|
||||
subjects: number;
|
||||
}
|
||||
|
||||
interface ChannelQueryRow {
|
||||
channel: AcquisitionChannel | null;
|
||||
name: string;
|
||||
subjects: number;
|
||||
}
|
||||
|
||||
interface PaidQueryRow {
|
||||
user_id: string;
|
||||
name: string | null;
|
||||
email: string | null;
|
||||
status: string;
|
||||
channel: AcquisitionChannel | null;
|
||||
self_reported: AcquisitionChannel | null;
|
||||
value_events_7: number;
|
||||
value_events_30: number;
|
||||
last_value_event_at: Date | null;
|
||||
}
|
||||
|
||||
function startOfWeek(date: Date): Date {
|
||||
const copy = new Date(
|
||||
Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate(), 0, 0, 0, 0)
|
||||
);
|
||||
// Postgres date_trunc('week') starts on Monday; match it so the two halves of
|
||||
// the table line up.
|
||||
const isoDayIndex = (copy.getUTCDay() + 6) % 7;
|
||||
copy.setUTCDate(copy.getUTCDate() - isoDayIndex);
|
||||
return copy;
|
||||
}
|
||||
|
||||
function emptyWeek(weekStart: Date): WeeklyRow {
|
||||
return {
|
||||
weekStart,
|
||||
visitors: 0,
|
||||
ctaClicks: 0,
|
||||
signupStarted: 0,
|
||||
signups: 0,
|
||||
emailVerified: 0,
|
||||
firstVideo: 0,
|
||||
shareLinks: 0,
|
||||
externalFeedback: 0,
|
||||
trials: 0,
|
||||
newPaid: 0,
|
||||
canceled: 0,
|
||||
activePaid: 0,
|
||||
mrrCents: 0,
|
||||
};
|
||||
}
|
||||
|
||||
const WEEK_COLUMN_BY_EVENT: Record<string, keyof WeeklyRow> = {
|
||||
LANDING_VIEW: 'visitors',
|
||||
CTA_CLICKED: 'ctaClicks',
|
||||
SIGNUP_STARTED: 'signupStarted',
|
||||
SIGNUP_COMPLETED: 'signups',
|
||||
EMAIL_VERIFIED: 'emailVerified',
|
||||
VIDEO_ADDED: 'firstVideo',
|
||||
SHARE_LINK_CREATED: 'shareLinks',
|
||||
FIRST_GUEST_COMMENT: 'externalFeedback',
|
||||
TRIAL_STARTED: 'trials',
|
||||
SUBSCRIPTION_STARTED: 'newPaid',
|
||||
SUBSCRIPTION_CANCELED: 'canceled',
|
||||
};
|
||||
|
||||
export interface FunnelRates {
|
||||
visitorToSignup: number | null;
|
||||
signupToFirstVideo: number | null;
|
||||
firstVideoToShare: number | null;
|
||||
shareToFeedback: number | null;
|
||||
trialToPaid: number | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Step-to-step conversion, or null when the denominator is zero.
|
||||
*
|
||||
* Null rather than 0 on purpose: "no visitors, so no rate" and "visitors, none
|
||||
* of whom converted" are different facts, and showing the first as 0% invents a
|
||||
* problem that is not there.
|
||||
*/
|
||||
export function conversionRates(row: {
|
||||
visitors: number;
|
||||
signups: number;
|
||||
firstVideo: number;
|
||||
shareLinks: number;
|
||||
externalFeedback: number;
|
||||
trials: number;
|
||||
newPaid: number;
|
||||
}): FunnelRates {
|
||||
const ratio = (numerator: number, denominator: number) =>
|
||||
denominator > 0 ? numerator / denominator : null;
|
||||
|
||||
return {
|
||||
visitorToSignup: ratio(row.signups, row.visitors),
|
||||
signupToFirstVideo: ratio(row.firstVideo, row.signups),
|
||||
firstVideoToShare: ratio(row.shareLinks, row.firstVideo),
|
||||
shareToFeedback: ratio(row.externalFeedback, row.shareLinks),
|
||||
trialToPaid: ratio(row.newPaid, row.trials),
|
||||
};
|
||||
}
|
||||
|
||||
export async function getScoreboard(options?: { weeks?: number }): Promise<Scoreboard> {
|
||||
const weeks = Math.min(Math.max(options?.weeks ?? DEFAULT_WEEKS, 1), 52);
|
||||
const now = new Date();
|
||||
const firstWeekStart = startOfWeek(now);
|
||||
firstWeekStart.setUTCDate(firstWeekStart.getUTCDate() - (weeks - 1) * 7);
|
||||
|
||||
const channelWindowStart = new Date(now);
|
||||
channelWindowStart.setUTCDate(channelWindowStart.getUTCDate() - CHANNEL_WINDOW_DAYS);
|
||||
|
||||
const [weekRows, channelRows, priorPaid, paidAccounts, stripeStats] = await Promise.all([
|
||||
// COUNT(DISTINCT COALESCE(anonymous_id, id)) rather than COUNT(*): a landing
|
||||
// view is deduped per visitor per day, so a visitor who came back on three
|
||||
// days would otherwise be three weekly visitors. Rows with no anonymous id
|
||||
// fall back to their own primary key and stay distinct.
|
||||
db.$queryRaw<WeeklyQueryRow[]>`
|
||||
SELECT date_trunc('week', occurred_at) AS week,
|
||||
name::text AS name,
|
||||
COUNT(DISTINCT COALESCE(anonymous_id, id))::int AS subjects
|
||||
FROM analytics_events
|
||||
WHERE occurred_at >= ${firstWeekStart}
|
||||
GROUP BY 1, 2
|
||||
`,
|
||||
db.$queryRaw<ChannelQueryRow[]>`
|
||||
SELECT COALESCE(ua.channel, e.channel) AS channel,
|
||||
e.name::text AS name,
|
||||
COUNT(DISTINCT COALESCE(e.anonymous_id, e.id))::int AS subjects
|
||||
FROM analytics_events e
|
||||
LEFT JOIN user_acquisitions ua ON ua.user_id = e.user_id
|
||||
WHERE e.occurred_at >= ${channelWindowStart}
|
||||
GROUP BY 1, 2
|
||||
`,
|
||||
db.$queryRaw<Array<{ started: number; canceled: number }>>`
|
||||
SELECT
|
||||
COUNT(*) FILTER (WHERE name::text = 'SUBSCRIPTION_STARTED')::int AS started,
|
||||
COUNT(*) FILTER (WHERE name::text = 'SUBSCRIPTION_CANCELED')::int AS canceled
|
||||
FROM analytics_events
|
||||
WHERE occurred_at < ${firstWeekStart}
|
||||
`,
|
||||
db.$queryRaw<PaidQueryRow[]>`
|
||||
SELECT u.id AS user_id,
|
||||
u.name,
|
||||
u.email,
|
||||
u."subscriptionStatus"::text AS status,
|
||||
ua.channel,
|
||||
ua.self_reported,
|
||||
COUNT(e.id) FILTER (WHERE e.occurred_at >= NOW() - INTERVAL '7 days')::int
|
||||
AS value_events_7,
|
||||
COUNT(e.id) FILTER (WHERE e.occurred_at >= NOW() - INTERVAL '30 days')::int
|
||||
AS value_events_30,
|
||||
MAX(e.occurred_at) AS last_value_event_at
|
||||
FROM users u
|
||||
LEFT JOIN user_acquisitions ua ON ua.user_id = u.id
|
||||
LEFT JOIN analytics_events e
|
||||
ON e.user_id = u.id
|
||||
AND e.name::text = ANY(${[...VALUE_EVENT_NAMES]}::text[])
|
||||
WHERE u."subscriptionStatus"::text IN ('ACTIVE', 'TRIALING')
|
||||
GROUP BY u.id, u.name, u.email, u."subscriptionStatus", ua.channel, ua.self_reported
|
||||
ORDER BY MAX(e.occurred_at) ASC NULLS FIRST
|
||||
LIMIT ${PAID_ACCOUNT_LIMIT + 1}
|
||||
`,
|
||||
getCachedStripeStats(),
|
||||
]);
|
||||
|
||||
const byWeek = new Map<number, WeeklyRow>();
|
||||
for (let index = 0; index < weeks; index += 1) {
|
||||
const weekStart = new Date(firstWeekStart);
|
||||
weekStart.setUTCDate(weekStart.getUTCDate() + index * 7);
|
||||
byWeek.set(weekStart.getTime(), emptyWeek(weekStart));
|
||||
}
|
||||
|
||||
for (const row of weekRows) {
|
||||
const bucket = byWeek.get(startOfWeek(row.week).getTime());
|
||||
const column = WEEK_COLUMN_BY_EVENT[row.name];
|
||||
if (!bucket || !column) continue;
|
||||
(bucket[column] as number) = row.subjects;
|
||||
}
|
||||
|
||||
// One flat plan, so a per-subscription price is enough to turn a subscriber
|
||||
// count into MRR. Taken from Stripe rather than hardcoded, and zero when
|
||||
// billing is not configured at all.
|
||||
const unitAmountCents =
|
||||
stripeStats && stripeStats.activeSubscribers > 0
|
||||
? Math.round(stripeStats.mrrCents / stripeStats.activeSubscribers)
|
||||
: 0;
|
||||
|
||||
let running = (priorPaid[0]?.started ?? 0) - (priorPaid[0]?.canceled ?? 0);
|
||||
const orderedWeeks = [...byWeek.values()].sort(
|
||||
(a, b) => a.weekStart.getTime() - b.weekStart.getTime()
|
||||
);
|
||||
for (const week of orderedWeeks) {
|
||||
running += week.newPaid - week.canceled;
|
||||
week.activePaid = Math.max(running, 0);
|
||||
week.mrrCents = week.activePaid * unitAmountCents;
|
||||
}
|
||||
|
||||
const channelBuckets = new Map<AcquisitionChannel, ChannelRow>();
|
||||
for (const row of channelRows) {
|
||||
const channel = row.channel ?? 'OTHER';
|
||||
const bucket = channelBuckets.get(channel) ?? {
|
||||
channel,
|
||||
visitors: 0,
|
||||
signups: 0,
|
||||
trials: 0,
|
||||
paid: 0,
|
||||
};
|
||||
if (row.name === 'LANDING_VIEW') bucket.visitors += row.subjects;
|
||||
if (row.name === 'SIGNUP_COMPLETED') bucket.signups += row.subjects;
|
||||
if (row.name === 'TRIAL_STARTED') bucket.trials += row.subjects;
|
||||
if (row.name === 'SUBSCRIPTION_STARTED') bucket.paid += row.subjects;
|
||||
channelBuckets.set(channel, bucket);
|
||||
}
|
||||
|
||||
// One row over the limit was fetched purely to tell "exactly full" from "cut off".
|
||||
const paidAccountsTruncated = paidAccounts.length > PAID_ACCOUNT_LIMIT;
|
||||
const accounts: PaidAccountRow[] = paidAccounts.slice(0, PAID_ACCOUNT_LIMIT).map((row) => ({
|
||||
userId: row.user_id,
|
||||
name: row.name,
|
||||
email: row.email,
|
||||
status: row.status,
|
||||
channel: row.channel,
|
||||
selfReported: row.self_reported,
|
||||
valueEvents7: row.value_events_7,
|
||||
valueEvents30: row.value_events_30,
|
||||
lastValueEventAt: row.last_value_event_at,
|
||||
}));
|
||||
|
||||
const silentBefore = new Date(now);
|
||||
silentBefore.setUTCDate(silentBefore.getUTCDate() - AT_RISK_SILENT_DAYS);
|
||||
|
||||
return {
|
||||
weeks: orderedWeeks,
|
||||
channels: [...channelBuckets.values()].sort((a, b) => b.visitors - a.visitors),
|
||||
channelWindowDays: CHANNEL_WINDOW_DAYS,
|
||||
paidAccounts: accounts,
|
||||
paidAccountsTruncated,
|
||||
paidAccountLimit: PAID_ACCOUNT_LIMIT,
|
||||
atRisk: accounts.filter(
|
||||
(account) => !account.lastValueEventAt || account.lastValueEventAt < silentBefore
|
||||
),
|
||||
currentActivePaid: stripeStats?.activeSubscribers ?? null,
|
||||
currentMrrCents: stripeStats?.mrrCents ?? null,
|
||||
currency: stripeStats?.currency ?? 'usd',
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
// Signing for the two acquisition cookies.
|
||||
//
|
||||
// httpOnly keeps JavaScript out of these cookies. It does nothing about curl,
|
||||
// and both cookies are read straight into database columns, so without a
|
||||
// signature the anonymous id is simply a string the caller picked. Picking one
|
||||
// is enough to write a first-touch row for a visitor who never existed, or to
|
||||
// claim another visitor's events at signup, since the backfill matches on the
|
||||
// id alone.
|
||||
//
|
||||
// Web Crypto rather than node:crypto: this is imported by the proxy, which runs
|
||||
// on the edge, and by the pages that read the cookies back, which run in Node.
|
||||
// Both have crypto.subtle; only Node has createHmac.
|
||||
|
||||
import { logWarn } from '@/lib/logger';
|
||||
|
||||
const SEPARATOR = '.';
|
||||
|
||||
/**
|
||||
* 132 bits of an HMAC-SHA256, base64url. Truncating a MAC is standard practice
|
||||
* and keeps a cookie that rides on every request small.
|
||||
*/
|
||||
const SIGNATURE_LENGTH = 22;
|
||||
|
||||
let cachedSecret: string | null = null;
|
||||
let cachedKey: Promise<CryptoKey> | null = null;
|
||||
let warnedAboutMissingSecret = false;
|
||||
|
||||
function readSecret(): string | null {
|
||||
const secret = process.env.AUTH_SECRET?.trim() || process.env.NEXTAUTH_SECRET?.trim();
|
||||
if (secret) return secret;
|
||||
|
||||
// Not thrown. The proxy runs on every request and the pages render for every
|
||||
// visitor; failing those to protect a funnel chart would be the wrong trade.
|
||||
// Analytics simply records nothing, which is visible on /admin/growth the same
|
||||
// day, and it is announced once per process rather than per request.
|
||||
if (!warnedAboutMissingSecret) {
|
||||
warnedAboutMissingSecret = true;
|
||||
logWarn(
|
||||
'AUTH_SECRET (or NEXTAUTH_SECRET) is not set, so acquisition cookies cannot be ' +
|
||||
'signed. Nothing will be recorded while it is missing.'
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function getKey(secret: string): Promise<CryptoKey> {
|
||||
if (!cachedKey || cachedSecret !== secret) {
|
||||
cachedSecret = secret;
|
||||
cachedKey = crypto.subtle.importKey(
|
||||
'raw',
|
||||
new TextEncoder().encode(secret),
|
||||
{ name: 'HMAC', hash: 'SHA-256' },
|
||||
false,
|
||||
['sign']
|
||||
);
|
||||
}
|
||||
return cachedKey;
|
||||
}
|
||||
|
||||
function toBase64Url(buffer: ArrayBuffer): string {
|
||||
let binary = '';
|
||||
for (const byte of new Uint8Array(buffer)) {
|
||||
binary += String.fromCharCode(byte);
|
||||
}
|
||||
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
||||
}
|
||||
|
||||
async function macOf(value: string, secret: string): Promise<string> {
|
||||
const signature = await crypto.subtle.sign(
|
||||
'HMAC',
|
||||
await getKey(secret),
|
||||
new TextEncoder().encode(value)
|
||||
);
|
||||
return toBase64Url(signature).slice(0, SIGNATURE_LENGTH);
|
||||
}
|
||||
|
||||
/** Constant time, so a forged cookie learns nothing from how long it took to reject. */
|
||||
function equals(a: string, b: string): boolean {
|
||||
if (a.length !== b.length) return false;
|
||||
let difference = 0;
|
||||
for (let index = 0; index < a.length; index += 1) {
|
||||
difference |= a.charCodeAt(index) ^ b.charCodeAt(index);
|
||||
}
|
||||
return difference === 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* `<mac>.<value>`.
|
||||
*
|
||||
* The MAC goes first and is fixed-length, so the split is a slice at a known
|
||||
* offset rather than a search for a separator that a future payload might
|
||||
* happen to contain.
|
||||
*
|
||||
* Returns null when there is no secret to sign with, which the callers treat as
|
||||
* "set no cookie".
|
||||
*/
|
||||
export async function signCookieValue(value: string): Promise<string | null> {
|
||||
const secret = readSecret();
|
||||
if (!secret) return null;
|
||||
return `${await macOf(value, secret)}${SEPARATOR}${value}`;
|
||||
}
|
||||
|
||||
/** The signed value back, or null if it was absent, truncated, or edited. */
|
||||
export async function unsignCookieValue(signed: string | null | undefined): Promise<string | null> {
|
||||
if (typeof signed !== 'string' || signed.length <= SIGNATURE_LENGTH + 1) return null;
|
||||
|
||||
const secret = readSecret();
|
||||
if (!secret) return null;
|
||||
|
||||
if (signed[SIGNATURE_LENGTH] !== SEPARATOR) return null;
|
||||
const mac = signed.slice(0, SIGNATURE_LENGTH);
|
||||
const value = signed.slice(SIGNATURE_LENGTH + 1);
|
||||
|
||||
return equals(mac, await macOf(value, secret)) ? value : null;
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
// Signup is the seam where an anonymous visitor becomes an account, so it is the
|
||||
// one place the two halves of the funnel are joined. Both ways of creating an
|
||||
// account (the credentials form and an OAuth provider) go through here, because
|
||||
// a channel that only shows up for one of them is worse than no channel at all.
|
||||
|
||||
import { eventKey, recordEvent, attachAcquisitionToUser } from '@/lib/analytics/record';
|
||||
import { NO_VISITOR, readVisitorContext, type VisitorContext } from '@/lib/analytics/visitor';
|
||||
import { isProductAnalyticsEnabled } from '@/lib/feature-flags';
|
||||
|
||||
/**
|
||||
* The visitor context of the request being handled.
|
||||
*
|
||||
* For the OAuth path there is no NextRequest to read: the account is created by
|
||||
* the adapter, from inside a NextAuth event. `cookies()` still resolves there,
|
||||
* and when it does not the signup is simply recorded without a channel rather
|
||||
* than not recorded at all.
|
||||
*/
|
||||
export async function readVisitorContextFromHeaders(): Promise<VisitorContext> {
|
||||
if (!isProductAnalyticsEnabled()) return NO_VISITOR;
|
||||
try {
|
||||
const { cookies, headers } = await import('next/headers');
|
||||
return await readVisitorContext(await cookies(), await headers());
|
||||
} catch {
|
||||
return NO_VISITOR;
|
||||
}
|
||||
}
|
||||
|
||||
export async function recordSignupCompleted(params: {
|
||||
userId: string;
|
||||
visitor: VisitorContext;
|
||||
}): Promise<void> {
|
||||
if (!isProductAnalyticsEnabled()) return;
|
||||
|
||||
await attachAcquisitionToUser({
|
||||
userId: params.userId,
|
||||
anonymousId: params.visitor.anonymousId,
|
||||
touch: params.visitor.firstTouch,
|
||||
});
|
||||
|
||||
await recordEvent({
|
||||
name: 'SIGNUP_COMPLETED',
|
||||
dedupeKey: eventKey('SIGNUP_COMPLETED', params.userId),
|
||||
userId: params.userId,
|
||||
anonymousId: params.visitor.anonymousId,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
// Reading the acquisition cookies, and recording the events that happen before
|
||||
// an account exists.
|
||||
//
|
||||
// The proxy sets the cookies but cannot write rows (it runs on the edge). These
|
||||
// helpers close that gap from Node, and are the reason no visitor event depends
|
||||
// on client-side JavaScript running: a landing view is recorded by the server
|
||||
// rendering the landing page, so an ad blocker has nothing to block. That is not
|
||||
// a purity argument. Blocking rates differ by channel, and an undercounted
|
||||
// denominator would make GitHub and Hacker News traffic look like it converts
|
||||
// better than it does.
|
||||
//
|
||||
// Recording server-side also means an anonymous request can write rows, so the
|
||||
// three filters that decide whether a request counts live here rather than only
|
||||
// in the proxy: the signature on the cookie, the bot and prefetch checks, and a
|
||||
// per-client ceiling. In the proxy they only govern which cookies get issued,
|
||||
// which is not the same thing as which rows get written.
|
||||
|
||||
import type { AnalyticsEventName } from '@prisma/client';
|
||||
import {
|
||||
ANONYMOUS_ID_COOKIE,
|
||||
FIRST_TOUCH_COOKIE,
|
||||
readAnonymousIdCookie,
|
||||
readFirstTouchCookie,
|
||||
type FirstTouch,
|
||||
} from '@/lib/analytics/cookies';
|
||||
import { isCountableDocumentRequest, isLikelyBot } from '@/lib/analytics/bots';
|
||||
import { dailyEventKey, recordEvent, recordFirstTouch } from '@/lib/analytics/record';
|
||||
import { isProductAnalyticsEnabled } from '@/lib/feature-flags';
|
||||
import {
|
||||
RATE_LIMIT_CONFIGS,
|
||||
checkRateLimit,
|
||||
getClientIpFromHeaders,
|
||||
isClientIpTrustworthy,
|
||||
} from '@/lib/rate-limit';
|
||||
|
||||
/** Both `cookies()` from next/headers and `request.cookies` satisfy this. */
|
||||
export interface AnalyticsCookieReader {
|
||||
get(name: string): { value: string } | undefined;
|
||||
}
|
||||
|
||||
export interface VisitorContext {
|
||||
anonymousId: string | null;
|
||||
firstTouch: FirstTouch | null;
|
||||
/** Carried so the ceiling below can be applied after the response, not during it. */
|
||||
clientIp: string | null;
|
||||
}
|
||||
|
||||
export const NO_VISITOR: VisitorContext = { anonymousId: null, firstTouch: null, clientIp: null };
|
||||
|
||||
/**
|
||||
* The visitor behind a set of cookies, or an empty context.
|
||||
*
|
||||
* Both cookies are verified, so an id that reaches a database column is one this
|
||||
* deployment issued. A forged one is not repaired or partially trusted, it is
|
||||
* simply not a visitor.
|
||||
*/
|
||||
export async function readVisitorContext(
|
||||
store: AnalyticsCookieReader,
|
||||
headers?: Headers
|
||||
): Promise<VisitorContext> {
|
||||
if (!isProductAnalyticsEnabled()) return NO_VISITOR;
|
||||
|
||||
const anonymousId = await readAnonymousIdCookie(store.get(ANONYMOUS_ID_COOKIE)?.value);
|
||||
if (!anonymousId) return NO_VISITOR;
|
||||
|
||||
return {
|
||||
anonymousId,
|
||||
firstTouch: await readFirstTouchCookie(store.get(FIRST_TOUCH_COOKIE)?.value),
|
||||
clientIp: headers ? getClientIpFromHeaders(headers) : null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The visitor behind a page render.
|
||||
*
|
||||
* Applies the same two header checks the proxy does, because they mean different
|
||||
* things in the two places. In the proxy they decide who gets a cookie; here they
|
||||
* decide what counts. A returning visitor already holds a cookie, so without this
|
||||
* Next prefetching /register as a CTA scrolls into view would record a signup
|
||||
* start for a page nobody opened.
|
||||
*/
|
||||
export async function readPageVisitor(): Promise<VisitorContext> {
|
||||
if (!isProductAnalyticsEnabled()) return NO_VISITOR;
|
||||
|
||||
const { cookies, headers } = await import('next/headers');
|
||||
const requestHeaders = await headers();
|
||||
if (!isCountableDocumentRequest(requestHeaders)) return NO_VISITOR;
|
||||
if (isLikelyBot(requestHeaders.get('user-agent'))) return NO_VISITOR;
|
||||
|
||||
return readVisitorContext(await cookies(), requestHeaders);
|
||||
}
|
||||
|
||||
/**
|
||||
* The visitor behind an API request.
|
||||
*
|
||||
* No document check: a beacon is `sec-fetch-dest: empty` by definition, and the
|
||||
* routes that call this are reached by a form submission rather than by a
|
||||
* navigation.
|
||||
*/
|
||||
export async function readRequestVisitor(request: {
|
||||
cookies: AnalyticsCookieReader;
|
||||
headers: Headers;
|
||||
}): Promise<VisitorContext> {
|
||||
if (!isProductAnalyticsEnabled()) return NO_VISITOR;
|
||||
if (isLikelyBot(request.headers.get('user-agent'))) return NO_VISITOR;
|
||||
return readVisitorContext(request.cookies, request.headers);
|
||||
}
|
||||
|
||||
const DIRECT_TOUCH: FirstTouch = {
|
||||
channel: 'DIRECT',
|
||||
utmSource: null,
|
||||
utmMedium: null,
|
||||
utmCampaign: null,
|
||||
referrerHost: null,
|
||||
landingPath: '/',
|
||||
};
|
||||
|
||||
/**
|
||||
* A ceiling on how many visitors one client can invent per hour.
|
||||
*
|
||||
* A fresh signed cookie is one request away: drop the cookie, ask for the
|
||||
* landing page again, and the proxy mints another id. The signature stops a
|
||||
* caller from choosing an id, and this stops them from collecting an unbounded
|
||||
* number of real ones. Skipped when the client IP is not real, where the bucket
|
||||
* would be shared by everybody and would throttle the site rather than the
|
||||
* flood.
|
||||
*/
|
||||
async function withinVisitorCeiling(clientIp: string | null): Promise<boolean> {
|
||||
if (!clientIp || !isClientIpTrustworthy()) return true;
|
||||
|
||||
const config = RATE_LIMIT_CONFIGS['analytics-visitor'];
|
||||
const result = await checkRateLimit(clientIp, 'analytics-visitor', config);
|
||||
return result.allowed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Records an event for a visitor with no account, once per visitor per UTC day.
|
||||
*
|
||||
* The first touch row is written here rather than in the proxy because this is
|
||||
* the first moment the visitor is known to be a browser that kept the cookie.
|
||||
*/
|
||||
export async function recordVisitorEvent(
|
||||
name: AnalyticsEventName,
|
||||
visitor: VisitorContext
|
||||
): Promise<void> {
|
||||
if (!isProductAnalyticsEnabled()) return;
|
||||
if (!visitor.anonymousId) return;
|
||||
if (!(await withinVisitorCeiling(visitor.clientIp))) return;
|
||||
|
||||
const touch = visitor.firstTouch ?? DIRECT_TOUCH;
|
||||
|
||||
await recordFirstTouch(visitor.anonymousId, touch);
|
||||
await recordEvent({
|
||||
name,
|
||||
dedupeKey: dailyEventKey(name, visitor.anonymousId),
|
||||
anonymousId: visitor.anonymousId,
|
||||
channel: touch.channel,
|
||||
});
|
||||
}
|
||||
+15
@@ -150,6 +150,21 @@ export const { handlers, signIn, signOut, auth } = NextAuth({
|
||||
return token;
|
||||
},
|
||||
},
|
||||
events: {
|
||||
// The OAuth half of signup. Accounts created by Google or GitHub are written
|
||||
// by the Prisma adapter and never reach app/api/auth/register, so recording
|
||||
// the event only there would have made every social signup invisible in the
|
||||
// funnel while looking like it worked.
|
||||
async createUser({ user }) {
|
||||
if (!user.id) return;
|
||||
const { recordSignupCompleted, readVisitorContextFromHeaders } =
|
||||
await import('@/lib/analytics/signup');
|
||||
await recordSignupCompleted({
|
||||
userId: user.id,
|
||||
visitor: await readVisitorContextFromHeaders(),
|
||||
});
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
+55
-3
@@ -4,6 +4,7 @@ import { BillingSubscriptionStatus } from '@prisma/client';
|
||||
import { db } from '@/lib/db';
|
||||
import { getStripe, getStripePriceId } from '@/lib/stripe';
|
||||
import { isStripeFeatureEnabled } from '@/lib/feature-flags';
|
||||
import { recordSubscriptionTransition } from '@/lib/analytics/billing-events';
|
||||
|
||||
const ACTIVE_SUBSCRIPTION_STATUSES = new Set<BillingSubscriptionStatus>([
|
||||
BillingSubscriptionStatus.ACTIVE,
|
||||
@@ -394,6 +395,10 @@ export async function syncStripeSubscriptionToUser(subscription: Stripe.Subscrip
|
||||
select: {
|
||||
id: true,
|
||||
billingTrialConsumedAt: true,
|
||||
// Read for the funnel: the transition is what gets recorded, so the state
|
||||
// being overwritten has to be captured before the update below.
|
||||
subscriptionStatus: true,
|
||||
stripeCancelAtPeriodEnd: true,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -430,7 +435,7 @@ export async function syncStripeSubscriptionToUser(subscription: Stripe.Subscrip
|
||||
(hasActiveSubscription(mappedStatus) ||
|
||||
Boolean(currentPeriodEnd && currentPeriodEnd * 1000 > Date.now()));
|
||||
|
||||
return db.user.update({
|
||||
const updated = await db.user.update({
|
||||
where: { id: user.id },
|
||||
data: {
|
||||
stripeSubscriptionId: subscription.id,
|
||||
@@ -449,6 +454,24 @@ export async function syncStripeSubscriptionToUser(subscription: Stripe.Subscrip
|
||||
: getInactiveBillingAccessEndedAt(subscription, hasEntitledPrice ? currentPeriodEnd : null),
|
||||
},
|
||||
});
|
||||
|
||||
await recordSubscriptionTransition({
|
||||
userId: user.id,
|
||||
subscriptionId: subscription.id,
|
||||
before: {
|
||||
status: user.subscriptionStatus,
|
||||
cancelAtPeriodEnd: user.stripeCancelAtPeriodEnd,
|
||||
hadTrial: user.billingTrialConsumedAt !== null,
|
||||
},
|
||||
after: {
|
||||
status: mappedStatus,
|
||||
cancelAtPeriodEnd,
|
||||
trialEndsAt: effectiveTrialEnd,
|
||||
currentPeriodEnd: effectiveCurrentPeriodEnd,
|
||||
},
|
||||
});
|
||||
|
||||
return updated;
|
||||
}
|
||||
|
||||
// A single Stripe customer can own several subscriptions at once (e.g. after
|
||||
@@ -524,14 +547,21 @@ export async function markSubscriptionCanceledByCustomerId(
|
||||
) {
|
||||
const user = await db.user.findUnique({
|
||||
where: { stripeCustomerId: customerId },
|
||||
select: { id: true },
|
||||
select: {
|
||||
id: true,
|
||||
subscriptionStatus: true,
|
||||
stripeSubscriptionId: true,
|
||||
stripeCancelAtPeriodEnd: true,
|
||||
stripeCurrentPeriodEnd: true,
|
||||
billingTrialConsumedAt: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return db.user.update({
|
||||
const updated = await db.user.update({
|
||||
where: { id: user.id },
|
||||
data: {
|
||||
subscriptionStatus: BillingSubscriptionStatus.CANCELED,
|
||||
@@ -544,4 +574,26 @@ export async function markSubscriptionCanceledByCustomerId(
|
||||
billingAccessEndedAt: options?.endedAt ?? options?.currentPeriodEnd ?? new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
// Reached when the customer has no subscriptions left at all. The cycle marker
|
||||
// uses the period end being cleared here, which is the same one the earlier
|
||||
// "cancel at period end" write carried, so a customer who cancelled through the
|
||||
// portal and then reached the end of their term produces one cancellation, not two.
|
||||
await recordSubscriptionTransition({
|
||||
userId: user.id,
|
||||
subscriptionId: user.stripeSubscriptionId ?? user.id,
|
||||
before: {
|
||||
status: user.subscriptionStatus,
|
||||
cancelAtPeriodEnd: user.stripeCancelAtPeriodEnd,
|
||||
hadTrial: user.billingTrialConsumedAt !== null,
|
||||
},
|
||||
after: {
|
||||
status: BillingSubscriptionStatus.CANCELED,
|
||||
cancelAtPeriodEnd: false,
|
||||
trialEndsAt: null,
|
||||
currentPeriodEnd: options?.currentPeriodEnd ?? user.stripeCurrentPeriodEnd ?? null,
|
||||
},
|
||||
});
|
||||
|
||||
return updated;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* Reports a click on a call to action.
|
||||
*
|
||||
* `sendBeacon` rather than a plain fetch because a CTA click navigates away
|
||||
* immediately, and a request still in flight when the page unloads is cancelled.
|
||||
*
|
||||
* Everything here is best effort. This is the only funnel event that depends on
|
||||
* the browser cooperating, which is also why nothing downstream divides by it:
|
||||
* the visitor and signup counts either side of it are recorded server-side.
|
||||
*/
|
||||
export function trackCtaClick(): void {
|
||||
if (typeof navigator === 'undefined') return;
|
||||
|
||||
const payload = JSON.stringify({ name: 'cta_clicked' });
|
||||
|
||||
try {
|
||||
if (typeof navigator.sendBeacon === 'function') {
|
||||
navigator.sendBeacon('/api/events', new Blob([payload], { type: 'application/json' }));
|
||||
return;
|
||||
}
|
||||
|
||||
void fetch('/api/events', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: payload,
|
||||
keepalive: true,
|
||||
}).catch(() => undefined);
|
||||
} catch {
|
||||
// A click must never fail because measurement did.
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,8 @@ import {
|
||||
EMAIL_COLORS,
|
||||
} from '@/lib/email-brand';
|
||||
import { logError } from '@/lib/logger';
|
||||
import { eventKey, recordEvent } from '@/lib/analytics/record';
|
||||
import { isProductAnalyticsEnabled } from '@/lib/feature-flags';
|
||||
|
||||
// Reduce window to 2 hours — shorter exposure in access logs and backups.
|
||||
const TOKEN_EXPIRY_HOURS = 2;
|
||||
@@ -77,6 +79,23 @@ export async function consumeVerificationToken(token: string): Promise<string |
|
||||
// Return null so a replayed/stale token never produces a misleading success redirect.
|
||||
if (user.count === 0) return null;
|
||||
|
||||
// Behind the flag so the extra lookup does not happen at all on a deployment
|
||||
// that is not measuring. count > 0 above already means this is the one call
|
||||
// that flipped the account, so a replayed link cannot reach here.
|
||||
if (isProductAnalyticsEnabled()) {
|
||||
const verified = await db.user.findUnique({
|
||||
where: { email: record.identifier },
|
||||
select: { id: true },
|
||||
});
|
||||
if (verified) {
|
||||
await recordEvent({
|
||||
name: 'EMAIL_VERIFIED',
|
||||
dedupeKey: eventKey('EMAIL_VERIFIED', verified.id),
|
||||
userId: verified.id,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return record.identifier;
|
||||
}
|
||||
|
||||
|
||||
@@ -98,6 +98,16 @@ export function isInviteCodeRequired() {
|
||||
return readBooleanEnv('OPENFRAME_REQUIRE_INVITE_CODE', true);
|
||||
}
|
||||
|
||||
// Acquisition attribution and funnel events. Defaults to OFF, unlike the other
|
||||
// product flags here, because the cost of the two mistakes is not symmetric: a
|
||||
// hosted instance that forgets to switch it on shows an empty growth page and is
|
||||
// noticed the same day, while a self-hosted instance that gets it silently
|
||||
// switched on accumulates rows nobody asked for. Nothing is ever sent off the
|
||||
// instance either way, so this is about cost, not disclosure.
|
||||
export function isProductAnalyticsEnabled() {
|
||||
return readBooleanEnv('OPENFRAME_ENABLE_ANALYTICS', false);
|
||||
}
|
||||
|
||||
function parseBigIntEnv(name: string, defaultValue: bigint, minValue?: bigint): bigint {
|
||||
const raw = process.env[name]?.trim();
|
||||
if (!raw) return defaultValue;
|
||||
|
||||
+28
-1
@@ -90,8 +90,11 @@ export const RATE_LIMIT_CONFIGS: Record<string, RateLimitConfig> = {
|
||||
'verify-email': { windowMs: 15 * 60 * 1000, maxRequests: 20 }, // 20 per 15 min (clicked link)
|
||||
'resend-verification': { windowMs: 60 * 60 * 1000, maxRequests: 5 }, // 5 per hour
|
||||
|
||||
// Onboarding — one-time action, very strict
|
||||
// Onboarding — one-time action, very strict. Both are keyed by user id, not IP:
|
||||
// an office behind one address must not be able to lock its colleagues out of
|
||||
// finishing onboarding.
|
||||
'onboarding-complete': { windowMs: 60 * 60 * 1000, maxRequests: 5 }, // 5 per hour
|
||||
'onboarding-source': { windowMs: 60 * 60 * 1000, maxRequests: 5 }, // 5 per hour
|
||||
|
||||
// Member management
|
||||
'invite-member': { windowMs: 60 * 60 * 1000, maxRequests: 30 }, // 30 per hour
|
||||
@@ -105,6 +108,16 @@ export const RATE_LIMIT_CONFIGS: Record<string, RateLimitConfig> = {
|
||||
// Mutations (update/delete) — moderate
|
||||
mutate: { windowMs: 60 * 1000, maxRequests: 30 }, // 30 per minute
|
||||
|
||||
// Analytics beacon — anonymous and public, so bound it per IP
|
||||
'analytics-beacon': { windowMs: 60 * 60 * 1000, maxRequests: 30 }, // 30 per hour
|
||||
|
||||
// Anonymous visitor events recorded server-side from the landing pages. Bounds
|
||||
// a flood that would otherwise write two rows per request forever, and is
|
||||
// deliberately generous: these are the denominator of every rate on the
|
||||
// scoreboard, so a limit that bites real traffic costs more than the flood it
|
||||
// stops. Only applied when the client IP is real — see isClientIpTrustworthy.
|
||||
'analytics-visitor': { windowMs: 60 * 60 * 1000, maxRequests: 240 }, // 240 per hour
|
||||
|
||||
// General reads — generous
|
||||
api: { windowMs: 60 * 1000, maxRequests: 100 }, // 100 per minute
|
||||
};
|
||||
@@ -261,6 +274,20 @@ export function getClientIpFromHeaders(headers: Headers): string {
|
||||
return '127.0.0.1';
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether {@link getClientIp} resolves to the caller rather than to 127.0.0.1.
|
||||
*
|
||||
* Without TRUSTED_PROXY_MODE every request shares one bucket. That is a usable
|
||||
* global brake on an endpoint nobody hits in a loop, and useless on a landing
|
||||
* page: the bucket would empty on real traffic long before it emptied on an
|
||||
* attacker, and the counting this whole subsystem exists for would stop. Callers
|
||||
* that only make sense per-client check this first.
|
||||
*/
|
||||
export function isClientIpTrustworthy(): boolean {
|
||||
const mode = process.env.TRUSTED_PROXY_MODE?.trim().toLowerCase();
|
||||
return mode === 'cloudflare' || mode === 'nginx';
|
||||
}
|
||||
|
||||
/**
|
||||
* Create rate limit headers for response
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
-- CreateEnum
|
||||
CREATE TYPE "AcquisitionChannel" AS ENUM ('DIRECT', 'GITHUB', 'YOUTUBE', 'GOOGLE', 'REVIEW_LINK', 'REFERRAL', 'OUTBOUND', 'COMMUNITY', 'OTHER');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "AnalyticsEventName" AS ENUM ('LANDING_VIEW', 'CTA_CLICKED', 'SIGNUP_STARTED', 'SIGNUP_COMPLETED', 'EMAIL_VERIFIED', 'TRIAL_STARTED', 'WORKSPACE_CREATED', 'PROJECT_CREATED', 'VIDEO_ADDED', 'SHARE_LINK_CREATED', 'FIRST_GUEST_COMMENT', 'APPROVAL_COMPLETED', 'CHECKOUT_STARTED', 'SUBSCRIPTION_STARTED', 'SUBSCRIPTION_CANCELED', 'SUBSCRIPTION_REACTIVATED');
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "acquisition_touches" (
|
||||
"id" TEXT NOT NULL,
|
||||
"anonymous_id" TEXT NOT NULL,
|
||||
"channel" "AcquisitionChannel" NOT NULL,
|
||||
"utm_source" TEXT,
|
||||
"utm_medium" TEXT,
|
||||
"utm_campaign" TEXT,
|
||||
"referrer_host" TEXT,
|
||||
"landing_path" TEXT NOT NULL,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "acquisition_touches_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "user_acquisitions" (
|
||||
"user_id" TEXT NOT NULL,
|
||||
"anonymous_id" TEXT,
|
||||
"channel" "AcquisitionChannel" NOT NULL DEFAULT 'DIRECT',
|
||||
"utm_source" TEXT,
|
||||
"utm_medium" TEXT,
|
||||
"utm_campaign" TEXT,
|
||||
"referrer_host" TEXT,
|
||||
"landing_path" TEXT,
|
||||
"self_reported" "AcquisitionChannel",
|
||||
"self_reported_note" TEXT,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "user_acquisitions_pkey" PRIMARY KEY ("user_id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "analytics_events" (
|
||||
"id" TEXT NOT NULL,
|
||||
"name" "AnalyticsEventName" NOT NULL,
|
||||
"user_id" TEXT,
|
||||
"anonymous_id" TEXT,
|
||||
"channel" "AcquisitionChannel",
|
||||
"dedupe_key" TEXT NOT NULL,
|
||||
"occurred_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "analytics_events_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "acquisition_touches_anonymous_id_key" ON "acquisition_touches"("anonymous_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "acquisition_touches_channel_created_at_idx" ON "acquisition_touches"("channel", "created_at");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "acquisition_touches_created_at_idx" ON "acquisition_touches"("created_at");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "user_acquisitions_channel_created_at_idx" ON "user_acquisitions"("channel", "created_at");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "user_acquisitions_anonymous_id_idx" ON "user_acquisitions"("anonymous_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "analytics_events_dedupe_key_key" ON "analytics_events"("dedupe_key");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "analytics_events_name_occurred_at_idx" ON "analytics_events"("name", "occurred_at");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "analytics_events_user_id_occurred_at_idx" ON "analytics_events"("user_id", "occurred_at");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "analytics_events_channel_name_occurred_at_idx" ON "analytics_events"("channel", "name", "occurred_at");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "analytics_events_anonymous_id_idx" ON "analytics_events"("anonymous_id");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "user_acquisitions" ADD CONSTRAINT "user_acquisitions_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "analytics_events" ADD CONSTRAINT "analytics_events_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
@@ -50,6 +50,8 @@ model User {
|
||||
canceledApprovalRequests ApprovalRequest[] @relation("ApprovalRequestsCanceledBy")
|
||||
approvalDecisions ApprovalDecision[]
|
||||
sentInvitations Invitation[] @relation("InvitationsSentBy")
|
||||
acquisition UserAcquisition?
|
||||
analyticsEvents AnalyticsEvent[]
|
||||
|
||||
@@map("users")
|
||||
}
|
||||
@@ -738,6 +740,119 @@ model VideoUploadSession {
|
||||
@@map("video_upload_sessions")
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// ACQUISITION + PRODUCT ANALYTICS
|
||||
// ============================================
|
||||
//
|
||||
// Everything here is first party. Rows are written to this deployment's own
|
||||
// database and read back by this deployment's own admin panel; no part of the
|
||||
// codebase ships them anywhere else. The whole subsystem is off unless
|
||||
// OPENFRAME_ENABLE_ANALYTICS is set, so a self-hosted instance carries the
|
||||
// tables empty and pays nothing for them.
|
||||
//
|
||||
// Referrers are reduced to a host and landing pages to a path, both without
|
||||
// query strings, so a shared link with a name or token in it cannot leak in
|
||||
// here. The one free-text column is user_acquisitions.self_reported_note, which
|
||||
// holds up to 200 characters the account typed into the onboarding question.
|
||||
|
||||
enum AcquisitionChannel {
|
||||
DIRECT
|
||||
GITHUB
|
||||
YOUTUBE
|
||||
GOOGLE
|
||||
REVIEW_LINK
|
||||
REFERRAL
|
||||
OUTBOUND
|
||||
COMMUNITY
|
||||
OTHER
|
||||
}
|
||||
|
||||
enum AnalyticsEventName {
|
||||
LANDING_VIEW
|
||||
CTA_CLICKED
|
||||
SIGNUP_STARTED
|
||||
SIGNUP_COMPLETED
|
||||
EMAIL_VERIFIED
|
||||
TRIAL_STARTED
|
||||
WORKSPACE_CREATED
|
||||
PROJECT_CREATED
|
||||
VIDEO_ADDED
|
||||
SHARE_LINK_CREATED
|
||||
FIRST_GUEST_COMMENT
|
||||
APPROVAL_COMPLETED
|
||||
CHECKOUT_STARTED
|
||||
SUBSCRIPTION_STARTED
|
||||
SUBSCRIPTION_CANCELED
|
||||
SUBSCRIPTION_REACTIVATED
|
||||
}
|
||||
|
||||
// First touch for a visitor who does not have an account yet. Written once per
|
||||
// anonymous id and never updated: the whole point is what brought them here the
|
||||
// first time, so a later visit carrying different UTM tags must not overwrite it.
|
||||
model AcquisitionTouch {
|
||||
id String @id @default(cuid())
|
||||
anonymousId String @unique @map("anonymous_id")
|
||||
channel AcquisitionChannel
|
||||
utmSource String? @map("utm_source")
|
||||
utmMedium String? @map("utm_medium")
|
||||
utmCampaign String? @map("utm_campaign")
|
||||
referrerHost String? @map("referrer_host")
|
||||
landingPath String @map("landing_path")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
@@index([channel, createdAt])
|
||||
@@index([createdAt])
|
||||
@@map("acquisition_touches")
|
||||
}
|
||||
|
||||
// The first touch copied onto the account at signup, plus the answer to the
|
||||
// onboarding question. Kept beside User rather than on it so the acquisition
|
||||
// columns stay out of every session and billing query.
|
||||
model UserAcquisition {
|
||||
userId String @id @map("user_id")
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
anonymousId String? @map("anonymous_id")
|
||||
channel AcquisitionChannel @default(DIRECT)
|
||||
utmSource String? @map("utm_source")
|
||||
utmMedium String? @map("utm_medium")
|
||||
utmCampaign String? @map("utm_campaign")
|
||||
referrerHost String? @map("referrer_host")
|
||||
landingPath String? @map("landing_path")
|
||||
selfReported AcquisitionChannel? @map("self_reported")
|
||||
selfReportedNote String? @map("self_reported_note")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
@@index([channel, createdAt])
|
||||
@@index([anonymousId])
|
||||
@@map("user_acquisitions")
|
||||
}
|
||||
|
||||
// One row per funnel step. `dedupeKey` is what makes "recorded exactly once"
|
||||
// a property of the schema instead of a property of every call site: a replayed
|
||||
// webhook, a double-submitted form or a refreshed page all collide on the unique
|
||||
// index and the second write is dropped.
|
||||
//
|
||||
// The user relation is SetNull rather than Cascade on purpose. A deleted account
|
||||
// still happened, and dropping its rows would silently rewrite past weeks of the
|
||||
// funnel.
|
||||
model AnalyticsEvent {
|
||||
id String @id @default(cuid())
|
||||
name AnalyticsEventName
|
||||
userId String? @map("user_id")
|
||||
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
|
||||
anonymousId String? @map("anonymous_id")
|
||||
channel AcquisitionChannel?
|
||||
dedupeKey String @unique @map("dedupe_key")
|
||||
occurredAt DateTime @default(now()) @map("occurred_at")
|
||||
|
||||
@@index([name, occurredAt])
|
||||
@@index([userId, occurredAt])
|
||||
@@index([channel, name, occurredAt])
|
||||
@@index([anonymousId])
|
||||
@@map("analytics_events")
|
||||
}
|
||||
|
||||
// Rate limiting table (created as UNLOGGED via raw SQL migration)
|
||||
// Defined here so `prisma db push` doesn't drop it
|
||||
model RateLimit {
|
||||
|
||||
@@ -1,9 +1,86 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { buildContentSecurityPolicy } from '@/lib/content-security-policy';
|
||||
import {
|
||||
classifyChannel,
|
||||
extractReferrerHost,
|
||||
sanitizeLandingPath,
|
||||
sanitizeTag,
|
||||
} from '@/lib/analytics/channel';
|
||||
import {
|
||||
ANONYMOUS_ID_COOKIE,
|
||||
ANONYMOUS_ID_MAX_AGE_SECONDS,
|
||||
FIRST_TOUCH_COOKIE,
|
||||
generateAnonymousId,
|
||||
readAnonymousIdCookie,
|
||||
signAnonymousId,
|
||||
signFirstTouch,
|
||||
} from '@/lib/analytics/cookies';
|
||||
import { isCountableDocumentRequest, isLikelyBot } from '@/lib/analytics/bots';
|
||||
import { isProductAnalyticsEnabled } from '@/lib/feature-flags';
|
||||
import { getPublicOrigin } from '@/lib/request-origin';
|
||||
|
||||
export function proxy() {
|
||||
const response = NextResponse.next();
|
||||
// Runs on the edge, so nothing here touches the database. It only decides who a
|
||||
// visitor is and what brought them, then hands both downstream as signed
|
||||
// cookies. The rows are written by the pages, which run in Node.
|
||||
async function applyAcquisitionCookies(
|
||||
request: NextRequest,
|
||||
response: NextResponse
|
||||
): Promise<void> {
|
||||
if (!isProductAnalyticsEnabled()) return;
|
||||
if (!isCountableDocumentRequest(request.headers)) return;
|
||||
if (isLikelyBot(request.headers.get('user-agent'))) return;
|
||||
|
||||
const cookieOptions = {
|
||||
httpOnly: true,
|
||||
sameSite: 'lax' as const,
|
||||
// Not `request.nextUrl.protocol`. Behind a TLS-terminating reverse proxy,
|
||||
// which is the deployment shape the README documents, that is the
|
||||
// container-internal `http://localhost:3000` and the flag would silently
|
||||
// come off in exactly the setup that needs it.
|
||||
secure: getPublicOrigin(request).startsWith('https:'),
|
||||
path: '/',
|
||||
maxAge: ANONYMOUS_ID_MAX_AGE_SECONDS,
|
||||
};
|
||||
|
||||
const existingId = await readAnonymousIdCookie(request.cookies.get(ANONYMOUS_ID_COOKIE)?.value);
|
||||
if (!existingId) {
|
||||
const signedId = await signAnonymousId(generateAnonymousId());
|
||||
if (!signedId) return;
|
||||
// Set on the request as well as the response: without this the page rendering
|
||||
// *this* request cannot see the id, and the first landing view of every new
|
||||
// visitor, the one carrying the campaign tags, goes unrecorded.
|
||||
request.cookies.set(ANONYMOUS_ID_COOKIE, signedId);
|
||||
response.cookies.set(ANONYMOUS_ID_COOKIE, signedId, cookieOptions);
|
||||
}
|
||||
|
||||
if (request.cookies.get(FIRST_TOUCH_COOKIE)) return;
|
||||
|
||||
const params = request.nextUrl.searchParams;
|
||||
const referrerHost = extractReferrerHost(
|
||||
request.headers.get('referer'),
|
||||
request.nextUrl.hostname
|
||||
);
|
||||
const utmSource = sanitizeTag(params.get('utm_source'));
|
||||
const utmMedium = sanitizeTag(params.get('utm_medium'));
|
||||
|
||||
const firstTouch = await signFirstTouch({
|
||||
channel: classifyChannel({ utmSource, utmMedium, referrerHost }),
|
||||
utmSource,
|
||||
utmMedium,
|
||||
utmCampaign: sanitizeTag(params.get('utm_campaign')),
|
||||
referrerHost,
|
||||
landingPath: sanitizeLandingPath(request.nextUrl.pathname),
|
||||
});
|
||||
if (!firstTouch) return;
|
||||
|
||||
request.cookies.set(FIRST_TOUCH_COOKIE, firstTouch);
|
||||
response.cookies.set(FIRST_TOUCH_COOKIE, firstTouch, cookieOptions);
|
||||
}
|
||||
|
||||
export async function proxy(request: NextRequest) {
|
||||
const response = NextResponse.next({ request });
|
||||
response.headers.set('Content-Security-Policy', buildContentSecurityPolicy());
|
||||
await applyAcquisitionCookies(request, response);
|
||||
return response;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,417 @@
|
||||
// The property the whole acquisition system rests on: every funnel event is
|
||||
// recorded exactly once, against the right account, and nothing at all is
|
||||
// recorded when the feature flag is off.
|
||||
//
|
||||
// The dedupe key is a UNIQUE column, so these tests are checking that each call
|
||||
// site derives the right key. A key that varies when it should not shows up here
|
||||
// as a duplicated row, which is the failure that would quietly inflate the
|
||||
// scoreboard.
|
||||
|
||||
import { describe, expect, it, beforeEach, afterEach, vi } from 'vitest';
|
||||
import { db } from '@/lib/db';
|
||||
import { POST as beacon } from '@/app/api/events/route';
|
||||
import { POST as register } from '@/app/api/auth/register/route';
|
||||
import { recordSubscriptionTransition } from '@/lib/analytics/billing-events';
|
||||
import { recordSignupCompleted } from '@/lib/analytics/signup';
|
||||
import { signAnonymousId, signFirstTouch, type FirstTouch } from '@/lib/analytics/cookies';
|
||||
import { apiRequest, callRoute } from '../helpers/request';
|
||||
import { signedOut } from '../helpers/session';
|
||||
import { createUser } from '../factories';
|
||||
|
||||
const ANON_ID = 'a1b2c3d4e5f60718293a4b5c6d7e8f90';
|
||||
const INVITE_CODE = 'test-invite';
|
||||
const ORIGIN = 'http://localhost:3000';
|
||||
const SECRET = 'analytics-test-secret';
|
||||
const BROWSER_UA =
|
||||
'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126 Safari/537.36';
|
||||
|
||||
const GITHUB_TOUCH: FirstTouch = {
|
||||
channel: 'GITHUB',
|
||||
utmSource: 'github',
|
||||
utmMedium: 'readme',
|
||||
utmCampaign: null,
|
||||
referrerHost: 'github.com',
|
||||
landingPath: '/',
|
||||
};
|
||||
|
||||
/** What the proxy would have set. Signed, because nothing downstream trusts anything else. */
|
||||
async function visitorCookies(anonymousId = ANON_ID, touch: FirstTouch = GITHUB_TOUCH) {
|
||||
return {
|
||||
of_aid: (await signAnonymousId(anonymousId)) ?? '',
|
||||
of_ft: (await signFirstTouch(touch)) ?? '',
|
||||
};
|
||||
}
|
||||
|
||||
async function beaconRequest(options?: {
|
||||
name?: string;
|
||||
origin?: string | null;
|
||||
userAgent?: string | null;
|
||||
cookies?: Record<string, string>;
|
||||
}) {
|
||||
const headers: Record<string, string> = {};
|
||||
const origin = options?.origin === undefined ? ORIGIN : options.origin;
|
||||
if (origin) headers.origin = origin;
|
||||
const userAgent = options?.userAgent === undefined ? BROWSER_UA : options.userAgent;
|
||||
if (userAgent) headers['user-agent'] = userAgent;
|
||||
|
||||
return apiRequest('/api/events', {
|
||||
body: { name: options?.name ?? 'cta_clicked' },
|
||||
headers,
|
||||
cookies: options?.cookies ?? (await visitorCookies()),
|
||||
});
|
||||
}
|
||||
|
||||
async function eventNames(): Promise<string[]> {
|
||||
const rows = await db.analyticsEvent.findMany({ orderBy: { dedupeKey: 'asc' } });
|
||||
return rows.map((row) => row.name);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
signedOut();
|
||||
vi.stubEnv('OPENFRAME_ENABLE_ANALYTICS', 'true');
|
||||
vi.stubEnv('NEXTAUTH_SECRET', SECRET);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
describe('POST /api/events', () => {
|
||||
it('records a CTA click and the first touch behind it', async () => {
|
||||
const response = await callRoute(beacon, await beaconRequest());
|
||||
|
||||
expect(response.status).toBe(204);
|
||||
|
||||
const events = await db.analyticsEvent.findMany();
|
||||
expect(events).toHaveLength(1);
|
||||
expect(events[0]).toMatchObject({
|
||||
name: 'CTA_CLICKED',
|
||||
anonymousId: ANON_ID,
|
||||
channel: 'GITHUB',
|
||||
userId: null,
|
||||
});
|
||||
|
||||
const touches = await db.acquisitionTouch.findMany();
|
||||
expect(touches).toHaveLength(1);
|
||||
expect(touches[0]).toMatchObject({
|
||||
anonymousId: ANON_ID,
|
||||
channel: 'GITHUB',
|
||||
utmSource: 'github',
|
||||
referrerHost: 'github.com',
|
||||
});
|
||||
});
|
||||
|
||||
it('records one event however many times the same visitor clicks', async () => {
|
||||
await callRoute(beacon, await beaconRequest());
|
||||
await callRoute(beacon, await beaconRequest());
|
||||
await callRoute(beacon, await beaconRequest());
|
||||
|
||||
expect(await db.analyticsEvent.count()).toBe(1);
|
||||
});
|
||||
|
||||
it('counts two different visitors separately', async () => {
|
||||
await callRoute(beacon, await beaconRequest());
|
||||
await callRoute(
|
||||
beacon,
|
||||
await beaconRequest({ cookies: await visitorCookies('f0e1d2c3b4a596877869504132231415') })
|
||||
);
|
||||
|
||||
expect(await db.analyticsEvent.count()).toBe(2);
|
||||
});
|
||||
|
||||
it('never keeps the first touch of a visitor who came back through another link', async () => {
|
||||
await callRoute(beacon, await beaconRequest());
|
||||
await callRoute(
|
||||
beacon,
|
||||
await beaconRequest({
|
||||
cookies: await visitorCookies(ANON_ID, {
|
||||
...GITHUB_TOUCH,
|
||||
channel: 'GOOGLE',
|
||||
utmSource: null,
|
||||
}),
|
||||
})
|
||||
);
|
||||
|
||||
const touches = await db.acquisitionTouch.findMany();
|
||||
expect(touches).toHaveLength(1);
|
||||
expect(touches[0]?.channel).toBe('GITHUB');
|
||||
});
|
||||
|
||||
it('refuses to record an event name the beacon does not own', async () => {
|
||||
// Without this the endpoint would let any anonymous caller write a payment
|
||||
// into the funnel.
|
||||
for (const name of ['subscription_started', 'signup_completed', 'SUBSCRIPTION_STARTED', '']) {
|
||||
const response = await callRoute(beacon, await beaconRequest({ name }));
|
||||
expect(response.status, name).toBe(204);
|
||||
}
|
||||
|
||||
expect(await db.analyticsEvent.count()).toBe(0);
|
||||
});
|
||||
|
||||
it('ignores a cross-origin caller', async () => {
|
||||
const response = await callRoute(
|
||||
beacon,
|
||||
await beaconRequest({ origin: 'https://evil.example' })
|
||||
);
|
||||
|
||||
expect(response.status).toBe(204);
|
||||
expect(await db.analyticsEvent.count()).toBe(0);
|
||||
});
|
||||
|
||||
it('ignores a caller with no anonymous id cookie', async () => {
|
||||
await callRoute(beacon, await beaconRequest({ cookies: {} }));
|
||||
|
||||
expect(await db.analyticsEvent.count()).toBe(0);
|
||||
expect(await db.acquisitionTouch.count()).toBe(0);
|
||||
});
|
||||
|
||||
it('ignores a hand-written cookie, whatever channel it claims', async () => {
|
||||
// httpOnly stops JavaScript, not curl. Without the signature this is a
|
||||
// visitor of the caller's choosing, filed under a channel of their choosing,
|
||||
// and every row on the scoreboard is theirs to write.
|
||||
await callRoute(
|
||||
beacon,
|
||||
await beaconRequest({
|
||||
cookies: {
|
||||
of_aid: 'deadbeefdeadbeefdeadbeefdeadbeef',
|
||||
of_ft: encodeURIComponent(JSON.stringify({ c: 'GITHUB', p: '/' })),
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
expect(await db.analyticsEvent.count()).toBe(0);
|
||||
expect(await db.acquisitionTouch.count()).toBe(0);
|
||||
});
|
||||
|
||||
it('ignores a cookie signed by another deployment', async () => {
|
||||
const cookies = await visitorCookies();
|
||||
vi.stubEnv('NEXTAUTH_SECRET', 'some-other-secret');
|
||||
|
||||
await callRoute(beacon, await beaconRequest({ cookies }));
|
||||
|
||||
expect(await db.analyticsEvent.count()).toBe(0);
|
||||
});
|
||||
|
||||
it('ignores a script that sends no user agent', async () => {
|
||||
await callRoute(beacon, await beaconRequest({ userAgent: null }));
|
||||
|
||||
expect(await db.analyticsEvent.count()).toBe(0);
|
||||
});
|
||||
|
||||
it('writes nothing at all when the flag is off', async () => {
|
||||
vi.stubEnv('OPENFRAME_ENABLE_ANALYTICS', 'false');
|
||||
|
||||
const response = await callRoute(beacon, await beaconRequest());
|
||||
|
||||
expect(response.status).toBe(204);
|
||||
expect(await db.analyticsEvent.count()).toBe(0);
|
||||
expect(await db.acquisitionTouch.count()).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('signup attribution', () => {
|
||||
async function registerWithCookies(email: string) {
|
||||
return callRoute(
|
||||
register,
|
||||
apiRequest('/api/auth/register', {
|
||||
body: {
|
||||
name: 'New User',
|
||||
email,
|
||||
password: 'correct horse battery',
|
||||
inviteCode: INVITE_CODE,
|
||||
},
|
||||
headers: { 'user-agent': BROWSER_UA },
|
||||
cookies: await visitorCookies(),
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
it('copies the first touch onto the account and records the signup once', async () => {
|
||||
const response = await registerWithCookies('[email protected]');
|
||||
expect(response.status).toBe(201);
|
||||
|
||||
const user = await db.user.findUniqueOrThrow({ where: { email: '[email protected]' } });
|
||||
const acquisition = await db.userAcquisition.findUniqueOrThrow({
|
||||
where: { userId: user.id },
|
||||
});
|
||||
|
||||
expect(acquisition).toMatchObject({
|
||||
channel: 'GITHUB',
|
||||
utmSource: 'github',
|
||||
utmMedium: 'readme',
|
||||
referrerHost: 'github.com',
|
||||
anonymousId: ANON_ID,
|
||||
});
|
||||
|
||||
const signups = await db.analyticsEvent.findMany({ where: { name: 'SIGNUP_COMPLETED' } });
|
||||
expect(signups).toHaveLength(1);
|
||||
expect(signups[0]?.userId).toBe(user.id);
|
||||
});
|
||||
|
||||
it('claims the events the visitor produced before they had an account', async () => {
|
||||
await callRoute(beacon, await beaconRequest());
|
||||
await registerWithCookies('[email protected]');
|
||||
|
||||
const user = await db.user.findUniqueOrThrow({ where: { email: '[email protected]' } });
|
||||
const click = await db.analyticsEvent.findFirstOrThrow({ where: { name: 'CTA_CLICKED' } });
|
||||
|
||||
// Without the backfill the click and the signup are two unrelated rows and
|
||||
// no query can tell you which channel converted.
|
||||
expect(click.userId).toBe(user.id);
|
||||
});
|
||||
|
||||
it('records one signup even if the helper runs twice', async () => {
|
||||
const user = await createUser();
|
||||
|
||||
await recordSignupCompleted({
|
||||
userId: user.id,
|
||||
visitor: { anonymousId: ANON_ID, firstTouch: GITHUB_TOUCH, clientIp: null },
|
||||
});
|
||||
await recordSignupCompleted({
|
||||
userId: user.id,
|
||||
visitor: { anonymousId: ANON_ID, firstTouch: GITHUB_TOUCH, clientIp: null },
|
||||
});
|
||||
|
||||
expect(await db.analyticsEvent.count({ where: { name: 'SIGNUP_COMPLETED' } })).toBe(1);
|
||||
expect(await db.userAcquisition.count({ where: { userId: user.id } })).toBe(1);
|
||||
});
|
||||
|
||||
it('records no acquisition row when the flag is off', async () => {
|
||||
vi.stubEnv('OPENFRAME_ENABLE_ANALYTICS', 'false');
|
||||
|
||||
const response = await registerWithCookies('[email protected]');
|
||||
|
||||
expect(response.status).toBe(201);
|
||||
expect(await db.userAcquisition.count()).toBe(0);
|
||||
expect(await db.analyticsEvent.count()).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('subscription transitions', () => {
|
||||
const SUB = 'sub_test_1';
|
||||
const periodEnd = new Date('2026-09-01T00:00:00.000Z');
|
||||
|
||||
async function transition(params: {
|
||||
userId: string;
|
||||
beforeStatus: 'FREE' | 'TRIALING' | 'ACTIVE' | 'CANCELED';
|
||||
afterStatus: 'FREE' | 'TRIALING' | 'ACTIVE' | 'CANCELED';
|
||||
beforeCancelAtPeriodEnd?: boolean;
|
||||
afterCancelAtPeriodEnd?: boolean;
|
||||
hadTrial?: boolean;
|
||||
trialEndsAt?: Date | null;
|
||||
}) {
|
||||
await recordSubscriptionTransition({
|
||||
userId: params.userId,
|
||||
subscriptionId: SUB,
|
||||
before: {
|
||||
status: params.beforeStatus,
|
||||
cancelAtPeriodEnd: params.beforeCancelAtPeriodEnd ?? false,
|
||||
hadTrial: params.hadTrial ?? false,
|
||||
},
|
||||
after: {
|
||||
status: params.afterStatus,
|
||||
cancelAtPeriodEnd: params.afterCancelAtPeriodEnd ?? false,
|
||||
trialEndsAt: params.trialEndsAt ?? null,
|
||||
currentPeriodEnd: periodEnd,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
it('records the trial once and the conversion to paid once', async () => {
|
||||
const user = await createUser();
|
||||
|
||||
await transition({
|
||||
userId: user.id,
|
||||
beforeStatus: 'FREE',
|
||||
afterStatus: 'TRIALING',
|
||||
trialEndsAt: new Date('2026-08-15T00:00:00.000Z'),
|
||||
});
|
||||
// The same webhook arriving again, which Stripe does routinely.
|
||||
await transition({
|
||||
userId: user.id,
|
||||
beforeStatus: 'FREE',
|
||||
afterStatus: 'TRIALING',
|
||||
trialEndsAt: new Date('2026-08-15T00:00:00.000Z'),
|
||||
});
|
||||
await transition({
|
||||
userId: user.id,
|
||||
beforeStatus: 'TRIALING',
|
||||
afterStatus: 'ACTIVE',
|
||||
hadTrial: true,
|
||||
});
|
||||
|
||||
expect(await eventNames()).toEqual(['SUBSCRIPTION_STARTED', 'TRIAL_STARTED']);
|
||||
});
|
||||
|
||||
it('does not record a second trial for an account that already had one', async () => {
|
||||
const user = await createUser();
|
||||
|
||||
await transition({
|
||||
userId: user.id,
|
||||
beforeStatus: 'CANCELED',
|
||||
afterStatus: 'TRIALING',
|
||||
hadTrial: true,
|
||||
trialEndsAt: new Date('2026-08-15T00:00:00.000Z'),
|
||||
});
|
||||
|
||||
expect(await eventNames()).toEqual([]);
|
||||
});
|
||||
|
||||
it('counts one cancellation for the flag and the status that follow each other', async () => {
|
||||
const user = await createUser();
|
||||
|
||||
// The customer cancels in the portal: cancel_at_period_end flips on.
|
||||
await transition({
|
||||
userId: user.id,
|
||||
beforeStatus: 'ACTIVE',
|
||||
afterStatus: 'ACTIVE',
|
||||
afterCancelAtPeriodEnd: true,
|
||||
});
|
||||
// The term ends weeks later and Stripe marks the subscription canceled.
|
||||
await transition({
|
||||
userId: user.id,
|
||||
beforeStatus: 'ACTIVE',
|
||||
afterStatus: 'CANCELED',
|
||||
beforeCancelAtPeriodEnd: true,
|
||||
});
|
||||
|
||||
expect(await db.analyticsEvent.count({ where: { name: 'SUBSCRIPTION_CANCELED' } })).toBe(1);
|
||||
});
|
||||
|
||||
it('records a reactivation when the customer changes their mind', async () => {
|
||||
const user = await createUser();
|
||||
|
||||
await transition({
|
||||
userId: user.id,
|
||||
beforeStatus: 'ACTIVE',
|
||||
afterStatus: 'ACTIVE',
|
||||
afterCancelAtPeriodEnd: true,
|
||||
});
|
||||
await transition({
|
||||
userId: user.id,
|
||||
beforeStatus: 'ACTIVE',
|
||||
afterStatus: 'ACTIVE',
|
||||
beforeCancelAtPeriodEnd: true,
|
||||
afterCancelAtPeriodEnd: false,
|
||||
});
|
||||
|
||||
expect(await eventNames()).toEqual(['SUBSCRIPTION_CANCELED', 'SUBSCRIPTION_REACTIVATED']);
|
||||
});
|
||||
|
||||
it('records nothing when nothing changed', async () => {
|
||||
const user = await createUser();
|
||||
|
||||
await transition({ userId: user.id, beforeStatus: 'ACTIVE', afterStatus: 'ACTIVE' });
|
||||
|
||||
expect(await eventNames()).toEqual([]);
|
||||
});
|
||||
|
||||
it('writes nothing when the flag is off', async () => {
|
||||
vi.stubEnv('OPENFRAME_ENABLE_ANALYTICS', 'false');
|
||||
const user = await createUser();
|
||||
|
||||
await transition({ userId: user.id, beforeStatus: 'FREE', afterStatus: 'ACTIVE' });
|
||||
|
||||
expect(await db.analyticsEvent.count()).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,155 @@
|
||||
// Exercises the scoreboard queries against a real database.
|
||||
//
|
||||
// These are raw SQL: a date_trunc grouping, a COALESCE across two tables and a
|
||||
// filtered left join. None of that is checked by the type system, so a seeded
|
||||
// week with known counts is the only thing standing between a renamed column and
|
||||
// a growth page that renders zeros forever.
|
||||
|
||||
import { describe, expect, it, beforeEach, afterEach, vi } from 'vitest';
|
||||
import type { AcquisitionChannel, AnalyticsEventName } from '@prisma/client';
|
||||
import { db } from '@/lib/db';
|
||||
import { AT_RISK_SILENT_DAYS, getScoreboard } from '@/lib/analytics/scoreboard';
|
||||
import { createUser } from '../factories';
|
||||
|
||||
function daysAgo(days: number): Date {
|
||||
const date = new Date();
|
||||
date.setUTCDate(date.getUTCDate() - days);
|
||||
return date;
|
||||
}
|
||||
|
||||
let sequence = 0;
|
||||
|
||||
async function seedEvent(params: {
|
||||
name: AnalyticsEventName;
|
||||
occurredAt: Date;
|
||||
userId?: string;
|
||||
anonymousId?: string;
|
||||
channel?: AcquisitionChannel;
|
||||
}) {
|
||||
sequence += 1;
|
||||
await db.analyticsEvent.create({
|
||||
data: {
|
||||
name: params.name,
|
||||
dedupeKey: `${params.name}:seed-${sequence}`,
|
||||
occurredAt: params.occurredAt,
|
||||
userId: params.userId ?? null,
|
||||
anonymousId: params.anonymousId ?? null,
|
||||
channel: params.channel ?? null,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
sequence = 0;
|
||||
vi.stubEnv('OPENFRAME_ENABLE_ANALYTICS', 'true');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
describe('getScoreboard', () => {
|
||||
it('returns an empty week for every week in the window when nothing happened', async () => {
|
||||
const scoreboard = await getScoreboard({ weeks: 4 });
|
||||
|
||||
expect(scoreboard.weeks).toHaveLength(4);
|
||||
expect(scoreboard.weeks.every((week) => week.visitors === 0)).toBe(true);
|
||||
expect(scoreboard.channels).toEqual([]);
|
||||
expect(scoreboard.paidAccounts).toEqual([]);
|
||||
});
|
||||
|
||||
it('counts a returning visitor once per week, not once per visit', async () => {
|
||||
// Landing views are deduped per visitor per day, so the same person on three
|
||||
// days is three rows. Weekly visitors is a distinct count over the id.
|
||||
for (const days of [1, 2, 3]) {
|
||||
await seedEvent({
|
||||
name: 'LANDING_VIEW',
|
||||
occurredAt: daysAgo(days),
|
||||
anonymousId: 'visitor-one',
|
||||
channel: 'GITHUB',
|
||||
});
|
||||
}
|
||||
await seedEvent({
|
||||
name: 'LANDING_VIEW',
|
||||
occurredAt: daysAgo(1),
|
||||
anonymousId: 'visitor-two',
|
||||
channel: 'GOOGLE',
|
||||
});
|
||||
|
||||
const scoreboard = await getScoreboard({ weeks: 2 });
|
||||
const total = scoreboard.weeks.reduce((sum, week) => sum + week.visitors, 0);
|
||||
|
||||
expect(total).toBe(2);
|
||||
});
|
||||
|
||||
it('reads a signed-up visitor through the channel on their account', async () => {
|
||||
const user = await createUser();
|
||||
await db.userAcquisition.create({
|
||||
data: { userId: user.id, channel: 'YOUTUBE', anonymousId: 'visitor-three' },
|
||||
});
|
||||
|
||||
// The visitor event carries GITHUB from the cookie, but the account says
|
||||
// YouTube. The account wins, so correcting a channel corrects its history.
|
||||
await seedEvent({
|
||||
name: 'LANDING_VIEW',
|
||||
occurredAt: daysAgo(2),
|
||||
anonymousId: 'visitor-three',
|
||||
channel: 'GITHUB',
|
||||
userId: user.id,
|
||||
});
|
||||
await seedEvent({
|
||||
name: 'SIGNUP_COMPLETED',
|
||||
occurredAt: daysAgo(2),
|
||||
userId: user.id,
|
||||
anonymousId: 'visitor-three',
|
||||
});
|
||||
|
||||
const scoreboard = await getScoreboard({ weeks: 2 });
|
||||
const youtube = scoreboard.channels.find((row) => row.channel === 'YOUTUBE');
|
||||
|
||||
expect(youtube).toMatchObject({ visitors: 1, signups: 1 });
|
||||
expect(scoreboard.channels.find((row) => row.channel === 'GITHUB')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('carries subscriptions started before the window into the running total', async () => {
|
||||
await seedEvent({ name: 'SUBSCRIPTION_STARTED', occurredAt: daysAgo(120) });
|
||||
await seedEvent({ name: 'SUBSCRIPTION_STARTED', occurredAt: daysAgo(3) });
|
||||
await seedEvent({ name: 'SUBSCRIPTION_CANCELED', occurredAt: daysAgo(3) });
|
||||
|
||||
const scoreboard = await getScoreboard({ weeks: 2 });
|
||||
const last = scoreboard.weeks[scoreboard.weeks.length - 1];
|
||||
|
||||
// One from before the window, plus one started and one canceled inside it.
|
||||
expect(last?.activePaid).toBe(1);
|
||||
expect(last?.newPaid).toBe(1);
|
||||
expect(last?.canceled).toBe(1);
|
||||
});
|
||||
|
||||
it('flags a paid account that has produced nothing recently', async () => {
|
||||
const busy = await createUser({ subscriptionStatus: 'ACTIVE' });
|
||||
const silent = await createUser({ subscriptionStatus: 'ACTIVE' });
|
||||
const trialing = await createUser({ subscriptionStatus: 'TRIALING' });
|
||||
await createUser({ subscriptionStatus: 'FREE' });
|
||||
|
||||
await seedEvent({ name: 'VIDEO_ADDED', occurredAt: daysAgo(2), userId: busy.id });
|
||||
await seedEvent({ name: 'SHARE_LINK_CREATED', occurredAt: daysAgo(20), userId: busy.id });
|
||||
await seedEvent({
|
||||
name: 'VIDEO_ADDED',
|
||||
occurredAt: daysAgo(AT_RISK_SILENT_DAYS + 5),
|
||||
userId: silent.id,
|
||||
});
|
||||
// A signup is not a value event, so it must not clear the risk flag.
|
||||
await seedEvent({ name: 'SIGNUP_COMPLETED', occurredAt: daysAgo(1), userId: trialing.id });
|
||||
|
||||
const scoreboard = await getScoreboard({ weeks: 4 });
|
||||
const ids = scoreboard.paidAccounts.map((row) => row.userId).sort();
|
||||
const atRisk = scoreboard.atRisk.map((row) => row.userId).sort();
|
||||
|
||||
expect(ids).toEqual([busy.id, silent.id, trialing.id].sort());
|
||||
expect(atRisk).toEqual([silent.id, trialing.id].sort());
|
||||
|
||||
const busyRow = scoreboard.paidAccounts.find((row) => row.userId === busy.id);
|
||||
expect(busyRow?.valueEvents7).toBe(1);
|
||||
expect(busyRow?.valueEvents30).toBe(2);
|
||||
});
|
||||
});
|
||||
@@ -44,6 +44,7 @@ import {
|
||||
} from '../factories';
|
||||
|
||||
import * as adminFeedbackRoute from '@/app/api/admin/feedback/[feedbackId]/route';
|
||||
import * as adminGrowthRoute from '@/app/api/admin/growth/route';
|
||||
import * as adminRefreshR2Route from '@/app/api/admin/stats/refresh-r2/route';
|
||||
import * as approvalCancelRoute from '@/app/api/approvals/[requestId]/cancel/route';
|
||||
import * as approvalDecisionRoute from '@/app/api/approvals/[requestId]/decision/route';
|
||||
@@ -54,6 +55,7 @@ import * as commentRoute from '@/app/api/comments/[commentId]/route';
|
||||
import * as feedbackRoute from '@/app/api/feedback/route';
|
||||
import * as feedbackUploadRoute from '@/app/api/feedback/upload/route';
|
||||
import * as onboardingCompleteRoute from '@/app/api/onboarding/complete/route';
|
||||
import * as onboardingSourceRoute from '@/app/api/onboarding/source/route';
|
||||
import * as approvalCandidatesRoute from '@/app/api/projects/[projectId]/approval-candidates/route';
|
||||
import * as projectDownloadRoute from '@/app/api/projects/[projectId]/download/route';
|
||||
import * as projectInvitationRoute from '@/app/api/projects/[projectId]/members/invitations/[invitationId]/route';
|
||||
@@ -143,7 +145,7 @@ vi.mock('@/lib/r2', async (importOriginal) => {
|
||||
// The count guard
|
||||
// ---------------------------------------------------------------------------
|
||||
// Bump this only together with a new entry in ROUTE_CASES or in PUBLIC_ROUTES.
|
||||
const EXPECTED_ROUTE_MODULE_COUNT = 60;
|
||||
const EXPECTED_ROUTE_MODULE_COUNT = 63;
|
||||
|
||||
/**
|
||||
* Routes that are public by design, and why. Everything else must reject an
|
||||
@@ -176,6 +178,15 @@ const PUBLIC_ROUTES: ReadonlyMap<string, string> = new Map([
|
||||
// so it cannot be used to enumerate accounts.
|
||||
'resend of the verification email, for users who cannot sign in yet',
|
||||
],
|
||||
[
|
||||
'events/route.ts',
|
||||
// The CTA-click beacon. Its whole job is to hear from visitors who have no
|
||||
// account yet, so a session cannot be the guard. It is bounded three ways
|
||||
// instead: same-origin only, IP rate limited, and it accepts exactly one
|
||||
// event name, so nothing a caller sends can forge a signup or a payment.
|
||||
// Covered in tests/api/analytics-events.test.ts.
|
||||
'anonymous CTA beacon, restricted to one event name and to same-origin callers',
|
||||
],
|
||||
[
|
||||
'stripe/webhook/route.ts',
|
||||
// Called by Stripe, not by a browser. Authenticated by the HMAC signature
|
||||
@@ -350,6 +361,11 @@ const ROUTE_CASES: readonly RouteCase[] = [
|
||||
url: (f) => `/api/admin/feedback/${f.feedbackId}`,
|
||||
params: (f) => ({ feedbackId: f.feedbackId }),
|
||||
},
|
||||
{
|
||||
file: 'admin/growth/route.ts',
|
||||
module: adminGrowthRoute,
|
||||
url: () => '/api/admin/growth',
|
||||
},
|
||||
{
|
||||
file: 'admin/stats/refresh-r2/route.ts',
|
||||
module: adminRefreshR2Route,
|
||||
@@ -405,6 +421,12 @@ const ROUTE_CASES: readonly RouteCase[] = [
|
||||
module: onboardingCompleteRoute,
|
||||
url: () => '/api/onboarding/complete',
|
||||
},
|
||||
{
|
||||
file: 'onboarding/source/route.ts',
|
||||
module: onboardingSourceRoute,
|
||||
url: () => '/api/onboarding/source',
|
||||
body: { source: 'GITHUB' },
|
||||
},
|
||||
{
|
||||
file: 'projects/[projectId]/approval-candidates/route.ts',
|
||||
module: approvalCandidatesRoute,
|
||||
|
||||
@@ -66,6 +66,7 @@ const REVIEWED_MIGRATIONS = [
|
||||
'20260613120000_add_r2_video_asset_provider',
|
||||
'20260614160000_add_project_allow_downloads',
|
||||
'20260627140000_add_video_upload_multipart_id',
|
||||
'20260801120000_add_acquisition_analytics',
|
||||
];
|
||||
|
||||
/** Objects POST_PUSH_SQL must have produced. Verified after it runs. */
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
classifyChannel,
|
||||
extractReferrerHost,
|
||||
normalizeHost,
|
||||
sanitizeLandingPath,
|
||||
sanitizeTag,
|
||||
} from '@/lib/analytics/channel';
|
||||
|
||||
// Every expected value below is written by hand. Deriving them from the lookup
|
||||
// tables in the module would mean deleting an entry from a table also deletes
|
||||
// its own test case.
|
||||
|
||||
describe('sanitizeTag', () => {
|
||||
it('lowercases and trims', () => {
|
||||
expect(sanitizeTag(' GitHub ')).toBe('github');
|
||||
});
|
||||
|
||||
it('rejects a tag carrying markup or control characters', () => {
|
||||
expect(sanitizeTag('<script>')).toBeNull();
|
||||
expect(sanitizeTag('news\nletter')).toBeNull();
|
||||
});
|
||||
|
||||
it('caps the length at 64 characters', () => {
|
||||
expect(sanitizeTag('a'.repeat(200))).toHaveLength(64);
|
||||
});
|
||||
|
||||
it('treats an empty or non-string value as absent', () => {
|
||||
expect(sanitizeTag(' ')).toBeNull();
|
||||
expect(sanitizeTag(null)).toBeNull();
|
||||
expect(sanitizeTag(undefined)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('normalizeHost', () => {
|
||||
it('drops the www prefix and the port', () => {
|
||||
expect(normalizeHost('WWW.GitHub.com:443')).toBe('github.com');
|
||||
});
|
||||
|
||||
it('rejects a value that is not a host', () => {
|
||||
expect(normalizeHost('not a host')).toBeNull();
|
||||
expect(normalizeHost('https://github.com')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractReferrerHost', () => {
|
||||
it('returns the host of a full referrer URL', () => {
|
||||
expect(extractReferrerHost('https://news.ycombinator.com/item?id=1')).toBe(
|
||||
'news.ycombinator.com'
|
||||
);
|
||||
});
|
||||
|
||||
it('drops the path and query, so a share token cannot be stored', () => {
|
||||
expect(extractReferrerHost('https://example.com/share/[email protected]')).toBe(
|
||||
'example.com'
|
||||
);
|
||||
});
|
||||
|
||||
it('ignores our own host, because that is a click inside the site', () => {
|
||||
expect(extractReferrerHost('https://open-frame.net/pricing', 'open-frame.net')).toBeNull();
|
||||
expect(extractReferrerHost('https://www.open-frame.net/pricing', 'open-frame.net')).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for a missing or unparseable referrer', () => {
|
||||
expect(extractReferrerHost(null)).toBeNull();
|
||||
expect(extractReferrerHost('android-app://com.example')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('sanitizeLandingPath', () => {
|
||||
it('keeps the path and drops the query string', () => {
|
||||
expect(sanitizeLandingPath('/vs/frameio')).toBe('/vs/frameio');
|
||||
});
|
||||
|
||||
it('falls back to / for anything that is not a path', () => {
|
||||
expect(sanitizeLandingPath('https://open-frame.net/x')).toBe('/');
|
||||
expect(sanitizeLandingPath(null)).toBe('/');
|
||||
});
|
||||
|
||||
it('keeps what a real route can carry', () => {
|
||||
expect(sanitizeLandingPath('/vs/frame.io')).toBe('/vs/frame.io');
|
||||
expect(sanitizeLandingPath('/watch/cm4x-01_a')).toBe('/watch/cm4x-01_a');
|
||||
expect(sanitizeLandingPath('/blog/%C3%BCr%C3%BCn')).toBe('/blog/%C3%BCr%C3%BCn');
|
||||
});
|
||||
|
||||
it('drops a path that could only have come from a hand-written cookie', () => {
|
||||
// The proxy feeds this a real pathname. The cookie reader feeds it whatever
|
||||
// the cookie said, and that value ends up in a database column.
|
||||
expect(sanitizeLandingPath('/<script>alert(1)</script>')).toBe('/');
|
||||
expect(sanitizeLandingPath('/ok\nX-Injected: 1')).toBe('/');
|
||||
expect(sanitizeLandingPath('/a b')).toBe('/');
|
||||
});
|
||||
});
|
||||
|
||||
describe('classifyChannel', () => {
|
||||
it('is DIRECT with no tags and no referrer', () => {
|
||||
expect(classifyChannel({})).toBe('DIRECT');
|
||||
});
|
||||
|
||||
it('reads the referring host when there are no tags', () => {
|
||||
expect(classifyChannel({ referrerHost: 'github.com' })).toBe('GITHUB');
|
||||
expect(classifyChannel({ referrerHost: 'gist.github.com' })).toBe('GITHUB');
|
||||
expect(classifyChannel({ referrerHost: 'youtu.be' })).toBe('YOUTUBE');
|
||||
expect(classifyChannel({ referrerHost: 'www.producthunt.com' })).toBe('REVIEW_LINK');
|
||||
expect(classifyChannel({ referrerHost: 'news.ycombinator.com' })).toBe('COMMUNITY');
|
||||
});
|
||||
|
||||
it('treats every Google country domain as search', () => {
|
||||
expect(classifyChannel({ referrerHost: 'google.com' })).toBe('GOOGLE');
|
||||
expect(classifyChannel({ referrerHost: 'google.com.tr' })).toBe('GOOGLE');
|
||||
expect(classifyChannel({ referrerHost: 'news.google.co.uk' })).toBe('GOOGLE');
|
||||
});
|
||||
|
||||
it('does not mistake a lookalike domain for the real one', () => {
|
||||
expect(classifyChannel({ referrerHost: 'notgithub.com' })).toBe('REFERRAL');
|
||||
expect(classifyChannel({ referrerHost: 'google.com.evil.example' })).toBe('REFERRAL');
|
||||
});
|
||||
|
||||
it('counts an unrecognised site that links to us as a referral', () => {
|
||||
expect(classifyChannel({ referrerHost: 'someblog.example' })).toBe('REFERRAL');
|
||||
});
|
||||
|
||||
it('prefers an explicit utm_source over the referring host', () => {
|
||||
expect(classifyChannel({ utmSource: 'youtube', referrerHost: 'google.com' })).toBe('YOUTUBE');
|
||||
});
|
||||
|
||||
it('reads a utm_source that was written as a domain', () => {
|
||||
expect(classifyChannel({ utmSource: 'github.com' })).toBe('GITHUB');
|
||||
});
|
||||
|
||||
it('files a tagged campaign we do not recognise as OTHER, not DIRECT', () => {
|
||||
expect(classifyChannel({ utmSource: 'conference-flyer' })).toBe('OTHER');
|
||||
});
|
||||
|
||||
it('lets the medium that names the motion win over the source that names the place', () => {
|
||||
expect(classifyChannel({ utmSource: 'linkedin', utmMedium: 'outbound' })).toBe('OUTBOUND');
|
||||
expect(classifyChannel({ utmSource: 'github', utmMedium: 'email' })).toBe('OUTBOUND');
|
||||
expect(classifyChannel({ utmSource: 'someone', utmMedium: 'referral' })).toBe('REFERRAL');
|
||||
});
|
||||
|
||||
it('ignores a source that fails sanitizing and falls back to the referrer', () => {
|
||||
expect(classifyChannel({ utmSource: '<script>', referrerHost: 'youtube.com' })).toBe('YOUTUBE');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,223 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import {
|
||||
decodeFirstTouch,
|
||||
encodeFirstTouch,
|
||||
generateAnonymousId,
|
||||
isAcquisitionChannel,
|
||||
isValidAnonymousId,
|
||||
readAnonymousIdCookie,
|
||||
readFirstTouchCookie,
|
||||
signAnonymousId,
|
||||
signFirstTouch,
|
||||
type FirstTouch,
|
||||
} from '@/lib/analytics/cookies';
|
||||
import { isCountableDocumentRequest, isLikelyBot } from '@/lib/analytics/bots';
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubEnv('NEXTAUTH_SECRET', 'cookie-test-secret');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
/** The cookie body a forged value would have to carry, in the wire form the reader expects. */
|
||||
function body(payload: unknown): string {
|
||||
return btoa(JSON.stringify(payload)).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
||||
}
|
||||
|
||||
const TOUCH: FirstTouch = {
|
||||
channel: 'GITHUB',
|
||||
utmSource: 'github',
|
||||
utmMedium: 'readme',
|
||||
utmCampaign: 'launch',
|
||||
referrerHost: 'github.com',
|
||||
landingPath: '/vs/frameio',
|
||||
};
|
||||
|
||||
describe('first touch cookie', () => {
|
||||
it('round-trips every field', () => {
|
||||
expect(decodeFirstTouch(encodeFirstTouch(TOUCH))).toEqual(TOUCH);
|
||||
});
|
||||
|
||||
it('round-trips a touch with nothing but a channel', () => {
|
||||
const bare: FirstTouch = {
|
||||
channel: 'DIRECT',
|
||||
utmSource: null,
|
||||
utmMedium: null,
|
||||
utmCampaign: null,
|
||||
referrerHost: null,
|
||||
landingPath: '/',
|
||||
};
|
||||
expect(decodeFirstTouch(encodeFirstTouch(bare))).toEqual(bare);
|
||||
});
|
||||
|
||||
it('rejects a hand-edited cookie carrying an unknown channel', () => {
|
||||
expect(decodeFirstTouch(body({ c: 'INVESTOR_DEMO', p: '/' }))).toBeNull();
|
||||
});
|
||||
|
||||
it('re-sanitizes fields rather than trusting the cookie', () => {
|
||||
const decoded = decodeFirstTouch(
|
||||
body({ c: 'DIRECT', p: '/x', s: '<script>alert(1)</script>', r: 'not a host' })
|
||||
);
|
||||
expect(decoded?.utmSource).toBeNull();
|
||||
expect(decoded?.referrerHost).toBeNull();
|
||||
});
|
||||
|
||||
it('re-sanitizes a landing path that could only have been hand-written', () => {
|
||||
expect(decodeFirstTouch(body({ c: 'DIRECT', p: '/<img src=x onerror=1>' }))?.landingPath).toBe(
|
||||
'/'
|
||||
);
|
||||
});
|
||||
|
||||
it('returns null for garbage and for an absent cookie', () => {
|
||||
expect(decodeFirstTouch('%%%not-base64%%%')).toBeNull();
|
||||
expect(decodeFirstTouch(null)).toBeNull();
|
||||
expect(decodeFirstTouch(body(['DIRECT']))).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('anonymous id', () => {
|
||||
it('generates an id the validator accepts', () => {
|
||||
expect(isValidAnonymousId(generateAnonymousId())).toBe(true);
|
||||
});
|
||||
|
||||
it('generates a different id each time', () => {
|
||||
expect(generateAnonymousId()).not.toBe(generateAnonymousId());
|
||||
});
|
||||
|
||||
it('rejects an id that is too short, too long or not base36', () => {
|
||||
expect(isValidAnonymousId('abc')).toBe(false);
|
||||
expect(isValidAnonymousId('a'.repeat(65))).toBe(false);
|
||||
expect(isValidAnonymousId('ABCDEF0123456789ABCD')).toBe(false);
|
||||
expect(isValidAnonymousId(undefined)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// Everything above tests the unsigned inner layer. Nothing outside the module
|
||||
// uses it: a cookie is only a visitor once the signature says this deployment
|
||||
// issued it, which is what stops a caller from inventing one with curl.
|
||||
describe('signed cookies', () => {
|
||||
const TOUCH_TO_SIGN: FirstTouch = {
|
||||
channel: 'YOUTUBE',
|
||||
utmSource: 'yt',
|
||||
utmMedium: null,
|
||||
utmCampaign: null,
|
||||
referrerHost: 'youtube.com',
|
||||
landingPath: '/',
|
||||
};
|
||||
|
||||
it('round-trips an id and a first touch', async () => {
|
||||
const id = generateAnonymousId();
|
||||
expect(await readAnonymousIdCookie(await signAnonymousId(id))).toBe(id);
|
||||
expect(await readFirstTouchCookie(await signFirstTouch(TOUCH_TO_SIGN))).toEqual(TOUCH_TO_SIGN);
|
||||
});
|
||||
|
||||
it('rejects a well-formed id that carries no signature', async () => {
|
||||
expect(await readAnonymousIdCookie('a1b2c3d4e5f60718293a4b5c6d7e8f90')).toBeNull();
|
||||
});
|
||||
|
||||
it('rejects a value whose body was edited under a valid signature', async () => {
|
||||
const signed = (await signAnonymousId(generateAnonymousId())) ?? '';
|
||||
const [mac] = signed.split('.');
|
||||
|
||||
expect(await readAnonymousIdCookie(`${mac}.a1b2c3d4e5f60718293a4b5c6d7e8f90`)).toBeNull();
|
||||
});
|
||||
|
||||
it('rejects a first touch re-signed to name another channel', async () => {
|
||||
const forgedBody = encodeFirstTouch({ ...TOUCH_TO_SIGN, channel: 'GITHUB' });
|
||||
const signed = (await signFirstTouch(TOUCH_TO_SIGN)) ?? '';
|
||||
const [mac] = signed.split('.');
|
||||
|
||||
expect(await readFirstTouchCookie(`${mac}.${forgedBody}`)).toBeNull();
|
||||
});
|
||||
|
||||
it('rejects a cookie signed with another deployment key', async () => {
|
||||
const signed = await signAnonymousId(generateAnonymousId());
|
||||
|
||||
vi.stubEnv('NEXTAUTH_SECRET', 'someone-elses-secret');
|
||||
|
||||
expect(await readAnonymousIdCookie(signed)).toBeNull();
|
||||
});
|
||||
|
||||
it('signs nothing and accepts nothing when there is no secret', async () => {
|
||||
const signed = await signAnonymousId(generateAnonymousId());
|
||||
|
||||
vi.stubEnv('NEXTAUTH_SECRET', undefined);
|
||||
vi.stubEnv('AUTH_SECRET', undefined);
|
||||
|
||||
expect(await signAnonymousId(generateAnonymousId())).toBeNull();
|
||||
expect(await readAnonymousIdCookie(signed)).toBeNull();
|
||||
});
|
||||
|
||||
it('rejects the empty, the truncated and the separator-less', async () => {
|
||||
expect(await readAnonymousIdCookie('')).toBeNull();
|
||||
expect(await readAnonymousIdCookie(undefined)).toBeNull();
|
||||
expect(await readAnonymousIdCookie('.')).toBeNull();
|
||||
expect(await readAnonymousIdCookie('a'.repeat(22))).toBeNull();
|
||||
expect(await readFirstTouchCookie('not-signed-at-all')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('isAcquisitionChannel', () => {
|
||||
it('accepts the nine buckets and nothing else', () => {
|
||||
expect(isAcquisitionChannel('REVIEW_LINK')).toBe(true);
|
||||
expect(isAcquisitionChannel('direct')).toBe(false);
|
||||
expect(isAcquisitionChannel(7)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isLikelyBot', () => {
|
||||
it('passes a real browser through', () => {
|
||||
expect(
|
||||
isLikelyBot(
|
||||
'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126 Safari/537.36'
|
||||
)
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('catches crawlers, link previewers and scripts', () => {
|
||||
expect(isLikelyBot('Googlebot/2.1 (+http://www.google.com/bot.html)')).toBe(true);
|
||||
expect(isLikelyBot('facebookexternalhit/1.1')).toBe(true);
|
||||
expect(isLikelyBot('curl/8.4.0')).toBe(true);
|
||||
expect(isLikelyBot('python-requests/2.31.0')).toBe(true);
|
||||
expect(isLikelyBot('HeadlessChrome/120.0.0.0')).toBe(true);
|
||||
});
|
||||
|
||||
it('treats a missing user agent as a bot', () => {
|
||||
expect(isLikelyBot('')).toBe(true);
|
||||
expect(isLikelyBot(null)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isCountableDocumentRequest', () => {
|
||||
it('counts a real page load', () => {
|
||||
expect(isCountableDocumentRequest(new Headers({ 'sec-fetch-dest': 'document' }))).toBe(true);
|
||||
});
|
||||
|
||||
it('does not count a prefetch of the register page', () => {
|
||||
expect(
|
||||
isCountableDocumentRequest(
|
||||
new Headers({ 'sec-fetch-dest': 'document', 'sec-purpose': 'prefetch;prerender' })
|
||||
)
|
||||
).toBe(false);
|
||||
expect(
|
||||
isCountableDocumentRequest(
|
||||
new Headers({ 'sec-fetch-dest': 'document', 'next-router-prefetch': '1' })
|
||||
)
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('does not count an RSC navigation or a subresource', () => {
|
||||
expect(
|
||||
isCountableDocumentRequest(new Headers({ 'sec-fetch-dest': 'document', rsc: '1' }))
|
||||
).toBe(false);
|
||||
expect(isCountableDocumentRequest(new Headers({ 'sec-fetch-dest': 'image' }))).toBe(false);
|
||||
});
|
||||
|
||||
it('falls back to the accept header when fetch metadata is missing', () => {
|
||||
expect(isCountableDocumentRequest(new Headers({ accept: 'text/html,*/*' }))).toBe(true);
|
||||
expect(isCountableDocumentRequest(new Headers({ accept: 'application/json' }))).toBe(false);
|
||||
expect(isCountableDocumentRequest(new Headers())).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { conversionRates } from '@/lib/analytics/scoreboard';
|
||||
|
||||
const WEEK = {
|
||||
visitors: 200,
|
||||
signups: 20,
|
||||
firstVideo: 10,
|
||||
shareLinks: 5,
|
||||
externalFeedback: 1,
|
||||
trials: 4,
|
||||
newPaid: 1,
|
||||
};
|
||||
|
||||
describe('conversionRates', () => {
|
||||
it('divides each step by the one above it', () => {
|
||||
const rates = conversionRates(WEEK);
|
||||
expect(rates.visitorToSignup).toBeCloseTo(0.1);
|
||||
expect(rates.signupToFirstVideo).toBeCloseTo(0.5);
|
||||
expect(rates.firstVideoToShare).toBeCloseTo(0.5);
|
||||
expect(rates.shareToFeedback).toBeCloseTo(0.2);
|
||||
expect(rates.trialToPaid).toBeCloseTo(0.25);
|
||||
});
|
||||
|
||||
it('returns null rather than zero when the denominator is zero', () => {
|
||||
const rates = conversionRates({ ...WEEK, visitors: 0, trials: 0 });
|
||||
expect(rates.visitorToSignup).toBeNull();
|
||||
expect(rates.trialToPaid).toBeNull();
|
||||
// "nobody arrived" and "nobody converted" are different facts, and the rest
|
||||
// of the funnel still has to report normally.
|
||||
expect(rates.signupToFirstVideo).toBeCloseTo(0.5);
|
||||
});
|
||||
|
||||
it('reports a step where nobody converted as zero, not as missing', () => {
|
||||
expect(conversionRates({ ...WEEK, newPaid: 0 }).trialToPaid).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,229 @@
|
||||
// The proxy is where a visitor gets an identity, and it is the only place that
|
||||
// can: it runs on the edge, before the page, on every document request.
|
||||
//
|
||||
// Two load-bearing details below. The id is written to the *request* as well as
|
||||
// the response, because a cookie set only on the response is invisible to the
|
||||
// page rendering that same request, so the very first landing view, the one
|
||||
// carrying the campaign tags that brought the visitor, would go unrecorded. And
|
||||
// both cookies are signed, because they are read straight into database columns
|
||||
// and httpOnly stops JavaScript, not curl.
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import { NextRequest } from 'next/server';
|
||||
import { proxy } from '@/proxy';
|
||||
import {
|
||||
ANONYMOUS_ID_COOKIE,
|
||||
FIRST_TOUCH_COOKIE,
|
||||
readAnonymousIdCookie,
|
||||
readFirstTouchCookie,
|
||||
} from '@/lib/analytics/cookies';
|
||||
|
||||
const BROWSER_UA =
|
||||
'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126 Safari/537.36';
|
||||
|
||||
function documentRequest(
|
||||
url: string,
|
||||
init?: { headers?: Record<string, string>; cookies?: Record<string, string> }
|
||||
) {
|
||||
const headers = new Headers({
|
||||
'user-agent': BROWSER_UA,
|
||||
'sec-fetch-dest': 'document',
|
||||
...init?.headers,
|
||||
});
|
||||
const cookies = Object.entries(init?.cookies ?? {});
|
||||
if (cookies.length > 0) {
|
||||
headers.set('cookie', cookies.map(([name, value]) => `${name}=${value}`).join('; '));
|
||||
}
|
||||
return new NextRequest(new URL(url), { headers });
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubEnv('NEXTAUTH_SECRET', 'proxy-test-secret');
|
||||
// getPublicOrigin prefers a configured origin over the request URL, so the
|
||||
// tests that care about the request URL have to start from neither being set.
|
||||
vi.stubEnv('NEXTAUTH_URL', undefined);
|
||||
vi.stubEnv('NEXT_PUBLIC_APP_URL', undefined);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
describe('proxy', () => {
|
||||
it('always sets the content security policy', async () => {
|
||||
vi.stubEnv('OPENFRAME_ENABLE_ANALYTICS', 'false');
|
||||
|
||||
const response = await proxy(documentRequest('https://open-frame.net/'));
|
||||
|
||||
expect(response.headers.get('Content-Security-Policy')).toContain("default-src 'self'");
|
||||
});
|
||||
|
||||
it('sets no acquisition cookie at all when the flag is off', async () => {
|
||||
vi.stubEnv('OPENFRAME_ENABLE_ANALYTICS', 'false');
|
||||
|
||||
const response = await proxy(documentRequest('https://open-frame.net/?utm_source=github'));
|
||||
|
||||
expect(response.cookies.get(ANONYMOUS_ID_COOKIE)).toBeUndefined();
|
||||
expect(response.cookies.get(FIRST_TOUCH_COOKIE)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('gives a new visitor an id and stores what brought them', async () => {
|
||||
vi.stubEnv('OPENFRAME_ENABLE_ANALYTICS', 'true');
|
||||
const request = documentRequest(
|
||||
'https://open-frame.net/?utm_source=youtube&utm_medium=video&utm_campaign=launch'
|
||||
);
|
||||
|
||||
const response = await proxy(request);
|
||||
|
||||
const cookie = response.cookies.get(ANONYMOUS_ID_COOKIE);
|
||||
expect(await readAnonymousIdCookie(cookie?.value)).toMatch(/^[a-z0-9]{32}$/);
|
||||
expect(cookie?.httpOnly).toBe(true);
|
||||
expect(cookie?.sameSite).toBe('lax');
|
||||
expect(cookie?.secure).toBe(true);
|
||||
|
||||
const touch = await readFirstTouchCookie(response.cookies.get(FIRST_TOUCH_COOKIE)?.value);
|
||||
expect(touch).toEqual({
|
||||
channel: 'YOUTUBE',
|
||||
utmSource: 'youtube',
|
||||
utmMedium: 'video',
|
||||
utmCampaign: 'launch',
|
||||
referrerHost: null,
|
||||
landingPath: '/',
|
||||
});
|
||||
|
||||
// The page rendering this same request has to be able to read both.
|
||||
expect(request.cookies.get(ANONYMOUS_ID_COOKIE)?.value).toBe(cookie?.value);
|
||||
expect(request.cookies.get(FIRST_TOUCH_COOKIE)?.value).toBeDefined();
|
||||
});
|
||||
|
||||
it('classifies a visit that only carries a referrer', async () => {
|
||||
vi.stubEnv('OPENFRAME_ENABLE_ANALYTICS', 'true');
|
||||
|
||||
const response = await proxy(
|
||||
documentRequest('https://open-frame.net/vs/frameio', {
|
||||
headers: { referer: 'https://github.com/yusufipk/OpenFrame' },
|
||||
})
|
||||
);
|
||||
|
||||
expect(
|
||||
await readFirstTouchCookie(response.cookies.get(FIRST_TOUCH_COOKIE)?.value)
|
||||
).toMatchObject({
|
||||
channel: 'GITHUB',
|
||||
referrerHost: 'github.com',
|
||||
landingPath: '/vs/frameio',
|
||||
});
|
||||
});
|
||||
|
||||
it('does not overwrite the first touch of a returning visitor', async () => {
|
||||
vi.stubEnv('OPENFRAME_ENABLE_ANALYTICS', 'true');
|
||||
const issued = await proxy(documentRequest('https://open-frame.net/?utm_source=github'));
|
||||
|
||||
const response = await proxy(
|
||||
documentRequest('https://open-frame.net/?utm_source=google', {
|
||||
cookies: {
|
||||
[ANONYMOUS_ID_COOKIE]: issued.cookies.get(ANONYMOUS_ID_COOKIE)?.value ?? '',
|
||||
[FIRST_TOUCH_COOKIE]: issued.cookies.get(FIRST_TOUCH_COOKIE)?.value ?? '',
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
expect(response.cookies.get(ANONYMOUS_ID_COOKIE)).toBeUndefined();
|
||||
expect(response.cookies.get(FIRST_TOUCH_COOKIE)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('replaces an id it did not sign, however well formed it looks', async () => {
|
||||
vi.stubEnv('OPENFRAME_ENABLE_ANALYTICS', 'true');
|
||||
|
||||
// The shape a real id has, chosen by the caller rather than issued here.
|
||||
// Accepting it would let anyone mint visitors, and claim the events of one
|
||||
// whose id they guessed.
|
||||
const forged = 'a1b2c3d4e5f60718293a4b5c6d7e8f90';
|
||||
|
||||
const response = await proxy(
|
||||
documentRequest('https://open-frame.net/', {
|
||||
cookies: { [ANONYMOUS_ID_COOKIE]: forged },
|
||||
})
|
||||
);
|
||||
|
||||
const issued = await readAnonymousIdCookie(response.cookies.get(ANONYMOUS_ID_COOKIE)?.value);
|
||||
expect(issued).toMatch(/^[a-z0-9]{32}$/);
|
||||
expect(issued).not.toBe(forged);
|
||||
});
|
||||
|
||||
it('replaces an id signed with another deployment key', async () => {
|
||||
vi.stubEnv('OPENFRAME_ENABLE_ANALYTICS', 'true');
|
||||
const issued = await proxy(documentRequest('https://open-frame.net/'));
|
||||
const stolen = issued.cookies.get(ANONYMOUS_ID_COOKIE)?.value ?? '';
|
||||
|
||||
vi.stubEnv('NEXTAUTH_SECRET', 'a-different-secret');
|
||||
const response = await proxy(
|
||||
documentRequest('https://open-frame.net/', {
|
||||
cookies: { [ANONYMOUS_ID_COOKIE]: stolen },
|
||||
})
|
||||
);
|
||||
|
||||
expect(response.cookies.get(ANONYMOUS_ID_COOKIE)?.value).toBeDefined();
|
||||
expect(response.cookies.get(ANONYMOUS_ID_COOKIE)?.value).not.toBe(stolen);
|
||||
});
|
||||
|
||||
it('sets nothing when there is no secret to sign with', async () => {
|
||||
vi.stubEnv('OPENFRAME_ENABLE_ANALYTICS', 'true');
|
||||
vi.stubEnv('NEXTAUTH_SECRET', undefined);
|
||||
vi.stubEnv('AUTH_SECRET', undefined);
|
||||
|
||||
const response = await proxy(documentRequest('https://open-frame.net/'));
|
||||
|
||||
expect(response.cookies.get(ANONYMOUS_ID_COOKIE)).toBeUndefined();
|
||||
expect(response.cookies.get(FIRST_TOUCH_COOKIE)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('ignores crawlers, so they never enter the visitor count', async () => {
|
||||
vi.stubEnv('OPENFRAME_ENABLE_ANALYTICS', 'true');
|
||||
|
||||
const response = await proxy(
|
||||
documentRequest('https://open-frame.net/', {
|
||||
headers: { 'user-agent': 'Googlebot/2.1 (+http://www.google.com/bot.html)' },
|
||||
})
|
||||
);
|
||||
|
||||
expect(response.cookies.get(ANONYMOUS_ID_COOKIE)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('ignores a prefetch and an API call', async () => {
|
||||
vi.stubEnv('OPENFRAME_ENABLE_ANALYTICS', 'true');
|
||||
|
||||
const prefetch = await proxy(
|
||||
documentRequest('https://open-frame.net/register', {
|
||||
headers: { 'next-router-prefetch': '1' },
|
||||
})
|
||||
);
|
||||
const api = await proxy(
|
||||
documentRequest('https://open-frame.net/api/projects', {
|
||||
headers: { 'sec-fetch-dest': 'empty' },
|
||||
})
|
||||
);
|
||||
|
||||
expect(prefetch.cookies.get(ANONYMOUS_ID_COOKIE)).toBeUndefined();
|
||||
expect(api.cookies.get(ANONYMOUS_ID_COOKIE)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('leaves the cookie insecure on plain http, so local development works', async () => {
|
||||
vi.stubEnv('OPENFRAME_ENABLE_ANALYTICS', 'true');
|
||||
|
||||
const response = await proxy(documentRequest('http://localhost:3000/'));
|
||||
|
||||
expect(response.cookies.get(ANONYMOUS_ID_COOKIE)?.secure).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps the cookie secure behind a TLS-terminating proxy', async () => {
|
||||
vi.stubEnv('OPENFRAME_ENABLE_ANALYTICS', 'true');
|
||||
// What a Docker deployment looks like from inside the container: the request
|
||||
// arrived over http on an internal address, and only the configured origin
|
||||
// knows the site is served over TLS.
|
||||
vi.stubEnv('NEXTAUTH_URL', 'https://open-frame.net');
|
||||
|
||||
const response = await proxy(documentRequest('http://localhost:3000/'));
|
||||
|
||||
expect(response.cookies.get(ANONYMOUS_ID_COOKIE)?.secure).toBe(true);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user