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
303 lines
13 KiB
JavaScript
303 lines
13 KiB
JavaScript
const prisma = require('../config/db');
|
|
const { safeErrorMessage } = require('../utils/errorUtils');
|
|
const { isDonationLeg } = require('../utils/cashupUtils');
|
|
|
|
// Shared building blocks for the per-dashboard stats endpoints below. Each dashboard
|
|
// (staff/supervisor/admin) gets exactly one endpoint that returns only what it renders,
|
|
// computed with aggregate queries — never a full payments/events list shipped to the
|
|
// client just to be reduced down to a couple of numbers.
|
|
|
|
async function computeScanStats(userId) {
|
|
const startOfDay = new Date();
|
|
startOfDay.setHours(0, 0, 0, 0);
|
|
const whereBase = { scannedAt: { gte: startOfDay } };
|
|
|
|
const [totalToday, myToday, lastHour, byStaffRaw] = await Promise.all([
|
|
prisma.ticketUsage.count({ where: whereBase }),
|
|
prisma.ticketUsage.count({ where: { ...whereBase, scannedById: userId } }),
|
|
prisma.ticketUsage.count({ where: { scannedAt: { gte: new Date(Date.now() - 60 * 60 * 1000) } } }),
|
|
prisma.ticketUsage.groupBy({ by: ['scannedById'], where: whereBase, _count: { _all: true } }),
|
|
]);
|
|
|
|
const staffIds = byStaffRaw.map((b) => b.scannedById);
|
|
const staffUsers = staffIds.length > 0
|
|
? await prisma.user.findMany({ where: { id: { in: staffIds } }, select: { id: true, name: true } })
|
|
: [];
|
|
const nameMap = Object.fromEntries(staffUsers.map((u) => [u.id, u.name]));
|
|
|
|
return {
|
|
totalToday,
|
|
myToday,
|
|
lastHour,
|
|
byStaff: byStaffRaw.map((b) => ({ scannedById: b.scannedById, name: nameMap[b.scannedById] || 'Staff', count: b._count._all })),
|
|
};
|
|
}
|
|
|
|
function getRecentScans(limit = 10) {
|
|
return prisma.ticketUsage.findMany({
|
|
orderBy: { scannedAt: 'desc' },
|
|
take: limit,
|
|
select: {
|
|
id: true,
|
|
scannedAt: true,
|
|
quantityRedeemed: true,
|
|
scannedBy: { select: { id: true, name: true } },
|
|
ticket: {
|
|
select: {
|
|
id: true,
|
|
event: { select: { title: true } },
|
|
registrationOption: { select: { eventOption: { select: { name: true } } } },
|
|
},
|
|
},
|
|
},
|
|
});
|
|
}
|
|
|
|
function getActiveEventsCount() {
|
|
return prisma.event.count({ where: { isActive: true, endDate: { gte: new Date() } } });
|
|
}
|
|
|
|
// A donation-application leg (isDonation:false, originalPaymentId set, amount>0) isn't new
|
|
// money — see isDonationLeg in cashupUtils.js. Matches the same exclusion already applied in
|
|
// paymentController.getPaymentStats and every report; without it these totals overcount.
|
|
const EXCLUDE_DONATION_LEGS = {
|
|
NOT: { AND: [{ isDonation: false }, { originalPaymentId: { not: null } }, { amount: { gt: 0 } }] },
|
|
};
|
|
|
|
async function computePaymentStats({ includeWeekMonth }) {
|
|
const startOfDay = new Date(new Date().setHours(0, 0, 0, 0));
|
|
|
|
const queries = [
|
|
prisma.payment.aggregate({ _sum: { amount: true }, where: { createdAt: { gte: startOfDay }, ...EXCLUDE_DONATION_LEGS } }),
|
|
prisma.payment.count({ where: { isDonation: true, createdAt: { gte: startOfDay } } }),
|
|
];
|
|
if (includeWeekMonth) {
|
|
const lastWeek = new Date(startOfDay.getTime() - 7 * 24 * 60 * 60 * 1000);
|
|
const lastMonth = new Date(startOfDay.getTime() - 30 * 24 * 60 * 60 * 1000);
|
|
queries.push(
|
|
prisma.payment.aggregate({ _sum: { amount: true }, where: { createdAt: { gte: lastWeek }, ...EXCLUDE_DONATION_LEGS } }),
|
|
prisma.payment.aggregate({ _sum: { amount: true }, where: { createdAt: { gte: lastMonth }, ...EXCLUDE_DONATION_LEGS } }),
|
|
);
|
|
}
|
|
|
|
const [totalToday, donationsToday, totalWeek, totalMonth] = await Promise.all(queries);
|
|
|
|
const stats = {
|
|
totalToday: totalToday._sum.amount || 0,
|
|
donationsToday,
|
|
};
|
|
if (includeWeekMonth) {
|
|
stats.totalWeek = totalWeek._sum.amount || 0;
|
|
stats.totalMonth = totalMonth._sum.amount || 0;
|
|
}
|
|
return stats;
|
|
}
|
|
|
|
// @desc All stats the staff dashboard needs, in one call
|
|
// @route GET /api/stats/staff
|
|
// @access Private/Staff+
|
|
const getStaffDashboardStats = async (req, res) => {
|
|
try {
|
|
const [scanStats, recentScans] = await Promise.all([
|
|
computeScanStats(req.user.id),
|
|
getRecentScans(10),
|
|
]);
|
|
res.json({ scanStats, recentScans });
|
|
} catch (error) {
|
|
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
|
|
}
|
|
};
|
|
|
|
// @desc All stats the supervisor dashboard needs, in one call
|
|
// @route GET /api/stats/supervisor
|
|
// @access Private/Supervisor+
|
|
const getSupervisorDashboardStats = async (req, res) => {
|
|
try {
|
|
const [scanStats, recentScans, paymentStats, activeEventsCount] = await Promise.all([
|
|
computeScanStats(req.user.id),
|
|
getRecentScans(10),
|
|
computePaymentStats({ includeWeekMonth: false }),
|
|
getActiveEventsCount(),
|
|
]);
|
|
res.json({ scanStats, recentScans, paymentStats, activeEventsCount });
|
|
} catch (error) {
|
|
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
|
|
}
|
|
};
|
|
|
|
// @desc All stats the admin dashboard needs, in one call
|
|
// @route GET /api/stats/admin
|
|
// @access Private/Admin
|
|
const getAdminDashboardStats = async (req, res) => {
|
|
try {
|
|
const [scanStats, recentScans, paymentStats, activeEventsCount] = await Promise.all([
|
|
computeScanStats(req.user.id),
|
|
getRecentScans(10),
|
|
computePaymentStats({ includeWeekMonth: true }),
|
|
getActiveEventsCount(),
|
|
]);
|
|
res.json({ scanStats, recentScans, paymentStats, activeEventsCount });
|
|
} catch (error) {
|
|
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
|
|
}
|
|
};
|
|
|
|
// Subtracts `months` calendar months from `date`, clamping to the last day of the target
|
|
// month if the original day doesn't exist there (e.g. 31 Mar - 1 month -> 28/29 Feb, not
|
|
// 3 Mar which is what naive setMonth() arithmetic would silently produce).
|
|
function subtractMonths(date, months) {
|
|
const d = new Date(date.getTime());
|
|
const originalDate = d.getDate();
|
|
d.setMonth(d.getMonth() - months, 1); // move to the 1st of the target month first, so setDate below can't spill into the following month
|
|
const daysInTargetMonth = new Date(d.getFullYear(), d.getMonth() + 1, 0).getDate();
|
|
d.setDate(Math.min(originalDate, daysInTargetMonth));
|
|
return d;
|
|
}
|
|
|
|
// Returns a rolling one-month window ending at the end of today (so today is fully
|
|
// included), and the equal-length window immediately before it — e.g. if today is 8 June,
|
|
// thisStart/thisEnd covers 9 May through the end of 8 June, and lastStart/lastEnd covers
|
|
// 9 April through 8 May. This replaced a calendar-month-to-date window (1st of the month
|
|
// through now), which under-counted for most of the month — e.g. on the 3rd it only
|
|
// covered 3 days' worth of data instead of a full trailing month.
|
|
function trailingMonthRanges(now = new Date()) {
|
|
const startOfToday = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 0, 0, 0, 0);
|
|
const thisEnd = new Date(startOfToday.getTime() + 24 * 60 * 60 * 1000); // exclusive upper bound, covers all of today
|
|
const thisStart = subtractMonths(thisEnd, 1);
|
|
const lastEnd = thisStart;
|
|
const lastStart = subtractMonths(thisStart, 1);
|
|
return { thisStart, thisEnd, lastStart, lastEnd };
|
|
}
|
|
|
|
function pctChange(current, previous) {
|
|
if (previous === 0) return current === 0 ? 0 : null;
|
|
return ((current - previous) / previous) * 100;
|
|
}
|
|
|
|
function fetchPaymentsWithOriginal(where) {
|
|
return prisma.payment.findMany({
|
|
where,
|
|
select: {
|
|
id: true, amount: true, isDonation: true, originalPaymentId: true, eventId: true, createdAt: true,
|
|
originalPayment: { select: { isDonation: true } },
|
|
},
|
|
});
|
|
}
|
|
|
|
// Classifies each payment the same way computeEventFinancials does (cashupUtils.js):
|
|
// donation-application legs are excluded entirely (not new money — see isDonationLeg).
|
|
// Everything else — registration payments, donations themselves, and refunds of either —
|
|
// is real money in/out and counts toward "revenue". "donations" is a breakdown *within*
|
|
// that revenue (donations plus any refund of a donation), not a separate bucket.
|
|
function classifyAndSum(payments) {
|
|
let revenue = 0;
|
|
let donations = 0;
|
|
for (const p of payments) {
|
|
if (isDonationLeg(p)) continue;
|
|
revenue += p.amount;
|
|
if (p.isDonation) {
|
|
donations += p.amount;
|
|
} else if (p.originalPaymentId && p.amount < 0 && p.originalPayment?.isDonation) {
|
|
donations += p.amount;
|
|
}
|
|
}
|
|
return { revenue, donations };
|
|
}
|
|
|
|
// @desc Registrations/tickets/revenue/donations for the trailing month (today back one
|
|
// month) vs the month before that, a daily revenue trend for the trailing month,
|
|
// and the top performing events overall.
|
|
// @route GET /api/stats/overview
|
|
// @access Private/Supervisor+
|
|
const getOverviewStats = async (req, res) => {
|
|
try {
|
|
const now = new Date();
|
|
const { thisStart, thisEnd, lastStart, lastEnd } = trailingMonthRanges(now);
|
|
|
|
const [thisPayments, lastPayments, thisRegistrations, lastRegistrations, thisTickets, lastTickets, activeEvents] = await Promise.all([
|
|
fetchPaymentsWithOriginal({ createdAt: { gte: thisStart, lt: thisEnd } }),
|
|
fetchPaymentsWithOriginal({ createdAt: { gte: lastStart, lt: lastEnd } }),
|
|
prisma.registration.count({ where: { createdAt: { gte: thisStart, lt: thisEnd }, status: { not: 'cancelled' } } }),
|
|
prisma.registration.count({ where: { createdAt: { gte: lastStart, lt: lastEnd }, status: { not: 'cancelled' } } }),
|
|
prisma.ticket.aggregate({ _sum: { quantity: true }, where: { createdAt: { gte: thisStart, lt: thisEnd } } }),
|
|
prisma.ticket.aggregate({ _sum: { quantity: true }, where: { createdAt: { gte: lastStart, lt: lastEnd } } }),
|
|
getActiveEventsCount(),
|
|
]);
|
|
|
|
const thisTotals = classifyAndSum(thisPayments);
|
|
const lastTotals = classifyAndSum(lastPayments);
|
|
const thisTicketsSold = thisTickets._sum.quantity || 0;
|
|
const lastTicketsSold = lastTickets._sum.quantity || 0;
|
|
|
|
// Daily revenue trend for the trailing month, from the payment rows already fetched above.
|
|
// Same definition as classifyAndSum: everything except donation-application legs.
|
|
const trendMap = new Map();
|
|
for (const p of thisPayments) {
|
|
if (isDonationLeg(p)) continue;
|
|
const day = p.createdAt.toISOString().slice(0, 10);
|
|
trendMap.set(day, (trendMap.get(day) || 0) + p.amount);
|
|
}
|
|
const trend = Array.from(trendMap.entries())
|
|
.sort(([a], [b]) => a.localeCompare(b))
|
|
.map(([date, revenue]) => ({ date, revenue }));
|
|
|
|
// Top performing events overall (not scoped to this month), ranked by revenue — same
|
|
// "real revenue" definition as the KPI above: everything except application legs.
|
|
const revenueByEvent = await prisma.payment.groupBy({
|
|
by: ['eventId'],
|
|
where: { ...EXCLUDE_DONATION_LEGS, eventId: { not: null } },
|
|
_sum: { amount: true },
|
|
orderBy: { _sum: { amount: 'desc' } },
|
|
take: 5,
|
|
});
|
|
const topEventIds = revenueByEvent.map(r => r.eventId).filter(Boolean);
|
|
const [events, regCounts, ticketSums] = await Promise.all([
|
|
prisma.event.findMany({ where: { id: { in: topEventIds } }, select: { id: true, title: true } }),
|
|
prisma.registration.groupBy({ by: ['eventId'], where: { eventId: { in: topEventIds }, status: { not: 'cancelled' } }, _count: { _all: true } }),
|
|
prisma.ticket.groupBy({ by: ['eventId'], where: { eventId: { in: topEventIds } }, _sum: { quantity: true } }),
|
|
]);
|
|
const eventTitleById = Object.fromEntries(events.map(e => [e.id, e.title]));
|
|
const regCountById = Object.fromEntries(regCounts.map(r => [r.eventId, r._count._all]));
|
|
const ticketSumById = Object.fromEntries(ticketSums.map(t => [t.eventId, t._sum.quantity || 0]));
|
|
const topEvents = revenueByEvent.map(r => ({
|
|
eventId: r.eventId,
|
|
title: eventTitleById[r.eventId] || 'Untitled event',
|
|
revenue: r._sum.amount || 0,
|
|
registrations: regCountById[r.eventId] || 0,
|
|
ticketsSold: ticketSumById[r.eventId] || 0,
|
|
}));
|
|
|
|
// Funnel conversion (event page views → paid) across live events, last 30 days.
|
|
let funnel = null;
|
|
try {
|
|
const live = await prisma.event.findMany({ where: { isActive: true, endDate: { gte: now } }, select: { id: true } });
|
|
if (live.length) {
|
|
const { funnelFor } = require('../utils/analytics');
|
|
const f = await funnelFor(live.map(e => e.id), { from: new Date(now.getTime() - 30 * 864e5).toISOString() });
|
|
if (f.totals.view > 0) funnel = { views: f.totals.view, paid: f.totals.paid, conversion: f.conversion.paid };
|
|
}
|
|
} catch (e) {
|
|
console.warn('[stats] funnel tile failed:', e?.message || e);
|
|
}
|
|
|
|
res.json({
|
|
funnel,
|
|
activeEvents,
|
|
registrations: { thisMonth: thisRegistrations, lastMonth: lastRegistrations, pctChange: pctChange(thisRegistrations, lastRegistrations) },
|
|
ticketsSold: { thisMonth: thisTicketsSold, lastMonth: lastTicketsSold, pctChange: pctChange(thisTicketsSold, lastTicketsSold) },
|
|
revenue: { thisMonth: thisTotals.revenue, lastMonth: lastTotals.revenue, pctChange: pctChange(thisTotals.revenue, lastTotals.revenue) },
|
|
donations: { thisMonth: thisTotals.donations, lastMonth: lastTotals.donations, pctChange: pctChange(thisTotals.donations, lastTotals.donations) },
|
|
trend,
|
|
topEvents,
|
|
});
|
|
} catch (error) {
|
|
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
|
|
}
|
|
};
|
|
|
|
module.exports = {
|
|
getStaffDashboardStats,
|
|
getSupervisorDashboardStats,
|
|
getAdminDashboardStats,
|
|
getOverviewStats,
|
|
}; |