const prisma = require('../config/db'); const { safeErrorMessage } = require('../utils/errorUtils'); const { isDonationLeg } = require('../utils/cashupUtils'); const { campusesEnabled, campusWhere, resolveCampusFilter } = require('../utils/campuses'); const { scopeFor, eventWhere: scopeEventWhere, withScope } = require('../utils/campusScope'); // 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, ticketWhere = {}) { const startOfDay = new Date(); startOfDay.setHours(0, 0, 0, 0); const whereBase = { scannedAt: { gte: startOfDay }, ...ticketWhere }; 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) }, ...ticketWhere } }), 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, ticketWhere = {}) { return prisma.ticketUsage.findMany({ where: ticketWhere, 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(eventWhere = {}) { return prisma.event.count({ where: { isActive: true, endDate: { gte: new Date() }, ...eventWhere } }); } /** * `where` pieces for a ?campus= filter (id, slug or 'all') together with the user's campus scope * (1.44.0), or empty for church-wide. A payment belongs to a campus through its event, or its * registration's event — so payments with neither (unlinked donations) aren't counted for a * scoped user. EXCLUDE_DONATION_LEGS uses NOT, so the payment OR never collides with it. */ async function campusFilters(value, scope = null) { const enabled = await campusesEnabled(); const eventWhere = withScope(campusWhere(enabled ? await resolveCampusFilter(value) : '', { enabled }), scopeEventWhere(scope)); if (!Object.keys(eventWhere).length) return { eventWhere: {}, byEvent: {}, payment: {}, usage: {} }; return { eventWhere, byEvent: { event: eventWhere }, payment: { OR: [{ event: eventWhere }, { registration: { event: eventWhere } }] }, usage: { ticket: { event: eventWhere } }, }; } // 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, paymentWhere = {} }) { 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, ...paymentWhere } }), prisma.payment.count({ where: { isDonation: true, createdAt: { gte: startOfDay }, ...paymentWhere } }), ]; 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, ...paymentWhere } }), prisma.payment.aggregate({ _sum: { amount: true }, where: { createdAt: { gte: lastMonth }, ...EXCLUDE_DONATION_LEGS, ...paymentWhere } }), ); } 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 cf = await campusFilters('', await scopeFor(req)); const [scanStats, recentScans] = await Promise.all([ computeScanStats(req.user.id, cf.usage), getRecentScans(10, cf.usage), ]); 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 cf = await campusFilters('', await scopeFor(req)); const [scanStats, recentScans, paymentStats, activeEventsCount] = await Promise.all([ computeScanStats(req.user.id, cf.usage), getRecentScans(10, cf.usage), computePaymentStats({ includeWeekMonth: false, paymentWhere: cf.payment }), getActiveEventsCount(cf.eventWhere), ]); 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?campus= (no campus = church-wide) // @access Private/Supervisor+ const getOverviewStats = async (req, res) => { try { const now = new Date(); const { thisStart, thisEnd, lastStart, lastEnd } = trailingMonthRanges(now); const cf = await campusFilters(req.query.campus, await scopeFor(req)); const [thisPayments, lastPayments, thisRegistrations, lastRegistrations, thisTickets, lastTickets, activeEvents] = await Promise.all([ fetchPaymentsWithOriginal({ createdAt: { gte: thisStart, lt: thisEnd }, ...cf.payment }), fetchPaymentsWithOriginal({ createdAt: { gte: lastStart, lt: lastEnd }, ...cf.payment }), prisma.registration.count({ where: { createdAt: { gte: thisStart, lt: thisEnd }, status: { not: 'cancelled' }, ...cf.byEvent } }), prisma.registration.count({ where: { createdAt: { gte: lastStart, lt: lastEnd }, status: { not: 'cancelled' }, ...cf.byEvent } }), prisma.ticket.aggregate({ _sum: { quantity: true }, where: { createdAt: { gte: thisStart, lt: thisEnd }, ...cf.byEvent } }), prisma.ticket.aggregate({ _sum: { quantity: true }, where: { createdAt: { gte: lastStart, lt: lastEnd }, ...cf.byEvent } }), getActiveEventsCount(cf.eventWhere), ]); 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 }, ...cf.byEvent }, _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 }, ...cf.eventWhere }, 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 = { campusFilters, getStaffDashboardStats, getSupervisorDashboardStats, getAdminDashboardStats, getOverviewStats, };