Full site redesign, help system, and dashboard stats fixes

Multi-phase visual facelift (design tokens, dashboards, sidebar/navbar
shell, per-page help guides, and a layout/content pass across every
remaining page) plus backend fixes to the dashboard KPI stats:

- Admin/Supervisor dashboard KPIs (revenue, donations, registrations,
  tickets sold) now use a rolling trailing-month window (today back one
  calendar month, e.g. 9 May - 8 June if today is 8 June) instead of
  calendar month-to-date, which under-counted for most of the month.
  The comparison window shifts the same way, so like is still compared
  with like.
- Reports deep-links from those stat tiles now match the same window
  (range=trailing_month, replacing range=this_month).
- Design tokens (brand-* Tailwind scale + shadcn CSS variables), a
  site-wide contextual help button, fixed dashboard sidebar/navbar,
  Admin/Supervisor/Staff/User dashboard rebuilds backed by a new
  GET /api/stats/overview endpoint, a dedicated Contact page, Site
  Settings restyle with WhatsApp config folded in, and an Account
  activity feed backed by a new SecurityEvent model.
- Every remaining page (home, events, registration flow, auth, legal,
  payment results, and every Admin/Supervisor/Staff/User tool page)
  restyled onto the same design tokens, several with real layout
  upgrades (home hero, events list/detail, donate page, auth pages).
- 20+ new dedicated help guides so the whole site has page-specific
  help content instead of falling back to a generic guide.
- Assorted fixes surfaced along the way: donation-leg double-counting
  in payment stats, donations not counting toward revenue, refund
  netting in per-method report breakdowns, and donation
  over-allocation after a refund.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-06 15:00:10 +02:00
co-authored by Claude Sonnet 5
parent d74fec3a5c
commit 8e6cb542d9
119 changed files with 5116 additions and 4316 deletions
@@ -0,0 +1,20 @@
-- CreateEnum
CREATE TYPE "SecurityEventType" AS ENUM ('login', 'password_changed', 'password_reset');
-- CreateTable
CREATE TABLE "SecurityEvent" (
"id" TEXT NOT NULL,
"userId" TEXT,
"type" "SecurityEventType" NOT NULL,
"ip" TEXT,
"device" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "SecurityEvent_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "SecurityEvent_userId_createdAt_idx" ON "SecurityEvent"("userId", "createdAt");
-- AddForeignKey
ALTER TABLE "SecurityEvent" ADD CONSTRAINT "SecurityEvent_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
+23
View File
@@ -47,6 +47,12 @@ enum EventCostType {
per_item
}
enum SecurityEventType {
login
password_changed
password_reset
}
model User {
id String @id @default(uuid())
name String
@@ -78,6 +84,8 @@ model User {
personCashCountsFor EventCashupPersonCount[] @relation("EventCashupPersonCountFor")
personCashCountsEntered EventCashupPersonCount[] @relation("EventCashupPersonCountEnteredBy")
securityEvents SecurityEvent[]
}
model Event {
@@ -291,6 +299,21 @@ model PasswordReset {
@@index([userId])
}
// Append-only "Account activity" log for the Profile & Security page (logins, password
// changes). Nullable FK with SetNull (not Cascade) so entries survive account
// close/anonymization, the same pattern EventCashup uses for its performedBy audit trail.
model SecurityEvent {
id String @id @default(uuid())
userId String?
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
type SecurityEventType
ip String?
device String?
createdAt DateTime @default(now())
@@index([userId, createdAt])
}
model EventAttachment {
id String @id @default(uuid())
eventId String
+151 -3
View File
@@ -1,5 +1,6 @@
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,
@@ -56,19 +57,26 @@ 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 } } }),
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 } } }),
prisma.payment.aggregate({ _sum: { amount: true }, where: { createdAt: { gte: lastMonth } } }),
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 } }),
);
}
@@ -134,8 +142,148 @@ const getAdminDashboardStats = async (req, res) => {
}
};
// 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,
}));
res.json({
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,
};
+18
View File
@@ -2,6 +2,7 @@ const prisma = require('../config/db');
const { generateToken, hashPassword, comparePassword } = require('../config/auth');
const { v4: uuidv4 } = require('uuid');
const { safeErrorMessage } = require('../utils/errorUtils');
const { logSecurityEvent, getRecentSecurityEvents } = require('../utils/securityEvents');
const axios = require('axios');
// ─── Helpers ────────────────────────────────────────────────────────────────
@@ -273,6 +274,7 @@ const loginUser = async (req, res) => {
// Send login notification in the background
sendLoginNotification(updated, req).catch(() => {});
logSecurityEvent({ userId: updated.id, type: 'login', ip: getClientIp(req), userAgent: req.headers['user-agent'] }).catch(() => {});
res.json({
id: updated.id,
@@ -399,6 +401,7 @@ const updateUserProfile = async (req, res) => {
.catch(e => console.warn('[email] Failed to send password changed alert:', e?.message || e));
const { waText } = require('../utils/notify');
waText(updatedUser, content.text).catch(() => {});
logSecurityEvent({ userId: updatedUser.id, type: 'password_changed', ip: getClientIp(req), userAgent: req.headers['user-agent'] }).catch(() => {});
}
res.json({
@@ -744,6 +747,8 @@ const resetPassword = async (req, res) => {
prisma.passwordReset.update({ where: { token }, data: { used: true } })
]);
logSecurityEvent({ userId: user.id, type: 'password_reset', ip: getClientIp(req), userAgent: req.headers['user-agent'] }).catch(() => {});
res.json({ message: 'Password has been reset successfully' });
} catch (error) {
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
@@ -929,6 +934,18 @@ const closeAccount = async (req, res) => {
}
};
// @desc Recent account activity (logins, password changes) for the current user
// @route GET /api/users/activity
// @access Private
const getMyActivity = async (req, res) => {
try {
const events = await getRecentSecurityEvents(req.user.id, 10);
res.json(events);
} catch (error) {
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
}
};
module.exports = {
registerUser,
loginUser,
@@ -946,4 +963,5 @@ module.exports = {
revokeMySession,
adminRevokeUserSessions,
closeAccount,
getMyActivity,
};
@@ -195,7 +195,7 @@ const handleWebhook = async (req, res) => {
const { getSettingSync } = require('../utils/settingsCache');
const adminEmail = getSettingSync('smtp_from', process.env.EMAIL_FROM || process.env.EMAIL_USER || '')
|| getSettingSync('org_email', process.env.EMAIL_FROM || process.env.EMAIL_USER || '');
const dashboardUrl = `${(process.env.FRONTEND_URL || 'http://localhost:3000').replace(/\/$/, '')}/dashboard/admin/whatsapp`;
const dashboardUrl = `${(process.env.FRONTEND_URL || 'http://localhost:3000').replace(/\/$/, '')}/dashboard/admin/settings?tab=whatsapp`;
const when = new Date().toLocaleString('en-ZA', { timeZone: 'Africa/Johannesburg' });
await sendMail({
to: adminEmail,
+5 -1
View File
@@ -1,6 +1,6 @@
const express = require('express');
const router = express.Router();
const { getStaffDashboardStats, getSupervisorDashboardStats, getAdminDashboardStats } = require('../controllers/statsController');
const { getStaffDashboardStats, getSupervisorDashboardStats, getAdminDashboardStats, getOverviewStats } = require('../controllers/statsController');
const { protect, staff, supervisor, admin } = require('../middleware/authMiddleware');
// One endpoint per dashboard — each returns exactly what that dashboard renders in a
@@ -10,4 +10,8 @@ router.get('/staff', protect, staff, getStaffDashboardStats);
router.get('/supervisor', protect, supervisor, getSupervisorDashboardStats);
router.get('/admin', protect, admin, getAdminDashboardStats);
// Month-over-month KPIs + trend + top events, shared by the Admin and Supervisor dashboards.
// Staff never sees financial data, so this has no staff-accessible route.
router.get('/overview', protect, supervisor, getOverviewStats);
module.exports = router;
+2
View File
@@ -17,6 +17,7 @@ const {
revokeMySession,
adminRevokeUserSessions,
closeAccount,
getMyActivity,
} = require('../controllers/userController');
const { protect, admin, supervisor, loginLimiter} = require('../middleware/authMiddleware');
@@ -34,6 +35,7 @@ router.route('/profile')
router.post('/revoke-sessions', protect, revokeMySession);
router.post('/close-account', protect, closeAccount);
router.get('/activity', protect, getMyActivity);
// Admin routes
router.route('/')
+34
View File
@@ -0,0 +1,34 @@
const prisma = require('../config/db');
const { describeUserAgent } = require('./userAgent');
// Fire-and-forget by design — a logging failure must never break login/password-change,
// so this swallows its own errors rather than propagating them to the caller (same
// posture as the existing email/WhatsApp notification sends elsewhere in this codebase).
async function logSecurityEvent({ userId, type, ip, userAgent }) {
try {
await prisma.securityEvent.create({
data: {
userId: userId || null,
type,
ip: ip || null,
device: describeUserAgent(userAgent),
},
});
} catch (e) {
console.error('Failed to log security event:', e?.message);
}
}
// Recent activity for a user's Profile & Security page — no raw IP in the response,
// just what the mockup shows (what happened, on what device, when).
async function getRecentSecurityEvents(userId, limit = 10) {
const rows = await prisma.securityEvent.findMany({
where: { userId },
orderBy: { createdAt: 'desc' },
take: limit,
select: { id: true, type: true, device: true, createdAt: true },
});
return rows;
}
module.exports = { logSecurityEvent, getRecentSecurityEvents };
+36
View File
@@ -0,0 +1,36 @@
// Lightweight User-Agent -> "Browser on OS" summary for account-activity logging.
// No UA-parsing library is installed in this project; this covers the common cases
// (desktop/mobile browsers, major OSes) without pulling one in for a single display string.
function detectBrowser(ua) {
if (/Edg\//.test(ua)) return 'Edge';
if (/OPR\//.test(ua) || /Opera/.test(ua)) return 'Opera';
if (/SamsungBrowser/.test(ua)) return 'Samsung Internet';
if (/CriOS/.test(ua)) return 'Chrome';
if (/FxiOS/.test(ua)) return 'Firefox';
if (/Firefox\//.test(ua)) return 'Firefox';
if (/Chrome\//.test(ua)) return 'Chrome';
if (/Safari\//.test(ua) && /Version\//.test(ua)) return 'Safari';
return null;
}
function detectOS(ua) {
if (/Windows/.test(ua)) return 'Windows';
if (/iPhone|iPad|iPod/.test(ua)) return 'iOS';
if (/Mac OS X/.test(ua)) return 'macOS';
if (/Android/.test(ua)) return 'Android';
if (/Linux/.test(ua)) return 'Linux';
return null;
}
function describeUserAgent(uaString) {
if (!uaString) return 'Unknown device';
const browser = detectBrowser(uaString);
const os = detectOS(uaString);
if (browser && os) return `${browser} on ${os}`;
if (browser) return browser;
if (os) return os;
return 'Unknown device';
}
module.exports = { describeUserAgent };