Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f3a2e812bf | ||
|
|
032d3c032e | ||
|
|
5a416916c9 | ||
|
|
44a9e0857c | ||
|
|
54b89d4f4b | ||
|
|
98ac26bf70 | ||
|
|
7e235637c4 | ||
|
|
0e8d5f93c9 | ||
|
|
a0ccce04a3 |
@@ -34,6 +34,8 @@ Thumbs.db
|
||||
# misc scratch / generated files
|
||||
temp/
|
||||
backend/public/uploads/
|
||||
backend/backups/
|
||||
hope-events-deployment-setup.txt
|
||||
|
||||
# runtime data stores (mutated by the running app, not source)
|
||||
backend/data/scheduled-emails.json
|
||||
|
||||
@@ -7,6 +7,38 @@ and this project follows [Semantic Versioning](https://semver.org/).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [1.10.1] - 2026-08-28
|
||||
|
||||
### Security
|
||||
|
||||
- Fixed a path-traversal vulnerability in event-image, branding (logo/favicon), and event-attachment uploads: the stored filename embedded the client-supplied `originalname` unsanitized, so a crafted filename (e.g. containing `../`) could write the uploaded file outside the intended `public/uploads` subfolder anywhere the server process could write. Uploaded files are now always saved under a server-generated random name; the original filename is preserved only as display metadata.
|
||||
- `express-rate-limit` was declared as a root-only dependency despite being required directly by the backend (`backend/src/index.js`) — a plain `cd backend && npm install`, as documented in the deployment guide, would not have installed it. It's now a proper `backend/package.json` dependency.
|
||||
- Bumped `next` (frontend) off a version affected by a critical unauthenticated RCE in the React Flight protocol (GHSA-9qr9-h5gf-34mp) and several other CVEs, and switched it from an exact pin to `^15.5.24` so future patch releases install automatically.
|
||||
- Bumped `multer`, `nodemailer`, `jsonwebtoken`, and `uuid` (backend) to versions fixing DoS, SMTP/CRLF-injection, HMAC-verification, and buffer-bounds advisories; added an `overrides` entry so the vulnerable `uuid` nested under `exceljs` is also patched. Ran `npm audit fix` across all three workspaces (root/backend/frontend) — 0 known vulnerabilities remain.
|
||||
- `jwt.verify()` now pins `algorithms: ['HS256']` explicitly rather than trusting the algorithm from the token header.
|
||||
- Uploaded assets served from `/uploads` now get `Content-Security-Policy: default-src 'none'; sandbox` and `X-Content-Type-Options: nosniff`, so an uploaded SVG containing a `<script>` can no longer execute if opened directly.
|
||||
|
||||
## [1.10.0] - 2026-08-28
|
||||
|
||||
### Added
|
||||
|
||||
- Event detail pages now have an "Add to calendar" button, and registration confirmation emails include an "Add to calendar" link (requires `BACKEND_URL` to be set), both downloading a `.ics` file for the event.
|
||||
- SEO: the site now serves a `sitemap.xml` (every public page plus every event) and `robots.txt`, and event/event-list pages have proper Open Graph/Twitter metadata for link previews.
|
||||
- Error monitoring via Sentry — set `SENTRY_DSN` (backend) and `NEXT_PUBLIC_SENTRY_DSN` (frontend) to enable; a no-op otherwise. Captures unhandled backend errors/promise rejections and frontend errors.
|
||||
- Nightly local database backups (`pg_dump`, gzipped, 02:00, 14-day retention by default) with a new Site Settings → Backups tab to view, manually trigger, and download them. Requires `pg_dump` to be installed on the app server; set `BACKUP_ENABLED=false` to disable. Local-disk only — not uploaded anywhere else.
|
||||
- Admin audit trail: refunds, donation assign/unassign, manual registrations, staff-initiated cancellations, event create/update/delete, and settings changes are now logged with who/when/what, viewable at Admin → Audit log.
|
||||
- Starter backend test suite (Jest) covering the early-bird tranche pricing logic and donation-balance calculation — the two areas with the most complex money-handling history in this changelog.
|
||||
|
||||
## [1.9.5] - 2026-08-27
|
||||
|
||||
### Fixed
|
||||
|
||||
- The public event-alias route (`/:redirectUrl`, used for short links like `/camp-2025`) is a catch-all matching any unmatched top-level path, so every bot/scanner probe for a nonexistent page (`/wp-login.php`, `/.env`, etc.) was hitting the backend and firing a live database query. Both the frontend route and the `GET /api/events/by-alias/:redirectUrl` endpoint now reject anything that isn't a plausible alias (letters/numbers/hyphens/underscores) before touching the database, instead of forwarding scanner noise straight through — this traffic pattern could exhaust the database connection pool and take the server down under load.
|
||||
|
||||
### Added
|
||||
|
||||
- PM2 (`ecosystem.config.js`) now restarts either process if it exceeds 500MB of memory, instead of letting an unbounded leak run until the OS OOM-kills it.
|
||||
|
||||
## [1.9.4] - 2026-08-26
|
||||
|
||||
### Added
|
||||
|
||||
@@ -74,6 +74,12 @@ or Branding tab has been saved even once — the wizard always writes
|
||||
|---|---|---|
|
||||
| `WAWP_ACCESS_TOKEN` / `WAWP_INSTANCE_ID` | `WAWP_ACCESS_TOKEN`/`WAWP_INSTANCE_ID` env vars, else *(blank)* | No hardcoded default — WhatsApp sending is simply unavailable until both are configured (Admin → Site Settings → WhatsApp). |
|
||||
|
||||
## Backups
|
||||
|
||||
| Setting | Default when unset | Notes |
|
||||
|---|---|---|
|
||||
| `backup_retain_count` | `14` | How many nightly backups to keep on disk (oldest deleted beyond this count). Editable at Admin → Site Settings → Backups. See `BACKUP_ENABLED` (env var) to turn the nightly job off entirely. |
|
||||
|
||||
## Setup state
|
||||
|
||||
| Setting | Default when unset | Notes |
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
module.exports = {
|
||||
testEnvironment: 'node',
|
||||
testMatch: ['**/tests/**/*.test.js'],
|
||||
};
|
||||
Generated
+4833
-132
File diff suppressed because it is too large
Load Diff
+13
-6
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "event-management-backend",
|
||||
"version": "1.9.4",
|
||||
"version": "1.10.1",
|
||||
"description": "Event Management System Backend",
|
||||
"main": "src/index.js",
|
||||
"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,23 +17,30 @@
|
||||
},
|
||||
"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",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"multer": "^2.0.2",
|
||||
"express-rate-limit": "^8.6.2",
|
||||
"ics": "^3.12.0",
|
||||
"jsonwebtoken": "^9.0.3",
|
||||
"multer": "^2.2.0",
|
||||
"node-fetch": "^2.7.0",
|
||||
"nodemailer": "^7.0.5",
|
||||
"nodemailer": "^9.0.6",
|
||||
"pdfkit": "^0.17.1",
|
||||
"qrcode": "^1.5.4",
|
||||
"raw-body": "^3.0.0",
|
||||
"uuid": "^9.0.1"
|
||||
"uuid": "^11.1.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"jest": "^30.4.2",
|
||||
"nodemon": "^3.0.1",
|
||||
"prisma": "^5.4.2"
|
||||
},
|
||||
"overrides": {
|
||||
"uuid": "^11.1.1"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -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 };
|
||||
@@ -3,7 +3,10 @@ const { v4: uuidv4 } = require('uuid');
|
||||
const multer = require('multer');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const crypto = require('crypto');
|
||||
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 +445,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 +507,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 +583,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 +591,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 +683,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 });
|
||||
@@ -919,7 +988,10 @@ const attachmentsStorage = multer.diskStorage({
|
||||
}
|
||||
},
|
||||
filename: function (req, file, cb) {
|
||||
const unique = `${Date.now()}-${file.originalname}`;
|
||||
// Extension only — file.originalname is untrusted and joining it into a
|
||||
// path allows `../` traversal to write outside the upload directory.
|
||||
const ext = path.extname(file.originalname).toLowerCase();
|
||||
const unique = `event-file-${Date.now()}-${crypto.randomBytes(8).toString('hex')}${ext}`;
|
||||
cb(null, unique);
|
||||
}
|
||||
});
|
||||
@@ -1696,9 +1768,20 @@ const scheduleWhatsappEventAttendees = async (req, res) => {
|
||||
* @route GET /api/events/by-alias/:redirectUrl
|
||||
* @access Public
|
||||
*/
|
||||
// Aliases are admin-set slugs (e.g. "camp-2025") — see the event wizard's "URL
|
||||
// Alias" field. This endpoint is public and also the target of the frontend's
|
||||
// catch-all [redirectUrl] route, so it's what every bot/scanner probe hitting
|
||||
// an unmatched top-level path (/wp-login.php, /.env, etc.) ends up calling.
|
||||
// Rejecting non-slug-shaped values here skips a DB round-trip for that traffic.
|
||||
const VALID_ALIAS = /^[a-zA-Z0-9_-]{1,100}$/;
|
||||
|
||||
const getEventByAlias = async (req, res) => {
|
||||
const { redirectUrl } = req.params;
|
||||
|
||||
if (!VALID_ALIAS.test(redirectUrl)) {
|
||||
return res.status(404).json({ message: 'Event not found' });
|
||||
}
|
||||
|
||||
try {
|
||||
const event = await prisma.event.findFirst({
|
||||
where: {
|
||||
@@ -1747,6 +1830,7 @@ module.exports = {
|
||||
getAllEvents,
|
||||
getEventsAll,
|
||||
getEventById,
|
||||
getEventIcs,
|
||||
updateEvent,
|
||||
getEventNotifyRecipients,
|
||||
updateEventNotifyRecipients,
|
||||
|
||||
@@ -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) });
|
||||
|
||||
@@ -1,7 +1,15 @@
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const crypto = require('crypto');
|
||||
const multer = require('multer');
|
||||
|
||||
// Builds a filename multer can never be tricked into escaping the upload
|
||||
// directory with — extension only, no attacker-controlled path segments.
|
||||
// (file.originalname is untrusted; joining it into a path allows `../` traversal.)
|
||||
function safeFilename(prefix, ext) {
|
||||
return `${prefix}-${Date.now()}-${crypto.randomBytes(8).toString('hex')}${ext}`;
|
||||
}
|
||||
|
||||
// Setup multer storage
|
||||
const storage = multer.diskStorage({
|
||||
destination: function (req, file, cb) {
|
||||
@@ -24,8 +32,7 @@ const storage = multer.diskStorage({
|
||||
}
|
||||
},
|
||||
filename: function (req, file, cb) {
|
||||
const uniqueName = `${Date.now()}-${file.originalname}`;
|
||||
cb(null, uniqueName);
|
||||
cb(null, safeFilename('event', path.extname(file.originalname).toLowerCase()));
|
||||
}
|
||||
});
|
||||
|
||||
@@ -54,7 +61,7 @@ const logoStorage = multer.diskStorage({
|
||||
}
|
||||
},
|
||||
filename: function (req, file, cb) {
|
||||
cb(null, `logo-${Date.now()}${path.extname(file.originalname).toLowerCase()}`);
|
||||
cb(null, safeFilename('logo', path.extname(file.originalname).toLowerCase()));
|
||||
}
|
||||
});
|
||||
|
||||
@@ -82,7 +89,7 @@ const faviconStorage = multer.diskStorage({
|
||||
}
|
||||
},
|
||||
filename: function (req, file, cb) {
|
||||
cb(null, `favicon-${Date.now()}${path.extname(file.originalname).toLowerCase()}`);
|
||||
cb(null, safeFilename('favicon', path.extname(file.originalname).toLowerCase()));
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
+70
-2
@@ -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,11 +171,22 @@ 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');
|
||||
warmCache().catch(() => {});
|
||||
app.use('/uploads', express.static('public/uploads'));
|
||||
// Uploaded branding assets can include SVGs, which may embed <script>/event
|
||||
// handlers. Serving them inline lets a compromised/malicious upload run script
|
||||
// in the site's origin if opened directly, so pin the safe response headers
|
||||
// (no inline execution, no MIME-sniffing to HTML/script) on every asset here.
|
||||
app.use('/uploads', express.static('public/uploads', {
|
||||
setHeaders: (res) => {
|
||||
res.setHeader('Content-Security-Policy', "default-src 'none'; style-src 'unsafe-inline'; sandbox");
|
||||
res.setHeader('X-Content-Type-Options', 'nosniff');
|
||||
},
|
||||
}));
|
||||
|
||||
// ── Shared page helpers ────────────────────────────────────────────────────────
|
||||
const jwt = require('jsonwebtoken');
|
||||
@@ -329,7 +356,7 @@ app.get('/docs', async (req, res) => {
|
||||
|
||||
let user;
|
||||
try {
|
||||
const decoded = jwt.verify(token, process.env.JWT_SECRET);
|
||||
const decoded = jwt.verify(token, process.env.JWT_SECRET, { algorithms: ['HS256'] });
|
||||
user = await prisma.user.findUnique({
|
||||
where: { id: decoded.id },
|
||||
select: { id: true, name: true, email: true, role: true, isActive: true, tokenVersion: true },
|
||||
@@ -1134,6 +1161,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 +1259,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 +1352,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 };
|
||||
@@ -13,8 +13,9 @@ const protect = async (req, res, next) => {
|
||||
// Get token from header
|
||||
token = req.headers.authorization.split(' ')[1];
|
||||
|
||||
// Verify token
|
||||
const decoded = jwt.verify(token, process.env.JWT_SECRET);
|
||||
// Verify token — pin the algorithm so a token signed with an
|
||||
// unexpected/attacker-chosen algorithm is never accepted.
|
||||
const decoded = jwt.verify(token, process.env.JWT_SECRET, { algorithms: ['HS256'] });
|
||||
|
||||
// Get user from the token (exclude password)
|
||||
req.user = await prisma.user.findUnique({
|
||||
@@ -105,7 +106,7 @@ const optionalAuth = async (req, res, next) => {
|
||||
}
|
||||
try {
|
||||
const token = req.headers.authorization.split(' ')[1];
|
||||
const decoded = jwt.verify(token, process.env.JWT_SECRET);
|
||||
const decoded = jwt.verify(token, process.env.JWT_SECRET, { algorithms: ['HS256'] });
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: decoded.id },
|
||||
select: { id: true, name: true, email: true, role: true, isActive: true, createdAt: true, updatedAt: true, phoneNumber: true, tokenVersion: true }
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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
|
||||
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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,
|
||||
|
||||
@@ -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 };
|
||||
@@ -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
|
||||
|
||||
@@ -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 };
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
+3
-1
@@ -5,13 +5,15 @@ module.exports = {
|
||||
cwd: __dirname + '/backend',
|
||||
script: 'src/index.js',
|
||||
env: { NODE_ENV: 'production' },
|
||||
max_memory_restart: '500M',
|
||||
},
|
||||
{
|
||||
name: 'hope-events-frontend',
|
||||
cwd: __dirname + '/frontend',
|
||||
script: 'npm',
|
||||
args: 'start -- -p 3001',
|
||||
args: 'start -- -p 3000',
|
||||
env: { NODE_ENV: 'production' },
|
||||
max_memory_restart: '500M',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { NextConfig } from "next";
|
||||
import { withSentryConfig } from "@sentry/nextjs";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
images: {
|
||||
@@ -12,4 +13,8 @@ const nextConfig: NextConfig = {
|
||||
},
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
// A no-op wrap when SENTRY_DSN isn't configured for this deployment — safe in
|
||||
// every environment (dev, or a fresh deploy that hasn't set up Sentry yet).
|
||||
export default process.env.NEXT_PUBLIC_SENTRY_DSN
|
||||
? withSentryConfig(nextConfig, { silent: true, disableLogger: true })
|
||||
: nextConfig;
|
||||
|
||||
Generated
+3889
-1728
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "hope-events-frontend",
|
||||
"version": "1.9.4",
|
||||
"version": "1.10.1",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev --turbopack",
|
||||
@@ -27,12 +27,13 @@
|
||||
"@radix-ui/react-tabs": "^1.1.12",
|
||||
"@radix-ui/react-toast": "^1.2.14",
|
||||
"@radix-ui/react-tooltip": "^1.2.7",
|
||||
"@sentry/nextjs": "^10.71.0",
|
||||
"@zxing/browser": "^0.1.5",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"lucide-react": "^0.536.0",
|
||||
"next": "15.4.5",
|
||||
"next": "^15.5.24",
|
||||
"qrcode": "^1.5.4",
|
||||
"react": "19.1.0",
|
||||
"react-day-picker": "^9.8.1",
|
||||
@@ -51,9 +52,14 @@
|
||||
"@types/react-dom": "^19",
|
||||
"autoprefixer": "^10.4.21",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "15.4.5",
|
||||
"eslint-config-next": "^15.5.24",
|
||||
"postcss": "^8.5.6",
|
||||
"tailwindcss": "3.4",
|
||||
"typescript": "^5"
|
||||
},
|
||||
"overrides": {
|
||||
"next": {
|
||||
"postcss": "^8.5.23"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,15 +3,25 @@ import { apiFetch } from "@/lib/api";
|
||||
|
||||
export const revalidate = 60;
|
||||
|
||||
// Event aliases are admin-set slugs (e.g. "camp-2025", "movie-night") — see the
|
||||
// "URL Alias" field in the event wizard. This catch-all route matches *any*
|
||||
// unmatched top-level path, so it's also what every bot/scanner probe hits
|
||||
// (/wp-login.php, /.env, /xmlrpc.php, etc.). Rejecting anything that isn't a
|
||||
// plausible slug here skips a live DB query for that background noise instead
|
||||
// of forwarding it straight to the backend.
|
||||
const VALID_ALIAS = /^[a-zA-Z0-9_-]{1,100}$/;
|
||||
|
||||
export default async function EventRedirectPage({ params }: { params: Promise<{ redirectUrl: string }> }) {
|
||||
const { redirectUrl } = await params;
|
||||
|
||||
let event: any = null;
|
||||
|
||||
try {
|
||||
event = await apiFetch<any>(`/api/events/by-alias/${redirectUrl}`);
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch event:", error);
|
||||
if (VALID_ALIAS.test(redirectUrl)) {
|
||||
try {
|
||||
event = await apiFetch<any>(`/api/events/by-alias/${redirectUrl}`);
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch event:", error);
|
||||
}
|
||||
}
|
||||
|
||||
if (!event || event.message?.toLowerCase().includes("not found")) {
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
"use client";
|
||||
|
||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import { useDismissingState } from "@/hooks/useDismissingState";
|
||||
import { History } from "lucide-react";
|
||||
import { Table, TableHeader, TableBody, TableRow, TableHead, TableCell } from "@/components/ui/table";
|
||||
|
||||
const ACTIONS = [
|
||||
"refund_created",
|
||||
"donation_assigned",
|
||||
"donation_unassigned",
|
||||
"registration_created_manual",
|
||||
"registration_cancelled",
|
||||
"event_created",
|
||||
"event_updated",
|
||||
"event_deleted",
|
||||
"settings_updated",
|
||||
] as const;
|
||||
|
||||
const ACTION_LABELS: Record<string, string> = {
|
||||
refund_created: "Refund created",
|
||||
donation_assigned: "Donation assigned",
|
||||
donation_unassigned: "Donation unassigned",
|
||||
registration_created_manual: "Manual registration created",
|
||||
registration_cancelled: "Registration cancelled (staff)",
|
||||
event_created: "Event created",
|
||||
event_updated: "Event updated",
|
||||
event_deleted: "Event deactivated",
|
||||
settings_updated: "Settings updated",
|
||||
};
|
||||
|
||||
interface AuditLogEntry {
|
||||
id: string;
|
||||
actorId: string | null;
|
||||
actorRole: string;
|
||||
action: string;
|
||||
targetType: string;
|
||||
targetId: string | null;
|
||||
metadata: Record<string, unknown> | null;
|
||||
ip: string | null;
|
||||
createdAt: string;
|
||||
actor: { id: string; name: string; email: string } | null;
|
||||
}
|
||||
|
||||
function formatMetadata(metadata: Record<string, unknown> | null): string {
|
||||
if (!metadata) return "";
|
||||
try {
|
||||
return Object.entries(metadata)
|
||||
.filter(([, v]) => v !== null && v !== undefined && v !== "")
|
||||
.map(([k, v]) => `${k}: ${Array.isArray(v) ? v.join(", ") : String(v)}`)
|
||||
.join(" · ");
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
export default function AdminAuditLogPage() {
|
||||
const { user, loading, token } = useAuth();
|
||||
const router = useRouter();
|
||||
|
||||
const isAdmin = useMemo(() => user?.role === "admin", [user]);
|
||||
|
||||
useEffect(() => {
|
||||
if (loading) return;
|
||||
if (!user) router.replace("/login");
|
||||
}, [user, loading, router]);
|
||||
|
||||
const [entries, setEntries] = useState<AuditLogEntry[]>([]);
|
||||
const [fetching, setFetching] = useState(false);
|
||||
const [error, setError] = useDismissingState<string | null>(null);
|
||||
const [page, setPage] = useState(1);
|
||||
const [total, setTotal] = useState(0);
|
||||
const pageSize = 50;
|
||||
const totalPages = Math.max(1, Math.ceil(total / pageSize));
|
||||
|
||||
const [actionFilter, setActionFilter] = useState<string>("");
|
||||
const [fromFilter, setFromFilter] = useState<string>("");
|
||||
const [toFilter, setToFilter] = useState<string>("");
|
||||
|
||||
const buildQuery = useCallback((p: number) => {
|
||||
const qs = new URLSearchParams({ page: String(p), limit: String(pageSize) });
|
||||
if (actionFilter) qs.set("action", actionFilter);
|
||||
if (fromFilter) qs.set("from", fromFilter);
|
||||
if (toFilter) qs.set("to", toFilter);
|
||||
return `/api/admin/audit-log?${qs.toString()}`;
|
||||
}, [actionFilter, fromFilter, toFilter]);
|
||||
|
||||
const load = useCallback(async (p = 1) => {
|
||||
if (!token) return;
|
||||
setError(null);
|
||||
setFetching(true);
|
||||
try {
|
||||
const res = await apiFetch<{ rows: AuditLogEntry[]; total: number; page: number }>(buildQuery(p), { authToken: token });
|
||||
setEntries(res?.rows || []);
|
||||
setTotal(res?.total ?? 0);
|
||||
setPage(p);
|
||||
} catch (e: any) {
|
||||
setError(e?.message || "Failed to load audit log");
|
||||
} finally {
|
||||
setFetching(false);
|
||||
}
|
||||
}, [token, buildQuery]);
|
||||
|
||||
useEffect(() => { if (token) load(1); }, [token, actionFilter, fromFilter, toFilter]);
|
||||
|
||||
return (
|
||||
<div className="max-w-6xl mx-auto w-full p-6">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="w-10 h-10 rounded-xl bg-brand-50 flex items-center justify-center shrink-0">
|
||||
<History className="w-5 h-5 text-brand-600" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold text-gray-900">Admin Audit Log</h1>
|
||||
<p className="text-sm text-gray-500">{total} action{total !== 1 ? "s" : ""} recorded</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!isAdmin && (
|
||||
<div className="p-3 border rounded bg-yellow-50 text-yellow-800 text-sm mb-4">
|
||||
You need admin access to view the audit log.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && <div className="mb-3 p-3 border rounded bg-red-50 text-red-800 text-sm">{error}</div>}
|
||||
|
||||
<div className="border rounded-xl p-4 bg-white shadow-sm">
|
||||
<div className="flex flex-wrap items-end gap-3 mb-4">
|
||||
<div>
|
||||
<label className="block text-xs text-gray-600 mb-1">Action</label>
|
||||
<select className="border rounded px-2 py-1.5 text-sm" value={actionFilter} onChange={e => setActionFilter(e.target.value)}>
|
||||
<option value="">All actions</option>
|
||||
{ACTIONS.map(a => <option key={a} value={a}>{ACTION_LABELS[a]}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-gray-600 mb-1">From</label>
|
||||
<input type="date" className="border rounded px-2 py-1.5 text-sm" value={fromFilter} onChange={e => setFromFilter(e.target.value)} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-gray-600 mb-1">To</label>
|
||||
<input type="date" className="border rounded px-2 py-1.5 text-sm" value={toFilter} onChange={e => setToFilter(e.target.value)} />
|
||||
</div>
|
||||
<button className="text-sm px-2 py-1.5 rounded bg-gray-100 hover:bg-gray-200" onClick={() => load(page)} disabled={fetching}>
|
||||
{fetching ? "Refreshing…" : "Refresh"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>When</TableHead>
|
||||
<TableHead>Actor</TableHead>
|
||||
<TableHead>Action</TableHead>
|
||||
<TableHead>Target</TableHead>
|
||||
<TableHead>Details</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{entries.map(e => (
|
||||
<TableRow key={e.id}>
|
||||
<TableCell className="whitespace-nowrap text-gray-600">{new Date(e.createdAt).toLocaleString()}</TableCell>
|
||||
<TableCell>
|
||||
{e.actor ? (
|
||||
<>
|
||||
<div className="font-medium">{e.actor.name}</div>
|
||||
<div className="text-xs text-gray-500">{e.actor.email}</div>
|
||||
</>
|
||||
) : (
|
||||
<span className="text-gray-400 italic">Deleted user</span>
|
||||
)}
|
||||
<div className="text-xs text-gray-400 capitalize">{e.actorRole}</div>
|
||||
</TableCell>
|
||||
<TableCell>{ACTION_LABELS[e.action] || e.action}</TableCell>
|
||||
<TableCell className="text-xs text-gray-600">{e.targetType}{e.targetId ? ` #${e.targetId.slice(0, 8)}` : ""}</TableCell>
|
||||
<TableCell className="text-xs text-gray-500 max-w-[280px] truncate" title={formatMetadata(e.metadata)}>{formatMetadata(e.metadata)}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
{entries.length === 0 && !fetching && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={5} className="text-gray-500">No matching audit entries.</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
{fetching && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={5} className="text-gray-400">Loading…</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center justify-between mt-4 text-sm">
|
||||
<span className="text-gray-500">Page {page} of {totalPages}</span>
|
||||
<div className="flex gap-1">
|
||||
<button
|
||||
className="px-2 py-1 rounded bg-gray-100 hover:bg-gray-200 disabled:opacity-40"
|
||||
disabled={page <= 1 || fetching}
|
||||
onClick={() => load(page - 1)}
|
||||
>
|
||||
← Prev
|
||||
</button>
|
||||
<button
|
||||
className="px-2 py-1 rounded bg-gray-100 hover:bg-gray-200 disabled:opacity-40"
|
||||
disabled={page >= totalPages || fetching}
|
||||
onClick={() => load(page + 1)}
|
||||
>
|
||||
Next →
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -8,7 +8,7 @@ import { useStableState } from "@/hooks/useStableState";
|
||||
import { useVisiblePolling } from "@/hooks/useVisiblePolling";
|
||||
import {
|
||||
Calendar, Banknote, Gift, Users, Ticket, QrCode, ClipboardList,
|
||||
UserPlus, FileText, MessageCircle, BarChart2, Mail, Wallet, DoorOpen,
|
||||
UserPlus, FileText, MessageCircle, BarChart2, Mail, Wallet, DoorOpen, History,
|
||||
} from "lucide-react";
|
||||
import { StatCard, StatCardRow } from "@/components/shared/StatCard";
|
||||
import { QuickActionTile, QuickActionGrid } from "@/components/shared/QuickActionTile";
|
||||
@@ -33,6 +33,7 @@ const QUICK_ACTIONS = [
|
||||
{ href: "/dashboard/supervisor/email-attendees", label: "Email attendees", description: "Send a message to attendees of an event", icon: Mail },
|
||||
{ href: "/dashboard/supervisor/whatsapp-attendees", label: "WhatsApp attendees", description: "Send a WhatsApp message to event attendees", icon: MessageCircle },
|
||||
{ href: "/dashboard/admin/cashup", label: "Post-event Cashup", description: "Set costs, reconcile takings, and close out events", icon: Wallet },
|
||||
{ href: "/dashboard/admin/audit-log", label: "Audit log", description: "Review refunds, manual registrations, event and settings changes", icon: History },
|
||||
] as const;
|
||||
|
||||
type OverviewMetric = { thisMonth: number; lastMonth: number; pctChange: number | null };
|
||||
|
||||
@@ -6,13 +6,14 @@ import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { apiFetch, API_BASE, resolveToApiOrigin } from "@/lib/api";
|
||||
import { useSiteSettings } from "@/contexts/SiteSettingsContext";
|
||||
import { useDismissingState } from "@/hooks/useDismissingState";
|
||||
import { Building2, Palette, Bell, Mail, Scale, MessageCircle, type LucideIcon } from "lucide-react";
|
||||
import { Building2, Palette, Bell, Mail, Scale, MessageCircle, DatabaseBackup, type LucideIcon } from "lucide-react";
|
||||
import { ColorPickerField } from "@/components/admin/ColorPickerField";
|
||||
import { BrandingPreviewPanel } from "@/components/admin/BrandingPreviewPanel";
|
||||
import { BackupsTab } from "@/components/admin/BackupsTab";
|
||||
import { extractDominantColors } from "@/lib/extractColors";
|
||||
import { mapsSearchUrl } from "@/lib/maps";
|
||||
|
||||
type TabId = "organisation" | "branding" | "notifications" | "email" | "legal" | "whatsapp";
|
||||
type TabId = "organisation" | "branding" | "notifications" | "email" | "legal" | "whatsapp" | "backups";
|
||||
|
||||
const TABS: { id: TabId; label: string; icon: LucideIcon }[] = [
|
||||
{ id: "organisation", label: "Organisation", icon: Building2 },
|
||||
@@ -21,6 +22,7 @@ const TABS: { id: TabId; label: string; icon: LucideIcon }[] = [
|
||||
{ id: "email", label: "Email", icon: Mail },
|
||||
{ id: "legal", label: "Legal", icon: Scale },
|
||||
{ id: "whatsapp", label: "WhatsApp", icon: MessageCircle },
|
||||
{ id: "backups", label: "Backups", icon: DatabaseBackup },
|
||||
];
|
||||
|
||||
const inputCls =
|
||||
@@ -662,6 +664,9 @@ function SiteSettingsPageInner() {
|
||||
|
||||
{/* ── WhatsApp ──────────────────────────────────────────────────────── */}
|
||||
{activeTab === "whatsapp" && <WhatsAppTab active={activeTab === "whatsapp"} />}
|
||||
|
||||
{/* ── Backups ───────────────────────────────────────────────────────── */}
|
||||
{activeTab === "backups" && <BackupsTab active={activeTab === "backups"} />}
|
||||
</div>
|
||||
|
||||
{activeTab === "branding" && (
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { Share2, QrCode } from "lucide-react";
|
||||
import { Share2, QrCode, CalendarPlus } from "lucide-react";
|
||||
import QRCode from "qrcode";
|
||||
import { API_BASE } from "@/lib/api";
|
||||
|
||||
export default function ClientActions({ event }: { event: any }) {
|
||||
return (
|
||||
@@ -43,6 +44,14 @@ export default function ClientActions({ event }: { event: any }) {
|
||||
<QrCode className="w-4 h-4" />
|
||||
Save QR
|
||||
</button>
|
||||
<a
|
||||
href={`${API_BASE}/api/events/${event.id}/ics`}
|
||||
download
|
||||
className="flex items-center gap-2 px-4 py-2 bg-gray-100 rounded-lg hover:bg-gray-200 transition-colors text-sm font-medium"
|
||||
>
|
||||
<CalendarPlus className="w-4 h-4" />
|
||||
Add to calendar
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { Metadata } from "next";
|
||||
import { notFound } from "next/navigation";
|
||||
import { Navbar } from "@/components/layout/Navbar";
|
||||
import { Footer } from "@/components/layout/Footer";
|
||||
@@ -41,7 +42,7 @@ type Event = {
|
||||
location?: string | null;
|
||||
};
|
||||
|
||||
import { apiFetch, ApiError } from "@/lib/api";
|
||||
import { apiFetch, ApiError, resolveToApiOrigin } from "@/lib/api";
|
||||
import { ApiImage } from "@/components/shared/ApiImage";
|
||||
import { formatDateTimeRange } from "@/lib/date";
|
||||
|
||||
@@ -105,17 +106,49 @@ function RegisterCta({ event }: { event: Event }) {
|
||||
);
|
||||
}
|
||||
|
||||
export default async function EventDetailPage({ params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await params;
|
||||
let event: Event;
|
||||
// Next dedupes an identical fetch (same URL + cache options) made during the same
|
||||
// request, so calling this again from the page component below is free.
|
||||
async function loadEvent(id: string): Promise<Event | null> {
|
||||
try {
|
||||
event = await apiFetch<Event>(`/api/events/${id}`, { nextOptions: { next: { revalidate } } });
|
||||
return await apiFetch<Event>(`/api/events/${id}`, { nextOptions: { next: { revalidate } } });
|
||||
} catch (e) {
|
||||
// The event endpoint 404s for missing, inactive, or not-yet-live events —
|
||||
// render the standard not-found page instead of crashing.
|
||||
if (e instanceof ApiError && e.status === 404) notFound();
|
||||
if (e instanceof ApiError && e.status === 404) return null;
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
export async function generateMetadata({ params }: { params: Promise<{ id: string }> }): Promise<Metadata> {
|
||||
const { id } = await params;
|
||||
const event = await loadEvent(id);
|
||||
if (!event) return {};
|
||||
|
||||
const description = event.description
|
||||
? event.description.slice(0, 200)
|
||||
: `${event.title} — ${formatDateTimeRange(event.startDate, event.endDate)}`;
|
||||
const imageUrl = event.picture ? resolveToApiOrigin(event.picture) : null;
|
||||
|
||||
return {
|
||||
title: event.title,
|
||||
description,
|
||||
openGraph: {
|
||||
title: event.title,
|
||||
description,
|
||||
type: "website",
|
||||
...(imageUrl ? { images: [{ url: imageUrl }] } : {}),
|
||||
},
|
||||
twitter: {
|
||||
card: "summary_large_image",
|
||||
title: event.title,
|
||||
description,
|
||||
...(imageUrl ? { images: [imageUrl] } : {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export default async function EventDetailPage({ params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await params;
|
||||
const event = await loadEvent(id);
|
||||
if (!event) notFound();
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col">
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { Metadata } from "next";
|
||||
import { Navbar } from "@/components/layout/Navbar";
|
||||
import { Footer } from "@/components/layout/Footer";
|
||||
import { EventCard } from "@/components/events/EventCard";
|
||||
@@ -6,6 +7,11 @@ import { Calendar, CalendarX } from "lucide-react";
|
||||
|
||||
export const revalidate = 60;
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "All Events",
|
||||
description: "Browse and register for upcoming events.",
|
||||
};
|
||||
|
||||
type Event = {
|
||||
id: string;
|
||||
title: string;
|
||||
|
||||
@@ -10,6 +10,7 @@ import { SetupGuard } from "@/components/shared/SetupGuard";
|
||||
import HelpFab from "@/components/shared/HelpFab";
|
||||
import { API_BASE, resolveToApiOrigin } from "@/lib/api";
|
||||
import { buildThemeCssVars } from "@/lib/colorScale";
|
||||
import { appUrl } from "@/lib/siteConfig";
|
||||
|
||||
const geistSans = Geist({
|
||||
variable: "--font-geist-sans",
|
||||
@@ -44,14 +45,30 @@ async function getServerSettings(): Promise<SiteSettings> {
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const settings = await getServerSettings();
|
||||
const faviconUrl = settings.favicon_url ? resolveToApiOrigin(settings.favicon_url) : null;
|
||||
const logoUrl = settings.logo_url ? resolveToApiOrigin(settings.logo_url) : null;
|
||||
const displayName = settings.org_name || appName;
|
||||
const description = settings.org_tagline || `Manage and register for events with ${displayName}`;
|
||||
|
||||
return {
|
||||
title: displayName,
|
||||
description: `Manage and register for events with ${displayName}`,
|
||||
metadataBase: new URL(appUrl),
|
||||
title: { default: displayName, template: `%s | ${displayName}` },
|
||||
description,
|
||||
icons: {
|
||||
icon: faviconUrl || "/favicon.ico",
|
||||
},
|
||||
openGraph: {
|
||||
type: "website",
|
||||
siteName: displayName,
|
||||
title: { default: displayName, template: `%s | ${displayName}` },
|
||||
description,
|
||||
...(logoUrl ? { images: [{ url: logoUrl }] } : {}),
|
||||
},
|
||||
twitter: {
|
||||
card: "summary_large_image",
|
||||
title: displayName,
|
||||
description,
|
||||
...(logoUrl ? { images: [logoUrl] } : {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import type { MetadataRoute } from "next";
|
||||
import { appUrl } from "@/lib/siteConfig";
|
||||
|
||||
export default function robots(): MetadataRoute.Robots {
|
||||
const base = appUrl.replace(/\/$/, "");
|
||||
|
||||
return {
|
||||
rules: {
|
||||
userAgent: "*",
|
||||
allow: "/",
|
||||
disallow: ["/dashboard", "/self-service", "/set-banner", "/lockdown-rules"],
|
||||
},
|
||||
sitemap: `${base}/sitemap.xml`,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import type { MetadataRoute } from "next";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import { appUrl } from "@/lib/siteConfig";
|
||||
|
||||
type Event = { id: string; updatedAt?: string; startDate: string };
|
||||
|
||||
export const revalidate = 3600;
|
||||
|
||||
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
|
||||
const base = appUrl.replace(/\/$/, "");
|
||||
|
||||
const staticEntries: MetadataRoute.Sitemap = [
|
||||
{ url: `${base}/`, changeFrequency: "daily", priority: 1 },
|
||||
{ url: `${base}/events`, changeFrequency: "daily", priority: 0.9 },
|
||||
{ url: `${base}/contact`, changeFrequency: "monthly", priority: 0.5 },
|
||||
];
|
||||
|
||||
let events: Event[] = [];
|
||||
try {
|
||||
events = await apiFetch<Event[]>("/api/events", { nextOptions: { next: { revalidate } } });
|
||||
} catch {
|
||||
// Backend unreachable at build/revalidate time — ship the static entries only,
|
||||
// same degrade-gracefully posture as layout.tsx's getServerSettings.
|
||||
}
|
||||
|
||||
const eventEntries: MetadataRoute.Sitemap = (events || []).map((e) => ({
|
||||
url: `${base}/events/${e.id}`,
|
||||
lastModified: e.updatedAt ? new Date(e.updatedAt) : undefined,
|
||||
changeFrequency: "weekly",
|
||||
priority: 0.7,
|
||||
}));
|
||||
|
||||
return [...staticEntries, ...eventEntries];
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
"use client";
|
||||
|
||||
import React, { useCallback, useEffect, useState } from "react";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { apiFetch, API_BASE } from "@/lib/api";
|
||||
import { useDismissingState } from "@/hooks/useDismissingState";
|
||||
import { DatabaseBackup, Download } from "lucide-react";
|
||||
|
||||
interface BackupEntry {
|
||||
filename: string;
|
||||
size: number;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
function formatSize(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
export function BackupsTab({ active }: { active: boolean }) {
|
||||
const { token } = useAuth();
|
||||
const [backups, setBackups] = useState<BackupEntry[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [running, setRunning] = useState(false);
|
||||
const [message, setMessage] = useDismissingState<{ type: "ok" | "err"; text: string } | null>(null);
|
||||
|
||||
const [retainCount, setRetainCount] = useState("14");
|
||||
const [savingRetain, setSavingRetain] = useState(false);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
if (!token) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const [list, allSettings] = await Promise.all([
|
||||
apiFetch<BackupEntry[]>("/api/backups", { authToken: token }),
|
||||
apiFetch<Record<string, string>>("/api/settings/all", { authToken: token }),
|
||||
]);
|
||||
setBackups(list || []);
|
||||
setRetainCount(allSettings?.backup_retain_count || "14");
|
||||
} catch (e: any) {
|
||||
setMessage({ type: "err", text: e?.message || "Failed to load backups" });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [token]);
|
||||
|
||||
const saveRetainCount = async () => {
|
||||
if (!token) return;
|
||||
setSavingRetain(true);
|
||||
try {
|
||||
await apiFetch("/api/settings", { method: "PUT", authToken: token, body: { backup_retain_count: retainCount } });
|
||||
setMessage({ type: "ok", text: "Retention setting saved." });
|
||||
} catch (e: any) {
|
||||
setMessage({ type: "err", text: e?.message || "Failed to save retention setting" });
|
||||
} finally {
|
||||
setSavingRetain(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => { if (active) load(); }, [active, load]);
|
||||
|
||||
const runBackup = async () => {
|
||||
if (!token) return;
|
||||
setRunning(true);
|
||||
setMessage(null);
|
||||
try {
|
||||
const res = await apiFetch<{ filename: string }>("/api/backups/run", { method: "POST", authToken: token });
|
||||
setMessage({ type: "ok", text: `Backup created: ${res.filename}` });
|
||||
await load();
|
||||
} catch (e: any) {
|
||||
setMessage({ type: "err", text: e?.message || "Backup failed" });
|
||||
} finally {
|
||||
setRunning(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-9 h-9 rounded-lg bg-brand-50 flex items-center justify-center shrink-0">
|
||||
<DatabaseBackup className="w-4 h-4 text-brand-600" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-gray-900">Database backups</h2>
|
||||
<p className="text-xs text-gray-500">Nightly automatic backups, stored locally on this server. Not uploaded anywhere else.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{message && (
|
||||
<div className={`text-sm p-2.5 rounded-lg ${message.type === "ok" ? "bg-green-50 text-green-700" : "bg-red-50 text-red-700"}`}>
|
||||
{message.text}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={runBackup}
|
||||
disabled={running}
|
||||
className="px-4 py-2 bg-brand-600 hover:bg-brand-700 disabled:opacity-50 text-white rounded-lg text-sm font-medium"
|
||||
>
|
||||
{running ? "Running…" : "Run backup now"}
|
||||
</button>
|
||||
|
||||
<div className="flex items-end gap-2 pt-2 border-t">
|
||||
<div>
|
||||
<label className="block text-xs text-gray-600 mb-1">Keep the most recent</label>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
className="w-24 border rounded-lg px-3 py-1.5 text-sm"
|
||||
value={retainCount}
|
||||
onChange={(e) => setRetainCount(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-sm text-gray-500 pb-1.5">backups, delete the rest</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={saveRetainCount}
|
||||
disabled={savingRetain}
|
||||
className="ml-auto px-3 py-1.5 text-sm rounded-lg bg-gray-100 hover:bg-gray-200 disabled:opacity-50"
|
||||
>
|
||||
{savingRetain ? "Saving…" : "Save"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="border rounded-lg overflow-hidden mt-2">
|
||||
<table className="min-w-full text-sm">
|
||||
<thead>
|
||||
<tr className="text-left text-gray-600 border-b bg-gray-50">
|
||||
<th className="p-2.5">Created</th>
|
||||
<th className="p-2.5">Size</th>
|
||||
<th className="p-2.5">Download</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{backups.map((b) => (
|
||||
<tr key={b.filename} className="border-t">
|
||||
<td className="p-2.5">{new Date(b.createdAt).toLocaleString()}</td>
|
||||
<td className="p-2.5 text-gray-500">{formatSize(b.size)}</td>
|
||||
<td className="p-2.5">
|
||||
<a
|
||||
href={`${API_BASE}/api/backups/${encodeURIComponent(b.filename)}/download`}
|
||||
className="inline-flex items-center gap-1.5 text-brand-600 hover:underline"
|
||||
onClick={(e) => {
|
||||
// authenticated download: fetch as blob rather than a bare link,
|
||||
// since this route requires an admin bearer token
|
||||
e.preventDefault();
|
||||
if (!token) return;
|
||||
fetch(`${API_BASE}/api/backups/${encodeURIComponent(b.filename)}/download`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
})
|
||||
.then((res) => res.blob())
|
||||
.then((blob) => {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = b.filename;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
URL.revokeObjectURL(url);
|
||||
})
|
||||
.catch(() => setMessage({ type: "err", text: "Download failed" }));
|
||||
}}
|
||||
>
|
||||
<Download className="w-3.5 h-3.5" />
|
||||
Download
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{backups.length === 0 && !loading && (
|
||||
<tr>
|
||||
<td className="p-3 text-gray-500" colSpan={3}>No backups yet.</td>
|
||||
</tr>
|
||||
)}
|
||||
{loading && (
|
||||
<tr>
|
||||
<td className="p-3 text-gray-400" colSpan={3}>Loading…</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
// A no-op when NEXT_PUBLIC_SENTRY_DSN isn't set, so this is safe in every environment
|
||||
// (dev, or a fresh deploy that hasn't configured Sentry yet). The dynamic import (rather
|
||||
// than a top-level `import * as Sentry`) keeps the Sentry client SDK out of every visitor's
|
||||
// bundle entirely when it's unconfigured — Next inlines NEXT_PUBLIC_* at build time, so an
|
||||
// unset DSN lets the bundler dead-code-eliminate this whole block, import included.
|
||||
if (process.env.NEXT_PUBLIC_SENTRY_DSN) {
|
||||
const dsn = process.env.NEXT_PUBLIC_SENTRY_DSN;
|
||||
import("@sentry/nextjs").then((Sentry) => {
|
||||
Sentry.init({
|
||||
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,
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
// Next.js server instrumentation entry point. A no-op when NEXT_PUBLIC_SENTRY_DSN isn't
|
||||
// set — see instrumentation-client.ts for the browser-side counterpart.
|
||||
//
|
||||
// Node runtime only, deliberately — this app's middleware.ts (which runs in Next's edge
|
||||
// runtime) is a trivial pass-through with no real error surface, so an edge-runtime branch
|
||||
// here would only inflate that shared middleware bundle for no actual coverage benefit.
|
||||
export async function register() {
|
||||
if (!process.env.NEXT_PUBLIC_SENTRY_DSN) return;
|
||||
if (process.env.NEXT_RUNTIME !== "nodejs") return;
|
||||
|
||||
const Sentry = await import("@sentry/nextjs");
|
||||
Sentry.init({
|
||||
dsn: process.env.NEXT_PUBLIC_SENTRY_DSN,
|
||||
environment: process.env.NODE_ENV || "development",
|
||||
tracesSampleRate: 0.1,
|
||||
});
|
||||
}
|
||||
Generated
+9
-915
File diff suppressed because it is too large
Load Diff
+1
-4
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "hope-events",
|
||||
"version": "1.9.4",
|
||||
"version": "1.10.1",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"dev:backend": "cd backend && npm run dev",
|
||||
@@ -17,8 +17,5 @@
|
||||
"description": "",
|
||||
"devDependencies": {
|
||||
"concurrently": "^9.2.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"express-rate-limit": "^8.3.1"
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user