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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user