Add calendar export, SEO, error monitoring, backups, audit trail, and a starter test suite

Six site improvements picked from a "what could be better" review, plus a Jest
test suite covering the two areas with the trickiest money-handling history
in this project (early-bird pricing tranches, donation-leg accounting):

- "Add to calendar" .ics download on event pages and in confirmation emails
- sitemap.xml, robots.txt, and Open Graph/Twitter metadata for public pages
- Sentry error monitoring (backend + frontend), a no-op until SENTRY_DSN is set
- Nightly local pg_dump backups with a Site Settings tab to browse/trigger/download
- Admin audit trail for refunds, donations, manual registrations, event and
  settings changes, and staff-initiated cancellations
- Jest tests reproducing and guarding against the 1.8.0 tranche-pricing bug
  and the 1.4.2 donation-balance-inflation bug

Wallet passes (Google/Apple) were scoped out of this round — Apple Wallet
needs a paid Apple Developer account the project doesn't have yet, and the
user preferred shipping both together later rather than Google alone now.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-27 14:50:11 +02:00
co-authored by Claude Sonnet 5
parent 98ac26bf70
commit 54b89d4f4b
43 changed files with 8414 additions and 92 deletions
+8
View File
@@ -47,6 +47,14 @@ WAWP_INSTANCE_ID=your_wawp_instance_id
DAILY_SUMMARY_ENABLED=true
SCHEDULED_EMAILS_ENABLED=true
SCHEDULED_EMAILS_INTERVAL_MS=30000
# Nightly database backup at 02:00 (requires the `pg_dump` binary on this host).
# Retention count is set via Admin → Site Settings → Backups (default 14).
BACKUP_ENABLED=true
# ─── Error monitoring (Sentry) — optional ─────────────────────────────────────
# Leave unset to disable entirely (a no-op, not an error). Set NEXT_PUBLIC_SENTRY_DSN
# in frontend/.env too if you want frontend errors captured.
# SENTRY_DSN=https://xxxxx@oxxxxxx.ingest.sentry.io/xxxxx
# ─── Note ─────────────────────────────────────────────────────────────────────
# The following are managed via Admin → Site Settings and stored in the database:
+4
View File
@@ -0,0 +1,4 @@
module.exports = {
testEnvironment: 'node',
testMatch: ['**/tests/**/*.test.js'],
};
+4564 -2
View File
File diff suppressed because it is too large Load Diff
+4 -1
View File
@@ -6,7 +6,7 @@
"scripts": {
"start": "node src/index.js",
"dev": "nodemon src/index.js",
"test": "echo \"Error: no test specified\" && exit 1",
"test": "jest",
"postinstall": "prisma generate",
"prisma:generate": "prisma generate",
"prisma:deploy": "prisma migrate deploy && prisma generate",
@@ -17,12 +17,14 @@
},
"dependencies": {
"@prisma/client": "^5.4.2",
"@sentry/node": "^10.71.0",
"axios": "^1.11.0",
"bcryptjs": "^2.4.3",
"cors": "^2.8.5",
"dotenv": "^16.3.1",
"exceljs": "^4.4.0",
"express": "^4.18.2",
"ics": "^3.12.0",
"jsonwebtoken": "^9.0.2",
"multer": "^2.0.2",
"node-fetch": "^2.7.0",
@@ -33,6 +35,7 @@
"uuid": "^9.0.1"
},
"devDependencies": {
"jest": "^30.4.2",
"nodemon": "^3.0.1",
"prisma": "^5.4.2"
}
@@ -0,0 +1,29 @@
-- CreateEnum
CREATE TYPE "AdminAuditAction" AS ENUM ('refund_created', 'donation_assigned', 'donation_unassigned', 'registration_created_manual', 'registration_cancelled', 'event_created', 'event_updated', 'event_deleted', 'settings_updated');
-- CreateTable
CREATE TABLE "AdminAuditLog" (
"id" TEXT NOT NULL,
"actorId" TEXT,
"actorRole" TEXT NOT NULL,
"action" "AdminAuditAction" NOT NULL,
"targetType" TEXT NOT NULL,
"targetId" TEXT,
"metadata" JSONB,
"ip" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "AdminAuditLog_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "AdminAuditLog_actorId_createdAt_idx" ON "AdminAuditLog"("actorId", "createdAt");
-- CreateIndex
CREATE INDEX "AdminAuditLog_action_createdAt_idx" ON "AdminAuditLog"("action", "createdAt");
-- CreateIndex
CREATE INDEX "AdminAuditLog_targetType_targetId_idx" ON "AdminAuditLog"("targetType", "targetId");
-- AddForeignKey
ALTER TABLE "AdminAuditLog" ADD CONSTRAINT "AdminAuditLog_actorId_fkey" FOREIGN KEY ("actorId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
+34
View File
@@ -86,6 +86,7 @@ model User {
personCashCountsEntered EventCashupPersonCount[] @relation("EventCashupPersonCountEnteredBy")
securityEvents SecurityEvent[]
adminAuditLogs AdminAuditLog[] @relation("AdminAuditActor")
}
model Event {
@@ -344,6 +345,39 @@ model SecurityEvent {
@@index([userId, createdAt])
}
enum AdminAuditAction {
refund_created
donation_assigned
donation_unassigned
registration_created_manual
registration_cancelled
event_created
event_updated
event_deleted
settings_updated
}
// Append-only audit trail for admin/supervisor-initiated actions with money or
// data-integrity impact — separate from SecurityEvent (user-account-security-specific,
// fixed enum). Nullable FK with SetNull mirrors SecurityEvent's pattern so entries
// survive account close/anonymization.
model AdminAuditLog {
id String @id @default(uuid())
actorId String?
actor User? @relation("AdminAuditActor", fields: [actorId], references: [id], onDelete: SetNull)
actorRole String
action AdminAuditAction
targetType String
targetId String?
metadata Json?
ip String?
createdAt DateTime @default(now())
@@index([actorId, createdAt])
@@index([action, createdAt])
@@index([targetType, targetId])
}
model EventAttachment {
id String @id @default(uuid())
eventId String
@@ -0,0 +1,17 @@
const { getAdminAuditLog } = require('../utils/adminAudit');
const { safeErrorMessage } = require('../utils/errorUtils');
// @desc Paginated admin/supervisor action audit trail, with optional filters
// @route GET /api/admin/audit-log
// @access Admin
const listAuditLog = async (req, res) => {
try {
const { page, limit, actorId, action, from, to } = req.query;
const result = await getAdminAuditLog({ page, limit, actorId, action, from, to });
res.json(result);
} catch (e) {
res.status(500).json({ message: safeErrorMessage(e) });
}
};
module.exports = { listAuditLog };
@@ -0,0 +1,53 @@
const path = require('path');
const fs = require('fs');
const { runBackup, listBackups, BACKUP_DIR, BACKUP_FILENAME_RE } = require('../utils/backupUtils');
const { safeErrorMessage } = require('../utils/errorUtils');
// @desc List local database backups (newest first)
// @route GET /api/backups
// @access Admin
const getBackups = async (req, res) => {
try {
const backups = await listBackups();
res.json(backups);
} catch (e) {
res.status(500).json({ message: safeErrorMessage(e) });
}
};
// @desc Manually trigger a backup now
// @route POST /api/backups/run
// @access Admin
const triggerBackup = async (req, res) => {
try {
const result = await runBackup();
res.status(201).json(result);
} catch (e) {
res.status(500).json({ message: safeErrorMessage(e) });
}
};
// @desc Download a backup file
// @route GET /api/backups/:filename/download
// @access Admin
const downloadBackup = async (req, res) => {
try {
const { filename } = req.params;
// Reject anything that isn't exactly the shape this app generates, before ever
// touching the filesystem — same posture as the event-alias path-safety fix.
if (!BACKUP_FILENAME_RE.test(filename)) {
res.status(400);
throw new Error('Invalid backup filename');
}
const filePath = path.join(BACKUP_DIR, filename);
if (!fs.existsSync(filePath)) {
res.status(404);
throw new Error('Backup not found');
}
res.download(filePath, filename);
} catch (e) {
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(e) });
}
};
module.exports = { getBackups, triggerBackup, downloadBackup };
@@ -4,6 +4,8 @@ const multer = require('multer');
const path = require('path');
const fs = require('fs');
const { assertEventOpen } = require('../utils/cashupUtils');
const { logAdminAction } = require('../utils/adminAudit');
const { getClientIp } = require('../utils/requestUtils');
// Helper to convert stored picture path/URL to an absolute, externally reachable URL based on the incoming request
function toAbsoluteUrl(req, url) {
@@ -442,6 +444,50 @@ const getEventById = async (req, res) => {
}
};
// @desc Download a .ics calendar file for an event
// @route GET /api/events/:id/ics
// @access Public (same visibility gating as getEventById)
const getEventIcs = async (req, res) => {
try {
const eventId = req.params.id;
const event = await prisma.event.findUnique({
where: { id: eventId },
select: { id: true, title: true, description: true, startDate: true, endDate: true, location: true, isActive: true, goLiveAt: true },
});
if (!event) {
res.status(404);
throw new Error('Event not found');
}
// Same public-visibility gate as getEventById — a hidden/not-yet-live event's
// details shouldn't be enumerable via this route either.
const isStaffOrHigher = !!(req.user && ['admin', 'supervisor', 'staff'].includes(req.user.role));
if (!isStaffOrHigher) {
if (event.isActive === false) {
res.status(404);
throw new Error('Event not found');
}
let goLiveAt = null;
try { goLiveAt = event.goLiveAt ? new Date(event.goLiveAt) : null; } catch (e) {}
if (goLiveAt && new Date() < goLiveAt) {
res.status(404);
throw new Error('Event not found');
}
}
const { buildEventIcs } = require('../utils/icsUtils');
const frontendUrl = (process.env.FRONTEND_URL || process.env.APP_BASE_URL || 'http://localhost:3001').replace(/\/$/, '');
const ics = buildEventIcs(event, `${frontendUrl}/events/${event.id}`);
res.setHeader('Content-Type', 'text/calendar; charset=utf-8');
res.setHeader('Content-Disposition', `attachment; filename="${event.title.replace(/[^a-z0-9 -]/gi, '').slice(0, 60) || 'event'}.ics"`);
res.send(ics);
} catch (error) {
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: error.message });
}
};
// @desc Update event
// @route PUT /api/events/:id
// @access Private/Admin
@@ -460,6 +506,16 @@ const updateEvent = async (req, res) => {
// totals — same rule already enforced for payments/costs. Admin can reopen first.
await assertEventOpen(req.params.id, res);
const logEventUpdate = () => logAdminAction({
actorId: req.user.id,
actorRole: req.user.role,
action: 'event_updated',
targetType: 'Event',
targetId: req.params.id,
metadata: { changedKeys: Object.keys(req.body || {}) },
ip: getClientIp(req),
});
const { title, description, startDate, endDate, registrationDeadline, goLiveAt, price, picture, isActive, redirectUrl, isHidden, requiresAuth, requiresRegistration, contactName, contactPhone, contactEmail, location } = req.body;
const data = {
@@ -526,6 +582,7 @@ const updateEvent = async (req, res) => {
return res.status(400).json({ message: 'Failed to save event form/fields', detail: msg, hint: 'Ensure Prisma migrations are applied and Prisma Client is regenerated, then restart the server.' });
}
logEventUpdate();
return res.json(updatedEvent);
} catch (err) {
const msg = String(err?.message || '');
@@ -533,12 +590,14 @@ const updateEvent = async (req, res) => {
// @ts-ignore
delete data.registrationDeadline;
const updatedEvent = await prisma.event.update({ where: { id: req.params.id }, data });
logEventUpdate();
return res.json(updatedEvent);
}
if (msg.includes('Unknown argument `goLiveAt`')) {
// @ts-ignore
delete data.goLiveAt;
const updatedEvent = await prisma.event.update({ where: { id: req.params.id }, data });
logEventUpdate();
return res.json(updatedEvent);
}
throw err;
@@ -623,6 +682,15 @@ const deleteEvent = async (req, res) => {
}
});
logAdminAction({
actorId: req.user.id,
actorRole: req.user.role,
action: 'event_deleted',
targetType: 'Event',
targetId: req.params.id,
ip: getClientIp(req),
});
res.json({ message: 'Event deactivated' });
} catch (error) {
res.status(400).json({ message: error.message });
@@ -1758,6 +1826,7 @@ module.exports = {
getAllEvents,
getEventsAll,
getEventById,
getEventIcs,
updateEvent,
getEventNotifyRecipients,
updateEventNotifyRecipients,
+36 -8
View File
@@ -2,6 +2,9 @@ const prisma = require('../config/db');
const { v4: uuidv4 } = require('uuid');
const { generateTicketsForRegistration } = require('../utils/ticketUtils');
const { computeRegistrationTotalDue, refreshPricingForRegistration } = require('../utils/pricing');
const { computeDonationRemaining } = require('../utils/donationUtils');
const { logAdminAction } = require('../utils/adminAudit');
const { getClientIp } = require('../utils/requestUtils');
const axios = require('axios');
const { emailTickets } = require('./ticketController');
const { safeErrorMessage } = require('../utils/errorUtils');
@@ -611,17 +614,12 @@ const assignDonationToRegistration = async (req, res) => {
throw new Error('Only donations can be assigned to registrations');
}
// Donations are never mutated once created — their remaining balance is the original
// amount minus every leg (a Payment row with isDonation:false and originalPaymentId
// pointing back at this donation) already allocated from it. A refund of the donation
// itself also creates such a leg, with a negative amount — Math.abs() so a refund reduces
// the remaining balance (money that's left the building) instead of increasing it (which a
// raw signed sum would do, since subtracting a negative adds).
// See computeDonationRemaining's doc comment for why refund legs (negative amount) reduce
// rather than inflate the remaining balance.
const existingLegs = await prisma.payment.findMany({
where: { originalPaymentId: payment.id, isDonation: false }
});
const alreadyUsed = existingLegs.reduce((sum, leg) => sum + Math.abs(leg.amount), 0);
const remainingDonation = payment.amount - alreadyUsed;
const remainingDonation = computeDonationRemaining(payment.amount, existingLegs);
if (remainingDonation <= 0.000001) {
res.status(400);
@@ -766,6 +764,16 @@ const assignDonationToRegistration = async (req, res) => {
donationRemaining: remainingDonation - allocateAmount
};
logAdminAction({
actorId: req.user.id,
actorRole: req.user.role,
action: 'donation_assigned',
targetType: 'Registration',
targetId: registrationId,
metadata: { paymentId, legId: leg.id, allocateAmount },
ip: getClientIp(req),
});
res.status(200).json(result);
} catch (error) {
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
@@ -885,6 +893,16 @@ const unassignDonationFromRegistration = async (req, res) => {
catch (e) { console.error('Failed to send emails after unassigning donation:', e); }
})();
logAdminAction({
actorId: req.user.id,
actorRole: req.user.role,
action: 'donation_unassigned',
targetType: 'Registration',
targetId: leg.registrationId,
metadata: { legId: leg.id, donationId: donation.id, amount: leg.amount },
ip: getClientIp(req),
});
return res.status(200).json({
message: 'Donation unassigned',
updatedRegistration: finalRegistration,
@@ -1357,6 +1375,16 @@ const createRefund = async (req, res) => {
const { sendRefundEmail } = require('../utils/notifications');
sendRefundEmail(negativePayment.id).catch(e => console.error('Failed to send refund email:', e));
logAdminAction({
actorId: req.user.id,
actorRole: req.user.role,
action: 'refund_created',
targetType: 'Payment',
targetId: negativePayment.id,
metadata: { amount: amt, method: method || 'refund', reason: reason || null, registrationId: linkRegistrationId },
ip: getClientIp(req),
});
return res.status(201).json(negativePayment);
} catch (error) {
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
@@ -5,6 +5,8 @@ const { emailTickets } = require('./ticketController');
const { hashPassword } = require('../config/auth');
const { resolveOptionPrice, resolveVariantTierPrice, computeRegistrationTotalDue, refreshPricingForRegistration, attachComputedTotals, attachComputedTotalsToList } = require('../utils/pricing');
const { assertEventOpen } = require('../utils/cashupUtils');
const { logAdminAction } = require('../utils/adminAudit');
const { getClientIp } = require('../utils/requestUtils');
/**
* Check overall stock availability for an EventOption.
@@ -698,6 +700,20 @@ const cancelRegistration = async (req, res) => {
}
});
// Only log when staff cancels on someone else's behalf — a routine self-service
// cancellation isn't an admin action worth cluttering the audit trail with.
if (registration.userId !== req.user.id) {
logAdminAction({
actorId: req.user.id,
actorRole: req.user.role,
action: 'registration_cancelled',
targetType: 'Registration',
targetId: req.params.id,
metadata: { registrationOwnerId: registration.userId },
ip: getClientIp(req),
});
}
res.json({ message: 'Registration cancelled', registration: updatedRegistration });
} catch (error) {
res.status(400).json({ message: error.message });
@@ -1143,6 +1159,16 @@ const createManualRegistration = async (req, res) => {
}
})();
logAdminAction({
actorId: req.user.id,
actorRole: req.user.role,
action: 'registration_created_manual',
targetType: 'Registration',
targetId: registrationId,
metadata: { eventId: registration.eventId, forUserId: userId },
ip: getClientIp(req),
});
return res.status(201).json(attachComputedTotals(registration));
} catch (error) {
console.error(error);
@@ -4,6 +4,8 @@ const { safeErrorMessage } = require('../utils/errorUtils');
const { v4: uuidv4 } = require('uuid');
const { invalidate: invalidateSettingsCache, warmCache, ENCRYPTED_KEYS } = require('../utils/settingsCache');
const { encrypt, decrypt, isEncrypted } = require('../utils/encryption');
const { logAdminAction } = require('../utils/adminAudit');
const { getClientIp } = require('../utils/requestUtils');
// Keys safe to return without auth — includes legal keys needed by public legal pages
const PUBLIC_KEYS = [
@@ -107,6 +109,20 @@ const updateSettings = async (req, res) => {
if (ops.length) await prisma.$transaction(ops);
invalidateSettingsCache();
await warmCache(); // ensure in-memory cache reflects the new values before responding
// Log which keys changed, never the values — some settings are secrets (e.g. the
// WAWP token) that aren't even encrypted at rest, let alone fit for an audit log.
if (ops.length) {
logAdminAction({
actorId: req.user.id,
actorRole: req.user.role,
action: 'settings_updated',
targetType: 'AppSetting',
metadata: { changedKeys: Object.keys(updates).filter(k => updates[k] !== undefined && updates[k] !== null) },
ip: getClientIp(req),
});
}
res.json({ message: 'Settings saved' });
} catch (e) {
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(e) });
+59
View File
@@ -11,6 +11,20 @@ const getRawBody = require('raw-body');
// Load environment variables
dotenv.config();
// Error monitoring — a no-op if SENTRY_DSN isn't set, so this is safe in every
// environment (dev, a fresh deploy that hasn't configured Sentry yet, etc.).
// Must run before the Express app is created so its instrumentation can hook in.
if (process.env.SENTRY_DSN) {
const Sentry = require('@sentry/node');
Sentry.init({
dsn: process.env.SENTRY_DSN,
environment: process.env.NODE_ENV || 'development',
// Small single-VM deployment, not high-traffic — start conservative and raise
// this once real usage is visible in Sentry, rather than sampling every request.
tracesSampleRate: 0.1,
});
}
// Initialize Prisma client
const prisma = new PrismaClient();
@@ -126,6 +140,8 @@ const setupRoutes = require('./routes/setupRoutes');
const costRoutes = require('./routes/costRoutes');
const cashupRoutes = require('./routes/cashupRoutes');
const statsRoutes = require('./routes/statsRoutes');
const adminRoutes = require('./routes/adminRoutes');
const backupRoutes = require('./routes/backupRoutes');
// Mount webhook routes BEFORE JSON body parser to avoid double-reading the stream
app.use('/api/webhooks', webhookRoutes);
@@ -155,6 +171,8 @@ app.use('/api/setup', setupRoutes);
app.use('/api/stats', statsRoutes);
app.use('/api', costRoutes);
app.use('/api/cashups', cashupRoutes);
app.use('/api/admin', adminRoutes);
app.use('/api/backups', backupRoutes);
// Pre-warm the settings cache so synchronous helpers have DB values from startup
const { getSettingSync, warmCache } = require('./utils/settingsCache');
@@ -1134,6 +1152,14 @@ function toggle(id) {
// Error middleware
app.use(notFound);
// Sentry captures the error here, then passes it through unchanged — errorHandler
// below remains the sole source of what's actually sent back to the client.
if (process.env.SENTRY_DSN) {
const Sentry = require('@sentry/node');
Sentry.setupExpressErrorHandler(app);
}
app.use(errorHandler);
// Start server
@@ -1224,6 +1250,36 @@ app.listen(PORT, () => {
console.warn('[temp cleanup] Not scheduled:', e?.message || e);
}
// Nightly database backup at 02:00 local time (before the 03:00 temp cleanup)
try {
const enabled = String(process.env.BACKUP_ENABLED || 'true').toLowerCase() !== 'false';
if (enabled) {
const { runBackup } = require('./utils/backupUtils');
function scheduleNightlyBackup() {
const now = new Date();
const next = new Date(now);
next.setHours(2, 0, 0, 0);
if (next <= now) next.setDate(next.getDate() + 1);
setTimeout(async () => {
try {
const result = await runBackup();
console.log(`[backup] Completed: ${result.filename}`);
} catch (e) {
console.error('[backup] Failed:', e?.message || e);
} finally {
scheduleNightlyBackup();
}
}, next.getTime() - now.getTime());
}
scheduleNightlyBackup();
console.log('[backup] Scheduler initialized (02:00 local time). Set BACKUP_ENABLED=false to disable.');
} else {
console.log('[backup] Scheduler disabled by env BACKUP_ENABLED=false');
}
} catch (e) {
console.warn('[backup] Not scheduled:', e?.message || e);
}
// Scheduled emails worker (polling)
try {
const enabled = String(process.env.SCHEDULED_EMAILS_ENABLED || 'true').toLowerCase() !== 'false';
@@ -1287,6 +1343,9 @@ app.listen(PORT, () => {
process.on('unhandledRejection', (err) => {
console.error('UNHANDLED REJECTION!', err?.name, err?.message);
console.error(err?.stack || err);
if (process.env.SENTRY_DSN) {
try { require('@sentry/node').captureException(err); } catch {}
}
});
module.exports = { app, prisma };
+8
View File
@@ -0,0 +1,8 @@
const express = require('express');
const router = express.Router();
const { listAuditLog } = require('../controllers/adminAuditController');
const { protect, admin } = require('../middleware/authMiddleware');
router.get('/audit-log', protect, admin, listAuditLog);
module.exports = router;
+10
View File
@@ -0,0 +1,10 @@
const express = require('express');
const router = express.Router();
const { getBackups, triggerBackup, downloadBackup } = require('../controllers/backupController');
const { protect, admin } = require('../middleware/authMiddleware');
router.get('/', protect, admin, getBackups);
router.post('/run', protect, admin, triggerBackup);
router.get('/:filename/download', protect, admin, downloadBackup);
module.exports = router;
+2
View File
@@ -6,6 +6,7 @@ const {
getAllEvents,
getEventsAll,
getEventById,
getEventIcs,
updateEvent,
getEventNotifyRecipients,
updateEventNotifyRecipients,
@@ -44,6 +45,7 @@ router.post('/attachments/sync', protect, admin, attachmentsSync);
// optionalAuth populates req.user when a valid token is present so staff/supervisor/admin
// can still load inactive events (e.g. for cashup or editing) without being 404'd.
router.get('/:id', optionalAuth, getEventById);
router.get('/:id/ics', optionalAuth, getEventIcs);
router.get('/by-alias/:redirectUrl', getEventByAlias);
// Create/Update/Delete event
+52
View File
@@ -0,0 +1,52 @@
const prisma = require('../config/db');
// Fire-and-forget by design — a logging failure must never break the underlying admin
// action, so this swallows its own errors rather than propagating them to the caller
// (same posture as logSecurityEvent).
async function logAdminAction({ actorId, actorRole, action, targetType, targetId, metadata, ip }) {
try {
await prisma.adminAuditLog.create({
data: {
actorId: actorId || null,
actorRole,
action,
targetType,
targetId: targetId || null,
metadata: metadata || undefined,
ip: ip || null,
},
});
} catch (e) {
console.error('Failed to log admin action:', e?.message);
}
}
// Paginated listing for the admin audit-log page, with optional actor/action/date filters.
async function getAdminAuditLog({ page = 1, limit = 50, actorId, action, from, to } = {}) {
const where = {};
if (actorId) where.actorId = actorId;
if (action) where.action = action;
if (from || to) {
where.createdAt = {};
if (from) where.createdAt.gte = new Date(from);
if (to) where.createdAt.lte = new Date(to);
}
const take = Math.min(Math.max(Number(limit) || 50, 1), 200);
const skip = (Math.max(Number(page) || 1, 1) - 1) * take;
const [rows, total] = await Promise.all([
prisma.adminAuditLog.findMany({
where,
orderBy: { createdAt: 'desc' },
take,
skip,
include: { actor: { select: { id: true, name: true, email: true } } },
}),
prisma.adminAuditLog.count({ where }),
]);
return { rows, total, page: Math.max(Number(page) || 1, 1), limit: take };
}
module.exports = { logAdminAction, getAdminAuditLog };
+87
View File
@@ -0,0 +1,87 @@
const { spawn } = require('child_process');
const { pipeline } = require('stream/promises');
const zlib = require('zlib');
const fs = require('fs');
const path = require('path');
const { getSettingSync } = require('./settingsCache');
const BACKUP_DIR = path.join(__dirname, '..', '..', 'backups');
// Local-disk-only by design (no offsite/cloud upload) — matches the exact filename shape
// this module generates, and is reused to validate download requests against path traversal.
const BACKUP_FILENAME_RE = /^backup-\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}-\d{3}Z\.sql\.gz$/;
function ensureBackupDir() {
if (!fs.existsSync(BACKUP_DIR)) fs.mkdirSync(BACKUP_DIR, { recursive: true });
}
function backupFilename(date = new Date()) {
return `backup-${date.toISOString().replace(/[:.]/g, '-')}.sql.gz`;
}
/**
* Run `pg_dump` against DATABASE_URL, gzip its output, and write it to backend/backups/.
* Requires the `pg_dump` binary to be installed on this host (Postgres itself is hosted
* separately) a deploy-environment prerequisite, not something this code can satisfy.
*
* @returns {Promise<{ filename: string, path: string }>}
*/
async function runBackup() {
ensureBackupDir();
const databaseUrl = process.env.DATABASE_URL;
if (!databaseUrl) throw new Error('DATABASE_URL is not configured');
const filename = backupFilename();
const filePath = path.join(BACKUP_DIR, filename);
const pgDump = spawn('pg_dump', [databaseUrl, '--no-owner', '--no-privileges'], { stdio: ['ignore', 'pipe', 'pipe'] });
let stderr = '';
pgDump.stderr.on('data', (d) => { stderr += d.toString(); });
const exitPromise = new Promise((resolve, reject) => {
pgDump.on('error', (err) => reject(new Error(`Failed to start pg_dump: ${err.message}. Is it installed on this host?`)));
pgDump.on('close', (code) => {
if (code === 0) resolve();
else reject(new Error(`pg_dump exited with code ${code}: ${stderr.slice(0, 500)}`));
});
});
const pipelinePromise = pipeline(pgDump.stdout, zlib.createGzip(), fs.createWriteStream(filePath));
try {
// Both must succeed: the process exiting cleanly, and the gzip write finishing —
// a mid-dump failure must not leave a truncated file looking like a real backup.
await Promise.all([exitPromise, pipelinePromise]);
} catch (err) {
await fs.promises.unlink(filePath).catch(() => {});
throw err;
}
const retainCount = parseInt(getSettingSync('backup_retain_count', '14'), 10) || 14;
await rotateBackups(retainCount);
return { filename, path: filePath };
}
/** @returns {Promise<Array<{ filename: string, size: number, createdAt: Date }>>} newest first */
async function listBackups() {
ensureBackupDir();
const files = await fs.promises.readdir(BACKUP_DIR);
const backups = await Promise.all(
files.filter((f) => BACKUP_FILENAME_RE.test(f)).map(async (f) => {
const stat = await fs.promises.stat(path.join(BACKUP_DIR, f));
return { filename: f, size: stat.size, createdAt: stat.mtime };
})
);
return backups.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
}
/** Deletes backups beyond the retention count, oldest first. */
async function rotateBackups(retainCount) {
const backups = await listBackups();
const toDelete = backups.slice(retainCount);
await Promise.all(toDelete.map((b) => fs.promises.unlink(path.join(BACKUP_DIR, b.filename)).catch(() => {})));
return { deleted: toDelete.length };
}
module.exports = { runBackup, listBackups, rotateBackups, BACKUP_DIR, BACKUP_FILENAME_RE };
+18
View File
@@ -0,0 +1,18 @@
/**
* Donations are never mutated once created their remaining balance is the original
* amount minus every "leg" (a Payment row with isDonation:false and originalPaymentId
* pointing back at this donation) already allocated from it. A refund of the donation
* itself also creates a leg, with a negative amount Math.abs() so a refund reduces
* the remaining balance (money that's left the building) instead of increasing it
* (which a raw signed sum would do, since subtracting a negative adds).
*
* @param {number} originalAmount - the donation payment's own amount
* @param {Array<{amount: number}>} legs - Payment rows with originalPaymentId === donation.id
* @returns {number}
*/
function computeDonationRemaining(originalAmount, legs) {
const alreadyUsed = (legs || []).reduce((sum, leg) => sum + Math.abs(leg.amount), 0);
return Number(originalAmount || 0) - alreadyUsed;
}
module.exports = { computeDonationRemaining };
+9
View File
@@ -162,6 +162,14 @@ function fallbackLink(url) {
</p>`;
}
/** Renders a subtle, labeled secondary link — distinct from the primary CTA button. */
function secondaryLink(label, url) {
const color = getOrg().headerColor;
return `<p style="text-align:center;margin:16px 0 0 0;font-size:13px;color:#64748b">
<a href="${url}" style="color:${color}">${label}</a>
</p>`;
}
/** Horizontal rule. */
function divider() {
return `<div style="border-top:1px solid #f1f5f9;margin:32px 0"></div>`;
@@ -390,6 +398,7 @@ module.exports = {
emailWrapper,
ctaButton,
fallbackLink,
secondaryLink,
divider,
callout,
paymentOption,
+36
View File
@@ -0,0 +1,36 @@
const { createEvent } = require('ics');
// Event.startDate/endDate are stored as true UTC instants (the admin event form's
// datetime-local input is parsed in the browser's local time before being sent as an
// ISO string), so serializing them as UTC here requires no timezone math and lets every
// viewer's calendar app localize correctly to *their own* timezone.
function toUtcArray(date) {
const d = new Date(date);
return [d.getUTCFullYear(), d.getUTCMonth() + 1, d.getUTCDate(), d.getUTCHours(), d.getUTCMinutes()];
}
/**
* Build an RFC 5545 .ics file (as a string) for a single event.
*
* @param {object} event - Prisma Event row: title, description?, startDate, endDate, location?
* @param {string} eventUrl - absolute URL to the event's public page
* @returns {string}
*/
function buildEventIcs(event, eventUrl) {
const { error, value } = createEvent({
title: event.title,
start: toUtcArray(event.startDate),
end: toUtcArray(event.endDate),
startInputType: 'utc',
endInputType: 'utc',
startOutputType: 'utc',
endOutputType: 'utc',
location: event.location || undefined,
description: event.description || undefined,
url: eventUrl,
});
if (error) throw error;
return value;
}
module.exports = { buildEventIcs };
+19 -3
View File
@@ -1,6 +1,6 @@
const fs = require('fs');
const prisma = require('../config/db');
const { sendMail, emailWrapper, ctaButton, fallbackLink, divider, callout, paymentOption } = require('./email');
const { sendMail, emailWrapper, ctaButton, fallbackLink, secondaryLink, divider, callout, paymentOption } = require('./email');
const { computeRegistrationTotalDue, computeOptionLineTotal } = require('./pricing');
// ─── Formatting helpers ───────────────────────────────────────────────────────
@@ -22,6 +22,20 @@ function fmtDateShort(d) {
const { getSettingSync } = require('./settingsCache');
// The .ics calendar-download link lives on the backend (not the frontend site), same
// as the ticket-PDF URLs sent to WhatsApp — see whatsapp.js's BACKEND_URL usage.
function getBackendUrl() {
return (process.env.BACKEND_URL || '').replace(/\/$/, '');
}
// Only rendered when BACKEND_URL is actually configured — the .ics endpoint lives on
// the backend, and there's no reliable way to derive that URL otherwise.
function calendarLinkRow(eventId) {
const backendUrl = getBackendUrl();
if (!backendUrl || !eventId) return '';
return secondaryLink('Add to calendar', `${backendUrl}/api/events/${eventId}/ics`);
}
function getOrg() {
return {
name: getSettingSync('org_name', process.env.ORG_NAME || 'Cross Code'),
@@ -269,7 +283,8 @@ function buildRegistrationConfirmation(reg, { isNew = true } = {}) {
${financialSummary(totalDue, totalPaid, balance)}
${paymentSection({ balance, yocoLink: null, source: 'user', siteUrl: org.url, formRequired: false, isUserActive })}
${accountCta(isUserActive, org.url)}`;
${accountCta(isUserActive, org.url)}
${calendarLinkRow(reg.eventId)}`;
const itemsText = (reg.registrationOptions || []).map(ro => `${ro.eventOption?.name || 'Option'} ×${ro.quantity}${fmtAmount(computeOptionLineTotal(ro, null, new Date()))}`).join('\n');
const text = `${heading}\n\nHi ${reg.user?.name || 'there'},\n\n${isNew ? `You are registered for ${eventTitle}` : `Your registration for ${eventTitle} has been updated`}${eventDate ? ' on ' + eventDate : ''}.\n\nYour selections:\n${itemsText || ' —'}\n\nTotal due: ${fmtAmount(totalDue)}\nAmount paid: ${fmtAmount(totalPaid)}\nBalance: ${fmtAmount(balance)}\n\n${balance > 0 ? `Payment options:\n 1. On our website: ${org.url}\n 2. At the door (cash or card)\n\nYour tickets will be sent once payment is confirmed.` : 'No payment required — your tickets have been sent separately.'}\n\n${org.name}${org.email}\n${org.url}`;
@@ -309,7 +324,8 @@ function buildAdminInitiatedRegistrationConfirmation(reg, { yocoLink = null, for
${financialSummary(totalDue, totalPaid, balance)}
${paymentSection({ balance, yocoLink, source: 'admin', siteUrl: org.url, formRequired, isUserActive })}
${accountCta(isUserActive, org.url)}`;
${accountCta(isUserActive, org.url)}
${calendarLinkRow(reg.eventId)}`;
const itemsText = (reg.registrationOptions || []).map(ro => `${ro.eventOption?.name || 'Option'} ×${ro.quantity}${fmtAmount(computeOptionLineTotal(ro, null, new Date()))}`).join('\n');
const payText = balance > 0
+9
View File
@@ -0,0 +1,9 @@
// Resolve a client IP from the request (works behind proxies) — mirrors the equivalent
// local helper in userController.js, shared here for the admin-audit call sites.
function getClientIp(req) {
const forwarded = req.headers['x-forwarded-for'];
if (forwarded) return forwarded.split(',')[0].trim();
return req.socket?.remoteAddress || 'unknown';
}
module.exports = { getClientIp };
+46
View File
@@ -0,0 +1,46 @@
const { computeDonationRemaining } = require('../src/utils/donationUtils');
describe('computeDonationRemaining', () => {
test('a donation with no legs has its full amount remaining', () => {
expect(computeDonationRemaining(500, [])).toBe(500);
});
test('an allocation leg reduces the remaining balance', () => {
const legs = [{ amount: 200 }];
expect(computeDonationRemaining(500, legs)).toBe(300);
});
test('multiple allocation legs reduce the remaining balance cumulatively', () => {
const legs = [{ amount: 200 }, { amount: 150 }];
expect(computeDonationRemaining(500, legs)).toBe(150);
});
test('the 1.4.2 regression: refunding the donation itself (a negative-amount leg) reduces remaining balance, not inflates it', () => {
// 500 donation, never allocated, R200 of it refunded directly back to the donor
// (a leg with amount: -200). That R200 is no longer available to allocate — remaining
// must drop to 300. The pre-1.4.2 bug summed legs without Math.abs(), so
// remaining = 500 - (-200) = 700 (inflated) instead of 500 - 200 = 300 (correct).
const legs = [{ amount: -200 }];
expect(computeDonationRemaining(500, legs)).toBe(300);
});
test('an allocation and a separate direct refund both reduce the remaining balance', () => {
// 500 donation: R200 allocated to a registration, R100 separately refunded to the donor.
// Remaining = 500 - 200 - 100 = 200.
const legs = [{ amount: 200 }, { amount: -100 }];
expect(computeDonationRemaining(500, legs)).toBe(200);
});
test('unassigning an allocation removes its leg entirely rather than adding an offsetting one', () => {
// unassignDonationFromRegistration deletes the leg row outright (confirmed in
// paymentController.js), so the "leg no longer exists" case — not a negative-amount
// leg — is how an unassigned allocation becomes available again.
const legsAfterUnassign = [];
expect(computeDonationRemaining(500, legsAfterUnassign)).toBe(500);
});
test('handles a null/undefined legs array', () => {
expect(computeDonationRemaining(500, null)).toBe(500);
expect(computeDonationRemaining(500, undefined)).toBe(500);
});
});
+146
View File
@@ -0,0 +1,146 @@
const {
getEffectiveUnitPrice,
computeOptionLineTotal,
computeRegistrationTotalDue,
attachComputedTotals,
} = require('../src/utils/pricing');
const DAY = 24 * 60 * 60 * 1000;
const NOW = new Date('2026-06-01T00:00:00Z');
const PAST = new Date(NOW.getTime() - DAY);
const FUTURE = new Date(NOW.getTime() + DAY);
describe('getEffectiveUnitPrice', () => {
test('returns base price when there are no early-bird tiers', () => {
const option = { price: 100, earlyBirdTiers: [] };
expect(getEffectiveUnitPrice(option, null, NOW)).toBe(100);
});
test('returns tier price when the tier deadline is still in the future', () => {
const option = {
price: 100,
earlyBirdTiers: [{ id: 't1', price: 50, deadline: FUTURE }],
};
expect(getEffectiveUnitPrice(option, null, NOW)).toBe(50);
});
test('falls back to base price once the tier deadline has passed', () => {
const option = {
price: 100,
earlyBirdTiers: [{ id: 't1', price: 50, deadline: PAST }],
};
expect(getEffectiveUnitPrice(option, null, NOW)).toBe(100);
});
test('requires the deadline to be after both referenceTime and atTime', () => {
const midDeadline = new Date(NOW.getTime() - DAY / 2);
const option = {
price: 100,
earlyBirdTiers: [{ id: 't1', price: 50, deadline: midDeadline }],
};
// referenceTime (PAST) is before the deadline, but atTime (NOW) is after it — tier no longer applies
expect(getEffectiveUnitPrice(option, PAST, NOW)).toBe(100);
});
test('with multiple applicable tiers, picks the one with the earliest deadline', () => {
const soonerDeadline = new Date(NOW.getTime() + DAY);
const laterDeadline = new Date(NOW.getTime() + 2 * DAY);
const option = {
price: 100,
earlyBirdTiers: [
{ id: 'later', price: 80, deadline: laterDeadline },
{ id: 'sooner', price: 60, deadline: soonerDeadline },
],
};
expect(getEffectiveUnitPrice(option, null, NOW)).toBe(60);
});
test('returns 0 for a missing eventOption', () => {
expect(getEffectiveUnitPrice(null, null, NOW)).toBe(0);
});
});
describe('computeOptionLineTotal', () => {
test('the 1.8.0 regression: buying more after an early-bird tier expires only re-prices the new quantity', () => {
// 5 tickets bought at R50 (early-bird), then 1 more bought after the price rose to R100.
// Must total 5*50 + 1*100 = 350, not 6*100 = 600.
const ro = {
quantity: 6,
priceSnapshot: 100,
tranches: [
{ quantity: 5, priceSnapshot: 50, createdAt: PAST },
{ quantity: 1, priceSnapshot: 100, createdAt: NOW },
],
};
expect(computeOptionLineTotal(ro, null, NOW)).toBe(350);
});
test('sums a single tranche correctly', () => {
const ro = { quantity: 3, priceSnapshot: 40, tranches: [{ quantity: 3, priceSnapshot: 40 }] };
expect(computeOptionLineTotal(ro, null, NOW)).toBe(120);
});
test('legacy fallback: no tranches, uses priceSnapshot directly', () => {
const ro = { quantity: 4, priceSnapshot: 25, tranches: [] };
expect(computeOptionLineTotal(ro, null, NOW)).toBe(100);
});
test('legacy fallback: no tranches and no priceSnapshot, re-evaluates from tier deadlines', () => {
const ro = {
quantity: 2,
priceSnapshot: null,
tranches: [],
eventOption: { price: 100, earlyBirdTiers: [{ id: 't1', price: 70, deadline: FUTURE }] },
};
expect(computeOptionLineTotal(ro, null, NOW)).toBe(140);
});
});
describe('computeRegistrationTotalDue', () => {
test('sums tranche-aware totals across multiple RegistrationOptions', () => {
const registration = {
registrationOptions: [
{
quantity: 6,
priceSnapshot: 100,
tranches: [
{ quantity: 5, priceSnapshot: 50, createdAt: PAST },
{ quantity: 1, priceSnapshot: 100, createdAt: NOW },
],
},
{
quantity: 2,
priceSnapshot: 20,
tranches: [{ quantity: 2, priceSnapshot: 20, createdAt: PAST }],
},
],
payments: [],
};
// 350 (first option, see 1.8.0 regression case) + 40 (second option)
expect(computeRegistrationTotalDue(registration, NOW)).toBe(390);
});
test('returns 0 for a registration with no options', () => {
expect(computeRegistrationTotalDue({ registrationOptions: [] }, NOW)).toBe(0);
expect(computeRegistrationTotalDue(null, NOW)).toBe(0);
});
});
describe('attachComputedTotals', () => {
test('mutates the registration with per-line and total computed amounts', () => {
const registration = {
registrationOptions: [
{ quantity: 2, priceSnapshot: 50, tranches: [{ quantity: 2, priceSnapshot: 50 }] },
],
payments: [],
};
const result = attachComputedTotals(registration);
expect(result).toBe(registration); // mutated in place
expect(result.registrationOptions[0].lineTotal).toBe(100);
expect(result.totalDueComputed).toBe(100);
});
test('handles a null registration gracefully', () => {
expect(attachComputedTotals(null)).toBeNull();
});
});