mirror of
https://github.com/yusufipk/OpenFrame.git
synced 2026-09-11 09:36:08 +00:00
Both cookies were read straight into database columns after nothing more than a format check. httpOnly keeps JavaScript out of them and does nothing about curl, so the anonymous id was a string the caller picked: enough to write a first-touch row for a visitor who never existed, to file it under a channel of their choosing, and to claim that id's events at signup, since the backfill matches on the id alone. They are now signed with an HMAC over AUTH_SECRET, through Web Crypto rather than node:crypto because the proxy runs on the edge and the pages that read the cookies back run in Node. The first-touch body moved to base64url on the way: cookie values are percent-encoded and decoded by several layers that do not agree on how many times, and a payload carrying its own percent escapes comes back subtly different and takes the signature with it. Signing stops a caller choosing an id, not collecting one, since dropping the cookie and asking for the landing page again mints another. So the bot and prefetch filters moved to where the rows are written rather than only where the cookies are issued, which also fixes a returning visitor's prefetch of /register recording a signup start, and a per-client hourly ceiling now sits in front of the write. The ceiling is skipped when TRUSTED_PROXY_MODE is unset, where every caller resolves to 127.0.0.1 and the bucket would empty on real traffic long before it emptied on a flood. Four smaller things around it: - /api/events checked the flag and the origin after paying for a rate-limit write, so a host who never turned analytics on was still writing a row per anonymous POST. Both checks are free and now come first, and the limiter answers 204 rather than 429: a beacon has nobody to tell, and a flooder should not be handed the reset time. - /api/onboarding/source was keyed by IP on an authenticated route. Without TRUSTED_PROXY_MODE that is five answers an hour for the whole deployment, and with it a shared office address locks out everyone after one colleague answered. Keyed by account, like /api/onboarding/complete beside it. - The cookies took their Secure flag from request.nextUrl.protocol, which behind a TLS-terminating reverse proxy is the container-internal http address. It comes off the configured public origin now. - sanitizeLandingPath took anything that started with a slash, including from the cookie, so a hand-written one could put newlines and markup into a column an admin table may render one day. Also: the paid-account query had no LIMIT and returned every active account's name and email, the growth route answered 403 where it meant 401, and the schema claimed no free text is stored when self_reported_note holds 200 characters of it.
342 lines
11 KiB
TypeScript
342 lines
11 KiB
TypeScript
// 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',
|
|
};
|
|
}
|