First-party, consent-based funnel tracking: a cookie notice asks once, and only visitors who allow it get an anonymous visit id. Event page views and registration starts are tracked from the browser; submit and paid come from the registration. Adds the Registration funnel report (conversion per stage, daily trend, by source/device/ticket option), a conversion tile on the supervisor/admin dashboards, short-link source tagging, and a daily rollup of raw rows older than 90 days. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CP62L9JxHomA36aYP1ekCe
221 lines
11 KiB
JavaScript
221 lines
11 KiB
JavaScript
/**
|
|
* Registration funnel — first-party, consent-based tracking.
|
|
*
|
|
* The browser only tracks after the visitor accepts cookies: it keeps a random session
|
|
* id and reports 'view' (event page) and 'start' (registration page). 'submit' is
|
|
* recorded here when a registration arrives carrying that session id, and 'paid' is
|
|
* read from that registration's status, so no personal data is stored with the events —
|
|
* just a stage, a traffic source and a device type (worked out from the User-Agent,
|
|
* which isn't kept).
|
|
*
|
|
* Counts are "reached": a session counts at a stage if it got that far or further, so a
|
|
* visitor who went straight to the registration page still counts as a view. Raw rows
|
|
* older than 90 days are rolled up into AnalyticsDaily and deleted.
|
|
*/
|
|
|
|
const prisma = require('../config/db');
|
|
|
|
const STAGES = ['view', 'start', 'submit', 'paid'];
|
|
const TRACKED = ['view', 'start'];
|
|
const RAW_DAYS = 90;
|
|
const SESSION_RE = /^[a-zA-Z0-9-]{16,64}$/;
|
|
|
|
const dayOf = (d) => new Date(d).toISOString().slice(0, 10);
|
|
|
|
// ─── Pure helpers ─────────────────────────────────────────────────────────────
|
|
|
|
const validSession = (sid) => typeof sid === 'string' && SESSION_RE.test(sid);
|
|
|
|
/** 'mobile' | 'tablet' | 'desktop', or null for bots. Pure. */
|
|
function deviceFromUA(ua) {
|
|
const s = String(ua || '');
|
|
if (!s || /bot|crawl|spider|slurp|preview|headless|lighthouse/i.test(s)) return null;
|
|
if (/iPad|Tablet|PlayBook|Silk|(Android(?!.*Mobile))/i.test(s)) return 'tablet';
|
|
if (/Mobi|iPhone|iPod|Android|Windows Phone/i.test(s)) return 'mobile';
|
|
return 'desktop';
|
|
}
|
|
|
|
/** A traffic source label: utm_source / short-link alias, tidied; 'direct' when none. Pure. */
|
|
function cleanSource(src) {
|
|
const s = String(src || '').trim().toLowerCase().replace(/[^a-z0-9_.-]/g, '').slice(0, 50);
|
|
return s || 'direct';
|
|
}
|
|
|
|
/**
|
|
* One entry per session: the furthest stage reached, its source/device, and the day each
|
|
* stage was reached. Pure.
|
|
* @param {Array} events - [{ sessionId, stage, createdAt, source, device }]
|
|
* @param {Set} paidSessions - session ids whose registration is paid
|
|
*/
|
|
function sessionsFrom(events, paidSessions = new Set()) {
|
|
const map = new Map();
|
|
for (const e of [...events].sort((a, b) => new Date(a.createdAt) - new Date(b.createdAt))) {
|
|
const s = map.get(e.sessionId) || { sessionId: e.sessionId, reach: -1, source: null, device: null, days: {} };
|
|
const idx = STAGES.indexOf(e.stage);
|
|
if (idx > s.reach) s.reach = idx;
|
|
if (!s.days[e.stage]) s.days[e.stage] = dayOf(e.createdAt);
|
|
if (!s.source && e.source) s.source = e.source;
|
|
if (!s.device && e.device) s.device = e.device;
|
|
map.set(e.sessionId, s);
|
|
}
|
|
for (const s of map.values()) {
|
|
if (paidSessions.has(s.sessionId) && s.reach >= 2) { s.reach = 3; s.days.paid = s.days.submit; }
|
|
// A stage reached without its own event (e.g. no 'view' for a direct link) takes the
|
|
// day of the next stage that was recorded.
|
|
for (let i = STAGES.length - 1; i >= 0; i--) {
|
|
if (i <= s.reach && !s.days[STAGES[i]]) s.days[STAGES[i]] = s.days[STAGES[i + 1]];
|
|
}
|
|
s.source = s.source || 'direct';
|
|
s.device = s.device || 'unknown';
|
|
}
|
|
return [...map.values()];
|
|
}
|
|
|
|
/** Rows for AnalyticsDaily: sessions reaching each stage per day/source/device. Pure. */
|
|
function rollupRows(sessions) {
|
|
const by = new Map();
|
|
for (const s of sessions) {
|
|
for (let i = 0; i <= s.reach; i++) {
|
|
const key = [s.days[STAGES[i]], STAGES[i], s.source, s.device].join('|');
|
|
by.set(key, (by.get(key) || 0) + 1);
|
|
}
|
|
}
|
|
return [...by.entries()].map(([key, n]) => {
|
|
const [day, stage, source, device] = key.split('|');
|
|
return { day, stage, source, device, sessions: n };
|
|
});
|
|
}
|
|
|
|
const emptyCounts = () => ({ view: 0, start: 0, submit: 0, paid: 0 });
|
|
|
|
/**
|
|
* The funnel report from raw sessions plus rolled-up rows. Pure.
|
|
* @returns {{ totals, conversion, trend, bySource, byDevice }}
|
|
*/
|
|
function funnelReport(sessions, daily = []) {
|
|
const rows = [...rollupRows(sessions), ...daily.map(d => ({ ...d, day: typeof d.day === 'string' ? d.day.slice(0, 10) : dayOf(d.day) }))];
|
|
const totals = emptyCounts();
|
|
const trend = new Map(), bySource = new Map(), byDevice = new Map();
|
|
const add = (map, key, stage, n) => { const c = map.get(key) || emptyCounts(); c[stage] += n; map.set(key, c); };
|
|
for (const r of rows) {
|
|
totals[r.stage] += r.sessions;
|
|
add(trend, r.day, r.stage, r.sessions);
|
|
add(bySource, r.source, r.stage, r.sessions);
|
|
add(byDevice, r.device, r.stage, r.sessions);
|
|
}
|
|
const pct = (a, b) => (b ? Math.round((a / b) * 1000) / 10 : null);
|
|
const list = (map) => [...map.entries()].map(([key, c]) => ({ key, ...c, conversion: pct(c.paid, c.view) })).sort((a, b) => b.view - a.view);
|
|
return {
|
|
totals,
|
|
conversion: {
|
|
start: pct(totals.start, totals.view), submit: pct(totals.submit, totals.view), paid: pct(totals.paid, totals.view),
|
|
startToSubmit: pct(totals.submit, totals.start), submitToPaid: pct(totals.paid, totals.submit),
|
|
},
|
|
trend: [...trend.entries()].sort(([a], [b]) => a.localeCompare(b)).map(([day, c]) => ({ day, ...c })),
|
|
bySource: list(bySource),
|
|
byDevice: list(byDevice),
|
|
};
|
|
}
|
|
|
|
// ─── Database ─────────────────────────────────────────────────────────────────
|
|
|
|
/** Records a 'view' or 'start'. Repeats from the same session are ignored. */
|
|
async function track({ sessionId, eventId, stage, source, userAgent }) {
|
|
if (!validSession(sessionId) || !TRACKED.includes(stage) || typeof eventId !== 'string') return false;
|
|
const device = deviceFromUA(userAgent);
|
|
if (!device) return false; // bots
|
|
const event = await prisma.event.findUnique({ where: { id: eventId }, select: { id: true } });
|
|
if (!event) return false;
|
|
await prisma.analyticsEvent.createMany({ data: [{ sessionId, eventId, stage, source: cleanSource(source), device }], skipDuplicates: true });
|
|
return true;
|
|
}
|
|
|
|
/** 'submit' for a registration made on the public registration page. */
|
|
async function recordSubmit(registration, sessionId) {
|
|
if (!validSession(sessionId) || !registration?.id) return;
|
|
const first = await prisma.analyticsEvent.findFirst({ where: { sessionId, eventId: registration.eventId }, orderBy: { createdAt: 'asc' } });
|
|
await prisma.registration.updateMany({ where: { id: registration.id, analyticsSessionId: null }, data: { analyticsSessionId: sessionId } });
|
|
await prisma.analyticsEvent.createMany({
|
|
data: [{ sessionId, eventId: registration.eventId, stage: 'submit', source: first?.source || 'direct', device: first?.device || null }],
|
|
skipDuplicates: true,
|
|
});
|
|
}
|
|
|
|
async function paidSessionsFor(eventIds, sessionIds) {
|
|
if (!sessionIds.length) return new Set();
|
|
const regs = await prisma.registration.findMany({
|
|
where: { eventId: { in: eventIds }, status: 'paid', analyticsSessionId: { in: sessionIds } },
|
|
select: { analyticsSessionId: true },
|
|
});
|
|
return new Set(regs.map(r => r.analyticsSessionId));
|
|
}
|
|
|
|
/**
|
|
* The funnel for some events between two dates (by the day each stage was reached).
|
|
* Adds a by-ticket-option breakdown from the tracked registrations.
|
|
*/
|
|
async function funnelFor(eventIds, { from, to } = {}) {
|
|
const range = {};
|
|
if (from) range.gte = new Date(from);
|
|
if (to) { const t = new Date(to); t.setHours(23, 59, 59, 999); range.lte = t; }
|
|
const where = { eventId: { in: eventIds }, ...(from || to ? { createdAt: range } : {}) };
|
|
const events = await prisma.analyticsEvent.findMany({ where, select: { sessionId: true, stage: true, createdAt: true, source: true, device: true, eventId: true } });
|
|
// A session is per event (the same browser can look at several events).
|
|
const keyed = events.map(e => ({ ...e, sessionId: `${e.eventId}:${e.sessionId}` }));
|
|
const paidRaw = await paidSessionsFor(eventIds, [...new Set(events.map(e => e.sessionId))]);
|
|
const paid = new Set(events.filter(e => paidRaw.has(e.sessionId)).map(e => `${e.eventId}:${e.sessionId}`));
|
|
const daily = await prisma.analyticsDaily.findMany({
|
|
where: { eventId: { in: eventIds }, ...(from || to ? { day: range } : {}) },
|
|
select: { day: true, stage: true, source: true, device: true, sessions: true },
|
|
});
|
|
const report = funnelReport(sessionsFrom(keyed, paid), daily);
|
|
|
|
const regs = await prisma.registration.findMany({
|
|
where: { eventId: { in: eventIds }, analyticsSessionId: { not: null }, status: { not: 'cancelled' }, ...(from || to ? { createdAt: range } : {}) },
|
|
select: { status: true, registrationOptions: { select: { quantity: true, eventOption: { select: { name: true } }, variant: { select: { name: true } } } } },
|
|
});
|
|
const byOption = new Map();
|
|
for (const r of regs) {
|
|
for (const ro of r.registrationOptions) {
|
|
const key = ro.variant ? `${ro.eventOption.name} — ${ro.variant.name}` : ro.eventOption.name;
|
|
const c = byOption.get(key) || { key, submitted: 0, paid: 0 };
|
|
c.submitted += 1;
|
|
if (r.status === 'paid') c.paid += 1;
|
|
byOption.set(key, c);
|
|
}
|
|
}
|
|
return { ...report, byOption: [...byOption.values()].sort((a, b) => b.submitted - a.submitted) };
|
|
}
|
|
|
|
/** Rolls up sessions whose last activity is older than 90 days, then deletes their rows. */
|
|
async function rollupOldEvents(now = new Date()) {
|
|
const cutoff = new Date(now.getTime() - RAW_DAYS * 864e5);
|
|
const groups = await prisma.analyticsEvent.groupBy({ by: ['eventId', 'sessionId'], _max: { createdAt: true }, having: { createdAt: { _max: { lt: cutoff } } } });
|
|
if (groups.length === 0) return 0;
|
|
const byEvent = new Map();
|
|
for (const g of groups) byEvent.set(g.eventId, [...(byEvent.get(g.eventId) || []), g.sessionId]);
|
|
let rolled = 0;
|
|
for (const [eventId, sessionIds] of byEvent) {
|
|
for (let i = 0; i < sessionIds.length; i += 500) {
|
|
const batch = sessionIds.slice(i, i + 500);
|
|
const events = await prisma.analyticsEvent.findMany({ where: { eventId, sessionId: { in: batch } } });
|
|
const paid = await paidSessionsFor([eventId], batch);
|
|
const rows = rollupRows(sessionsFrom(events, paid));
|
|
await prisma.$transaction(async (tx) => {
|
|
for (const r of rows) {
|
|
await tx.analyticsDaily.upsert({
|
|
where: { eventId_day_stage_source_device: { eventId, day: new Date(r.day), stage: r.stage, source: r.source, device: r.device } },
|
|
update: { sessions: { increment: r.sessions } },
|
|
create: { eventId, day: new Date(r.day), stage: r.stage, source: r.source, device: r.device, sessions: r.sessions },
|
|
});
|
|
}
|
|
await tx.analyticsEvent.deleteMany({ where: { eventId, sessionId: { in: batch } } });
|
|
});
|
|
rolled += batch.length;
|
|
}
|
|
}
|
|
return rolled;
|
|
}
|
|
|
|
module.exports = { STAGES, RAW_DAYS, validSession, deviceFromUA, cleanSource, sessionsFrom, rollupRows, funnelReport, track, recordSubmit, funnelFor, rollupOldEvents };
|