Compare commits
25
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f3a2e812bf | ||
|
|
032d3c032e | ||
|
|
5a416916c9 | ||
|
|
44a9e0857c | ||
|
|
54b89d4f4b | ||
|
|
98ac26bf70 | ||
|
|
7e235637c4 | ||
|
|
0e8d5f93c9 | ||
|
|
a0ccce04a3 | ||
|
|
fbb84b037c | ||
|
|
2dfe8d32c4 | ||
|
|
305499ee91 | ||
|
|
c2112bf707 | ||
|
|
e63fb2cc27 | ||
|
|
d6da2c8227 | ||
|
|
7eed7a01df | ||
|
|
a7be2baab2 | ||
|
|
5a9137e252 | ||
|
|
4e63a78e8e | ||
|
|
2a067a3687 | ||
|
|
bdae0c6b08 | ||
|
|
c84cc257d0 | ||
|
|
7cbb147b00 | ||
|
|
b75be18a87 | ||
|
|
05840541c2 |
@@ -34,6 +34,8 @@ Thumbs.db
|
|||||||
# misc scratch / generated files
|
# misc scratch / generated files
|
||||||
temp/
|
temp/
|
||||||
backend/public/uploads/
|
backend/public/uploads/
|
||||||
|
backend/backups/
|
||||||
|
hope-events-deployment-setup.txt
|
||||||
|
|
||||||
# runtime data stores (mutated by the running app, not source)
|
# runtime data stores (mutated by the running app, not source)
|
||||||
backend/data/scheduled-emails.json
|
backend/data/scheduled-emails.json
|
||||||
|
|||||||
@@ -7,6 +7,73 @@ and this project follows [Semantic Versioning](https://semver.org/).
|
|||||||
|
|
||||||
## [Unreleased]
|
## [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
|
||||||
|
|
||||||
|
- New `TRUST_PROXY` backend env var — set it when the app runs behind a reverse proxy (e.g. nginx on a separate server) so rate limiting reads the real client IP from `X-Forwarded-For` instead of the proxy's. Accepts a hop count, `true`/`false`, or trusted proxy IP(s)/CIDR(s).
|
||||||
|
|
||||||
|
## [1.9.3] - 2026-08-26
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- The server used to hard-crash (`process.exit(1)`) on any unhandled promise rejection, so a single missed error handler anywhere in the app's fire-and-forget notification code (email/WhatsApp sending) could take the whole server down. It now logs the error and keeps running.
|
||||||
|
- Accounts created on someone's behalf (at-the-door walk-in registration, or manual registration from the Admin/Supervisor dashboard) now get their activation link (email or WhatsApp, whichever they have) sent immediately when the account is created, instead of only on their first failed login attempt — matching what the Terms of Use already promised.
|
||||||
|
- Manual registration with a real email address used to create the account already active with a fixed, undisclosed password (`Hope123`) — the visitor had no way to know it. That account is now created inactive and gets the same immediate activation link, so the visitor sets their own password — unless a password was supplied directly (see below), in which case it's activated immediately with no link needed.
|
||||||
|
- The self-service kiosk's "Create an account" password field never actually worked — the account was always created with a different password behind the scenes, so visitors who set one couldn't log in with it. Manual registration now honours a caller-supplied password and activates the account immediately instead of discarding it.
|
||||||
|
- Removed the "Guest (no account)" checkboxes from the Manual Registration pages (both the current one and the legacy form) and the equivalent flag from the at-the-door kiosk — they stopped affecting backend behaviour once every walk-in account started being created inactive with an activation link. The self-service kiosk's own "Create an account" toggle still controls whether that link is sent, since that one is the visitor's own choice rather than staff acting on their behalf.
|
||||||
|
|
||||||
|
## [1.9.2] - 2026-08-22
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Contact-only events (no registration possible) were still getting the daily summary email — it now skips them since there's nothing to summarize.
|
||||||
|
|
||||||
|
## [1.9.1] - 2026-08-22
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- The browser tab title and homepage "Welcome to..." heading always showed the app's built-in default name instead of the organisation name configured in Site Settings → Organisation.
|
||||||
|
- The backend's status page (`/`) and API docs page (`/docs`) always showed "Cross Code Events" instead of the configured organisation name.
|
||||||
|
|
||||||
|
## [1.9.0] - 2026-08-21
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Events now have an optional Location field (address), defaulting to the organisation's configured address when creating a new event. Wherever an address is shown — event admin form, public event page, event card listings, the Contact page, and Site Settings → Organisation — there's now a "Directions"/"View on map" link, and the event detail and Contact pages also show an embedded Google Maps view (no API key required).
|
||||||
|
|
||||||
## [1.8.0] - 2026-08-21
|
## [1.8.0] - 2026-08-21
|
||||||
|
|
||||||
### Added
|
### 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). |
|
| `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
|
## Setup state
|
||||||
|
|
||||||
| Setting | Default when unset | Notes |
|
| Setting | Default when unset | Notes |
|
||||||
|
|||||||
@@ -8,6 +8,13 @@ JWT_SECRET=your_jwt_secret_here_minimum_32_characters
|
|||||||
PORT=5000
|
PORT=5000
|
||||||
NODE_ENV=development
|
NODE_ENV=development
|
||||||
|
|
||||||
|
# Set this if the app runs behind a reverse proxy (e.g. nginx on a separate
|
||||||
|
# server) so rate limiting reads the real client IP instead of the proxy's.
|
||||||
|
# Accepts a hop count ("1"), "true"/"false", or comma-separated IP(s)/CIDR(s)
|
||||||
|
# of your trusted proxy (e.g. "10.0.0.5" or "10.0.0.0/8"). Leave unset if the
|
||||||
|
# app is not behind a proxy.
|
||||||
|
# TRUST_PROXY=1
|
||||||
|
|
||||||
# ─── CORS ─────────────────────────────────────────────────────────────────────
|
# ─── CORS ─────────────────────────────────────────────────────────────────────
|
||||||
# Comma-separated list of allowed frontend origins
|
# Comma-separated list of allowed frontend origins
|
||||||
FRONTEND_URL=http://localhost:3000
|
FRONTEND_URL=http://localhost:3000
|
||||||
@@ -40,6 +47,14 @@ WAWP_INSTANCE_ID=your_wawp_instance_id
|
|||||||
DAILY_SUMMARY_ENABLED=true
|
DAILY_SUMMARY_ENABLED=true
|
||||||
SCHEDULED_EMAILS_ENABLED=true
|
SCHEDULED_EMAILS_ENABLED=true
|
||||||
SCHEDULED_EMAILS_INTERVAL_MS=30000
|
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 ─────────────────────────────────────────────────────────────────────
|
# ─── Note ─────────────────────────────────────────────────────────────────────
|
||||||
# The following are managed via Admin → Site Settings and stored in the database:
|
# The following are managed via Admin → Site Settings and stored in the database:
|
||||||
|
|||||||
@@ -84,6 +84,7 @@ Create `backend/.env` from `.env.example`. The only variables you must set are:
|
|||||||
| `APP_BASE_URL` | — | Public frontend URL — used in email links (fallback if not set via admin panel) |
|
| `APP_BASE_URL` | — | Public frontend URL — used in email links (fallback if not set via admin panel) |
|
||||||
| `BACKEND_URL` | — | Public backend URL — used to serve ticket PDFs over WhatsApp |
|
| `BACKEND_URL` | — | Public backend URL — used to serve ticket PDFs over WhatsApp |
|
||||||
| `PORT` | — | Port to listen on (default `3000`) |
|
| `PORT` | — | Port to listen on (default `3000`) |
|
||||||
|
| `TRUST_PROXY` | — | Set when running behind a reverse proxy (e.g. nginx on a separate server), so `req.ip`/`X-Forwarded-For` are read correctly by rate limiting. Accepts a hop count (`1`), `true`/`false`, or comma-separated trusted proxy IP(s)/CIDR(s). |
|
||||||
| `NODE_ENV` | — | `production` or `development` |
|
| `NODE_ENV` | — | `production` or `development` |
|
||||||
| `WAWP_ACCESS_TOKEN` | — | WAWP fallback token (preferred: set via Admin → Site Settings) |
|
| `WAWP_ACCESS_TOKEN` | — | WAWP fallback token (preferred: set via Admin → Site Settings) |
|
||||||
| `WAWP_INSTANCE_ID` | — | WAWP fallback instance ID (preferred: set via Admin → Site Settings) |
|
| `WAWP_INSTANCE_ID` | — | WAWP fallback instance ID (preferred: set via Admin → Site Settings) |
|
||||||
|
|||||||
@@ -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",
|
"name": "event-management-backend",
|
||||||
"version": "1.8.0",
|
"version": "1.10.1",
|
||||||
"description": "Event Management System Backend",
|
"description": "Event Management System Backend",
|
||||||
"main": "src/index.js",
|
"main": "src/index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "node src/index.js",
|
"start": "node src/index.js",
|
||||||
"dev": "nodemon src/index.js",
|
"dev": "nodemon src/index.js",
|
||||||
"test": "echo \"Error: no test specified\" && exit 1",
|
"test": "jest",
|
||||||
"postinstall": "prisma generate",
|
"postinstall": "prisma generate",
|
||||||
"prisma:generate": "prisma generate",
|
"prisma:generate": "prisma generate",
|
||||||
"prisma:deploy": "prisma migrate deploy && prisma generate",
|
"prisma:deploy": "prisma migrate deploy && prisma generate",
|
||||||
@@ -17,23 +17,30 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@prisma/client": "^5.4.2",
|
"@prisma/client": "^5.4.2",
|
||||||
|
"@sentry/node": "^10.71.0",
|
||||||
"axios": "^1.11.0",
|
"axios": "^1.11.0",
|
||||||
"bcryptjs": "^2.4.3",
|
"bcryptjs": "^2.4.3",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"dotenv": "^16.3.1",
|
"dotenv": "^16.3.1",
|
||||||
"exceljs": "^4.4.0",
|
"exceljs": "^4.4.0",
|
||||||
"express": "^4.18.2",
|
"express": "^4.18.2",
|
||||||
"jsonwebtoken": "^9.0.2",
|
"express-rate-limit": "^8.6.2",
|
||||||
"multer": "^2.0.2",
|
"ics": "^3.12.0",
|
||||||
|
"jsonwebtoken": "^9.0.3",
|
||||||
|
"multer": "^2.2.0",
|
||||||
"node-fetch": "^2.7.0",
|
"node-fetch": "^2.7.0",
|
||||||
"nodemailer": "^7.0.5",
|
"nodemailer": "^9.0.6",
|
||||||
"pdfkit": "^0.17.1",
|
"pdfkit": "^0.17.1",
|
||||||
"qrcode": "^1.5.4",
|
"qrcode": "^1.5.4",
|
||||||
"raw-body": "^3.0.0",
|
"raw-body": "^3.0.0",
|
||||||
"uuid": "^9.0.1"
|
"uuid": "^11.1.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"jest": "^30.4.2",
|
||||||
"nodemon": "^3.0.1",
|
"nodemon": "^3.0.1",
|
||||||
"prisma": "^5.4.2"
|
"prisma": "^5.4.2"
|
||||||
|
},
|
||||||
|
"overrides": {
|
||||||
|
"uuid": "^11.1.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Event" ADD COLUMN "location" TEXT;
|
||||||
@@ -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")
|
personCashCountsEntered EventCashupPersonCount[] @relation("EventCashupPersonCountEnteredBy")
|
||||||
|
|
||||||
securityEvents SecurityEvent[]
|
securityEvents SecurityEvent[]
|
||||||
|
adminAuditLogs AdminAuditLog[] @relation("AdminAuditActor")
|
||||||
}
|
}
|
||||||
|
|
||||||
model Event {
|
model Event {
|
||||||
@@ -105,6 +106,7 @@ model Event {
|
|||||||
contactName String?
|
contactName String?
|
||||||
contactPhone String?
|
contactPhone String?
|
||||||
contactEmail String?
|
contactEmail String?
|
||||||
|
location String?
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
createdById String?
|
createdById String?
|
||||||
@@ -343,6 +345,39 @@ model SecurityEvent {
|
|||||||
@@index([userId, createdAt])
|
@@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 {
|
model EventAttachment {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
eventId String
|
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 multer = require('multer');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
|
const crypto = require('crypto');
|
||||||
const { assertEventOpen } = require('../utils/cashupUtils');
|
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
|
// Helper to convert stored picture path/URL to an absolute, externally reachable URL based on the incoming request
|
||||||
function toAbsoluteUrl(req, url) {
|
function toAbsoluteUrl(req, url) {
|
||||||
@@ -41,7 +44,7 @@ function toAbsoluteUrl(req, url) {
|
|||||||
// @access Private/Admin
|
// @access Private/Admin
|
||||||
const createEvent = async (req, res) => {
|
const createEvent = async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { title, description, startDate, endDate, registrationDeadline, goLiveAt, price, picture, redirectUrl, isHidden, requiresAuth, requiresRegistration, contactName, contactPhone, contactEmail } = req.body;
|
const { title, description, startDate, endDate, registrationDeadline, goLiveAt, price, picture, redirectUrl, isHidden, requiresAuth, requiresRegistration, contactName, contactPhone, contactEmail, location } = req.body;
|
||||||
|
|
||||||
const data = {
|
const data = {
|
||||||
id: uuidv4(),
|
id: uuidv4(),
|
||||||
@@ -62,6 +65,7 @@ const createEvent = async (req, res) => {
|
|||||||
contactName: contactName || null,
|
contactName: contactName || null,
|
||||||
contactPhone: contactPhone || null,
|
contactPhone: contactPhone || null,
|
||||||
contactEmail: contactEmail || null,
|
contactEmail: contactEmail || null,
|
||||||
|
location: location || null,
|
||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -441,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
|
// @desc Update event
|
||||||
// @route PUT /api/events/:id
|
// @route PUT /api/events/:id
|
||||||
// @access Private/Admin
|
// @access Private/Admin
|
||||||
@@ -459,7 +507,17 @@ const updateEvent = async (req, res) => {
|
|||||||
// totals — same rule already enforced for payments/costs. Admin can reopen first.
|
// totals — same rule already enforced for payments/costs. Admin can reopen first.
|
||||||
await assertEventOpen(req.params.id, res);
|
await assertEventOpen(req.params.id, res);
|
||||||
|
|
||||||
const { title, description, startDate, endDate, registrationDeadline, goLiveAt, price, picture, isActive, redirectUrl, isHidden, requiresAuth, requiresRegistration, contactName, contactPhone, contactEmail } = req.body;
|
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 = {
|
const data = {
|
||||||
title: title || event.title,
|
title: title || event.title,
|
||||||
@@ -477,6 +535,7 @@ const updateEvent = async (req, res) => {
|
|||||||
contactName: contactName !== undefined ? (contactName || null) : event.contactName,
|
contactName: contactName !== undefined ? (contactName || null) : event.contactName,
|
||||||
contactPhone: contactPhone !== undefined ? (contactPhone || null) : event.contactPhone,
|
contactPhone: contactPhone !== undefined ? (contactPhone || null) : event.contactPhone,
|
||||||
contactEmail: contactEmail !== undefined ? (contactEmail || null) : event.contactEmail,
|
contactEmail: contactEmail !== undefined ? (contactEmail || null) : event.contactEmail,
|
||||||
|
location: location !== undefined ? (location || null) : event.location,
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
redirectUrl: redirectUrl !== undefined ? redirectUrl : event.redirectUrl,
|
redirectUrl: redirectUrl !== undefined ? redirectUrl : event.redirectUrl,
|
||||||
};
|
};
|
||||||
@@ -524,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.' });
|
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);
|
return res.json(updatedEvent);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const msg = String(err?.message || '');
|
const msg = String(err?.message || '');
|
||||||
@@ -531,12 +591,14 @@ const updateEvent = async (req, res) => {
|
|||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
delete data.registrationDeadline;
|
delete data.registrationDeadline;
|
||||||
const updatedEvent = await prisma.event.update({ where: { id: req.params.id }, data });
|
const updatedEvent = await prisma.event.update({ where: { id: req.params.id }, data });
|
||||||
|
logEventUpdate();
|
||||||
return res.json(updatedEvent);
|
return res.json(updatedEvent);
|
||||||
}
|
}
|
||||||
if (msg.includes('Unknown argument `goLiveAt`')) {
|
if (msg.includes('Unknown argument `goLiveAt`')) {
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
delete data.goLiveAt;
|
delete data.goLiveAt;
|
||||||
const updatedEvent = await prisma.event.update({ where: { id: req.params.id }, data });
|
const updatedEvent = await prisma.event.update({ where: { id: req.params.id }, data });
|
||||||
|
logEventUpdate();
|
||||||
return res.json(updatedEvent);
|
return res.json(updatedEvent);
|
||||||
}
|
}
|
||||||
throw err;
|
throw err;
|
||||||
@@ -621,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' });
|
res.json({ message: 'Event deactivated' });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(400).json({ message: error.message });
|
res.status(400).json({ message: error.message });
|
||||||
@@ -917,7 +988,10 @@ const attachmentsStorage = multer.diskStorage({
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
filename: function (req, file, cb) {
|
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);
|
cb(null, unique);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -1694,9 +1768,20 @@ const scheduleWhatsappEventAttendees = async (req, res) => {
|
|||||||
* @route GET /api/events/by-alias/:redirectUrl
|
* @route GET /api/events/by-alias/:redirectUrl
|
||||||
* @access Public
|
* @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 getEventByAlias = async (req, res) => {
|
||||||
const { redirectUrl } = req.params;
|
const { redirectUrl } = req.params;
|
||||||
|
|
||||||
|
if (!VALID_ALIAS.test(redirectUrl)) {
|
||||||
|
return res.status(404).json({ message: 'Event not found' });
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const event = await prisma.event.findFirst({
|
const event = await prisma.event.findFirst({
|
||||||
where: {
|
where: {
|
||||||
@@ -1745,6 +1830,7 @@ module.exports = {
|
|||||||
getAllEvents,
|
getAllEvents,
|
||||||
getEventsAll,
|
getEventsAll,
|
||||||
getEventById,
|
getEventById,
|
||||||
|
getEventIcs,
|
||||||
updateEvent,
|
updateEvent,
|
||||||
getEventNotifyRecipients,
|
getEventNotifyRecipients,
|
||||||
updateEventNotifyRecipients,
|
updateEventNotifyRecipients,
|
||||||
|
|||||||
@@ -2,6 +2,9 @@ const prisma = require('../config/db');
|
|||||||
const { v4: uuidv4 } = require('uuid');
|
const { v4: uuidv4 } = require('uuid');
|
||||||
const { generateTicketsForRegistration } = require('../utils/ticketUtils');
|
const { generateTicketsForRegistration } = require('../utils/ticketUtils');
|
||||||
const { computeRegistrationTotalDue, refreshPricingForRegistration } = require('../utils/pricing');
|
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 axios = require('axios');
|
||||||
const { emailTickets } = require('./ticketController');
|
const { emailTickets } = require('./ticketController');
|
||||||
const { safeErrorMessage } = require('../utils/errorUtils');
|
const { safeErrorMessage } = require('../utils/errorUtils');
|
||||||
@@ -611,17 +614,12 @@ const assignDonationToRegistration = async (req, res) => {
|
|||||||
throw new Error('Only donations can be assigned to registrations');
|
throw new Error('Only donations can be assigned to registrations');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Donations are never mutated once created — their remaining balance is the original
|
// See computeDonationRemaining's doc comment for why refund legs (negative amount) reduce
|
||||||
// amount minus every leg (a Payment row with isDonation:false and originalPaymentId
|
// rather than inflate the remaining balance.
|
||||||
// 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).
|
|
||||||
const existingLegs = await prisma.payment.findMany({
|
const existingLegs = await prisma.payment.findMany({
|
||||||
where: { originalPaymentId: payment.id, isDonation: false }
|
where: { originalPaymentId: payment.id, isDonation: false }
|
||||||
});
|
});
|
||||||
const alreadyUsed = existingLegs.reduce((sum, leg) => sum + Math.abs(leg.amount), 0);
|
const remainingDonation = computeDonationRemaining(payment.amount, existingLegs);
|
||||||
const remainingDonation = payment.amount - alreadyUsed;
|
|
||||||
|
|
||||||
if (remainingDonation <= 0.000001) {
|
if (remainingDonation <= 0.000001) {
|
||||||
res.status(400);
|
res.status(400);
|
||||||
@@ -766,6 +764,16 @@ const assignDonationToRegistration = async (req, res) => {
|
|||||||
donationRemaining: remainingDonation - allocateAmount
|
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);
|
res.status(200).json(result);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(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); }
|
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({
|
return res.status(200).json({
|
||||||
message: 'Donation unassigned',
|
message: 'Donation unassigned',
|
||||||
updatedRegistration: finalRegistration,
|
updatedRegistration: finalRegistration,
|
||||||
@@ -1357,6 +1375,16 @@ const createRefund = async (req, res) => {
|
|||||||
const { sendRefundEmail } = require('../utils/notifications');
|
const { sendRefundEmail } = require('../utils/notifications');
|
||||||
sendRefundEmail(negativePayment.id).catch(e => console.error('Failed to send refund email:', e));
|
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);
|
return res.status(201).json(negativePayment);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
|
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
const prisma = require('../config/db');
|
const prisma = require('../config/db');
|
||||||
const { v4: uuidv4 } = require('uuid');
|
const { v4: uuidv4 } = require('uuid');
|
||||||
const axios = require("axios");
|
|
||||||
const { generateTicketsForRegistration } = require('../utils/ticketUtils');
|
const { generateTicketsForRegistration } = require('../utils/ticketUtils');
|
||||||
const { emailTickets } = require('./ticketController');
|
const { emailTickets } = require('./ticketController');
|
||||||
const { hashPassword } = require('../config/auth');
|
const { hashPassword } = require('../config/auth');
|
||||||
const { resolveOptionPrice, resolveVariantTierPrice, computeRegistrationTotalDue, refreshPricingForRegistration, attachComputedTotals, attachComputedTotalsToList } = require('../utils/pricing');
|
const { resolveOptionPrice, resolveVariantTierPrice, computeRegistrationTotalDue, refreshPricingForRegistration, attachComputedTotals, attachComputedTotalsToList } = require('../utils/pricing');
|
||||||
const { assertEventOpen } = require('../utils/cashupUtils');
|
const { assertEventOpen } = require('../utils/cashupUtils');
|
||||||
|
const { logAdminAction } = require('../utils/adminAudit');
|
||||||
|
const { getClientIp } = require('../utils/requestUtils');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check overall stock availability for an EventOption.
|
* Check overall stock availability for an EventOption.
|
||||||
@@ -699,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 });
|
res.json({ message: 'Registration cancelled', registration: updatedRegistration });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(400).json({ message: error.message });
|
res.status(400).json({ message: error.message });
|
||||||
@@ -760,7 +775,7 @@ const getRegistrationsByEvent = async (req, res) => {
|
|||||||
const createManualRegistration = async (req, res) => {
|
const createManualRegistration = async (req, res) => {
|
||||||
let userRecord;
|
let userRecord;
|
||||||
try {
|
try {
|
||||||
const { eventId, options, user, guestOnly, notificationPreference: prefFromBody } = req.body;
|
const { eventId, options, user, notificationPreference: prefFromBody, skipActivationNotice } = req.body;
|
||||||
|
|
||||||
if (!eventId || !options || !user || !user.name || (!user.email && !user.phoneNumber)) {
|
if (!eventId || !options || !user || !user.name || (!user.email && !user.phoneNumber)) {
|
||||||
res.status(400);
|
res.status(400);
|
||||||
@@ -844,7 +859,7 @@ const createManualRegistration = async (req, res) => {
|
|||||||
? prefFromBody
|
? prefFromBody
|
||||||
: (hasValidEmail && phone ? 'both' : phone ? 'whatsapp' : 'email');
|
: (hasValidEmail && phone ? 'both' : phone ? 'whatsapp' : 'email');
|
||||||
|
|
||||||
// Always search by email AND/OR phone regardless of guestOnly.
|
// Always search by email AND/OR phone.
|
||||||
// Resolve each channel independently (rather than a single findFirst with an OR
|
// Resolve each channel independently (rather than a single findFirst with an OR
|
||||||
// across both) so that an email belonging to one account and a phone number
|
// across both) so that an email belonging to one account and a phone number
|
||||||
// belonging to a *different* account can never be silently collapsed into
|
// belonging to a *different* account can never be silently collapsed into
|
||||||
@@ -907,25 +922,36 @@ const createManualRegistration = async (req, res) => {
|
|||||||
if (Object.keys(updateData).length > 0) {
|
if (Object.keys(updateData).length > 0) {
|
||||||
await prisma.user.update({ where: { id: userId }, data: updateData }).catch(() => {});
|
await prisma.user.update({ where: { id: userId }, data: updateData }).catch(() => {});
|
||||||
}
|
}
|
||||||
} else if (!guestOnly && hasValidEmail) {
|
|
||||||
// Create a real active account (non-guest with email)
|
|
||||||
try {
|
|
||||||
const password = 'Hope123';
|
|
||||||
const response = await axios.post(
|
|
||||||
`${process.env.NEXT_PUBLIC_API_URL || 'http://localhost:5000'}/api/users`,
|
|
||||||
{ name: user.name, email: user.email, password, phoneNumber: phone || null }
|
|
||||||
);
|
|
||||||
const createdUser = response.data.user || response.data;
|
|
||||||
if (!createdUser?.id) { res.status(400); throw new Error('User creation failed: No user ID returned'); }
|
|
||||||
userId = createdUser.id;
|
|
||||||
// Set derived preference on the new account
|
|
||||||
await prisma.user.update({ where: { id: userId }, data: { notificationPreference: derivedPref } }).catch(() => {});
|
|
||||||
} catch (userErr) {
|
|
||||||
res.status(400);
|
|
||||||
throw new Error(`Failed to create user: ${userErr.response?.data?.message || userErr.message}`);
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
// Guest path: phone-only, guestOnly=true, or no valid email
|
const suppliedPassword = typeof user.password === 'string' && user.password.trim().length >= 6
|
||||||
|
? user.password.trim()
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if (hasValidEmail && suppliedPassword) {
|
||||||
|
// Caller supplied their own password (the self-service kiosk, where the
|
||||||
|
// visitor sets it themselves on the spot) — activate immediately, since
|
||||||
|
// there's nothing left for them to do via an activation link.
|
||||||
|
const hashed = await hashPassword(suppliedPassword);
|
||||||
|
const created = await prisma.user.create({
|
||||||
|
data: {
|
||||||
|
id: uuidv4(),
|
||||||
|
name: user.name,
|
||||||
|
email: user.email,
|
||||||
|
password: hashed,
|
||||||
|
phoneNumber: phone || null,
|
||||||
|
isActive: true,
|
||||||
|
notificationPreference: derivedPref,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
}
|
||||||
|
});
|
||||||
|
userId = created.id;
|
||||||
|
} else {
|
||||||
|
// New account: uses the real email if a valid one was given, otherwise a
|
||||||
|
// guest.local placeholder (phone-only registration). Always created inactive
|
||||||
|
// with a random password — the visitor activates it themselves via the link
|
||||||
|
// sent immediately below (email or WhatsApp), unless the caller explicitly
|
||||||
|
// opted out of that nudge (e.g. a self-service visitor who declined to
|
||||||
|
// create an account at all).
|
||||||
const placeholderEmail = hasValidEmail
|
const placeholderEmail = hasValidEmail
|
||||||
? user.email
|
? user.email
|
||||||
: `guest+${uuidv4().slice(0, 8)}@guest.local`;
|
: `guest+${uuidv4().slice(0, 8)}@guest.local`;
|
||||||
@@ -943,6 +969,11 @@ const createManualRegistration = async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
userId = created.id;
|
userId = created.id;
|
||||||
|
if (!skipActivationNotice) {
|
||||||
|
const { sendActivationLink } = require('./userController');
|
||||||
|
sendActivationLink(created);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Merge into existing non-cancelled registration if one exists, otherwise create new
|
// Merge into existing non-cancelled registration if one exists, otherwise create new
|
||||||
@@ -1128,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));
|
return res.status(201).json(attachComputedTotals(registration));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ const { safeErrorMessage } = require('../utils/errorUtils');
|
|||||||
const { v4: uuidv4 } = require('uuid');
|
const { v4: uuidv4 } = require('uuid');
|
||||||
const { invalidate: invalidateSettingsCache, warmCache, ENCRYPTED_KEYS } = require('../utils/settingsCache');
|
const { invalidate: invalidateSettingsCache, warmCache, ENCRYPTED_KEYS } = require('../utils/settingsCache');
|
||||||
const { encrypt, decrypt, isEncrypted } = require('../utils/encryption');
|
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
|
// Keys safe to return without auth — includes legal keys needed by public legal pages
|
||||||
const PUBLIC_KEYS = [
|
const PUBLIC_KEYS = [
|
||||||
@@ -107,6 +109,20 @@ const updateSettings = async (req, res) => {
|
|||||||
if (ops.length) await prisma.$transaction(ops);
|
if (ops.length) await prisma.$transaction(ops);
|
||||||
invalidateSettingsCache();
|
invalidateSettingsCache();
|
||||||
await warmCache(); // ensure in-memory cache reflects the new values before responding
|
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' });
|
res.json({ message: 'Settings saved' });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(e) });
|
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(e) });
|
||||||
|
|||||||
@@ -1,7 +1,15 @@
|
|||||||
const path = require('path');
|
const path = require('path');
|
||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
|
const crypto = require('crypto');
|
||||||
const multer = require('multer');
|
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
|
// Setup multer storage
|
||||||
const storage = multer.diskStorage({
|
const storage = multer.diskStorage({
|
||||||
destination: function (req, file, cb) {
|
destination: function (req, file, cb) {
|
||||||
@@ -24,8 +32,7 @@ const storage = multer.diskStorage({
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
filename: function (req, file, cb) {
|
filename: function (req, file, cb) {
|
||||||
const uniqueName = `${Date.now()}-${file.originalname}`;
|
cb(null, safeFilename('event', path.extname(file.originalname).toLowerCase()));
|
||||||
cb(null, uniqueName);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -54,7 +61,7 @@ const logoStorage = multer.diskStorage({
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
filename: function (req, file, cb) {
|
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) {
|
filename: function (req, file, cb) {
|
||||||
cb(null, `favicon-${Date.now()}${path.extname(file.originalname).toLowerCase()}`);
|
cb(null, safeFilename('favicon', path.extname(file.originalname).toLowerCase()));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -23,6 +23,53 @@ function getClientIp(req) {
|
|||||||
|
|
||||||
const PRIVATE_IP_RE = /^(::1|::ffff:127\.|127\.|10\.|172\.(1[6-9]|2\d|3[01])\.|192\.168\.)/;
|
const PRIVATE_IP_RE = /^(::1|::ffff:127\.|127\.|10\.|172\.(1[6-9]|2\d|3[01])\.|192\.168\.)/;
|
||||||
|
|
||||||
|
// Fire-and-forget: create a 24h activation token and deliver it to an inactive
|
||||||
|
// account — via email if it has a real (non-guest) address, otherwise via
|
||||||
|
// WhatsApp if it has a phone number. Used both when a login attempt hits an
|
||||||
|
// inactive account, and immediately when an admin/supervisor creates an
|
||||||
|
// account on someone's behalf (walk-in / manual registration).
|
||||||
|
async function sendActivationLink(user) {
|
||||||
|
const hasRealEmail = !!(user?.email && !user.email.endsWith('@guest.local'));
|
||||||
|
if (!hasRealEmail && !user?.phoneNumber) return;
|
||||||
|
try {
|
||||||
|
const token = uuidv4();
|
||||||
|
const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000); // 24h
|
||||||
|
await prisma.passwordReset.updateMany({
|
||||||
|
where: { userId: user.id, used: false },
|
||||||
|
data: { used: true }
|
||||||
|
});
|
||||||
|
await prisma.passwordReset.create({
|
||||||
|
data: { id: uuidv4(), userId: user.id, token, expiresAt, used: false }
|
||||||
|
});
|
||||||
|
const baseUrl = process.env.FRONTEND_URL || process.env.APP_BASE_URL || 'http://localhost:3001';
|
||||||
|
const activationUrl = `${baseUrl.replace(/\/$/, '')}/activate-account?token=${encodeURIComponent(token)}`;
|
||||||
|
|
||||||
|
if (hasRealEmail) {
|
||||||
|
const { sendMail, buildAccountActivationEmail } = require('../utils/email');
|
||||||
|
const content = buildAccountActivationEmail({ name: user.name, activationUrl });
|
||||||
|
sendMail({ to: user.email, subject: `Activate your ${getOrgName()} account`, ...content })
|
||||||
|
.catch(e => console.warn('[activation email] Failed:', e?.message || e));
|
||||||
|
} else {
|
||||||
|
const orgName = getOrgName();
|
||||||
|
const waMessage = [
|
||||||
|
`🔓 *Activate your ${orgName} account*`,
|
||||||
|
'',
|
||||||
|
`Hi ${user.name || 'there'},`,
|
||||||
|
'',
|
||||||
|
`Your account needs to be activated before you can log in. Tap the link below to set a password and activate your account:`,
|
||||||
|
'',
|
||||||
|
activationUrl,
|
||||||
|
'',
|
||||||
|
`_This link expires in 24 hours._`,
|
||||||
|
].join('\n');
|
||||||
|
const { waTextAny } = require('../utils/notify');
|
||||||
|
waTextAny(user, waMessage).catch(e => console.warn('[activation WA] Failed:', e?.message || e));
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[activation token] Failed to create activation token:', e?.message || e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Fire-and-forget: send a login notification email with approximate geo location
|
// Fire-and-forget: send a login notification email with approximate geo location
|
||||||
async function sendLoginNotification(user, req) {
|
async function sendLoginNotification(user, req) {
|
||||||
try {
|
try {
|
||||||
@@ -176,61 +223,16 @@ const loginUser = async (req, res) => {
|
|||||||
|
|
||||||
// Check if user is active
|
// Check if user is active
|
||||||
if (!user.isActive) {
|
if (!user.isActive) {
|
||||||
// If the account has a real email (not a guest placeholder), send an activation link via email
|
// Resend the activation link on each failed login attempt against an inactive
|
||||||
|
// account, in case the original one (sent at creation, or a prior attempt) expired.
|
||||||
|
await sendActivationLink(user);
|
||||||
|
// If the account has a real email (not a guest placeholder), it went out via email
|
||||||
if (user.email && !user.email.endsWith('@guest.local')) {
|
if (user.email && !user.email.endsWith('@guest.local')) {
|
||||||
try {
|
|
||||||
const token = uuidv4();
|
|
||||||
const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000); // 24h
|
|
||||||
await prisma.passwordReset.updateMany({
|
|
||||||
where: { userId: user.id, used: false },
|
|
||||||
data: { used: true }
|
|
||||||
});
|
|
||||||
await prisma.passwordReset.create({
|
|
||||||
data: { id: uuidv4(), userId: user.id, token, expiresAt, used: false }
|
|
||||||
});
|
|
||||||
const baseUrl = process.env.FRONTEND_URL || process.env.APP_BASE_URL || 'http://localhost:3001';
|
|
||||||
const activationUrl = `${baseUrl.replace(/\/$/, '')}/activate-account?token=${encodeURIComponent(token)}`;
|
|
||||||
const { sendMail, buildAccountActivationEmail } = require('../utils/email');
|
|
||||||
const content = buildAccountActivationEmail({ name: user.name, activationUrl });
|
|
||||||
sendMail({ to: user.email, subject: `Activate your ${getOrgName()} account`, ...content })
|
|
||||||
.catch(e => console.warn('[activation email] Failed:', e?.message || e));
|
|
||||||
} catch (e) {
|
|
||||||
console.warn('[activation token] Failed to create activation token:', e?.message || e);
|
|
||||||
}
|
|
||||||
res.status(401);
|
res.status(401);
|
||||||
throw new Error('Your account is not yet active. We\'ve sent you an email with a link to activate your account.');
|
throw new Error('Your account is not yet active. We\'ve sent you an email with a link to activate your account.');
|
||||||
}
|
}
|
||||||
// No real email — if they have a phone number, send the activation link via WhatsApp
|
// No real email — if they have a phone number, it went out via WhatsApp
|
||||||
if (user.phoneNumber) {
|
if (user.phoneNumber) {
|
||||||
try {
|
|
||||||
const token = uuidv4();
|
|
||||||
const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000); // 24h
|
|
||||||
await prisma.passwordReset.updateMany({
|
|
||||||
where: { userId: user.id, used: false },
|
|
||||||
data: { used: true }
|
|
||||||
});
|
|
||||||
await prisma.passwordReset.create({
|
|
||||||
data: { id: uuidv4(), userId: user.id, token, expiresAt, used: false }
|
|
||||||
});
|
|
||||||
const baseUrl = process.env.FRONTEND_URL || process.env.APP_BASE_URL || 'http://localhost:3001';
|
|
||||||
const activationUrl = `${baseUrl.replace(/\/$/, '')}/activate-account?token=${encodeURIComponent(token)}`;
|
|
||||||
const orgName = getOrgName();
|
|
||||||
const waMessage = [
|
|
||||||
`🔓 *Activate your ${orgName} account*`,
|
|
||||||
'',
|
|
||||||
`Hi ${user.name || 'there'},`,
|
|
||||||
'',
|
|
||||||
`Your account needs to be activated before you can log in. Tap the link below to set a password and activate your account:`,
|
|
||||||
'',
|
|
||||||
activationUrl,
|
|
||||||
'',
|
|
||||||
`_This link expires in 24 hours._`,
|
|
||||||
].join('\n');
|
|
||||||
const { waTextAny } = require('../utils/notify');
|
|
||||||
waTextAny(user, waMessage).catch(e => console.warn('[activation WA] Failed:', e?.message || e));
|
|
||||||
} catch (e) {
|
|
||||||
console.warn('[activation token WA] Failed to create activation token:', e?.message || e);
|
|
||||||
}
|
|
||||||
res.status(401);
|
res.status(401);
|
||||||
throw new Error('Your account is not yet active. We\'ve sent you a WhatsApp message with a link to activate your account.');
|
throw new Error('Your account is not yet active. We\'ve sent you a WhatsApp message with a link to activate your account.');
|
||||||
}
|
}
|
||||||
@@ -970,4 +972,5 @@ module.exports = {
|
|||||||
adminRevokeUserSessions,
|
adminRevokeUserSessions,
|
||||||
closeAccount,
|
closeAccount,
|
||||||
getMyActivity,
|
getMyActivity,
|
||||||
|
sendActivationLink,
|
||||||
};
|
};
|
||||||
+100
-12
@@ -11,6 +11,20 @@ const getRawBody = require('raw-body');
|
|||||||
// Load environment variables
|
// Load environment variables
|
||||||
dotenv.config();
|
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
|
// Initialize Prisma client
|
||||||
const prisma = new PrismaClient();
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
@@ -18,6 +32,22 @@ const prisma = new PrismaClient();
|
|||||||
const app = express();
|
const app = express();
|
||||||
const PORT = process.env.PORT || 3000;
|
const PORT = process.env.PORT || 3000;
|
||||||
|
|
||||||
|
// Trust proxy — required when running behind a reverse proxy (e.g. nginx on a
|
||||||
|
// separate server) so req.ip / X-Forwarded-For are read correctly by
|
||||||
|
// express-rate-limit and friends. Accepts a hop count ("1"), "true"/"false",
|
||||||
|
// or a comma-separated list of trusted proxy IPs/CIDRs.
|
||||||
|
if (process.env.TRUST_PROXY) {
|
||||||
|
const raw = process.env.TRUST_PROXY.trim();
|
||||||
|
let trustProxyValue;
|
||||||
|
if (raw === 'true') trustProxyValue = true;
|
||||||
|
else if (raw === 'false') trustProxyValue = false;
|
||||||
|
else if (/^\d+$/.test(raw)) trustProxyValue = parseInt(raw, 10);
|
||||||
|
else if (raw.includes(',')) trustProxyValue = raw.split(',').map((s) => s.trim());
|
||||||
|
else trustProxyValue = raw;
|
||||||
|
app.set('trust proxy', trustProxyValue);
|
||||||
|
console.log(`[startup] trust proxy set to: ${JSON.stringify(trustProxyValue)}`);
|
||||||
|
}
|
||||||
|
|
||||||
// CORS — allow only the configured frontend origin
|
// CORS — allow only the configured frontend origin
|
||||||
const allowedOrigins = (process.env.FRONTEND_URL || 'http://localhost:3000')
|
const allowedOrigins = (process.env.FRONTEND_URL || 'http://localhost:3000')
|
||||||
.split(',')
|
.split(',')
|
||||||
@@ -110,6 +140,8 @@ const setupRoutes = require('./routes/setupRoutes');
|
|||||||
const costRoutes = require('./routes/costRoutes');
|
const costRoutes = require('./routes/costRoutes');
|
||||||
const cashupRoutes = require('./routes/cashupRoutes');
|
const cashupRoutes = require('./routes/cashupRoutes');
|
||||||
const statsRoutes = require('./routes/statsRoutes');
|
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
|
// Mount webhook routes BEFORE JSON body parser to avoid double-reading the stream
|
||||||
app.use('/api/webhooks', webhookRoutes);
|
app.use('/api/webhooks', webhookRoutes);
|
||||||
@@ -139,10 +171,22 @@ app.use('/api/setup', setupRoutes);
|
|||||||
app.use('/api/stats', statsRoutes);
|
app.use('/api/stats', statsRoutes);
|
||||||
app.use('/api', costRoutes);
|
app.use('/api', costRoutes);
|
||||||
app.use('/api/cashups', cashupRoutes);
|
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
|
// Pre-warm the settings cache so synchronous helpers have DB values from startup
|
||||||
require('./utils/settingsCache').warmCache().catch(() => {});
|
const { getSettingSync, warmCache } = require('./utils/settingsCache');
|
||||||
app.use('/uploads', express.static('public/uploads'));
|
warmCache().catch(() => {});
|
||||||
|
// 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 ────────────────────────────────────────────────────────
|
// ── Shared page helpers ────────────────────────────────────────────────────────
|
||||||
const jwt = require('jsonwebtoken');
|
const jwt = require('jsonwebtoken');
|
||||||
@@ -244,8 +288,9 @@ app.get('/', async (req, res) => {
|
|||||||
? `<span class="badge badge-warn">testing</span>`
|
? `<span class="badge badge-warn">testing</span>`
|
||||||
: `<span class="badge badge-warn">development</span>`;
|
: `<span class="badge badge-warn">development</span>`;
|
||||||
|
|
||||||
const html = pageShell('Cross Code Events API — Status', '#2563eb', `
|
const orgName = getSettingSync('org_name', process.env.ORG_NAME || 'Cross Code');
|
||||||
<h1>Cross Code Events API</h1>
|
const html = pageShell(`${orgName} Events API — Status`, '#2563eb', `
|
||||||
|
<h1>${orgName} Events API</h1>
|
||||||
<p class="subtitle">v${API_VERSION} — ${now}</p>
|
<p class="subtitle">v${API_VERSION} — ${now}</p>
|
||||||
|
|
||||||
<div class="stat-grid">
|
<div class="stat-grid">
|
||||||
@@ -311,7 +356,7 @@ app.get('/docs', async (req, res) => {
|
|||||||
|
|
||||||
let user;
|
let user;
|
||||||
try {
|
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({
|
user = await prisma.user.findUnique({
|
||||||
where: { id: decoded.id },
|
where: { id: decoded.id },
|
||||||
select: { id: true, name: true, email: true, role: true, isActive: true, tokenVersion: true },
|
select: { id: true, name: true, email: true, role: true, isActive: true, tokenVersion: true },
|
||||||
@@ -1030,13 +1075,14 @@ app.get('/docs', async (req, res) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const notificationsHtml = NOTIFICATIONS.map(renderNotificationCategory).join('');
|
const notificationsHtml = NOTIFICATIONS.map(renderNotificationCategory).join('');
|
||||||
|
const orgName = getSettingSync('org_name', process.env.ORG_NAME || 'Cross Code');
|
||||||
|
|
||||||
const html = `<!DOCTYPE html>
|
const html = `<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>Cross Code Events — API Docs</title>
|
<title>${orgName} Events — API Docs</title>
|
||||||
<style>
|
<style>
|
||||||
*{box-sizing:border-box;margin:0;padding:0}
|
*{box-sizing:border-box;margin:0;padding:0}
|
||||||
body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;background:#f3f4f6;color:#1f2937;min-height:100vh;padding:24px 16px}
|
body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;background:#f3f4f6;color:#1f2937;min-height:100vh;padding:24px 16px}
|
||||||
@@ -1060,7 +1106,7 @@ app.get('/docs', async (req, res) => {
|
|||||||
<body>
|
<body>
|
||||||
<div class="wrap">
|
<div class="wrap">
|
||||||
<div style="display:flex;align-items:baseline;justify-content:space-between;flex-wrap:wrap;gap:8px;margin-bottom:6px">
|
<div style="display:flex;align-items:baseline;justify-content:space-between;flex-wrap:wrap;gap:8px;margin-bottom:6px">
|
||||||
<h1 style="font-size:1.4rem;font-weight:700;color:#111827">Cross Code Events — API Reference</h1>
|
<h1 style="font-size:1.4rem;font-weight:700;color:#111827">${orgName} Events — API Reference</h1>
|
||||||
<a href="/" style="font-size:.82rem;color:#6b7280">← Status page</a>
|
<a href="/" style="font-size:.82rem;color:#6b7280">← Status page</a>
|
||||||
</div>
|
</div>
|
||||||
<p style="font-size:.82rem;color:#6b7280;margin-bottom:20px">
|
<p style="font-size:.82rem;color:#6b7280;margin-bottom:20px">
|
||||||
@@ -1087,7 +1133,7 @@ app.get('/docs', async (req, res) => {
|
|||||||
${notificationsHtml}
|
${notificationsHtml}
|
||||||
|
|
||||||
<p style="font-size:.72rem;color:#9ca3af;margin-top:28px;text-align:center">
|
<p style="font-size:.72rem;color:#9ca3af;margin-top:28px;text-align:center">
|
||||||
Cross Code Events API v${API_VERSION} — ${new Date().toISOString()}
|
${orgName} Events API v${API_VERSION} — ${new Date().toISOString()}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<script>
|
<script>
|
||||||
@@ -1115,6 +1161,14 @@ function toggle(id) {
|
|||||||
|
|
||||||
// Error middleware
|
// Error middleware
|
||||||
app.use(notFound);
|
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);
|
app.use(errorHandler);
|
||||||
|
|
||||||
// Start server
|
// Start server
|
||||||
@@ -1205,6 +1259,36 @@ app.listen(PORT, () => {
|
|||||||
console.warn('[temp cleanup] Not scheduled:', e?.message || e);
|
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)
|
// Scheduled emails worker (polling)
|
||||||
try {
|
try {
|
||||||
const enabled = String(process.env.SCHEDULED_EMAILS_ENABLED || 'true').toLowerCase() !== 'false';
|
const enabled = String(process.env.SCHEDULED_EMAILS_ENABLED || 'true').toLowerCase() !== 'false';
|
||||||
@@ -1262,11 +1346,15 @@ app.listen(PORT, () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Handle unhandled promise rejections
|
// Log unhandled promise rejections without killing the server, since a single
|
||||||
|
// missed .catch() on fire-and-forget notification code (email/WhatsApp sends)
|
||||||
|
// would otherwise take the whole app down.
|
||||||
process.on('unhandledRejection', (err) => {
|
process.on('unhandledRejection', (err) => {
|
||||||
console.log('UNHANDLED REJECTION! Shutting down...');
|
console.error('UNHANDLED REJECTION!', err?.name, err?.message);
|
||||||
console.log(err.name, err.message);
|
console.error(err?.stack || err);
|
||||||
process.exit(1);
|
if (process.env.SENTRY_DSN) {
|
||||||
|
try { require('@sentry/node').captureException(err); } catch {}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
module.exports = { app, prisma };
|
module.exports = { app, prisma };
|
||||||
@@ -13,8 +13,9 @@ const protect = async (req, res, next) => {
|
|||||||
// Get token from header
|
// Get token from header
|
||||||
token = req.headers.authorization.split(' ')[1];
|
token = req.headers.authorization.split(' ')[1];
|
||||||
|
|
||||||
// Verify token
|
// Verify token — pin the algorithm so a token signed with an
|
||||||
const decoded = jwt.verify(token, process.env.JWT_SECRET);
|
// 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)
|
// Get user from the token (exclude password)
|
||||||
req.user = await prisma.user.findUnique({
|
req.user = await prisma.user.findUnique({
|
||||||
@@ -105,7 +106,7 @@ const optionalAuth = async (req, res, next) => {
|
|||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const token = req.headers.authorization.split(' ')[1];
|
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({
|
const user = await prisma.user.findUnique({
|
||||||
where: { id: decoded.id },
|
where: { id: decoded.id },
|
||||||
select: { id: true, name: true, email: true, role: true, isActive: true, createdAt: true, updatedAt: true, phoneNumber: true, tokenVersion: true }
|
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,
|
getAllEvents,
|
||||||
getEventsAll,
|
getEventsAll,
|
||||||
getEventById,
|
getEventById,
|
||||||
|
getEventIcs,
|
||||||
updateEvent,
|
updateEvent,
|
||||||
getEventNotifyRecipients,
|
getEventNotifyRecipients,
|
||||||
updateEventNotifyRecipients,
|
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
|
// 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.
|
// can still load inactive events (e.g. for cashup or editing) without being 404'd.
|
||||||
router.get('/:id', optionalAuth, getEventById);
|
router.get('/:id', optionalAuth, getEventById);
|
||||||
|
router.get('/:id/ics', optionalAuth, getEventIcs);
|
||||||
router.get('/by-alias/:redirectUrl', getEventByAlias);
|
router.get('/by-alias/:redirectUrl', getEventByAlias);
|
||||||
|
|
||||||
// Create/Update/Delete event
|
// 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>`;
|
</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. */
|
/** Horizontal rule. */
|
||||||
function divider() {
|
function divider() {
|
||||||
return `<div style="border-top:1px solid #f1f5f9;margin:32px 0"></div>`;
|
return `<div style="border-top:1px solid #f1f5f9;margin:32px 0"></div>`;
|
||||||
@@ -390,6 +398,7 @@ module.exports = {
|
|||||||
emailWrapper,
|
emailWrapper,
|
||||||
ctaButton,
|
ctaButton,
|
||||||
fallbackLink,
|
fallbackLink,
|
||||||
|
secondaryLink,
|
||||||
divider,
|
divider,
|
||||||
callout,
|
callout,
|
||||||
paymentOption,
|
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 fs = require('fs');
|
||||||
const prisma = require('../config/db');
|
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');
|
const { computeRegistrationTotalDue, computeOptionLineTotal } = require('./pricing');
|
||||||
|
|
||||||
// ─── Formatting helpers ───────────────────────────────────────────────────────
|
// ─── Formatting helpers ───────────────────────────────────────────────────────
|
||||||
@@ -22,6 +22,20 @@ function fmtDateShort(d) {
|
|||||||
|
|
||||||
const { getSettingSync } = require('./settingsCache');
|
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() {
|
function getOrg() {
|
||||||
return {
|
return {
|
||||||
name: getSettingSync('org_name', process.env.ORG_NAME || 'Cross Code'),
|
name: getSettingSync('org_name', process.env.ORG_NAME || 'Cross Code'),
|
||||||
@@ -269,7 +283,8 @@ function buildRegistrationConfirmation(reg, { isNew = true } = {}) {
|
|||||||
${financialSummary(totalDue, totalPaid, balance)}
|
${financialSummary(totalDue, totalPaid, balance)}
|
||||||
|
|
||||||
${paymentSection({ balance, yocoLink: null, source: 'user', siteUrl: org.url, formRequired: false, isUserActive })}
|
${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 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}`;
|
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)}
|
${financialSummary(totalDue, totalPaid, balance)}
|
||||||
|
|
||||||
${paymentSection({ balance, yocoLink, source: 'admin', siteUrl: org.url, formRequired, isUserActive })}
|
${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 itemsText = (reg.registrationOptions || []).map(ro => ` • ${ro.eventOption?.name || 'Option'} ×${ro.quantity} — ${fmtAmount(computeOptionLineTotal(ro, null, new Date()))}`).join('\n');
|
||||||
const payText = balance > 0
|
const payText = balance > 0
|
||||||
@@ -1370,14 +1386,14 @@ async function sendDailyEventSummaries(now = new Date()) {
|
|||||||
const today = new Date(now);
|
const today = new Date(now);
|
||||||
const notifyInclude = { createdBy: { select: { id: true, name: true, email: true } }, notifyRecipients: { select: { id: true, name: true, email: true } } };
|
const notifyInclude = { createdBy: { select: { id: true, name: true, email: true } }, notifyRecipients: { select: { id: true, name: true, email: true } } };
|
||||||
let events = await prisma.event.findMany({
|
let events = await prisma.event.findMany({
|
||||||
where: { isActive: true, startDate: { gte: today } },
|
where: { isActive: true, startDate: { gte: today }, requiresRegistration: true },
|
||||||
include: notifyInclude,
|
include: notifyInclude,
|
||||||
orderBy: { startDate: 'asc' },
|
orderBy: { startDate: 'asc' },
|
||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
events = await prisma.event.findMany({
|
events = await prisma.event.findMany({
|
||||||
where: { isActive: true, startDate: { gte: today }, goLiveAt: { lte: today } },
|
where: { isActive: true, startDate: { gte: today }, goLiveAt: { lte: today }, requiresRegistration: true },
|
||||||
include: notifyInclude,
|
include: notifyInclude,
|
||||||
orderBy: { startDate: 'asc' },
|
orderBy: { startDate: 'asc' },
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
module.exports = {
|
||||||
|
apps: [
|
||||||
|
{
|
||||||
|
name: 'hope-events-backend',
|
||||||
|
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 3000',
|
||||||
|
env: { NODE_ENV: 'production' },
|
||||||
|
max_memory_restart: '500M',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { NextConfig } from "next";
|
import type { NextConfig } from "next";
|
||||||
|
import { withSentryConfig } from "@sentry/nextjs";
|
||||||
|
|
||||||
const nextConfig: NextConfig = {
|
const nextConfig: NextConfig = {
|
||||||
images: {
|
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
+3886
-1725
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "hope-events-frontend",
|
"name": "hope-events-frontend",
|
||||||
"version": "1.8.0",
|
"version": "1.10.1",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev --turbopack",
|
"dev": "next dev --turbopack",
|
||||||
@@ -27,12 +27,13 @@
|
|||||||
"@radix-ui/react-tabs": "^1.1.12",
|
"@radix-ui/react-tabs": "^1.1.12",
|
||||||
"@radix-ui/react-toast": "^1.2.14",
|
"@radix-ui/react-toast": "^1.2.14",
|
||||||
"@radix-ui/react-tooltip": "^1.2.7",
|
"@radix-ui/react-tooltip": "^1.2.7",
|
||||||
|
"@sentry/nextjs": "^10.71.0",
|
||||||
"@zxing/browser": "^0.1.5",
|
"@zxing/browser": "^0.1.5",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
"lucide-react": "^0.536.0",
|
"lucide-react": "^0.536.0",
|
||||||
"next": "15.4.5",
|
"next": "^15.5.24",
|
||||||
"qrcode": "^1.5.4",
|
"qrcode": "^1.5.4",
|
||||||
"react": "19.1.0",
|
"react": "19.1.0",
|
||||||
"react-day-picker": "^9.8.1",
|
"react-day-picker": "^9.8.1",
|
||||||
@@ -51,9 +52,14 @@
|
|||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^19",
|
||||||
"autoprefixer": "^10.4.21",
|
"autoprefixer": "^10.4.21",
|
||||||
"eslint": "^9",
|
"eslint": "^9",
|
||||||
"eslint-config-next": "15.4.5",
|
"eslint-config-next": "^15.5.24",
|
||||||
"postcss": "^8.5.6",
|
"postcss": "^8.5.6",
|
||||||
"tailwindcss": "3.4",
|
"tailwindcss": "3.4",
|
||||||
"typescript": "^5"
|
"typescript": "^5"
|
||||||
|
},
|
||||||
|
"overrides": {
|
||||||
|
"next": {
|
||||||
|
"postcss": "^8.5.23"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,16 +3,26 @@ import { apiFetch } from "@/lib/api";
|
|||||||
|
|
||||||
export const revalidate = 60;
|
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 }> }) {
|
export default async function EventRedirectPage({ params }: { params: Promise<{ redirectUrl: string }> }) {
|
||||||
const { redirectUrl } = await params;
|
const { redirectUrl } = await params;
|
||||||
|
|
||||||
let event: any = null;
|
let event: any = null;
|
||||||
|
|
||||||
|
if (VALID_ALIAS.test(redirectUrl)) {
|
||||||
try {
|
try {
|
||||||
event = await apiFetch<any>(`/api/events/by-alias/${redirectUrl}`);
|
event = await apiFetch<any>(`/api/events/by-alias/${redirectUrl}`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to fetch event:", error);
|
console.error("Failed to fetch event:", error);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (!event || event.message?.toLowerCase().includes("not found")) {
|
if (!event || event.message?.toLowerCase().includes("not found")) {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { Navbar } from "@/components/layout/Navbar";
|
|||||||
import { Footer } from "@/components/layout/Footer";
|
import { Footer } from "@/components/layout/Footer";
|
||||||
import { useSiteSettings } from "@/contexts/SiteSettingsContext";
|
import { useSiteSettings } from "@/contexts/SiteSettingsContext";
|
||||||
import { appName } from "@/lib/siteConfig";
|
import { appName } from "@/lib/siteConfig";
|
||||||
|
import { LocationMap } from "@/components/events/LocationMap";
|
||||||
|
|
||||||
export default function ContactPage() {
|
export default function ContactPage() {
|
||||||
const { settings, loading } = useSiteSettings();
|
const { settings, loading } = useSiteSettings();
|
||||||
@@ -68,6 +69,12 @@ export default function ContactPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{address && (
|
||||||
|
<div className="mt-8 border rounded-xl p-5 bg-white shadow-sm">
|
||||||
|
<LocationMap address={address} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</main>
|
</main>
|
||||||
<Footer />
|
<Footer />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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 { useVisiblePolling } from "@/hooks/useVisiblePolling";
|
||||||
import {
|
import {
|
||||||
Calendar, Banknote, Gift, Users, Ticket, QrCode, ClipboardList,
|
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";
|
} from "lucide-react";
|
||||||
import { StatCard, StatCardRow } from "@/components/shared/StatCard";
|
import { StatCard, StatCardRow } from "@/components/shared/StatCard";
|
||||||
import { QuickActionTile, QuickActionGrid } from "@/components/shared/QuickActionTile";
|
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/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/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/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;
|
] as const;
|
||||||
|
|
||||||
type OverviewMetric = { thisMonth: number; lastMonth: number; pctChange: number | null };
|
type OverviewMetric = { thisMonth: number; lastMonth: number; pctChange: number | null };
|
||||||
|
|||||||
@@ -6,12 +6,14 @@ import { useRouter, useSearchParams } from "next/navigation";
|
|||||||
import { apiFetch, API_BASE, resolveToApiOrigin } from "@/lib/api";
|
import { apiFetch, API_BASE, resolveToApiOrigin } from "@/lib/api";
|
||||||
import { useSiteSettings } from "@/contexts/SiteSettingsContext";
|
import { useSiteSettings } from "@/contexts/SiteSettingsContext";
|
||||||
import { useDismissingState } from "@/hooks/useDismissingState";
|
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 { ColorPickerField } from "@/components/admin/ColorPickerField";
|
||||||
import { BrandingPreviewPanel } from "@/components/admin/BrandingPreviewPanel";
|
import { BrandingPreviewPanel } from "@/components/admin/BrandingPreviewPanel";
|
||||||
|
import { BackupsTab } from "@/components/admin/BackupsTab";
|
||||||
import { extractDominantColors } from "@/lib/extractColors";
|
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 }[] = [
|
const TABS: { id: TabId; label: string; icon: LucideIcon }[] = [
|
||||||
{ id: "organisation", label: "Organisation", icon: Building2 },
|
{ id: "organisation", label: "Organisation", icon: Building2 },
|
||||||
@@ -20,6 +22,7 @@ const TABS: { id: TabId; label: string; icon: LucideIcon }[] = [
|
|||||||
{ id: "email", label: "Email", icon: Mail },
|
{ id: "email", label: "Email", icon: Mail },
|
||||||
{ id: "legal", label: "Legal", icon: Scale },
|
{ id: "legal", label: "Legal", icon: Scale },
|
||||||
{ id: "whatsapp", label: "WhatsApp", icon: MessageCircle },
|
{ id: "whatsapp", label: "WhatsApp", icon: MessageCircle },
|
||||||
|
{ id: "backups", label: "Backups", icon: DatabaseBackup },
|
||||||
];
|
];
|
||||||
|
|
||||||
const inputCls =
|
const inputCls =
|
||||||
@@ -410,9 +413,15 @@ function SiteSettingsPageInner() {
|
|||||||
value={orgPhone} onChange={e => setOrgPhone(e.target.value)} />
|
value={orgPhone} onChange={e => setOrgPhone(e.target.value)} />
|
||||||
</Field>
|
</Field>
|
||||||
</div>
|
</div>
|
||||||
<Field label="Address">
|
<Field label="Address" hint="Used as the default location for new events.">
|
||||||
<input className={inputCls} placeholder="123 Church St, City"
|
<input className={inputCls} placeholder="123 Church St, City"
|
||||||
value={orgAddress} onChange={e => setOrgAddress(e.target.value)} />
|
value={orgAddress} onChange={e => setOrgAddress(e.target.value)} />
|
||||||
|
{orgAddress.trim() && (
|
||||||
|
<a href={mapsSearchUrl(orgAddress.trim())} target="_blank" rel="noopener noreferrer"
|
||||||
|
className="inline-block text-xs text-brand-600 hover:underline mt-1">
|
||||||
|
View on map ↗
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
</Field>
|
</Field>
|
||||||
<Field label="Site URL" hint="The public URL of this site — used in email links (e.g. password reset, ticket delivery). e.g. https://events.yourchurch.org">
|
<Field label="Site URL" hint="The public URL of this site — used in email links (e.g. password reset, ticket delivery). e.g. https://events.yourchurch.org">
|
||||||
<input className={inputCls} placeholder="https://events.yourchurch.org"
|
<input className={inputCls} placeholder="https://events.yourchurch.org"
|
||||||
@@ -655,6 +664,9 @@ function SiteSettingsPageInner() {
|
|||||||
|
|
||||||
{/* ── WhatsApp ──────────────────────────────────────────────────────── */}
|
{/* ── WhatsApp ──────────────────────────────────────────────────────── */}
|
||||||
{activeTab === "whatsapp" && <WhatsAppTab active={activeTab === "whatsapp"} />}
|
{activeTab === "whatsapp" && <WhatsAppTab active={activeTab === "whatsapp"} />}
|
||||||
|
|
||||||
|
{/* ── Backups ───────────────────────────────────────────────────────── */}
|
||||||
|
{activeTab === "backups" && <BackupsTab active={activeTab === "backups"} />}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{activeTab === "branding" && (
|
{activeTab === "branding" && (
|
||||||
|
|||||||
@@ -242,7 +242,6 @@ export default function AtTheDoorPage() {
|
|||||||
authToken: token,
|
authToken: token,
|
||||||
body: {
|
body: {
|
||||||
eventId,
|
eventId,
|
||||||
guestOnly: pendingUser.guestOnly,
|
|
||||||
user: {
|
user: {
|
||||||
name: pendingUser.name,
|
name: pendingUser.name,
|
||||||
...(pendingUser.email ? { email: pendingUser.email } : {}),
|
...(pendingUser.email ? { email: pendingUser.email } : {}),
|
||||||
@@ -282,7 +281,7 @@ export default function AtTheDoorPage() {
|
|||||||
});
|
});
|
||||||
setQuantities(qtyMap);
|
setQuantities(qtyMap);
|
||||||
setMinQuantities({});
|
setMinQuantities({});
|
||||||
setPendingUser({ guestOnly: true, name, email: email || null, phone: phone || null, notifPref });
|
setPendingUser({ name, email: email || null, phone: phone || null, notifPref });
|
||||||
setPendingEditReg(null);
|
setPendingEditReg(null);
|
||||||
setShowNewAttendeeModal(false);
|
setShowNewAttendeeModal(false);
|
||||||
setShowOptionsModal(true);
|
setShowOptionsModal(true);
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { useAuth } from "@/hooks/useAuth";
|
|||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { apiFetch, resolveToApiOrigin } from "@/lib/api";
|
import { apiFetch, resolveToApiOrigin } from "@/lib/api";
|
||||||
import { useDismissingState } from "@/hooks/useDismissingState";
|
import { useDismissingState } from "@/hooks/useDismissingState";
|
||||||
|
import { useSiteSettings } from "@/contexts/SiteSettingsContext";
|
||||||
import { Calendar } from "lucide-react";
|
import { Calendar } from "lucide-react";
|
||||||
|
|
||||||
// ─── helpers ────────────────────────────────────────────────────────────────
|
// ─── helpers ────────────────────────────────────────────────────────────────
|
||||||
@@ -756,13 +757,15 @@ interface EventDraft {
|
|||||||
registrationDeadline: string; goLiveAt: string; price: string; picture: string;
|
registrationDeadline: string; goLiveAt: string; price: string; picture: string;
|
||||||
redirectUrl: string; isActive: boolean; isHidden: boolean; requiresAuth: boolean;
|
redirectUrl: string; isActive: boolean; isHidden: boolean; requiresAuth: boolean;
|
||||||
requiresRegistration: boolean; contactName: string; contactPhone: string; contactEmail: string;
|
requiresRegistration: boolean; contactName: string; contactPhone: string; contactEmail: string;
|
||||||
|
location: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const blankDraft = (): EventDraft => ({
|
const blankDraft = (location = ""): EventDraft => ({
|
||||||
title: "", description: "", startDate: "", endDate: "",
|
title: "", description: "", startDate: "", endDate: "",
|
||||||
registrationDeadline: "", goLiveAt: "", price: "", picture: "",
|
registrationDeadline: "", goLiveAt: "", price: "", picture: "",
|
||||||
redirectUrl: "", isActive: true, isHidden: false, requiresAuth: true,
|
redirectUrl: "", isActive: true, isHidden: false, requiresAuth: true,
|
||||||
requiresRegistration: true, contactName: "", contactPhone: "", contactEmail: "",
|
requiresRegistration: true, contactName: "", contactPhone: "", contactEmail: "",
|
||||||
|
location,
|
||||||
});
|
});
|
||||||
|
|
||||||
const blankOptions = (): OptionDraft[] => [
|
const blankOptions = (): OptionDraft[] => [
|
||||||
@@ -781,12 +784,15 @@ interface EventModalProps {
|
|||||||
function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) {
|
function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) {
|
||||||
const { token, user } = useAuth();
|
const { token, user } = useAuth();
|
||||||
const isAdmin = user?.role === "admin";
|
const isAdmin = user?.role === "admin";
|
||||||
|
const { settings } = useSiteSettings();
|
||||||
const [step, setStep] = useState<StepIdx>(0);
|
const [step, setStep] = useState<StepIdx>(0);
|
||||||
const [pricingSubstep, setPricingSubstep] = useState<PricingSubstep>(0);
|
const [pricingSubstep, setPricingSubstep] = useState<PricingSubstep>(0);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
// ── event draft ──
|
// ── event draft ──
|
||||||
|
// New events default their location to the organisation's address (settings.org_address);
|
||||||
|
// editing an existing event always reflects its own saved location instead.
|
||||||
const [draft, setDraft] = useState<EventDraft>(() => ev ? {
|
const [draft, setDraft] = useState<EventDraft>(() => ev ? {
|
||||||
title: ev.title || "", description: ev.description || "",
|
title: ev.title || "", description: ev.description || "",
|
||||||
startDate: toLocalDT(ev.startDate), endDate: toLocalDT(ev.endDate),
|
startDate: toLocalDT(ev.startDate), endDate: toLocalDT(ev.endDate),
|
||||||
@@ -795,7 +801,8 @@ function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) {
|
|||||||
isActive: ev.isActive !== false, isHidden: !!ev.isHidden, requiresAuth: ev.requiresAuth !== false,
|
isActive: ev.isActive !== false, isHidden: !!ev.isHidden, requiresAuth: ev.requiresAuth !== false,
|
||||||
requiresRegistration: ev.requiresRegistration !== false,
|
requiresRegistration: ev.requiresRegistration !== false,
|
||||||
contactName: ev.contactName || "", contactPhone: ev.contactPhone || "", contactEmail: ev.contactEmail || "",
|
contactName: ev.contactName || "", contactPhone: ev.contactPhone || "", contactEmail: ev.contactEmail || "",
|
||||||
} : blankDraft());
|
location: ev.location || "",
|
||||||
|
} : blankDraft(settings.org_address || ""));
|
||||||
|
|
||||||
// ── options (with per-variant tiers) ──
|
// ── options (with per-variant tiers) ──
|
||||||
const [options, setOptions] = useState<OptionDraft[]>(() => {
|
const [options, setOptions] = useState<OptionDraft[]>(() => {
|
||||||
@@ -1065,6 +1072,7 @@ function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) {
|
|||||||
contactName: draft.contactName || undefined,
|
contactName: draft.contactName || undefined,
|
||||||
contactPhone: draft.contactPhone || undefined,
|
contactPhone: draft.contactPhone || undefined,
|
||||||
contactEmail: draft.contactEmail || undefined,
|
contactEmail: draft.contactEmail || undefined,
|
||||||
|
location: draft.location.trim(),
|
||||||
};
|
};
|
||||||
|
|
||||||
if (mode === "edit") {
|
if (mode === "edit") {
|
||||||
@@ -1209,6 +1217,11 @@ function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) {
|
|||||||
<DTInput label="Registration Deadline (optional)" value={draft.registrationDeadline} onChange={v => upd({ registrationDeadline: v })} />
|
<DTInput label="Registration Deadline (optional)" value={draft.registrationDeadline} onChange={v => upd({ registrationDeadline: v })} />
|
||||||
<DTInput label="Go Live At (optional)" value={draft.goLiveAt} onChange={v => upd({ goLiveAt: v })} hint="Leave blank to show immediately" />
|
<DTInput label="Go Live At (optional)" value={draft.goLiveAt} onChange={v => upd({ goLiveAt: v })} hint="Leave blank to show immediately" />
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs text-gray-600 mb-1">Location</label>
|
||||||
|
<input className="w-full border rounded px-3 py-2 text-sm" value={draft.location} onChange={e => upd({ location: e.target.value })} placeholder="e.g. 123 Church St, City" />
|
||||||
|
<p className="text-[10px] text-gray-400 mt-0.5">Defaults to your organisation's address — shown to attendees with a map link.</p>
|
||||||
|
</div>
|
||||||
<div className="flex items-start gap-2 p-3 border rounded bg-gray-50">
|
<div className="flex items-start gap-2 p-3 border rounded bg-gray-50">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ export default function ManualRegistrationPage() {
|
|||||||
const [name, setName] = useState("");
|
const [name, setName] = useState("");
|
||||||
const [email, setEmail] = useState("");
|
const [email, setEmail] = useState("");
|
||||||
const [phoneNumber, setPhoneNumber] = useState("");
|
const [phoneNumber, setPhoneNumber] = useState("");
|
||||||
const [registerAsGuest, setRegisterAsGuest] = useState(false);
|
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const [error, setError] = useDismissingState<string | null>(null);
|
const [error, setError] = useDismissingState<string | null>(null);
|
||||||
const [createdReg, setCreatedReg] = useState<any | null>(null);
|
const [createdReg, setCreatedReg] = useState<any | null>(null);
|
||||||
@@ -43,7 +42,6 @@ export default function ManualRegistrationPage() {
|
|||||||
eventId,
|
eventId,
|
||||||
options: [{ eventOptionId: optionId, quantity }],
|
options: [{ eventOptionId: optionId, quantity }],
|
||||||
user: { name, ...(email ? { email } : {}), ...(phoneNumber ? { phoneNumber } : {}) },
|
user: { name, ...(email ? { email } : {}), ...(phoneNumber ? { phoneNumber } : {}) },
|
||||||
guestOnly: registerAsGuest,
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
setCreatedReg(res);
|
setCreatedReg(res);
|
||||||
@@ -89,10 +87,7 @@ export default function ManualRegistrationPage() {
|
|||||||
<input className="w-full border rounded px-3 py-2" value={name} onChange={(e) => setName(e.target.value)} required />
|
<input className="w-full border rounded px-3 py-2" value={name} onChange={(e) => setName(e.target.value)} required />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<label className="block text-sm font-medium">Email</label>
|
<label className="block text-sm font-medium">Email</label>
|
||||||
<label className="text-xs flex items-center gap-2"><input type="checkbox" checked={registerAsGuest} onChange={e=>setRegisterAsGuest(e.target.checked)} /> Guest (no account)</label>
|
|
||||||
</div>
|
|
||||||
<input type="email" className="w-full border rounded px-3 py-2" value={email} onChange={(e) => setEmail(e.target.value)} placeholder="email@example.com" />
|
<input type="email" className="w-full border rounded px-3 py-2" value={email} onChange={(e) => setEmail(e.target.value)} placeholder="email@example.com" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -100,7 +95,7 @@ export default function ManualRegistrationPage() {
|
|||||||
<label className="block text-sm font-medium">Cell Number</label>
|
<label className="block text-sm font-medium">Cell Number</label>
|
||||||
<input type="tel" className="w-full border rounded px-3 py-2" value={phoneNumber} onChange={(e) => setPhoneNumber(e.target.value)} placeholder="+27…" />
|
<input type="tel" className="w-full border rounded px-3 py-2" value={phoneNumber} onChange={(e) => setPhoneNumber(e.target.value)} placeholder="+27…" />
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-gray-500">At least one of email or cell number is required. If no email is provided, a guest account is created automatically.</p>
|
<p className="text-xs text-gray-500">At least one of email or cell number is required. The account is created inactive, and an activation link is sent immediately (by email if provided, otherwise WhatsApp) so the attendee can set their own password.</p>
|
||||||
{error && <p className="text-sm text-red-600">{error}</p>}
|
{error && <p className="text-sm text-red-600">{error}</p>}
|
||||||
<button type="submit" disabled={busy} className="bg-brand-600 hover:bg-brand-700 text-white rounded px-4 py-2 disabled:opacity-60">
|
<button type="submit" disabled={busy} className="bg-brand-600 hover:bg-brand-700 text-white rounded px-4 py-2 disabled:opacity-60">
|
||||||
{busy ? "Submitting..." : "Create"}
|
{busy ? "Submitting..." : "Create"}
|
||||||
|
|||||||
@@ -76,7 +76,6 @@ export default function ManualRegistrationPage() {
|
|||||||
const [allUsers, setAllUsers] = useState<any[]>([]);
|
const [allUsers, setAllUsers] = useState<any[]>([]);
|
||||||
|
|
||||||
const [guest, setGuest] = useState({ name: "", email: "", phoneNumber: "" });
|
const [guest, setGuest] = useState({ name: "", email: "", phoneNumber: "" });
|
||||||
const [registerAsGuest, setRegisterAsGuest] = useState(false);
|
|
||||||
const [notifPref, setNotifPref] = useState<"email" | "whatsapp" | "both">("email");
|
const [notifPref, setNotifPref] = useState<"email" | "whatsapp" | "both">("email");
|
||||||
const [quantities, setQuantities] = useState<Record<string, number>>({});
|
const [quantities, setQuantities] = useState<Record<string, number>>({});
|
||||||
|
|
||||||
@@ -287,7 +286,7 @@ export default function ManualRegistrationPage() {
|
|||||||
setError(null);
|
setError(null);
|
||||||
setMessage(null);
|
setMessage(null);
|
||||||
if (!selectedEventId) { setError("Please select an event."); return; }
|
if (!selectedEventId) { setError("Please select an event."); return; }
|
||||||
if (!guest.name || (!registerAsGuest && !guest.email)) { setError("Guest name and email are required."); return; }
|
if (!guest.name || (!guest.email.trim() && !guest.phoneNumber.trim())) { setError("Guest name and at least one of email or phone are required."); return; }
|
||||||
const opts = Object.entries(quantities)
|
const opts = Object.entries(quantities)
|
||||||
.filter(([, qty]) => qty > 0)
|
.filter(([, qty]) => qty > 0)
|
||||||
.map(([key, quantity]) => {
|
.map(([key, quantity]) => {
|
||||||
@@ -308,7 +307,6 @@ export default function ManualRegistrationPage() {
|
|||||||
eventId: selectedEventId,
|
eventId: selectedEventId,
|
||||||
options: opts,
|
options: opts,
|
||||||
user: guest,
|
user: guest,
|
||||||
guestOnly: registerAsGuest,
|
|
||||||
notificationPreference: resolvedPref,
|
notificationPreference: resolvedPref,
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -324,7 +322,6 @@ export default function ManualRegistrationPage() {
|
|||||||
|
|
||||||
// Reset guest/ticket fields so the next registration starts from a clean slate
|
// Reset guest/ticket fields so the next registration starts from a clean slate
|
||||||
setGuest({ name: "", email: "", phoneNumber: "" });
|
setGuest({ name: "", email: "", phoneNumber: "" });
|
||||||
setRegisterAsGuest(false);
|
|
||||||
setNotifPref("email");
|
setNotifPref("email");
|
||||||
setUserQuery("");
|
setUserQuery("");
|
||||||
setDropdownOpen(false);
|
setDropdownOpen(false);
|
||||||
@@ -442,13 +439,6 @@ export default function ManualRegistrationPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="border-t pt-3 mb-3">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<input id="registerAsGuest" type="checkbox" checked={registerAsGuest} onChange={e => setRegisterAsGuest(e.target.checked)} />
|
|
||||||
<label htmlFor="registerAsGuest" className="text-sm text-gray-700">Guest (do not link to an existing account)</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<input
|
<input
|
||||||
className="border rounded px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-brand-400"
|
className="border rounded px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-brand-400"
|
||||||
@@ -458,11 +448,10 @@ export default function ManualRegistrationPage() {
|
|||||||
/>
|
/>
|
||||||
<input
|
<input
|
||||||
className="border rounded px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-brand-400"
|
className="border rounded px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-brand-400"
|
||||||
placeholder={registerAsGuest ? "Email (optional for guest)" : "Email"}
|
placeholder="Email (or provide a phone number below)"
|
||||||
type="email"
|
type="email"
|
||||||
value={guest.email}
|
value={guest.email}
|
||||||
onChange={e => setGuest({ ...guest, email: e.target.value })}
|
onChange={e => setGuest({ ...guest, email: e.target.value })}
|
||||||
required={!registerAsGuest}
|
|
||||||
/>
|
/>
|
||||||
<input
|
<input
|
||||||
className="border rounded px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-brand-400"
|
className="border rounded px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-brand-400"
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Share2, QrCode } from "lucide-react";
|
import { Share2, QrCode, CalendarPlus } from "lucide-react";
|
||||||
import QRCode from "qrcode";
|
import QRCode from "qrcode";
|
||||||
|
import { API_BASE } from "@/lib/api";
|
||||||
|
|
||||||
export default function ClientActions({ event }: { event: any }) {
|
export default function ClientActions({ event }: { event: any }) {
|
||||||
return (
|
return (
|
||||||
@@ -43,6 +44,14 @@ export default function ClientActions({ event }: { event: any }) {
|
|||||||
<QrCode className="w-4 h-4" />
|
<QrCode className="w-4 h-4" />
|
||||||
Save QR
|
Save QR
|
||||||
</button>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
|
import { Metadata } from "next";
|
||||||
import { notFound } from "next/navigation";
|
import { notFound } from "next/navigation";
|
||||||
import { Navbar } from "@/components/layout/Navbar";
|
import { Navbar } from "@/components/layout/Navbar";
|
||||||
import { Footer } from "@/components/layout/Footer";
|
import { Footer } from "@/components/layout/Footer";
|
||||||
import ClientActions from "@/app/events/[id]/ClientActions";
|
import ClientActions from "@/app/events/[id]/ClientActions";
|
||||||
import { ContactButton } from "@/components/events/ContactButton";
|
import { ContactButton } from "@/components/events/ContactButton";
|
||||||
|
import { LocationMap } from "@/components/events/LocationMap";
|
||||||
import { Calendar, Ticket, Paperclip, Sparkles } from "lucide-react";
|
import { Calendar, Ticket, Paperclip, Sparkles } from "lucide-react";
|
||||||
|
|
||||||
export const revalidate = 60;
|
export const revalidate = 60;
|
||||||
@@ -37,9 +39,10 @@ type Event = {
|
|||||||
contactName?: string | null;
|
contactName?: string | null;
|
||||||
contactPhone?: string | null;
|
contactPhone?: string | null;
|
||||||
contactEmail?: string | null;
|
contactEmail?: string | null;
|
||||||
|
location?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
import { apiFetch, ApiError } from "@/lib/api";
|
import { apiFetch, ApiError, resolveToApiOrigin } from "@/lib/api";
|
||||||
import { ApiImage } from "@/components/shared/ApiImage";
|
import { ApiImage } from "@/components/shared/ApiImage";
|
||||||
import { formatDateTimeRange } from "@/lib/date";
|
import { formatDateTimeRange } from "@/lib/date";
|
||||||
|
|
||||||
@@ -103,17 +106,49 @@ function RegisterCta({ event }: { event: Event }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async function EventDetailPage({ params }: { params: Promise<{ id: string }> }) {
|
// Next dedupes an identical fetch (same URL + cache options) made during the same
|
||||||
const { id } = await params;
|
// request, so calling this again from the page component below is free.
|
||||||
let event: Event;
|
async function loadEvent(id: string): Promise<Event | null> {
|
||||||
try {
|
try {
|
||||||
event = await apiFetch<Event>(`/api/events/${id}`, { nextOptions: { next: { revalidate } } });
|
return await apiFetch<Event>(`/api/events/${id}`, { nextOptions: { next: { revalidate } } });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// The event endpoint 404s for missing, inactive, or not-yet-live events —
|
if (e instanceof ApiError && e.status === 404) return null;
|
||||||
// render the standard not-found page instead of crashing.
|
|
||||||
if (e instanceof ApiError && e.status === 404) notFound();
|
|
||||||
throw e;
|
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 (
|
return (
|
||||||
<div className="min-h-screen flex flex-col">
|
<div className="min-h-screen flex flex-col">
|
||||||
@@ -142,6 +177,13 @@ export default async function EventDetailPage({ params }: { params: Promise<{ id
|
|||||||
|
|
||||||
<p className="text-gray-700 whitespace-pre-line">{event.description}</p>
|
<p className="text-gray-700 whitespace-pre-line">{event.description}</p>
|
||||||
|
|
||||||
|
{event.location && (
|
||||||
|
<div className="border rounded-xl p-5 bg-white shadow-sm">
|
||||||
|
<h2 className="text-base font-semibold text-gray-900 mb-3">Location</h2>
|
||||||
|
<LocationMap address={event.location} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{event.attachments && event.attachments.length > 0 && (
|
{event.attachments && event.attachments.length > 0 && (
|
||||||
<div className="border rounded-xl p-5 bg-white shadow-sm">
|
<div className="border rounded-xl p-5 bg-white shadow-sm">
|
||||||
<div className="flex items-center gap-2 mb-3">
|
<div className="flex items-center gap-2 mb-3">
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { Metadata } from "next";
|
||||||
import { Navbar } from "@/components/layout/Navbar";
|
import { Navbar } from "@/components/layout/Navbar";
|
||||||
import { Footer } from "@/components/layout/Footer";
|
import { Footer } from "@/components/layout/Footer";
|
||||||
import { EventCard } from "@/components/events/EventCard";
|
import { EventCard } from "@/components/events/EventCard";
|
||||||
@@ -6,6 +7,11 @@ import { Calendar, CalendarX } from "lucide-react";
|
|||||||
|
|
||||||
export const revalidate = 60;
|
export const revalidate = 60;
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "All Events",
|
||||||
|
description: "Browse and register for upcoming events.",
|
||||||
|
};
|
||||||
|
|
||||||
type Event = {
|
type Event = {
|
||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { SetupGuard } from "@/components/shared/SetupGuard";
|
|||||||
import HelpFab from "@/components/shared/HelpFab";
|
import HelpFab from "@/components/shared/HelpFab";
|
||||||
import { API_BASE, resolveToApiOrigin } from "@/lib/api";
|
import { API_BASE, resolveToApiOrigin } from "@/lib/api";
|
||||||
import { buildThemeCssVars } from "@/lib/colorScale";
|
import { buildThemeCssVars } from "@/lib/colorScale";
|
||||||
|
import { appUrl } from "@/lib/siteConfig";
|
||||||
|
|
||||||
const geistSans = Geist({
|
const geistSans = Geist({
|
||||||
variable: "--font-geist-sans",
|
variable: "--font-geist-sans",
|
||||||
@@ -44,13 +45,30 @@ async function getServerSettings(): Promise<SiteSettings> {
|
|||||||
export async function generateMetadata(): Promise<Metadata> {
|
export async function generateMetadata(): Promise<Metadata> {
|
||||||
const settings = await getServerSettings();
|
const settings = await getServerSettings();
|
||||||
const faviconUrl = settings.favicon_url ? resolveToApiOrigin(settings.favicon_url) : null;
|
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 {
|
return {
|
||||||
title: appName,
|
metadataBase: new URL(appUrl),
|
||||||
description: `Manage and register for events with ${appName}`,
|
title: { default: displayName, template: `%s | ${displayName}` },
|
||||||
|
description,
|
||||||
icons: {
|
icons: {
|
||||||
icon: faviconUrl || "/favicon.ico",
|
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] } : {}),
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { Navbar } from "@/components/layout/Navbar";
|
|||||||
import { Footer } from "@/components/layout/Footer";
|
import { Footer } from "@/components/layout/Footer";
|
||||||
import { JoinUsButton } from "@/components/home/JoinUsButton";
|
import { JoinUsButton } from "@/components/home/JoinUsButton";
|
||||||
import { appName } from "@/lib/siteConfig";
|
import { appName } from "@/lib/siteConfig";
|
||||||
|
import { API_BASE } from "@/lib/api";
|
||||||
import { Calendar, ArrowRight, CalendarCheck, Users, Heart, ShieldCheck } from "lucide-react";
|
import { Calendar, ArrowRight, CalendarCheck, Users, Heart, ShieldCheck } from "lucide-react";
|
||||||
|
|
||||||
type Event = {
|
type Event = {
|
||||||
@@ -26,8 +27,22 @@ const FEATURES = [
|
|||||||
{ icon: Heart, title: "Make an Impact", description: "Be part of what God is doing and make a difference together." },
|
{ icon: Heart, title: "Make an Impact", description: "Be part of what God is doing and make a difference together." },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
async function getOrgName(): Promise<string> {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API_BASE}/api/settings`, { next: { revalidate: 60 } });
|
||||||
|
if (!res.ok) return appName;
|
||||||
|
const settings = await res.json();
|
||||||
|
return settings?.org_name || appName;
|
||||||
|
} catch {
|
||||||
|
return appName;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export default async function HomePage() {
|
export default async function HomePage() {
|
||||||
const events = await apiFetch<Event[]>("/api/events", { nextOptions: { next: { revalidate: 60 } } });
|
const [events, displayName] = await Promise.all([
|
||||||
|
apiFetch<Event[]>("/api/events", { nextOptions: { next: { revalidate: 60 } } }),
|
||||||
|
getOrgName(),
|
||||||
|
]);
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const upcoming = (events || []).filter(e => {
|
const upcoming = (events || []).filter(e => {
|
||||||
const t = new Date(e.startDate).getTime();
|
const t = new Date(e.startDate).getTime();
|
||||||
@@ -52,7 +67,7 @@ export default async function HomePage() {
|
|||||||
{sorted.length} upcoming event{sorted.length === 1 ? "" : "s"}
|
{sorted.length} upcoming event{sorted.length === 1 ? "" : "s"}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<h1 className="text-4xl sm:text-5xl font-bold mb-4 text-gray-900">Welcome to {appName}</h1>
|
<h1 className="text-4xl sm:text-5xl font-bold mb-4 text-gray-900">Welcome to {displayName}</h1>
|
||||||
<p className="text-gray-600 text-lg mb-8">Experience unforgettable moments. Powered by purpose.</p>
|
<p className="text-gray-600 text-lg mb-8">Experience unforgettable moments. Powered by purpose.</p>
|
||||||
<div className="flex flex-wrap items-center justify-center gap-3">
|
<div className="flex flex-wrap items-center justify-center gap-3">
|
||||||
<a href="/events" className="inline-flex items-center gap-2 px-6 py-2.5 bg-brand-600 text-white rounded-xl hover:bg-brand-700 shadow-sm font-medium transition-colors">
|
<a href="/events" className="inline-flex items-center gap-2 px-6 py-2.5 bg-brand-600 text-white rounded-xl hover:bg-brand-700 shadow-sm font-medium transition-colors">
|
||||||
|
|||||||
@@ -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`,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -386,7 +386,9 @@ export default function SelfServicePage() {
|
|||||||
...(visitorEmail.trim() ? { email: visitorEmail.trim() } : {}),
|
...(visitorEmail.trim() ? { email: visitorEmail.trim() } : {}),
|
||||||
...(visitorPhone.trim() ? { phoneNumber: visitorPhone.trim() } : {}),
|
...(visitorPhone.trim() ? { phoneNumber: visitorPhone.trim() } : {}),
|
||||||
},
|
},
|
||||||
guestOnly: !createAccount,
|
// If the visitor declined to create an account, don't push an unsolicited
|
||||||
|
// activation email/WhatsApp at them afterward.
|
||||||
|
skipActivationNotice: !createAccount,
|
||||||
notificationPreference: notificationPref,
|
notificationPreference: notificationPref,
|
||||||
};
|
};
|
||||||
if (createAccount && visitorPassword) {
|
if (createAccount && visitorPassword) {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { ApiImage } from "@/components/shared/ApiImage";
|
import { ApiImage } from "@/components/shared/ApiImage";
|
||||||
import { Calendar } from "lucide-react";
|
import { Calendar, MapPin } from "lucide-react";
|
||||||
import { ContactButton } from "@/components/events/ContactButton";
|
import { ContactButton } from "@/components/events/ContactButton";
|
||||||
|
|
||||||
type Event = {
|
type Event = {
|
||||||
@@ -17,6 +17,7 @@ import { ContactButton } from "@/components/events/ContactButton";
|
|||||||
contactName?: string | null;
|
contactName?: string | null;
|
||||||
contactPhone?: string | null;
|
contactPhone?: string | null;
|
||||||
contactEmail?: string | null;
|
contactEmail?: string | null;
|
||||||
|
location?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
import { formatDateTimeRange } from "@/lib/date";
|
import { formatDateTimeRange } from "@/lib/date";
|
||||||
@@ -40,6 +41,12 @@ export const EventCard = ({ event }: { event: Event }) => {
|
|||||||
<Calendar className="w-3.5 h-3.5 shrink-0" />
|
<Calendar className="w-3.5 h-3.5 shrink-0" />
|
||||||
{dateRange}
|
{dateRange}
|
||||||
</p>
|
</p>
|
||||||
|
{event.location && (
|
||||||
|
<p className="text-sm text-gray-500 flex items-center gap-1.5 mt-1">
|
||||||
|
<MapPin className="w-3.5 h-3.5 shrink-0" />
|
||||||
|
<span className="truncate">{event.location}</span>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
<p className="text-sm text-gray-600 mt-2 line-clamp-2">{event.description}</p>
|
<p className="text-sm text-gray-600 mt-2 line-clamp-2">{event.description}</p>
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { MapPin, ExternalLink } from "lucide-react";
|
||||||
|
import { mapsSearchUrl, mapsEmbedUrl } from "@/lib/maps";
|
||||||
|
|
||||||
|
export function LocationMap({ address, className }: { address?: string | null; className?: string }) {
|
||||||
|
if (!address) return null;
|
||||||
|
return (
|
||||||
|
<div className={className}>
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<p className="text-sm text-gray-700 flex items-start gap-1.5">
|
||||||
|
<MapPin className="w-4 h-4 shrink-0 mt-0.5 text-gray-400" />
|
||||||
|
<span>{address}</span>
|
||||||
|
</p>
|
||||||
|
<a
|
||||||
|
href={mapsSearchUrl(address)}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-xs text-brand-600 hover:underline flex items-center gap-1 shrink-0 whitespace-nowrap"
|
||||||
|
>
|
||||||
|
Directions <ExternalLink className="w-3 h-3" />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 rounded-lg overflow-hidden border">
|
||||||
|
<iframe
|
||||||
|
title={`Map showing ${address}`}
|
||||||
|
src={mapsEmbedUrl(address)}
|
||||||
|
className="w-full h-48 border-0"
|
||||||
|
loading="lazy"
|
||||||
|
referrerPolicy="no-referrer-when-downgrade"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -14,7 +14,7 @@ export const supervisorManualHelpContent: HelpContent = {
|
|||||||
icon: UserPlus,
|
icon: UserPlus,
|
||||||
content: (
|
content: (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<p>Pick an event, then either search for an existing user or enter guest details (name, email, phone) and tick "Guest" to skip linking an account. Choose ticket options and quantities — early-bird pricing is applied automatically — then create the registration.</p>
|
<p>Pick an event, then either search for an existing user or enter guest details (name, and at least one of email or phone). Choose ticket options and quantities — early-bird pricing is applied automatically — then create the registration. A new guest account is created inactive, and an activation link is sent immediately (by email if provided, otherwise WhatsApp) so they can set their own password.</p>
|
||||||
<p className="text-xs text-gray-500">After creating a registration, the Record Payment tab is pre-filled with it — switch tabs to take payment right away.</p>
|
<p className="text-xs text-gray-500">After creating a registration, the Record Payment tab is pre-filled with it — switch tabs to take payment right away.</p>
|
||||||
</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,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
/** Opens Google Maps with the address pre-filled — no API key required. */
|
||||||
|
export function mapsSearchUrl(address: string): string {
|
||||||
|
return `https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(address)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Embeddable Google Maps iframe src for the given address — no API key required. */
|
||||||
|
export function mapsEmbedUrl(address: string): string {
|
||||||
|
return `https://maps.google.com/maps?q=${encodeURIComponent(address)}&output=embed`;
|
||||||
|
}
|
||||||
Generated
+9
-915
File diff suppressed because it is too large
Load Diff
+1
-4
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "hope-events",
|
"name": "hope-events",
|
||||||
"version": "1.8.0",
|
"version": "1.10.1",
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev:backend": "cd backend && npm run dev",
|
"dev:backend": "cd backend && npm run dev",
|
||||||
@@ -17,8 +17,5 @@
|
|||||||
"description": "",
|
"description": "",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"concurrently": "^9.2.1"
|
"concurrently": "^9.2.1"
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"express-rate-limit": "^8.3.1"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user