Compare commits

..
Author SHA1 Message Date
joshuaandClaude Opus 5.5 95dc3998ea Bump to 1.13.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 11:43:26 +02:00
joshuaandClaude Opus 5.5 709ea38c10 Add coupon codes and staff pricing
Percentage, fixed-amount and comp codes, limited by event, ticket option
or variant, dates, use counts, minimum quantity, and people, roles or an
email domain. The discount is locked in on the registration (mirrored on
Registration.discountTotal so every total sees it), shows on invoices,
receipts, emails and the Yoco checkout, and is released on cancellation.
Use limits are enforced under an advisory lock.

Ticket options and variants can also carry a staff price that Staff and
above get automatically. Adds a Supervisor > Coupon codes page, code
fields on registration, My Events, manual registration, at the door and
the kiosk, a Discounts report, and audit log entries. Also fixes Yoco
line items and the free-registration check for lines spanning several
price tranches.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 11:43:10 +02:00
joshua 7ce125e36c Merge branch 'feat/required-2fa-staff-roles' 2026-10-19 11:00:51 +02:00
joshuaandClaude Opus 5.5 96bfeff205 Bump to 1.12.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 11:00:47 +02:00
joshuaandClaude Opus 5.5 c3012cb3f6 Require two-factor authentication for admins and supervisors
Admins and supervisors without 2FA are sent a code at their next login,
and verifying it turns 2FA on. They can change the channel but not turn
it off. Older sessions without 2FA are rejected with TWO_FACTOR_REQUIRED,
and promoting a user ends their sessions so they enrol at next login.
The requirement is skipped when no email or WhatsApp channel can deliver
a code, and TWO_FACTOR_REQUIRED_ROLES=false switches it off.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 11:00:12 +02:00
joshuaandClaude Opus 5.5 0306c4dcb1 Add FEATURES.md and refresh info.md
FEATURES.md is a repo-root overview of everything the platform does.
info.md, the overview for prospective churches, is brought up to date
with features added since it was written.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 10:06:06 +02:00
joshuaandClaude Opus 5.5 fc4e65b80a Bump to 1.11.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 14:49:34 +02:00
joshuaandClaude Opus 5.5 e52bf67015 Add optional two-factor authentication
Users can turn on 2FA from Profile & Security and receive a 6-digit
login code by email or WhatsApp, with a "trust this device for 30 days"
option. Admins can reset it for a locked-out user, and the self-service
kiosk handles the code step at setup.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 14:49:34 +02:00
joshuaandClaude Opus 5.5 e909f43fea Bump to 1.10.6
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 13:59:37 +02:00
joshuaandClaude Opus 5.5 889995e450 Fix frontend build hanging when the backend is unreachable
Server-side fetches now fail fast when no API base URL is configured,
and the home and events pages build with an empty list if /api/events
can't be reached during next build.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 13:59:30 +02:00
joshuaandClaude Opus 5.5 73fdce1879 Update dependencies to clear npm audit advisories
Run npm audit fix in root, backend and frontend, bump nodemailer to 10,
override shell-quote in the root package, and allow-list the dependency
install scripts npm was blocking.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 13:59:22 +02:00
joshuaandClaude Opus 5.5 7e661abd24 Bump to 1.10.5
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 14:46:36 +02:00
joshuaandClaude Opus 5.5 1f59916efa Show in-progress events on home and events pages
Filter by endDate instead of startDate so events stay listed until they finish.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 14:46:04 +02:00
joshuaandClaude Sonnet 5 e728a06b9a Bump to 1.10.4
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PAwumFeBwx6XSsQXp8Nfqt
2026-09-01 09:04:31 +02:00
joshuaandClaude Sonnet 5 1d9b87500d Fix "Add to calendar" 400 when FRONTEND_URL lacks a scheme
The ics library's url validator requires a scheme (https://...), but
production's FRONTEND_URL/APP_BASE_URL was configured without one, so
createEvent() rejected every request before generating a .ics file.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PAwumFeBwx6XSsQXp8Nfqt
2026-09-01 09:00:17 +02:00
joshua 97faff1c89 Merge pull request 'Log event creation to admin audit log, add back button, bump to 1.10.3' (#3) from fix/event-create-audit-log-and-back-button into main 2026-08-28 14:49:24 +02:00
joshuaandClaude Sonnet 5 5b2183677d Also log staff cancellations via PUT /api/registrations/:id
Registrations can be cancelled two ways: DELETE /:id (owner or admin,
already logged registration_cancelled) and PUT /:id (staff+, status
change endpoint) — the latter was silently unlogged. Since PUT /:id
is staff-only, any transition into 'cancelled' there is inherently a
staff-initiated cancellation, so it's now logged the same way.

Audited all six categories promised in the 1.10.0 changelog entry
(refunds, donation assign/unassign, manual registrations,
staff-initiated cancellations, event create/update/delete, settings
changes) against their actual logAdminAction call sites and route
wiring — this was the only other gap found.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CSWFWQsjTc9GyffPiXEDQT
2026-08-28 14:48:17 +02:00
joshuaandClaude Sonnet 5 af6dffe534 Log event creation to admin audit log, add back button, bump to 1.10.3
createEvent never called logAdminAction, even though 1.10.0 already
listed event_created as a filterable action on the audit-log page —
only updateEvent/deleteEvent actually logged. Event creation is now
logged the same way, at every return path including the legacy
pre-migration retry branches.

Also adds a "Back to dashboard" link to Admin -> Audit log, matching
the existing back-link pattern on the Cashup page.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CSWFWQsjTc9GyffPiXEDQT
2026-08-28 14:44:23 +02:00
joshua 798156efe6 Merge pull request 'Fix Sentry not instrumenting Express, bump version to 1.10.2' (#2) from fix/sentry-express-instrumentation-order into main 2026-08-28 13:22:54 +02:00
joshuaandClaude Sonnet 5 c79e0f2ce8 Fix Sentry not instrumenting Express, bump version to 1.10.2
express, cors, and @prisma/client were required at the top of
backend/src/index.js before Sentry.init() ran, so Sentry's
auto-instrumentation (which patches those modules via a require hook)
missed them — startup logged "[Sentry] express is not instrumented".
Sentry.init() now runs immediately after dotenv.config(), before any
of the libraries it instruments are required.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CSWFWQsjTc9GyffPiXEDQT
2026-08-28 13:21:19 +02:00
joshua 49b6ddc397 Merge pull request 'Fix upload path-traversal RCE vector, patch all known-vulnerable deps' (#1) from security/upload-path-traversal-and-dep-fixes into main 2026-08-28 12:44:21 +02:00
joshuaandClaude Sonnet 5 f3a2e812bf Bump version to 1.10.1
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CSWFWQsjTc9GyffPiXEDQT
2026-08-28 12:36:55 +02:00
joshuaandClaude Sonnet 5 032d3c032e Fix upload path-traversal RCE vector, patch all known-vulnerable deps
Path traversal (CWE-22/CWE-73): event-image, branding (logo/favicon),
and event-attachment uploads built the saved filename from the
client-supplied original filename with no sanitization, and multer's
diskStorage joins that straight into the destination path. A crafted
filename containing `../` sequences could write the uploaded file
anywhere the server process has write access — reachable by any
supervisor-level account, and briefly pre-auth via the branding
uploads during initial /setup. Filenames are now always server-
generated (random bytes + validated extension); the original name is
kept only as display metadata.

Dependencies: express-rate-limit was declared only at the repo root
despite being required directly by backend/src/index.js, so a plain
`cd backend && npm install` (per the deployment doc) would never
install it — moved it into backend/package.json. Bumped next off a
version affected by a critical unauthenticated RCE (React Flight
protocol) and switched it from an exact pin to a caret range so future
patches install automatically. Bumped multer/nodemailer/jsonwebtoken/
uuid to patched versions, with an override forcing the vulnerable
nested uuid inside exceljs and the vulnerable postcss bundled inside
next to the patched versions too. `npm audit` is now clean (0
vulnerabilities) across root, backend, and frontend.

Hardening: jwt.verify() now pins algorithms: ['HS256'] instead of
trusting the token header; /uploads now serves with a restrictive CSP
and X-Content-Type-Options: nosniff so an uploaded SVG containing
<script> can't execute if opened directly.

Verified: backend's Jest suite passes, the backend boots and serves
real requests on the bumped deps, and `next build` compiles/type-
checks cleanly on the bumped frontend deps.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CSWFWQsjTc9GyffPiXEDQT
2026-08-28 11:33:05 +02:00
joshuaandClaude Sonnet 5 5a416916c9 Bump version to 1.10.0
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-28 09:25:44 +02:00
joshuaandClaude Sonnet 5 44a9e0857c Ignore hope-events-deployment-setup.txt
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-28 09:22:24 +02:00
joshuaandClaude Sonnet 5 54b89d4f4b Add calendar export, SEO, error monitoring, backups, audit trail, and a starter test suite
Six site improvements picked from a "what could be better" review, plus a Jest
test suite covering the two areas with the trickiest money-handling history
in this project (early-bird pricing tranches, donation-leg accounting):

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

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

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-27 14:50:11 +02:00
joshua 98ac26bf70 Bump version to 1.9.5 2026-08-27 09:35:58 +02:00
joshua 7e235637c4 Merge branch 'fix/alias-route-bot-traffic-oom' into main 2026-08-27 09:35:33 +02:00
joshuaandClaude Sonnet 5 0e8d5f93c9 Reject bot-probe paths on the event alias route before hitting the DB
The public [redirectUrl] catch-all route (and its backend counterpart,
GET /api/events/by-alias/:redirectUrl) matched any unmatched top-level
path, so routine bot/scanner traffic (/wp-login.php, /.env, etc.) was
firing a live database query on every hit. That traffic pattern looks
like the cause of the P1017 "server has closed the connection" storms
and OOM crashes seen from v1.7 onward. Both now reject anything that
isn't a plausible alias (letters/numbers/hyphens/underscores) before
touching Prisma.

Also adds a max_memory_restart safety net to PM2 for both processes.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-27 09:33:23 +02:00
joshua a0ccce04a3 Run frontend PM2 process on port 3000 2026-08-26 14:38:29 +02:00
joshua fbb84b037c Add TRUST_PROXY env var for reverse-proxy deployments
Fixes express-rate-limit's ERR_ERL_UNEXPECTED_X_FORWARDED_FOR warning
and incorrect IP keying when nginx runs on a separate server in front
of the app.
2026-08-26 14:36:51 +02:00
joshua 2dfe8d32c4 Add PM2 ecosystem config for production deployment 2026-08-26 11:59:08 +02:00
joshua 305499ee91 Merge branch 'fix/unhandled-rejection-no-crash' into main
Stops the server from hard-crashing on unhandled promise rejections, and bumps version to 1.9.3.
2026-08-26 09:07:06 +02:00
joshua c2112bf707 Bump version to 1.9.3 2026-08-26 09:06:52 +02:00
joshua e63fb2cc27 Stop crashing the server on unhandled promise rejections
A single missed .catch() anywhere in the notification code (email/WhatsApp
sending) previously took down the whole process via process.exit(1). Log
the error instead and keep running.
2026-08-26 09:03:15 +02:00
joshuaandClaude Sonnet 5 d6da2c8227 Send activation link immediately for walk-in and manual registration accounts
Accounts created by staff on someone's behalf now get their activation
link (email or WhatsApp) sent right away, instead of only on a first
failed login attempt, matching what the Terms of Use already promised.
This also fixed a real account with a real email being silently
activated with a fixed, undisclosed password (Hope123).

Also fixes the self-service kiosk's "Create an account" password field,
which never actually took effect server-side, and removes the "Guest
(no account)" checkboxes that no longer had any backend effect once
every walk-in account started behaving the same way.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-24 13:29:38 +02:00
joshuaandClaude Sonnet 5 7eed7a01df Bump version to 1.9.2
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-22 21:24:07 +02:00
joshua a7be2baab2 Merge branch 'fix/contact-only-daily-summary' into main 2026-08-22 21:23:24 +02:00
99 changed files with 16285 additions and 3274 deletions
+2
View File
@@ -34,6 +34,8 @@ Thumbs.db
# misc scratch / generated files
temp/
backend/public/uploads/
backend/backups/
hope-events-deployment-setup.txt
# runtime data stores (mutated by the running app, not source)
backend/data/scheduled-emails.json
+137
View File
@@ -7,6 +7,143 @@ and this project follows [Semantic Versioning](https://semver.org/).
## [Unreleased]
## [1.13.0] - 2026-10-19
### Added
- Coupon codes. Supervisors create percentage, fixed-amount or free ("comp") codes under Supervisor → Coupon codes.
- Codes can be limited to particular events, ticket options or variants, a date window, total and per-person uses (one per person by default), a minimum number of tickets, and specific people, roles (e.g. Staff and above) or an email domain. Restricted codes need a logged-in account, so they can't be shared around.
- By default the better of the code and the early-bird price wins; a code can instead stack on top of early-bird pricing.
- People enter codes under "Have a code?" on the registration page and in My Events (for registrations not yet paid in full). Staff can apply them in Manual registration, At the door and the self-service kiosk.
- The discount is locked in when it's applied, the same way early-bird prices are. It appears as its own line on invoices, receipts, registration and payment emails, WhatsApp messages and the Yoco checkout.
- Editing a registration's items recalculates percentage and free codes; fixed-amount codes keep their amount. Cancelling a registration frees the use. A free code that covers everything marks the registration paid and sends the tickets.
- Use limits are enforced under a database lock, so the last available use can't be claimed twice.
- Staff price: a ticket option or variant can carry a staff price (event wizard → Items & pricing) that Staff and above pay automatically when it's lower than the normal price.
- New "Discounts (coupon codes)" report (Reports → Financial), showing uses and value given away per code and event. The Finance report's "What was sold" is now shown after discounts.
- Coupon activity in the admin audit log: codes created, edited and turned off, and codes applied or removed by staff.
- New endpoints: `POST /api/coupons/validate`, `GET|POST /api/coupons`, `GET|PUT /api/coupons/:id`, `GET /api/coupons/redemptions`, `POST|DELETE /api/registrations/:id/coupon`. `POST /api/registrations` and `/manual` accept `couponCode`.
- Registration responses now include `discountTotal` and `subtotalComputed`.
- Requires the `add_coupon_codes` database migration (`npm run prisma:deploy` in `backend`).
### Fixed
- Yoco checkout line items priced each item at its most recent price times the whole quantity. When a line spanned several price tranches (some tickets at early-bird, some at full price), the item lines didn't match the amount charged. They now list each price actually charged.
- The free-registration check on `POST /api/registrations` and `/manual` had the same fault: it used the latest price for the whole quantity instead of the real total.
## [1.12.0] - 2026-10-19
### Added
- Two-factor authentication is now required for Admin and Supervisor accounts. An admin or supervisor without it is sent a code at their next login (by email, or WhatsApp when SMTP isn't set up), and entering it turns two-factor on — the same code step as before, with a note explaining why. Trusted devices don't skip this first code. They can change whether codes come by email or WhatsApp from Profile & Security, but can no longer turn two-factor off (`POST /api/users/2fa/disable` returns 403 for them).
- Existing admin and supervisor sessions without two-factor are ended on their next request (`401` with `code: "TWO_FACTOR_REQUIRED"`), and the login page explains why. Promoting a user to supervisor or admin also ends their sessions if they don't have two-factor yet, so they enrol at their next login. An admin "Reset 2FA" for a supervisor/admin now means they set it up again at their next login.
- The requirement is skipped when no channel can deliver a code — no SMTP configured and no WhatsApp — so a fresh install (or the setup wizard, which also gets a short grace period) never locks the first admin out. `TWO_FACTOR_REQUIRED_ROLES=false` turns the requirement off as an emergency switch.
- `FEATURES.md`, a repo-root overview of everything the platform does — roles, public site, events, registration, payments, tickets, messaging, dashboards, cashup, reports, settings, security, background jobs, and a page map.
### Changed
- `info.md` (the overview for prospective churches) brought up to date with features added since it was written — donations, invoices and receipts, cashup and costs, profit reporting, audit trail, scheduled messaging, self-service kiosk, scan points, backups, branding, and privacy controls.
## [1.11.0] - 2026-10-08
### Added
- Optional two-factor authentication. Any user can turn it on from Profile & Security and choose to receive a 6-digit code by email or WhatsApp; the chosen channel is confirmed with a code before it takes effect. At login the code is asked for after the password, expires after 10 minutes and allows 5 attempts, with a "Resend code" option. If a WhatsApp code can't be sent, it falls back to the account's email address.
- "Trust this device for 30 days" on the code step, so a remembered device skips the code. Trusted devices are forgotten when the password is changed or reset, when sessions are signed out everywhere, and when two-factor is turned off.
- Admins can reset two-factor authentication for a user who can no longer receive codes ("Reset 2FA" on the Users page). Turning two-factor on or off shows in Account activity.
- The self-service kiosk asks supervisors with two-factor on for their code at setup. Changing the event now re-confirms the password against the existing kiosk session (`POST /api/users/verify-password`) instead of logging in again.
### Changed
- `POST /api/users/login` returns `{ twoFactorRequired, challengeId, method, destination }` instead of a token for accounts with two-factor on; the token is issued by the new `POST /api/users/login/2fa`. Accounts without two-factor are unaffected. Requires the `add_two_factor` database migration (`npm run prisma:deploy` in `backend`).
## [1.10.6] - 2026-10-08
### Fixed
- The frontend production build (`next build`) no longer hangs or fails when the backend isn't reachable. With no `NEXT_PUBLIC_API_URL`/`API_URL` set, the root layout fetched a relative `/api/settings` URL that never settled during prerender, so every page timed out after 60 seconds; server-side fetches now fail fast when no API base is configured. The home page and the Events page also no longer fail the build when `/api/events` can't be reached — they build with an empty list and fill in on the first revalidation. At runtime a failed fetch still errors, so the last good page keeps being served.
### Security
- Updated dependencies to clear `npm audit` advisories: `express`, `multer`, `proxy-addr`, `qs` and others in the backend, `next` (15.5.27) and `sharp` (0.35.5) in the frontend, and `nodemailer` from 9 to 10 (requires Node.js 20 or newer). The root package now overrides `shell-quote` to a patched release, since every `concurrently` version pins a vulnerable one. Backend production dependencies now report no known vulnerabilities; the remaining advisories are in development tooling (`nodemon`, `jest`, `tailwindcss` 3) and have no non-breaking fix.
- Dependency install scripts are now explicitly allow-listed via `allowScripts` in the backend and frontend `package.json` (Prisma, `@sentry/cli`, `unrs-resolver`), so npm no longer blocks them.
## [1.10.5] - 2026-10-01
### Fixed
- Events that have already started but not yet finished now show on the home page and the Events page. Both pages were hiding an event as soon as its start time passed; they now keep it listed until its end time, which matches what `GET /api/events` already returns.
## [1.10.4] - 2026-09-01
### Fixed
- "Add to calendar" (`GET /api/events/:id/ics`) always failed with a 400 in production: the `ics` library's `url` field requires a scheme (`https://...`), but `FRONTEND_URL`/`APP_BASE_URL` was configured without one, so `createEvent()` rejected it before generating any output. A missing scheme is now assumed to be `https://` instead of failing.
## [1.10.3] - 2026-08-28
### Fixed
- Creating an event was never recorded in the admin audit log — 1.10.0 added `event_created` as a logged action on the frontend's filter list, but the backend's `createEvent` never actually called `logAdminAction`, only `updateEvent`/`deleteEvent` did. Event creation is now logged the same way.
- Staff cancelling a registration via `PUT /api/registrations/:id` (the staff status-change endpoint, separate from the owner-facing `DELETE /:id` cancel route) wasn't logged at all — only the `DELETE` path logged `registration_cancelled`. Both paths now log it, verified against all six audit categories promised in 1.10.0 (refunds, donation assign/unassign, manual registrations, staff-initiated cancellations, event create/update/delete, settings changes) with no other gaps found.
- Added a "Back to dashboard" link to Admin → Audit log, matching the back-link pattern already used on the Cashup page.
## [1.10.2] - 2026-08-28
### Fixed
- Sentry wasn't instrumenting Express (`[Sentry] express is not instrumented` at startup): `express`, `cors`, and `@prisma/client` were required at the top of `backend/src/index.js` before `Sentry.init()` ran, but Sentry's auto-instrumentation patches those modules via a require hook that only works if `Sentry.init()` runs first. `Sentry.init()` now runs immediately after `dotenv.config()`, before any of the libraries it instruments are required.
## [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.
+440
View File
@@ -0,0 +1,440 @@
# Hope Events — Feature Overview
A complete map of what the platform does, grouped by who uses it. For setup and
deployment see [README.md](./README.md); for the API see
[backend/API_DOCUMENTATION.md](./backend/API_DOCUMENTATION.md); for setting
defaults see [SETTINGS.md](./SETTINGS.md); for history see
[CHANGELOG.md](./CHANGELOG.md).
**Stack:** Next.js (React, Tailwind, shadcn/ui) frontend · Express + Prisma +
PostgreSQL backend · Yoco for card payments · SMTP for email · WAWP for
WhatsApp · PM2 for process management · optional Sentry for error monitoring.
---
## Contents
1. [Roles](#roles)
2. [Public site](#public-site)
3. [Events](#events)
4. [Registration](#registration)
5. [Payments, donations & refunds](#payments-donations--refunds)
6. [Tickets & check-in](#tickets--check-in)
7. [Attendee forms](#attendee-forms)
8. [Messaging & notifications](#messaging--notifications)
9. [User dashboard (My Events)](#user-dashboard-my-events)
10. [Staff tools](#staff-tools)
11. [Supervisor tools](#supervisor-tools)
12. [Admin tools](#admin-tools)
13. [Cashup & event costs](#cashup--event-costs)
14. [Reports](#reports)
15. [Site settings & setup wizard](#site-settings--setup-wizard)
16. [Accounts & security](#accounts--security)
17. [Help system](#help-system)
18. [Background jobs](#background-jobs)
19. [Operations](#operations)
20. [Page map](#page-map)
---
## Roles
Four roles, each including everything the roles below it can do:
| Role | Can do |
|------|--------|
| **User** | Browse events, register, pay, donate, view tickets, fill in forms, manage their own profile |
| **Staff** | Scan tickets, view/print event tickets, view registrations and form responses |
| **Supervisor** | Create and manage events, record payments/refunds, assign donations, at-the-door desk, manual registration, bulk email/WhatsApp, reports, site banner, self-service kiosk |
| **Admin** | Manage users and roles, all registrations, event costs and cashup (close/reopen events), audit log, site settings, WhatsApp connection, backups |
---
## Public site
- **Home page** — hero section with the organisation's name and branding, upcoming
and in-progress events, and a "Join Us" button (becomes "My Dashboard" when
logged in).
- **Events list** (`/events`) — event cards with picture, dates, location,
price, and a Register / Sold Out / Contact us button.
- **Event detail** (`/events/[id]`) — description, ticket options with
variant and early-bird pricing, low-stock and sold-out badges, downloadable
attachments, embedded Google Map with "Directions" link, and an
**Add to calendar** (`.ics`) button.
- **Short links** — events can have an alias (e.g. `/camp-2025`) that redirects
to the event. Only plausible aliases hit the database, so bot probes are
rejected cheaply.
- **Contact page** (`/contact`) — organisation email, phone, address and map.
- **Legal pages** — Terms of Use and Privacy Policy, filled from Site Settings.
- **Site banner** — a site-wide announcement bar set by supervisors.
- **Lockdown rules** (`/lockdown-rules`) — static rules page for the youth
lockdown event.
- **SEO** — `sitemap.xml` (every public page and event), `robots.txt`, and
Open Graph/Twitter link-preview metadata.
- **Theming** — brand colours, logo and favicon come from Site Settings.
---
## Events
Created and edited by supervisors through a 6-step wizard: **Basic Details →
Items & Pricing → Sections → Form → Visibility → Attachments**.
- **Basic details** — title, description, start/end dates, registration
deadline, picture (upload or URL), location (defaults to the org's address),
contact person.
- **Contact-only events** — for things like baptisms: shown publicly with a
"Contact us" popup instead of registration, with no tickets.
- **Ticket options** — multiple options per event (e.g. Adult, Child, Dinner,
T-shirt), each with a price and optional **stock limit**. One option is marked
as the main ticket, which is what gets checked in at the door.
- **Variants** — sub-types of an option (sizes, colours, Adult/Child/VIP), each
with an optional price override and its own stock limit.
- **Early-bird tiers** — deadline- and/or stock-limited cheaper prices per
option or variant. Prices are locked in per purchase ("tranches"), so buying
more later doesn't re-price what was already bought.
- **Sections** — named scan points (e.g. "Main hall", "Dinner") limited to
certain ticket options, so a scanner at that point only accepts those tickets.
- **Visibility** — active/inactive, hidden (reachable only by link), scheduled
go-live date, require login vs. allow guest registration, or no registration
at all.
- **Attachments** — downloadable files (maps, schedules, waivers) shown on the
event page.
- **Notify recipients** — choose which staff get registration/payment alerts and
the daily summary for the event.
- **Sold-out handling** — an event is sold out when every stock-limited option
is exhausted. "X remaining" badges appear at a threshold that scales with
capacity.
---
## Registration
- **Online registration** (`/register/[eventId]`) — pick options, variants and
quantities; see early-bird prices and stock warnings; fill in the event form.
- **Guest registration** — when an event doesn't require login, an inactive
account is created and an activation link is sent by email or WhatsApp.
- **Statuses** — Pending, Confirmed, Partially Paid, Paid, Cancelled.
- **Edit options** — users can add or change items on their registration
(not for past or closed events).
- **Self-cancellation** — allowed only before any payment; admins can cancel
any time.
- **Pricing refresh** — when an unpaid registration is viewed or paid, expired
early-bird prices are re-evaluated, and the user is warned if the total
changed.
- **Manual registration** (supervisor) — register someone on their behalf,
creating an account if needed. Includes a "Record Payment" tab.
- **Duplicate-account protection** — if an email and phone belong to two
different accounts, the registration is blocked instead of merging them.
- **Coupon codes** — percentage, fixed-amount or free (comp) codes, entered under
"Have a code?" on the registration page, in My Events (for unpaid registrations),
manual registration, At the door and the kiosk. Codes can be limited to events,
ticket options or variants, dates, total and per-person uses, a minimum quantity,
and specific people, roles or an email domain (restricted codes need a login).
Early-bird prices either stack or the better price wins. The discount is locked in
when applied and shows on invoices, receipts, emails and the Yoco checkout;
cancelling frees the use. Managed from Supervisor → Coupon codes.
- **Staff price** — a ticket option or variant can have a staff price that Staff and
above get automatically, without a code.
---
## Payments, donations & refunds
- **Online card payments via Yoco** — checkout for the full outstanding balance,
confirmed by a signed webhook.
- **Payment links** — supervisors can generate and send a Yoco link for any
amount (including partial).
- **Manual payments** — cash, card, EFT, voucher or other, recorded by staff.
Each payment records **who recorded it** as well as who it's for.
- **Refunds** — negative payments against an original payment, netted against
the original method.
- **Donations** — users can donate from their dashboard (`/dashboard/user/donate`).
Supervisors can **assign** a donation (fully or partly) to someone's
registration and **unassign** it later. Assignments are kept as separate
records, so the original donation never changes.
- **Yoco reconciliation** — imported Yoco transactions can be matched to a
registration, recorded as a donation (optionally choosing the donor), or
ignored.
- **Invoices & receipts** — branded PDFs: an invoice (itemised, early-bird
discount, balance due, pay-now link and QR code) and a payment receipt. Sent
automatically and re-sendable on demand by email or WhatsApp.
- **Method normalisation** — Apple Pay / Google Pay / checkout payments count as
"Card" in reports and user views.
---
## Tickets & check-in
- **Automatic tickets** — QR-coded tickets are generated when a registration is
fully paid (or free), and sent as a PDF by email and/or WhatsApp.
- **Quantity tickets** — one ticket can cover several people and be redeemed
partially (e.g. check in 2 of 3).
- **QR scanner** — works in any mobile browser. A preview checks the ticket is
for the selected event/section, then a confirmation screen shows holder, type
and remaining quantity.
- **Check-in confirmation** — holders get an email/WhatsApp saying how many were
checked in and how many remain.
- **Scan stats** — recent scans and per-event scan counts.
- **Printing** — A4 sheets (8 tickets per page) and A6 single tickets.
- **Resend** — send tickets to a different email/phone at the door.
---
## Attendee forms
- **Form builder** per event — field types: Yes/No, Text, Paragraph, Number,
Date, plus Statement and Heading blocks; required/optional, help text,
reordering. The whole form can be marked required.
- **Drafts** — partially filled forms are auto-saved.
- **Responses** — view, filter (event, user, past/inactive) and print one
response per A4 page. Supervisors can edit responses.
---
## Messaging & notifications
Every user picks a **notification preference**: Email, WhatsApp, or Both.
**Automatic notifications**
| Trigger | Goes to |
|---------|---------|
| Registration created / updated | Registrant (with invoice if unpaid) + event notify recipients |
| Payment recorded / donation applied / unassigned | Registrant (with receipt) + admins |
| Refund processed | Registrant + admins |
| Tickets generated | Registrant (ticket PDF) |
| Ticket checked in | Ticket holder |
| Daily summary, 07:00, for each live event that hasn't started yet | Registrations inbox + event notify recipients |
| Account registered, activation, login, password changed/reset, account closed | The account holder |
**Bulk messaging** (supervisor)
- **Email attendees** and **WhatsApp attendees** — filter by status or pick
specific attendees, preview, send now or schedule for later.
- **Placeholders** — `{{name}}`, `{{event.title}}`, `{{event.link}}`,
`{{event.start}}`, `{{balance}}`, and `{{payment.link}}` (a live Yoco link for each recipient's
outstanding balance).
- **Broadcasts** — ad-hoc email/WhatsApp to any users or addresses, not tied to
one event.
- **Automations** — schedule a sequence of lifecycle emails for an event
(pre-event, final reminder, thank-you, promo for another event).
- **Manage scheduled** — view, edit or delete queued jobs per channel. Sends
run in the background and are purged 24 h after sending.
**WhatsApp** is connected through the WAWP API: an admin links a number in
Site Settings → WhatsApp (QR code or pairing code), with status, restart and
logout controls. Disconnected sessions are recovered automatically.
---
## User dashboard (My Events)
- Registrations with status badges, event thumbnails, and Closed/Past/Inactive
tags. Past and closed events are hidden by default.
- Registration detail: itemised bill (early-bird lines grouped), payments,
**Invoice** and **Receipt** buttons, edit, cancel, **Make payment**.
- Upcoming events and tickets side by side, with bulk ticket email/WhatsApp.
- **Payment history** (`/dashboard/user/payments`) — paginated, with filters by
date, method and payment/refund.
- **Donate** page.
- **Forms** — complete outstanding attendee forms.
- **Profile & Security** — edit details and notification preference, change
password, see recent account activity (logins, password changes, device),
sign out everywhere, deactivate or delete my data.
---
## Staff tools
- **Staff dashboard** — scan stats and quick actions.
- **Ticket scanning** (`/dashboard/staff/ticket-scanning`) — camera QR scanner
with event and section filters.
- **Event tickets & printing** (`/dashboard/staff/event-tickets`) — list, search
and print an event's tickets.
---
## Supervisor tools
- **Dashboard** — KPIs (active events, revenue, donations, registrations,
tickets sold, each vs. the previous month), revenue trend chart, top events,
and recent scans. Tiles link to the matching report.
- **Manage events** — the 6-step event wizard, ticket options, variants,
sections, form and attachments.
- **At the door** — search by name/phone/ticket ID, walk-in registration,
payments, a **Check-In** tab for redeeming main tickets by quantity, and
ticket printing/resending.
- **Self-service kiosk** (`/self-service`) — a touchscreen page a supervisor
unlocks for an event, where visitors register themselves. Existing accounts
are matched by email/phone, and any change to their details needs
confirmation.
- **Manual registration** — register someone on their behalf and record their
payment.
- **Payments** — tabs for Payment, Refund, Donations (assign/unassign),
Reconcile (Yoco transactions), and Payment Link.
- **Coupon codes** (`/dashboard/supervisor/coupons`) — create, edit and turn off
discount codes, and see who used each one.
- **Email attendees / WhatsApp attendees** — bulk and scheduled messaging.
- **Forms** — view, filter, edit and print form responses.
- **Reports** — see [Reports](#reports).
- **Site banner** (`/set-banner`) — set the announcement bar.
---
## Admin tools
- **Admin dashboard** — same KPIs as supervisor, plus admin quick actions.
- **Manage users** — server-side search and filters (role, active), paging,
edit role and notification preference, revoke sessions, delete or
**anonymise** personal data.
- **Registrations** — all registrations, with filters (event, status, text),
status counts, revenue and outstanding totals, and per-registration options,
form answers and payments (with who recorded each one).
- **Cashup** — see below.
- **Audit log** (`/dashboard/admin/audit-log`) — who did what and when for
refunds, donation assign/unassign, manual registrations, staff
cancellations, event create/update/delete, settings changes, and coupon codes
(created, edited, turned off, applied or removed by staff).
- **Site settings** — see below.
---
## Cashup & event costs
- **Event costs** — once-off or per-item costs (optionally linked to a ticket
option), with a "paid from takings" method so cash on hand is correct.
- **Expected vs. actual** — expected takings per method (cash/card/EFT/other)
compared with actual amounts, with variance shown.
- **Cash denomination counts** — entered per staff member, at any time. The
event's actual cash is the sum of these counts.
- **Payment accountability by staff member** — what each person recorded, by
method, with live cash variance.
- **Close / quick close / reopen** an event — closing locks it from further
edits and hides it from the kiosk and user actions. Drafts are saved as you
go.
- **Report tab** — a read-only cashup summary, shown automatically once the
event is closed.
- **Cashup audit trail** — every close/reopen with totals and who did it.
---
## Reports
A full-width workspace with shared filters (events, date range with presets,
include past/inactive/closed) and a searchable grid of reports, each opening in
a viewer with its own filters, stat tiles and bar charts.
| Category | Reports |
|----------|---------|
| Financial | Payments between dates · Revenue summary (by method) · Revenue detailed · Finance report (revenue & costs, after discounts) · Profit report · Discounts (coupon codes) |
| Registration | Attendees per event (grouped by option) · Registration type counts · Master orders breakdown · Registration status breakdown (count by registration or ticket quantity) |
| Ticketing | Ticket usage summary |
| Donations | Donations breakdown (used / unused per event) |
| Cashup | Cashup reconciliation · Cashup audit trail |
**Export:** Print, branded PDF download, styled Excel (`.xlsx`) workbook,
Email (to yourself), or WhatsApp (the PDF to your own number). Dashboard tiles
deep-link into reports with `?report=&range=`.
---
## Site settings & setup wizard
**Setup wizard** (`/setup`) — on a fresh database, walks through organisation
details, the first admin account and branding. No manual seeding needed.
**Site Settings** (`/dashboard/admin/settings`) — stored in the database and
applied without a restart. Secrets are encrypted with AES-256-GCM.
| Tab | Contains |
|-----|----------|
| Organisation | Name, tagline, contact email/phone, address (with map link) |
| Branding | Primary/secondary/accent colours, logo, favicon, colours suggested from the logo, live website/email preview on desktop and mobile |
| Notifications | Registrations inbox and admin notification emails |
| Email | SMTP host/port/TLS/user/password/from, with a "Test connection" button |
| Legal | Operator, Information Officer, website, effective date for the Terms/Privacy pages |
| WhatsApp | WAWP credentials, connect a number, status/restart/logout |
| Backups | List, run now, download database backups |
See [SETTINGS.md](./SETTINGS.md) for the default of every setting.
---
## Accounts & security
- Register, log in, forgot/reset password, activate account (for accounts
created on someone's behalf).
- JWT authentication (HS256 pinned) with **session revocation** (token
versioning) — users can sign out everywhere, and admins can do it for any user.
- **Two-factor authentication** — a 6-digit login code by email or WhatsApp
(10-minute expiry, 5 attempts, resend), with "Trust this device for 30 days".
**Required for Admins and Supervisors**: their next login sends a code that turns
it on, and they can change the channel but not turn it off (skipped only when no
email or WhatsApp channel can deliver a code; `TWO_FACTOR_REQUIRED_ROLES=false`
is the emergency switch). Optional for everyone else from Profile & Security.
Admins can reset it for a locked-out user, and the kiosk asks for the code at
setup.
- **Login lockout** after repeated failed attempts, plus login rate limiting
(`TRUST_PROXY` to read real IPs behind a proxy).
- **Security event log** — logins, password changes and resets, with device
and IP.
- **Account closure** — deactivate, or anonymise personal data while keeping
financial records.
- **Upload hardening** — server-generated filenames, and uploads served with a
sandbox CSP and `nosniff`.
---
## Help system
A floating **"Need help?"** button on every page opens a page-specific guide
(tabs, quick links). Every page has dedicated content (`frontend/src/content/help/`),
falling back to a general guide.
---
## Background jobs
| Job | Schedule |
|-----|----------|
| Scheduled email/WhatsApp worker | Polls every 30 s (`SCHEDULED_EMAILS_INTERVAL_MS`) and purges sent jobs after 24 h |
| Daily event summary email | 07:00 daily |
| Database backup (`pg_dump`, gzipped, 14-day retention) | 02:00 daily (`BACKUP_ENABLED=false` to disable) |
| Temp file cleanup (files older than 7 days in `/temp`) | Daily |
---
## Operations
- **PM2** (`ecosystem.config.js`) runs backend and frontend, restarting either
one above 500 MB of memory.
- **Sentry** error monitoring when `SENTRY_DSN` / `NEXT_PUBLIC_SENTRY_DSN` are
set.
- Unhandled promise rejections are logged and don't crash the server.
- Backend status page (`/`) and built-in API docs page (`/docs`).
- Jest tests for early-bird tranche pricing and donation balances.
- Versioning via git tags and [CHANGELOG.md](./CHANGELOG.md).
---
## Page map
| Path | Who | What |
|------|-----|------|
| `/` | Public | Home |
| `/events`, `/events/[id]` | Public | Event list and detail |
| `/[alias]` | Public | Event short link |
| `/register/[eventId]` | Public / User | Registration |
| `/registration/success` | Public / User | Confirmation, form and pay |
| `/payment/success`, `/payment/failure`, `/payment/cancel` | Public | Yoco return pages |
| `/forms` | Public / User | Attendee forms |
| `/contact`, `/legal/terms`, `/legal/privacy`, `/lockdown-rules` | Public | Info pages |
| `/login`, `/register`, `/forgot-password`, `/reset-password`, `/activate-account` | Public | Accounts |
| `/setup` | First run | Setup wizard |
| `/self-service` | Supervisor-unlocked | Kiosk |
| `/set-banner` | Supervisor | Site banner |
| `/dashboard/user` (+ `/payments`, `/donate`, `/forms`, `/profile`, `/reset-password`) | User | My Events |
| `/dashboard/staff` (+ `/ticket-scanning`, `/event-tickets`) | Staff | Staff tools |
| `/dashboard/supervisor` (+ `/events`, `/event-options`, `/at-the-door`, `/manual`, `/manual-registration`, `/payments`, `/coupons`, `/email-attendees`, `/whatsapp-attendees`, `/forms`, `/reports`) | Supervisor | Supervisor tools |
| `/dashboard/admin` (+ `/users`, `/registrations`, `/forms`, `/cashup`, `/cashup/[id]`, `/audit-log`, `/settings`) | Admin | Admin tools |
+1
View File
@@ -112,6 +112,7 @@ Documentation: see Frontend README for environment and scripts.
- Backend README: ./backend/README.md
- Frontend README: ./frontend/README.md
- Site Settings — defaults reference: ./SETTINGS.md
- Feature overview: ./FEATURES.md
## Recent Changes
+6
View File
@@ -74,6 +74,12 @@ or Branding tab has been saved even once — the wizard always writes
|---|---|---|
| `WAWP_ACCESS_TOKEN` / `WAWP_INSTANCE_ID` | `WAWP_ACCESS_TOKEN`/`WAWP_INSTANCE_ID` env vars, else *(blank)* | No hardcoded default — WhatsApp sending is simply unavailable until both are configured (Admin → Site Settings → WhatsApp). |
## Backups
| Setting | Default when unset | Notes |
|---|---|---|
| `backup_retain_count` | `14` | How many nightly backups to keep on disk (oldest deleted beyond this count). Editable at Admin → Site Settings → Backups. See `BACKUP_ENABLED` (env var) to turn the nightly job off entirely. |
## Setup state
| Setting | Default when unset | Notes |
+20
View File
@@ -4,10 +4,22 @@ DATABASE_URL="postgresql://username:password@localhost:5432/hope_events"
# ─── Auth ─────────────────────────────────────────────────────────────────────
JWT_SECRET=your_jwt_secret_here_minimum_32_characters
# Two-factor authentication is required for admins and supervisors whenever
# email (SMTP) or WhatsApp can deliver a code. Set to false only as an emergency
# switch, e.g. if the mail server is down and admins are locked out.
# TWO_FACTOR_REQUIRED_ROLES=true
# ─── Server ───────────────────────────────────────────────────────────────────
PORT=5000
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 ─────────────────────────────────────────────────────────────────────
# Comma-separated list of allowed frontend origins
FRONTEND_URL=http://localhost:3000
@@ -40,6 +52,14 @@ WAWP_INSTANCE_ID=your_wawp_instance_id
DAILY_SUMMARY_ENABLED=true
SCHEDULED_EMAILS_ENABLED=true
SCHEDULED_EMAILS_INTERVAL_MS=30000
# Nightly database backup at 02:00 (requires the `pg_dump` binary on this host).
# Retention count is set via Admin → Site Settings → Backups (default 14).
BACKUP_ENABLED=true
# ─── Error monitoring (Sentry) — optional ─────────────────────────────────────
# Leave unset to disable entirely (a no-op, not an error). Set NEXT_PUBLIC_SENTRY_DSN
# in frontend/.env too if you want frontend errors captured.
# SENTRY_DSN=https://xxxxx@oxxxxxx.ingest.sentry.io/xxxxx
# ─── Note ─────────────────────────────────────────────────────────────────────
# The following are managed via Admin → Site Settings and stored in the database:
+59 -4
View File
@@ -18,6 +18,7 @@ Tokens are issued by `POST /api/users/login`.
- [Event Options & Variants](#event-options--variants)
- [Event Attachments](#event-attachments)
- [Registrations](#registrations)
- [Coupons](#coupons)
- [Payments](#payments)
- [Tickets](#tickets)
- [Broadcasts (Email)](#broadcasts-email)
@@ -43,7 +44,9 @@ Tokens are issued by `POST /api/users/login`.
| Method | Path | Auth | Description |
|--------|------|------|-------------|
| POST | `/api/users` | public | Register new account |
| POST | `/api/users/login` | public | Login, returns JWT |
| POST | `/api/users/login` | public | Login, returns JWT — or a two-factor challenge when the account has 2FA on |
| POST | `/api/users/login/2fa` | public | Exchange a two-factor code for the JWT |
| POST | `/api/users/login/2fa/resend` | public | Send a new code for a pending login challenge (60s cooldown) |
| POST | `/api/users/forgot` | public | Request password reset email |
| POST | `/api/users/reset` | public | Reset password with token |
| POST | `/api/users/activate` | public | Activate account from email link |
@@ -51,9 +54,13 @@ Tokens are issued by `POST /api/users/login`.
| PUT | `/api/users/profile` | user+ | Update own profile |
| POST | `/api/users/revoke-sessions` | user+ | Revoke own sessions |
| POST | `/api/users/close-account` | user+ | Deactivate own account |
| POST | `/api/users/2fa/enable` | user+ | Start turning on 2FA — sends a code to the chosen channel |
| POST | `/api/users/2fa/confirm` | user+ | Finish turning on 2FA with that code |
| POST | `/api/users/2fa/disable` | user+ | Turn off 2FA (requires password) |
| POST | `/api/users/verify-password` | user+ | Re-confirm own password without starting a new session |
| GET | `/api/users` | supervisor+ | List users — supports `?search=`, `?role=`, `?isActive=true\|false`, `?page=`, `?limit=` |
| GET | `/api/users/:id` | admin | Get user by ID |
| PUT | `/api/users/:id` | admin | Update user (name, email, role, isActive, phoneNumber) |
| PUT | `/api/users/:id` | admin | Update user (name, email, role, isActive, phoneNumber). `twoFactorEnabled: false` resets a user's 2FA; it cannot be switched on this way |
| DELETE | `/api/users/:id` | admin | Deactivate user (`isActive: false`) |
| POST | `/api/users/:id/revoke-sessions` | admin | Revoke all sessions for a user |
| POST | `/api/users/:id/anonymize` | admin | Erase personal data — sets name to "Deleted User", clears email/phone, deactivates account |
@@ -71,10 +78,32 @@ Returns: `{ _id, name, email, role, token }`
### POST /api/users/login
```json
{ "email": "jane@example.com", "password": "securepass123" }
{ "email": "jane@example.com", "password": "securepass123", "deviceToken": "optional-trusted-device-token" }
```
Returns: `{ _id, name, email, role, token }`
If the account has two-factor authentication on and `deviceToken` is missing, expired or not recognised, no token is issued. A 6-digit code is sent by email or WhatsApp and the response is instead:
```json
{ "twoFactorRequired": true, "challengeId": "uuid", "method": "email", "destination": "j•••@example.com" }
```
**Required for admins and supervisors.** An admin or supervisor without 2FA gets the same challenge with `"enrolling": true` (trusted devices don't skip it), sent to their email when SMTP is configured, otherwise WhatsApp. Verifying it turns 2FA on with that channel. If neither channel can deliver a code, the requirement is skipped so the account isn't locked out. `TWO_FACTOR_REQUIRED_ROLES=false` turns the requirement off.
### POST /api/users/login/2fa
```json
{ "challengeId": "uuid", "code": "123456", "rememberDevice": true }
```
Returns: `{ _id, name, email, role, token }`, plus `deviceToken` when `rememberDevice` is `true`. Send that `deviceToken` with later logins to skip the code for 30 days. Codes expire after 10 minutes and allow 5 attempts. Trusted devices are cleared when the password changes or sessions are revoked.
### Two-factor setup (`/api/users/2fa/*`)
- `POST /api/users/2fa/enable` — `{ "method": "email" | "whatsapp", "password": "..." }` sends a code to that channel and returns `{ challengeId, method, destination }`. WhatsApp needs a valid phone number on the account and a connected WhatsApp session.
- `POST /api/users/2fa/confirm` — `{ "challengeId": "uuid", "code": "123456" }` turns 2FA on.
- `POST /api/users/2fa/disable` — `{ "password": "..." }` turns it off. Returns 403 for admins and supervisors, who must keep it on (they can switch channel with `enable`/`confirm`).
An admin or supervisor session without 2FA (from before the requirement, or after a promotion or 2FA reset) is rejected by every protected route with `401 { "code": "TWO_FACTOR_REQUIRED" }`; logging in again enrols them. Promoting a user to supervisor/admin ends their sessions when they don't have 2FA yet.
At login, if a WhatsApp code can't be sent, the code falls back to the account's email address.
### PUT /api/users/profile
```json
{ "name": "Jane Doe", "phoneNumber": "+27829999999", "notificationPreference": "email" }
@@ -199,7 +228,9 @@ Accepted file types: `.pdf`, `.doc`, `.docx`, `.xls`, `.xlsx`, `.ppt`, `.pptx`,
| GET | `/api/registrations` | staff+ | All registrations |
| PUT | `/api/registrations/:id` | staff+ | Update registration status |
| GET | `/api/registrations/event/:eventId` | staff+ | Registrations for an event |
| POST | `/api/registrations/manual` | supervisor+ | Manual registration |
| POST | `/api/registrations/manual` | supervisor+ | Manual registration (accepts `couponCode`) |
| POST | `/api/registrations/:id/coupon` | user+ | Apply a code to an existing registration (owner, or staff on someone's behalf) |
| DELETE | `/api/registrations/:id/coupon` | staff+ | Remove the code (refused once a ticket on a paid registration has been scanned) |
### POST /api/registrations
```json
@@ -211,6 +242,10 @@ Accepted file types: `.pdf`, `.doc`, `.docx`, `.xls`, `.xlsx`, `.ppt`, `.pptx`,
]
}
```
Add `"couponCode": "STAFF50"` to apply a discount code. The code is checked before anything is saved (400 with the reason if it can't be used). If the last use is taken by someone else in the moment between checking and saving, the registration is still created at full price and the response includes `couponError`.
Registration responses include `discountTotal` (the coupon discount), `subtotalComputed` (items before the discount), `totalDueComputed` (after it) and `couponRedemptions` (the active code).
For unauthenticated (guest) registration on events with `requiresAuth: false`:
```json
{
@@ -233,6 +268,26 @@ For unauthenticated (guest) registration on events with `requiresAuth: false`:
---
## Coupons
| Method | Path | Auth | Description |
|--------|------|------|-------------|
| POST | `/api/coupons/validate` | optional | Check a code against a basket (`{ code, eventId, options, forUserId? }`) |
| GET | `/api/coupons` | supervisor+ | List codes with use counts and total discount |
| POST | `/api/coupons` | supervisor+ | Create a code |
| GET | `/api/coupons/:id` | supervisor+ | One code with its uses |
| PUT | `/api/coupons/:id` | supervisor+ | Edit or turn off a code |
| GET | `/api/coupons/redemptions` | supervisor+ | Uses for the Discounts report (`eventIds`, `from`, `to`) |
- **Types:** `percent` (value 1–100), `fixed` (rand amount), `comp` (free).
- **Scope:** `eventIds` (none = every event) and `options: [{ eventOptionId, variantId? }]` (none = the whole registration).
- **Restrictions:** `userIds`, `restrictRoles` and `restrictDomain`. A match on any one is enough, and restricted codes need a known account.
- **Limits:** `validFrom`, `validUntil`, `maxUses`, `maxUsesPerUser` (default 1) and `minQuantity`.
- **Early-bird:** `stackEarlyBird: false` means the better price wins; `true` discounts the early-bird price.
- **Locked amount:** the discount is locked when applied. Editing items recalculates percentage and comp codes, but fixed codes keep their amount. Cancelling releases the use.
Options and variants also accept an optional `staffPrice`, charged automatically to Staff and above when it's lower than the normal price.
## Payments
| Method | Path | Auth | Description |
+1
View File
@@ -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) |
| `BACKEND_URL` | — | Public backend URL — used to serve ticket PDFs over WhatsApp |
| `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` |
| `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) |
+4
View File
@@ -0,0 +1,4 @@
module.exports = {
testEnvironment: 'node',
testMatch: ['**/tests/**/*.test.js'],
};
+5363 -172
View File
File diff suppressed because it is too large Load Diff
+20 -6
View File
@@ -1,12 +1,12 @@
{
"name": "event-management-backend",
"version": "1.9.1",
"version": "1.13.0",
"description": "Event Management System Backend",
"main": "src/index.js",
"scripts": {
"start": "node src/index.js",
"dev": "nodemon src/index.js",
"test": "echo \"Error: no test specified\" && exit 1",
"test": "jest",
"postinstall": "prisma generate",
"prisma:generate": "prisma generate",
"prisma:deploy": "prisma migrate deploy && prisma generate",
@@ -17,23 +17,37 @@
},
"dependencies": {
"@prisma/client": "^5.4.2",
"@sentry/node": "^10.71.0",
"axios": "^1.11.0",
"bcryptjs": "^2.4.3",
"cors": "^2.8.5",
"dotenv": "^16.3.1",
"exceljs": "^4.4.0",
"express": "^4.18.2",
"jsonwebtoken": "^9.0.2",
"multer": "^2.0.2",
"express-rate-limit": "^8.6.2",
"ics": "^3.12.0",
"jsonwebtoken": "^9.0.3",
"multer": "^2.2.0",
"node-fetch": "^2.7.0",
"nodemailer": "^7.0.5",
"nodemailer": "^10.0.16",
"pdfkit": "^0.17.1",
"qrcode": "^1.5.4",
"raw-body": "^3.0.0",
"uuid": "^9.0.1"
"uuid": "^11.1.1"
},
"devDependencies": {
"jest": "^30.4.2",
"nodemon": "^3.0.1",
"prisma": "^5.4.2"
},
"overrides": {
"uuid": "^11.1.1"
},
"allowScripts": {
"prisma@5.22.0": true,
"@prisma/client@5.22.0": true,
"@prisma/engines@5.22.0": true,
"unrs-resolver@1.12.2": true,
"@parcel/watcher": false
}
}
@@ -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;
@@ -0,0 +1,62 @@
-- CreateEnum
CREATE TYPE "TwoFactorMethod" AS ENUM ('email', 'whatsapp');
-- AlterEnum
-- This migration adds more than one value to an enum.
-- With PostgreSQL versions 11 and earlier, this is not possible
-- in a single migration. This can be worked around by creating
-- multiple migrations, each migration adding only one value to
-- the enum.
ALTER TYPE "SecurityEventType" ADD VALUE 'two_factor_enabled';
ALTER TYPE "SecurityEventType" ADD VALUE 'two_factor_disabled';
-- AlterTable
ALTER TABLE "User" ADD COLUMN "twoFactorEnabled" BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN "twoFactorMethod" "TwoFactorMethod";
-- CreateTable
CREATE TABLE "TwoFactorChallenge" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"purpose" TEXT NOT NULL,
"method" "TwoFactorMethod" NOT NULL,
"codeHash" TEXT NOT NULL,
"attempts" INTEGER NOT NULL DEFAULT 0,
"lastSentAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"expiresAt" TIMESTAMP(3) NOT NULL,
"usedAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "TwoFactorChallenge_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "TrustedDevice" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"tokenHash" TEXT NOT NULL,
"device" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"lastUsedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"expiresAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "TrustedDevice_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "TwoFactorChallenge_userId_idx" ON "TwoFactorChallenge"("userId");
-- CreateIndex
CREATE UNIQUE INDEX "TrustedDevice_tokenHash_key" ON "TrustedDevice"("tokenHash");
-- CreateIndex
CREATE INDEX "TrustedDevice_userId_idx" ON "TrustedDevice"("userId");
-- AddForeignKey
ALTER TABLE "TwoFactorChallenge" ADD CONSTRAINT "TwoFactorChallenge_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "TrustedDevice" ADD CONSTRAINT "TrustedDevice_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,164 @@
-- CreateEnum
CREATE TYPE "CouponType" AS ENUM ('percent', 'fixed', 'comp');
-- AlterEnum
-- This migration adds more than one value to an enum.
-- With PostgreSQL versions 11 and earlier, this is not possible
-- in a single migration. This can be worked around by creating
-- multiple migrations, each migration adding only one value to
-- the enum.
ALTER TYPE "AdminAuditAction" ADD VALUE 'coupon_created';
ALTER TYPE "AdminAuditAction" ADD VALUE 'coupon_updated';
ALTER TYPE "AdminAuditAction" ADD VALUE 'coupon_disabled';
ALTER TYPE "AdminAuditAction" ADD VALUE 'coupon_applied_manual';
-- AlterTable
ALTER TABLE "EventOption" ADD COLUMN "staffPrice" DOUBLE PRECISION;
-- AlterTable
ALTER TABLE "OptionVariant" ADD COLUMN "staffPrice" DOUBLE PRECISION;
-- AlterTable
ALTER TABLE "Registration" ADD COLUMN "discountTotal" DOUBLE PRECISION NOT NULL DEFAULT 0;
-- CreateTable
CREATE TABLE "Coupon" (
"id" TEXT NOT NULL,
"code" TEXT NOT NULL,
"description" TEXT,
"type" "CouponType" NOT NULL,
"value" DOUBLE PRECISION NOT NULL DEFAULT 0,
"stackEarlyBird" BOOLEAN NOT NULL DEFAULT false,
"validFrom" TIMESTAMP(3),
"validUntil" TIMESTAMP(3),
"maxUses" INTEGER,
"maxUsesPerUser" INTEGER DEFAULT 1,
"minQuantity" INTEGER,
"restrictRoles" "UserRole"[],
"restrictDomain" TEXT,
"active" BOOLEAN NOT NULL DEFAULT true,
"createdById" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Coupon_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "CouponEvent" (
"id" TEXT NOT NULL,
"couponId" TEXT NOT NULL,
"eventId" TEXT NOT NULL,
CONSTRAINT "CouponEvent_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "CouponOption" (
"id" TEXT NOT NULL,
"couponId" TEXT NOT NULL,
"eventOptionId" TEXT NOT NULL,
"variantId" TEXT,
CONSTRAINT "CouponOption_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "CouponUser" (
"id" TEXT NOT NULL,
"couponId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
CONSTRAINT "CouponUser_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "CouponRedemption" (
"id" TEXT NOT NULL,
"couponId" TEXT NOT NULL,
"registrationId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"discountAmount" DOUBLE PRECISION NOT NULL,
"appliedById" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"releasedAt" TIMESTAMP(3),
CONSTRAINT "CouponRedemption_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "Coupon_code_key" ON "Coupon"("code");
-- CreateIndex
CREATE INDEX "Coupon_createdById_idx" ON "Coupon"("createdById");
-- CreateIndex
CREATE INDEX "CouponEvent_eventId_idx" ON "CouponEvent"("eventId");
-- CreateIndex
CREATE UNIQUE INDEX "CouponEvent_couponId_eventId_key" ON "CouponEvent"("couponId", "eventId");
-- CreateIndex
CREATE INDEX "CouponOption_couponId_idx" ON "CouponOption"("couponId");
-- CreateIndex
CREATE INDEX "CouponOption_eventOptionId_idx" ON "CouponOption"("eventOptionId");
-- CreateIndex
CREATE INDEX "CouponOption_variantId_idx" ON "CouponOption"("variantId");
-- CreateIndex
CREATE INDEX "CouponUser_userId_idx" ON "CouponUser"("userId");
-- CreateIndex
CREATE UNIQUE INDEX "CouponUser_couponId_userId_key" ON "CouponUser"("couponId", "userId");
-- CreateIndex
CREATE INDEX "CouponRedemption_couponId_releasedAt_idx" ON "CouponRedemption"("couponId", "releasedAt");
-- CreateIndex
CREATE INDEX "CouponRedemption_registrationId_idx" ON "CouponRedemption"("registrationId");
-- CreateIndex
CREATE INDEX "CouponRedemption_userId_idx" ON "CouponRedemption"("userId");
-- CreateIndex
CREATE INDEX "CouponRedemption_appliedById_idx" ON "CouponRedemption"("appliedById");
-- AddForeignKey
ALTER TABLE "Coupon" ADD CONSTRAINT "Coupon_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "CouponEvent" ADD CONSTRAINT "CouponEvent_couponId_fkey" FOREIGN KEY ("couponId") REFERENCES "Coupon"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "CouponEvent" ADD CONSTRAINT "CouponEvent_eventId_fkey" FOREIGN KEY ("eventId") REFERENCES "Event"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "CouponOption" ADD CONSTRAINT "CouponOption_couponId_fkey" FOREIGN KEY ("couponId") REFERENCES "Coupon"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "CouponOption" ADD CONSTRAINT "CouponOption_eventOptionId_fkey" FOREIGN KEY ("eventOptionId") REFERENCES "EventOption"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "CouponOption" ADD CONSTRAINT "CouponOption_variantId_fkey" FOREIGN KEY ("variantId") REFERENCES "OptionVariant"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "CouponUser" ADD CONSTRAINT "CouponUser_couponId_fkey" FOREIGN KEY ("couponId") REFERENCES "Coupon"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "CouponUser" ADD CONSTRAINT "CouponUser_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "CouponRedemption" ADD CONSTRAINT "CouponRedemption_couponId_fkey" FOREIGN KEY ("couponId") REFERENCES "Coupon"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "CouponRedemption" ADD CONSTRAINT "CouponRedemption_registrationId_fkey" FOREIGN KEY ("registrationId") REFERENCES "Registration"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "CouponRedemption" ADD CONSTRAINT "CouponRedemption_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "CouponRedemption" ADD CONSTRAINT "CouponRedemption_appliedById_fkey" FOREIGN KEY ("appliedById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
+231 -34
View File
@@ -51,6 +51,13 @@ enum SecurityEventType {
login
password_changed
password_reset
two_factor_enabled
two_factor_disabled
}
enum TwoFactorMethod {
email
whatsapp
}
model User {
@@ -67,6 +74,8 @@ model User {
lockUntil DateTime?
tokenVersion Int @default(0)
notificationPreference NotificationPreference @default(email)
twoFactorEnabled Boolean @default(false)
twoFactorMethod TwoFactorMethod?
registrations Registration[]
payments Payment[]
paymentsRecorded Payment[] @relation("PaymentRecordedBy")
@@ -82,10 +91,19 @@ model User {
cashupsPerformed EventCashup[]
notifyForEvents Event[] @relation("EventNotifyRecipients")
personCashCountsFor EventCashupPersonCount[] @relation("EventCashupPersonCountFor")
personCashCountsEntered EventCashupPersonCount[] @relation("EventCashupPersonCountEnteredBy")
personCashCountsFor EventCashupPersonCount[] @relation("EventCashupPersonCountFor")
personCashCountsEntered EventCashupPersonCount[] @relation("EventCashupPersonCountEnteredBy")
securityEvents SecurityEvent[]
adminAuditLogs AdminAuditLog[] @relation("AdminAuditActor")
couponsCreated Coupon[] @relation("CouponCreatedBy")
couponAccess CouponUser[]
couponRedemptions CouponRedemption[] @relation("CouponRedemptionUser")
couponsApplied CouponRedemption[] @relation("CouponRedemptionAppliedBy")
twoFactorChallenges TwoFactorChallenge[]
trustedDevices TrustedDevice[]
}
model Event {
@@ -119,21 +137,22 @@ model Event {
form EventForm?
Section Section[]
cashupStatus CashupStatus @default(open)
cashupDraft Json?
closedAt DateTime?
closedById String?
closedBy User? @relation("EventClosedBy", fields: [closedById], references: [id], onDelete: SetNull)
reopenedAt DateTime?
reopenedById String?
reopenedBy User? @relation("EventReopenedBy", fields: [reopenedById], references: [id], onDelete: SetNull)
costs EventCost[]
cashups EventCashup[]
cashupStatus CashupStatus @default(open)
cashupDraft Json?
closedAt DateTime?
closedById String?
closedBy User? @relation("EventClosedBy", fields: [closedById], references: [id], onDelete: SetNull)
reopenedAt DateTime?
reopenedById String?
reopenedBy User? @relation("EventReopenedBy", fields: [reopenedById], references: [id], onDelete: SetNull)
costs EventCost[]
cashups EventCashup[]
personCashCounts EventCashupPersonCount[]
// Users who should receive registration/payment/daily-summary notifications for this
// event. Falls back to `createdBy` when empty (see backend/src/utils/notifications.js).
notifyRecipients User[] @relation("EventNotifyRecipients")
notifyRecipients User[] @relation("EventNotifyRecipients")
coupons CouponEvent[]
@@index([createdById])
@@index([closedById])
@@ -147,26 +166,29 @@ model EventOption {
price Float
isMainTicket Boolean
stockLimit Int @default(0)
// Applied automatically for Staff and above instead of `price` (when lower). Null = no staff price.
staffPrice Float?
event Event @relation(fields: [eventId], references: [id], onDelete: Restrict)
registrationOptions RegistrationOption[]
earlyBirdTiers EarlyBirdTier[]
SectionOption SectionOption[]
variants OptionVariant[]
costs EventCost[]
couponScopes CouponOption[]
@@index([eventId])
}
model EarlyBirdTier {
id String @id @default(uuid())
id String @id @default(uuid())
eventOptionId String
variantId String?
deadline DateTime
price Float
order Int @default(0)
stockLimit Int @default(0)
eventOption EventOption @relation(fields: [eventOptionId], references: [id], onDelete: Cascade)
variant OptionVariant? @relation(fields: [variantId], references: [id], onDelete: Cascade)
order Int @default(0)
stockLimit Int @default(0)
eventOption EventOption @relation(fields: [eventOptionId], references: [id], onDelete: Cascade)
variant OptionVariant? @relation(fields: [variantId], references: [id], onDelete: Cascade)
registrationOptions RegistrationOption[]
tranches RegistrationOptionTranche[]
@@ -180,10 +202,13 @@ model OptionVariant {
name String
price Float?
stockLimit Int @default(0)
// Staff price override for this variant (falls back to the option's staffPrice).
staffPrice Float?
order Int @default(0)
eventOption EventOption @relation(fields: [eventOptionId], references: [id], onDelete: Cascade)
registrationOptions RegistrationOption[]
earlyBirdTiers EarlyBirdTier[]
couponScopes CouponOption[]
@@index([eventOptionId])
}
@@ -194,6 +219,10 @@ model Registration {
eventId String
status RegistrationStatus @default(pending)
checkoutId String? @unique
// Sum of this registration's active coupon discounts, kept in step with
// couponRedemptions. Stored here so every total-due calculation sees it no matter
// which relations the caller loaded (see utils/pricing.js computeRegistrationTotalDue).
discountTotal Float @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Restrict)
@@ -202,6 +231,7 @@ model Registration {
payments Payment[]
formResponses FormResponse[]
formDrafts FormDraft[]
couponRedemptions CouponRedemption[]
@@index([userId])
@@index([eventId])
@@ -211,17 +241,17 @@ model Registration {
}
model RegistrationOption {
id String @id @default(uuid())
id String @id @default(uuid())
registrationId String
eventOptionId String
quantity Int @default(1)
quantity Int @default(1)
variantId String?
appliedTierId String?
priceSnapshot Float?
registration Registration @relation(fields: [registrationId], references: [id], onDelete: Restrict)
eventOption EventOption @relation(fields: [eventOptionId], references: [id], onDelete: Restrict)
variant OptionVariant? @relation(fields: [variantId], references: [id], onDelete: SetNull)
appliedTier EarlyBirdTier? @relation(fields: [appliedTierId], references: [id], onDelete: SetNull)
registration Registration @relation(fields: [registrationId], references: [id], onDelete: Restrict)
eventOption EventOption @relation(fields: [eventOptionId], references: [id], onDelete: Restrict)
variant OptionVariant? @relation(fields: [variantId], references: [id], onDelete: SetNull)
appliedTier EarlyBirdTier? @relation(fields: [appliedTierId], references: [id], onDelete: SetNull)
tickets Ticket[]
tranches RegistrationOptionTranche[]
@@ -239,14 +269,14 @@ model RegistrationOption {
// mirror the most recently added tranche) for the many call sites that only need "how many"
// or a single display price.
model RegistrationOptionTranche {
id String @id @default(uuid())
registrationOptionId String
quantity Int
priceSnapshot Float
appliedTierId String?
createdAt DateTime @default(now())
registrationOption RegistrationOption @relation(fields: [registrationOptionId], references: [id], onDelete: Cascade)
appliedTier EarlyBirdTier? @relation(fields: [appliedTierId], references: [id], onDelete: SetNull)
id String @id @default(uuid())
registrationOptionId String
quantity Int
priceSnapshot Float
appliedTierId String?
createdAt DateTime @default(now())
registrationOption RegistrationOption @relation(fields: [registrationOptionId], references: [id], onDelete: Cascade)
appliedTier EarlyBirdTier? @relation(fields: [appliedTierId], references: [id], onDelete: SetNull)
@@index([registrationOptionId])
@@index([appliedTierId])
@@ -329,6 +359,40 @@ model PasswordReset {
@@index([userId])
}
// A 6-digit code sent by email/WhatsApp, either as the second step of a login
// ('login') or to prove the channel works before 2FA is switched on ('enable').
// Only the HMAC of the code is stored (see backend/src/utils/twoFactor.js).
model TwoFactorChallenge {
id String @id @default(uuid())
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
purpose String // 'login' | 'enable'
method TwoFactorMethod
codeHash String
attempts Int @default(0)
lastSentAt DateTime @default(now())
expiresAt DateTime
usedAt DateTime?
createdAt DateTime @default(now())
@@index([userId])
}
// A browser that passed the 2FA code step with "Trust this device" ticked — logins
// presenting its token skip the code until it expires or is cleared.
model TrustedDevice {
id String @id @default(uuid())
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
tokenHash String @unique
device String?
createdAt DateTime @default(now())
lastUsedAt DateTime @default(now())
expiresAt DateTime
@@index([userId])
}
// Append-only "Account activity" log for the Profile & Security page (logins, password
// changes). Nullable FK with SetNull (not Cascade) so entries survive account
// close/anonymization, the same pattern EventCashup uses for its performedBy audit trail.
@@ -344,6 +408,43 @@ model SecurityEvent {
@@index([userId, createdAt])
}
enum AdminAuditAction {
refund_created
donation_assigned
donation_unassigned
registration_created_manual
registration_cancelled
event_created
event_updated
event_deleted
settings_updated
coupon_created
coupon_updated
coupon_disabled
coupon_applied_manual
}
// Append-only audit trail for admin/supervisor-initiated actions with money or
// data-integrity impact — separate from SecurityEvent (user-account-security-specific,
// fixed enum). Nullable FK with SetNull mirrors SecurityEvent's pattern so entries
// survive account close/anonymization.
model AdminAuditLog {
id String @id @default(uuid())
actorId String?
actor User? @relation("AdminAuditActor", fields: [actorId], references: [id], onDelete: SetNull)
actorRole String
action AdminAuditAction
targetType String
targetId String?
metadata Json?
ip String?
createdAt DateTime @default(now())
@@index([actorId, createdAt])
@@index([action, createdAt])
@@index([targetType, targetId])
}
model EventAttachment {
id String @id @default(uuid())
eventId String
@@ -562,11 +663,11 @@ model EventCashupPersonCount {
}
model EventCashupPersonCountDenomination {
id String @id @default(uuid())
id String @id @default(uuid())
countId String
value Float
count Int
personCount EventCashupPersonCount @relation(fields: [countId], references: [id], onDelete: Cascade)
personCount EventCashupPersonCount @relation(fields: [countId], references: [id], onDelete: Cascade)
@@index([countId])
}
@@ -726,3 +827,99 @@ model EventCashupPersonCountDenomination {
// @@index([createdAt])
// @@index([method])
// }
// ─── Coupon codes ─────────────────────────────────────────────────────────────
// A code gives a percentage, fixed-amount or 100% (comp) discount on a registration.
// The discount is locked into a CouponRedemption when applied, like early-bird
// tranches: later pricing refreshes never re-price it (see utils/coupons.js).
enum CouponType {
percent
fixed
comp
}
model Coupon {
id String @id @default(uuid())
code String @unique // stored uppercase; matched case-insensitively
description String?
type CouponType
value Float @default(0) // percent (0-100) or rand amount; ignored for comp
stackEarlyBird Boolean @default(false) // true: discount the early-bird price; false: best price wins
validFrom DateTime?
validUntil DateTime?
maxUses Int?
maxUsesPerUser Int? @default(1)
minQuantity Int?
restrictRoles UserRole[]
restrictDomain String?
active Boolean @default(true)
createdById String?
createdBy User? @relation("CouponCreatedBy", fields: [createdById], references: [id], onDelete: SetNull)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
events CouponEvent[] // none = valid for every event
options CouponOption[] // none = whole event
allowedUsers CouponUser[]
redemptions CouponRedemption[]
@@index([createdById])
}
model CouponEvent {
id String @id @default(uuid())
couponId String
eventId String
coupon Coupon @relation(fields: [couponId], references: [id], onDelete: Cascade)
event Event @relation(fields: [eventId], references: [id], onDelete: Cascade)
@@unique([couponId, eventId])
@@index([eventId])
}
// Limits a coupon to specific ticket options, or to one variant of an option.
model CouponOption {
id String @id @default(uuid())
couponId String
eventOptionId String
variantId String?
coupon Coupon @relation(fields: [couponId], references: [id], onDelete: Cascade)
eventOption EventOption @relation(fields: [eventOptionId], references: [id], onDelete: Cascade)
variant OptionVariant? @relation(fields: [variantId], references: [id], onDelete: Cascade)
@@index([couponId])
@@index([eventOptionId])
@@index([variantId])
}
model CouponUser {
id String @id @default(uuid())
couponId String
userId String
coupon Coupon @relation(fields: [couponId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([couponId, userId])
@@index([userId])
}
model CouponRedemption {
id String @id @default(uuid())
couponId String
registrationId String
userId String
discountAmount Float // locked at apply time
appliedById String? // staff member, when applied on someone's behalf
createdAt DateTime @default(now())
releasedAt DateTime? // set when the registration is cancelled — frees the use
coupon Coupon @relation(fields: [couponId], references: [id], onDelete: Restrict)
registration Registration @relation(fields: [registrationId], references: [id], onDelete: Cascade)
user User @relation("CouponRedemptionUser", fields: [userId], references: [id], onDelete: Restrict)
appliedBy User? @relation("CouponRedemptionAppliedBy", fields: [appliedById], references: [id], onDelete: SetNull)
@@index([couponId, releasedAt])
@@index([registrationId])
@@index([userId])
@@index([appliedById])
}
+3 -2
View File
@@ -1,9 +1,10 @@
const jwt = require('jsonwebtoken');
const bcrypt = require('bcryptjs');
const generateToken = (userId, role, tokenVersion = 0) => {
// `extra` adds claims, e.g. { setup: true } for the first-run setup wizard's token.
const generateToken = (userId, role, tokenVersion = 0, extra = {}) => {
return jwt.sign(
{ id: userId, role, tokenVersion },
{ ...extra, id: userId, role, tokenVersion },
process.env.JWT_SECRET,
{ expiresIn: '30d' }
);
@@ -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 };
+331
View File
@@ -0,0 +1,331 @@
const prisma = require('../config/db');
const { safeErrorMessage } = require('../utils/errorUtils');
const { logAdminAction } = require('../utils/adminAudit');
const { getClientIp } = require('../utils/requestUtils');
const { normalizeCode, previewCoupon, buildCouponLines, describeCoupon, countCouponUses } = require('../utils/coupons');
const { resolveOptionPrice, resolveVariantTierPrice } = require('../utils/pricing');
const TYPES = ['percent', 'fixed', 'comp'];
const ROLES = ['user', 'staff', 'supervisor', 'admin'];
const STAFF_ROLES = ['staff', 'supervisor', 'admin'];
const COUPON_DETAIL_INCLUDE = {
events: { include: { event: { select: { id: true, title: true, startDate: true } } } },
options: { include: { eventOption: { select: { id: true, name: true, eventId: true } }, variant: { select: { id: true, name: true } } } },
allowedUsers: { include: { user: { select: { id: true, name: true, email: true } } } },
createdBy: { select: { id: true, name: true } },
};
const optionalDate = (v, label) => {
if (v === undefined) return undefined;
if (v === null || v === '') return null;
const d = new Date(v);
if (isNaN(d.getTime())) throw new Error(`${label} is not a valid date`);
return d;
};
const optionalInt = (v, label, min = 0) => {
if (v === undefined) return undefined;
if (v === null || v === '') return null;
const n = Number(v);
if (!Number.isInteger(n) || n < min) throw new Error(`${label} must be a whole number of at least ${min}`);
return n;
};
/**
* Turns a create/update body into Prisma data plus the relation lists. On update,
* fields left out of the body are left alone.
*/
async function parseCouponBody(body, { isCreate }) {
const data = {};
if (isCreate || body.code !== undefined) {
const code = normalizeCode(body.code);
if (!/^[A-Z0-9_-]{3,32}$/.test(code)) {
throw new Error('Codes are 3–32 letters, numbers, hyphens or underscores');
}
data.code = code;
}
if (isCreate || body.type !== undefined) {
if (!TYPES.includes(body.type)) throw new Error("Discount type must be 'percent', 'fixed' or 'comp'");
data.type = body.type;
}
if (isCreate || body.value !== undefined) {
const type = data.type || body.type;
const value = type === 'comp' ? 0 : Number(body.value);
if (type !== 'comp' && !(value > 0)) throw new Error('Enter a discount value greater than zero');
if (type === 'percent' && value > 100) throw new Error('A percentage discount can\'t be more than 100');
data.value = value;
}
if (body.description !== undefined) data.description = body.description ? String(body.description).trim() : null;
if (body.stackEarlyBird !== undefined) data.stackEarlyBird = !!body.stackEarlyBird;
if (body.active !== undefined) data.active = !!body.active;
const validFrom = optionalDate(body.validFrom, 'Valid from');
const validUntil = optionalDate(body.validUntil, 'Valid until');
if (validFrom !== undefined) data.validFrom = validFrom;
if (validUntil !== undefined) data.validUntil = validUntil;
if (validFrom && validUntil && validFrom > validUntil) throw new Error('"Valid from" must be before "Valid until"');
const maxUses = optionalInt(body.maxUses, 'Max total uses', 1);
const maxUsesPerUser = optionalInt(body.maxUsesPerUser, 'Max uses per person', 1);
const minQuantity = optionalInt(body.minQuantity, 'Minimum quantity', 1);
if (maxUses !== undefined) data.maxUses = maxUses;
if (maxUsesPerUser !== undefined) data.maxUsesPerUser = maxUsesPerUser;
if (minQuantity !== undefined) data.minQuantity = minQuantity;
if (body.restrictRoles !== undefined) {
const roles = Array.isArray(body.restrictRoles) ? body.restrictRoles : [];
if (roles.some(r => !ROLES.includes(r))) throw new Error('Unknown role in restrictions');
data.restrictRoles = roles;
}
if (body.restrictDomain !== undefined) {
const domain = String(body.restrictDomain || '').trim().toLowerCase().replace(/^@/, '');
if (domain && !/^[a-z0-9.-]+\.[a-z]{2,}$/.test(domain)) throw new Error('Enter an email domain like church.org');
data.restrictDomain = domain || null;
}
const eventIds = body.eventIds !== undefined ? [...new Set((body.eventIds || []).filter(Boolean))] : undefined;
const scopes = body.options !== undefined
? (body.options || []).filter(o => o && o.eventOptionId).map(o => ({ eventOptionId: o.eventOptionId, variantId: o.variantId || null }))
: undefined;
const userIds = body.userIds !== undefined ? [...new Set((body.userIds || []).filter(Boolean))] : undefined;
if (scopes && scopes.length > 0) {
const optionRows = await prisma.eventOption.findMany({
where: { id: { in: scopes.map(s => s.eventOptionId) } },
select: { id: true, eventId: true, variants: { select: { id: true } } },
});
for (const s of scopes) {
const row = optionRows.find(o => o.id === s.eventOptionId);
if (!row) throw new Error('One of the selected ticket options no longer exists');
if (s.variantId && !row.variants.some(v => v.id === s.variantId)) throw new Error('One of the selected variants no longer exists');
// A scoped option implies its event, so the code can't be used elsewhere by mistake.
if (eventIds && eventIds.length > 0 && !eventIds.includes(row.eventId)) {
throw new Error('Selected ticket options must belong to the selected events');
}
}
}
return { data, eventIds, scopes, userIds };
}
function relationWrites({ eventIds, scopes, userIds }, isCreate) {
const out = {};
if (eventIds !== undefined) out.events = { ...(isCreate ? {} : { deleteMany: {} }), create: eventIds.map(eventId => ({ eventId })) };
if (scopes !== undefined) out.options = { ...(isCreate ? {} : { deleteMany: {} }), create: scopes };
if (userIds !== undefined) out.allowedUsers = { ...(isCreate ? {} : { deleteMany: {} }), create: userIds.map(userId => ({ userId })) };
return out;
}
// @desc List coupons with their use counts
// @route GET /api/coupons
// @access Private/Supervisor
const listCoupons = async (req, res) => {
try {
const coupons = await prisma.coupon.findMany({ include: COUPON_DETAIL_INCLUDE, orderBy: { createdAt: 'desc' } });
const totals = await prisma.couponRedemption.groupBy({
by: ['couponId'],
where: { releasedAt: null, registration: { status: { not: 'cancelled' } } },
_count: { _all: true },
_sum: { discountAmount: true },
});
const byId = Object.fromEntries(totals.map(t => [t.couponId, t]));
res.json(coupons.map(c => ({
...c,
summary: describeCoupon(c),
uses: byId[c.id]?._count?._all || 0,
totalDiscount: byId[c.id]?._sum?.discountAmount || 0,
})));
} catch (error) {
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
}
};
// @desc Get one coupon with its redemptions
// @route GET /api/coupons/:id
// @access Private/Supervisor
const getCoupon = async (req, res) => {
try {
const coupon = await prisma.coupon.findUnique({
where: { id: req.params.id },
include: {
...COUPON_DETAIL_INCLUDE,
redemptions: {
orderBy: { createdAt: 'desc' },
include: {
user: { select: { id: true, name: true, email: true } },
appliedBy: { select: { id: true, name: true } },
registration: { select: { id: true, status: true, event: { select: { id: true, title: true } } } },
},
},
},
});
if (!coupon) { res.status(404); throw new Error('Coupon not found'); }
res.json({ ...coupon, summary: describeCoupon(coupon) });
} catch (error) {
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
}
};
// @desc Create a coupon
// @route POST /api/coupons
// @access Private/Supervisor
const createCoupon = async (req, res) => {
try {
let parsed;
try {
parsed = await parseCouponBody(req.body || {}, { isCreate: true });
} catch (e) { res.status(400); throw e; }
if (await prisma.coupon.findUnique({ where: { code: parsed.data.code } })) {
res.status(409); throw new Error(`The code ${parsed.data.code} already exists`);
}
const coupon = await prisma.coupon.create({
data: { ...parsed.data, createdById: req.user.id, ...relationWrites(parsed, true) },
include: COUPON_DETAIL_INCLUDE,
});
logAdminAction({
actorId: req.user.id, actorRole: req.user.role, action: 'coupon_created',
targetType: 'Coupon', targetId: coupon.id,
metadata: { code: coupon.code, type: coupon.type, value: coupon.value },
ip: getClientIp(req),
});
res.status(201).json({ ...coupon, summary: describeCoupon(coupon), uses: 0, totalDiscount: 0 });
} catch (error) {
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
}
};
// @desc Update a coupon (including turning it off). Discounts already applied keep
// their locked amount.
// @route PUT /api/coupons/:id
// @access Private/Supervisor
const updateCoupon = async (req, res) => {
try {
const existing = await prisma.coupon.findUnique({ where: { id: req.params.id } });
if (!existing) { res.status(404); throw new Error('Coupon not found'); }
let parsed;
try {
parsed = await parseCouponBody({ type: existing.type, ...req.body }, { isCreate: false });
} catch (e) { res.status(400); throw e; }
if (parsed.data.code && parsed.data.code !== existing.code) {
const used = await prisma.couponRedemption.count({ where: { couponId: existing.id } });
if (used > 0) { res.status(400); throw new Error('This code has been used, so it can\'t be renamed. Turn it off and create a new one instead.'); }
if (await prisma.coupon.findUnique({ where: { code: parsed.data.code } })) {
res.status(409); throw new Error(`The code ${parsed.data.code} already exists`);
}
}
const coupon = await prisma.coupon.update({
where: { id: existing.id },
data: { ...parsed.data, ...relationWrites(parsed, false) },
include: COUPON_DETAIL_INCLUDE,
});
const disabled = existing.active && coupon.active === false;
const changedKeys = Object.keys(req.body || {});
logAdminAction({
actorId: req.user.id, actorRole: req.user.role,
action: disabled && changedKeys.length === 1 ? 'coupon_disabled' : 'coupon_updated',
targetType: 'Coupon', targetId: coupon.id,
metadata: { code: coupon.code, changedKeys },
ip: getClientIp(req),
});
const { usesTotal } = await countCouponUses(coupon.id, null);
res.json({ ...coupon, summary: describeCoupon(coupon), uses: usesTotal });
} catch (error) {
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
}
};
// @desc Check a code against the items someone is about to register for
// @route POST /api/coupons/validate
// @access Public (login needed for restricted codes)
// Body: { code, eventId, options: [{ eventOptionId, variantId?, quantity }], forUserId? }
// Staff can pass forUserId to check on an attendee's behalf, or leave it out for a new
// attendee — restricted codes are then checked when the registration is saved.
const validateCode = async (req, res) => {
try {
const { code, eventId, options, forUserId } = req.body || {};
if (!code || !eventId || !Array.isArray(options) || options.length === 0) {
res.status(400); throw new Error('code, eventId and options are required');
}
const event = await prisma.event.findUnique({
where: { id: eventId },
include: { eventOptions: { include: { earlyBirdTiers: true, variants: true } } },
});
if (!event) { res.status(404); throw new Error('Event not found'); }
const isStaff = req.user && STAFF_ROLES.includes(req.user.role);
let buyer = req.user ? { id: req.user.id, role: req.user.role, email: req.user.email } : null;
if (isStaff && forUserId) {
buyer = await prisma.user.findUnique({ where: { id: forUserId }, select: { id: true, role: true, email: true } });
} else if (isStaff && forUserId === null) {
buyer = null; // a walk-in without an account yet
}
// Price the basket the same way registration will.
const registrationOptions = [];
for (const o of options) {
const eo = event.eventOptions.find(x => x.id === o.eventOptionId);
const qty = Number(o.quantity || 0);
if (!eo || qty < 1) continue;
const resolved = o.variantId
? await resolveVariantTierPrice(eo, o.variantId, qty, buyer)
: await resolveOptionPrice(eo, qty, buyer);
registrationOptions.push({
eventOptionId: eo.id, variantId: o.variantId || null, quantity: qty, eventOption: eo,
tranches: [{ quantity: qty, priceSnapshot: resolved.price, appliedTierId: resolved.tierId }],
});
}
const lines = buildCouponLines({ registrationOptions });
const subtotal = lines.reduce((s, l) => s + l.unitPrice * l.quantity, 0);
try {
const { coupon, discount } = await previewCoupon({
code, eventId, user: buyer, lines,
deferRestrictions: isStaff && !buyer,
});
res.json({
valid: true,
code: coupon.code,
type: coupon.type,
value: coupon.value,
summary: describeCoupon(coupon),
description: coupon.description,
discount,
subtotal,
total: Math.max(0, subtotal - discount),
});
} catch (e) {
if (!e.isCouponError) throw e;
res.status(400).json({ valid: false, message: e.message });
}
} catch (error) {
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
}
};
// @desc Coupon uses for the Discounts report
// @route GET /api/coupons/redemptions?eventIds=a,b&from=&to=
// @access Private/Supervisor
const listRedemptions = async (req, res) => {
try {
const eventIds = String(req.query.eventIds || '').split(',').map(s => s.trim()).filter(Boolean);
const from = req.query.from ? new Date(req.query.from) : null;
const to = req.query.to ? new Date(req.query.to) : null;
const where = {
releasedAt: null,
registration: { status: { not: 'cancelled' }, ...(eventIds.length ? { eventId: { in: eventIds } } : {}) },
...(from || to ? { createdAt: { ...(from && !isNaN(from) ? { gte: from } : {}), ...(to && !isNaN(to) ? { lte: to } : {}) } } : {}),
};
const rows = await prisma.couponRedemption.findMany({
where,
orderBy: { createdAt: 'desc' },
include: {
coupon: { select: { id: true, code: true, type: true, value: true } },
user: { select: { id: true, name: true, email: true } },
appliedBy: { select: { id: true, name: true } },
registration: { select: { id: true, status: true, event: { select: { id: true, title: true } } } },
},
});
res.json(rows.map(r => ({ ...r, summary: describeCoupon(r.coupon) })));
} catch (error) {
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
}
};
module.exports = { listCoupons, getCoupon, createCoupon, updateCoupon, validateCode, listRedemptions };
+125 -6
View File
@@ -3,7 +3,10 @@ const { v4: uuidv4 } = require('uuid');
const multer = require('multer');
const path = require('path');
const fs = require('fs');
const crypto = require('crypto');
const { assertEventOpen } = require('../utils/cashupUtils');
const { logAdminAction } = require('../utils/adminAudit');
const { getClientIp } = require('../utils/requestUtils');
// Helper to convert stored picture path/URL to an absolute, externally reachable URL based on the incoming request
function toAbsoluteUrl(req, url) {
@@ -65,8 +68,19 @@ const createEvent = async (req, res) => {
location: location || null,
};
const logEventCreate = (createdEvent) => logAdminAction({
actorId: req.user?.id,
actorRole: req.user?.role,
action: 'event_created',
targetType: 'Event',
targetId: createdEvent.id,
metadata: { title: createdEvent.title },
ip: getClientIp(req),
});
try {
const event = await prisma.event.create({ data });
logEventCreate(event);
// Automatically create a main ticket (event option) with the event price — contact-only
// events have no bookable options, so there's nothing to auto-create for them.
@@ -130,24 +144,28 @@ const createEvent = async (req, res) => {
// @ts-ignore delete field and retry
delete data.registrationDeadline;
const event = await prisma.event.create({ data });
logEventCreate(event);
return res.status(201).json(event);
}
if (msg.includes('Unknown argument `goLiveAt`')) {
// @ts-ignore delete field and retry
delete data.goLiveAt;
const event = await prisma.event.create({ data });
logEventCreate(event);
return res.status(201).json(event);
}
if (msg.includes('Unknown argument `createdById`')) {
// @ts-ignore delete field and retry
delete data.createdById;
const event = await prisma.event.create({ data });
logEventCreate(event);
return res.status(201).json(event);
}
if (msg.includes('Unknown argument `redirectUrl`')) {
// @ts-ignore delete field and retry
delete data.redirectUrl;
const event = await prisma.event.create({ data });
logEventCreate(event);
return res.status(201).json(event);
}
throw err;
@@ -442,6 +460,55 @@ 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');
let frontendUrl = (process.env.FRONTEND_URL || process.env.APP_BASE_URL || 'http://localhost:3001').replace(/\/$/, '');
// The ics library's url validator requires a scheme; guard against FRONTEND_URL/APP_BASE_URL
// being configured without one (e.g. "example.com" instead of "https://example.com").
if (!/^[a-z0-9+.-]+:\/\//i.test(frontendUrl)) {
frontendUrl = `https://${frontendUrl}`;
}
const ics = buildEventIcs(event, `${frontendUrl}/events/${event.id}`);
res.setHeader('Content-Type', 'text/calendar; charset=utf-8');
res.setHeader('Content-Disposition', `attachment; filename="${event.title.replace(/[^a-z0-9 -]/gi, '').slice(0, 60) || 'event'}.ics"`);
res.send(ics);
} catch (error) {
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: error.message });
}
};
// @desc Update event
// @route PUT /api/events/:id
// @access Private/Admin
@@ -460,6 +527,16 @@ const updateEvent = async (req, res) => {
// totals — same rule already enforced for payments/costs. Admin can reopen first.
await assertEventOpen(req.params.id, res);
const logEventUpdate = () => logAdminAction({
actorId: req.user.id,
actorRole: req.user.role,
action: 'event_updated',
targetType: 'Event',
targetId: req.params.id,
metadata: { changedKeys: Object.keys(req.body || {}) },
ip: getClientIp(req),
});
const { title, description, startDate, endDate, registrationDeadline, goLiveAt, price, picture, isActive, redirectUrl, isHidden, requiresAuth, requiresRegistration, contactName, contactPhone, contactEmail, location } = req.body;
const data = {
@@ -526,6 +603,7 @@ const updateEvent = async (req, res) => {
return res.status(400).json({ message: 'Failed to save event form/fields', detail: msg, hint: 'Ensure Prisma migrations are applied and Prisma Client is regenerated, then restart the server.' });
}
logEventUpdate();
return res.json(updatedEvent);
} catch (err) {
const msg = String(err?.message || '');
@@ -533,12 +611,14 @@ const updateEvent = async (req, res) => {
// @ts-ignore
delete data.registrationDeadline;
const updatedEvent = await prisma.event.update({ where: { id: req.params.id }, data });
logEventUpdate();
return res.json(updatedEvent);
}
if (msg.includes('Unknown argument `goLiveAt`')) {
// @ts-ignore
delete data.goLiveAt;
const updatedEvent = await prisma.event.update({ where: { id: req.params.id }, data });
logEventUpdate();
return res.json(updatedEvent);
}
throw err;
@@ -623,12 +703,30 @@ const deleteEvent = async (req, res) => {
}
});
logAdminAction({
actorId: req.user.id,
actorRole: req.user.role,
action: 'event_deleted',
targetType: 'Event',
targetId: req.params.id,
ip: getClientIp(req),
});
res.json({ message: 'Event deactivated' });
} catch (error) {
res.status(400).json({ message: error.message });
}
};
// Optional staff price from a request body: '' / null clears it, undefined leaves it.
function parseStaffPrice(value, current = null) {
if (value === undefined) return current;
if (value === null || value === '') return null;
const n = parseFloat(value);
if (!(n >= 0)) throw new Error('Staff price must be zero or more');
return n;
}
// @desc Create event option
// @route POST /api/events/:id/options
// @access Private/Admin
@@ -645,7 +743,7 @@ const createEventOption = async (req, res) => {
await assertEventOpen(req.params.id, res);
const { name, price, isMainTicket, stockLimit } = req.body;
const { name, price, isMainTicket, stockLimit, staffPrice } = req.body;
const eventOption = await prisma.eventOption.create({
data: {
@@ -655,6 +753,7 @@ const createEventOption = async (req, res) => {
price: parseFloat(price),
isMainTicket: isMainTicket || false,
stockLimit: stockLimit ? parseInt(stockLimit, 10) : 0,
staffPrice: parseStaffPrice(staffPrice),
}
});
@@ -681,7 +780,7 @@ const updateEventOption = async (req, res) => {
await assertEventOpen(eventOption.eventId, res);
const { name, price, isMainTicket, stockLimit, earlyBirdTiers, variants } = req.body;
const { name, price, isMainTicket, stockLimit, earlyBirdTiers, variants, staffPrice } = req.body;
const updatedEventOption = await prisma.eventOption.update({
where: { id: req.params.id },
@@ -690,6 +789,7 @@ const updateEventOption = async (req, res) => {
price: price !== undefined ? parseFloat(price) : eventOption.price,
isMainTicket: isMainTicket !== undefined ? isMainTicket : eventOption.isMainTicket,
stockLimit: stockLimit !== undefined ? parseInt(stockLimit, 10) : eventOption.stockLimit,
staffPrice: parseStaffPrice(staffPrice, eventOption.staffPrice),
}
});
@@ -758,10 +858,11 @@ const updateEventOption = async (req, res) => {
if (!v.name) continue;
const variantPrice = v.price !== undefined && v.price !== null && v.price !== '' ? parseFloat(v.price) : null;
const variantStockLimit = v.stockLimit !== undefined ? parseInt(v.stockLimit, 10) : 0;
const variantStaffPrice = parseStaffPrice(v.staffPrice);
if (v.id) {
await prisma.optionVariant.update({
where: { id: v.id },
data: { name: v.name, price: variantPrice, stockLimit: variantStockLimit, order: i }
data: { name: v.name, price: variantPrice, stockLimit: variantStockLimit, staffPrice: variantStaffPrice, order: i }
});
} else {
await prisma.optionVariant.create({
@@ -771,6 +872,7 @@ const updateEventOption = async (req, res) => {
name: v.name,
price: variantPrice,
stockLimit: variantStockLimit,
staffPrice: variantStaffPrice,
order: i,
}
});
@@ -848,7 +950,7 @@ const createOptionVariant = async (req, res) => {
const eventOption = await prisma.eventOption.findUnique({ where: { id: req.params.id } });
if (!eventOption) { res.status(404); throw new Error('Event option not found'); }
await assertEventOpen(eventOption.eventId, res);
const { name, price, stockLimit, order } = req.body;
const { name, price, stockLimit, order, staffPrice } = req.body;
if (!name) { res.status(400); throw new Error('Variant name is required'); }
const variant = await prisma.optionVariant.create({
data: {
@@ -857,6 +959,7 @@ const createOptionVariant = async (req, res) => {
name,
price: price !== undefined && price !== null && price !== '' ? parseFloat(price) : null,
stockLimit: stockLimit ? parseInt(stockLimit, 10) : 0,
staffPrice: parseStaffPrice(staffPrice),
order: order !== undefined ? parseInt(order, 10) : 0,
}
});
@@ -874,11 +977,12 @@ const updateOptionVariant = async (req, res) => {
const variant = await prisma.optionVariant.findUnique({ where: { id: req.params.id }, include: { eventOption: { select: { eventId: true } } } });
if (!variant) { res.status(404); throw new Error('Variant not found'); }
await assertEventOpen(variant.eventOption.eventId, res);
const { name, price, stockLimit, order } = req.body;
const { name, price, stockLimit, order, staffPrice } = req.body;
const updated = await prisma.optionVariant.update({
where: { id: req.params.id },
data: {
name: name !== undefined ? name : variant.name,
staffPrice: parseStaffPrice(staffPrice, variant.staffPrice),
price: price !== undefined ? (price === null || price === '' ? null : parseFloat(price)) : variant.price,
stockLimit: stockLimit !== undefined ? parseInt(stockLimit, 10) : variant.stockLimit,
order: order !== undefined ? parseInt(order, 10) : variant.order,
@@ -919,7 +1023,10 @@ const attachmentsStorage = multer.diskStorage({
}
},
filename: function (req, file, cb) {
const unique = `${Date.now()}-${file.originalname}`;
// Extension only — file.originalname is untrusted and joining it into a
// path allows `../` traversal to write outside the upload directory.
const ext = path.extname(file.originalname).toLowerCase();
const unique = `event-file-${Date.now()}-${crypto.randomBytes(8).toString('hex')}${ext}`;
cb(null, unique);
}
});
@@ -1696,9 +1803,20 @@ const scheduleWhatsappEventAttendees = async (req, res) => {
* @route GET /api/events/by-alias/:redirectUrl
* @access Public
*/
// Aliases are admin-set slugs (e.g. "camp-2025") — see the event wizard's "URL
// Alias" field. This endpoint is public and also the target of the frontend's
// catch-all [redirectUrl] route, so it's what every bot/scanner probe hitting
// an unmatched top-level path (/wp-login.php, /.env, etc.) ends up calling.
// Rejecting non-slug-shaped values here skips a DB round-trip for that traffic.
const VALID_ALIAS = /^[a-zA-Z0-9_-]{1,100}$/;
const getEventByAlias = async (req, res) => {
const { redirectUrl } = req.params;
if (!VALID_ALIAS.test(redirectUrl)) {
return res.status(404).json({ message: 'Event not found' });
}
try {
const event = await prisma.event.findFirst({
where: {
@@ -1747,6 +1865,7 @@ module.exports = {
getAllEvents,
getEventsAll,
getEventById,
getEventIcs,
updateEvent,
getEventNotifyRecipients,
updateEventNotifyRecipients,
+59 -43
View File
@@ -2,6 +2,9 @@ const prisma = require('../config/db');
const { v4: uuidv4 } = require('uuid');
const { generateTicketsForRegistration } = require('../utils/ticketUtils');
const { computeRegistrationTotalDue, refreshPricingForRegistration } = require('../utils/pricing');
const { computeDonationRemaining } = require('../utils/donationUtils');
const { logAdminAction } = require('../utils/adminAudit');
const { getClientIp } = require('../utils/requestUtils');
const axios = require('axios');
const { emailTickets } = require('./ticketController');
const { safeErrorMessage } = require('../utils/errorUtils');
@@ -611,17 +614,12 @@ const assignDonationToRegistration = async (req, res) => {
throw new Error('Only donations can be assigned to registrations');
}
// Donations are never mutated once created — their remaining balance is the original
// amount minus every leg (a Payment row with isDonation:false and originalPaymentId
// pointing back at this donation) already allocated from it. A refund of the donation
// itself also creates such a leg, with a negative amount — Math.abs() so a refund reduces
// the remaining balance (money that's left the building) instead of increasing it (which a
// raw signed sum would do, since subtracting a negative adds).
// See computeDonationRemaining's doc comment for why refund legs (negative amount) reduce
// rather than inflate the remaining balance.
const existingLegs = await prisma.payment.findMany({
where: { originalPaymentId: payment.id, isDonation: false }
});
const alreadyUsed = existingLegs.reduce((sum, leg) => sum + Math.abs(leg.amount), 0);
const remainingDonation = payment.amount - alreadyUsed;
const remainingDonation = computeDonationRemaining(payment.amount, existingLegs);
if (remainingDonation <= 0.000001) {
res.status(400);
@@ -766,6 +764,16 @@ const assignDonationToRegistration = async (req, res) => {
donationRemaining: remainingDonation - allocateAmount
};
logAdminAction({
actorId: req.user.id,
actorRole: req.user.role,
action: 'donation_assigned',
targetType: 'Registration',
targetId: registrationId,
metadata: { paymentId, legId: leg.id, allocateAmount },
ip: getClientIp(req),
});
res.status(200).json(result);
} catch (error) {
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
@@ -885,6 +893,16 @@ const unassignDonationFromRegistration = async (req, res) => {
catch (e) { console.error('Failed to send emails after unassigning donation:', e); }
})();
logAdminAction({
actorId: req.user.id,
actorRole: req.user.role,
action: 'donation_unassigned',
targetType: 'Registration',
targetId: leg.registrationId,
metadata: { legId: leg.id, donationId: donation.id, amount: leg.amount },
ip: getClientIp(req),
});
return res.status(200).json({
message: 'Donation unassigned',
updatedRegistration: finalRegistration,
@@ -895,6 +913,25 @@ const unassignDonationFromRegistration = async (req, res) => {
}
};
/**
* Yoco line items for a registration: one per item at each price actually charged
* (tranche-aware, so a line bought partly at early-bird shows both prices), plus the
* coupon discount as a negative line. Previous/partial payment lines are added by callers.
*/
function checkoutLineItems(registration) {
const { buildLineItems, couponDiscountLine } = require('../utils/pdfDocs');
const items = buildLineItems(registration.registrationOptions).map(row => ({
displayName: row.label + (row.isEarlyBird ? ' (early bird)' : ''),
quantity: row.quantity,
pricingDetails: { price: Math.round(row.unitPrice * 100) },
}));
const coupon = couponDiscountLine(registration);
if (coupon) {
items.push({ displayName: coupon.label, quantity: 1, pricingDetails: { price: -Math.round(coupon.amount * 100) } });
}
return items;
}
// Internal helper: create a Yoco checkout for a registration and return { checkoutId, redirectUrl, amount }
// Does NOT check user authorization — callers are responsible for ensuring the user owns the registration.
async function createRegistrationCheckoutInternal(registrationId, userId, { successUrl, cancelUrl, failureUrl } = {}) {
@@ -909,6 +946,7 @@ async function createRegistrationCheckoutInternal(registrationId, userId, { succ
}
},
payments: true,
couponRedemptions: { where: { releasedAt: null }, include: { coupon: { select: { code: true } } } },
user: { select: { id: true, name: true, email: true } },
event: true
}
@@ -924,26 +962,7 @@ async function createRegistrationCheckoutInternal(registrationId, userId, { succ
const amountInCents = Math.round(remainingAmount * 100);
const idempotencyKey = `reg_${registrationId}_${amountInCents}_${Date.now()}`;
const now = new Date();
const lastPaymentAt = registration.payments.length > 0
? new Date(Math.max(...registration.payments.map(p => new Date(p.createdAt).getTime())))
: null;
const lineItems = registration.registrationOptions.map(option => {
const variantLabel = option.variant?.name ? ` — ${option.variant.name}` : '';
// Use priceSnapshot when available (variant-aware, authoritative); fall back to deadline-only check
const unitPrice = (option.priceSnapshot !== null && option.priceSnapshot !== undefined)
? option.priceSnapshot
: require('../utils/pricing').getEffectiveUnitPrice(option.eventOption, lastPaymentAt, now);
return {
displayName: `${option.eventOption.name}${variantLabel}`,
quantity: option.quantity,
pricingDetails: {
price: Math.round(unitPrice * 100)
}
};
});
const lineItems = checkoutLineItems(registration);
// Show prior payments as a deduction line
if (totalPaid > 0) {
@@ -1047,6 +1066,7 @@ const createYocoCheckout = async (req, res) => {
}
},
payments: true,
couponRedemptions: { where: { releasedAt: null }, include: { coupon: { select: { code: true } } } },
user: { select: { id: true, name: true, email: true } },
event: true
}
@@ -1060,21 +1080,7 @@ const createYocoCheckout = async (req, res) => {
if (remainingAmount >= 15 && requested < 15) { res.status(400); throw new Error('Minimum payment is R15'); }
const charge = Math.min(requested, remainingAmount);
const amountInCents = Math.round(charge * 100);
const now = new Date();
const lastPaymentAt = registration.payments.length > 0
? new Date(Math.max(...registration.payments.map(p => new Date(p.createdAt).getTime())))
: null;
const lineItems = registration.registrationOptions.map(option => {
const variantLabel = option.variant?.name ? ` — ${option.variant.name}` : '';
const unitPrice = (option.priceSnapshot !== null && option.priceSnapshot !== undefined)
? option.priceSnapshot
: require('../utils/pricing').getEffectiveUnitPrice(option.eventOption, lastPaymentAt, now);
return {
displayName: `${option.eventOption.name}${variantLabel}`,
quantity: option.quantity,
pricingDetails: { price: Math.round(unitPrice * 100) }
};
});
const lineItems = checkoutLineItems(registration);
// Previous payments deduction
if (totalPaid > 0) {
lineItems.push({ displayName: 'Previous Payments', quantity: 1, pricingDetails: { price: -Math.round(totalPaid * 100) } });
@@ -1357,6 +1363,16 @@ const createRefund = async (req, res) => {
const { sendRefundEmail } = require('../utils/notifications');
sendRefundEmail(negativePayment.id).catch(e => console.error('Failed to send refund email:', e));
logAdminAction({
actorId: req.user.id,
actorRole: req.user.role,
action: 'refund_created',
targetType: 'Payment',
targetId: negativePayment.id,
metadata: { amount: amt, method: method || 'refund', reason: reason || null, registrationId: linkRegistrationId },
ip: getClientIp(req),
});
return res.status(201).json(negativePayment);
} catch (error) {
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
+392 -107
View File
@@ -1,11 +1,53 @@
const prisma = require('../config/db');
const { v4: uuidv4 } = require('uuid');
const axios = require("axios");
const { generateTicketsForRegistration } = require('../utils/ticketUtils');
const { emailTickets } = require('./ticketController');
const { hashPassword } = require('../config/auth');
const { resolveOptionPrice, resolveVariantTierPrice, computeRegistrationTotalDue, refreshPricingForRegistration, attachComputedTotals, attachComputedTotalsToList } = require('../utils/pricing');
const { assertEventOpen } = require('../utils/cashupUtils');
const { logAdminAction } = require('../utils/adminAudit');
const { getClientIp } = require('../utils/requestUtils');
const { buildCouponLines, previewCoupon, applyCouponToRegistration, recalculateRegistrationDiscount, releaseRegistrationCoupons, describeCoupon } = require('../utils/coupons');
// Active coupon on a registration, for API responses (the discount itself is
// Registration.discountTotal).
const ACTIVE_COUPONS_INCLUDE = {
where: { releasedAt: null },
select: { id: true, discountAmount: true, createdAt: true, coupon: { select: { id: true, code: true, type: true, value: true, description: true } } },
};
/**
* Coupon lines for options that haven't been saved yet, so a code can be checked
* before anything is created. Mirrors buildCouponLines for one tranche per option.
*/
function pendingCouponLines(event, resolvedOptions) {
return buildCouponLines({
registrationOptions: resolvedOptions.map(o => ({
eventOptionId: o.eventOptionId,
variantId: o.variantId || null,
quantity: o.quantity,
eventOption: event.eventOptions.find(eo => eo.id === o.eventOptionId),
tranches: [{ quantity: o.quantity, priceSnapshot: o.priceSnapshot, appliedTierId: o.appliedTierId }],
})),
});
}
/**
* Price one requested option for this buyer: early-bird tiers, then variant/option price,
* then the staff price for Staff and above. Returns { priceSnapshot, appliedTierId }.
*/
async function priceRequestedOption(eventOption, variantId, qty, buyer) {
try {
const resolved = variantId
? await resolveVariantTierPrice(eventOption, variantId, qty, buyer)
: await resolveOptionPrice(eventOption, qty, buyer);
return { priceSnapshot: resolved.price, appliedTierId: resolved.tierId };
} catch (e) {
const variant = variantId ? (eventOption.variants || []).find(v => v.id === variantId) : null;
const price = variant && variant.price !== null && variant.price !== undefined ? variant.price : eventOption.price;
return { priceSnapshot: Number(price || 0), appliedTierId: null };
}
}
/**
* Check overall stock availability for an EventOption.
@@ -43,7 +85,7 @@ async function checkVariantStock(variant, requestedQty) {
// @access Private
const createRegistration = async (req, res) => {
try {
const { eventId, options, guestName, guestEmail, guestPhone } = req.body;
const { eventId, options, guestName, guestEmail, guestPhone, couponCode } = req.body;
// Check if event exists
const canIncludeVariants = !!(prisma && prisma.optionVariant && typeof prisma.optionVariant.findMany === 'function');
@@ -185,30 +227,12 @@ const createRegistration = async (req, res) => {
variantPrice = variant.price; // null = use option price
}
// Resolve price: variant-level tiers take priority, then option-level tiers, then base prices
// Resolve price: variant-level tiers take priority, then option-level tiers, then
// base prices, then the staff price for Staff and above.
let priceSnapshot = null;
let appliedTierId = null;
if (canIncludeTiers) {
try {
if (variantId) {
// Try variant-specific early-bird tier first
const variantResolved = await resolveVariantTierPrice(eventOption, variantId, qty);
if (variantResolved.tierId) {
priceSnapshot = variantResolved.price;
appliedTierId = variantResolved.tierId;
} else {
// No variant tier — use the variant's own price (may be null → falls back to option price below)
priceSnapshot = variantPrice !== null && variantPrice !== undefined ? variantPrice : null;
}
} else {
// No variant — use option-level early-bird tier
const resolved = await resolveOptionPrice(eventOption, qty);
priceSnapshot = resolved.price;
appliedTierId = resolved.tierId;
}
} catch (e) {
priceSnapshot = variantPrice !== null && variantPrice !== undefined ? variantPrice : eventOption.price;
}
({ priceSnapshot, appliedTierId } = await priceRequestedOption(eventOption, variantId, qty, req.user));
} else {
priceSnapshot = variantPrice !== null && variantPrice !== undefined ? variantPrice : null;
}
@@ -217,6 +241,13 @@ const createRegistration = async (req, res) => {
return { ...option, variantId, appliedTierId, priceSnapshot };
}));
// Check a coupon code before anything is saved, so a bad code doesn't leave a
// half-made registration behind. It's applied (and re-checked under a lock) below.
if (couponCode) {
const buyer = req.user ? { id: req.user.id, role: req.user.role, email: req.user.email } : null;
await previewCoupon({ code: couponCode, eventId, user: buyer, lines: pendingCouponLines(event, resolvedOptions) });
}
// Check for existing non-cancelled registration for this user+event → merge instead
const existingReg = await prisma.registration.findFirst({
where: { userId, eventId, status: { not: 'cancelled' } },
@@ -327,10 +358,34 @@ const createRegistration = async (req, res) => {
});
}
// Apply the coupon (or re-work an existing one over the merged items).
let couponError = null;
if (!isNewRegistration) await recalculateRegistrationDiscount(registration.id);
if (couponCode) {
try {
await applyCouponToRegistration({ registrationId: registration.id, code: couponCode });
} catch (e) {
// Lost a race for the last use since the check above — keep the registration.
if (!e.isCouponError) throw e;
couponError = e.message;
}
}
if (couponCode || !isNewRegistration) {
registration = await prisma.registration.findUnique({
where: { id: registration.id },
include: {
registrationOptions: { include: { eventOption: true, tranches: true } },
event: true,
user: { select: { id: true, name: true, email: true, phoneNumber: true } },
couponRedemptions: ACTIVE_COUPONS_INCLUDE,
}
});
}
// If total due is zero, mark as paid and generate/update tickets if no required form
let freeTicketMockReq = null, freeTicketMockRes = null;
try {
const totalDue = (registration.registrationOptions || []).reduce((sum, ro) => sum + ((ro.priceSnapshot ?? ro.eventOption?.price ?? 0) * (ro.quantity || 0)), 0);
const totalDue = computeRegistrationTotalDue(registration, new Date());
if (totalDue === 0) {
// Mark paid (no-op if already paid)
if (registration.status !== 'paid') {
@@ -398,7 +453,7 @@ const createRegistration = async (req, res) => {
}
})();
res.status(201).json(attachComputedTotals(registration));
res.status(201).json({ ...attachComputedTotals(registration), ...(couponError ? { couponError } : {}) });
} catch (error) {
res.status(400).json({ message: error.message });
}
@@ -410,6 +465,7 @@ const createRegistration = async (req, res) => {
const getRegistrations = async (req, res) => {
try {
const include = {
couponRedemptions: ACTIVE_COUPONS_INCLUDE,
payments: { include: { recordedBy: { select: { id: true, name: true, email: true } } } },
registrationOptions: {
include: {
@@ -487,6 +543,7 @@ const getUserRegistrations = async (req, res) => {
tranches: true,
}
},
couponRedemptions: ACTIVE_COUPONS_INCLUDE,
// Nest the event's form so the frontend can tell whether attendee forms are
// required without a separate GET /api/events/:id per registration.
event: { include: { form: { include: { fields: true } } } },
@@ -533,7 +590,8 @@ const getRegistrationById = async (req, res) => {
}
},
payments: { include: { recordedBy: { select: { id: true, name: true, email: true } } } },
formResponses: { include: { answers: true } }
formResponses: { include: { answers: true } },
couponRedemptions: ACTIVE_COUPONS_INCLUDE,
};
let registration = await prisma.registration.findUnique({ where: { id: req.params.id }, include });
@@ -625,6 +683,23 @@ const updateRegistrationStatus = async (req, res) => {
}
});
// This is the staff-only status-change endpoint (separate from the owner-facing
// DELETE /:id cancel route), so any transition into 'cancelled' here is always a
// staff-initiated cancellation — log it the same way DELETE /:id does, so both
// paths land under the one 'registration_cancelled' filter in the audit log.
if (status === 'cancelled' && registration.status !== 'cancelled') {
await releaseRegistrationCoupons(req.params.id);
logAdminAction({
actorId: req.user.id,
actorRole: req.user.role,
action: 'registration_cancelled',
targetType: 'Registration',
targetId: req.params.id,
metadata: { registrationOwnerId: registration.userId, previousStatus: registration.status },
ip: getClientIp(req),
});
}
// Generate tickets and email them when status is manually set to 'paid' by staff
if (status === 'paid') {
(async () => {
@@ -698,6 +773,21 @@ const cancelRegistration = async (req, res) => {
updatedAt: new Date()
}
});
await releaseRegistrationCoupons(req.params.id);
// Only log when staff cancels on someone else's behalf — a routine self-service
// cancellation isn't an admin action worth cluttering the audit trail with.
if (registration.userId !== req.user.id) {
logAdminAction({
actorId: req.user.id,
actorRole: req.user.role,
action: 'registration_cancelled',
targetType: 'Registration',
targetId: req.params.id,
metadata: { registrationOwnerId: registration.userId },
ip: getClientIp(req),
});
}
res.json({ message: 'Registration cancelled', registration: updatedRegistration });
} catch (error) {
@@ -734,7 +824,8 @@ const getRegistrationsByEvent = async (req, res) => {
}
},
payments: { include: { recordedBy: { select: { id: true, name: true, email: true } } } },
user: { select: { id: true, name: true, email: true, phoneNumber: true, notificationPreference: true, isActive: true } }
user: { select: { id: true, name: true, email: true, phoneNumber: true, notificationPreference: true, isActive: true } },
couponRedemptions: ACTIVE_COUPONS_INCLUDE,
},
orderBy: { createdAt: 'asc' }
});
@@ -760,7 +851,7 @@ const getRegistrationsByEvent = async (req, res) => {
const createManualRegistration = async (req, res) => {
let userRecord;
try {
const { eventId, options, user, guestOnly, notificationPreference: prefFromBody } = req.body;
const { eventId, options, user, notificationPreference: prefFromBody, skipActivationNotice, couponCode } = req.body;
if (!eventId || !options || !user || !user.name || (!user.email && !user.phoneNumber)) {
res.status(400);
@@ -791,8 +882,7 @@ const createManualRegistration = async (req, res) => {
throw new Error('At least one option must be selected');
}
// Resolve pricing for each option (appliedTierId + priceSnapshot)
const resolvedManualOptions = [];
// Validate each option up front, before any account is created or changed.
for (const option of options) {
const eventOption = event.eventOptions.find(eo => eo.id === option.eventOptionId);
if (!eventOption) {
@@ -803,34 +893,17 @@ const createManualRegistration = async (req, res) => {
res.status(400);
throw new Error('Quantity must be at least 1');
}
const variantId = option.variantId || null;
let priceSnapshot = null;
let appliedTierId = null;
try {
if (variantId) {
const variantResolved = await resolveVariantTierPrice(eventOption, variantId, option.quantity);
if (variantResolved.tierId) {
priceSnapshot = variantResolved.price;
appliedTierId = variantResolved.tierId;
} else {
const variant = (eventOption.variants || []).find(v => v.id === variantId);
priceSnapshot = (variant && variant.price !== null && variant.price !== undefined)
? Number(variant.price)
: Number(eventOption.price || 0);
}
} else {
const resolved = await resolveOptionPrice(eventOption, option.quantity);
priceSnapshot = resolved.price;
appliedTierId = resolved.tierId;
}
} catch (e) {
priceSnapshot = Number(eventOption.price || 0);
}
resolvedManualOptions.push({ ...option, variantId, appliedTierId, priceSnapshot });
}
// Pricing (appliedTierId + priceSnapshot) depends on who the attendee is — Staff and
// above get the staff price — so it runs once the account is resolved below.
const priceManualOptions = (attendee) => Promise.all(options.map(async (option) => {
const eventOption = event.eventOptions.find(eo => eo.id === option.eventOptionId);
const variantId = option.variantId || null;
const priced = await priceRequestedOption(eventOption, variantId, option.quantity, attendee);
return { ...option, variantId, ...priced };
}));
// Resolve or create a user
let userId;
@@ -844,7 +917,7 @@ const createManualRegistration = async (req, res) => {
? prefFromBody
: (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
// 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
@@ -907,42 +980,67 @@ const createManualRegistration = async (req, res) => {
if (Object.keys(updateData).length > 0) {
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 {
// Guest path: phone-only, guestOnly=true, or no valid email
const placeholderEmail = hasValidEmail
? user.email
: `guest+${uuidv4().slice(0, 8)}@guest.local`;
const hashed = await hashPassword(uuidv4());
const created = await prisma.user.create({
data: {
id: uuidv4(),
name: user.name,
email: placeholderEmail,
password: hashed,
phoneNumber: phone || null,
isActive: false,
notificationPreference: derivedPref,
updatedAt: new Date(),
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
? user.email
: `guest+${uuidv4().slice(0, 8)}@guest.local`;
const hashed = await hashPassword(uuidv4());
const created = await prisma.user.create({
data: {
id: uuidv4(),
name: user.name,
email: placeholderEmail,
password: hashed,
phoneNumber: phone || null,
isActive: false,
notificationPreference: derivedPref,
updatedAt: new Date(),
}
});
userId = created.id;
if (!skipActivationNotice) {
const { sendActivationLink } = require('./userController');
sendActivationLink(created);
}
});
userId = created.id;
}
}
let couponError = null;
const attendee = await prisma.user.findUnique({ where: { id: userId }, select: { id: true, role: true, email: true } });
const resolvedManualOptions = await priceManualOptions(attendee);
// Check the coupon against this attendee before saving anything.
if (couponCode) {
await previewCoupon({ code: couponCode, eventId, user: attendee, lines: pendingCouponLines(event, resolvedManualOptions) });
}
// Merge into existing non-cancelled registration if one exists, otherwise create new
@@ -1003,6 +1101,18 @@ const createManualRegistration = async (req, res) => {
}
});
}));
// Re-work any coupon over the merged items, and apply a new one, before the status
// is worked out from the totals.
await recalculateRegistrationDiscount(existingReg.id);
if (couponCode) {
try {
await applyCouponToRegistration({ registrationId: existingReg.id, code: couponCode, appliedById: req.user.id });
} catch (e) {
if (!e.isCouponError) throw e;
couponError = e.message;
}
}
// Recompute status based on totals across ALL current options and payments
const freshForStatus = await prisma.registration.findUnique({
where: { id: existingReg.id },
@@ -1025,6 +1135,7 @@ const createManualRegistration = async (req, res) => {
registrationOptions: { include: { eventOption: true, tranches: true } },
event: true,
user: { select: { id: true, name: true, email: true, phoneNumber: true } },
couponRedemptions: ACTIVE_COUPONS_INCLUDE,
},
});
registration.status = newExistingStatus;
@@ -1062,10 +1173,39 @@ const createManualRegistration = async (req, res) => {
user: { select: { id: true, name: true, email: true, phoneNumber: true } },
},
});
if (couponCode) {
try {
await applyCouponToRegistration({ registrationId: registration.id, code: couponCode, appliedById: req.user.id });
} catch (e) {
if (!e.isCouponError) throw e;
couponError = e.message;
}
registration = await prisma.registration.findUnique({
where: { id: registration.id },
include: {
registrationOptions: { include: { eventOption: true, tranches: true } },
event: true,
user: { select: { id: true, name: true, email: true, phoneNumber: true } },
couponRedemptions: ACTIVE_COUPONS_INCLUDE,
},
});
}
}
const registrationId = registration.id;
if (couponCode && !couponError) {
logAdminAction({
actorId: req.user.id,
actorRole: req.user.role,
action: 'coupon_applied_manual',
targetType: 'Registration',
targetId: registrationId,
metadata: { code: String(couponCode).trim().toUpperCase(), discount: registration.discountTotal, forUserId: userId },
ip: getClientIp(req),
});
}
// Auto mark paid and generate tickets if free and no required form (new registrations only)
let hasRequiredForm = false;
try {
@@ -1075,7 +1215,7 @@ const createManualRegistration = async (req, res) => {
// Auto-mark paid and generate tickets for free registrations (new OR existing that became free)
if (registration.status !== 'paid') try {
const totalDue = (registration.registrationOptions || []).reduce((sum, ro) => sum + ((ro.priceSnapshot ?? ro.eventOption?.price ?? 0) * (ro.quantity || 0)), 0);
const totalDue = computeRegistrationTotalDue(registration, new Date());
if (totalDue === 0) {
await prisma.registration.update({ where: { id: registration.id }, data: { status: 'paid', updatedAt: new Date() } });
registration.status = 'paid';
@@ -1128,7 +1268,17 @@ const createManualRegistration = async (req, res) => {
}
})();
return res.status(201).json(attachComputedTotals(registration));
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), ...(couponError ? { couponError } : {}) });
} catch (error) {
console.error(error);
return res.status(400).json({message: error.message});
@@ -1162,6 +1312,7 @@ const updateRegistrationOptions = async (req, res) => {
},
event: { include: { eventOptions: { include: { earlyBirdTiers: true, variants: true } } } },
payments: true,
user: { select: { id: true, role: true, email: true } },
}
});
@@ -1290,19 +1441,7 @@ const updateRegistrationOptions = async (req, res) => {
let removalPlan = null;
if (delta > 0) {
try {
if (variantId) {
const variantResolved = await resolveVariantTierPrice(eventOption, variantId, delta);
priceSnapshot = variantResolved.price;
appliedTierId = variantResolved.tierId;
} else {
const resolved = await resolveOptionPrice(eventOption, delta);
priceSnapshot = resolved.price;
appliedTierId = resolved.tierId;
}
} catch (e) {
priceSnapshot = Number(eventOption?.price || 0);
}
({ priceSnapshot, appliedTierId } = await priceRequestedOption(eventOption, variantId, delta, registration.user));
newTotalDue += existingTotal + delta * (priceSnapshot || 0);
} else if (delta < 0) {
const existingTranches = existingRows.flatMap(ro => ro.tranches || []);
@@ -1434,9 +1573,16 @@ const updateRegistrationOptions = async (req, res) => {
}
}
// Coupon: percentage/comp codes follow the new items, fixed codes keep their amount.
const discount = await recalculateRegistrationDiscount(registrationId, tx);
const dueAfterDiscount = Math.max(0, newTotalDue - discount);
if (dueAfterDiscount < totalPaid - 0.001) {
throw new Error('Cannot reduce items below the amount already paid');
}
// Update updatedAt and potentially status based on payments vs due
let newStatus = registration.status;
if (totalPaid >= newTotalDue) newStatus = 'paid';
if (totalPaid >= dueAfterDiscount) newStatus = 'paid';
else if (totalPaid > 0) newStatus = 'partial_paid';
else newStatus = 'pending';
@@ -1454,6 +1600,7 @@ const updateRegistrationOptions = async (req, res) => {
event: true,
user: { select: { id: true, name: true, email: true, phoneNumber: true } },
payments: true,
couponRedemptions: ACTIVE_COUPONS_INCLUDE,
}
});
@@ -1761,7 +1908,145 @@ const sendInvoice = async (req, res) => {
}
};
/**
* After a discount changes, bring the status in line with the new total. A registration
* that a coupon has made fully paid gets its tickets, as any paid registration does.
*/
async function syncStatusAfterDiscount(registrationId) {
const reg = await prisma.registration.findUnique({
where: { id: registrationId },
include: { registrationOptions: { include: { eventOption: true, tranches: true } }, payments: true },
});
const due = computeRegistrationTotalDue(reg, new Date());
const paid = (reg.payments || []).reduce((s, p) => s + (p.amount || 0), 0);
const status = paid >= due - 0.001 ? 'paid' : paid > 0 ? 'partial_paid' : 'pending';
if (status !== reg.status) {
await prisma.registration.update({ where: { id: registrationId }, data: { status, updatedAt: new Date() } });
}
if (status === 'paid' && reg.status !== 'paid') {
(async () => {
try {
let hasRequiredForm = false;
try {
const form = await prisma.eventForm.findUnique({ where: { eventId: reg.eventId } });
hasRequiredForm = !!(form && form.isRequired);
} catch {}
if (hasRequiredForm) return;
await generateTicketsForRegistration(registrationId);
const mockReq = { user: { id: reg.userId }, body: { registrationId } };
const mockRes = { status: () => mockRes, json: () => {} };
await emailTickets(mockReq, mockRes);
} catch (e) {
console.error('[coupon] Failed to issue tickets after discount:', e?.message || e);
}
})();
}
return status;
}
const isStaffRole = (role) => ['admin', 'supervisor', 'staff'].includes(role);
// @desc Apply a coupon code to an existing registration
// @route POST /api/registrations/:id/coupon
// @access Private (owner, or staff applying it on someone's behalf)
const applyCoupon = async (req, res) => {
try {
const { code } = req.body || {};
const registration = await prisma.registration.findUnique({
where: { id: req.params.id },
include: { event: { select: { endDate: true, cashupStatus: true } } },
});
if (!registration) { res.status(404); throw new Error('Registration not found'); }
const byStaff = registration.userId !== req.user.id;
if (byStaff && !isStaffRole(req.user.role)) { res.status(403); throw new Error('Not authorized to change this registration'); }
if (registration.status === 'cancelled') { res.status(400); throw new Error('This registration is cancelled.'); }
if (registration.event?.cashupStatus === 'closed') { res.status(400); throw new Error('This event is closed.'); }
if (registration.event?.endDate && new Date(registration.event.endDate) < new Date()) {
res.status(400); throw new Error('This event has already ended.');
}
const { coupon, discount } = await applyCouponToRegistration({
registrationId: registration.id,
code,
appliedById: byStaff ? req.user.id : null,
});
await syncStatusAfterDiscount(registration.id);
if (byStaff) {
logAdminAction({
actorId: req.user.id,
actorRole: req.user.role,
action: 'coupon_applied_manual',
targetType: 'Registration',
targetId: registration.id,
metadata: { code: coupon.code, discount, forUserId: registration.userId },
ip: getClientIp(req),
});
}
const { sendRegistrationUpdatedEmails } = require('../utils/notifications');
sendRegistrationUpdatedEmails(registration.id).catch(e => console.error('Failed to send registration updated emails:', e));
const updated = await prisma.registration.findUnique({
where: { id: registration.id },
include: {
registrationOptions: { include: { eventOption: true, variant: { select: { id: true, name: true, price: true } }, tranches: true } },
payments: true,
couponRedemptions: ACTIVE_COUPONS_INCLUDE,
},
});
res.json({ ...attachComputedTotals(updated), message: `Code ${coupon.code} applied — ${describeCoupon(coupon)}.` });
} catch (error) {
res.status(error.statusCode || (res.statusCode === 200 ? 400 : res.statusCode)).json({ message: error.message });
}
};
// @desc Remove the coupon from a registration (the use is freed)
// @route DELETE /api/registrations/:id/coupon
// @access Private/Staff
const removeCoupon = async (req, res) => {
try {
const registration = await prisma.registration.findUnique({ where: { id: req.params.id } });
if (!registration) { res.status(404); throw new Error('Registration not found'); }
await assertEventOpen(registration.eventId, res);
// Removing a discount raises the total, which can take a paid registration back to
// unpaid. Same rule as a staff status change: refuse once a ticket has been scanned,
// otherwise withdraw the unused tickets until it's paid again.
const tickets = await prisma.ticket.findMany({
where: { registrationOption: { registrationId: registration.id } },
include: { usages: true },
});
if (registration.status === 'paid' && tickets.some(t => t.isUsed || t.usages.length > 0)) {
res.status(400);
throw new Error('A ticket on this registration has already been scanned, so its code can\'t be removed.');
}
const released = await releaseRegistrationCoupons(registration.id);
if (!released) { res.status(400); throw new Error('No code is applied to this registration.'); }
const status = await syncStatusAfterDiscount(registration.id);
if (registration.status === 'paid' && status !== 'paid') {
await prisma.ticket.deleteMany({ where: { registrationOption: { registrationId: registration.id }, isUsed: false } });
}
logAdminAction({
actorId: req.user.id,
actorRole: req.user.role,
action: 'coupon_applied_manual',
targetType: 'Registration',
targetId: registration.id,
metadata: { removed: true, forUserId: registration.userId },
ip: getClientIp(req),
});
res.json({ message: 'Code removed.' });
} catch (error) {
res.status(error.statusCode || (res.statusCode === 200 ? 400 : res.statusCode)).json({ message: error.message });
}
};
module.exports = {
applyCoupon,
removeCoupon,
createRegistration,
getRegistrations,
getUserRegistrations,
+19 -1
View File
@@ -4,6 +4,8 @@ const { safeErrorMessage } = require('../utils/errorUtils');
const { v4: uuidv4 } = require('uuid');
const { invalidate: invalidateSettingsCache, warmCache, ENCRYPTED_KEYS } = require('../utils/settingsCache');
const { encrypt, decrypt, isEncrypted } = require('../utils/encryption');
const { logAdminAction } = require('../utils/adminAudit');
const { getClientIp } = require('../utils/requestUtils');
// Keys safe to return without auth — includes legal keys needed by public legal pages
const PUBLIC_KEYS = [
@@ -107,6 +109,20 @@ const updateSettings = async (req, res) => {
if (ops.length) await prisma.$transaction(ops);
invalidateSettingsCache();
await warmCache(); // ensure in-memory cache reflects the new values before responding
// Log which keys changed, never the values — some settings are secrets (e.g. the
// WAWP token) that aren't even encrypted at rest, let alone fit for an audit log.
if (ops.length) {
logAdminAction({
actorId: req.user.id,
actorRole: req.user.role,
action: 'settings_updated',
targetType: 'AppSetting',
metadata: { changedKeys: Object.keys(updates).filter(k => updates[k] !== undefined && updates[k] !== null) },
ip: getClientIp(req),
});
}
res.json({ message: 'Settings saved' });
} catch (e) {
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(e) });
@@ -156,7 +172,9 @@ const setupRegister = async (req, res) => {
},
});
const token = generateToken(user.id, user.role, user.tokenVersion ?? 0);
// Marked as a setup token so the 2FA requirement doesn't block the rest of the
// wizard; the admin enrols at their first normal login.
const token = generateToken(user.id, user.role, user.tokenVersion ?? 0, { setup: true });
res.json({ token, name: user.name, email: user.email });
} catch (e) {
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(e) });
+11 -4
View File
@@ -1,7 +1,15 @@
const path = require('path');
const fs = require('fs');
const crypto = require('crypto');
const multer = require('multer');
// Builds a filename multer can never be tricked into escaping the upload
// directory with — extension only, no attacker-controlled path segments.
// (file.originalname is untrusted; joining it into a path allows `../` traversal.)
function safeFilename(prefix, ext) {
return `${prefix}-${Date.now()}-${crypto.randomBytes(8).toString('hex')}${ext}`;
}
// Setup multer storage
const storage = multer.diskStorage({
destination: function (req, file, cb) {
@@ -24,8 +32,7 @@ const storage = multer.diskStorage({
}
},
filename: function (req, file, cb) {
const uniqueName = `${Date.now()}-${file.originalname}`;
cb(null, uniqueName);
cb(null, safeFilename('event', path.extname(file.originalname).toLowerCase()));
}
});
@@ -54,7 +61,7 @@ const logoStorage = multer.diskStorage({
}
},
filename: function (req, file, cb) {
cb(null, `logo-${Date.now()}${path.extname(file.originalname).toLowerCase()}`);
cb(null, safeFilename('logo', path.extname(file.originalname).toLowerCase()));
}
});
@@ -82,7 +89,7 @@ const faviconStorage = multer.diskStorage({
}
},
filename: function (req, file, cb) {
cb(null, `favicon-${Date.now()}${path.extname(file.originalname).toLowerCase()}`);
cb(null, safeFilename('favicon', path.extname(file.originalname).toLowerCase()));
}
});
+318 -64
View File
@@ -4,6 +4,18 @@ const { v4: uuidv4 } = require('uuid');
const { safeErrorMessage } = require('../utils/errorUtils');
const { logSecurityEvent, getRecentSecurityEvents } = require('../utils/securityEvents');
const { getSettingSync } = require('../utils/settingsCache');
const {
METHODS: TWO_FACTOR_METHODS,
availableMethods,
createChallenge,
verifyChallenge,
resendChallenge,
issueTrustedDevice,
isTrustedDevice,
clearTrustedDevices,
requiresTwoFactor,
enforcementMethod,
} = require('../utils/twoFactor');
const axios = require('axios');
// ─── Helpers ────────────────────────────────────────────────────────────────
@@ -23,6 +35,53 @@ function getClientIp(req) {
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
async function sendLoginNotification(user, req) {
try {
@@ -77,6 +136,35 @@ async function sendWelcomeEmail(user) {
}
}
// Fields the login flow needs once the password (and any 2FA code) has checked out.
const LOGIN_USER_SELECT = {
id: true, name: true, email: true, role: true, tokenVersion: true, isActive: true,
phoneNumber: true, notificationPreference: true, twoFactorEnabled: true, twoFactorMethod: true,
};
// Errors from utils/twoFactor carry their own HTTP status; everything else follows
// the controller convention of res.status(...) before throwing.
function errorStatus(error, res) {
return error?.statusCode || (res.statusCode === 200 ? 400 : res.statusCode);
}
// Final step of a successful login (after the password, and the 2FA code when
// required): notify, record the activity, and issue the session token.
function completeLogin(user, req, res, extra = {}) {
// Send login notification in the background
sendLoginNotification(user, req).catch(() => {});
logSecurityEvent({ userId: user.id, type: 'login', ip: getClientIp(req), userAgent: req.headers['user-agent'] }).catch(() => {});
res.json({
id: user.id,
name: user.name,
email: user.email,
role: user.role,
token: generateToken(user.id, user.role, user.tokenVersion),
...extra
});
}
// @desc Register a new user
// @route POST /api/users
// @access Public
@@ -151,7 +239,7 @@ const registerUser = async (req, res) => {
// @access Public
const loginUser = async (req, res) => {
try {
const {email, password} = req.body;
const {email, password, deviceToken} = req.body;
const rawInput = email;
@@ -176,61 +264,16 @@ const loginUser = async (req, res) => {
// Check if user is active
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')) {
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);
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) {
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);
throw new Error('Your account is not yet active. We\'ve sent you a WhatsApp message with a link to activate your account.');
}
@@ -276,20 +319,179 @@ const loginUser = async (req, res) => {
const updated = await prisma.user.update({
where: { id: user.id },
data: { failedAttempts: 0, lockUntil: null },
select: { id: true, name: true, email: true, role: true, tokenVersion: true, phoneNumber: true, notificationPreference: true },
select: LOGIN_USER_SELECT,
});
// Send login notification in the background
sendLoginNotification(updated, req).catch(() => {});
logSecurityEvent({ userId: updated.id, type: 'login', ip: getClientIp(req), userAgent: req.headers['user-agent'] }).catch(() => {});
// Two-factor: the password alone isn't enough unless this browser was
// remembered earlier. Send a code and hand back a challenge instead of a token.
if (updated.twoFactorEnabled && !(await isTrustedDevice(updated.id, deviceToken))) {
const challenge = await createChallenge(updated, 'login', updated.twoFactorMethod || 'email');
return res.json({ twoFactorRequired: true, ...challenge });
}
res.json({
id: updated.id,
name: updated.name,
email: updated.email,
role: updated.role,
token: generateToken(updated.id, updated.role, updated.tokenVersion)
// Admins and supervisors must use 2FA. Not set up yet: send a code to the
// first channel that can deliver one, and verifying it turns 2FA on. A
// trusted device can't skip this — nothing has been verified yet.
if (!updated.twoFactorEnabled && requiresTwoFactor(updated)) {
const method = await enforcementMethod(updated);
if (method) {
const challenge = await createChallenge(updated, 'login', method);
return res.json({ twoFactorRequired: true, enrolling: true, ...challenge });
}
}
completeLogin(updated, req, res);
} catch (error) {
res.status(errorStatus(error, res)).json({ message: safeErrorMessage(error) });
}
};
// @desc Second login step — exchange an emailed/WhatsApp code for a token
// @route POST /api/users/login/2fa
// @access Public
const verifyLoginTwoFactor = async (req, res) => {
try {
const { challengeId, code, rememberDevice } = req.body || {};
const challenge = await verifyChallenge(challengeId, code, 'login');
let user = await prisma.user.findUnique({ where: { id: challenge.userId }, select: LOGIN_USER_SELECT });
if (!user || !user.isActive) {
res.status(401);
throw new Error('Your account has been deactivated');
}
// First login since 2FA became required for this role: the code proves the
// channel works, so turn 2FA on with it.
if (!user.twoFactorEnabled && requiresTwoFactor(user)) {
user = await prisma.user.update({
where: { id: user.id },
data: { twoFactorEnabled: true, twoFactorMethod: challenge.method, updatedAt: new Date() },
select: LOGIN_USER_SELECT,
});
logSecurityEvent({ userId: user.id, type: 'two_factor_enabled', ip: getClientIp(req), userAgent: req.headers['user-agent'] }).catch(() => {});
}
const extra = {};
if (rememberDevice === true) {
extra.deviceToken = await issueTrustedDevice(user.id, req.headers['user-agent']);
}
completeLogin(user, req, res, extra);
} catch (error) {
res.status(errorStatus(error, res)).json({ message: safeErrorMessage(error) });
}
};
// @desc Send a new code for a pending login challenge
// @route POST /api/users/login/2fa/resend
// @access Public
const resendLoginTwoFactor = async (req, res) => {
try {
const { challengeId } = req.body || {};
res.json(await resendChallenge(challengeId, 'login'));
} catch (error) {
res.status(errorStatus(error, res)).json({ message: safeErrorMessage(error) });
}
};
// @desc Start turning on 2FA — sends a code to the chosen channel to prove it works
// @route POST /api/users/2fa/enable
// @access Private
const startEnableTwoFactor = async (req, res) => {
try {
const { method, password } = req.body || {};
const user = await prisma.user.findUnique({ where: { id: req.user.id } });
if (!user) { res.status(404); throw new Error('User not found'); }
if (typeof password !== 'string' || !(await comparePassword(password, user.password))) {
res.status(400);
throw new Error('Incorrect password');
}
if (!TWO_FACTOR_METHODS.includes(method)) {
res.status(400);
throw new Error('Choose email or WhatsApp for your verification codes');
}
if (!(await availableMethods(user)).includes(method)) {
res.status(400);
throw new Error(method === 'whatsapp'
? 'WhatsApp codes aren\'t available for your account. Add a valid phone number or choose email.'
: 'Add a valid email address to your profile before turning on two-factor authentication.');
}
// Unlike login, don't fall back to email here — the point of this step is to
// prove the channel the user picked actually reaches them.
res.json(await createChallenge(user, 'enable', method, { allowFallback: false }));
} catch (error) {
res.status(errorStatus(error, res)).json({ message: safeErrorMessage(error) });
}
};
// @desc Finish turning on 2FA with the code that was sent
// @route POST /api/users/2fa/confirm
// @access Private
const confirmEnableTwoFactor = async (req, res) => {
try {
const { challengeId, code } = req.body || {};
const challenge = await verifyChallenge(challengeId, code, 'enable', req.user.id);
await prisma.user.update({
where: { id: req.user.id },
data: { twoFactorEnabled: true, twoFactorMethod: challenge.method, updatedAt: new Date() },
});
logSecurityEvent({ userId: req.user.id, type: 'two_factor_enabled', ip: getClientIp(req), userAgent: req.headers['user-agent'] }).catch(() => {});
res.json({ twoFactorEnabled: true, twoFactorMethod: challenge.method });
} catch (error) {
res.status(errorStatus(error, res)).json({ message: safeErrorMessage(error) });
}
};
// @desc Turn off 2FA
// @route POST /api/users/2fa/disable
// @access Private
const disableTwoFactor = async (req, res) => {
try {
const { password } = req.body || {};
const user = await prisma.user.findUnique({ where: { id: req.user.id } });
if (!user) { res.status(404); throw new Error('User not found'); }
if (requiresTwoFactor(user)) {
res.status(403);
throw new Error("Two-factor authentication is required for your role and can't be turned off.");
}
if (typeof password !== 'string' || !(await comparePassword(password, user.password))) {
res.status(400);
throw new Error('Incorrect password');
}
await prisma.user.update({
where: { id: user.id },
data: { twoFactorEnabled: false, twoFactorMethod: null, updatedAt: new Date() },
});
await clearTrustedDevices(user.id);
if (user.twoFactorEnabled) {
logSecurityEvent({ userId: user.id, type: 'two_factor_disabled', ip: getClientIp(req), userAgent: req.headers['user-agent'] }).catch(() => {});
}
res.json({ twoFactorEnabled: false, twoFactorMethod: null });
} catch (error) {
res.status(errorStatus(error, res)).json({ message: safeErrorMessage(error) });
}
};
// @desc Re-confirm the logged-in user's password without starting a new session
// (the self-service kiosk uses this before letting the event be changed)
// @route POST /api/users/verify-password
// @access Private
const verifyPassword = async (req, res) => {
try {
const { password } = req.body || {};
const user = await prisma.user.findUnique({ where: { id: req.user.id }, select: { password: true } });
if (!user || typeof password !== 'string' || !(await comparePassword(password, user.password))) {
res.status(401);
throw new Error('Incorrect password');
}
res.json({ ok: true });
} catch (error) {
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
}
@@ -309,6 +511,8 @@ const getUserProfile = async (req, res) => {
role: true,
phoneNumber: true,
notificationPreference: true,
twoFactorEnabled: true,
twoFactorMethod: true,
createdAt: true,
updatedAt: true,
isActive: true
@@ -316,7 +520,7 @@ const getUserProfile = async (req, res) => {
});
if (user) {
res.json(user);
res.json({ ...user, twoFactorRequired: requiresTwoFactor(user) });
} else {
res.status(404);
throw new Error('User not found');
@@ -373,6 +577,11 @@ const updateUserProfile = async (req, res) => {
const { resolveNotificationPreference } = require('../utils/notificationPreference');
const newPref = resolveNotificationPreference(notificationPreference, newPhone, user.notificationPreference);
// 2FA codes can't go to WhatsApp without a usable number — move them to email
// rather than leave the account pointing at a channel that no longer exists.
const { isValidZAPhone } = require('../utils/whatsapp');
const newTwoFactorMethod = user.twoFactorMethod === 'whatsapp' && !isValidZAPhone(newPhone) ? 'email' : user.twoFactorMethod;
// Update user data
const updatedUser = await prisma.user.update({
where: { id: req.user.id },
@@ -382,6 +591,7 @@ const updateUserProfile = async (req, res) => {
password: newHashedPassword ? newHashedPassword : user.password,
phoneNumber: newPhone,
notificationPreference: newPref,
twoFactorMethod: newTwoFactorMethod,
updatedAt: new Date()
},
select: {
@@ -391,6 +601,8 @@ const updateUserProfile = async (req, res) => {
role: true,
phoneNumber: true,
notificationPreference: true,
twoFactorEnabled: true,
twoFactorMethod: true,
createdAt: true,
updatedAt: true,
isActive: true,
@@ -400,6 +612,7 @@ const updateUserProfile = async (req, res) => {
// If the password was changed, send a security alert email (fire-and-forget)
if (newHashedPassword) {
await clearTrustedDevices(updatedUser.id);
const { sendMail, buildPasswordChangedEmail } = require('../utils/email');
const supportEmail = getSettingSync('org_email', process.env.EMAIL_FROM || '');
const content = buildPasswordChangedEmail({ name: updatedUser.name, when: Date.now(), supportEmail });
@@ -412,6 +625,7 @@ const updateUserProfile = async (req, res) => {
res.json({
...updatedUser,
twoFactorRequired: requiresTwoFactor(updatedUser),
token: generateToken(updatedUser.id, updatedUser.role, updatedUser.tokenVersion)
});
} catch (error) {
@@ -430,7 +644,7 @@ const getUsers = async (req, res) => {
const select = {
id: true, name: true, email: true, role: true,
phoneNumber: true, notificationPreference: true, createdAt: true, updatedAt: true, isActive: true
phoneNumber: true, notificationPreference: true, twoFactorEnabled: true, createdAt: true, updatedAt: true, isActive: true
};
// Build filters
@@ -557,7 +771,7 @@ const updateUser = async (req, res) => {
throw new Error('User not found');
}
const { name, email, role, isActive, phoneNumber, password, notificationPreference } = req.body;
const { name, email, role, isActive, phoneNumber, password, notificationPreference, twoFactorEnabled } = req.body;
const newPhone = phoneNumber !== undefined ? (phoneNumber || null) : user.phoneNumber;
const { resolveNotificationPreference } = require('../utils/notificationPreference');
@@ -578,6 +792,21 @@ const updateUser = async (req, res) => {
data.password = await hashPassword(password.trim());
}
// Admins can switch 2FA off for someone who has lost access to their codes,
// but never on — enabling requires the user to prove the channel themselves.
const resetTwoFactor = twoFactorEnabled === false && user.twoFactorEnabled;
if (resetTwoFactor) {
data.twoFactorEnabled = false;
data.twoFactorMethod = null;
}
// Promoted to a role that requires 2FA without having it: end their sessions
// so the next login enrols them.
const willHaveTwoFactor = resetTwoFactor ? false : user.twoFactorEnabled;
if (!willHaveTwoFactor && data.role !== user.role && requiresTwoFactor({ role: data.role })) {
data.tokenVersion = { increment: 1 };
}
const updatedUser = await prisma.user.update({
where: { id: req.params.id },
data,
@@ -588,12 +817,20 @@ const updateUser = async (req, res) => {
role: true,
phoneNumber: true,
notificationPreference: true,
twoFactorEnabled: true,
createdAt: true,
updatedAt: true,
isActive: true
}
});
if (resetTwoFactor || data.password) {
await clearTrustedDevices(updatedUser.id);
}
if (resetTwoFactor) {
logSecurityEvent({ userId: updatedUser.id, type: 'two_factor_disabled', ip: getClientIp(req), userAgent: req.headers['user-agent'] }).catch(() => {});
}
res.json(updatedUser);
} catch (error) {
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
@@ -644,10 +881,13 @@ const anonymizeUser = async (req, res) => {
email: `deleted-${req.params.id}@deleted.local`,
phoneNumber: null,
isActive: false,
twoFactorEnabled: false,
twoFactorMethod: null,
tokenVersion: { increment: 1 },
updatedAt: new Date()
}
});
await clearTrustedDevices(req.params.id);
res.json({ message: 'User data deleted' });
} catch (error) {
@@ -752,6 +992,7 @@ const resetPassword = async (req, res) => {
prisma.user.update({ where: { id: user.id }, data: { password: newHashed, updatedAt: new Date() } }),
prisma.passwordReset.update({ where: { token }, data: { used: true } })
]);
await clearTrustedDevices(user.id);
logSecurityEvent({ userId: user.id, type: 'password_reset', ip: getClientIp(req), userAgent: req.headers['user-agent'] }).catch(() => {});
@@ -836,6 +1077,7 @@ const revokeMySession = async (req, res) => {
data: { tokenVersion: { increment: 1 } },
select: { id: true, role: true, tokenVersion: true },
});
await clearTrustedDevices(updated.id);
// Bumping tokenVersion invalidates every existing token, including the one
// this request just used. Issue a fresh token for the current device so it
// isn't logged out too.
@@ -862,6 +1104,7 @@ const adminRevokeUserSessions = async (req, res) => {
where: { id: req.params.id },
data: { tokenVersion: { increment: 1 } },
});
await clearTrustedDevices(req.params.id);
res.json({ message: `Sessions revoked for ${target.name}.` });
} catch (error) {
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
@@ -899,10 +1142,13 @@ const closeAccount = async (req, res) => {
password: '',
phoneNumber: null,
isActive: false,
twoFactorEnabled: false,
twoFactorMethod: null,
tokenVersion: { increment: 1 },
updatedAt: new Date(),
},
});
await clearTrustedDevices(req.user.id);
// Send closure confirmation to the real address (before it was wiped)
if (realEmail && !realEmail.endsWith('@deleted.invalid') && !realEmail.endsWith('@guest.local')) {
const { sendMail, buildAccountClosedEmail } = require('../utils/email');
@@ -924,6 +1170,7 @@ const closeAccount = async (req, res) => {
updatedAt: new Date(),
},
});
await clearTrustedDevices(req.user.id);
// Send closure confirmation
if (realEmail && !realEmail.endsWith('@deleted.invalid') && !realEmail.endsWith('@guest.local')) {
const { sendMail, buildAccountClosedEmail } = require('../utils/email');
@@ -955,6 +1202,12 @@ const getMyActivity = async (req, res) => {
module.exports = {
registerUser,
loginUser,
verifyLoginTwoFactor,
resendLoginTwoFactor,
startEnableTwoFactor,
confirmEnableTwoFactor,
disableTwoFactor,
verifyPassword,
getUserProfile,
updateUserProfile,
getUsers,
@@ -970,4 +1223,5 @@ module.exports = {
adminRevokeUserSessions,
closeAccount,
getMyActivity,
sendActivationLink,
};
+157 -13
View File
@@ -1,16 +1,33 @@
const express = require('express');
const path = require('path');
const { version: API_VERSION } = require('../package.json');
const cors = require('cors');
const rateLimit = require('express-rate-limit');
const dotenv = require('dotenv');
const { PrismaClient } = require('@prisma/client');
const { notFound, errorHandler } = require('./middleware/errorMiddleware');
const getRawBody = require('raw-body');
// Load environment variables
dotenv.config();
// Error monitoring — a no-op if SENTRY_DSN isn't set, so this is safe in every
// environment (dev, a fresh deploy that hasn't configured Sentry yet, etc.).
// Must run before express/@prisma/client are required below — Sentry's
// auto-instrumentation patches those modules via a require hook, which only
// works if Sentry.init() runs before they're first required into the cache.
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,
});
}
const express = require('express');
const { version: API_VERSION } = require('../package.json');
const cors = require('cors');
const rateLimit = require('express-rate-limit');
const { PrismaClient } = require('@prisma/client');
const { notFound, errorHandler } = require('./middleware/errorMiddleware');
const getRawBody = require('raw-body');
// Initialize Prisma client
const prisma = new PrismaClient();
@@ -18,6 +35,22 @@ const prisma = new PrismaClient();
const app = express();
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
const allowedOrigins = (process.env.FRONTEND_URL || 'http://localhost:3000')
.split(',')
@@ -110,6 +143,8 @@ const setupRoutes = require('./routes/setupRoutes');
const costRoutes = require('./routes/costRoutes');
const cashupRoutes = require('./routes/cashupRoutes');
const statsRoutes = require('./routes/statsRoutes');
const adminRoutes = require('./routes/adminRoutes');
const backupRoutes = require('./routes/backupRoutes');
// Mount webhook routes BEFORE JSON body parser to avoid double-reading the stream
app.use('/api/webhooks', webhookRoutes);
@@ -139,11 +174,23 @@ app.use('/api/setup', setupRoutes);
app.use('/api/stats', statsRoutes);
app.use('/api', costRoutes);
app.use('/api/cashups', cashupRoutes);
app.use('/api/admin', adminRoutes);
app.use('/api/coupons', require('./routes/couponRoutes'));
app.use('/api/backups', backupRoutes);
// Pre-warm the settings cache so synchronous helpers have DB values from startup
const { getSettingSync, warmCache } = require('./utils/settingsCache');
warmCache().catch(() => {});
app.use('/uploads', express.static('public/uploads'));
// Uploaded branding assets can include SVGs, which may embed <script>/event
// handlers. Serving them inline lets a compromised/malicious upload run script
// in the site's origin if opened directly, so pin the safe response headers
// (no inline execution, no MIME-sniffing to HTML/script) on every asset here.
app.use('/uploads', express.static('public/uploads', {
setHeaders: (res) => {
res.setHeader('Content-Security-Policy', "default-src 'none'; style-src 'unsafe-inline'; sandbox");
res.setHeader('X-Content-Type-Options', 'nosniff');
},
}));
// ── Shared page helpers ────────────────────────────────────────────────────────
const jwt = require('jsonwebtoken');
@@ -313,7 +360,7 @@ app.get('/docs', async (req, res) => {
let user;
try {
const decoded = jwt.verify(token, process.env.JWT_SECRET);
const decoded = jwt.verify(token, process.env.JWT_SECRET, { algorithms: ['HS256'] });
user = await prisma.user.findUnique({
where: { id: decoded.id },
select: { id: true, name: true, email: true, role: true, isActive: true, tokenVersion: true },
@@ -345,8 +392,24 @@ app.get('/docs', async (req, res) => {
request:{ body:{ email:'jane@example.com', password:'secret123' }},
responses:[
{ status:200, desc:'Success', body:{ id:'a1b2c3d4-...', name:'Jane Doe', email:'jane@example.com', role:'user', token:'eyJhbGciOiJIUzI1NiJ9...' }},
{ status:200, desc:'Two-factor code sent (account has 2FA on) — no token yet', body:{ twoFactorRequired:true, challengeId:'c0ffee00-...', method:'email', destination:'j•••@example.com' }},
{ status:200, desc:'Admin/supervisor without 2FA — code sent; verifying it turns 2FA on', body:{ twoFactorRequired:true, enrolling:true, challengeId:'c0ffee00-...', method:'email', destination:'j•••@example.com' }},
{ status:401, desc:'Wrong credentials', body:{ message:'Invalid email or password' }},
]},
{ method:'POST', path:'/api/users/login/2fa', auth:'public', desc:'Second login step — exchange the emailed/WhatsApp code for a JWT',
notes:'Codes expire after 10 minutes and allow 5 attempts. With rememberDevice, the response includes a deviceToken; send it with later logins to skip the code for 30 days.',
request:{ body:{ challengeId:'c0ffee00-...', code:'123456', rememberDevice:true }},
responses:[
{ status:200, desc:'Success', body:{ id:'a1b2c3d4-...', name:'Jane Doe', email:'jane@example.com', role:'user', token:'eyJhbGciOiJIUzI1NiJ9...', deviceToken:'9f2c...' }},
{ status:400, desc:'Wrong or expired code', body:{ message:'Incorrect code. 4 attempts left.' }},
]},
{ method:'POST', path:'/api/users/login/2fa/resend', auth:'public', desc:'Send a new code for a pending login challenge',
notes:'At most one send per 60 seconds.',
request:{ body:{ challengeId:'c0ffee00-...' }},
responses:[
{ status:200, desc:'Sent', body:{ challengeId:'c0ffee00-...', method:'email', destination:'j•••@example.com' }},
{ status:429, desc:'Too soon', body:{ message:'Please wait 42 seconds before requesting another code.' }},
]},
{ method:'POST', path:'/api/users/forgot', auth:'public', desc:'Request a password reset email',
request:{ body:{ email:'jane@example.com' }},
responses:[
@@ -378,6 +441,29 @@ app.get('/docs', async (req, res) => {
responses:[{ status:200, desc:'Success', body:{ message:'All sessions revoked.' }}]},
{ method:'POST', path:'/api/users/close-account', auth:'user+', desc:'Deactivate own account',
responses:[{ status:200, desc:'Success', body:{ message:'Account deactivated.' }}]},
{ method:'POST', path:'/api/users/2fa/enable', auth:'user+', desc:'Start turning on two-factor authentication — sends a code to the chosen channel',
notes:'WhatsApp needs a valid phone number on the account and a connected WhatsApp session.',
request:{ body:{ method:'email', password:'secret123' }},
responses:[
{ status:200, desc:'Code sent', body:{ challengeId:'c0ffee00-...', method:'email', destination:'j•••@example.com' }},
{ status:400, desc:'Wrong password or unavailable channel', body:{ message:'Incorrect password' }},
]},
{ method:'POST', path:'/api/users/2fa/confirm', auth:'user+', desc:'Finish turning on two-factor authentication with the code that was sent',
request:{ body:{ challengeId:'c0ffee00-...', code:'123456' }},
responses:[{ status:200, desc:'Enabled', body:{ twoFactorEnabled:true, twoFactorMethod:'email' }}]},
{ method:'POST', path:'/api/users/2fa/disable', auth:'user+', desc:'Turn off two-factor authentication',
request:{ body:{ password:'secret123' }},
notes:'Admins and supervisors must keep two-factor on — 403 for them.',
responses:[
{ status:200, desc:'Disabled', body:{ twoFactorEnabled:false, twoFactorMethod:null }},
{ status:403, desc:'Required for this role', body:{ message:"Two-factor authentication is required for your role and can't be turned off." }},
]},
{ method:'POST', path:'/api/users/verify-password', auth:'user+', desc:'Re-confirm own password without starting a new session',
request:{ body:{ password:'secret123' }},
responses:[
{ status:200, desc:'Correct', body:{ ok:true }},
{ status:401, desc:'Wrong password', body:{ message:'Incorrect password' }},
]},
{ method:'GET', path:'/api/users', auth:'supervisor+', desc:'List all users (paginated). All filters are applied server-side.',
queryParams:{ page:'Page number (default 1)', limit:'Items per page (max 200, default 100)', search:'Search by name, email, or phone number (case-insensitive substring)', role:'Filter by role: admin|supervisor|staff|user', isActive:'Filter by active status: true|false (omit for both)' },
responses:[
@@ -542,6 +628,22 @@ app.get('/docs', async (req, res) => {
]},
]},
{ title: 'Coupons', base: '/api/coupons', endpoints: [
{ method:'POST', path:'/api/coupons/validate', auth:'public', desc:'Check a code against a basket before registering; returns the discount. Login needed for restricted codes; staff can pass forUserId (or null for a walk-in).',
request:{ body:{ code:'STAFF50', eventId:'ev-uuid-...', options:[{ eventOptionId:'opt-uuid-...', quantity:2 }] }},
responses:[
{ status:200, desc:'Valid', body:{ valid:true, code:'STAFF50', type:'percent', value:50, summary:'50% off', discount:450, subtotal:900, total:450 }},
{ status:400, desc:'Not usable', body:{ valid:false, message:'This code has expired.' }},
]},
{ method:'GET', path:'/api/coupons', auth:'supervisor+', desc:'List codes with use counts and total discount given',
responses:[{ status:200, desc:'Success', body:[{ id:'cpn-uuid-...', code:'STAFF50', type:'percent', value:50, summary:'50% off', active:true, uses:3, totalDiscount:1350 }]}]},
{ method:'POST', path:'/api/coupons', auth:'supervisor+', desc:'Create a code. type: percent | fixed | comp. eventIds/options/userIds/restrictRoles/restrictDomain are optional.',
request:{ body:{ code:'STAFF50', type:'percent', value:50, eventIds:['ev-uuid-...'], restrictRoles:['staff','supervisor','admin'], maxUsesPerUser:1, stackEarlyBird:false }},
responses:[{ status:201, desc:'Created', body:{ id:'cpn-uuid-...', code:'STAFF50' }}, { status:409, desc:'Code exists', body:{ message:'The code STAFF50 already exists' }}]},
{ method:'GET', path:'/api/coupons/:id', auth:'supervisor+', desc:'One code with every use (redemption)' },
{ method:'PUT', path:'/api/coupons/:id', auth:'supervisor+', desc:'Edit a code or turn it off ({ active:false }). Discounts already given keep their amount; a used code can\'t be renamed.' },
{ method:'GET', path:'/api/coupons/redemptions', auth:'supervisor+', desc:'Active uses for the Discounts report. Query: eventIds (comma-separated), from, to' },
]},
{ title: 'Payments', base: '/api/payments', endpoints: [
{ method:'POST', path:'/api/payments/yoco-checkout', auth:'user+', desc:'Initiate a Yoco checkout session. Before creating the checkout, re-evaluates early-bird tier eligibility (deadline + stock). If any price changed since registration, returns priceUpdated:true instead of creating a checkout — the client must inform the user and retry.',
request:{ body:{ registrationId:'reg-uuid-...', amount:450, successUrl:'https://events.example.com/payment/success', cancelUrl:'https://events.example.com/payment/cancel', failureUrl:'https://events.example.com/payment/failure' }},
@@ -1118,6 +1220,14 @@ function toggle(id) {
// Error middleware
app.use(notFound);
// Sentry captures the error here, then passes it through unchanged — errorHandler
// below remains the sole source of what's actually sent back to the client.
if (process.env.SENTRY_DSN) {
const Sentry = require('@sentry/node');
Sentry.setupExpressErrorHandler(app);
}
app.use(errorHandler);
// Start server
@@ -1208,6 +1318,36 @@ app.listen(PORT, () => {
console.warn('[temp cleanup] Not scheduled:', e?.message || e);
}
// Nightly database backup at 02:00 local time (before the 03:00 temp cleanup)
try {
const enabled = String(process.env.BACKUP_ENABLED || 'true').toLowerCase() !== 'false';
if (enabled) {
const { runBackup } = require('./utils/backupUtils');
function scheduleNightlyBackup() {
const now = new Date();
const next = new Date(now);
next.setHours(2, 0, 0, 0);
if (next <= now) next.setDate(next.getDate() + 1);
setTimeout(async () => {
try {
const result = await runBackup();
console.log(`[backup] Completed: ${result.filename}`);
} catch (e) {
console.error('[backup] Failed:', e?.message || e);
} finally {
scheduleNightlyBackup();
}
}, next.getTime() - now.getTime());
}
scheduleNightlyBackup();
console.log('[backup] Scheduler initialized (02:00 local time). Set BACKUP_ENABLED=false to disable.');
} else {
console.log('[backup] Scheduler disabled by env BACKUP_ENABLED=false');
}
} catch (e) {
console.warn('[backup] Not scheduled:', e?.message || e);
}
// Scheduled emails worker (polling)
try {
const enabled = String(process.env.SCHEDULED_EMAILS_ENABLED || 'true').toLowerCase() !== 'false';
@@ -1265,11 +1405,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) => {
console.log('UNHANDLED REJECTION! Shutting down...');
console.log(err.name, err.message);
process.exit(1);
console.error('UNHANDLED REJECTION!', err?.name, err?.message);
console.error(err?.stack || err);
if (process.env.SENTRY_DSN) {
try { require('@sentry/node').captureException(err); } catch {}
}
});
module.exports = { app, prisma };
+21 -4
View File
@@ -2,6 +2,10 @@ const rateLimit = require("express-rate-limit");
const jwt = require('jsonwebtoken');
const prisma = require('../config/db');
const { mustEnrolTwoFactor } = require('../utils/twoFactor');
// The setup wizard's token may finish setup without 2FA for this long.
const SETUP_TOKEN_GRACE_SECONDS = 2 * 60 * 60;
// Protect routes - verify token
const protect = async (req, res, next) => {
@@ -13,8 +17,9 @@ const protect = async (req, res, next) => {
// Get token from header
token = req.headers.authorization.split(' ')[1];
// Verify token
const decoded = jwt.verify(token, process.env.JWT_SECRET);
// Verify token — pin the algorithm so a token signed with an
// unexpected/attacker-chosen algorithm is never accepted.
const decoded = jwt.verify(token, process.env.JWT_SECRET, { algorithms: ['HS256'] });
// Get user from the token (exclude password)
req.user = await prisma.user.findUnique({
@@ -28,7 +33,8 @@ const protect = async (req, res, next) => {
createdAt: true,
updatedAt: true,
phoneNumber: true,
tokenVersion: true
tokenVersion: true,
twoFactorEnabled: true
}
});
@@ -50,6 +56,17 @@ const protect = async (req, res, next) => {
return next(new Error('Session has been revoked. Please log in again.'));
}
// Admins and supervisors must use 2FA. A session that predates the
// requirement (or the user's promotion) is sent back to log in, where they
// enrol. The setup wizard's own token gets a short grace period.
const inSetupGrace = decoded.setup === true && Date.now() / 1000 - (decoded.iat || 0) < SETUP_TOKEN_GRACE_SECONDS;
if (!inSetupGrace && (await mustEnrolTwoFactor(req.user))) {
return res.status(401).json({
message: 'Two-factor authentication is required for your role. Please log in again to set it up.',
code: 'TWO_FACTOR_REQUIRED',
});
}
next();
} catch (error) {
console.error(error);
@@ -105,7 +122,7 @@ const optionalAuth = async (req, res, next) => {
}
try {
const token = req.headers.authorization.split(' ')[1];
const decoded = jwt.verify(token, process.env.JWT_SECRET);
const decoded = jwt.verify(token, process.env.JWT_SECRET, { algorithms: ['HS256'] });
const user = await prisma.user.findUnique({
where: { id: decoded.id },
select: { id: true, name: true, email: true, role: true, isActive: true, createdAt: true, updatedAt: true, phoneNumber: true, tokenVersion: true }
+8
View File
@@ -0,0 +1,8 @@
const express = require('express');
const router = express.Router();
const { listAuditLog } = require('../controllers/adminAuditController');
const { protect, admin } = require('../middleware/authMiddleware');
router.get('/audit-log', protect, admin, listAuditLog);
module.exports = router;
+10
View File
@@ -0,0 +1,10 @@
const express = require('express');
const router = express.Router();
const { getBackups, triggerBackup, downloadBackup } = require('../controllers/backupController');
const { protect, admin } = require('../middleware/authMiddleware');
router.get('/', protect, admin, getBackups);
router.post('/run', protect, admin, triggerBackup);
router.get('/:filename/download', protect, admin, downloadBackup);
module.exports = router;
+15
View File
@@ -0,0 +1,15 @@
const express = require('express');
const router = express.Router();
const { listCoupons, getCoupon, createCoupon, updateCoupon, validateCode, listRedemptions } = require('../controllers/couponController');
const { protect, supervisor, optionalAuth } = require('../middleware/authMiddleware');
// Check a code against a basket before registering (guests included)
router.post('/validate', optionalAuth, validateCode);
router.get('/', protect, supervisor, listCoupons);
router.post('/', protect, supervisor, createCoupon);
router.get('/redemptions', protect, supervisor, listRedemptions);
router.get('/:id', protect, supervisor, getCoupon);
router.put('/:id', protect, supervisor, updateCoupon);
module.exports = router;
+2
View File
@@ -6,6 +6,7 @@ const {
getAllEvents,
getEventsAll,
getEventById,
getEventIcs,
updateEvent,
getEventNotifyRecipients,
updateEventNotifyRecipients,
@@ -44,6 +45,7 @@ router.post('/attachments/sync', protect, admin, attachmentsSync);
// optionalAuth populates req.user when a valid token is present so staff/supervisor/admin
// can still load inactive events (e.g. for cashup or editing) without being 404'd.
router.get('/:id', optionalAuth, getEventById);
router.get('/:id/ics', optionalAuth, getEventIcs);
router.get('/by-alias/:redirectUrl', getEventByAlias);
// Create/Update/Delete event
+3
View File
@@ -13,6 +13,7 @@ const {
submitFormResponses, replaceFormResponses,
getFormDraft, saveFormDraft,
sendInvoice,
applyCoupon, removeCoupon,
} = require('../controllers/registrationController');
const { protect, supervisor, staff, optionalAuth } = require('../middleware/authMiddleware');
@@ -22,6 +23,8 @@ router.get('/myregistrations', protect, getUserRegistrations);
router.put('/:id/options', protect, updateRegistrationOptions);
router.delete('/:id', protect, cancelRegistration);
router.post('/:id/send-invoice', protect, sendInvoice);
router.post('/:id/coupon', protect, applyCoupon);
router.delete('/:id/coupon', protect, staff, removeCoupon);
// Registration detail + forms — optionalAuth so guests can access with just the registrationId
router.get('/:id', optionalAuth, getRegistrationById);
+12
View File
@@ -3,6 +3,12 @@ const router = express.Router();
const {
registerUser,
loginUser,
verifyLoginTwoFactor,
resendLoginTwoFactor,
startEnableTwoFactor,
confirmEnableTwoFactor,
disableTwoFactor,
verifyPassword,
getUserProfile,
updateUserProfile,
getUsers,
@@ -24,6 +30,8 @@ const { protect, admin, supervisor, loginLimiter} = require('../middleware/authM
// Public routes
router.post('/', registerUser);
router.post('/login', loginLimiter, loginUser);
router.post('/login/2fa', loginLimiter, verifyLoginTwoFactor);
router.post('/login/2fa/resend', loginLimiter, resendLoginTwoFactor);
router.post('/forgot', requestPasswordReset);
router.post('/reset', resetPassword);
router.post('/activate', activateAccount);
@@ -36,6 +44,10 @@ router.route('/profile')
router.post('/revoke-sessions', protect, revokeMySession);
router.post('/close-account', protect, closeAccount);
router.get('/activity', protect, getMyActivity);
router.post('/verify-password', protect, loginLimiter, verifyPassword);
router.post('/2fa/enable', protect, loginLimiter, startEnableTwoFactor);
router.post('/2fa/confirm', protect, loginLimiter, confirmEnableTwoFactor);
router.post('/2fa/disable', protect, loginLimiter, disableTwoFactor);
// Admin routes
router.route('/')
+52
View File
@@ -0,0 +1,52 @@
const prisma = require('../config/db');
// Fire-and-forget by design — a logging failure must never break the underlying admin
// action, so this swallows its own errors rather than propagating them to the caller
// (same posture as logSecurityEvent).
async function logAdminAction({ actorId, actorRole, action, targetType, targetId, metadata, ip }) {
try {
await prisma.adminAuditLog.create({
data: {
actorId: actorId || null,
actorRole,
action,
targetType,
targetId: targetId || null,
metadata: metadata || undefined,
ip: ip || null,
},
});
} catch (e) {
console.error('Failed to log admin action:', e?.message);
}
}
// Paginated listing for the admin audit-log page, with optional actor/action/date filters.
async function getAdminAuditLog({ page = 1, limit = 50, actorId, action, from, to } = {}) {
const where = {};
if (actorId) where.actorId = actorId;
if (action) where.action = action;
if (from || to) {
where.createdAt = {};
if (from) where.createdAt.gte = new Date(from);
if (to) where.createdAt.lte = new Date(to);
}
const take = Math.min(Math.max(Number(limit) || 50, 1), 200);
const skip = (Math.max(Number(page) || 1, 1) - 1) * take;
const [rows, total] = await Promise.all([
prisma.adminAuditLog.findMany({
where,
orderBy: { createdAt: 'desc' },
take,
skip,
include: { actor: { select: { id: true, name: true, email: true } } },
}),
prisma.adminAuditLog.count({ where }),
]);
return { rows, total, page: Math.max(Number(page) || 1, 1), limit: take };
}
module.exports = { logAdminAction, getAdminAuditLog };
+87
View File
@@ -0,0 +1,87 @@
const { spawn } = require('child_process');
const { pipeline } = require('stream/promises');
const zlib = require('zlib');
const fs = require('fs');
const path = require('path');
const { getSettingSync } = require('./settingsCache');
const BACKUP_DIR = path.join(__dirname, '..', '..', 'backups');
// Local-disk-only by design (no offsite/cloud upload) — matches the exact filename shape
// this module generates, and is reused to validate download requests against path traversal.
const BACKUP_FILENAME_RE = /^backup-\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}-\d{3}Z\.sql\.gz$/;
function ensureBackupDir() {
if (!fs.existsSync(BACKUP_DIR)) fs.mkdirSync(BACKUP_DIR, { recursive: true });
}
function backupFilename(date = new Date()) {
return `backup-${date.toISOString().replace(/[:.]/g, '-')}.sql.gz`;
}
/**
* Run `pg_dump` against DATABASE_URL, gzip its output, and write it to backend/backups/.
* Requires the `pg_dump` binary to be installed on this host (Postgres itself is hosted
* separately) — a deploy-environment prerequisite, not something this code can satisfy.
*
* @returns {Promise<{ filename: string, path: string }>}
*/
async function runBackup() {
ensureBackupDir();
const databaseUrl = process.env.DATABASE_URL;
if (!databaseUrl) throw new Error('DATABASE_URL is not configured');
const filename = backupFilename();
const filePath = path.join(BACKUP_DIR, filename);
const pgDump = spawn('pg_dump', [databaseUrl, '--no-owner', '--no-privileges'], { stdio: ['ignore', 'pipe', 'pipe'] });
let stderr = '';
pgDump.stderr.on('data', (d) => { stderr += d.toString(); });
const exitPromise = new Promise((resolve, reject) => {
pgDump.on('error', (err) => reject(new Error(`Failed to start pg_dump: ${err.message}. Is it installed on this host?`)));
pgDump.on('close', (code) => {
if (code === 0) resolve();
else reject(new Error(`pg_dump exited with code ${code}: ${stderr.slice(0, 500)}`));
});
});
const pipelinePromise = pipeline(pgDump.stdout, zlib.createGzip(), fs.createWriteStream(filePath));
try {
// Both must succeed: the process exiting cleanly, and the gzip write finishing —
// a mid-dump failure must not leave a truncated file looking like a real backup.
await Promise.all([exitPromise, pipelinePromise]);
} catch (err) {
await fs.promises.unlink(filePath).catch(() => {});
throw err;
}
const retainCount = parseInt(getSettingSync('backup_retain_count', '14'), 10) || 14;
await rotateBackups(retainCount);
return { filename, path: filePath };
}
/** @returns {Promise<Array<{ filename: string, size: number, createdAt: Date }>>} newest first */
async function listBackups() {
ensureBackupDir();
const files = await fs.promises.readdir(BACKUP_DIR);
const backups = await Promise.all(
files.filter((f) => BACKUP_FILENAME_RE.test(f)).map(async (f) => {
const stat = await fs.promises.stat(path.join(BACKUP_DIR, f));
return { filename: f, size: stat.size, createdAt: stat.mtime };
})
);
return backups.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
}
/** Deletes backups beyond the retention count, oldest first. */
async function rotateBackups(retainCount) {
const backups = await listBackups();
const toDelete = backups.slice(retainCount);
await Promise.all(toDelete.map((b) => fs.promises.unlink(path.join(BACKUP_DIR, b.filename)).catch(() => {})));
return { deleted: toDelete.length };
}
module.exports = { runBackup, listBackups, rotateBackups, BACKUP_DIR, BACKUP_FILENAME_RE };
+7 -2
View File
@@ -72,7 +72,7 @@ async function computeEventFinancials(eventId) {
throw new Error('Event not found');
}
const [payments, costs, tickets, salesRows, history] = await Promise.all([
const [payments, costs, tickets, salesRows, history, discountAgg] = await Promise.all([
prisma.payment.findMany({
where: { OR: [{ eventId }, { registration: { eventId } }] }
}),
@@ -92,7 +92,9 @@ async function computeEventFinancials(eventId) {
performedBy: { select: { id: true, name: true, email: true } }
},
orderBy: { createdAt: 'desc' }
})
}),
// Coupon discounts on the same paid registrations salesByOption counts.
prisma.registration.aggregate({ where: { eventId, status: 'paid' }, _sum: { discountTotal: true } })
]);
// Ticket quantity sold per EventOption (used to price per-item costs)
@@ -204,6 +206,8 @@ async function computeEventFinancials(eventId) {
salesByOptionMap[opt.id].revenue += computeOptionLineTotal(ro, null, new Date());
}
const salesByOption = Object.values(salesByOptionMap);
// salesByOption is at list (or early-bird) price; this is what coupon codes took off it.
const discountsTotal = Number(discountAgg?._sum?.discountTotal || 0);
return {
event,
@@ -222,6 +226,7 @@ async function computeEventFinancials(eventId) {
unallocatedDonationsTotal,
totalDonations,
salesByOption,
discountsTotal,
history
};
}
+298
View File
@@ -0,0 +1,298 @@
/**
* Coupon codes — percentage, fixed-amount and comp (100%) discounts on registrations.
*
* Rules:
* - A registration can carry one active coupon. Its discount is stored as a
* CouponRedemption (amount locked at apply time) and mirrored as the sum of active
* redemptions on Registration.discountTotal, which computeRegistrationTotalDue
* subtracts. Early-bird pricing refreshes never touch it.
* - Editing a registration's items recalculates percentage and comp codes over the new
* items; fixed-amount codes keep their amount (capped at what's in scope).
* - Cancelling a registration releases its redemption, freeing the use.
* - Scope: no CouponOption rows = the whole event; otherwise only matching options
* (or one variant of an option).
* - stackEarlyBird=false means "best price wins": the coupon is worked out against the
* standard price, and only the part that beats the early-bird price is discounted.
* - Restricted codes (users, roles, email domain) need a known account, so guests
* can't use them; a match on any one restriction is enough.
*/
const prisma = require('../config/db');
function normalizeCode(code) {
return String(code || '').trim().toUpperCase();
}
function couponError(message) {
const err = new Error(message);
err.isCouponError = true;
err.statusCode = 400;
return err;
}
const round2 = (n) => Math.round(Number(n || 0) * 100) / 100;
/**
* Discountable lines from a registration — one per price tranche, so tickets bought at
* different prices are each discounted at what was actually charged.
* `basePrice` is the standard (non-early-bird) price, used for "best price wins".
*/
function buildCouponLines(registration) {
const lines = [];
for (const ro of registration?.registrationOptions || []) {
const eo = ro.eventOption || {};
const variant = ro.variantId ? (eo.variants || []).find(v => v.id === ro.variantId) : null;
const standard = variant && variant.price !== null && variant.price !== undefined ? Number(variant.price) : Number(eo.price || 0);
const tranches = ro.tranches && ro.tranches.length > 0
? ro.tranches
: [{ quantity: ro.quantity, priceSnapshot: ro.priceSnapshot ?? standard, appliedTierId: ro.appliedTierId }];
for (const t of tranches) {
const unitPrice = Number(t.priceSnapshot ?? standard);
lines.push({
eventOptionId: ro.eventOptionId,
variantId: ro.variantId || null,
quantity: Number(t.quantity || 0),
unitPrice,
// Only an early-bird tranche has a "standard" price above what was charged; a
// staff price or plain price is already the base the coupon works from.
basePrice: t.appliedTierId ? Math.max(standard, unitPrice) : unitPrice,
});
}
}
return lines;
}
function lineInScope(line, scopes) {
if (!scopes || scopes.length === 0) return true;
return scopes.some(s => s.eventOptionId === line.eventOptionId && (!s.variantId || s.variantId === line.variantId));
}
function scopedLines(coupon, lines) {
return (lines || []).filter(l => lineInScope(l, coupon.options));
}
/**
* The discount a coupon gives on these lines, in rand, never more than what the in-scope
* lines cost. Pure — used for previews, applying, and recalculating after edits.
*/
function computeCouponDiscount(coupon, lines) {
const scoped = scopedLines(coupon, lines);
const charged = scoped.reduce((s, l) => s + l.unitPrice * l.quantity, 0);
if (charged <= 0) return 0;
let discount = 0;
if (coupon.type === 'comp') {
discount = charged;
} else if (coupon.type === 'percent') {
const pct = Math.min(100, Math.max(0, Number(coupon.value || 0))) / 100;
if (coupon.stackEarlyBird) {
discount = charged * pct;
} else {
discount = scoped.reduce((s, l) => s + (l.unitPrice - Math.min(l.unitPrice, l.basePrice * (1 - pct))) * l.quantity, 0);
}
} else if (coupon.type === 'fixed') {
const amount = Math.max(0, Number(coupon.value || 0));
if (coupon.stackEarlyBird) {
discount = amount;
} else {
const base = scoped.reduce((s, l) => s + l.basePrice * l.quantity, 0);
discount = charged - Math.max(0, base - amount);
}
}
return round2(Math.min(charged, Math.max(0, discount)));
}
/**
* Checks whether a coupon can be used. Pure — the caller supplies the use counts.
*
* @param {object} coupon - with events[], options[], allowedUsers[]
* @param {object} ctx - { eventId, user: {id, role, email}|null, now, usesTotal, usesByUser, lines,
* deferRestrictions } — deferRestrictions lets staff preview a
* restricted code before the attendee's account exists; it's
* checked against the attendee when the registration is saved.
* @returns {{ ok: true } | { ok: false, message: string }}
*/
function validateCoupon(coupon, ctx) {
const no = (message) => ({ ok: false, message });
if (!coupon) return no('That code isn\'t valid.');
if (!coupon.active) return no('This code is no longer active.');
const now = ctx.now ? new Date(ctx.now) : new Date();
if (coupon.validFrom && now < new Date(coupon.validFrom)) return no('This code isn\'t valid yet.');
if (coupon.validUntil && now > new Date(coupon.validUntil)) return no('This code has expired.');
if ((coupon.events || []).length > 0 && !coupon.events.some(e => e.eventId === ctx.eventId)) {
return no('This code isn\'t valid for this event.');
}
const restrictedUsers = (coupon.allowedUsers || []).map(u => u.userId);
const restrictedRoles = coupon.restrictRoles || [];
const domain = String(coupon.restrictDomain || '').trim().toLowerCase().replace(/^@/, '');
if ((restrictedUsers.length || restrictedRoles.length || domain) && !(ctx.deferRestrictions && !ctx.user)) {
const user = ctx.user;
if (!user) return no('Log in to use this code.');
const email = String(user.email || '').toLowerCase();
const allowed = restrictedUsers.includes(user.id)
|| restrictedRoles.includes(user.role)
|| (domain && email.endsWith(`@${domain}`));
if (!allowed) return no('This code isn\'t available for your account.');
}
if (coupon.maxUses !== null && coupon.maxUses !== undefined && (ctx.usesTotal || 0) >= coupon.maxUses) {
return no('This code has been fully used.');
}
if (coupon.maxUsesPerUser !== null && coupon.maxUsesPerUser !== undefined && ctx.user && (ctx.usesByUser || 0) >= coupon.maxUsesPerUser) {
return no('You\'ve already used this code.');
}
const scopedQty = scopedLines(coupon, ctx.lines).reduce((s, l) => s + l.quantity, 0);
if (scopedQty === 0) return no('This code doesn\'t apply to the items you\'ve chosen.');
if (coupon.minQuantity && scopedQty < coupon.minQuantity) {
return no(`This code needs at least ${coupon.minQuantity} qualifying ticket${coupon.minQuantity === 1 ? '' : 's'}.`);
}
return { ok: true };
}
/** Short human description, e.g. "20% off", "R50 off", "Free". */
function describeCoupon(coupon) {
if (!coupon) return '';
if (coupon.type === 'comp') return 'Free';
if (coupon.type === 'percent') return `${Number(coupon.value)}% off`;
return `R${Number(coupon.value).toFixed(2)} off`;
}
// ─── Database helpers ─────────────────────────────────────────────────────────
const COUPON_INCLUDE = { events: true, options: true, allowedUsers: true };
const REGISTRATION_INCLUDE = {
registrationOptions: { include: { tranches: true, eventOption: { include: { variants: true } } } },
couponRedemptions: { where: { releasedAt: null }, include: { coupon: { include: COUPON_INCLUDE } } },
user: { select: { id: true, role: true, email: true } },
};
async function findCouponByCode(code, db = prisma) {
const normalized = normalizeCode(code);
if (!normalized) return null;
return db.coupon.findUnique({ where: { code: normalized }, include: COUPON_INCLUDE });
}
/** Active uses: unreleased redemptions on registrations that aren't cancelled. */
async function countCouponUses(couponId, userId, db = prisma) {
const base = { couponId, releasedAt: null, registration: { status: { not: 'cancelled' } } };
const [usesTotal, usesByUser] = await Promise.all([
db.couponRedemption.count({ where: base }),
userId ? db.couponRedemption.count({ where: { ...base, userId } }) : Promise.resolve(0),
]);
return { usesTotal, usesByUser };
}
/**
* Validates `code` against hypothetical lines (nothing saved) and returns the discount it
* would give. Throws a coupon error with a user-facing message when it can't be used.
*/
async function previewCoupon({ code, eventId, user, lines, deferRestrictions = false }) {
const coupon = await findCouponByCode(code);
const uses = coupon ? await countCouponUses(coupon.id, user?.id) : {};
const check = validateCoupon(coupon, { eventId, user, lines, deferRestrictions, ...uses });
if (!check.ok) throw couponError(check.message);
return { coupon, discount: computeCouponDiscount(coupon, lines) };
}
/**
* Applies `code` to a saved registration: validates it against the registration's owner
* and items, locks in the discount, and updates Registration.discountTotal. Runs under a
* per-coupon advisory lock so the last available use can't be claimed twice.
*/
async function applyCouponToRegistration({ registrationId, code, appliedById = null }) {
const normalized = normalizeCode(code);
if (!normalized) throw couponError('Enter a code.');
return prisma.$transaction(async (tx) => {
const coupon = await findCouponByCode(normalized, tx);
if (!coupon) throw couponError('That code isn\'t valid.');
await tx.$executeRaw`SELECT pg_advisory_xact_lock(hashtext(${coupon.id}))`;
const registration = await tx.registration.findUnique({ where: { id: registrationId }, include: REGISTRATION_INCLUDE });
if (!registration) throw couponError('Registration not found.');
if (registration.couponRedemptions.length > 0) {
throw couponError(registration.couponRedemptions[0].couponId === coupon.id
? 'This code is already applied to this registration.'
: 'A code has already been applied to this registration.');
}
const lines = buildCouponLines(registration);
const uses = await countCouponUses(coupon.id, registration.userId, tx);
const check = validateCoupon(coupon, { eventId: registration.eventId, user: registration.user, lines, ...uses });
if (!check.ok) throw couponError(check.message);
const discount = computeCouponDiscount(coupon, lines);
const redemption = await tx.couponRedemption.create({
data: { couponId: coupon.id, registrationId, userId: registration.userId, discountAmount: discount, appliedById },
});
await tx.registration.update({ where: { id: registrationId }, data: { discountTotal: discount, updatedAt: new Date() } });
return { coupon, discount, redemption };
});
}
/**
* Re-works active discounts after a registration's items change: percentage and comp
* codes are recalculated over the new items, fixed codes keep their amount (capped at
* what's still in scope). Pass `tx` to run inside the caller's transaction.
*/
async function recalculateRegistrationDiscount(registrationId, db = prisma) {
const registration = await db.registration.findUnique({ where: { id: registrationId }, include: REGISTRATION_INCLUDE });
if (!registration) return 0;
if (registration.couponRedemptions.length === 0) {
if (registration.discountTotal !== 0) {
await db.registration.update({ where: { id: registrationId }, data: { discountTotal: 0 } });
}
return 0;
}
const lines = buildCouponLines(registration);
let total = 0;
for (const r of registration.couponRedemptions) {
let amount;
if (r.coupon.type === 'fixed') {
const inScope = scopedLines(r.coupon, lines).reduce((s, l) => s + l.unitPrice * l.quantity, 0);
amount = round2(Math.min(r.discountAmount, inScope));
} else {
amount = computeCouponDiscount(r.coupon, lines);
}
if (Math.abs(amount - r.discountAmount) > 0.001) {
await db.couponRedemption.update({ where: { id: r.id }, data: { discountAmount: amount } });
}
total += amount;
}
total = round2(total);
if (Math.abs(total - registration.discountTotal) > 0.001) {
await db.registration.update({ where: { id: registrationId }, data: { discountTotal: total } });
}
return total;
}
/** Releases a cancelled registration's coupon uses so they can be claimed again. */
async function releaseRegistrationCoupons(registrationId, db = prisma) {
const released = await db.couponRedemption.updateMany({
where: { registrationId, releasedAt: null },
data: { releasedAt: new Date() },
});
if (released.count > 0) {
await db.registration.update({ where: { id: registrationId }, data: { discountTotal: 0 } });
}
return released.count;
}
module.exports = {
normalizeCode,
couponError,
buildCouponLines,
computeCouponDiscount,
validateCoupon,
describeCoupon,
findCouponByCode,
countCouponUses,
previewCoupon,
applyCouponToRegistration,
recalculateRegistrationDiscount,
releaseRegistrationCoupons,
};
+18
View File
@@ -0,0 +1,18 @@
/**
* Donations are never mutated once created — their remaining balance is the original
* amount minus every "leg" (a Payment row with isDonation:false and originalPaymentId
* pointing back at this donation) already allocated from it. A refund of the donation
* itself also creates a leg, with a negative amount — Math.abs() so a refund reduces
* the remaining balance (money that's left the building) instead of increasing it
* (which a raw signed sum would do, since subtracting a negative adds).
*
* @param {number} originalAmount - the donation payment's own amount
* @param {Array<{amount: number}>} legs - Payment rows with originalPaymentId === donation.id
* @returns {number}
*/
function computeDonationRemaining(originalAmount, legs) {
const alreadyUsed = (legs || []).reduce((sum, leg) => sum + Math.abs(leg.amount), 0);
return Number(originalAmount || 0) - alreadyUsed;
}
module.exports = { computeDonationRemaining };
+38
View File
@@ -49,6 +49,11 @@ function _getTransporter() {
return { transporter: _cachedTransporter, cfg };
}
/** True when a real SMTP server is configured (not the jsonTransport dev fallback). */
function isSmtpConfigured() {
return !!_smtpConfig().host;
}
async function sendMail({ to, subject, html, text, attachments }) {
// Never send to anonymised/deleted accounts
if (!to || String(to).endsWith('@deleted.invalid')) {
@@ -162,6 +167,14 @@ function fallbackLink(url) {
</p>`;
}
/** Renders a subtle, labeled secondary link — distinct from the primary CTA button. */
function secondaryLink(label, url) {
const color = getOrg().headerColor;
return `<p style="text-align:center;margin:16px 0 0 0;font-size:13px;color:#64748b">
<a href="${url}" style="color:${color}">${label}</a>
</p>`;
}
/** Horizontal rule. */
function divider() {
return `<div style="border-top:1px solid #f1f5f9;margin:32px 0"></div>`;
@@ -226,6 +239,28 @@ function buildPasswordResetEmail({ name, resetUrl }) {
return { text, html: emailWrapper(body, { preheader }) };
}
function buildTwoFactorCodeEmail({ name, code }) {
const org = getOrg();
const preheader = `Your ${org.name} verification code. It expires in 10 minutes.`;
const body = `
<p style="font-size:22px;font-weight:800;color:#0f172a;margin:0 0 8px 0;letter-spacing:-0.3px">Your verification code</p>
<p style="color:#64748b;font-size:14px;margin:0 0 32px 0">Two-factor authentication for your account</p>
<p style="margin:0 0 8px 0;color:#374151">Hi <strong>${name || 'there'}</strong>,</p>
<p style="margin:0 0 24px 0;color:#374151">Enter this code to continue. It will expire in <strong>10 minutes</strong>.</p>
<p style="margin:0 0 24px 0;font-size:34px;font-weight:800;letter-spacing:8px;color:#0f172a;text-align:center;font-family:'SFMono-Regular',Consolas,'Liberation Mono',Menlo,monospace">${code}</p>
${divider()}
<p style="margin:0;font-size:13px;color:#94a3b8">
Never share this code with anyone. If you did not try to log in, someone may know your password —
change it and contact us at <a href="mailto:${org.email}" style="color:#2563eb">${org.email}</a>.
</p>`;
const text = `Hi ${name || 'there'},\n\nYour ${org.name} verification code is: ${code}\n\nIt expires in 10 minutes. Never share this code with anyone.\n\nIf you did not try to log in, change your password and contact ${org.email}.\n\n${org.name} — ${org.email}`;
return { text, html: emailWrapper(body, { preheader }) };
}
function buildPasswordChangedEmail({ name, when, supportEmail }) {
const org = getOrg();
const contact = supportEmail || org.email;
@@ -387,13 +422,16 @@ function buildAccountClosedEmail({ name, dataDeleted }) {
module.exports = {
sendMail,
isSmtpConfigured,
emailWrapper,
ctaButton,
fallbackLink,
secondaryLink,
divider,
callout,
paymentOption,
buildPasswordResetEmail,
buildTwoFactorCodeEmail,
buildPasswordChangedEmail,
buildLoginNotificationEmail,
buildWelcomeEmail,
+36
View File
@@ -0,0 +1,36 @@
const { createEvent } = require('ics');
// Event.startDate/endDate are stored as true UTC instants (the admin event form's
// datetime-local input is parsed in the browser's local time before being sent as an
// ISO string), so serializing them as UTC here requires no timezone math and lets every
// viewer's calendar app localize correctly to *their own* timezone.
function toUtcArray(date) {
const d = new Date(date);
return [d.getUTCFullYear(), d.getUTCMonth() + 1, d.getUTCDate(), d.getUTCHours(), d.getUTCMinutes()];
}
/**
* Build an RFC 5545 .ics file (as a string) for a single event.
*
* @param {object} event - Prisma Event row: title, description?, startDate, endDate, location?
* @param {string} eventUrl - absolute URL to the event's public page
* @returns {string}
*/
function buildEventIcs(event, eventUrl) {
const { error, value } = createEvent({
title: event.title,
start: toUtcArray(event.startDate),
end: toUtcArray(event.endDate),
startInputType: 'utc',
endInputType: 'utc',
startOutputType: 'utc',
endOutputType: 'utc',
location: event.location || undefined,
description: event.description || undefined,
url: eventUrl,
});
if (error) throw error;
return value;
}
module.exports = { buildEventIcs };
+43 -9
View File
@@ -1,7 +1,8 @@
const fs = require('fs');
const prisma = require('../config/db');
const { sendMail, emailWrapper, ctaButton, fallbackLink, divider, callout, paymentOption } = require('./email');
const { sendMail, emailWrapper, ctaButton, fallbackLink, secondaryLink, divider, callout, paymentOption } = require('./email');
const { computeRegistrationTotalDue, computeOptionLineTotal } = require('./pricing');
const { couponDiscountLine } = require('./pdfDocs');
// ─── Formatting helpers ───────────────────────────────────────────────────────
@@ -22,6 +23,20 @@ function fmtDateShort(d) {
const { getSettingSync } = require('./settingsCache');
// The .ics calendar-download link lives on the backend (not the frontend site), same
// as the ticket-PDF URLs sent to WhatsApp — see whatsapp.js's BACKEND_URL usage.
function getBackendUrl() {
return (process.env.BACKEND_URL || '').replace(/\/$/, '');
}
// Only rendered when BACKEND_URL is actually configured — the .ics endpoint lives on
// the backend, and there's no reliable way to derive that URL otherwise.
function calendarLinkRow(eventId) {
const backendUrl = getBackendUrl();
if (!backendUrl || !eventId) return '';
return secondaryLink('Add to calendar', `${backendUrl}/api/events/${eventId}/ics`);
}
function getOrg() {
return {
name: getSettingSync('org_name', process.env.ORG_NAME || 'Cross Code'),
@@ -75,6 +90,7 @@ async function loadRegistrationFull(registrationId) {
include: {
registrationOptions: { include: { eventOption: { include: { earlyBirdTiers: true } }, variant: true, tranches: true } },
payments: true,
couponRedemptions: { where: { releasedAt: null }, include: { coupon: { select: { code: true } } } },
user: { select: { id: true, name: true, email: true, phoneNumber: true, isActive: true, notificationPreference: true } },
event: { include: { createdBy: { select: { id: true, name: true, email: true } }, notifyRecipients: { select: { id: true, name: true, email: true } } } },
},
@@ -101,6 +117,7 @@ async function loadPaymentFull(paymentId) {
include: {
registrationOptions: { include: { eventOption: { include: { earlyBirdTiers: true } }, variant: true, tranches: true } },
payments: true,
couponRedemptions: { where: { releasedAt: null }, include: { coupon: { select: { code: true } } } },
user: { select: { id: true, name: true, email: true, phoneNumber: true, isActive: true, notificationPreference: true } },
event: { include: { createdBy: { select: { id: true, name: true, email: true } }, notifyRecipients: { select: { id: true, name: true, email: true } } } },
},
@@ -112,8 +129,8 @@ async function loadPaymentFull(paymentId) {
// ─── Shared building blocks ───────────────────────────────────────────────────
/** Renders the selections summary table. */
function selectionsTable(registrationOptions) {
/** Renders the selections summary table, with the coupon discount (if any) as its own row. */
function selectionsTable(registrationOptions, couponLine = null) {
const rows = (registrationOptions || []).map(ro => {
const name = ro.eventOption?.name || 'Option';
const qty = ro.quantity || 1;
@@ -127,6 +144,13 @@ function selectionsTable(registrationOptions) {
if (!rows.length) return `<p style="color:#94a3b8;font-size:14px;margin:0">No items</p>`;
if (couponLine) {
rows.push(`<tr>
<td colspan="2" style="padding:10px 16px 10px 0;font-size:14px;color:#059669;font-family:${ff};border-bottom:1px solid #f1f5f9">${couponLine.label}</td>
<td style="padding:10px 0 10px 16px;font-size:14px;color:#059669;text-align:right;font-weight:500;font-family:${ff};border-bottom:1px solid #f1f5f9">-${fmtAmount(couponLine.amount)}</td>
</tr>`);
}
return `<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="border:1px solid #e2e8f0;border-radius:8px;overflow:hidden;margin:0 0 4px 0">
<tr style="background:#f8fafc">
<th style="padding:10px 16px 10px 0;font-size:12px;font-weight:700;color:#64748b;text-align:left;text-transform:uppercase;letter-spacing:0.5px;font-family:${ff}">Item</th>
@@ -137,6 +161,14 @@ function selectionsTable(registrationOptions) {
</table>`;
}
/** Plain-text selections, one bullet per item plus the coupon discount. */
function selectionsText(reg) {
const lines = (reg.registrationOptions || []).map(ro => ` • ${ro.eventOption?.name || 'Option'} ×${ro.quantity} — ${fmtAmount(computeOptionLineTotal(ro, null, new Date()))}`);
const coupon = couponDiscountLine(reg);
if (coupon) lines.push(` • ${coupon.label} — -${fmtAmount(coupon.amount)}`);
return lines.join('\n');
}
/** Renders a financial summary line. */
function financialSummary(totalDue, totalPaid, balance) {
const ff2 = ff;
@@ -265,13 +297,14 @@ function buildRegistrationConfirmation(reg, { isNew = true } = {}) {
<p style="margin:0 0 28px 0;color:#374151;font-family:${ff}">${subtext}${eventDate ? ` — <strong>${eventDate}</strong>` : ''}</p>
<p style="font-size:16px;font-weight:700;color:#0f172a;margin:0 0 12px 0;font-family:${ff}">Your registration</p>
${selectionsTable(reg.registrationOptions)}
${selectionsTable(reg.registrationOptions, couponDiscountLine(reg))}
${financialSummary(totalDue, totalPaid, balance)}
${paymentSection({ balance, yocoLink: null, source: 'user', siteUrl: org.url, formRequired: false, isUserActive })}
${accountCta(isUserActive, org.url)}`;
${accountCta(isUserActive, org.url)}
${calendarLinkRow(reg.eventId)}`;
const itemsText = (reg.registrationOptions || []).map(ro => ` • ${ro.eventOption?.name || 'Option'} ×${ro.quantity} — ${fmtAmount(computeOptionLineTotal(ro, null, new Date()))}`).join('\n');
const itemsText = selectionsText(reg);
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}`;
return { subject, text, html: emailWrapper(body, { preheader }) };
@@ -305,13 +338,14 @@ function buildAdminInitiatedRegistrationConfirmation(reg, { yocoLink = null, for
<p style="margin:0 0 28px 0;color:#374151;font-family:${ff}">${subtext}${eventDate ? ` The event takes place on <strong>${eventDate}</strong>.` : ''}</p>
<p style="font-size:16px;font-weight:700;color:#0f172a;margin:0 0 12px 0;font-family:${ff}">Your registration</p>
${selectionsTable(reg.registrationOptions)}
${selectionsTable(reg.registrationOptions, couponDiscountLine(reg))}
${financialSummary(totalDue, totalPaid, balance)}
${paymentSection({ balance, yocoLink, source: 'admin', siteUrl: org.url, formRequired, isUserActive })}
${accountCta(isUserActive, org.url)}`;
${accountCta(isUserActive, org.url)}
${calendarLinkRow(reg.eventId)}`;
const itemsText = (reg.registrationOptions || []).map(ro => ` • ${ro.eventOption?.name || 'Option'} ×${ro.quantity} — ${fmtAmount(computeOptionLineTotal(ro, null, new Date()))}`).join('\n');
const itemsText = selectionsText(reg);
const payText = balance > 0
? `Payment options:\n${yocoLink ? ` 1. Pay online (Yoco): ${yocoLink}\n (Already paid? Ignore this option)\n` : ''} ${yocoLink ? '2' : '1'}. On our website: ${org.url}\n ${yocoLink ? '3' : '2'}. At the door (cash or card)\n\nYour tickets will be sent once payment is confirmed.`
: formRequired
+24 -3
View File
@@ -93,6 +93,18 @@ function buildLineItems(registrationOptions) {
return rows;
}
/**
* The registration's coupon discount and a label naming the code(s), e.g. "Code STAFF50".
* Needs `couponRedemptions[].coupon.code` loaded for the label; the amount comes from
* Registration.discountTotal, which is always present.
*/
function couponDiscountLine(registration) {
const amount = Number(registration?.discountTotal || 0);
if (!(amount > 0.001)) return null;
const codes = (registration.couponRedemptions || []).map(r => r.coupon?.code).filter(Boolean);
return { amount, label: codes.length ? `Code ${codes.join(', ')}` : 'Discount code' };
}
/** Draws a two-column item table starting at `y`; returns the y position after the last row. */
function drawItemsTable(doc, { x, width, y, rows, brandColor, headerLight = true }) {
const colDesc = x;
@@ -198,6 +210,13 @@ async function generateReceiptPdf(payment) {
if (rows.length > 0) {
y = drawItemsTable(doc, { x: marginX, width: pageWidth - marginX * 2, y, rows, brandColor: org.brandColor });
}
const coupon = couponDiscountLine(reg);
if (coupon) {
y += 8;
doc.font('Helvetica').fontSize(10).fillColor('#059669')
.text(`${coupon.label}: -${fmtAmount(coupon.amount)}`, marginX, y, { width: pageWidth - marginX * 2 - 8, align: 'right' });
y += 14;
}
y += 20;
const boxW = 160, boxH = 32;
@@ -240,6 +259,7 @@ async function generateInvoicePdf(registration, { paymentUrl = null, totalDue =
const fullPriceTotal = rows.reduce((s, r) => s + (r.isEarlyBird ? r.basePrice : r.unitPrice) * r.quantity, 0);
const chargedTotal = rows.reduce((s, r) => s + r.unitPrice * r.quantity, 0);
const discount = Math.max(0, fullPriceTotal - chargedTotal);
const coupon = couponDiscountLine(registration);
const filename = `invoice-${invoiceNo}.pdf`;
const filePath = path.join(tempDir(), `${Date.now()}-${filename}`);
@@ -303,9 +323,10 @@ async function generateInvoicePdf(registration, { paymentUrl = null, totalDue =
.text(value, totalsX + 120, y, { width: 100, align: 'right' });
y += (opts.size || 10) + 10;
};
if (discount > 0.01) {
if (discount > 0.01 || coupon) {
totalLine('SUBTOTAL', fmtAmount(fullPriceTotal));
totalLine('DISCOUNT', `-${fmtAmount(discount)}`, { color: '#059669' });
if (discount > 0.01) totalLine(coupon ? 'EARLY BIRD' : 'DISCOUNT', `-${fmtAmount(discount)}`, { color: '#059669' });
if (coupon) totalLine(coupon.label.toUpperCase(), `-${fmtAmount(coupon.amount)}`, { color: '#059669' });
doc.moveTo(totalsX, y).lineTo(totalsX + 220, y).strokeColor('#e2e8f0').stroke();
y += 8;
}
@@ -348,4 +369,4 @@ async function generateInvoicePdf(registration, { paymentUrl = null, totalDue =
return { filePath, filename };
}
module.exports = { generateReceiptPdf, generateInvoicePdf, buildLineItems, getBranding, docNumber };
module.exports = { generateReceiptPdf, generateInvoicePdf, buildLineItems, couponDiscountLine, getBranding, docNumber };
+49 -5
View File
@@ -18,6 +18,10 @@
* - computeRegistrationTotalDue: sums quantity*priceSnapshot across each RegistrationOption's
* tranches (falling back to the legacy single-priceSnapshot/getEffectiveUnitPrice path for
* rows created before tranches existed).
* - Staff price: an option (or variant) can carry a staffPrice that Staff and above get
* automatically when it's lower than the price they'd otherwise pay.
* - Coupon discounts are stored on Registration.discountTotal (see utils/coupons.js) and
* subtracted by computeRegistrationTotalDue, never below zero.
* - refreshPricingForRegistration: re-evaluates only the newest, not-yet-paid-for tranche of
* each RegistrationOption; if its tier has expired or lost stock, resolves the next
* applicable tier/price for just that tranche. Older tranches — already priced-in — are
@@ -26,6 +30,24 @@
const prisma = require('../config/db');
const STAFF_ROLES = ['staff', 'supervisor', 'admin'];
/** The staff price this user gets for an option/variant, or null if none applies. */
function staffPriceFor(option, variantId, user) {
if (!user || !STAFF_ROLES.includes(user.role)) return null;
const variant = variantId ? (option?.variants || []).find(v => v.id === variantId) : null;
const price = variant && variant.staffPrice !== null && variant.staffPrice !== undefined
? variant.staffPrice
: option?.staffPrice;
return price === null || price === undefined ? null : Number(price);
}
// Staff price wins only when it's cheaper than the resolved (possibly early-bird) price.
function withStaffPrice(resolved, staffPrice) {
if (staffPrice === null || !(staffPrice < resolved.price)) return resolved;
return { price: staffPrice, tierId: null };
}
/**
* Compute effective unit price for an event option at a given time considering early-bird tiers.
* Only checks deadline (not stock). Used for display and as a legacy fallback.
@@ -65,9 +87,14 @@ function getEffectiveUnitPrice(eventOption, referenceTime, atTime) {
*
* @param {object} option - EventOption with price, earlyBirdTiers[]
* @param {number} requestedQty - quantity being purchased
* @param {object|null} user - buyer ({ role }); Staff and above may get the staff price
* @returns {Promise<{ price: number, tierId: string|null }>}
*/
async function resolveOptionPrice(option, requestedQty = 1) {
async function resolveOptionPrice(option, requestedQty = 1, user = null) {
return withStaffPrice(await resolveOptionTierPrice(option, requestedQty), staffPriceFor(option, null, user));
}
async function resolveOptionTierPrice(option, requestedQty) {
const now = new Date();
// Only option-level tiers (no variant association)
const tiers = (Array.isArray(option.earlyBirdTiers) ? option.earlyBirdTiers : [])
@@ -115,9 +142,14 @@ async function resolveOptionPrice(option, requestedQty = 1) {
* @param {object} option - EventOption with price, earlyBirdTiers[], variants[]
* @param {string} variantId
* @param {number} requestedQty
* @param {object|null} user - buyer ({ role }); Staff and above may get the staff price
* @returns {Promise<{ price: number, tierId: string|null }>}
*/
async function resolveVariantTierPrice(option, variantId, requestedQty = 1) {
async function resolveVariantTierPrice(option, variantId, requestedQty = 1, user = null) {
return withStaffPrice(await resolveVariantOnlyTierPrice(option, variantId, requestedQty), staffPriceFor(option, variantId, user));
}
async function resolveVariantOnlyTierPrice(option, variantId, requestedQty) {
const now = new Date();
const variant = (option.variants || []).find(v => v.id === variantId);
const basePrice = (variant && variant.price !== null && variant.price !== undefined)
@@ -172,6 +204,7 @@ async function refreshPricingForRegistration(registrationId) {
}
},
payments: true,
user: { select: { role: true } },
}
});
if (!registration) return { changed: false };
@@ -215,8 +248,8 @@ async function refreshPricingForRegistration(registrationId) {
// Deadline has passed (or tier record missing) — resolve the next applicable tier
const resolved = ro.variantId
? await resolveVariantTierPrice(ro.eventOption, ro.variantId, newest.quantity)
: await resolveOptionPrice(ro.eventOption, newest.quantity);
? await resolveVariantTierPrice(ro.eventOption, ro.variantId, newest.quantity, registration.user)
: await resolveOptionPrice(ro.eventOption, newest.quantity, registration.user);
const tierChanged = resolved.tierId !== newest.appliedTierId;
const priceChanged = Math.abs(resolved.price - newest.priceSnapshot) > 0.001;
@@ -273,7 +306,8 @@ function computeOptionLineTotal(ro, lastPaymentAt, atTime) {
* Compute total due for a registration at a given time.
*
* Sums computeOptionLineTotal() across each RegistrationOption (tranche-aware when
* `.tranches` is included, legacy priceSnapshot/getEffectiveUnitPrice fallback otherwise).
* `.tranches` is included, legacy priceSnapshot/getEffectiveUnitPrice fallback otherwise),
* less the registration's coupon discount (`discountTotal`), never below zero.
*
* @param {object} registration - includes registrationOptions[].{priceSnapshot, quantity, eventOption, tranches?}
* and optionally payments[]
@@ -291,6 +325,13 @@ function computeRegistrationTotalDue(registration, atTime) {
}
} catch {}
const subtotal = computeRegistrationSubtotal(registration, lastPaymentAt, atTime);
return Math.max(0, subtotal - Number(registration.discountTotal || 0));
}
/** Sum of the item lines before any coupon discount. */
function computeRegistrationSubtotal(registration, lastPaymentAt, atTime) {
if (!registration || !Array.isArray(registration.registrationOptions)) return 0;
return registration.registrationOptions.reduce((sum, ro) => sum + computeOptionLineTotal(ro, lastPaymentAt, atTime), 0);
}
@@ -317,6 +358,7 @@ function attachComputedTotals(registration) {
ro.lineTotal = computeOptionLineTotal(ro, lastPaymentAt, now);
}
}
registration.subtotalComputed = computeRegistrationSubtotal(registration, lastPaymentAt, now);
registration.totalDueComputed = computeRegistrationTotalDue(registration, now);
return registration;
}
@@ -332,6 +374,8 @@ module.exports = {
refreshPricingForRegistration,
computeOptionLineTotal,
computeRegistrationTotalDue,
computeRegistrationSubtotal,
staffPriceFor,
attachComputedTotals,
attachComputedTotalsToList,
};
+9
View File
@@ -0,0 +1,9 @@
// Resolve a client IP from the request (works behind proxies) — mirrors the equivalent
// local helper in userController.js, shared here for the admin-audit call sites.
function getClientIp(req) {
const forwarded = req.headers['x-forwarded-for'];
if (forwarded) return forwarded.split(',')[0].trim();
return req.socket?.remoteAddress || 'unknown';
}
module.exports = { getClientIp };
+285
View File
@@ -0,0 +1,285 @@
/**
* Two-factor authentication helpers: 6-digit codes delivered by email or
* WhatsApp, and "trust this device" tokens that let a browser skip the code.
*
* Neither the code nor the device token is stored in the clear — only an
* HMAC keyed with JWT_SECRET, so a leaked database row can't be replayed.
*/
const crypto = require('crypto');
const prisma = require('../config/db');
const { describeUserAgent } = require('./userAgent');
const CODE_TTL_MS = 10 * 60 * 1000; // a code is valid for 10 minutes
const MAX_ATTEMPTS = 5; // wrong guesses allowed per code
const RESEND_COOLDOWN_MS = 60 * 1000; // minimum gap between sends
const CHALLENGE_MAX_AGE_MS = 30 * 60 * 1000; // after this, log in again rather than resend
const TRUSTED_DEVICE_TTL_MS = 30 * 24 * 60 * 60 * 1000;
const METHODS = ['email', 'whatsapp'];
// Roles that must use two-factor authentication.
const REQUIRED_ROLES = ['admin', 'supervisor'];
// Application error carrying the HTTP status the controller should respond with.
function fail(message, statusCode = 400) {
const err = new Error(message);
err.statusCode = statusCode;
return err;
}
function generateCode() {
return String(crypto.randomInt(0, 1000000)).padStart(6, '0');
}
function hmac(value) {
return crypto.createHmac('sha256', process.env.JWT_SECRET || '').update(String(value)).digest('hex');
}
// Namespaced so a code hash can never collide with a device-token hash.
const hashCode = (code) => hmac(`2fa-code:${code}`);
const hashDeviceToken = (token) => hmac(`2fa-device:${token}`);
function safeEqual(a, b) {
const ba = Buffer.from(String(a));
const bb = Buffer.from(String(b));
return ba.length === bb.length && crypto.timingSafeEqual(ba, bb);
}
function hasRealEmail(user) {
return !!(user?.email && !user.email.endsWith('@guest.local') && !user.email.endsWith('@deleted.invalid') && !user.email.endsWith('@deleted.local'));
}
function maskEmail(email) {
const [local, domain] = String(email || '').split('@');
if (!local || !domain) return '•••';
return `${local[0]}•••@${domain}`;
}
function maskPhone(phone) {
const digits = String(phone || '').replace(/\D/g, '');
return `••• ••• ${digits.slice(-4)}`;
}
/** Channels this user could receive a code on right now. */
async function availableMethods(user) {
const { isValidZAPhone, isConfigured } = require('./whatsapp');
const methods = [];
if (hasRealEmail(user)) methods.push('email');
if (isValidZAPhone(user?.phoneNumber) && (await isConfigured())) methods.push('whatsapp');
return methods;
}
/**
* True when this user's role must use two-factor authentication.
* TWO_FACTOR_REQUIRED_ROLES=false turns the requirement off (break-glass).
*/
function requiresTwoFactor(user) {
if (String(process.env.TWO_FACTOR_REQUIRED_ROLES || 'true').toLowerCase() === 'false') return false;
return REQUIRED_ROLES.includes(user?.role);
}
/**
* The channel a required-2FA user should be enrolled on, or null when no channel
* can actually deliver a code — SMTP not set up and no WhatsApp — in which case the
* requirement is skipped rather than locking the account out.
*/
async function enforcementMethod(user) {
const { isSmtpConfigured } = require('./email');
if (hasRealEmail(user) && isSmtpConfigured()) return 'email';
const { isValidZAPhone, isConfigured } = require('./whatsapp');
if (isValidZAPhone(user?.phoneNumber) && (await isConfigured())) return 'whatsapp';
return null;
}
/** True when this user must enrol in 2FA before using their account. */
async function mustEnrolTwoFactor(user) {
if (!user || user.twoFactorEnabled || !requiresTwoFactor(user)) return false;
return (await enforcementMethod(user)) !== null;
}
/**
* Sends the code and reports where it actually went. A WhatsApp failure (session
* disconnected, not configured) falls back to email so a user is never locked out
* by the WhatsApp gateway being down. Throws when no channel could deliver.
* Pass `allowFallback: false` when the point is to prove the chosen channel works.
*/
async function deliverCode(user, method, code, { allowFallback = true } = {}) {
const { getSettingSync } = require('./settingsCache');
const orgName = getSettingSync('org_name', process.env.ORG_NAME || 'Cross Code');
if (process.env.NODE_ENV !== 'production') {
console.info(`[2fa][dev] code for ${user.email}: ${code}`);
}
if (method === 'whatsapp') {
const { isValidZAPhone, isConfigured, sendText } = require('./whatsapp');
const { buildWATwoFactorCode } = require('./waMessages');
try {
// sendText silently no-ops when WhatsApp isn't set up, so check first.
if (!isValidZAPhone(user.phoneNumber) || !(await isConfigured())) throw new Error('WhatsApp unavailable');
await sendText(user.phoneNumber, buildWATwoFactorCode({ name: user.name, code }));
return { method: 'whatsapp', destination: maskPhone(user.phoneNumber) };
} catch (e) {
console.warn('[2fa] WhatsApp code failed:', e?.message || e);
if (!allowFallback) throw fail('We couldn\'t send a WhatsApp message right now. Please try again later or choose email.', 503);
if (!hasRealEmail(user)) throw fail('We couldn\'t send your verification code. Please try again later.', 503);
}
}
if (!hasRealEmail(user)) throw fail('We couldn\'t send your verification code. Please try again later.', 503);
const { sendMail, buildTwoFactorCodeEmail } = require('./email');
try {
await sendMail({ to: user.email, subject: `Your ${orgName} verification code`, ...buildTwoFactorCodeEmail({ name: user.name, code }) });
} catch (e) {
console.warn('[2fa] Email code failed:', e?.message || e);
throw fail('We couldn\'t send your verification code. Please try again later.', 503);
}
return { method: 'email', destination: maskEmail(user.email) };
}
/**
* Sends a fresh code and records the challenge. Any earlier challenge of the same
* purpose for this user is discarded, so only the newest code works.
*/
async function createChallenge(user, purpose, method, options) {
const code = generateCode();
const sent = await deliverCode(user, method, code, options);
await prisma.twoFactorChallenge.deleteMany({ where: { userId: user.id, purpose } });
const challenge = await prisma.twoFactorChallenge.create({
data: {
userId: user.id,
purpose,
method: sent.method,
codeHash: hashCode(code),
expiresAt: new Date(Date.now() + CODE_TTL_MS),
},
});
return { challengeId: challenge.id, method: sent.method, destination: sent.destination };
}
/**
* Checks a submitted code. Returns the challenge (with its user) on success and
* throws otherwise. Pass `userId` to also require the challenge belong to that user.
*/
async function verifyChallenge(challengeId, code, purpose, userId) {
const invalid = () => fail('That code is invalid or has expired. Please request a new one.');
if (!challengeId || typeof challengeId !== 'string') throw invalid();
const submitted = String(code ?? '').replace(/\D/g, '');
// Count the attempt before comparing, atomically, so parallel guesses can't
// slip past the attempt limit.
const counted = await prisma.twoFactorChallenge.updateMany({
where: {
id: challengeId,
purpose,
...(userId ? { userId } : {}),
usedAt: null,
expiresAt: { gt: new Date() },
attempts: { lt: MAX_ATTEMPTS },
},
data: { attempts: { increment: 1 } },
});
if (counted.count !== 1) throw invalid();
const challenge = await prisma.twoFactorChallenge.findUnique({ where: { id: challengeId }, include: { user: true } });
if (!challenge || !safeEqual(challenge.codeHash, hashCode(submitted))) {
const left = challenge ? MAX_ATTEMPTS - challenge.attempts : 0;
throw fail(left > 0 ? `Incorrect code. ${left} attempt${left === 1 ? '' : 's'} left.` : 'Too many incorrect attempts. Please request a new code.');
}
// Single use — if two requests race with the right code, only one wins.
const used = await prisma.twoFactorChallenge.updateMany({
where: { id: challengeId, usedAt: null },
data: { usedAt: new Date() },
});
if (used.count !== 1) throw invalid();
return challenge;
}
/** Replaces the code on an existing challenge and sends it again. */
async function resendChallenge(challengeId, purpose, userId) {
const expired = () => fail('This verification has expired. Please start again.');
if (!challengeId || typeof challengeId !== 'string') throw expired();
const challenge = await prisma.twoFactorChallenge.findUnique({ where: { id: challengeId }, include: { user: true } });
if (!challenge || challenge.purpose !== purpose || challenge.usedAt || (userId && challenge.userId !== userId)) throw expired();
if (Date.now() - challenge.createdAt.getTime() > CHALLENGE_MAX_AGE_MS) throw expired();
const wait = RESEND_COOLDOWN_MS - (Date.now() - challenge.lastSentAt.getTime());
if (wait > 0) throw fail(`Please wait ${Math.ceil(wait / 1000)} seconds before requesting another code.`, 429);
// Claim the send slot first so two simultaneous resends can't both go out.
const claimed = await prisma.twoFactorChallenge.updateMany({
where: { id: challengeId, lastSentAt: challenge.lastSentAt, usedAt: null },
data: { lastSentAt: new Date() },
});
if (claimed.count !== 1) throw fail('Please wait before requesting another code.', 429);
const code = generateCode();
const sent = await deliverCode(challenge.user, challenge.method, code);
await prisma.twoFactorChallenge.update({
where: { id: challengeId },
data: { codeHash: hashCode(code), method: sent.method, attempts: 0, expiresAt: new Date(Date.now() + CODE_TTL_MS) },
});
return { challengeId, method: sent.method, destination: sent.destination };
}
/** Remembers this browser for 30 days. Returns the raw token to hand to the client. */
async function issueTrustedDevice(userId, userAgent) {
const token = crypto.randomBytes(32).toString('hex');
await prisma.trustedDevice.deleteMany({ where: { userId, expiresAt: { lt: new Date() } } });
await prisma.trustedDevice.create({
data: {
userId,
tokenHash: hashDeviceToken(token),
device: describeUserAgent(userAgent),
expiresAt: new Date(Date.now() + TRUSTED_DEVICE_TTL_MS),
},
});
return token;
}
/** True when `token` is an unexpired trusted-device token belonging to this user. */
async function isTrustedDevice(userId, token) {
if (!token || typeof token !== 'string') return false;
const row = await prisma.trustedDevice.findUnique({ where: { tokenHash: hashDeviceToken(token) } });
if (!row || row.userId !== userId || row.expiresAt <= new Date()) return false;
prisma.trustedDevice.update({ where: { id: row.id }, data: { lastUsedAt: new Date() } }).catch(() => {});
return true;
}
/** Forgets every trusted device — call whenever credentials or sessions are reset. */
async function clearTrustedDevices(userId) {
try {
await prisma.trustedDevice.deleteMany({ where: { userId } });
} catch (e) {
console.error('Failed to clear trusted devices:', e?.message);
}
}
module.exports = {
METHODS,
REQUIRED_ROLES,
requiresTwoFactor,
enforcementMethod,
mustEnrolTwoFactor,
MAX_ATTEMPTS,
generateCode,
hashCode,
hashDeviceToken,
maskEmail,
maskPhone,
hasRealEmail,
availableMethods,
createChallenge,
verifyChallenge,
resendChallenge,
issueTrustedDevice,
isTrustedDevice,
clearTrustedDevices,
};
+26 -3
View File
@@ -47,9 +47,12 @@ function buildWARegistration(reg, { isNew = true, balance, totalDue, totalPaid }
: `Your registration for *${eventTitle}* has been updated.`;
const { computeOptionLineTotal } = require('./pricing');
const items = (reg.registrationOptions || [])
.map(ro => `- ${ro.eventOption?.name || 'Option'} ×${ro.quantity} — ${fmtAmount(computeOptionLineTotal(ro, null, new Date()))}`)
.join('\n');
const { couponDiscountLine } = require('./pdfDocs');
const coupon = couponDiscountLine(reg);
const items = [
...(reg.registrationOptions || []).map(ro => `- ${ro.eventOption?.name || 'Option'} ×${ro.quantity} — ${fmtAmount(computeOptionLineTotal(ro, null, new Date()))}`),
...(coupon ? [`- ${coupon.label} — -${fmtAmount(coupon.amount)}`] : []),
].join('\n');
const paid = totalPaid ?? (reg.payments || []).reduce((s, p) => s + (p.amount || 0), 0);
const due = totalDue ?? 0;
@@ -149,6 +152,25 @@ function buildWALogin({ name, when, location, userAgent }) {
].join('\n');
}
// ─── Two-factor code ──────────────────────────────────────────────────────────
function buildWATwoFactorCode({ name, code }) {
const org = getOrg();
return [
`🔑 *Your verification code*`,
'',
`Hi ${name || 'there'},`,
'',
`Your *${org.name}* verification code is:`,
'',
`*${code}*`,
'',
`_It expires in 10 minutes. Never share this code with anyone._`,
'',
`> _Not you?_ Change your password immediately at ${org.url} or contact ${org.email}.`,
].join('\n');
}
// ─── Welcome ──────────────────────────────────────────────────────────────────
function buildWAWelcome({ name, events }) {
@@ -306,6 +328,7 @@ module.exports = {
buildWADonationAppliedToRegistrant,
buildWADonationUnassignedFromRegistrant,
buildWALogin,
buildWATwoFactorCode,
buildWAWelcome,
buildWAAccountClosed,
buildWATicketCaption,
+176
View File
@@ -0,0 +1,176 @@
const {
normalizeCode,
buildCouponLines,
computeCouponDiscount,
validateCoupon,
describeCoupon,
} = require('../src/utils/coupons');
const { computeRegistrationTotalDue, staffPriceFor } = require('../src/utils/pricing');
const NOW = new Date('2026-06-15T10:00:00Z');
const PAST = new Date('2026-06-01T00:00:00Z');
const FUTURE = new Date('2026-07-01T00:00:00Z');
const coupon = (over = {}) => ({
id: 'c1', code: 'STAFF50', type: 'percent', value: 50, stackEarlyBird: false, active: true,
validFrom: null, validUntil: null, maxUses: null, maxUsesPerUser: 1, minQuantity: null,
restrictRoles: [], restrictDomain: null, events: [], options: [], allowedUsers: [],
...over,
});
// 2 adult tickets at R200 (standard) and 1 T-shirt at R100.
const LINES = [
{ eventOptionId: 'adult', variantId: null, quantity: 2, unitPrice: 200, basePrice: 200 },
{ eventOptionId: 'shirt', variantId: 'L', quantity: 1, unitPrice: 100, basePrice: 100 },
];
describe('normalizeCode', () => {
test('trims and uppercases', () => {
expect(normalizeCode(' staff50 ')).toBe('STAFF50');
expect(normalizeCode(null)).toBe('');
});
});
describe('computeCouponDiscount', () => {
test('percentage over the whole event', () => {
expect(computeCouponDiscount(coupon(), LINES)).toBe(250);
});
test('fixed amount, capped at what is in scope', () => {
expect(computeCouponDiscount(coupon({ type: 'fixed', value: 120 }), LINES)).toBe(120);
expect(computeCouponDiscount(coupon({ type: 'fixed', value: 9999 }), LINES)).toBe(500);
});
test('comp covers only in-scope options', () => {
const c = coupon({ type: 'comp', options: [{ eventOptionId: 'adult', variantId: null }] });
expect(computeCouponDiscount(c, LINES)).toBe(400);
});
test('scope can target a single variant', () => {
const c = coupon({ type: 'comp', options: [{ eventOptionId: 'shirt', variantId: 'M' }] });
expect(computeCouponDiscount(c, LINES)).toBe(0);
const c2 = coupon({ type: 'comp', options: [{ eventOptionId: 'shirt', variantId: 'L' }] });
expect(computeCouponDiscount(c2, LINES)).toBe(100);
});
test('best price wins against early-bird when not stacking', () => {
// Early-bird R150 vs standard R200: 10% off standard = R180, early-bird already beats it.
const eb = [{ eventOptionId: 'adult', variantId: null, quantity: 1, unitPrice: 150, basePrice: 200 }];
expect(computeCouponDiscount(coupon({ value: 10 }), eb)).toBe(0);
// 50% off standard = R100, beats R150 early-bird by R50.
expect(computeCouponDiscount(coupon({ value: 50 }), eb)).toBe(50);
// Fixed R80 off standard = R120, beats R150 by R30.
expect(computeCouponDiscount(coupon({ type: 'fixed', value: 80 }), eb)).toBe(30);
});
test('stacking applies on top of the early-bird price', () => {
const eb = [{ eventOptionId: 'adult', variantId: null, quantity: 1, unitPrice: 150, basePrice: 200 }];
expect(computeCouponDiscount(coupon({ value: 10, stackEarlyBird: true }), eb)).toBe(15);
expect(computeCouponDiscount(coupon({ type: 'fixed', value: 80, stackEarlyBird: true }), eb)).toBe(80);
});
test('never goes below zero or above the price', () => {
expect(computeCouponDiscount(coupon({ value: 150 }), LINES)).toBe(500);
expect(computeCouponDiscount(coupon({ type: 'fixed', value: -10 }), LINES)).toBe(0);
expect(computeCouponDiscount(coupon(), [])).toBe(0);
});
});
describe('validateCoupon', () => {
const ctx = (over = {}) => ({ eventId: 'e1', user: { id: 'u1', role: 'user', email: 'a@church.org' }, now: NOW, usesTotal: 0, usesByUser: 0, lines: LINES, ...over });
test('accepts an unrestricted active code', () => {
expect(validateCoupon(coupon(), ctx())).toEqual({ ok: true });
});
test('rejects inactive, not-yet-valid and expired codes', () => {
expect(validateCoupon(coupon({ active: false }), ctx()).ok).toBe(false);
expect(validateCoupon(coupon({ validFrom: FUTURE }), ctx()).message).toMatch(/isn't valid yet/);
expect(validateCoupon(coupon({ validUntil: PAST }), ctx()).message).toMatch(/expired/);
});
test('limits to the coupon events when any are set', () => {
expect(validateCoupon(coupon({ events: [{ eventId: 'e2' }] }), ctx()).ok).toBe(false);
expect(validateCoupon(coupon({ events: [{ eventId: 'e1' }, { eventId: 'e2' }] }), ctx()).ok).toBe(true);
});
test('restricted codes need a known account matching any restriction', () => {
const staffOnly = coupon({ restrictRoles: ['staff', 'supervisor', 'admin'] });
expect(validateCoupon(staffOnly, ctx({ user: null })).message).toMatch(/Log in/);
expect(validateCoupon(staffOnly, ctx()).ok).toBe(false);
expect(validateCoupon(staffOnly, ctx({ user: { id: 'u1', role: 'staff', email: 'x@y.z' } })).ok).toBe(true);
expect(validateCoupon(coupon({ restrictDomain: '@church.org' }), ctx()).ok).toBe(true);
expect(validateCoupon(coupon({ restrictDomain: 'other.org' }), ctx()).ok).toBe(false);
expect(validateCoupon(coupon({ allowedUsers: [{ userId: 'u1' }] }), ctx()).ok).toBe(true);
});
test('enforces total and per-user use limits', () => {
expect(validateCoupon(coupon({ maxUses: 5 }), ctx({ usesTotal: 5 })).message).toMatch(/fully used/);
expect(validateCoupon(coupon({ maxUsesPerUser: 1 }), ctx({ usesByUser: 1 })).message).toMatch(/already used/);
expect(validateCoupon(coupon({ maxUsesPerUser: null }), ctx({ usesByUser: 9 })).ok).toBe(true);
});
test('needs qualifying items and the minimum quantity', () => {
expect(validateCoupon(coupon({ options: [{ eventOptionId: 'other' }] }), ctx()).message).toMatch(/doesn't apply/);
expect(validateCoupon(coupon({ minQuantity: 4 }), ctx()).message).toMatch(/at least 4/);
expect(validateCoupon(coupon({ minQuantity: 3 }), ctx()).ok).toBe(true);
});
});
describe('buildCouponLines', () => {
test('one line per tranche; early-bird tranches keep the standard price as base', () => {
const reg = {
registrationOptions: [{
eventOptionId: 'adult', variantId: null, quantity: 3,
eventOption: { price: 200, variants: [] },
tranches: [
{ quantity: 2, priceSnapshot: 150, appliedTierId: 'tier1' },
{ quantity: 1, priceSnapshot: 200, appliedTierId: null },
],
}],
};
expect(buildCouponLines(reg)).toEqual([
{ eventOptionId: 'adult', variantId: null, quantity: 2, unitPrice: 150, basePrice: 200 },
{ eventOptionId: 'adult', variantId: null, quantity: 1, unitPrice: 200, basePrice: 200 },
]);
});
});
describe('computeRegistrationTotalDue with a discount', () => {
const reg = (discountTotal) => ({
discountTotal,
registrationOptions: [{ quantity: 2, tranches: [{ quantity: 2, priceSnapshot: 100 }] }],
});
test('subtracts the coupon discount', () => {
expect(computeRegistrationTotalDue(reg(50), NOW)).toBe(150);
});
test('never goes below zero', () => {
expect(computeRegistrationTotalDue(reg(500), NOW)).toBe(0);
});
});
describe('staffPriceFor', () => {
const option = { price: 200, staffPrice: 50, variants: [{ id: 'v1', staffPrice: 20 }, { id: 'v2', staffPrice: null }] };
test('applies to Staff and above only', () => {
expect(staffPriceFor(option, null, { role: 'staff' })).toBe(50);
expect(staffPriceFor(option, null, { role: 'admin' })).toBe(50);
expect(staffPriceFor(option, null, { role: 'user' })).toBeNull();
expect(staffPriceFor(option, null, null)).toBeNull();
});
test('a variant staff price overrides the option one', () => {
expect(staffPriceFor(option, 'v1', { role: 'staff' })).toBe(20);
expect(staffPriceFor(option, 'v2', { role: 'staff' })).toBe(50);
});
});
describe('describeCoupon', () => {
test('summarises each type', () => {
expect(describeCoupon(coupon())).toBe('50% off');
expect(describeCoupon(coupon({ type: 'fixed', value: 25 }))).toBe('R25.00 off');
expect(describeCoupon(coupon({ type: 'comp' }))).toBe('Free');
});
});
+46
View File
@@ -0,0 +1,46 @@
const { computeDonationRemaining } = require('../src/utils/donationUtils');
describe('computeDonationRemaining', () => {
test('a donation with no legs has its full amount remaining', () => {
expect(computeDonationRemaining(500, [])).toBe(500);
});
test('an allocation leg reduces the remaining balance', () => {
const legs = [{ amount: 200 }];
expect(computeDonationRemaining(500, legs)).toBe(300);
});
test('multiple allocation legs reduce the remaining balance cumulatively', () => {
const legs = [{ amount: 200 }, { amount: 150 }];
expect(computeDonationRemaining(500, legs)).toBe(150);
});
test('the 1.4.2 regression: refunding the donation itself (a negative-amount leg) reduces remaining balance, not inflates it', () => {
// 500 donation, never allocated, R200 of it refunded directly back to the donor
// (a leg with amount: -200). That R200 is no longer available to allocate — remaining
// must drop to 300. The pre-1.4.2 bug summed legs without Math.abs(), so
// remaining = 500 - (-200) = 700 (inflated) instead of 500 - 200 = 300 (correct).
const legs = [{ amount: -200 }];
expect(computeDonationRemaining(500, legs)).toBe(300);
});
test('an allocation and a separate direct refund both reduce the remaining balance', () => {
// 500 donation: R200 allocated to a registration, R100 separately refunded to the donor.
// Remaining = 500 - 200 - 100 = 200.
const legs = [{ amount: 200 }, { amount: -100 }];
expect(computeDonationRemaining(500, legs)).toBe(200);
});
test('unassigning an allocation removes its leg entirely rather than adding an offsetting one', () => {
// unassignDonationFromRegistration deletes the leg row outright (confirmed in
// paymentController.js), so the "leg no longer exists" case — not a negative-amount
// leg — is how an unassigned allocation becomes available again.
const legsAfterUnassign = [];
expect(computeDonationRemaining(500, legsAfterUnassign)).toBe(500);
});
test('handles a null/undefined legs array', () => {
expect(computeDonationRemaining(500, null)).toBe(500);
expect(computeDonationRemaining(500, undefined)).toBe(500);
});
});
+146
View File
@@ -0,0 +1,146 @@
const {
getEffectiveUnitPrice,
computeOptionLineTotal,
computeRegistrationTotalDue,
attachComputedTotals,
} = require('../src/utils/pricing');
const DAY = 24 * 60 * 60 * 1000;
const NOW = new Date('2026-06-01T00:00:00Z');
const PAST = new Date(NOW.getTime() - DAY);
const FUTURE = new Date(NOW.getTime() + DAY);
describe('getEffectiveUnitPrice', () => {
test('returns base price when there are no early-bird tiers', () => {
const option = { price: 100, earlyBirdTiers: [] };
expect(getEffectiveUnitPrice(option, null, NOW)).toBe(100);
});
test('returns tier price when the tier deadline is still in the future', () => {
const option = {
price: 100,
earlyBirdTiers: [{ id: 't1', price: 50, deadline: FUTURE }],
};
expect(getEffectiveUnitPrice(option, null, NOW)).toBe(50);
});
test('falls back to base price once the tier deadline has passed', () => {
const option = {
price: 100,
earlyBirdTiers: [{ id: 't1', price: 50, deadline: PAST }],
};
expect(getEffectiveUnitPrice(option, null, NOW)).toBe(100);
});
test('requires the deadline to be after both referenceTime and atTime', () => {
const midDeadline = new Date(NOW.getTime() - DAY / 2);
const option = {
price: 100,
earlyBirdTiers: [{ id: 't1', price: 50, deadline: midDeadline }],
};
// referenceTime (PAST) is before the deadline, but atTime (NOW) is after it — tier no longer applies
expect(getEffectiveUnitPrice(option, PAST, NOW)).toBe(100);
});
test('with multiple applicable tiers, picks the one with the earliest deadline', () => {
const soonerDeadline = new Date(NOW.getTime() + DAY);
const laterDeadline = new Date(NOW.getTime() + 2 * DAY);
const option = {
price: 100,
earlyBirdTiers: [
{ id: 'later', price: 80, deadline: laterDeadline },
{ id: 'sooner', price: 60, deadline: soonerDeadline },
],
};
expect(getEffectiveUnitPrice(option, null, NOW)).toBe(60);
});
test('returns 0 for a missing eventOption', () => {
expect(getEffectiveUnitPrice(null, null, NOW)).toBe(0);
});
});
describe('computeOptionLineTotal', () => {
test('the 1.8.0 regression: buying more after an early-bird tier expires only re-prices the new quantity', () => {
// 5 tickets bought at R50 (early-bird), then 1 more bought after the price rose to R100.
// Must total 5*50 + 1*100 = 350, not 6*100 = 600.
const ro = {
quantity: 6,
priceSnapshot: 100,
tranches: [
{ quantity: 5, priceSnapshot: 50, createdAt: PAST },
{ quantity: 1, priceSnapshot: 100, createdAt: NOW },
],
};
expect(computeOptionLineTotal(ro, null, NOW)).toBe(350);
});
test('sums a single tranche correctly', () => {
const ro = { quantity: 3, priceSnapshot: 40, tranches: [{ quantity: 3, priceSnapshot: 40 }] };
expect(computeOptionLineTotal(ro, null, NOW)).toBe(120);
});
test('legacy fallback: no tranches, uses priceSnapshot directly', () => {
const ro = { quantity: 4, priceSnapshot: 25, tranches: [] };
expect(computeOptionLineTotal(ro, null, NOW)).toBe(100);
});
test('legacy fallback: no tranches and no priceSnapshot, re-evaluates from tier deadlines', () => {
const ro = {
quantity: 2,
priceSnapshot: null,
tranches: [],
eventOption: { price: 100, earlyBirdTiers: [{ id: 't1', price: 70, deadline: FUTURE }] },
};
expect(computeOptionLineTotal(ro, null, NOW)).toBe(140);
});
});
describe('computeRegistrationTotalDue', () => {
test('sums tranche-aware totals across multiple RegistrationOptions', () => {
const registration = {
registrationOptions: [
{
quantity: 6,
priceSnapshot: 100,
tranches: [
{ quantity: 5, priceSnapshot: 50, createdAt: PAST },
{ quantity: 1, priceSnapshot: 100, createdAt: NOW },
],
},
{
quantity: 2,
priceSnapshot: 20,
tranches: [{ quantity: 2, priceSnapshot: 20, createdAt: PAST }],
},
],
payments: [],
};
// 350 (first option, see 1.8.0 regression case) + 40 (second option)
expect(computeRegistrationTotalDue(registration, NOW)).toBe(390);
});
test('returns 0 for a registration with no options', () => {
expect(computeRegistrationTotalDue({ registrationOptions: [] }, NOW)).toBe(0);
expect(computeRegistrationTotalDue(null, NOW)).toBe(0);
});
});
describe('attachComputedTotals', () => {
test('mutates the registration with per-line and total computed amounts', () => {
const registration = {
registrationOptions: [
{ quantity: 2, priceSnapshot: 50, tranches: [{ quantity: 2, priceSnapshot: 50 }] },
],
payments: [],
};
const result = attachComputedTotals(registration);
expect(result).toBe(registration); // mutated in place
expect(result.registrationOptions[0].lineTotal).toBe(100);
expect(result.totalDueComputed).toBe(100);
});
test('handles a null registration gracefully', () => {
expect(attachComputedTotals(null)).toBeNull();
});
});
+125
View File
@@ -0,0 +1,125 @@
process.env.JWT_SECRET = 'test-secret';
process.env.NODE_ENV = 'test';
// Two-factor is required for admins and supervisors — but only when some channel
// can actually deliver a code, so a site with no SMTP/WhatsApp never locks them out.
const mockChannels = { smtp: true, whatsapp: true };
const mockUsers = new Map();
jest.mock('../src/config/db', () => ({
user: { findUnique: async ({ where }) => mockUsers.get(where.id) || null },
}));
jest.mock('../src/utils/settingsCache', () => ({ getSettingSync: (_key, fallback) => fallback }));
jest.mock('../src/utils/email', () => ({ isSmtpConfigured: () => mockChannels.smtp }));
jest.mock('../src/utils/whatsapp', () => ({
isValidZAPhone: (p) => /^27\d{9}$/.test(String(p || '')),
isConfigured: async () => mockChannels.whatsapp,
}));
const jwt = require('jsonwebtoken');
const { requiresTwoFactor, enforcementMethod, mustEnrolTwoFactor } = require('../src/utils/twoFactor');
const { protect } = require('../src/middleware/authMiddleware');
const ADMIN = { id: 'a1', name: 'Ann', email: 'ann@example.com', phoneNumber: '27821234567', role: 'admin', isActive: true, tokenVersion: 0, twoFactorEnabled: false };
beforeEach(() => {
mockChannels.smtp = true;
mockChannels.whatsapp = true;
mockUsers.clear();
delete process.env.TWO_FACTOR_REQUIRED_ROLES;
});
describe('requiresTwoFactor', () => {
test('applies to admins and supervisors only', () => {
expect(requiresTwoFactor({ role: 'admin' })).toBe(true);
expect(requiresTwoFactor({ role: 'supervisor' })).toBe(true);
expect(requiresTwoFactor({ role: 'staff' })).toBe(false);
expect(requiresTwoFactor({ role: 'user' })).toBe(false);
});
test('TWO_FACTOR_REQUIRED_ROLES=false turns the requirement off', () => {
process.env.TWO_FACTOR_REQUIRED_ROLES = 'false';
expect(requiresTwoFactor({ role: 'admin' })).toBe(false);
});
});
describe('enforcementMethod', () => {
test('prefers email when SMTP is configured', async () => {
expect(await enforcementMethod(ADMIN)).toBe('email');
});
test('falls back to WhatsApp without SMTP', async () => {
mockChannels.smtp = false;
expect(await enforcementMethod(ADMIN)).toBe('whatsapp');
});
test('skips a guest placeholder email', async () => {
expect(await enforcementMethod({ ...ADMIN, email: 'x@guest.local' })).toBe('whatsapp');
});
test('is null when no channel can deliver a code', async () => {
mockChannels.smtp = false;
mockChannels.whatsapp = false;
expect(await enforcementMethod(ADMIN)).toBeNull();
});
});
describe('mustEnrolTwoFactor', () => {
test('true for an admin without 2FA', async () => {
expect(await mustEnrolTwoFactor(ADMIN)).toBe(true);
});
test('false once enrolled, for other roles, or with no channel', async () => {
expect(await mustEnrolTwoFactor({ ...ADMIN, twoFactorEnabled: true })).toBe(false);
expect(await mustEnrolTwoFactor({ ...ADMIN, role: 'staff' })).toBe(false);
mockChannels.smtp = false;
mockChannels.whatsapp = false;
expect(await mustEnrolTwoFactor(ADMIN)).toBe(false);
});
});
describe('protect', () => {
function run(user, claims = {}) {
mockUsers.set(user.id, user);
const token = jwt.sign({ id: user.id, role: user.role, tokenVersion: 0, ...claims }, process.env.JWT_SECRET);
const req = { headers: { authorization: `Bearer ${token}` } };
const res = { statusCode: 200, body: null };
res.status = (c) => { res.statusCode = c; return res; };
res.json = (b) => { res.body = b; return res; };
return new Promise((resolve) => {
protect(req, res, (err) => resolve({ req, res, err, passed: !err })).then(() => {
if (res.body) resolve({ req, res, passed: false });
});
});
}
test('rejects an un-enrolled admin session with TWO_FACTOR_REQUIRED', async () => {
const { res, passed } = await run(ADMIN);
expect(passed).toBe(false);
expect(res.statusCode).toBe(401);
expect(res.body.code).toBe('TWO_FACTOR_REQUIRED');
});
test('lets an enrolled admin through', async () => {
const { passed } = await run({ ...ADMIN, twoFactorEnabled: true });
expect(passed).toBe(true);
});
test('lets a regular user through without 2FA', async () => {
const { passed } = await run({ ...ADMIN, id: 'u1', role: 'user' });
expect(passed).toBe(true);
});
test('gives the setup wizard token a grace period', async () => {
const { passed } = await run(ADMIN, { setup: true });
expect(passed).toBe(true);
});
test('does not enforce when no channel can deliver a code', async () => {
mockChannels.smtp = false;
mockChannels.whatsapp = false;
const { passed } = await run(ADMIN);
expect(passed).toBe(true);
});
});
+74
View File
@@ -0,0 +1,74 @@
process.env.JWT_SECRET = 'test-secret';
// The pure helpers under test never touch the database.
jest.mock('../src/config/db', () => ({}));
const {
generateCode,
hashCode,
hashDeviceToken,
maskEmail,
maskPhone,
hasRealEmail,
} = require('../src/utils/twoFactor');
describe('generateCode', () => {
test('always returns exactly six digits', () => {
for (let i = 0; i < 500; i++) {
expect(generateCode()).toMatch(/^\d{6}$/);
}
});
});
describe('hashCode / hashDeviceToken', () => {
test('is deterministic and never returns the input', () => {
expect(hashCode('123456')).toBe(hashCode('123456'));
expect(hashCode('123456')).not.toContain('123456');
expect(hashCode('123456')).toMatch(/^[0-9a-f]{64}$/);
});
test('different codes hash differently', () => {
expect(hashCode('123456')).not.toBe(hashCode('123457'));
});
test('code and device-token hashes are namespaced apart', () => {
expect(hashCode('123456')).not.toBe(hashDeviceToken('123456'));
});
test('depends on JWT_SECRET', () => {
const before = hashCode('123456');
process.env.JWT_SECRET = 'another-secret';
expect(hashCode('123456')).not.toBe(before);
process.env.JWT_SECRET = 'test-secret';
});
});
describe('masking', () => {
test('maskEmail keeps the first letter and the domain', () => {
expect(maskEmail('jane@example.com')).toBe('j•••@example.com');
});
test('maskEmail copes with junk input', () => {
expect(maskEmail('')).toBe('•••');
expect(maskEmail(null)).toBe('•••');
});
test('maskPhone keeps only the last four digits', () => {
expect(maskPhone('27821234567')).toBe('••• ••• 4567');
expect(maskPhone('+27 82 123 4567')).toBe('••• ••• 4567');
});
});
describe('hasRealEmail', () => {
test('accepts a normal address', () => {
expect(hasRealEmail({ email: 'jane@example.com' })).toBe(true);
});
test('rejects guest and deleted placeholders and missing values', () => {
expect(hasRealEmail({ email: 'abc@guest.local' })).toBe(false);
expect(hasRealEmail({ email: 'deleted-1@deleted.invalid' })).toBe(false);
expect(hasRealEmail({ email: 'deleted-1@deleted.local' })).toBe(false);
expect(hasRealEmail({})).toBe(false);
expect(hasRealEmail(null)).toBe(false);
});
});
+282
View File
@@ -0,0 +1,282 @@
process.env.JWT_SECRET = 'test-secret';
process.env.NODE_ENV = 'test';
// ─── In-memory stand-in for the two Prisma models the 2FA helpers use ─────────
// Supports just the `where` shapes those helpers issue: equality, null, { gt }, { lt }.
function matches(row, where = {}) {
return Object.entries(where).every(([key, cond]) => {
const value = row[key];
if (cond && typeof cond === 'object' && !(cond instanceof Date)) {
if ('gt' in cond) return value > cond.gt;
if ('lt' in cond) return value < cond.lt;
}
if (cond instanceof Date) return value instanceof Date && value.getTime() === cond.getTime();
return value === cond;
});
}
function applyData(row, data) {
for (const [key, val] of Object.entries(data)) {
row[key] = val && typeof val === 'object' && 'increment' in val ? row[key] + val.increment : val;
}
}
function makeModel(defaults, relations = {}) {
let rows = [];
let seq = 0;
const withRelations = (row, include) => {
if (!row) return null;
const out = { ...row };
for (const key of Object.keys(include || {})) out[key] = relations[key](row);
return out;
};
return {
_rows: () => rows,
_reset: () => { rows = []; },
create: async ({ data }) => {
const row = { id: `id-${++seq}`, ...defaults(), ...data };
rows.push(row);
return { ...row };
},
findUnique: async ({ where, include }) => withRelations(rows.find(r => matches(r, where)), include),
update: async ({ where, data }) => {
const row = rows.find(r => matches(r, where));
applyData(row, data);
return { ...row };
},
updateMany: async ({ where, data }) => {
const hit = rows.filter(r => matches(r, where));
hit.forEach(r => applyData(r, data));
return { count: hit.length };
},
deleteMany: async ({ where }) => {
const before = rows.length;
rows = rows.filter(r => !matches(r, where));
return { count: before - rows.length };
},
};
}
const USER = { id: 'user-1', name: 'Jane', email: 'jane@example.com', phoneNumber: '27821234567' };
const mockDb = {
twoFactorChallenge: makeModel(
() => ({ attempts: 0, usedAt: null, lastSentAt: new Date(), createdAt: new Date() }),
{ user: () => USER },
),
trustedDevice: makeModel(() => ({ createdAt: new Date(), lastUsedAt: new Date() })),
};
const mockSent = { emails: [], whatsapps: [] };
const mockWhatsApp = { configured: true, fail: false };
jest.mock('../src/config/db', () => mockDb);
jest.mock('../src/utils/settingsCache', () => ({ getSettingSync: (_key, fallback) => fallback }));
jest.mock('../src/utils/email', () => ({
sendMail: async (mail) => { mockSent.emails.push(mail); },
isSmtpConfigured: () => true,
buildTwoFactorCodeEmail: ({ code }) => ({ text: `code:${code}`, html: '' }),
}));
jest.mock('../src/utils/waMessages', () => ({ buildWATwoFactorCode: ({ code }) => `code:${code}` }));
jest.mock('../src/utils/whatsapp', () => ({
isValidZAPhone: (p) => /^27\d{9}$/.test(String(p || '')),
isConfigured: async () => mockWhatsApp.configured,
sendText: async (to, message) => {
if (mockWhatsApp.fail) throw new Error('session disconnected');
mockSent.whatsapps.push({ to, message });
},
}));
const {
MAX_ATTEMPTS,
availableMethods,
createChallenge,
verifyChallenge,
resendChallenge,
issueTrustedDevice,
isTrustedDevice,
clearTrustedDevices,
} = require('../src/utils/twoFactor');
const lastEmailCode = () => mockSent.emails[mockSent.emails.length - 1].text.split(':')[1];
const lastWhatsAppCode = () => mockSent.whatsapps[mockSent.whatsapps.length - 1].message.split(':')[1];
const wrongCode = (code) => (code === '000000' ? '111111' : '000000');
beforeEach(() => {
mockDb.twoFactorChallenge._reset();
mockDb.trustedDevice._reset();
mockSent.emails = [];
mockSent.whatsapps = [];
mockWhatsApp.configured = true;
mockWhatsApp.fail = false;
jest.spyOn(console, 'warn').mockImplementation(() => {});
jest.spyOn(console, 'info').mockImplementation(() => {});
});
afterEach(() => jest.restoreAllMocks());
describe('availableMethods', () => {
test('offers WhatsApp only with a valid phone and a configured gateway', async () => {
expect(await availableMethods(USER)).toEqual(['email', 'whatsapp']);
mockWhatsApp.configured = false;
expect(await availableMethods(USER)).toEqual(['email']);
mockWhatsApp.configured = true;
expect(await availableMethods({ ...USER, phoneNumber: null })).toEqual(['email']);
});
test('offers nothing by email for a guest placeholder address', async () => {
expect(await availableMethods({ ...USER, email: 'x@guest.local' })).toEqual(['whatsapp']);
});
});
describe('createChallenge', () => {
test('emails the code and stores only its hash', async () => {
const res = await createChallenge(USER, 'login', 'email');
expect(res).toMatchObject({ method: 'email', destination: 'j•••@example.com' });
const [row] = mockDb.twoFactorChallenge._rows();
expect(row.id).toBe(res.challengeId);
expect(row.codeHash).not.toContain(lastEmailCode());
expect(mockSent.emails[0].to).toBe(USER.email);
});
test('sends by WhatsApp when asked', async () => {
const res = await createChallenge(USER, 'login', 'whatsapp');
expect(res).toMatchObject({ method: 'whatsapp', destination: '••• ••• 4567' });
expect(mockSent.whatsapps).toHaveLength(1);
expect(mockSent.emails).toHaveLength(0);
});
test('falls back to email when WhatsApp fails or is not configured', async () => {
mockWhatsApp.fail = true;
expect((await createChallenge(USER, 'login', 'whatsapp')).method).toBe('email');
mockWhatsApp.fail = false;
mockWhatsApp.configured = false;
expect((await createChallenge(USER, 'login', 'whatsapp')).method).toBe('email');
expect(mockSent.emails).toHaveLength(2);
});
test('does not fall back when allowFallback is false, and records no challenge', async () => {
mockWhatsApp.fail = true;
await expect(createChallenge(USER, 'enable', 'whatsapp', { allowFallback: false })).rejects.toMatchObject({ statusCode: 503 });
expect(mockSent.emails).toHaveLength(0);
expect(mockDb.twoFactorChallenge._rows()).toHaveLength(0);
});
test('a new challenge replaces the previous one for the same purpose', async () => {
const first = await createChallenge(USER, 'login', 'email');
const firstCode = lastEmailCode();
await createChallenge(USER, 'login', 'email');
await expect(verifyChallenge(first.challengeId, firstCode, 'login')).rejects.toThrow(/invalid or has expired/);
});
});
describe('verifyChallenge', () => {
test('accepts the right code exactly once', async () => {
const { challengeId } = await createChallenge(USER, 'login', 'email');
const code = lastEmailCode();
const challenge = await verifyChallenge(challengeId, code, 'login');
expect(challenge.userId).toBe(USER.id);
await expect(verifyChallenge(challengeId, code, 'login')).rejects.toThrow(/invalid or has expired/);
});
test('tolerates spaces in the submitted code', async () => {
const { challengeId } = await createChallenge(USER, 'login', 'email');
const code = lastEmailCode();
await expect(verifyChallenge(challengeId, ` ${code.slice(0, 3)} ${code.slice(3)} `, 'login')).resolves.toBeTruthy();
});
test('locks the challenge after the maximum wrong attempts, even for the right code', async () => {
const { challengeId } = await createChallenge(USER, 'login', 'email');
const code = lastEmailCode();
for (let i = 1; i <= MAX_ATTEMPTS; i++) {
await expect(verifyChallenge(challengeId, wrongCode(code), 'login')).rejects.toThrow(
i < MAX_ATTEMPTS ? /Incorrect code/ : /Too many incorrect attempts/
);
}
await expect(verifyChallenge(challengeId, code, 'login')).rejects.toThrow(/invalid or has expired/);
});
test('rejects an expired code', async () => {
const { challengeId } = await createChallenge(USER, 'login', 'email');
mockDb.twoFactorChallenge._rows()[0].expiresAt = new Date(Date.now() - 1000);
await expect(verifyChallenge(challengeId, lastEmailCode(), 'login')).rejects.toThrow(/invalid or has expired/);
});
test('rejects a code used for the wrong purpose or by another user', async () => {
const { challengeId } = await createChallenge(USER, 'enable', 'email');
const code = lastEmailCode();
await expect(verifyChallenge(challengeId, code, 'login')).rejects.toThrow(/invalid or has expired/);
await expect(verifyChallenge(challengeId, code, 'enable', 'someone-else')).rejects.toThrow(/invalid or has expired/);
await expect(verifyChallenge(challengeId, code, 'enable', USER.id)).resolves.toBeTruthy();
});
test('rejects missing or malformed input', async () => {
await expect(verifyChallenge(undefined, '123456', 'login')).rejects.toThrow();
await expect(verifyChallenge({ not: 'a string' }, '123456', 'login')).rejects.toThrow();
const { challengeId } = await createChallenge(USER, 'login', 'email');
await expect(verifyChallenge(challengeId, undefined, 'login')).rejects.toThrow(/Incorrect code/);
});
});
describe('resendChallenge', () => {
test('refuses inside the cooldown', async () => {
const { challengeId } = await createChallenge(USER, 'login', 'email');
await expect(resendChallenge(challengeId, 'login')).rejects.toMatchObject({ statusCode: 429 });
expect(mockSent.emails).toHaveLength(1);
});
test('after the cooldown, sends a new code that replaces the old one and resets attempts', async () => {
const { challengeId } = await createChallenge(USER, 'login', 'email');
const oldCode = lastEmailCode();
await expect(verifyChallenge(challengeId, wrongCode(oldCode), 'login')).rejects.toThrow();
mockDb.twoFactorChallenge._rows()[0].lastSentAt = new Date(Date.now() - 61 * 1000);
await resendChallenge(challengeId, 'login');
const newCode = lastEmailCode();
expect(mockSent.emails).toHaveLength(2);
expect(mockDb.twoFactorChallenge._rows()[0].attempts).toBe(0);
if (newCode !== oldCode) {
await expect(verifyChallenge(challengeId, oldCode, 'login')).rejects.toThrow(/Incorrect code/);
}
await expect(verifyChallenge(challengeId, newCode, 'login')).resolves.toBeTruthy();
});
test('refuses once the challenge is too old or already used', async () => {
const { challengeId } = await createChallenge(USER, 'login', 'email');
const row = mockDb.twoFactorChallenge._rows()[0];
row.lastSentAt = new Date(Date.now() - 61 * 1000);
row.createdAt = new Date(Date.now() - 31 * 60 * 1000);
await expect(resendChallenge(challengeId, 'login')).rejects.toThrow(/expired/);
row.createdAt = new Date();
row.usedAt = new Date();
await expect(resendChallenge(challengeId, 'login')).rejects.toThrow(/expired/);
});
});
describe('trusted devices', () => {
test('a freshly issued token is trusted for its own user only', async () => {
const token = await issueTrustedDevice(USER.id, 'Mozilla/5.0 (Windows NT 10.0) Chrome/120.0 Safari/537.36');
expect(token).toMatch(/^[0-9a-f]{64}$/);
expect(mockDb.trustedDevice._rows()[0].tokenHash).not.toBe(token);
expect(await isTrustedDevice(USER.id, token)).toBe(true);
expect(await isTrustedDevice('someone-else', token)).toBe(false);
});
test('unknown, malformed and expired tokens are not trusted', async () => {
const token = await issueTrustedDevice(USER.id, '');
expect(await isTrustedDevice(USER.id, 'nope')).toBe(false);
expect(await isTrustedDevice(USER.id, undefined)).toBe(false);
expect(await isTrustedDevice(USER.id, { tokenHash: 'x' })).toBe(false);
mockDb.trustedDevice._rows()[0].expiresAt = new Date(Date.now() - 1000);
expect(await isTrustedDevice(USER.id, token)).toBe(false);
});
test('clearTrustedDevices forgets every device for the user', async () => {
const a = await issueTrustedDevice(USER.id, '');
const b = await issueTrustedDevice(USER.id, '');
await clearTrustedDevices(USER.id);
expect(await isTrustedDevice(USER.id, a)).toBe(false);
expect(await isTrustedDevice(USER.id, b)).toBe(false);
});
});
+19
View File
@@ -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',
},
],
};
+6 -1
View File
@@ -1,4 +1,5 @@
import type { NextConfig } from "next";
import { withSentryConfig } from "@sentry/nextjs";
const nextConfig: NextConfig = {
images: {
@@ -12,4 +13,8 @@ const nextConfig: NextConfig = {
},
};
export default nextConfig;
// A no-op wrap when SENTRY_DSN isn't configured for this deployment — safe in
// every environment (dev, or a fresh deploy that hasn't set up Sentry yet).
export default process.env.NEXT_PUBLIC_SENTRY_DSN
? withSentryConfig(nextConfig, { silent: true, disableLogger: true })
: nextConfig;
+3956 -1735
View File
File diff suppressed because it is too large Load Diff
+13 -3
View File
@@ -1,6 +1,6 @@
{
"name": "hope-events-frontend",
"version": "1.9.1",
"version": "1.13.0",
"private": true,
"scripts": {
"dev": "next dev --turbopack",
@@ -27,12 +27,13 @@
"@radix-ui/react-tabs": "^1.1.12",
"@radix-ui/react-toast": "^1.2.14",
"@radix-ui/react-tooltip": "^1.2.7",
"@sentry/nextjs": "^10.71.0",
"@zxing/browser": "^0.1.5",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"date-fns": "^4.1.0",
"lucide-react": "^0.536.0",
"next": "15.4.5",
"next": "^15.5.24",
"qrcode": "^1.5.4",
"react": "19.1.0",
"react-day-picker": "^9.8.1",
@@ -51,9 +52,18 @@
"@types/react-dom": "^19",
"autoprefixer": "^10.4.21",
"eslint": "^9",
"eslint-config-next": "15.4.5",
"eslint-config-next": "^15.5.24",
"postcss": "^8.5.6",
"tailwindcss": "3.4",
"typescript": "^5"
},
"overrides": {
"next": {
"postcss": "^8.5.23"
}
},
"allowScripts": {
"@sentry/cli@2.58.6": true,
"unrs-resolver@1.12.2": true
}
}
+14 -4
View File
@@ -3,15 +3,25 @@ import { apiFetch } from "@/lib/api";
export const revalidate = 60;
// Event aliases are admin-set slugs (e.g. "camp-2025", "movie-night") — see the
// "URL Alias" field in the event wizard. This catch-all route matches *any*
// unmatched top-level path, so it's also what every bot/scanner probe hits
// (/wp-login.php, /.env, /xmlrpc.php, etc.). Rejecting anything that isn't a
// plausible slug here skips a live DB query for that background noise instead
// of forwarding it straight to the backend.
const VALID_ALIAS = /^[a-zA-Z0-9_-]{1,100}$/;
export default async function EventRedirectPage({ params }: { params: Promise<{ redirectUrl: string }> }) {
const { redirectUrl } = await params;
let event: any = null;
try {
event = await apiFetch<any>(`/api/events/by-alias/${redirectUrl}`);
} catch (error) {
console.error("Failed to fetch event:", error);
if (VALID_ALIAS.test(redirectUrl)) {
try {
event = await apiFetch<any>(`/api/events/by-alias/${redirectUrl}`);
} catch (error) {
console.error("Failed to fetch event:", error);
}
}
if (!event || event.message?.toLowerCase().includes("not found")) {
@@ -0,0 +1,227 @@
"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",
"coupon_created",
"coupon_updated",
"coupon_disabled",
"coupon_applied_manual",
] 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",
coupon_created: "Coupon created",
coupon_updated: "Coupon edited",
coupon_disabled: "Coupon turned off",
coupon_applied_manual: "Coupon applied/removed by staff",
};
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>
<button className="text-xs text-brand-600 hover:underline" onClick={() => router.push("/dashboard/admin")}>← Back to dashboard</button>
<h1 className="text-2xl font-semibold text-gray-900 mt-0.5">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>
);
}
+3 -1
View File
@@ -8,7 +8,7 @@ import { useStableState } from "@/hooks/useStableState";
import { useVisiblePolling } from "@/hooks/useVisiblePolling";
import {
Calendar, Banknote, Gift, Users, Ticket, QrCode, ClipboardList,
UserPlus, FileText, MessageCircle, BarChart2, Mail, Wallet, DoorOpen,
UserPlus, FileText, MessageCircle, BarChart2, Mail, Wallet, DoorOpen, History, TicketPercent,
} from "lucide-react";
import { StatCard, StatCardRow } from "@/components/shared/StatCard";
import { QuickActionTile, QuickActionGrid } from "@/components/shared/QuickActionTile";
@@ -27,12 +27,14 @@ const QUICK_ACTIONS = [
{ href: "/dashboard/supervisor/manual", label: "Manual registration", description: "Register a guest and issue tickets", icon: UserPlus },
{ href: "/dashboard/supervisor/at-the-door", label: "At the door", description: "Walk-ins, payments, ticket printing", icon: DoorOpen },
{ href: "/dashboard/supervisor/payments", label: "Payments & donations", description: "Manual payments and assignment", icon: Wallet },
{ href: "/dashboard/supervisor/coupons", label: "Coupon codes", description: "Discounts for staff, leaders and promotions", icon: TicketPercent },
{ href: "/dashboard/staff/ticket-scanning", label: "Open scanner", description: "Use your device camera to validate tickets", icon: QrCode },
{ href: "/dashboard/supervisor/reports", label: "Reports", description: "View, export, and email reports", icon: BarChart2 },
{ href: "/dashboard/admin/forms", label: "Attendee forms", description: "View submitted attendee forms", icon: FileText },
{ href: "/dashboard/supervisor/email-attendees", label: "Email attendees", description: "Send a message to attendees of an event", icon: Mail },
{ href: "/dashboard/supervisor/whatsapp-attendees", label: "WhatsApp attendees", description: "Send a WhatsApp message to event attendees", icon: MessageCircle },
{ href: "/dashboard/admin/cashup", label: "Post-event Cashup", description: "Set costs, reconcile takings, and close out events", icon: Wallet },
{ href: "/dashboard/admin/audit-log", label: "Audit log", description: "Review refunds, manual registrations, event and settings changes", icon: History },
] as const;
type OverviewMetric = { thisMonth: number; lastMonth: number; pctChange: number | null };
@@ -6,13 +6,14 @@ import { useRouter, useSearchParams } from "next/navigation";
import { apiFetch, API_BASE, resolveToApiOrigin } from "@/lib/api";
import { useSiteSettings } from "@/contexts/SiteSettingsContext";
import { useDismissingState } from "@/hooks/useDismissingState";
import { Building2, Palette, Bell, Mail, Scale, MessageCircle, type LucideIcon } from "lucide-react";
import { Building2, Palette, Bell, Mail, Scale, MessageCircle, DatabaseBackup, type LucideIcon } from "lucide-react";
import { ColorPickerField } from "@/components/admin/ColorPickerField";
import { BrandingPreviewPanel } from "@/components/admin/BrandingPreviewPanel";
import { BackupsTab } from "@/components/admin/BackupsTab";
import { extractDominantColors } from "@/lib/extractColors";
import { mapsSearchUrl } from "@/lib/maps";
type TabId = "organisation" | "branding" | "notifications" | "email" | "legal" | "whatsapp";
type TabId = "organisation" | "branding" | "notifications" | "email" | "legal" | "whatsapp" | "backups";
const TABS: { id: TabId; label: string; icon: LucideIcon }[] = [
{ id: "organisation", label: "Organisation", icon: Building2 },
@@ -21,6 +22,7 @@ const TABS: { id: TabId; label: string; icon: LucideIcon }[] = [
{ id: "email", label: "Email", icon: Mail },
{ id: "legal", label: "Legal", icon: Scale },
{ id: "whatsapp", label: "WhatsApp", icon: MessageCircle },
{ id: "backups", label: "Backups", icon: DatabaseBackup },
];
const inputCls =
@@ -662,6 +664,9 @@ function SiteSettingsPageInner() {
{/* ── WhatsApp ──────────────────────────────────────────────────────── */}
{activeTab === "whatsapp" && <WhatsAppTab active={activeTab === "whatsapp"} />}
{/* ── Backups ───────────────────────────────────────────────────────── */}
{activeTab === "backups" && <BackupsTab active={activeTab === "backups"} />}
</div>
{activeTab === "branding" && (
@@ -15,6 +15,7 @@ interface UserItem {
role: string;
phoneNumber?: string | null;
notificationPreference?: string;
twoFactorEnabled?: boolean;
isActive: boolean;
createdAt: string;
updatedAt: string;
@@ -186,6 +187,21 @@ export default function AdminUsersPage() {
}
};
const resetTwoFactor = async (id: string, name: string, role: string) => {
// Admins and supervisors can't go without 2FA: a reset just makes them set it up
// again (on a channel that works) at their next login.
const message = role === "admin" || role === "supervisor"
? `Reset two-factor authentication for ${name}? Their sessions end, and they'll set it up again with a new code at their next login.`
: `Turn off two-factor authentication for ${name}? They will be able to log in with just their password until they turn it back on.`;
if (!confirm(message)) return;
try {
await apiFetch(`/api/users/${encodeURIComponent(id)}`, { method: "PUT", authToken: token!, body: { twoFactorEnabled: false } });
await loadUsers(page);
} catch (e: any) {
setError(e?.message || "Failed to reset two-factor authentication");
}
};
const deactivate = async (id: string) => {
if (!confirm("Deactivate this user? Their account will be disabled.")) return;
try {
@@ -352,6 +368,9 @@ export default function AdminUsersPage() {
<div className="flex gap-1 flex-wrap">
<button className="px-2 py-1 text-xs rounded bg-gray-100 hover:bg-gray-200" onClick={() => startEdit(u)}>Edit</button>
<button className="px-2 py-1 text-xs rounded bg-amber-500 text-white hover:bg-amber-600" onClick={() => revokeUserSessions(u.id, u.name)} title="Force this user to sign in again on every device where they're currently logged in">Sign out everywhere</button>
{u.twoFactorEnabled && (
<button className="px-2 py-1 text-xs rounded bg-amber-500 text-white hover:bg-amber-600" onClick={() => resetTwoFactor(u.id, u.name, u.role)} title={u.role === "admin" || u.role === "supervisor" ? "Reset two-factor authentication — they set it up again at their next login" : "Turn off two-factor authentication for a user who can no longer receive their codes"}>Reset 2FA</button>
)}
<button className="px-2 py-1 text-xs rounded bg-orange-500 text-white hover:bg-orange-600" onClick={() => deactivate(u.id)} title="Deactivate account">Deactivate</button>
<button className="px-2 py-1 text-xs rounded bg-red-700 text-white hover:bg-red-800" onClick={() => deleteUserData(u.id, u.name)} title="Erase personal data">Delete data</button>
</div>
@@ -6,6 +6,7 @@ import { useRouter } from "next/navigation";
import { apiFetch } from "@/lib/api";
import { scoreUser } from "@/lib/fuzzyMatch";
import { useDismissingState } from "@/hooks/useDismissingState";
import { CouponCodeField, AppliedCoupon } from "@/components/shared/CouponCodeField";
import { DoorOpen, UserPlus, CreditCard, CheckSquare, Ticket, RotateCcw, type LucideIcon } from "lucide-react";
type Mode = "registration" | "payment" | "checkin" | "tickets" | "refund";
@@ -100,6 +101,7 @@ export default function AtTheDoorPage() {
const [eventOptions, setEventOptions] = useState<any[]>([]);
const [showOptionsModal, setShowOptionsModal] = useState(false);
const [pendingUser, setPendingUser] = useState<any | null>(null);
const [doorCoupon, setDoorCoupon] = useState<AppliedCoupon | null>(null);
const [pendingEditReg, setPendingEditReg] = useState<any | null>(null);
const [confirming, setConfirming] = useState(false);
const [quantities, setQuantities] = useState<Record<string, number>>({});
@@ -226,14 +228,28 @@ export default function AtTheDoorPage() {
try {
if (pendingEditReg) {
// Edit existing registration — replace options via PUT
const res = await apiFetch(`/api/registrations/${pendingEditReg.id}/options`, {
let res = await apiFetch(`/api/registrations/${pendingEditReg.id}/options`, {
method: "PUT",
authToken: token,
body: { options: opts }
});
let message = "Registration updated";
if (doorCoupon) {
// Applied after the items are saved, so it's worked out on the final items.
try {
res = await apiFetch(`/api/registrations/${pendingEditReg.id}/coupon`, {
method: "POST",
authToken: token,
body: { code: doorCoupon.code },
});
message = `Registration updated — code ${doorCoupon.code} applied`;
} catch (ce: any) {
message = `Registration updated, but the code wasn't applied: ${ce?.message || "unknown error"}`;
}
}
setShowOptionsModal(false);
setPendingEditReg(null);
setInfo("Registration updated");
setInfo(message);
handleRegistrationSelected(res);
} else if (pendingUser) {
// New attendee
@@ -242,7 +258,6 @@ export default function AtTheDoorPage() {
authToken: token,
body: {
eventId,
guestOnly: pendingUser.guestOnly,
user: {
name: pendingUser.name,
...(pendingUser.email ? { email: pendingUser.email } : {}),
@@ -250,9 +265,11 @@ export default function AtTheDoorPage() {
},
options: opts,
notificationPreference: pendingUser.notifPref,
...(doorCoupon ? { couponCode: doorCoupon.code } : {}),
}
});
setShowOptionsModal(false);
if (res?.couponError) setError(`Registered, but the code wasn't applied: ${res.couponError}`);
handleRegistrationCreated(res);
}
} catch (e: any) {
@@ -282,8 +299,9 @@ export default function AtTheDoorPage() {
});
setQuantities(qtyMap);
setMinQuantities({});
setPendingUser({ guestOnly: true, name, email: email || null, phone: phone || null, notifPref });
setPendingUser({ name, email: email || null, phone: phone || null, notifPref });
setPendingEditReg(null);
setDoorCoupon(null);
setShowNewAttendeeModal(false);
setShowOptionsModal(true);
};
@@ -320,6 +338,7 @@ export default function AtTheDoorPage() {
setMinQuantities(minQtyMap);
setPendingEditReg(freshReg);
setPendingUser(null);
setDoorCoupon(null);
setShowOptionsModal(true);
};
@@ -464,6 +483,12 @@ export default function AtTheDoorPage() {
confirming={confirming}
isEdit={!!pendingEditReg}
totalPaid={(pendingEditReg?.payments || []).reduce((s: number, p: any) => s + (p.amount || 0), 0)}
eventId={eventId}
token={token}
coupon={doorCoupon}
setCoupon={setDoorCoupon}
couponForUserId={pendingEditReg ? pendingEditReg.userId : null}
existingCode={(pendingEditReg?.couponRedemptions || [])[0]?.coupon?.code || null}
/>
<DonationModal
open={showDonationModal}
@@ -1148,9 +1173,16 @@ function DoorCheckInPanel({ token, eventId, registration, setError, setInfo }: a
);
}
function OptionsModal({ open, onClose, options, quantities, setQuantities, minQuantities = {}, onConfirm, confirming, isEdit, totalPaid = 0 }: any) {
function OptionsModal({ open, onClose, options, quantities, setQuantities, minQuantities = {}, onConfirm, confirming, isEdit, totalPaid = 0, eventId, token, coupon, setCoupon, couponForUserId, existingCode }: any) {
if (!open) return null;
const basket = Object.entries(quantities as Record<string, number>)
.filter(([, qty]) => qty > 0)
.map(([key, quantity]) => {
const [eventOptionId, variantId] = key.split("::");
return { eventOptionId, quantity, variantId: variantId || null };
});
const safeOptions: any[] = options || [];
const total = safeOptions.reduce((sum: number, o: any) => {
@@ -1255,8 +1287,21 @@ function OptionsModal({ open, onClose, options, quantities, setQuantities, minQu
One or more items are below the quantity already issued as tickets. Please increase the selection.
</div>
)}
{existingCode ? (
<div className="mb-3 text-sm text-green-700">Code <span className="font-mono font-semibold">{existingCode}</span> is applied to this registration.</div>
) : (
<div className="mb-3">
<CouponCodeField eventId={eventId} basket={basket} token={token} forUserId={couponForUserId} applied={coupon} onChange={setCoupon} />
</div>
)}
<div className="flex justify-between items-center">
<div className="text-lg font-semibold">Total: R {total.toFixed(2)}</div>
<div>
{coupon && coupon.discount > 0 && !isEdit && (
<div className="text-xs text-gray-500"><span className="line-through">R {coupon.subtotal.toFixed(2)}</span> <span className="text-green-700">−R {coupon.discount.toFixed(2)}</span></div>
)}
<div className="text-lg font-semibold">Total: R {(coupon && !isEdit ? coupon.total : total).toFixed(2)}</div>
{coupon && isEdit && <div className="text-xs text-green-700">Code {coupon.code} is applied when you save</div>}
</div>
<div className="flex gap-2">
<button onClick={onClose} className="px-4 py-2 border rounded" disabled={confirming}>
Cancel
@@ -0,0 +1,596 @@
"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 { TicketPercent, Plus, X, Search } from "lucide-react";
import { Table, TableHeader, TableBody, TableRow, TableHead, TableCell } from "@/components/ui/table";
type CouponType = "percent" | "fixed" | "comp";
type Role = "user" | "staff" | "supervisor" | "admin";
type EventLite = {
id: string;
title: string;
startDate: string;
eventOptions: { id: string; name: string; variants?: { id: string; name: string }[] }[];
};
type Coupon = {
id: string;
code: string;
description: string | null;
type: CouponType;
value: number;
stackEarlyBird: boolean;
validFrom: string | null;
validUntil: string | null;
maxUses: number | null;
maxUsesPerUser: number | null;
minQuantity: number | null;
restrictRoles: Role[];
restrictDomain: string | null;
active: boolean;
summary: string;
uses: number;
totalDiscount: number;
events: { eventId: string; event: { id: string; title: string } }[];
options: { eventOptionId: string; variantId: string | null; eventOption: { name: string; eventId: string }; variant: { name: string } | null }[];
allowedUsers: { userId: string; user: { id: string; name: string; email: string } }[];
};
type Redemption = {
id: string;
discountAmount: number;
createdAt: string;
releasedAt: string | null;
user: { name: string; email: string };
appliedBy: { name: string } | null;
registration: { status: string; event: { title: string } };
};
type Draft = {
code: string;
description: string;
type: CouponType;
value: string;
stackEarlyBird: boolean;
validFrom: string;
validUntil: string;
maxUses: string;
maxUsesPerUser: string;
minQuantity: string;
eventIds: string[];
options: { eventOptionId: string; variantId: string | null }[];
restrictTo: "anyone" | "roles" | "users" | "domain";
restrictRoles: Role[];
restrictDomain: string;
users: { id: string; name: string; email: string }[];
active: boolean;
};
const blankDraft = (): Draft => ({
code: "", description: "", type: "percent", value: "", stackEarlyBird: false,
validFrom: "", validUntil: "", maxUses: "", maxUsesPerUser: "1", minQuantity: "",
eventIds: [], options: [], restrictTo: "anyone", restrictRoles: ["staff", "supervisor", "admin"],
restrictDomain: "", users: [], active: true,
});
// <input type="datetime-local"> wants local "YYYY-MM-DDTHH:mm".
const toLocalInput = (iso: string | null) => {
if (!iso) return "";
const d = new Date(iso);
const pad = (n: number) => String(n).padStart(2, "0");
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
};
function draftFromCoupon(c: Coupon): Draft {
const restrictTo: Draft["restrictTo"] = c.allowedUsers.length ? "users" : c.restrictRoles.length ? "roles" : c.restrictDomain ? "domain" : "anyone";
return {
code: c.code, description: c.description || "", type: c.type, value: c.type === "comp" ? "" : String(c.value),
stackEarlyBird: c.stackEarlyBird, validFrom: toLocalInput(c.validFrom), validUntil: toLocalInput(c.validUntil),
maxUses: c.maxUses ? String(c.maxUses) : "", maxUsesPerUser: c.maxUsesPerUser ? String(c.maxUsesPerUser) : "",
minQuantity: c.minQuantity ? String(c.minQuantity) : "", eventIds: c.events.map(e => e.eventId),
options: c.options.map(o => ({ eventOptionId: o.eventOptionId, variantId: o.variantId })),
restrictTo, restrictRoles: c.restrictRoles.length ? c.restrictRoles : ["staff", "supervisor", "admin"],
restrictDomain: c.restrictDomain || "", users: c.allowedUsers.map(u => u.user), active: c.active,
};
}
function bodyFromDraft(d: Draft) {
return {
code: d.code.trim(),
description: d.description.trim() || null,
type: d.type,
value: d.type === "comp" ? 0 : Number(d.value),
stackEarlyBird: d.stackEarlyBird,
validFrom: d.validFrom ? new Date(d.validFrom).toISOString() : null,
validUntil: d.validUntil ? new Date(d.validUntil).toISOString() : null,
maxUses: d.maxUses || null,
maxUsesPerUser: d.maxUsesPerUser || null,
minQuantity: d.minQuantity || null,
eventIds: d.eventIds,
options: d.options,
restrictRoles: d.restrictTo === "roles" ? d.restrictRoles : [],
restrictDomain: d.restrictTo === "domain" ? d.restrictDomain.trim() : null,
userIds: d.restrictTo === "users" ? d.users.map(u => u.id) : [],
active: d.active,
};
}
const fmtDate = (iso: string | null) => (iso ? new Date(iso).toLocaleDateString(undefined, { day: "numeric", month: "short", year: "numeric" }) : "");
const fmtRand = (n: number) => `R ${(n || 0).toFixed(2)}`;
const inputCls = "w-full border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-brand-400";
function couponStatus(c: Coupon): { label: string; cls: string } {
const now = Date.now();
if (!c.active) return { label: "Off", cls: "bg-gray-100 text-gray-600" };
if (c.validUntil && new Date(c.validUntil).getTime() < now) return { label: "Expired", cls: "bg-gray-100 text-gray-600" };
if (c.validFrom && new Date(c.validFrom).getTime() > now) return { label: "Scheduled", cls: "bg-amber-100 text-amber-800" };
if (c.maxUses && c.uses >= c.maxUses) return { label: "Used up", cls: "bg-gray-100 text-gray-600" };
return { label: "Active", cls: "bg-green-100 text-green-800" };
}
export default function CouponsPage() {
const { user, loading, token } = useAuth();
const router = useRouter();
const canView = useMemo(() => user?.role === "admin" || user?.role === "supervisor", [user]);
useEffect(() => {
if (loading) return;
if (!user) router.replace("/login");
}, [user, loading, router]);
const [coupons, setCoupons] = useState<Coupon[]>([]);
const [events, setEvents] = useState<EventLite[]>([]);
const [fetching, setFetching] = useState(false);
const [error, setError] = useDismissingState<string | null>(null);
const [info, setInfo] = useDismissingState<string | null>(null);
const [query, setQuery] = useState("");
const [showOff, setShowOff] = useState(false);
const [editing, setEditing] = useState<{ id: string | null; draft: Draft } | null>(null);
const [viewing, setViewing] = useState<{ coupon: Coupon; redemptions: Redemption[] } | null>(null);
const load = useCallback(async () => {
if (!token) return;
setFetching(true);
try {
const [c, ev] = await Promise.all([
apiFetch<Coupon[]>("/api/coupons", { authToken: token }),
apiFetch<EventLite[]>("/api/events/all?includePast=true", { authToken: token }),
]);
setCoupons(c || []);
setEvents(ev || []);
} catch (e: any) {
setError(e?.message || "Failed to load coupons");
} finally {
setFetching(false);
}
}, [token, setError]);
useEffect(() => { if (token && canView) load(); }, [token, canView, load]);
const visible = useMemo(() => {
const q = query.trim().toLowerCase();
return coupons.filter(c => (showOff || c.active) && (!q || c.code.toLowerCase().includes(q) || (c.description || "").toLowerCase().includes(q)));
}, [coupons, query, showOff]);
const toggleActive = async (c: Coupon) => {
try {
await apiFetch(`/api/coupons/${c.id}`, { method: "PUT", authToken: token!, body: { active: !c.active } });
setInfo(`${c.code} turned ${c.active ? "off" : "on"}.`);
load();
} catch (e: any) {
setError(e?.message || "Failed to update coupon");
}
};
const openUses = async (c: Coupon) => {
try {
const full = await apiFetch<Coupon & { redemptions: Redemption[] }>(`/api/coupons/${c.id}`, { authToken: token! });
setViewing({ coupon: c, redemptions: full.redemptions || [] });
} catch (e: any) {
setError(e?.message || "Failed to load uses");
}
};
return (
<div className="max-w-6xl mx-auto w-full p-6">
<div className="flex items-center justify-between gap-3 mb-4 flex-wrap">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-brand-50 flex items-center justify-center shrink-0">
<TicketPercent className="w-5 h-5 text-brand-600" />
</div>
<div>
<button className="text-xs text-brand-600 hover:underline" onClick={() => router.push("/dashboard")}>← Back to dashboard</button>
<h1 className="text-2xl font-semibold text-gray-900 mt-0.5">Coupon codes</h1>
<p className="text-sm text-gray-500">Discounts for staff, leaders, volunteers and promotions</p>
</div>
</div>
{canView && (
<button
onClick={() => setEditing({ id: null, draft: blankDraft() })}
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-brand-600 text-white text-sm font-medium hover:bg-brand-700"
>
<Plus className="w-4 h-4" /> New code
</button>
)}
</div>
{!canView && (
<div className="p-3 border rounded bg-yellow-50 text-yellow-800 text-sm mb-4">You need supervisor access to manage coupon codes.</div>
)}
{error && <div className="mb-3 p-3 border rounded bg-red-50 text-red-800 text-sm">{error}</div>}
{info && <div className="mb-3 p-3 border rounded bg-green-50 text-green-800 text-sm">{info}</div>}
{canView && (
<div className="border rounded-xl p-4 bg-white shadow-sm">
<div className="flex flex-wrap items-center gap-3 mb-3">
<div className="relative w-full sm:w-64">
<Search className="w-4 h-4 text-gray-400 absolute left-2.5 top-2.5" />
<input className={`${inputCls} pl-8`} placeholder="Search codes" value={query} onChange={e => setQuery(e.target.value)} />
</div>
<label className="flex items-center gap-2 text-sm text-gray-700">
<input type="checkbox" checked={showOff} onChange={e => setShowOff(e.target.checked)} /> Show codes that are off
</label>
</div>
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead>Code</TableHead>
<TableHead>Discount</TableHead>
<TableHead>Events</TableHead>
<TableHead>Uses</TableHead>
<TableHead>Valid</TableHead>
<TableHead>Status</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{visible.map(c => {
const status = couponStatus(c);
return (
<TableRow key={c.id}>
<TableCell>
<div className="font-mono font-semibold text-gray-900">{c.code}</div>
{c.description && <div className="text-xs text-gray-500">{c.description}</div>}
{(c.restrictRoles.length > 0 || c.allowedUsers.length > 0 || c.restrictDomain) && (
<div className="text-xs text-amber-700 mt-0.5">
Restricted: {c.allowedUsers.length ? `${c.allowedUsers.length} person${c.allowedUsers.length === 1 ? "" : "s"}` : c.restrictRoles.length ? c.restrictRoles.join(", ") : `@${c.restrictDomain}`}
</div>
)}
</TableCell>
<TableCell>
<div>{c.summary}</div>
{c.options.length > 0 && <div className="text-xs text-gray-500">{c.options.map(o => o.eventOption.name + (o.variant ? ` (${o.variant.name})` : "")).join(", ")}</div>}
</TableCell>
<TableCell className="text-sm">{c.events.length ? c.events.map(e => e.event.title).join(", ") : <span className="text-gray-500">All events</span>}</TableCell>
<TableCell className="text-sm">
{c.uses}{c.maxUses ? ` / ${c.maxUses}` : ""}
<div className="text-xs text-gray-500">{fmtRand(c.totalDiscount)} given</div>
</TableCell>
<TableCell className="text-xs text-gray-600">
{c.validFrom || c.validUntil ? `${c.validFrom ? fmtDate(c.validFrom) : "Now"} – ${c.validUntil ? fmtDate(c.validUntil) : "no end"}` : "Always"}
</TableCell>
<TableCell><span className={`text-xs px-2 py-0.5 rounded-full font-medium ${status.cls}`}>{status.label}</span></TableCell>
<TableCell className="text-right whitespace-nowrap">
<button className="px-2 py-1 text-xs rounded border hover:bg-gray-50 mr-1" onClick={() => openUses(c)}>Uses</button>
<button className="px-2 py-1 text-xs rounded border hover:bg-gray-50 mr-1" onClick={() => setEditing({ id: c.id, draft: draftFromCoupon(c) })}>Edit</button>
<button className={`px-2 py-1 text-xs rounded text-white ${c.active ? "bg-amber-500 hover:bg-amber-600" : "bg-green-600 hover:bg-green-700"}`} onClick={() => toggleActive(c)}>
{c.active ? "Turn off" : "Turn on"}
</button>
</TableCell>
</TableRow>
);
})}
{!fetching && visible.length === 0 && (
<TableRow><TableCell colSpan={7} className="text-center text-sm text-gray-500 py-8">No coupon codes yet.</TableCell></TableRow>
)}
</TableBody>
</Table>
</div>
</div>
)}
{editing && (
<CouponModal
token={token!}
events={events}
couponId={editing.id}
initial={editing.draft}
onClose={() => setEditing(null)}
onSaved={(msg) => { setEditing(null); setInfo(msg); load(); }}
/>
)}
{viewing && (
<div className="fixed inset-0 z-50 bg-black/40 flex items-center justify-center p-4" onClick={() => setViewing(null)}>
<div className="bg-white rounded-xl shadow-xl w-full max-w-2xl max-h-[85vh] flex flex-col" onClick={e => e.stopPropagation()}>
<div className="flex items-center justify-between p-4 border-b">
<h2 className="font-semibold">Uses of <span className="font-mono">{viewing.coupon.code}</span></h2>
<button onClick={() => setViewing(null)} aria-label="Close"><X className="w-5 h-5 text-gray-500" /></button>
</div>
<div className="p-4 overflow-y-auto">
{viewing.redemptions.length === 0 ? (
<p className="text-sm text-gray-500">This code hasn&apos;t been used yet.</p>
) : (
<Table>
<TableHeader>
<TableRow><TableHead>Person</TableHead><TableHead>Event</TableHead><TableHead>Discount</TableHead><TableHead>When</TableHead></TableRow>
</TableHeader>
<TableBody>
{viewing.redemptions.map(r => (
<TableRow key={r.id} className={r.releasedAt ? "opacity-60" : ""}>
<TableCell>
<div className="text-sm">{r.user.name}</div>
<div className="text-xs text-gray-500">{r.user.email}</div>
{r.appliedBy && <div className="text-xs text-gray-500">Applied by {r.appliedBy.name}</div>}
</TableCell>
<TableCell className="text-sm">{r.registration.event.title}</TableCell>
<TableCell className="text-sm">
{fmtRand(r.discountAmount)}
{r.releasedAt && <div className="text-xs text-gray-500">Released (cancelled)</div>}
</TableCell>
<TableCell className="text-xs text-gray-600">{fmtDate(r.createdAt)}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</div>
</div>
</div>
)}
</div>
);
}
function CouponModal({ token, events, couponId, initial, onClose, onSaved }: {
token: string;
events: EventLite[];
couponId: string | null;
initial: Draft;
onClose: () => void;
onSaved: (message: string) => void;
}) {
const [d, setD] = useState<Draft>(initial);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const [userQuery, setUserQuery] = useState("");
const [userResults, setUserResults] = useState<{ id: string; name: string; email: string }[]>([]);
const set = <K extends keyof Draft>(key: K, value: Draft[K]) => setD(prev => ({ ...prev, [key]: value }));
// Ticket options only make sense within the chosen events.
const scopedEvents = useMemo(() => events.filter(e => d.eventIds.includes(e.id)), [events, d.eventIds]);
useEffect(() => {
if (d.restrictTo !== "users" || userQuery.trim().length < 2) { setUserResults([]); return; }
const t = setTimeout(async () => {
try {
const res = await apiFetch<{ data: { id: string; name: string; email: string }[] }>(`/api/users?search=${encodeURIComponent(userQuery.trim())}&limit=8`, { authToken: token });
setUserResults(res?.data || []);
} catch { setUserResults([]); }
}, 300);
return () => clearTimeout(t);
}, [userQuery, d.restrictTo, token]);
const toggleEvent = (id: string) => {
const eventIds = d.eventIds.includes(id) ? d.eventIds.filter(x => x !== id) : [...d.eventIds, id];
// Drop scoped options from events that are no longer selected.
const keepOptionIds = new Set(events.filter(e => eventIds.includes(e.id)).flatMap(e => e.eventOptions.map(o => o.id)));
setD(prev => ({ ...prev, eventIds, options: prev.options.filter(o => keepOptionIds.has(o.eventOptionId)) }));
};
const scopeKey = (o: { eventOptionId: string; variantId: string | null }) => `${o.eventOptionId}::${o.variantId || ""}`;
const toggleScope = (eventOptionId: string, variantId: string | null) => {
const key = scopeKey({ eventOptionId, variantId });
set("options", d.options.some(o => scopeKey(o) === key) ? d.options.filter(o => scopeKey(o) !== key) : [...d.options, { eventOptionId, variantId }]);
};
const save = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
if (d.restrictTo === "users" && d.users.length === 0) { setError("Add at least one person, or choose another restriction."); return; }
setSaving(true);
try {
const body = bodyFromDraft(d);
if (couponId) {
await apiFetch(`/api/coupons/${couponId}`, { method: "PUT", authToken: token, body });
onSaved(`${body.code.toUpperCase()} saved.`);
} else {
await apiFetch(`/api/coupons`, { method: "POST", authToken: token, body });
onSaved(`${body.code.toUpperCase()} created.`);
}
} catch (err: any) {
setError(err?.message || "Failed to save");
} finally {
setSaving(false);
}
};
return (
<div className="fixed inset-0 z-50 bg-black/40 flex items-center justify-center p-4" onClick={onClose}>
<form onSubmit={save} className="bg-white rounded-xl shadow-xl w-full max-w-2xl max-h-[90vh] flex flex-col" onClick={e => e.stopPropagation()}>
<div className="flex items-center justify-between p-4 border-b">
<h2 className="font-semibold">{couponId ? "Edit code" : "New coupon code"}</h2>
<button type="button" onClick={onClose} aria-label="Close"><X className="w-5 h-5 text-gray-500" /></button>
</div>
<div className="p-4 space-y-5 overflow-y-auto">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Code</label>
<input className={`${inputCls} font-mono uppercase`} value={d.code} onChange={e => set("code", e.target.value.toUpperCase().replace(/[^A-Z0-9_-]/g, ""))} placeholder="STAFF50" required maxLength={32} />
<p className="text-xs text-gray-500 mt-1">Letters, numbers, - and _. Not case-sensitive when entered.</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Description (internal)</label>
<input className={inputCls} value={d.description} onChange={e => set("description", e.target.value)} placeholder="Staff discount for Camp 2026" />
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Discount type</label>
<select className={inputCls} value={d.type} onChange={e => set("type", e.target.value as CouponType)}>
<option value="percent">Percentage</option>
<option value="fixed">Fixed amount (R)</option>
<option value="comp">Free (100%)</option>
</select>
</div>
{d.type !== "comp" && (
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">{d.type === "percent" ? "Percent off" : "Amount off (R)"}</label>
<input type="number" min="0" step={d.type === "percent" ? "1" : "0.01"} max={d.type === "percent" ? 100 : undefined} className={inputCls} value={d.value} onChange={e => set("value", e.target.value)} required />
</div>
)}
<div className="flex items-end">
<label className="flex items-start gap-2 text-sm text-gray-700">
<input type="checkbox" className="mt-0.5" checked={d.stackEarlyBird} onChange={e => set("stackEarlyBird", e.target.checked)} />
<span>Also apply on top of early-bird prices <span className="block text-xs text-gray-500">Off: the better of the two prices wins.</span></span>
</label>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Events</label>
<p className="text-xs text-gray-500 mb-2">Leave all unticked for every event.</p>
<div className="max-h-40 overflow-y-auto border rounded-lg divide-y">
{events.map(ev => (
<label key={ev.id} className="flex items-center gap-2 px-3 py-2 text-sm">
<input type="checkbox" checked={d.eventIds.includes(ev.id)} onChange={() => toggleEvent(ev.id)} />
<span>{ev.title}</span>
<span className="text-xs text-gray-500 ml-auto">{fmtDate(ev.startDate)}</span>
</label>
))}
{events.length === 0 && <p className="px-3 py-2 text-sm text-gray-500">No events.</p>}
</div>
</div>
{scopedEvents.length > 0 && (
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Only these tickets / items</label>
<p className="text-xs text-gray-500 mb-2">Leave all unticked to discount the whole registration.</p>
<div className="space-y-2">
{scopedEvents.map(ev => (
<div key={ev.id} className="border rounded-lg p-3">
<div className="text-xs font-semibold text-gray-600 mb-1">{ev.title}</div>
{ev.eventOptions.map(o => (
<div key={o.id}>
<label className="flex items-center gap-2 text-sm">
<input type="checkbox" checked={d.options.some(s => scopeKey(s) === scopeKey({ eventOptionId: o.id, variantId: null }))} onChange={() => toggleScope(o.id, null)} />
{o.name}{(o.variants || []).length > 0 && <span className="text-xs text-gray-500">(all variants)</span>}
</label>
{(o.variants || []).map(v => (
<label key={v.id} className="flex items-center gap-2 text-sm ml-6">
<input type="checkbox" checked={d.options.some(s => scopeKey(s) === scopeKey({ eventOptionId: o.id, variantId: v.id }))} onChange={() => toggleScope(o.id, v.id)} />
{v.name}
</label>
))}
</div>
))}
</div>
))}
</div>
</div>
)}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Valid from</label>
<input type="datetime-local" className={inputCls} value={d.validFrom} onChange={e => set("validFrom", e.target.value)} />
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Valid until</label>
<input type="datetime-local" className={inputCls} value={d.validUntil} onChange={e => set("validUntil", e.target.value)} />
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Max total uses</label>
<input type="number" min="1" className={inputCls} value={d.maxUses} onChange={e => set("maxUses", e.target.value)} placeholder="Unlimited" />
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Max uses per person</label>
<input type="number" min="1" className={inputCls} value={d.maxUsesPerUser} onChange={e => set("maxUsesPerUser", e.target.value)} placeholder="Unlimited" />
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Minimum tickets</label>
<input type="number" min="1" className={inputCls} value={d.minQuantity} onChange={e => set("minQuantity", e.target.value)} placeholder="None" />
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Who can use it</label>
<select className={inputCls} value={d.restrictTo} onChange={e => set("restrictTo", e.target.value as Draft["restrictTo"])}>
<option value="anyone">Anyone with the code</option>
<option value="roles">People with certain roles</option>
<option value="users">Specific people</option>
<option value="domain">An email domain</option>
</select>
{d.restrictTo !== "anyone" && (
<p className="text-xs text-gray-500 mt-1">Restricted codes need the person to be logged in (or registered by staff), so they can&apos;t be shared in group chats.</p>
)}
{d.restrictTo === "roles" && (
<div className="flex flex-wrap gap-4 mt-2">
{(["user", "staff", "supervisor", "admin"] as Role[]).map(r => (
<label key={r} className="flex items-center gap-2 text-sm capitalize">
<input type="checkbox" checked={d.restrictRoles.includes(r)} onChange={() => set("restrictRoles", d.restrictRoles.includes(r) ? d.restrictRoles.filter(x => x !== r) : [...d.restrictRoles, r])} />
{r === "user" ? "Member" : r}
</label>
))}
</div>
)}
{d.restrictTo === "domain" && (
<input className={`${inputCls} mt-2`} value={d.restrictDomain} onChange={e => set("restrictDomain", e.target.value)} placeholder="church.org" required />
)}
{d.restrictTo === "users" && (
<div className="mt-2">
<div className="flex flex-wrap gap-2 mb-2">
{d.users.map(u => (
<span key={u.id} className="inline-flex items-center gap-1 text-xs bg-brand-50 text-brand-800 rounded-full px-2 py-1">
{u.name}
<button type="button" onClick={() => set("users", d.users.filter(x => x.id !== u.id))} aria-label={`Remove ${u.name}`}><X className="w-3 h-3" /></button>
</span>
))}
</div>
<input className={inputCls} value={userQuery} onChange={e => setUserQuery(e.target.value)} placeholder="Search by name, email or phone" />
{userResults.length > 0 && (
<div className="border rounded-lg mt-1 divide-y max-h-40 overflow-y-auto">
{userResults.filter(r => !d.users.some(u => u.id === r.id)).map(r => (
<button type="button" key={r.id} className="w-full text-left px-3 py-2 text-sm hover:bg-gray-50" onClick={() => { set("users", [...d.users, r]); setUserQuery(""); }}>
{r.name} <span className="text-xs text-gray-500">{r.email}</span>
</button>
))}
</div>
)}
</div>
)}
</div>
<label className="flex items-center gap-2 text-sm text-gray-700">
<input type="checkbox" checked={d.active} onChange={e => set("active", e.target.checked)} /> Active
</label>
{error && <div className="p-3 border rounded bg-red-50 text-red-800 text-sm">{error}</div>}
</div>
<div className="flex justify-end gap-3 p-4 border-t">
<button type="button" onClick={onClose} className="px-4 py-2 rounded-lg bg-gray-100 text-sm font-medium hover:bg-gray-200">Cancel</button>
<button type="submit" disabled={saving} className="px-4 py-2 rounded-lg bg-brand-600 text-white text-sm font-medium hover:bg-brand-700 disabled:opacity-50">
{saving ? "Saving…" : couponId ? "Save changes" : "Create code"}
</button>
</div>
</form>
</div>
);
}
@@ -50,6 +50,7 @@ type Variant = {
name: string;
price: string; // empty → use option base price
stockLimit: string;
staffPrice?: string; // empty → use the option's staff price
earlyBirdTiers: Tier[];
};
@@ -59,6 +60,7 @@ type OptionDraft = {
price: string;
isMainTicket: boolean;
stockLimit: string;
staffPrice?: string; // empty → no staff price
earlyBirdTiers: Tier[]; // option-level (used only when no variants)
variants: Variant[];
};
@@ -193,6 +195,11 @@ function OptionsEditor({ options, onChange, required }: { options: OptionDraft[]
<input type="number" step="1" min="0" className="border rounded px-2 py-1.5 text-sm w-24"
value={opt.stockLimit} onChange={e => upd(i, { stockLimit: e.target.value })} />
</div>
<div>
<label className="block text-[10px] text-gray-500 mb-0.5" title="Staff and above pay this automatically when it's lower than the price they'd otherwise pay">Staff price (R)</label>
<input type="number" step="1" min="0" className="border rounded px-2 py-1.5 text-sm w-24" placeholder="(none)"
value={opt.staffPrice ?? ""} onChange={e => upd(i, { staffPrice: e.target.value })} />
</div>
<div className="flex items-center gap-3 pb-1">
<label className="text-xs flex items-center gap-1 cursor-pointer whitespace-nowrap">
<input type="checkbox" checked={opt.isMainTicket} onChange={e => upd(i, { isMainTicket: e.target.checked })} /> Main ticket
@@ -253,6 +260,11 @@ function VariantsEditor({ options, onChange }: { options: OptionDraft[]; onChang
<input type="number" step="1" min="0" className="border rounded px-2 py-1 text-xs w-20"
value={v.stockLimit} onChange={e => updVariant(vi, { stockLimit: e.target.value })} />
</div>
<div>
<label className="block text-[10px] text-gray-500 mb-0.5">Staff price</label>
<input type="number" step="1" min="0" className="border rounded px-2 py-1 text-xs w-20" placeholder="(option)"
value={v.staffPrice ?? ""} onChange={e => updVariant(vi, { staffPrice: e.target.value })} />
</div>
<button type="button" className="text-xs px-2 py-1 rounded bg-red-50 text-red-700 border border-red-200 hover:bg-red-100 self-end" onClick={() => remVariant(vi)}>✕</button>
</div>
))}
@@ -815,6 +827,7 @@ function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) {
price: String(o.price ?? ""),
isMainTicket: !!o.isMainTicket,
stockLimit: String(o.stockLimit ?? "0"),
staffPrice: o.staffPrice !== null && o.staffPrice !== undefined ? String(o.staffPrice) : "",
// Option-level tiers only (no variantId)
earlyBirdTiers: allTiers
.filter((t: any) => !t.variantId)
@@ -823,6 +836,7 @@ function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) {
id: v.id, name: v.name,
price: v.price !== null && v.price !== undefined ? String(v.price) : "",
stockLimit: String(v.stockLimit ?? "0"),
staffPrice: v.staffPrice !== null && v.staffPrice !== undefined ? String(v.staffPrice) : "",
// Variant-level tiers — filter by variantId
earlyBirdTiers: allTiers
.filter((t: any) => t.variantId === v.id)
@@ -908,13 +922,14 @@ function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) {
id: v.id, name: v.name,
price: v.price !== "" ? parseFloat(v.price) : null,
stockLimit: parseInt(v.stockLimit || "0"), order: vi,
staffPrice: v.staffPrice ? parseFloat(v.staffPrice) : null,
}));
if (opt.id) {
// Update option + variants first to get/confirm variant IDs
const updated: any = await apiFetch(`/api/events/options/${opt.id}`, {
method: "PUT", authToken: token || undefined,
body: { name: opt.name, price: parseFloat(opt.price || "0"), isMainTicket: opt.isMainTicket, stockLimit: parseInt(opt.stockLimit || "0"), variants: variantsPayload }
body: { name: opt.name, price: parseFloat(opt.price || "0"), isMainTicket: opt.isMainTicket, stockLimit: parseInt(opt.stockLimit || "0"), staffPrice: opt.staffPrice ? parseFloat(opt.staffPrice) : null, variants: variantsPayload }
});
// Build variant tiers with confirmed IDs from response
@@ -946,7 +961,7 @@ function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) {
// New option added during edit
const created: any = await apiFetch(`/api/events/${eventId}/options`, {
method: "POST", authToken: token || undefined,
body: { name: opt.name, price: parseFloat(opt.price || "0"), isMainTicket: opt.isMainTicket, stockLimit: parseInt(opt.stockLimit || "0") }
body: { name: opt.name, price: parseFloat(opt.price || "0"), isMainTicket: opt.isMainTicket, stockLimit: parseInt(opt.stockLimit || "0"), staffPrice: opt.staffPrice ? parseFloat(opt.staffPrice) : null }
});
if (created?.id) {
const updated: any = await apiFetch(`/api/events/options/${created.id}`, {
@@ -984,6 +999,7 @@ function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) {
const variantsPayload = opt.variants.map((v, vi) => ({
name: v.name, price: v.price !== "" ? parseFloat(v.price) : null,
stockLimit: parseInt(v.stockLimit || "0"), order: vi,
staffPrice: v.staffPrice ? parseFloat(v.staffPrice) : null,
}));
let optId: string;
@@ -992,13 +1008,13 @@ function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) {
// Update the auto-created main ticket
await apiFetch(`/api/events/options/${existingMain.id}`, {
method: "PUT", authToken: token || undefined,
body: { name: opt.name, price: parseFloat(opt.price || "0"), isMainTicket: true, stockLimit: parseInt(opt.stockLimit || "0") }
body: { name: opt.name, price: parseFloat(opt.price || "0"), isMainTicket: true, stockLimit: parseInt(opt.stockLimit || "0"), staffPrice: opt.staffPrice ? parseFloat(opt.staffPrice) : null }
});
optId = existingMain.id;
} else {
const created: any = await apiFetch(`/api/events/${eventId}/options`, {
method: "POST", authToken: token || undefined,
body: { name: opt.name, price: parseFloat(opt.price || "0"), isMainTicket: opt.isMainTicket, stockLimit: parseInt(opt.stockLimit || "0") }
body: { name: opt.name, price: parseFloat(opt.price || "0"), isMainTicket: opt.isMainTicket, stockLimit: parseInt(opt.stockLimit || "0"), staffPrice: opt.staffPrice ? parseFloat(opt.staffPrice) : null }
});
optId = created?.id;
}
@@ -18,7 +18,6 @@ export default function ManualRegistrationPage() {
const [name, setName] = useState("");
const [email, setEmail] = useState("");
const [phoneNumber, setPhoneNumber] = useState("");
const [registerAsGuest, setRegisterAsGuest] = useState(false);
const [busy, setBusy] = useState(false);
const [error, setError] = useDismissingState<string | null>(null);
const [createdReg, setCreatedReg] = useState<any | null>(null);
@@ -43,7 +42,6 @@ export default function ManualRegistrationPage() {
eventId,
options: [{ eventOptionId: optionId, quantity }],
user: { name, ...(email ? { email } : {}), ...(phoneNumber ? { phoneNumber } : {}) },
guestOnly: registerAsGuest,
},
});
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 />
</div>
<div>
<div className="flex items-center justify-between">
<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>
<label className="block text-sm font-medium">Email</label>
<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>
@@ -100,7 +95,7 @@ export default function ManualRegistrationPage() {
<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…" />
</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>}
<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"}
@@ -6,6 +6,7 @@ import { useRouter } from "next/navigation";
import { apiFetch, fetchAllUsers } from "@/lib/api";
import { scoreUser } from "@/lib/fuzzyMatch";
import { useDismissingState } from "@/hooks/useDismissingState";
import { CouponCodeField, AppliedCoupon } from "@/components/shared/CouponCodeField";
import { UserPlus, CreditCard } from "lucide-react";
// ─── Pricing helpers ─────────────────────────────────────────────────────────
@@ -76,7 +77,6 @@ export default function ManualRegistrationPage() {
const [allUsers, setAllUsers] = useState<any[]>([]);
const [guest, setGuest] = useState({ name: "", email: "", phoneNumber: "" });
const [registerAsGuest, setRegisterAsGuest] = useState(false);
const [notifPref, setNotifPref] = useState<"email" | "whatsapp" | "both">("email");
const [quantities, setQuantities] = useState<Record<string, number>>({});
@@ -216,7 +216,12 @@ export default function ManualRegistrationPage() {
.map(x => x.u);
}, [userQuery, allUsers]);
// Discount code, checked against the picked account (or a walk-in when none is picked).
const [coupon, setCoupon] = useState<AppliedCoupon | null>(null);
const [pickedUserId, setPickedUserId] = useState<string | null>(null);
const selectUser = (u: any) => {
setPickedUserId(u.id || null);
setGuest({ name: u.name || "", email: u.email || "", phoneNumber: u.phoneNumber || "" });
setUserQuery(u.name || "");
setDropdownOpen(false);
@@ -287,7 +292,7 @@ export default function ManualRegistrationPage() {
setError(null);
setMessage(null);
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)
.filter(([, qty]) => qty > 0)
.map(([key, quantity]) => {
@@ -308,11 +313,13 @@ export default function ManualRegistrationPage() {
eventId: selectedEventId,
options: opts,
user: guest,
guestOnly: registerAsGuest,
notificationPreference: resolvedPref,
...(coupon ? { couponCode: coupon.code } : {}),
}
});
setMessage("Manual registration created successfully.");
setMessage(res?.couponError
? `Manual registration created, but the code wasn't applied: ${res.couponError}`
: coupon ? `Manual registration created with code ${coupon.code}.` : "Manual registration created successfully.");
// Pre-load the Record Payment tab with this registration so it's ready to go the
// moment a supervisor clicks over — the tab itself is never switched to automatically.
@@ -324,10 +331,11 @@ export default function ManualRegistrationPage() {
// Reset guest/ticket fields so the next registration starts from a clean slate
setGuest({ name: "", email: "", phoneNumber: "" });
setRegisterAsGuest(false);
setNotifPref("email");
setUserQuery("");
setDropdownOpen(false);
setCoupon(null);
setPickedUserId(null);
setQuantities(prev => Object.fromEntries(Object.keys(prev).map(k => [k, 0])));
} catch (e: any) {
setError(e?.message || "Failed to create manual registration");
@@ -442,13 +450,6 @@ export default function ManualRegistrationPage() {
)}
</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">
<input
className="border rounded px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-brand-400"
@@ -458,11 +459,10 @@ export default function ManualRegistrationPage() {
/>
<input
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"
value={guest.email}
onChange={e => setGuest({ ...guest, email: e.target.value })}
required={!registerAsGuest}
/>
<input
className="border rounded px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-brand-400"
@@ -515,7 +515,7 @@ export default function ManualRegistrationPage() {
{guest.name && (
<button
type="button"
onClick={() => { setGuest({ name: "", email: "", phoneNumber: "" }); setUserQuery(""); setNotifPref("email"); }}
onClick={() => { setGuest({ name: "", email: "", phoneNumber: "" }); setUserQuery(""); setNotifPref("email"); setPickedUserId(null); }}
className="text-xs text-gray-400 hover:text-gray-600 text-left"
>
✕ Clear
@@ -592,8 +592,27 @@ export default function ManualRegistrationPage() {
)}
</div>
{selectedEventId && totalDue > 0 && (
<div className="mt-6 max-w-md">
<CouponCodeField
eventId={selectedEventId}
basket={Object.entries(quantities).map(([key, quantity]) => {
const [eventOptionId, variantId] = key.split("::");
return { eventOptionId, quantity, variantId: variantId || null };
})}
token={token}
forUserId={pickedUserId}
applied={coupon}
onChange={setCoupon}
/>
</div>
)}
<div className="flex items-center justify-between mt-6">
<div className="text-sm">Total due: <span className="font-semibold">R {totalDue.toFixed(2)}</span></div>
<div className="text-sm">
Total due: <span className="font-semibold">R {(coupon ? coupon.total : totalDue).toFixed(2)}</span>
{coupon && coupon.discount > 0 && <span className="ml-2 text-xs text-gray-500 line-through">R {totalDue.toFixed(2)}</span>}
</div>
<button disabled={submitting} onClick={submit} className="px-4 py-2 text-sm rounded bg-brand-600 text-white hover:bg-brand-700 disabled:opacity-50 shadow-sm">{submitting ? 'Creating…' : 'Create registration'}</button>
</div>
</>
@@ -8,7 +8,7 @@ import { useStableState } from "@/hooks/useStableState";
import { useVisiblePolling } from "@/hooks/useVisiblePolling";
import {
Calendar, Banknote, Gift, Users, Ticket, QrCode, DoorOpen, Wallet,
UserPlus, FileText, MessageCircle, BarChart2, Mail,
UserPlus, FileText, MessageCircle, BarChart2, Mail, TicketPercent,
} from "lucide-react";
import { StatCard, StatCardRow } from "@/components/shared/StatCard";
import { QuickActionTile, QuickActionGrid } from "@/components/shared/QuickActionTile";
@@ -24,6 +24,7 @@ const QUICK_ACTIONS = [
{ href: "/dashboard/supervisor/manual", label: "Manual registration", description: "Register a guest and issue tickets", icon: UserPlus },
{ href: "/dashboard/supervisor/events", label: "Manage events", description: "Create, edit, and update ticket types", icon: Calendar },
{ href: "/dashboard/supervisor/payments", label: "Payments & donations", description: "Manual payments and assignment", icon: Wallet },
{ href: "/dashboard/supervisor/coupons", label: "Coupon codes", description: "Discounts for staff, leaders and promotions", icon: TicketPercent },
{ href: "/dashboard/staff/ticket-scanning", label: "Open scanner", description: "Use your device camera to validate tickets", icon: QrCode },
{ href: "/dashboard/supervisor/at-the-door", label: "At the door", description: "Walk-ins, payments, ticket printing", icon: DoorOpen },
{ href: "/dashboard/supervisor/reports", label: "Reports", description: "View, export, and email reports", icon: BarChart2 },
+60
View File
@@ -497,6 +497,41 @@ export default function UserDashboardPage() {
return false;
}, [activeReg, activeEventHasForm]);
// Discount code on an existing registration
const [codeInput, setCodeInput] = useState("");
const [codeBusy, setCodeBusy] = useState(false);
const [codeError, setCodeError] = useState<string | null>(null);
const applyCodeToActive = async () => {
if (!token || !activeRegId || !codeInput.trim()) return;
setCodeBusy(true);
setCodeError(null);
try {
const res = await apiFetch<{ message?: string }>(`/api/registrations/${encodeURIComponent(activeRegId)}/coupon`, {
method: 'POST',
authToken: token,
body: { code: codeInput.trim() },
});
const myRegs = await apiFetch<any[]>(`/api/registrations/myregistrations?showPast=${showPast ? '1' : '0'}`, { authToken: token });
setRegistrations(myRegs);
const updated = myRegs.find(r => r.id === activeRegId);
const pays = await apiFetch<any[]>(`/api/payments/registration/${encodeURIComponent(activeRegId)}`, { authToken: token });
const totalPaid = pays.reduce((s, p) => s + (p.amount || 0), 0);
const totalDue = updated?.totalDueComputed ?? 0;
setBilling(prev => ({ ...prev, [activeRegId]: { totalDue, totalPaid, outstanding: Math.max(0, totalDue - totalPaid), payments: pays } }));
try {
const myTicks = await apiFetch<any[]>("/api/tickets/mytickets", { authToken: token });
setTickets(myTicks);
} catch {}
setCodeInput("");
setInfo(res?.message || 'Code applied.');
} catch (e: any) {
setCodeError(e?.message || "Couldn't apply that code");
} finally {
setCodeBusy(false);
}
};
// Cancel registration state
const [cancelLoading, setCancelLoading] = useState(false);
const [showCancelConfirm, setShowCancelConfirm] = useState(false);
@@ -1034,10 +1069,35 @@ export default function UserDashboardPage() {
</ul>
</div>
);
const activeCoupon = (activeReg.couponRedemptions || [])[0];
return (
<div>
{renderGroup('Early bird', earlyBirdRows)}
{renderGroup('Standard price', standardRows)}
{Number(activeReg.discountTotal || 0) > 0 && (
<div className="text-sm text-green-700 mt-2">
Discount code{activeCoupon?.coupon?.code ? <> <span className="font-mono font-semibold">{activeCoupon.coupon.code}</span></> : null} — −{formatRand(Number(activeReg.discountTotal))}
</div>
)}
{canModifyActive && activeBill && activeBill.outstanding > 0 && !activeCoupon && (
<div className="mt-3">
<div className="flex gap-2">
<input
value={codeInput}
onChange={e => { setCodeInput(e.target.value.toUpperCase()); setCodeError(null); }}
placeholder="Have a discount code?"
className="flex-1 min-w-0 border rounded px-2 py-1 text-sm font-mono uppercase"
aria-label="Discount code"
/>
<button
onClick={applyCodeToActive}
disabled={codeBusy || !codeInput.trim()}
className="px-3 py-1 text-sm rounded border border-brand-600 text-brand-700 hover:bg-brand-50 disabled:opacity-50"
>{codeBusy ? 'Applying…' : 'Apply'}</button>
</div>
{codeError && <p className="text-xs text-red-600 mt-1">{codeError}</p>}
</div>
)}
</div>
);
})() : (
@@ -6,16 +6,21 @@ import { useRouter } from "next/navigation";
import { apiFetch } from "@/lib/api";
import { isValidZAPhone } from "@/lib/phone";
import { useDismissingState } from "@/hooks/useDismissingState";
import { User, Lock, ShieldAlert, Trash2, Clock, LogIn, KeyRound, RotateCcw } from "lucide-react";
import { TwoFactorChallenge, TwoFactorMethod } from "@/lib/auth";
import { User, Lock, ShieldAlert, ShieldCheck, ShieldOff, Trash2, Clock, LogIn, KeyRound, RotateCcw } from "lucide-react";
type ActivityEvent = { id: string; type: "login" | "password_changed" | "password_reset"; device: string | null; createdAt: string };
type ActivityEvent = { id: string; type: "login" | "password_changed" | "password_reset" | "two_factor_enabled" | "two_factor_disabled"; device: string | null; createdAt: string };
const ACTIVITY_META: Record<ActivityEvent["type"], { label: (device: string | null) => string; icon: typeof LogIn }> = {
login: { label: d => `Logged in${d ? ` from ${d}` : ""}`, icon: LogIn },
password_changed: { label: () => "Password changed", icon: KeyRound },
password_reset: { label: () => "Password reset via email link", icon: RotateCcw },
two_factor_enabled: { label: () => "Two-factor authentication turned on", icon: ShieldCheck },
two_factor_disabled: { label: () => "Two-factor authentication turned off", icon: ShieldOff },
};
const TWO_FACTOR_METHOD_LABEL: Record<TwoFactorMethod, string> = { email: "email", whatsapp: "WhatsApp" };
export default function UserProfilePage() {
const { user, token, logout, updateToken } = useAuth();
const router = useRouter();
@@ -152,6 +157,105 @@ export default function UserProfilePage() {
useEffect(() => { loadActivity(); }, [token]);
// ── Two-factor authentication ─────────────────────────────────────────────
// Mirrors the saved account state locally: the auth context's `user` is only
// loaded once per page load, so it wouldn't reflect a change made here.
const [tfEnabled, setTfEnabled] = useState(false);
const [tfMethod, setTfMethod] = useState<TwoFactorMethod>("email");
const [tfStep, setTfStep] = useState<"idle" | "code" | "disable" | "change">("idle");
const [tfPassword, setTfPassword] = useState("");
const [tfCode, setTfCode] = useState("");
const [tfChallenge, setTfChallenge] = useState<TwoFactorChallenge | null>(null);
const [tfMsg, setTfMsg] = useDismissingState<{ type: "ok" | "err"; text: string } | null>(null);
const [tfBusy, setTfBusy] = useState(false);
// WhatsApp codes need the number that's actually saved, not one still being typed.
const canWhatsAppCodes = isValidZAPhone(user?.phoneNumber || "");
// Admins and supervisors must keep 2FA on; they can only change the channel.
const tfRequired = !!user?.twoFactorRequired;
useEffect(() => {
if (user) {
setTfEnabled(!!user.twoFactorEnabled);
setTfMethod(user.twoFactorMethod || "email");
}
}, [user]);
const resetTwoFactorForm = () => {
setTfStep("idle");
setTfPassword("");
setTfCode("");
setTfChallenge(null);
};
const startEnableTwoFactor = async (e: React.FormEvent) => {
e.preventDefault();
if (!token) return;
setTfBusy(true);
setTfMsg(null);
try {
const res = await apiFetch<TwoFactorChallenge>("/api/users/2fa/enable", {
method: "POST",
authToken: token,
body: { method: tfMethod, password: tfPassword },
});
setTfChallenge(res);
setTfCode("");
setTfPassword("");
setTfStep("code");
} catch (e: any) {
setTfMsg({ type: "err", text: e?.message || "Failed to send a verification code." });
} finally {
setTfBusy(false);
}
};
const confirmEnableTwoFactor = async (e: React.FormEvent) => {
e.preventDefault();
if (!token || !tfChallenge) return;
setTfBusy(true);
setTfMsg(null);
try {
const res = await apiFetch<{ twoFactorMethod: TwoFactorMethod }>("/api/users/2fa/confirm", {
method: "POST",
authToken: token,
body: { challengeId: tfChallenge.challengeId, code: tfCode },
});
const wasEnabled = tfEnabled;
setTfEnabled(true);
setTfMethod(res.twoFactorMethod);
resetTwoFactorForm();
setTfMsg({ type: "ok", text: wasEnabled ? `Codes will now be sent by ${TWO_FACTOR_METHOD_LABEL[res.twoFactorMethod]}.` : "Two-factor authentication is on." });
loadActivity();
} catch (e: any) {
setTfMsg({ type: "err", text: e?.message || "Failed to verify the code." });
} finally {
setTfBusy(false);
}
};
const disableTwoFactor = async (e: React.FormEvent) => {
e.preventDefault();
if (!token) return;
setTfBusy(true);
setTfMsg(null);
try {
await apiFetch("/api/users/2fa/disable", {
method: "POST",
authToken: token,
body: { password: tfPassword },
});
setTfEnabled(false);
resetTwoFactorForm();
setTfMsg({ type: "ok", text: "Two-factor authentication is off." });
loadActivity();
} catch (e: any) {
setTfMsg({ type: "err", text: e?.message || "Failed to turn off two-factor authentication." });
} finally {
setTfBusy(false);
}
};
// ── Account closure ───────────────────────────────────────────────────────
const [closeStep, setCloseStep] = useState<"idle" | "confirm">("idle");
const [deleteData, setDeleteData] = useState(false);
@@ -299,6 +403,113 @@ export default function UserProfilePage() {
</form>
</section>
{/* ── Two-factor authentication ────────────────────────────────── */}
<section className="border rounded-xl p-5 bg-white shadow-sm">
<SectionHeader icon={ShieldCheck} title="Two-factor authentication" />
{tfStep === "code" && tfChallenge ? (
<form onSubmit={confirmEnableTwoFactor} className="space-y-4">
<p className="text-sm text-gray-500">
We sent a 6-digit code to {tfChallenge.destination} by {TWO_FACTOR_METHOD_LABEL[tfChallenge.method]}. {tfEnabled ? "Enter it to switch your codes to this channel." : "Enter it to finish turning on two-factor authentication."}
</p>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Verification code</label>
<input
type="text"
inputMode="numeric"
autoComplete="one-time-code"
maxLength={6}
className={`${inputCls} tracking-widest`}
value={tfCode}
onChange={e => setTfCode(e.target.value.replace(/\D/g, ""))}
required
/>
</div>
<div className="flex gap-3">
<button type="button" onClick={resetTwoFactorForm} className="px-4 py-2 rounded-lg bg-gray-100 text-sm font-medium hover:bg-gray-200">
Cancel
</button>
<button type="submit" disabled={tfBusy || tfCode.length !== 6} className="px-4 py-2 rounded-lg bg-brand-600 text-white text-sm font-medium hover:bg-brand-700 disabled:opacity-50">
{tfBusy ? "Verifying…" : "Turn on two-factor"}
</button>
</div>
</form>
) : tfEnabled && tfStep !== "change" ? (
<>
<p className="text-sm text-gray-500 mb-4">
<span className="font-medium text-green-700">On.</span> When you log in we send a 6-digit code by{" "}
{TWO_FACTOR_METHOD_LABEL[tfMethod]}. Devices you&apos;ve chosen to trust skip the code for 30 days.
</p>
{tfRequired ? (
<>
<p className="text-xs text-gray-500 mb-3">Required for your role, so it can&apos;t be turned off.</p>
<button
onClick={() => { setTfStep("change"); setTfPassword(""); setTfMsg(null); }}
className="px-4 py-2 rounded-lg border border-gray-300 text-gray-700 text-sm font-medium hover:bg-gray-50"
>
Change how I get codes…
</button>
</>
) : tfStep !== "disable" ? (
<button
onClick={() => { setTfStep("disable"); setTfPassword(""); setTfMsg(null); }}
className="px-4 py-2 rounded-lg border border-gray-300 text-gray-700 text-sm font-medium hover:bg-gray-50"
>
Turn off…
</button>
) : (
<form onSubmit={disableTwoFactor} className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Confirm with your password</label>
<input type="password" className={inputCls} value={tfPassword} onChange={e => setTfPassword(e.target.value)} required autoComplete="current-password" />
</div>
<div className="flex gap-3">
<button type="button" onClick={resetTwoFactorForm} className="px-4 py-2 rounded-lg bg-gray-100 text-sm font-medium hover:bg-gray-200">
Cancel
</button>
<button type="submit" disabled={tfBusy || !tfPassword} className="px-4 py-2 rounded-lg bg-amber-500 text-white text-sm font-medium hover:bg-amber-600 disabled:opacity-50">
{tfBusy ? "Turning off…" : "Turn off two-factor"}
</button>
</div>
</form>
)}
</>
) : (
<form onSubmit={startEnableTwoFactor} className="space-y-4">
<p className="text-sm text-gray-500">
{tfEnabled
? "Choose where your login codes go. We'll send a code there first to check it reaches you."
: "Add a second step to logging in: after your password, we send a 6-digit code that you need to enter."}
</p>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Send my codes by</label>
<select className={inputCls} value={tfMethod} onChange={e => setTfMethod(e.target.value as TwoFactorMethod)}>
<option value="email">Email</option>
{canWhatsAppCodes && <option value="whatsapp">WhatsApp</option>}
</select>
{!canWhatsAppCodes && (
<p className="mt-1 text-xs text-gray-500">Save a valid phone number on your profile to receive codes by WhatsApp.</p>
)}
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Confirm with your password</label>
<input type="password" className={inputCls} value={tfPassword} onChange={e => setTfPassword(e.target.value)} required autoComplete="current-password" />
</div>
<div className="flex gap-3">
{tfEnabled && (
<button type="button" onClick={resetTwoFactorForm} className="px-4 py-2 rounded-lg bg-gray-100 text-sm font-medium hover:bg-gray-200">
Cancel
</button>
)}
<button type="submit" disabled={tfBusy || !tfPassword} className="px-4 py-2 rounded-lg bg-brand-600 text-white text-sm font-medium hover:bg-brand-700 disabled:opacity-50">
{tfBusy ? "Sending code…" : "Send verification code"}
</button>
</div>
</form>
)}
{tfMsg && <Alert msg={tfMsg} />}
</section>
{/* ── Security ─────────────────────────────────────────────────── */}
<section className="border rounded-xl p-5 bg-white shadow-sm">
<SectionHeader icon={ShieldAlert} title="Security" tone="amber" />
+10 -1
View File
@@ -1,7 +1,8 @@
"use client";
import { Share2, QrCode } from "lucide-react";
import { Share2, QrCode, CalendarPlus } from "lucide-react";
import QRCode from "qrcode";
import { API_BASE } from "@/lib/api";
export default function ClientActions({ event }: { event: any }) {
return (
@@ -43,6 +44,14 @@ export default function ClientActions({ event }: { event: any }) {
<QrCode className="w-4 h-4" />
Save QR
</button>
<a
href={`${API_BASE}/api/events/${event.id}/ics`}
download
className="flex items-center gap-2 px-4 py-2 bg-gray-100 rounded-lg hover:bg-gray-200 transition-colors text-sm font-medium"
>
<CalendarPlus className="w-4 h-4" />
Add to calendar
</a>
</div>
);
}
+41 -8
View File
@@ -1,3 +1,4 @@
import { Metadata } from "next";
import { notFound } from "next/navigation";
import { Navbar } from "@/components/layout/Navbar";
import { Footer } from "@/components/layout/Footer";
@@ -41,7 +42,7 @@ type Event = {
location?: string | null;
};
import { apiFetch, ApiError } from "@/lib/api";
import { apiFetch, ApiError, resolveToApiOrigin } from "@/lib/api";
import { ApiImage } from "@/components/shared/ApiImage";
import { formatDateTimeRange } from "@/lib/date";
@@ -105,17 +106,49 @@ function RegisterCta({ event }: { event: Event }) {
);
}
export default async function EventDetailPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
let event: Event;
// Next dedupes an identical fetch (same URL + cache options) made during the same
// request, so calling this again from the page component below is free.
async function loadEvent(id: string): Promise<Event | null> {
try {
event = await apiFetch<Event>(`/api/events/${id}`, { nextOptions: { next: { revalidate } } });
return await apiFetch<Event>(`/api/events/${id}`, { nextOptions: { next: { revalidate } } });
} catch (e) {
// The event endpoint 404s for missing, inactive, or not-yet-live events —
// render the standard not-found page instead of crashing.
if (e instanceof ApiError && e.status === 404) notFound();
if (e instanceof ApiError && e.status === 404) return null;
throw e;
}
}
export async function generateMetadata({ params }: { params: Promise<{ id: string }> }): Promise<Metadata> {
const { id } = await params;
const event = await loadEvent(id);
if (!event) return {};
const description = event.description
? event.description.slice(0, 200)
: `${event.title} — ${formatDateTimeRange(event.startDate, event.endDate)}`;
const imageUrl = event.picture ? resolveToApiOrigin(event.picture) : null;
return {
title: event.title,
description,
openGraph: {
title: event.title,
description,
type: "website",
...(imageUrl ? { images: [{ url: imageUrl }] } : {}),
},
twitter: {
card: "summary_large_image",
title: event.title,
description,
...(imageUrl ? { images: [imageUrl] } : {}),
},
};
}
export default async function EventDetailPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const event = await loadEvent(id);
if (!event) notFound();
return (
<div className="min-h-screen flex flex-col">
+9 -3
View File
@@ -1,11 +1,17 @@
import { Metadata } from "next";
import { Navbar } from "@/components/layout/Navbar";
import { Footer } from "@/components/layout/Footer";
import { EventCard } from "@/components/events/EventCard";
import { apiFetch } from "@/lib/api";
import { apiFetchListForPrerender } from "@/lib/api";
import { Calendar, CalendarX } from "lucide-react";
export const revalidate = 60;
export const metadata: Metadata = {
title: "All Events",
description: "Browse and register for upcoming events.",
};
type Event = {
id: string;
title: string;
@@ -21,11 +27,11 @@ type Event = {
};
export default async function EventsPage() {
const events = await apiFetch<Event[]>("/api/events", { nextOptions: { next: { revalidate: 60 } } });
const events = await apiFetchListForPrerender<Event>("/api/events", { nextOptions: { next: { revalidate: 60 } } });
const now = Date.now();
const upcoming = (events || []).filter(e => {
if (e.cashupStatus === "closed") return false;
const t = new Date(e.startDate).getTime();
const t = new Date(e.endDate || e.startDate).getTime();
return !isNaN(t) && t > now;
});
const sorted = [...upcoming].sort((a, b) => new Date(a.startDate).getTime() - new Date(b.startDate).getTime());
+22 -2
View File
@@ -10,6 +10,7 @@ import { SetupGuard } from "@/components/shared/SetupGuard";
import HelpFab from "@/components/shared/HelpFab";
import { API_BASE, resolveToApiOrigin } from "@/lib/api";
import { buildThemeCssVars } from "@/lib/colorScale";
import { appUrl } from "@/lib/siteConfig";
const geistSans = Geist({
variable: "--font-geist-sans",
@@ -32,6 +33,9 @@ const appName = process.env.NEXT_PUBLIC_APP_NAME || 'Cross Code';
* to fail the whole page render over.
*/
async function getServerSettings(): Promise<SiteSettings> {
// No API base configured: a relative fetch never settles during prerender and
// would time out every page, so skip straight to the defaults.
if (!API_BASE) return {};
try {
const res = await fetch(`${API_BASE}/api/settings`, { next: { revalidate: 60 } });
if (!res.ok) return {};
@@ -44,14 +48,30 @@ async function getServerSettings(): Promise<SiteSettings> {
export async function generateMetadata(): Promise<Metadata> {
const settings = await getServerSettings();
const faviconUrl = settings.favicon_url ? resolveToApiOrigin(settings.favicon_url) : null;
const logoUrl = settings.logo_url ? resolveToApiOrigin(settings.logo_url) : null;
const displayName = settings.org_name || appName;
const description = settings.org_tagline || `Manage and register for events with ${displayName}`;
return {
title: displayName,
description: `Manage and register for events with ${displayName}`,
metadataBase: new URL(appUrl),
title: { default: displayName, template: `%s | ${displayName}` },
description,
icons: {
icon: faviconUrl || "/favicon.ico",
},
openGraph: {
type: "website",
siteName: displayName,
title: { default: displayName, template: `%s | ${displayName}` },
description,
...(logoUrl ? { images: [{ url: logoUrl }] } : {}),
},
twitter: {
card: "summary_large_image",
title: displayName,
description,
...(logoUrl ? { images: [logoUrl] } : {}),
},
};
}
+4 -3
View File
@@ -18,7 +18,7 @@ type Event = {
picture?: string;
};
import { apiFetch } from "@/lib/api";
import { apiFetchListForPrerender } from "@/lib/api";
const FEATURES = [
{ icon: CalendarCheck, title: "Meaningful Events", description: "Events designed to inspire, encourage and strengthen your faith." },
@@ -28,6 +28,7 @@ const FEATURES = [
];
async function getOrgName(): Promise<string> {
if (!API_BASE) return appName;
try {
const res = await fetch(`${API_BASE}/api/settings`, { next: { revalidate: 60 } });
if (!res.ok) return appName;
@@ -40,12 +41,12 @@ async function getOrgName(): Promise<string> {
export default async function HomePage() {
const [events, displayName] = await Promise.all([
apiFetch<Event[]>("/api/events", { nextOptions: { next: { revalidate: 60 } } }),
apiFetchListForPrerender<Event>("/api/events", { nextOptions: { next: { revalidate: 60 } } }),
getOrgName(),
]);
const now = Date.now();
const upcoming = (events || []).filter(e => {
const t = new Date(e.startDate).getTime();
const t = new Date(e.endDate || e.startDate).getTime();
return !isNaN(t) && t > now;
});
const sorted = [...upcoming].sort((a, b) => new Date(a.startDate).getTime() - new Date(b.startDate).getTime());
@@ -3,6 +3,7 @@
import React, { useEffect, useMemo, useState } from "react";
import { useAuth } from "@/hooks/useAuth";
import { apiFetch } from "@/lib/api";
import { CouponCodeField, AppliedCoupon } from "@/components/shared/CouponCodeField";
// Tiered low-stock threshold — mirrors events/[id]/page.tsx
function lowStockThreshold(stockLimit: number): number {
@@ -246,6 +247,7 @@ export default function RegisterForm({ event }: { event: Event }) {
const [registrationId, setRegistrationId] = useState<string | null>(null);
const [creatingCheckout, setCreatingCheckout] = useState(false);
const [creatingRegistration, setCreatingRegistration] = useState(false);
const [coupon, setCoupon] = useState<AppliedCoupon | null>(null);
// Guest details (for unauthenticated free-event registration)
const [guestName, setGuestName] = useState("");
@@ -292,7 +294,11 @@ export default function RegisterForm({ event }: { event: Event }) {
return limitedOpts.length > 0 && limitedOpts.every(o => o.availableCount !== undefined && o.availableCount <= 0);
}, [event]);
const buildItems = () => {
const basket = useMemo(() => buildItems(), [quantities]); // eslint-disable-line react-hooks/exhaustive-deps
// The server prices the basket (staff price, tiers) when checking a code, so its total wins.
const payable = coupon ? coupon.total : total;
function buildItems() {
const items: { eventOptionId: string; quantity: number; variantId?: string }[] = [];
Object.entries(quantities).forEach(([key, qty]) => {
if (qty <= 0) return;
@@ -304,7 +310,7 @@ export default function RegisterForm({ event }: { event: Event }) {
}
});
return items;
};
}
const submitRegistration = async () => {
const items = buildItems();
@@ -322,18 +328,25 @@ export default function RegisterForm({ event }: { event: Event }) {
try {
setError(null);
setCreatingRegistration(true);
const res = await apiFetch<{ id: string }>("/api/registrations", {
const res = await apiFetch<{ id: string; totalDueComputed?: number; couponError?: string }>("/api/registrations", {
method: "POST",
body: { eventId, options: items },
body: { eventId, options: items, ...(coupon ? { couponCode: coupon.code } : {}) },
authToken: token,
});
if (res?.id) {
const hasForm = !!(event?.form && Array.isArray(event.form.fields) && event.form.fields.length > 0);
if (hasForm) {
window.location.href = `/forms?registrationId=${encodeURIComponent(res.id)}`;
} else {
window.location.href = `/registration/success?registrationId=${encodeURIComponent(res.id)}&totalDue=${encodeURIComponent((total || 0).toFixed(2))}`;
const due = res.totalDueComputed ?? payable;
const next = hasForm
? `/forms?registrationId=${encodeURIComponent(res.id)}`
: `/registration/success?registrationId=${encodeURIComponent(res.id)}&totalDue=${encodeURIComponent((due || 0).toFixed(2))}`;
if (res.couponError) {
// The code ran out between checking and registering — the registration is
// saved at full price, so say so before moving on.
setError(`You're registered, but the code couldn't be applied: ${res.couponError} Continuing at full price…`);
setTimeout(() => { window.location.href = next; }, 4000);
return;
}
window.location.href = next;
}
} catch (e: any) {
setError(e?.message || "Registration failed");
@@ -475,8 +488,22 @@ export default function RegisterForm({ event }: { event: Event }) {
);
})()}
{isLoggedIn && total > 0 && !eventSoldOut && (
<div className="mt-6">
<CouponCodeField eventId={eventId} basket={basket} token={token} applied={coupon} onChange={setCoupon} />
</div>
)}
<div className="mt-6 flex items-center justify-between">
<div className="text-lg font-semibold">Total: R{total.toFixed(2)}</div>
<div>
{coupon && coupon.discount > 0 && (
<div className="text-sm text-gray-500">
<span className="line-through">R{coupon.subtotal.toFixed(2)}</span>
<span className="ml-2 text-green-700">−R{coupon.discount.toFixed(2)}</span>
</div>
)}
<div className="text-lg font-semibold">Total: R{payable.toFixed(2)}</div>
</div>
{!registrationId ? (
eventSoldOut ? (
<button disabled className="bg-red-50 text-red-700 border border-red-200 px-4 py-2 rounded cursor-not-allowed">
+15
View File
@@ -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`,
};
}
+88 -18
View File
@@ -2,6 +2,8 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { apiFetch } from "@/lib/api";
import { TwoFactorChallenge, isTwoFactorChallenge } from "@/lib/auth";
import { CouponCodeField, AppliedCoupon } from "@/components/shared/CouponCodeField";
// ─── Types ────────────────────────────────────────────────────────────────────
type EarlyBirdTier = { deadline: string; price: number; order?: number; variantId?: string | null };
@@ -133,6 +135,10 @@ export default function SelfServicePage() {
const [setupLoading, setSetupLoading] = useState(false);
const [setupError, setSetupError] = useState<string | null>(null);
const [supervisorToken, setSupervisorToken] = useState<string | null>(null);
const [kioskCoupon, setKioskCoupon] = useState<AppliedCoupon | null>(null);
// Set while a supervisor with two-factor on still has to enter their code.
const [setupChallenge, setSetupChallenge] = useState<TwoFactorChallenge | null>(null);
const [setupCode, setSetupCode] = useState("");
const [events, setEvents] = useState<KioskEvent[]>([]);
const [selectedEventId, setSelectedEventId] = useState("");
const [eventsLoading, setEventsLoading] = useState(false);
@@ -277,16 +283,32 @@ export default function SelfServicePage() {
setSetupError(null);
setSetupLoading(true);
try {
const data = await apiFetch("/api/users/login", {
method: "POST",
body: { email: setupEmail, password: setupPassword },
});
// Second pass for accounts with two-factor on: exchange the code that was
// sent after the password step. The kiosk never asks to be remembered.
const data = setupChallenge
? await apiFetch("/api/users/login/2fa", {
method: "POST",
body: { challengeId: setupChallenge.challengeId, code: setupCode, rememberDevice: false },
})
: await apiFetch("/api/users/login", {
method: "POST",
body: { email: setupEmail, password: setupPassword },
});
if (isTwoFactorChallenge(data)) {
setSetupChallenge(data);
setSetupCode("");
return;
}
const role: string = data.role || "";
if (!["admin", "supervisor"].includes(role)) {
setSetupError("Only admins and supervisors can set up the kiosk.");
setSetupChallenge(null);
setSetupCode("");
setSetupLoading(false);
return;
}
setSetupChallenge(null);
setSetupCode("");
setSupervisorToken(data.token);
await loadEvents(data.token);
} catch (err: any) {
@@ -316,18 +338,16 @@ export default function SelfServicePage() {
setChangeError(null);
setChangeLoading(true);
try {
const data = await apiFetch("/api/users/login", {
// Re-confirm the password against the supervisor session the kiosk already
// holds, rather than logging in again — a fresh login would send a new
// two-factor code every time the event is changed.
if (!supervisorToken) throw new Error("Your session has expired. Reload the kiosk and sign in again.");
await apiFetch("/api/users/verify-password", {
method: "POST",
body: { email: setupEmail, password: changePassword },
authToken: supervisorToken,
body: { password: changePassword },
});
const role: string = data.role || "";
if (!["admin", "supervisor"].includes(role)) {
setChangeError("Only admins and supervisors can change the event.");
setChangeLoading(false);
return;
}
setSupervisorToken(data.token);
await loadEvents(data.token);
await loadEvents(supervisorToken);
setChangePassword("");
setChangeError(null);
setShowChangeModal(false);
@@ -386,8 +406,11 @@ export default function SelfServicePage() {
...(visitorEmail.trim() ? { email: visitorEmail.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,
...(kioskCoupon ? { couponCode: kioskCoupon.code } : {}),
};
if (createAccount && visitorPassword) {
payload.user.password = visitorPassword;
@@ -401,7 +424,7 @@ export default function SelfServicePage() {
setCurrentRegistrationId(result?.id || null);
setCurrentUserId(result?.userId || null);
setRegistrationIsFree(result?.status === "paid" || total === 0);
setRegistrationIsFree(result?.status === "paid" || (result?.totalDueComputed ?? total) === 0);
setConfirmationName(visitorName.trim());
// If event has a required form and there are main tickets, show form step
@@ -502,6 +525,7 @@ export default function SelfServicePage() {
}
function resetForm() {
setKioskCoupon(null);
setVisitorName("");
setVisitorEmail("");
setVisitorPhone("");
@@ -699,13 +723,41 @@ export default function SelfServicePage() {
<PasswordToggleButton shown={showSetupPassword} onToggle={() => setShowSetupPassword((v) => !v)} />
</div>
</div>
{setupChallenge && (
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Verification code</label>
<input
type="text"
inputMode="numeric"
autoComplete="one-time-code"
maxLength={6}
value={setupCode}
onChange={(e) => setSetupCode(e.target.value.replace(/\D/g, ""))}
className="w-full border border-gray-300 rounded-xl px-4 py-3 text-base tracking-widest focus:outline-none focus:ring-2 focus:ring-brand-500"
placeholder="6-digit code"
autoFocus
required
/>
<p className="mt-1 text-xs text-gray-500">
{setupChallenge.enrolling && "Two-factor authentication is required for your role — this code turns it on. "}
We sent a code to {setupChallenge.destination} by {setupChallenge.method === "whatsapp" ? "WhatsApp" : "email"}.{" "}
<button
type="button"
className="text-brand-600 hover:underline"
onClick={() => { setSetupChallenge(null); setSetupCode(""); setSetupError(null); }}
>
Start again
</button>
</p>
</div>
)}
{setupError && <p className="text-red-600 text-sm">{setupError}</p>}
<button
type="submit"
disabled={setupLoading}
className="w-full bg-brand-600 text-white rounded-xl py-3.5 text-lg font-semibold hover:bg-brand-700 disabled:opacity-60 transition mt-2"
>
{setupLoading ? "Signing in…" : "Sign In"}
{setupLoading ? "Signing in…" : setupChallenge ? "Verify" : "Sign In"}
</button>
</form>
)}
@@ -941,9 +993,27 @@ export default function SelfServicePage() {
);
})}
</div>
{total > 0 && selectedEventId && (
<div className="mt-4">
<CouponCodeField
eventId={selectedEventId}
basket={Object.entries(quantities).map(([key, quantity]) => {
const [eventOptionId, variantId] = key.split("::");
return { eventOptionId, quantity, variantId: variantId || null };
})}
token={supervisorToken}
forUserId={matchedAccount?.id ?? null}
applied={kioskCoupon}
onChange={setKioskCoupon}
/>
</div>
)}
{total > 0 && (
<div className="mt-3 text-right text-base font-semibold text-gray-800">
Total: {fmtCurrency(total)}
{kioskCoupon && kioskCoupon.discount > 0 && (
<span className="mr-2 text-sm font-normal text-gray-500 line-through">{fmtCurrency(kioskCoupon.subtotal)}</span>
)}
Total: {fmtCurrency(kioskCoupon ? kioskCoupon.total : total)}
</div>
)}
</div>
+34
View File
@@ -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>
);
}
+142 -8
View File
@@ -1,12 +1,16 @@
"use client";
import React, { useState } from "react";
import React, { useEffect, useState } from "react";
import { useAuth } from "@/hooks/useAuth";
import { AuthUser, TwoFactorChallenge, isTwoFactorChallenge } from "@/lib/auth";
import { useSearchParams } from "next/navigation";
import Link from "next/link";
// Matches the backend's minimum gap between code sends.
const RESEND_COOLDOWN_SECONDS = 60;
export function LoginForm() {
const { login } = useAuth();
const { login, verifyTwoFactor, resendTwoFactor } = useAuth();
const searchParams = useSearchParams();
const raw = searchParams.get("redirect") || "";
const hasExplicitRedirect = raw.startsWith("/") && !raw.startsWith("//");
@@ -19,6 +23,25 @@ export function LoginForm() {
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
// Two-factor step — set once the password is accepted for an account with 2FA on.
const [challenge, setChallenge] = useState<TwoFactorChallenge | null>(null);
const [code, setCode] = useState("");
const [rememberDevice, setRememberDevice] = useState(false);
const [resendIn, setResendIn] = useState(0);
useEffect(() => {
if (resendIn <= 0) return;
const t = setTimeout(() => setResendIn((s) => s - 1), 1000);
return () => clearTimeout(t);
}, [resendIn]);
// Skip the generic /dashboard hop (which just redirects again once the role is known)
// and go straight to the role-specific dashboard, unless the caller asked for a specific
// page back (e.g. returning to a registration flow after login).
const goToDashboard = (loggedInUser: AuthUser) => {
window.location.href = hasExplicitRedirect ? redirectTo : `/dashboard/${loggedInUser.role || "user"}`;
};
const getErrorMessage = (err: any) => {
if (!err) return "Login failed";
@@ -45,12 +68,15 @@ export function LoginForm() {
setLoading(true);
try {
const loggedInUser = await login(email, password);
// Skip the generic /dashboard hop (which just redirects again once the role is known)
// and go straight to the role-specific dashboard, unless the caller asked for a specific
// page back (e.g. returning to a registration flow after login).
const target = hasExplicitRedirect ? redirectTo : `/dashboard/${loggedInUser.role || "user"}`;
window.location.href = target;
const result = await login(email, password);
if (isTwoFactorChallenge(result)) {
// Password was right, but the account has two-factor on — show the code step.
setChallenge(result);
setCode("");
setResendIn(RESEND_COOLDOWN_SECONDS);
return;
}
goToDashboard(result);
} catch (err: any) {
setError(getErrorMessage(err) || "Login failed. Please try again.");
} finally {
@@ -58,8 +84,116 @@ export function LoginForm() {
}
};
const onSubmitCode = async (e: React.FormEvent) => {
e.preventDefault();
if (!challenge) return;
setError(null);
setLoading(true);
try {
goToDashboard(await verifyTwoFactor(challenge.challengeId, code, rememberDevice));
} catch (err: any) {
setError(getErrorMessage(err) || "Verification failed. Please try again.");
} finally {
setLoading(false);
}
};
const onResend = async () => {
if (!challenge || resendIn > 0) return;
setError(null);
try {
// The resend response doesn't repeat `enrolling`, so carry it over.
const next = await resendTwoFactor(challenge.challengeId);
setChallenge({ ...next, enrolling: challenge.enrolling });
setCode("");
setResendIn(RESEND_COOLDOWN_SECONDS);
} catch (err: any) {
setError(getErrorMessage(err) || "Couldn't send a new code. Please try again.");
}
};
const backToPassword = () => {
setChallenge(null);
setCode("");
setPassword("");
setError(null);
};
if (challenge) {
return (
<form onSubmit={onSubmitCode} className="space-y-4 max-w-sm w-full">
<div>
<h2 className="text-base font-semibold">Enter your verification code</h2>
{challenge.enrolling && (
<p className="text-sm text-brand-800 bg-brand-50 border border-brand-200 rounded px-3 py-2 mt-2">
Two-factor authentication is required for your role. Entering this code turns it on for your account.
</p>
)}
<p className="text-sm text-gray-600 mt-1">
We sent a 6-digit code to {challenge.destination} by {challenge.method === "whatsapp" ? "WhatsApp" : "email"}. It expires in 10 minutes.
</p>
</div>
<div>
<label className="block text-sm font-medium mb-1">Verification code</label>
<input
type="text"
inputMode="numeric"
autoComplete="one-time-code"
maxLength={6}
value={code}
onChange={(e) => setCode(e.target.value.replace(/\D/g, ""))}
className="w-full border rounded px-3 py-2 text-lg tracking-widest"
autoFocus
required
/>
</div>
<label className="flex items-start gap-2 text-sm text-gray-700 cursor-pointer">
<input
type="checkbox"
className="mt-0.5"
checked={rememberDevice}
onChange={(e) => setRememberDevice(e.target.checked)}
/>
<span>Trust this device for 30 days. Only tick this on a device that&apos;s yours.</span>
</label>
{error && <p className="text-sm text-red-600">{error}</p>}
<button
type="submit"
disabled={loading || code.length !== 6}
className="w-full bg-brand-600 hover:bg-brand-700 text-white rounded py-2 disabled:opacity-60"
>
{loading ? "Verifying..." : "Verify and log in"}
</button>
<div className="flex items-center justify-between text-xs">
<button type="button" onClick={backToPassword} className="text-brand-600 hover:underline">
Back to login
</button>
<button
type="button"
onClick={onResend}
disabled={resendIn > 0}
className="text-brand-600 hover:underline disabled:text-gray-400 disabled:no-underline"
>
{resendIn > 0 ? `Resend code in ${resendIn}s` : "Resend code"}
</button>
</div>
</form>
);
}
return (
<form onSubmit={onSubmit} className="space-y-4 max-w-sm w-full">
{searchParams.get("reason") === "2fa" && (
<p className="text-sm text-brand-800 bg-brand-50 border border-brand-200 rounded px-3 py-2">
Two-factor authentication is now required for your role. Log in again to set it up — we&apos;ll send you a code.
</p>
)}
<div>
<label className="block text-sm font-medium mb-1">Email or phone</label>
<input
@@ -13,6 +13,7 @@ import {
FileBarChart,
BarChart3,
ClipboardList,
TicketPercent,
} from "lucide-react";
export type ReportCategory = "Financial" | "Registration" | "Ticketing" | "Donations" | "Cashup" | "Other";
@@ -33,6 +34,7 @@ export const REPORTS = [
{ key: "financeReport", label: "Finance report (revenue & costs)", description: "Revenue & costs overview", category: "Financial", icon: FileBarChart, fields: 6 },
{ key: "profitReport", label: "Profit report", description: "View profit report", category: "Financial", icon: BarChart3, fields: 5 },
{ key: "cashupAudit", label: "Cashup audit trail", description: "Audit trail of cashup actions", category: "Cashup", icon: ClipboardList, fields: 6 },
{ key: "discounts", label: "Discounts (coupon codes)", description: "Uses and value given away per code and event", category: "Financial", icon: TicketPercent, fields: 4 },
] as const satisfies ReadonlyArray<{
key: string;
label: string;
+111 -3
View File
@@ -5,7 +5,7 @@ import { useSearchParams } from "next/navigation";
import { useAuth } from "@/hooks/useAuth";
import { apiFetch, API_BASE } from "@/lib/api";
import { downloadReportExcel, emailReportPdf, whatsappReportPdf, viewReportPdf, type ReportPdfPayload } from "@/lib/export";
import { Printer, Mail, FileSpreadsheet, MessageCircle, ShoppingCart, CreditCard, Gift, Clock, HandHeart, Search, TrendingUp } from "lucide-react";
import { Printer, Mail, FileSpreadsheet, MessageCircle, ShoppingCart, CreditCard, Gift, Clock, HandHeart, Search, TrendingUp, TicketPercent } from "lucide-react";
import { REPORTS, type ReportKey } from "./ReportCatalog";
import ReportsShell from "./ReportsShell";
import ReportViewerModal, { ReportActionButton } from "./ReportViewerModal";
@@ -173,6 +173,7 @@ export default function ReportsV2({ onBack }: { onBack?: () => void } = {}) {
const [ticketUsage, setTicketUsage] = useState<Record<string, { used: number; unused: number }>>({});
const [financialsByEvent, setFinancialsByEvent] = useState<Record<string, any>>({});
const [auditRows, setAuditRows] = useState<any[]>([]);
const [discountRows, setDiscountRows] = useState<any[]>([]);
// Deep-linking: dashboards link here with ?report=<key>&range=trailing_month to jump
// straight into a specific report, pre-filtered to every currently-visible event and the
@@ -282,6 +283,19 @@ export default function ReportsV2({ onBack }: { onBack?: () => void } = {}) {
return byEv;
};
// Coupon uses (active redemptions on non-cancelled registrations) for the Discounts report.
const loadDiscounts = async (eventIds: string[]) => {
if (!token || !eventIds.length) return [] as any[];
const query = new URLSearchParams({ eventIds: eventIds.join(",") });
if (dateFrom) query.set("from", dateFrom);
if (dateTo) query.set("to", dateTo);
try {
return await apiFetch<any[]>(`/api/coupons/redemptions?${query.toString()}`, { authToken: token });
} catch (e) {
return [];
}
};
const loadCashupAudit = async (eventIds: string[]) => {
if (!token) return [] as any[];
const query = new URLSearchParams();
@@ -298,6 +312,29 @@ export default function ReportsV2({ onBack }: { onBack?: () => void } = {}) {
}
};
// Discounts report: one row per code per event.
const discountSummary = useMemo(() => {
const byKey = new Map<string, { code: string; summary: string; event: string; uses: number; total: number }>();
for (const r of discountRows) {
const event = r.registration?.event?.title || "—";
const key = `${r.coupon?.code}__${event}`;
const row = byKey.get(key) || { code: r.coupon?.code || "?", summary: r.summary || "", event, uses: 0, total: 0 };
row.uses += 1;
row.total += Number(r.discountAmount || 0);
byKey.set(key, row);
}
const rows = Array.from(byKey.values()).sort((a, b) => b.total - a.total);
const byCode = new Map<string, number>();
rows.forEach(r => byCode.set(r.code, (byCode.get(r.code) || 0) + r.total));
return {
rows,
totalUses: rows.reduce((s, r) => s + r.uses, 0),
totalValue: rows.reduce((s, r) => s + r.total, 0),
codes: byCode.size,
chart: Array.from(byCode.entries()).map(([label, value]) => ({ label, value })),
};
}, [discountRows]);
// View handler according to selected report
const onView = async () => {
setBusy(true);
@@ -351,6 +388,8 @@ export default function ReportsV2({ onBack }: { onBack?: () => void } = {}) {
} else if (report === "cashupAudit") {
const rows = await loadCashupAudit(selectedEventIds);
setAuditRows(rows);
} else if (report === "discounts") {
setDiscountRows(await loadDiscounts(selectedEventIds));
}
setReady(true);
} finally {
@@ -924,6 +963,25 @@ export default function ReportsV2({ onBack }: { onBack?: () => void } = {}) {
table: { columns: ["Event", "Donations (R)", "Used (R)", "Unused (R)", "Count"], rows }
};
}
if (report === "discounts") {
return {
title: 'Discounts (coupon codes)', subtitle, kind: 'table', orientation: 'portrait',
stats: [
{ label: 'Value given away', value: `R ${discountSummary.totalValue.toFixed(2)}`, tone: 'rose' },
{ label: 'Uses', value: String(discountSummary.totalUses), tone: 'violet' },
{ label: 'Codes used', value: String(discountSummary.codes), tone: 'blue' },
],
chart: { title: 'Value given away, by code', data: discountSummary.chart.map(c => ({ ...c, displayValue: `R ${c.value.toFixed(2)}` })) },
note: 'Counts codes on registrations that are not cancelled. Cancelling a registration frees its code.',
table: {
columns: ["Code", "Discount", "Event", "Uses", "Value (R)"],
rows: [
...discountSummary.rows.map(r => [r.code, r.summary, r.event, r.uses, Number(r.total.toFixed(2))]),
["Total", "", "", discountSummary.totalUses, Number(discountSummary.totalValue.toFixed(2))],
],
},
};
}
if (report === "cashup") {
const rows: (string | number)[][] = [];
const incomeByMethod: Record<string, number> = {};
@@ -958,7 +1016,7 @@ export default function ReportsV2({ onBack }: { onBack?: () => void } = {}) {
const ev = filteredEvents.find(e => e.id === evId);
if (!f) return;
const title = ev?.title || evId;
sold += (f.salesByOption || []).reduce((s: number, o: any) => s + (o.revenue || 0), 0);
sold += (f.salesByOption || []).reduce((s: number, o: any) => s + (o.revenue || 0), 0) - (f.discountsTotal || 0);
paidByAll += METHOD_KEYS.reduce((s, m) => s + (f.paymentsByMethod?.[m] || 0), 0);
METHOD_KEYS.forEach(m => { paidByMethod[m] = (paidByMethod[m] || 0) + (f.paymentsByMethod?.[m] || 0); });
donationsProfit += f.unallocatedDonationsTotal || 0;
@@ -972,6 +1030,7 @@ export default function ReportsV2({ onBack }: { onBack?: () => void } = {}) {
// as profit" — no need to repeat it here.
rows.push([title, "Sales", "What was sold", ""]);
(f.salesByOption || []).forEach((s: any) => rows.push([title, "", `${s.name} (${s.quantitySold} sold)`, Number(s.revenue.toFixed(2))]));
if (f.discountsTotal > 0) rows.push([title, "", "Less discount codes", -Number(f.discountsTotal.toFixed(2))]);
rows.push([title, "Paid by", "", ""]);
METHOD_KEYS.forEach(m => rows.push([title, "", METHOD_LABEL[m], Number((f.paymentsByMethod?.[m] || 0).toFixed(2))]));
rows.push([title, "Cashup comparison", "", ""]);
@@ -1108,6 +1167,7 @@ export default function ReportsV2({ onBack }: { onBack?: () => void } = {}) {
financeReport: "Finance Report",
profitReport: "Profit Report",
cashupAudit: "Cashup Audit Trail",
discounts: "Discounts Report",
};
const doPrintPdf = async () => {
@@ -1558,6 +1618,54 @@ export default function ReportsV2({ onBack }: { onBack?: () => void } = {}) {
)}
</div>
)}
{report === "discounts" && (
<div>
{discountSummary.rows.length === 0 ? (
<div className="text-sm text-gray-500">No discount codes used for the selected events and dates.</div>
) : (
<>
<StatTileRow>
<StatTile icon={TicketPercent} label="Value given away" value={`R ${discountSummary.totalValue.toFixed(2)}`} tone="rose" />
<StatTile icon={ShoppingCart} label="Uses" value={String(discountSummary.totalUses)} tone="violet" />
<StatTile icon={Gift} label="Codes used" value={String(discountSummary.codes)} tone="blue" />
</StatTileRow>
<div className="mt-4 border border-gray-100 rounded-xl p-4">
<div className="text-xs text-gray-500 mb-3">Value given away, by code</div>
<HorizontalBarChart data={discountSummary.chart} valueFormatter={v => `R ${v.toFixed(2)}`} />
</div>
<div className="mt-4 overflow-auto border border-gray-100 rounded-xl">
<table className="min-w-[640px] w-full">
<thead>
<tr className="text-left text-gray-500 text-xs uppercase tracking-wide bg-gray-50">
<th className="py-2 px-3">Code</th>
<th className="py-2 px-3">Discount</th>
<th className="py-2 px-3">Event</th>
<th className="py-2 px-3 text-right">Uses</th>
<th className="py-2 px-3 text-right">Value (R)</th>
</tr>
</thead>
<tbody>
{discountSummary.rows.map(row => (
<tr key={`${row.code}-${row.event}`} className="border-t border-gray-100">
<td className="py-2 px-3 font-mono">{row.code}</td>
<td className="py-2 px-3">{row.summary}</td>
<td className="py-2 px-3">{row.event}</td>
<td className="py-2 px-3 text-right">{row.uses}</td>
<td className="py-2 px-3 text-right">R {row.total.toFixed(2)}</td>
</tr>
))}
<tr className="font-semibold bg-gray-50 border-t border-gray-200">
<td className="py-2 px-3" colSpan={3}>Total</td>
<td className="py-2 px-3 text-right">{discountSummary.totalUses}</td>
<td className="py-2 px-3 text-right">R {discountSummary.totalValue.toFixed(2)}</td>
</tr>
</tbody>
</table>
</div>
</>
)}
</div>
)}
{report === "donations" && (
<div>
{Object.keys(paymentsByEvent).length === 0 ? (
@@ -1878,7 +1986,7 @@ export default function ReportsV2({ onBack }: { onBack?: () => void } = {}) {
<div className="font-medium text-gray-900">{ev?.title || evId}</div>
<StatTileRow>
<StatTile icon={ShoppingCart} label="What was sold" value={`R ${(f.salesByOption || []).reduce((s: number, o: any) => s + (o.revenue || 0), 0).toFixed(2)}`} tone="green" />
<StatTile icon={ShoppingCart} label={f.discountsTotal > 0 ? "What was sold (after discounts)" : "What was sold"} value={`R ${((f.salesByOption || []).reduce((s: number, o: any) => s + (o.revenue || 0), 0) - (f.discountsTotal || 0)).toFixed(2)}`} tone="green" />
<StatTile icon={CreditCard} label="Paid by (all methods)" value={`R ${METHOD_KEYS.reduce((s, m) => s + (f.paymentsByMethod?.[m] || 0), 0).toFixed(2)}`} tone="blue" />
<StatTile icon={HandHeart} label="Donations counted as profit" value={`R ${(f.unallocatedDonationsTotal || 0).toFixed(2)}`} tone="rose" />
<StatTile icon={FileSpreadsheet} label="Total costs" value={`R ${(f.totalCosts || 0).toFixed(2)}`} tone="amber" />
@@ -0,0 +1,140 @@
"use client";
import React, { useEffect, useRef, useState } from "react";
import { TicketPercent, X } from "lucide-react";
import { apiFetch, ApiError } from "@/lib/api";
export type BasketItem = { eventOptionId: string; variantId?: string | null; quantity: number };
export type AppliedCoupon = {
code: string;
summary: string;
discount: number;
subtotal: number;
total: number;
};
/**
* "Have a code?" field. Checks the code against the current basket with
* POST /api/coupons/validate and reports the result through `onChange` — the parent
* sends `applied.code` as `couponCode` when it registers. Re-checks automatically when
* the basket changes, since the discount (or whether the code applies at all) can change.
*
* Staff checking on someone's behalf pass `forUserId` (the attendee), or `null` for a
* walk-in with no account yet.
*/
export function CouponCodeField({
eventId,
basket,
token,
forUserId,
applied,
onChange,
compact = false,
}: {
eventId: string;
basket: BasketItem[];
token?: string | null;
forUserId?: string | null;
applied: AppliedCoupon | null;
onChange: (applied: AppliedCoupon | null) => void;
compact?: boolean;
}) {
const [open, setOpen] = useState(!!applied);
const [code, setCode] = useState(applied?.code || "");
const [checking, setChecking] = useState(false);
const [error, setError] = useState<string | null>(null);
const items = basket.filter(b => b.quantity > 0);
const basketKey = JSON.stringify(items);
const lastKey = useRef(basketKey);
const check = async (value: string) => {
const trimmed = value.trim();
if (!trimmed) return;
if (items.length === 0) { setError("Choose your tickets first."); return; }
setChecking(true);
setError(null);
try {
const body: Record<string, unknown> = { code: trimmed, eventId, options: items };
if (forUserId !== undefined) body.forUserId = forUserId;
const res = await apiFetch<AppliedCoupon & { valid: boolean }>("/api/coupons/validate", { method: "POST", authToken: token || undefined, body });
onChange({ code: res.code, summary: res.summary, discount: res.discount, subtotal: res.subtotal, total: res.total });
} catch (e) {
onChange(null);
setError(e instanceof ApiError ? e.message : "Couldn't check that code. Please try again.");
} finally {
setChecking(false);
}
};
// Basket changed after a code was applied: check it again for the new items.
useEffect(() => {
if (lastKey.current === basketKey) return;
lastKey.current = basketKey;
if (applied) check(applied.code);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [basketKey]);
// Staff switched attendee: restricted codes depend on who it's for.
useEffect(() => {
if (applied) check(applied.code);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [forUserId]);
const remove = () => {
onChange(null);
setCode("");
setError(null);
};
if (!open) {
return (
<button type="button" onClick={() => setOpen(true)} className="inline-flex items-center gap-1.5 text-sm text-brand-600 hover:underline">
<TicketPercent className="w-4 h-4" /> Have a code?
</button>
);
}
if (applied) {
return (
<div className="flex items-center justify-between gap-3 rounded-lg border border-green-200 bg-green-50 px-3 py-2 text-sm">
<div className="flex items-center gap-2 text-green-800">
<TicketPercent className="w-4 h-4 shrink-0" />
<span>
<span className="font-mono font-semibold">{applied.code}</span> — {applied.summary}
{applied.discount > 0 && <span className="font-medium"> (−R {applied.discount.toFixed(2)})</span>}
</span>
</div>
<button type="button" onClick={remove} className="text-green-800 hover:text-green-950" aria-label="Remove code">
<X className="w-4 h-4" />
</button>
</div>
);
}
return (
<div>
{!compact && <label className="block text-sm font-medium text-gray-700 mb-1">Discount code</label>}
<div className="flex gap-2">
<input
value={code}
onChange={e => { setCode(e.target.value.toUpperCase()); setError(null); }}
onKeyDown={e => { if (e.key === "Enter") { e.preventDefault(); check(code); } }}
placeholder="Enter code"
className="flex-1 min-w-0 border rounded-lg px-3 py-2 text-sm font-mono uppercase focus:outline-none focus:ring-2 focus:ring-brand-400"
aria-label="Discount code"
/>
<button
type="button"
onClick={() => check(code)}
disabled={checking || !code.trim()}
className="px-4 py-2 rounded-lg border border-brand-600 text-brand-700 text-sm font-medium hover:bg-brand-50 disabled:opacity-50"
>
{checking ? "Checking…" : "Apply"}
</button>
</div>
{error && <p className="text-xs text-red-600 mt-1">{error}</p>}
</div>
);
}
+6
View File
@@ -27,6 +27,12 @@ export const authHelpContent: HelpContent = {
<GuideItem icon={LogIn} title="Already registered as a guest?" tone="green">
If you registered as a guest before, you can still create a full account later using the same email — your past registrations will be there once you&apos;re logged in.
</GuideItem>
<GuideItem icon={ShieldCheck} title="Asked for a verification code?" tone="amber">
If you&apos;ve turned on two-factor authentication, we send a 6-digit code by email or WhatsApp after your password. Enter it within 10 minutes, or use &quot;Resend code&quot; if it doesn&apos;t arrive. Tick &quot;Trust this device&quot; to skip the code on that device for 30 days. You can turn two-factor on or off from Profile &amp; Security.
</GuideItem>
<GuideItem icon={ShieldCheck} title="Admin or supervisor?" tone="amber">
Two-factor authentication is required for admin and supervisor accounts. If it isn&apos;t on yet, your next login sends you a code, and entering it turns two-factor on. You can change whether codes come by email or WhatsApp from Profile &amp; Security, but you can&apos;t turn it off.
</GuideItem>
</div>
</div>
),
+1 -1
View File
@@ -34,7 +34,7 @@ export const dashboardUserHelpContent: HelpContent = {
Tap a registration to change quantities or options, pay what&apos;s outstanding, or cancel it entirely.
</GuideItem>
<GuideItem icon={FileText} title="Invoice" tone="rose">
The Invoice button sends a PDF breakdown of the registration — itemized cost, any early-bird discount, and what&apos;s still owing (or &quot;Paid in full&quot;) — to your email or WhatsApp, whichever you pick.
The Invoice button sends a PDF breakdown of the registration — itemized cost, any early-bird or discount-code saving, and what&apos;s still owing (or &quot;Paid in full&quot;) — to your email or WhatsApp, whichever you pick. If you have a discount code and haven&apos;t paid in full yet, enter it in the registration popup.
</GuideItem>
<GuideItem icon={History} title="Show past events" tone="gray">
Registrations page defaults to upcoming events only — tick &quot;Show past events&quot; at the top to bring back ones that have already happened.
+1 -1
View File
@@ -20,7 +20,7 @@ export const eventDetailHelpContent: HelpContent = {
<p>The tickets panel lists every option available for this event, with live availability.</p>
<div className="space-y-4">
<GuideItem icon={Tag} title="Early-bird pricing" tone="amber">
If a discounted price is shown with the original struck through, an early-bird window is active — the price rises automatically once it ends.
If a discounted price is shown with the original struck through, an early-bird window is active — the price rises automatically once it ends. Got a discount code? Enter it on the registration page under &quot;Have a code?&quot; (you need to be logged in).
</GuideItem>
<GuideItem icon={Ticket} title="Availability badges" tone="brand">
&quot;N remaining&quot; appears once stock runs low, and &quot;Sold out&quot; once an option is fully booked.
+6
View File
@@ -21,6 +21,7 @@ import { supervisorManualHelpContent, supervisorManualRegistrationLegacyHelpCont
import { supervisorAtTheDoorHelpContent } from "./supervisor-at-the-door";
import { supervisorPaymentsHelpContent } from "./supervisor-payments";
import { supervisorFormsHelpContent } from "./supervisor-forms";
import { supervisorCouponsHelpContent } from "./supervisor-coupons";
import { supervisorEmailAttendeesHelpContent } from "./supervisor-email-attendees";
import { supervisorWhatsappAttendeesHelpContent } from "./supervisor-whatsapp-attendees";
import { staffEventTicketsHelpContent } from "./staff-event-tickets";
@@ -147,6 +148,11 @@ const REGISTRY: HelpRegistryEntry[] = [
matches: pathname => pathname === "/dashboard/supervisor/payments",
content: supervisorPaymentsHelpContent,
},
{
slug: "supervisor-coupons",
matches: pathname => pathname === "/dashboard/supervisor/coupons",
content: supervisorCouponsHelpContent,
},
{
slug: "supervisor-forms",
matches: pathname => pathname === "/dashboard/supervisor/forms" || pathname === "/dashboard/admin/forms",
@@ -42,6 +42,7 @@ export const supervisorAtTheDoorHelpContent: HelpContent = {
<div className="space-y-4">
<p>Selecting a registration automatically jumps to Payment mode if it&apos;s unpaid, or Check-in mode if it&apos;s already fully paid.</p>
<p>When editing an existing registration&apos;s items, quantities can&apos;t be reduced below what&apos;s already been printed or paid for.</p>
<p>The item picker has a &quot;Have a code?&quot; field for discount codes. On an existing registration the code is applied when you save.</p>
</div>
),
},
@@ -0,0 +1,73 @@
import React from "react";
import { TicketPercent, Plus, Users, CalendarClock, Layers, Ban, BarChart2, BadgePercent, DoorOpen } from "lucide-react";
import { GuideItem } from "@/components/shared/GuideItem";
import type { HelpContent } from "./types";
/** Help content for /dashboard/supervisor/coupons. */
export const supervisorCouponsHelpContent: HelpContent = {
title: "Coupon codes",
subtitle: "Discounts for staff, leaders, volunteers and promotions.",
tabs: [
{
key: "create",
label: "Creating codes",
icon: Plus,
content: (
<div className="space-y-4">
<div className="space-y-4">
<GuideItem icon={TicketPercent} title="Discount type" tone="brand">
A percentage off, a fixed rand amount off, or free (100%). People type the code on the registration page; it isn&apos;t case-sensitive.
</GuideItem>
<GuideItem icon={Layers} title="Events and tickets" tone="green">
Leave the events unticked for every event, or pick one or more. Once events are picked you can limit the code to certain ticket options or variants — otherwise it discounts the whole registration.
</GuideItem>
<GuideItem icon={BadgePercent} title="Early-bird prices" tone="violet">
By default the better of the two prices wins: the code is worked out against the standard price, and only what beats the early-bird price comes off. Tick &quot;Also apply on top of early-bird prices&quot; to stack them.
</GuideItem>
<GuideItem icon={CalendarClock} title="Dates and limits" tone="amber">
Optional start and end dates, a maximum number of uses overall, a maximum per person (1 by default), and a minimum number of tickets.
</GuideItem>
<GuideItem icon={Users} title="Who can use it" tone="blue">
Anyone with the code, people with certain roles (e.g. Staff and above), specific people, or an email domain. Restricted codes need the person to be logged in, so they can&apos;t be passed around a group chat.
</GuideItem>
</div>
</div>
),
},
{
key: "using",
label: "How discounts work",
icon: TicketPercent,
content: (
<div className="space-y-4">
<div className="space-y-4">
<GuideItem icon={TicketPercent} title="Locked in when applied" tone="brand">
The discount is saved on the registration and shows as its own line on invoices, receipts, emails and the payment page. Turning a code off later doesn&apos;t change discounts already given.
</GuideItem>
<GuideItem icon={Layers} title="Changing items" tone="green">
If the items on a registration change, percentage and free codes are worked out again on the new items. Fixed-amount codes keep their amount.
</GuideItem>
<GuideItem icon={Ban} title="Cancellations" tone="rose">
Cancelling a registration frees its code so it can be used again. A free code that covers everything marks the registration paid and sends the tickets.
</GuideItem>
<GuideItem icon={DoorOpen} title="Applying for someone" tone="amber">
Manual registration, At the door and the kiosk all have a &quot;Have a code?&quot; field. Every code staff apply is recorded in the admin audit log.
</GuideItem>
</div>
<p className="text-xs text-gray-500">Staff price: a ticket option can also have a staff price (set in the event&apos;s Items &amp; pricing step) that Staff and above get automatically, with no code.</p>
</div>
),
},
{
key: "tracking",
label: "Tracking",
icon: BarChart2,
content: (
<div className="space-y-4">
<p>&quot;Uses&quot; on each code lists who used it, on which event, and how much it took off. The <strong>Discounts</strong> report (Reports → Financial) totals uses and value given away per code and event, and the Finance report shows sales after discounts.</p>
<p className="text-xs text-gray-500">A code that has been used can&apos;t be renamed — turn it off and create a new one instead.</p>
</div>
),
},
],
};
@@ -35,7 +35,7 @@ export const supervisorEventsHelpContent: HelpContent = {
Title, description, start/end times, registration deadline, go-live time, base price, picture, and a custom URL alias.
</GuideItem>
<GuideItem icon={ListChecks} title="Items & pricing" tone="green">
Ticket options and stock, variants of each option, and early-bird pricing tiers.
Ticket options and stock, variants of each option, and early-bird pricing tiers. An optional staff price is charged automatically to Staff and above when it&apos;s lower than the normal price.
</GuideItem>
<GuideItem icon={Layers} title="Sections" tone="violet">
Group ticket options under named headings as shown on the public registration page.
@@ -14,7 +14,7 @@ export const supervisorManualHelpContent: HelpContent = {
icon: UserPlus,
content: (
<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 &quot;Guest&quot; 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 and staff pricing are applied automatically, and a discount code can be added under &quot;Have a code?&quot; — 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>
</div>
),
+35 -10
View File
@@ -2,13 +2,15 @@
import React, { createContext, useCallback, useContext, useEffect, useMemo, useState } from "react";
import { apiFetch, ApiError } from "@/lib/api";
import { AuthResponse, AuthUser, clearToken, getToken, saveToken } from "@/lib/auth";
import { AuthResponse, AuthUser, TwoFactorChallenge, clearToken, getDeviceToken, getToken, isTwoFactorChallenge, saveDeviceToken, saveToken } from "@/lib/auth";
export type AuthContextValue = {
user: AuthUser | null;
token: string | null;
loading: boolean;
login: (email: string, password: string) => Promise<AuthUser>;
login: (email: string, password: string) => Promise<AuthUser | (TwoFactorChallenge & { twoFactorRequired: true })>;
verifyTwoFactor: (challengeId: string, code: string, rememberDevice: boolean) => Promise<AuthUser>;
resendTwoFactor: (challengeId: string) => Promise<TwoFactorChallenge>;
register: (data: { name: string; email: string; password: string; phoneNumber?: string; notificationPreference?: "email" | "whatsapp" | "both" }) => Promise<void>;
logout: () => void;
updateToken: (token: string) => void;
@@ -49,18 +51,41 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
init();
}, []);
const login = useCallback(async (email: string, password: string) => {
const res = await apiFetch<AuthResponse>("/api/users/login", {
method: "POST",
body: { email, password },
});
const { token, ...loggedInUser } = res;
const startSession = useCallback((res: AuthResponse) => {
const { token, deviceToken, ...loggedInUser } = res;
if (deviceToken) saveDeviceToken(deviceToken);
saveToken(token);
setToken(token);
setUser(loggedInUser);
return loggedInUser;
}, []);
const login = useCallback(async (email: string, password: string) => {
const res = await apiFetch<AuthResponse | TwoFactorChallenge>("/api/users/login", {
method: "POST",
body: { email, password, deviceToken: getDeviceToken() || undefined },
});
// 2FA accounts get a challenge back instead of a token — nothing is saved
// until verifyTwoFactor exchanges the emailed/WhatsApp code for one.
if (isTwoFactorChallenge(res)) return res;
return startSession(res as AuthResponse);
}, [startSession]);
const verifyTwoFactor = useCallback(async (challengeId: string, code: string, rememberDevice: boolean) => {
const res = await apiFetch<AuthResponse>("/api/users/login/2fa", {
method: "POST",
body: { challengeId, code, rememberDevice },
});
return startSession(res);
}, [startSession]);
const resendTwoFactor = useCallback((challengeId: string) => {
return apiFetch<TwoFactorChallenge>("/api/users/login/2fa/resend", {
method: "POST",
body: { challengeId },
});
}, []);
const register = useCallback(async (data: { name: string; email: string; password: string; phoneNumber?: string; notificationPreference?: "email" | "whatsapp" | "both" }) => {
const res = await apiFetch<AuthResponse>("/api/users", {
method: "POST",
@@ -87,8 +112,8 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
}, []);
const value = useMemo(
() => ({ user, token, loading, login, register, logout, updateToken }),
[loading, login, logout, register, token, updateToken, user]
() => ({ user, token, loading, login, verifyTwoFactor, resendTwoFactor, register, logout, updateToken }),
[loading, login, logout, register, resendTwoFactor, token, updateToken, user, verifyTwoFactor]
);
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
+17
View File
@@ -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,
});
});
}
+17
View File
@@ -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,
});
}
+30
View File
@@ -84,6 +84,13 @@ export async function apiFetch<T = any>(path: string, options: ApiOptions = {}):
const url = path.startsWith("http") ? path : `${API_BASE}${path}`;
// On the server a relative URL has no origin to resolve against, and Next's
// patched fetch never settles on one during prerender (the build then times out
// every page). Fail fast instead so callers can degrade.
if (typeof window === "undefined" && !url.startsWith("http")) {
throw new Error(`API base URL is not configured (set NEXT_PUBLIC_API_URL or API_URL); cannot fetch ${path}`);
}
const mergedHeaders: Record<string, string> = { ...headers };
if (!(body instanceof FormData)) {
mergedHeaders["Content-Type"] = "application/json";
@@ -110,6 +117,15 @@ export async function apiFetch<T = any>(path: string, options: ApiOptions = {}):
if (json?.message && typeof json.message === "string") message = json.message;
data = json;
} catch { /* not JSON — use raw text */ }
// An admin/supervisor session from before 2FA was required for their role:
// end it and send them to log in, where they enrol.
if (res.status === 401 && data?.code === "TWO_FACTOR_REQUIRED" && typeof window !== "undefined") {
localStorage.removeItem("hope_events_token");
if (!window.location.pathname.startsWith("/login")) {
const back = window.location.pathname + window.location.search;
window.location.href = `/login?reason=2fa&redirect=${encodeURIComponent(back)}`;
}
}
throw new ApiError(res.status, message, data);
}
const contentType = res.headers.get("content-type") || "";
@@ -120,6 +136,20 @@ export async function apiFetch<T = any>(path: string, options: ApiOptions = {}):
return res.text();
}
// For server components that prerender a public list: during `next build` the
// backend is often not running (or no API URL is set), so fall back to an empty
// list rather than failing the build — the page fills in on its first revalidate.
// At runtime the error still propagates, so ISR keeps serving the last good page
// instead of caching an empty one.
export async function apiFetchListForPrerender<T>(path: string, options: ApiOptions = {}): Promise<T[]> {
try {
return (await apiFetch<T[]>(path, options)) || [];
} catch (err) {
if (process.env.NEXT_PHASE === "phase-production-build") return [];
throw err;
}
}
// Several list endpoints (/api/users, /api/payments) are paginated and cap
// `limit` at 200 server-side, so a single request can silently miss records
// beyond the first page. This walks every page and returns the full list.
+34
View File
@@ -5,13 +5,47 @@ export type AuthUser = {
role?: "admin" | "supervisor" | "staff" | "user";
phoneNumber?: string | null;
notificationPreference?: "email" | "whatsapp" | "both";
twoFactorEnabled?: boolean;
twoFactorMethod?: TwoFactorMethod | null;
// True for roles that must use 2FA (admin, supervisor) — it can't be turned off.
twoFactorRequired?: boolean;
};
export type AuthResponse = AuthUser & {
token: string;
// Only present when the user ticked "Trust this device" on the 2FA step.
deviceToken?: string;
};
export type TwoFactorMethod = "email" | "whatsapp";
// What the login endpoint returns instead of a token when the account has 2FA on:
// a code was sent to `destination` (masked) and must be exchanged for the token.
export type TwoFactorChallenge = {
twoFactorRequired?: true;
challengeId: string;
method: TwoFactorMethod;
destination: string;
// The role requires 2FA and it wasn't on yet: verifying this code turns it on.
enrolling?: boolean;
};
export function isTwoFactorChallenge(res: unknown): res is TwoFactorChallenge & { twoFactorRequired: true } {
return !!res && typeof res === "object" && (res as TwoFactorChallenge).twoFactorRequired === true;
}
const TOKEN_KEY = "hope_events_token";
// Remembers this browser after a 2FA code so the next login can skip it. Kept on
// logout on purpose — it identifies the device, not the session.
const DEVICE_KEY = "hope_events_device";
export function saveDeviceToken(token: string) {
if (typeof window !== "undefined") localStorage.setItem(DEVICE_KEY, token);
}
export function getDeviceToken(): string | null {
if (typeof window === "undefined") return null;
return localStorage.getItem(DEVICE_KEY);
}
export function saveToken(token: string) {
if (typeof window !== "undefined") localStorage.setItem(TOKEN_KEY, token);
+49 -23
View File
@@ -2,7 +2,9 @@
## What is it?
Hope Events is a complete event management platform built specifically for churches and faith-based organisations. It handles everything from advertising upcoming events and taking online registrations to collecting payments, generating QR-coded tickets, and checking people in at the door.
Hope Events is a complete event management platform built specifically for churches and faith-based organisations. It handles everything from advertising upcoming events and taking online registrations to collecting payments and donations, sending QR-coded tickets, checking people in at the door, and balancing the books afterwards.
It runs in any web browser — on a phone, tablet, or computer — with nothing to install.
---
@@ -10,32 +12,56 @@ Hope Events is a complete event management platform built specifically for churc
### For your congregation
- **Browse events** — a clean, mobile-friendly listing of upcoming events with photos, descriptions, and dates. Events that are fully booked display a "Sold Out" badge so attendees know at a glance.
- **Register online** — simple registration flow with support for multiple ticket types (e.g. adult, child, family) at different prices, including early-bird pricing and low-stock warnings.
- **Pay online** — secure card payments via Yoco. Outstanding balances can be settled any time.
- **Receive tickets** — QR-coded tickets are emailed automatically after payment. Attendees can also retrieve their tickets via a self-service portal using just their email address.
- **Registration forms** — events can include custom forms (dietary requirements, T-shirt sizes, emergency contacts, etc.) completed at registration time.
- **Downloads** — events can include downloadable attachments (maps, schedules, waiver forms, etc.) visible on the event detail page.
- **Browse events** — a clean, mobile-friendly listing of upcoming events with photos, descriptions, dates, and location (with a map and directions). Fully booked events show a "Sold Out" badge, and popular tickets show how many are left.
- **Discount codes** — enter a code for a staff, leader or promotional discount, shown on the invoice and receipt.
- **Register online** — choose from multiple ticket types (e.g. adult, child, family, dinner, T-shirt sizes) at different prices, including early-bird pricing. People can register as a guest or with an account, depending on how you set up the event.
- **Pay online** — secure card payments (including Apple Pay and Google Pay) via Yoco. Outstanding balances can be settled any time from a link in their email or WhatsApp.
- **Give** — members can make donations online, which you can put towards someone's registration (for example, to sponsor a child's camp place).
- **Invoices and receipts** — branded PDF invoices and payment receipts are sent automatically and can be requested again at any time.
- **Receive tickets** — QR-coded tickets are sent by email and/or WhatsApp as soon as a registration is paid. Each person chooses how they'd like to be contacted.
- **Add to calendar** — one tap adds the event to their phone or computer calendar.
- **Registration forms** — events can include custom forms (dietary requirements, medical information, emergency contacts, indemnities, etc.), saved as they go.
- **Downloads** — events can include attachments such as maps, schedules, or waiver forms.
- **Their own dashboard** — see registrations, tickets, payment history, and forms in one place; add tickets, cancel before paying, and manage their profile and privacy.
### For staff on the day
- **QR code scanner** — check people in by scanning their ticket QR code. Works in any mobile browser — no app to install.
- **At-the-door panel** — search by name, phone, or ticket ID; mark people in; print or resend individual tickets on the spot.
- **QR code scanner** — check people in by scanning their ticket with any phone camera. A single ticket can cover a whole family and be checked in a few people at a time.
- **Scan points** — set up separate check-in points (e.g. main hall, dinner) that only accept the right tickets.
- **At-the-door desk** — search by name, phone, or ticket number; register walk-ins; take cash, card, or EFT payments; check people in; and print or resend tickets on the spot.
- **Self-service kiosk** — set up a tablet where visitors register themselves. Returning visitors are recognised by their email or phone number.
- **Check-in confirmations** — attendees get a message confirming how many people were checked in.
### For event organisers (supervisors)
### For event organisers
- **Event management** — create and manage events with photos, pricing, early-bird tiers, and registration deadlines.
- **Ticket types** — flexible options (e.g. Adult, Child, VIP, Dinner) each with their own price.
- **Payments** — view all payments, record cash or EFT manually, and issue adjustments.
- **Bulk messaging** — send emails or WhatsApp messages to event attendees. Schedule reminders, thank-you messages, or general broadcasts.
- **Event management** — a step-by-step wizard for creating events: details, ticket types and pricing, early-bird tiers, stock limits, check-in points, registration forms, visibility, and attachments.
- **Flexible publishing** — schedule an event to go live later, keep it hidden and share it only by link, give it a short web address (e.g. `/camp-2026`), or create "contact us" events (e.g. baptisms) with no registration.
- **Coupon codes** — percentage, fixed or free codes for staff, leaders, volunteers and promotions, limited to events, tickets, dates, number of uses or particular people. A staff price can also apply automatically to your team.
- **Payments** — record cash, card, EFT, or voucher payments; issue refunds; send payment links; assign donations; and match card payments to registrations. Every payment records who took it.
- **Messaging** — send emails or WhatsApp messages to attendees, filtered by who has paid or still owes. Personalise messages with each person's name, balance, or their own payment link. Schedule reminders, thank-you messages, and promotions in advance.
- **Daily summary** — the event team gets a daily email with registrations, payments, and outstanding balances in the run-up to each event.
- **Forms** — view, filter, and print attendee form responses.
- **Reports** — download attendance and payment reports.
- **Reports** — 15 ready-made reports covering payments, revenue, attendees, ticket usage, registration status, donations, discounts, costs, and profit. Each can be printed or exported to PDF, Excel, email, or WhatsApp.
- **Dashboard** — revenue, donations, registrations, and tickets sold at a glance, compared with the previous month.
### For administrators
### For administrators and treasurers
- **User management** — manage accounts and roles (user, staff, supervisor, admin).
- **Registrations** — a complete overview of all registrations across all events.
- **Site settings** — update the organisation name, logo, brand colour, and notification preferences directly from the admin panel — no technical knowledge required.
- **Cashup** — record event costs, count cash by denomination for each staff member, and compare expected and actual takings for cash, card, and EFT. Close the event once the books balance.
- **Profit reporting** — see revenue, costs, and profit per event.
- **Audit trail** — a record of who did what and when for refunds, donations, manual registrations, cancellations, event changes, and settings changes.
- **User management** — manage accounts and roles (member, staff, supervisor, admin), and erase a person's personal data on request.
- **Registrations overview** — every registration across all events, with paid and outstanding totals.
- **Site settings** — update your organisation's name, address, logo, brand colours, email settings, legal pages, and WhatsApp connection yourself, with a live preview. No technical knowledge required.
- **Backups** — the database is backed up every night, and backups can be downloaded from the admin panel.
---
## Built to be trusted
- **Your branding** — your logo, colours, and name on the website, emails, invoices, receipts, and reports.
- **Privacy** — Terms of Use and Privacy Policy pages filled in with your details, and members can close their account or have their personal data removed.
- **Security** — two-step login codes by email or WhatsApp (required for admins and supervisors, optional for members), protection against password-guessing, a record of each account's recent logins, and the ability to sign out on all devices.
- **Help on every page** — a "Need help?" button explains each page in plain language.
---
@@ -50,7 +76,7 @@ Hope Events is offered as a **hosted service**. You don't need to worry about se
Pricing is quoted per organisation based on expected usage. Contact us for a quote.
> Online payment processing fees (charged by Yoco directly to your merchant account) are separate and not included in the subscription.
> WhatsApp messaging requires a WhatsApp number linked to the platform.
---
@@ -58,7 +84,7 @@ Pricing is quoted per organisation based on expected usage. Contact us for a quo
1. **Get a quote** — contact us and we'll provide pricing for your organisation.
2. **We set it up** — once you're onboarded, we deploy and configure the platform for you. You'll receive a link to your site.
3. **First login** — a setup wizard walks you through entering your organisation details and creating your admin account. No technical steps required.
3. **First login** — a setup wizard walks you through entering your organisation details, creating your admin account, and adding your logo and colours. No technical steps required.
4. **You're live** — start creating events and inviting attendees.
---
@@ -74,8 +100,8 @@ Pricing is quoted per organisation based on expected usage. Contact us for a quo
## Payment processing
The platform integrates with **Yoco** — a South African payment gateway that supports all major credit and debit cards. You will need your own Yoco merchant account to accept payments. Yoco's standard transaction fee applies (currently 2.95% per transaction); this goes directly to Yoco and is not part of our subscription.
The platform integrates with **Yoco** — a South African payment gateway that supports all major credit and debit cards, Apple Pay, and Google Pay. You will need your own Yoco merchant account to accept payments.
---
*Built for Hope Family Church, Henley-on-Klip. Available to other churches and faith-based organisations.*
*Built for Hope Family Church, Henley-on-Klip. Available to other churches and faith-based organisations.*
+9 -915
View File
File diff suppressed because it is too large Load Diff
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "hope-events",
"version": "1.9.1",
"version": "1.13.0",
"main": "index.js",
"scripts": {
"dev:backend": "cd backend && npm run dev",
@@ -18,7 +18,7 @@
"devDependencies": {
"concurrently": "^9.2.1"
},
"dependencies": {
"express-rate-limit": "^8.3.1"
"overrides": {
"shell-quote": "^1.12.0"
}
}