Compare commits

...
Author SHA1 Message Date
joshuaandClaude Opus 5.5 83df90b531 Bump to 1.18.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 14:23:31 +02:00
joshuaandClaude Opus 5.5 87ffe3b84b Add ticket transfer
Payers and holders can hand a ticket to someone else before the event.
The new holder gets a new QR code (and an inactive account with an
activation link if they're new); the old code stops working and scans
of it, online or in offline sync, say it was transferred. One place can
be split off a shared group ticket. Transfers can be turned off per
event (supervisors can always transfer from At the door), youth events
need the new holder's own consent, and every transfer is audited.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 14:23:24 +02:00
joshua 9a4bf51ad4 Merge branch 'feat/offline-scanning' 2026-10-19 14:06:40 +02:00
joshuaandClaude Opus 5.5 d84663f901 Bump to 1.17.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 14:06:34 +02:00
joshuaandClaude Opus 5.5 be621c49a2 Add offline ticket scanning
Staff can lock a section (or the whole event) to their device, download
its tickets and keep scanning with no connection. Scans are queued in
IndexedDB and synced idempotently when back online; conflicts go into a
sync report for supervisors, who can also force-release a lost device.
The server now validates scan sections and refuses scans in a scope
locked to another device. Adds a web app manifest and a service worker
for the scanner page.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 14:06:22 +02:00
joshua edf7c3052b Merge branch 'feat/waitlist' 2026-10-19 13:43:20 +02:00
joshuaandClaude Opus 5.5 f66968768f Bump to 1.16.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 13:43:16 +02:00
joshuaandClaude Opus 5.5 2166f94247 Add waitlist for sold-out tickets
Events can take a waitlist for sold-out lines. When places free up
(cancellation, smaller booking, higher stock limit) the next person is
offered them with a claim link and the places are held for a set time;
unclaimed offers expire and move on, with optional partial offers. Stock
is now counted in one place (utils/stock.js), including held places,
and manual registrations and booking edits are now stock-checked. Adds a
supervisor waitlist page, My Events waitlists, and the daily summary count.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 13:43:10 +02:00
joshua bae9675485 Merge branch 'feat/parental-consent' 2026-10-19 13:25:02 +02:00
joshuaandClaude Opus 5.5 13fd3a4cbb Bump to 1.15.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 13:24:57 +02:00
joshuaandClaude Opus 5.5 809b6a11d7 Add parental consent for youth events
Youth events need a name and date of birth for every attendee, and a
parent/guardian's details for under-18s. Parents get an email and
WhatsApp link to a consent page that records the signed name, time, IP,
device and exact consent text; medical and emergency details are stored
encrypted. Tickets are issued but check-in is blocked until consent is
given (supervisor override audited). A daily job sends reminders and
purges medical details after the retention period. Adds a supervisor
consent tracker, consent status and medical list reports, and clears
consent data when an account is anonymised.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 13:24:44 +02:00
joshua f1e0b62c73 Merge branch 'feat/group-tickets' 2026-10-19 12:12:52 +02:00
joshuaandClaude Opus 5.5 72ecf500c8 Bump to 1.14.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 12:12:44 +02:00
joshuaandClaude Opus 5.5 df0bf09a0e Add group tickets with named attendees
Events can take a name (and optional email/phone) for each main-ticket
place. Each named person gets their own quantity-1 ticket and attendee
form; unnamed places share one ticket. Tickets go to the payer or to each
person, attendees matching one existing account are linked to it, and
the payer can edit names and send form links from My Events. Ticket
generation is now planned per line (utils/attendees.js), free
registrations use the same generator, and guest registration matches
accounts by phone too, refusing conflicting email/phone pairs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 12:12:33 +02:00
joshua 4b413788ac Merge branch 'feat/coupon-codes' 2026-10-19 11:43:36 +02:00
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
joshuaandClaude Sonnet 5 5a9137e252 Skip daily summary emails for contact-only events
Contact-only events have no registration flow, so their daily
summary (registration/payment stats) was always empty — exclude
them from the query instead of sending a pointless email.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-22 21:22:53 +02:00
joshuaandClaude Sonnet 5 4e63a78e8e Bump version to 1.9.1
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-22 21:12:37 +02:00
joshua 2a067a3687 Merge branch 'fix/org-name-branding' into main 2026-08-22 21:11:52 +02:00
joshuaandClaude Sonnet 5 bdae0c6b08 Fix org name not showing in tab title, homepage, and backend pages
The browser tab title, homepage "Welcome to..." heading, and the
backend status/API docs pages all had the "Cross Code" default
hardcoded instead of reading the configured org_name setting.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-22 21:09:35 +02:00
joshuaandClaude Sonnet 5 c84cc257d0 Bump version to 1.9.0
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-21 11:51:27 +02:00
joshua 7cbb147b00 Merge branch 'feature/event-location-maps' into main 2026-08-21 11:50:31 +02:00
joshua b75be18a87 Add event/organisation location with Google Maps links
Events and the organisation profile now have an address, with a
"Directions" link and an embedded Google Maps view (no API key
required) shown on event pages, event cards, and the Contact page.
New events default their location to the org's configured address.
2026-08-21 11:47:23 +02:00
joshua 05840541c2 Merge branch 'feature/early-bird-tranches-and-contact-events' into main 2026-08-21 10:33:19 +02:00
joshuaandClaude Sonnet 5 96aa9b10cc Bump version to 1.8.0
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-21 10:32:17 +02:00
joshuaandClaude Sonnet 5 bc64069021 Add PDF invoices/receipts with manual send, fix early-bird tier edit data loss
- Registration confirmations attach an invoice PDF (itemized breakdown,
  early-bird discount, balance due, Yoco pay-now link/QR) whenever a
  balance is outstanding; payment/donation confirmations attach a
  payment receipt PDF. Sent as an email attachment and, over WhatsApp,
  as the PDF itself with the existing message as its caption.
- Users can also (re)send either document on demand: an "Invoice"
  button on the registration detail popup, and a "Receipt" button next
  to each payment there and on the Payment history page, each opening
  an Email/WhatsApp choice popup, via two new endpoints restricted to
  the registration/payment's own owner.
- Fix: editing an event option's early-bird tiers deleted and
  recreated every tier for that option with brand-new ids, silently
  severing the appliedTierId link on all historical purchases (losing
  early-bird attribution and undercounting stock-limit usage) even for
  tiers the admin didn't touch. Tiers are now upserted by id.
- Update the "My Events" help content and the API docs index for the
  new endpoints.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-21 10:26:49 +02:00
joshuaandClaude Sonnet 5 e9cb238ce1 Fix early-bird total in notifications; group dashboard registration items
Registration/payment/reminder emails and WhatsApp messages loaded
registrations without their price tranches, so any line spanning more
than one tranche fell back to charging the full quantity at the most
recent tranche's price, silently dropping the early-bird discount from
the outstanding balance and itemized amounts shown to the user.

The user dashboard's registration detail popup also listed one raw
line per tranche; it now merges same item/price/tier lines and groups
early-bird lines separately from standard-price ones.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-21 08:57:29 +02:00
joshuaandClaude Sonnet 5 f0f8d4c242 Fix early-bird price blending and mislabeling; add contact-only events
- Early-bird pricing: RegistrationOption now tracks each purchase as a
  separate price tranche instead of overwriting a single price/quantity
  on repeat purchases, so buying more tickets after a tier expires no
  longer re-prices tickets already bought at the old price. Stock-limit
  checks, total-due calculation, and the Finance report's revenue-by-
  option are all tranche-aware; pages that showed one blended price per
  line now render/total each tranche. Viewing a pending/partially-paid
  registration (dashboard, detail page, or an event's registration
  list) now refreshes stale pricing on the spot instead of only at
  payment time.
- Fixed the "(early bird)" dashboard label incorrectly firing on any
  line priced below the base option price (e.g. a plain cheaper
  variant) — it now checks the real applied-tier flag.
- Added contact-only events (e.g. baptism): no registration/payment
  flow, shown on the public site with a "Contact us" popup instead of
  a Register button. Configurable via the admin event wizard.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-20 17:26:35 +02:00
joshua f2c3172e16 Merge branch 'feature/site-branding-customization' into main 2026-08-20 14:57:44 +02:00
joshuaandClaude Sonnet 5 fd9e917b62 Bump version to 1.7.0
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-20 14:57:23 +02:00
joshuaandClaude Sonnet 5 86af7093ac Add admin-configurable branding (colors, logo, favicon) and generic default fallbacks
Site Settings -> Branding now supports a Primary/Secondary/Accent brand color
system applied site-wide (buttons, nav, hover states, links) and to outgoing
email header/CTA colors, plus a favicon upload alongside the existing logo
upload, a live preview panel (website/email x desktop/mobile), and
logo-based color suggestions. The setup wizard's Branding step got the same
treatment. Fixes two related bugs found along the way: the setup wizard's
logo/favicon upload was missing its auth token, and a static favicon.ico in
Next's special app/ convention path was silently overriding the dynamic one.

Also replaces every "Hope Events"/"Hope Family Church" default (org name,
email subjects, WhatsApp messages, report metadata, API docs) with a neutral
"Cross Code" placeholder, and the optional legal settings (operator name, IO
details, website URL, effective date) with obviously-generic placeholders
instead of defaulting to real personal/organisational details -- since this
platform is deployed for multiple organisations. Adds SETTINGS.md documenting
every setting's default behaviour.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-20 14:49:51 +02:00
joshua 2d99b7cafe Merge branch 'feature/supervisor-payments-improvements' into main 2026-08-08 01:24:52 +02:00
joshuaandClaude Sonnet 5 906c79a8e8 Bump version to 1.6.0
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-08 01:19:49 +02:00
joshuaandClaude Sonnet 5 47b79181b7 Add search and event filter to the Assigned donations list
Finding a specific leg to unassign meant scanning the whole list by
eye. Adds a text search (matches registrant, donor, or event) and an
event dropdown (built from events that actually have assigned legs)
above the list.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-08 01:15:45 +02:00
joshuaandClaude Sonnet 5 34f9826829 Fix donation-assignment admin email reading as a real payment
The internal admin notice for applying a donation to a registration
reused buildPaymentAdminNotice as-is, so it read "Payment recorded" /
"Registration payment" — indistinguishable from an actual incoming
payment even though no new money changed hands. Adds a dedicated
buildDonationAssignmentAdminNotice wrapper (mirroring the one already
added for unassignment) so the subject, heading, and Type field all
say "Donation applied" instead.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-08 00:59:24 +02:00
joshuaandClaude Sonnet 5 252f80fadb Add ability to unassign a donation from a registration
Assigning a donation to a registration was one-way: the refund flow
could reverse the money but left the donation's "leg" payment in
place, permanently locking that portion of the donation as used even
though it had been refunded back out. Adds POST
/api/payments/unassign-donation, which deletes the leg, reverts the
registration's status/tickets the same way a refund downgrade already
does, and notifies the registrant. New "Assigned donations" list on
the supervisor Payments page surfaces existing legs with an Unassign
action, since no such list existed before.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-08 00:53:21 +02:00
joshuaandClaude Sonnet 5 0a5b08020f Speed up payment/refund processing on the supervisor dashboard
Reconciling a Yoco payment and sending payment links blocked the HTTP
response on ticket-PDF generation and email/WhatsApp sends; they now
run in the background like the other payment flows already did.
Registration/payment option loops (pricing, stock checks, ticket
generation) now resolve concurrently instead of sequentially. The
Payments page dropped a per-registration N+1 fetch and now refreshes
its lists in parallel after each action. Added missing indexes for
dashboard stats and donation-leg lookups, and made GET
/api/registrations optionally paginated.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-08 00:00:29 +02:00
joshua 6759e9c2d3 Merge branch 'fix/join-us-button-logged-in' into main 2026-08-07 01:57:23 +02:00
joshuaandClaude Sonnet 5 8799eec717 Bump version to 1.5.4
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-07 01:57:02 +02:00
joshuaandClaude Sonnet 5 56e0c8a31f Fix Join Us button linking logged-in users to register page
Send logged-in users to their dashboard instead of the registration
form, matching how the Navbar already treats logged-in vs guest users.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-07 01:52:35 +02:00
joshua 4bcb07f9d1 Merge branch 'fix/help-modal-mobile-overflow' into main 2026-08-07 01:17:08 +02:00
joshuaandClaude Sonnet 5 a0a3dc2416 Bump version to 1.5.3
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-07 01:16:53 +02:00
joshuaandClaude Sonnet 5 51639322e2 Fix help guide popups overflowing the screen on mobile
The popup card had no overall height cap, only the tab content area
did, so the header/quick-links/footer could push the close button off
screen on short mobile viewports. The card is now capped to the
screen height with the header and footer pinned in place, and only
the tab content scrolls internally.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-07 01:14:44 +02:00
joshuaandClaude Sonnet 5 1f186d6d2a Bump version to 1.5.2
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-07 00:47:54 +02:00
joshua 55e7ca9546 Merge branch 'fix/dashboard-mobile-styling' into main 2026-08-07 00:47:14 +02:00
joshua 8f69e58aa2 Add tab icons and fix mobile dashboard styling issues
- Add matching lucide-react icons to tab/mode switcher buttons on
  payments, at-the-door, manual, email-attendees, whatsapp-attendees,
  and admin cashup pages, mirroring icons already used in their help menus
- Fix navbar Logout button sitting lower than other nav links (missing
  border/padding classes that other links use for their active-underline)
- Fix stat card labels getting truncated on mobile by removing the
  ellipsis-cut label and widening the mobile grid to one column
- Fix Revenue trend / Top performing events rendering outside the
  viewport on mobile by containing horizontal overflow and truncating
  long event titles in the table
2026-08-07 00:41:52 +02:00
joshuaandClaude Sonnet 5 1b2f2a7c79 Stop tracking banner.json runtime data store
Same reasoning as scheduled-emails.json: it's mutated by the running
app (site banner content), not source. readBanner() already falls
back to sane defaults when the file is missing, so untracking it is
safe for fresh checkouts.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-07 00:04:45 +02:00
joshuaandClaude Sonnet 5 7906248c67 Stop tracking scheduled-emails.json runtime data store
It's mutated by the running app (scheduled job queue), not source -
committing it churns the repo with live recipient/message content.
Kept on disk, just untracked; a fresh checkout will get an empty
store created on demand by ensureStore().

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-07 00:03:04 +02:00
joshua 22ffbf201e Merge branch 'fix/whatsapp-scheduling-channel-bug' into main (1.5.1) 2026-08-07 00:01:01 +02:00
joshuaandClaude Sonnet 5 be499f0d66 Fix scheduled job list/edit dropping message content
toClient() never returned the message body (html/text for email,
message for WhatsApp), so "Manage scheduled" showed nothing to view
or edit even though the content existed in storage. Editing a
scheduled WhatsApp message also saved to the wrong payload field
(text instead of message), silently discarding the edit.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-07 00:00:43 +02:00
joshuaandClaude Sonnet 5 75b2f8ccc5 Bump version to 1.5.1
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-06 23:41:43 +02:00
joshua 4f003f1628 Merge branch 'fix/whatsapp-scheduling-channel-bug' 2026-08-06 23:40:32 +02:00
joshuaandClaude Sonnet 5 b3ff2b9c5e Fix scheduled WhatsApp messages sending as email; add 24h cleanup and recipient display
The scheduled-job store never persisted the channel field, so the
send worker always fell through to its email branch regardless of
what was requested. Also purges sent jobs 24h after sending instead
of keeping them forever, and surfaces who each scheduled job will go
to in the admin "manage scheduled" lists (now correctly filtered per
channel too).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-06 23:37:35 +02:00
joshuaandClaude Sonnet 5 0833888eaa Bump version to 1.5.0
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-06 15:29:17 +02:00
joshuaandClaude Sonnet 5 b626404640 Merge branch 'feature/site-redesign' (1.5.0)
Full site visual redesign, site-wide contextual help system, dashboard
KPI overhaul (rolling trailing-month window), and assorted fixes.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-06 15:27:39 +02:00
joshuaandClaude Sonnet 5 0f2b5afa74 Help guide opens on demand only; add At the door to admin quick actions
- The site-wide help guide no longer auto-opens on first visit to a page
  — it only opens when the help button is clicked. Removed the
  now-pointless "Don't show this again" checkbox and the per-page
  dismissal tracking it drove.
- Admin dashboard quick actions was missing "At the door" (walk-in
  registration, payments, ticket printing), which Supervisor already had.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-06 15:17:12 +02:00
joshuaandClaude Sonnet 5 8e6cb542d9 Full site redesign, help system, and dashboard stats fixes
Multi-phase visual facelift (design tokens, dashboards, sidebar/navbar
shell, per-page help guides, and a layout/content pass across every
remaining page) plus backend fixes to the dashboard KPI stats:

- Admin/Supervisor dashboard KPIs (revenue, donations, registrations,
  tickets sold) now use a rolling trailing-month window (today back one
  calendar month, e.g. 9 May - 8 June if today is 8 June) instead of
  calendar month-to-date, which under-counted for most of the month.
  The comparison window shifts the same way, so like is still compared
  with like.
- Reports deep-links from those stat tiles now match the same window
  (range=trailing_month, replacing range=this_month).
- Design tokens (brand-* Tailwind scale + shadcn CSS variables), a
  site-wide contextual help button, fixed dashboard sidebar/navbar,
  Admin/Supervisor/Staff/User dashboard rebuilds backed by a new
  GET /api/stats/overview endpoint, a dedicated Contact page, Site
  Settings restyle with WhatsApp config folded in, and an Account
  activity feed backed by a new SecurityEvent model.
- Every remaining page (home, events, registration flow, auth, legal,
  payment results, and every Admin/Supervisor/Staff/User tool page)
  restyled onto the same design tokens, several with real layout
  upgrades (home hero, events list/detail, donate page, auth pages).
- 20+ new dedicated help guides so the whole site has page-specific
  help content instead of falling back to a generic guide.
- Assorted fixes surfaced along the way: donation-leg double-counting
  in payment stats, donations not counting toward revenue, refund
  netting in per-method report breakdowns, and donation
  over-allocation after a refund.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-06 15:00:10 +02:00
joshuaandClaude Sonnet 5 d74fec3a5c Bump version to 1.4.2
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-06 09:13:04 +02:00
joshuaandClaude Sonnet 5 4bee1b24f7 Fix supervisor payments mobile tabs, reconcile donor, report method buckets, and refund/donation accounting
- Supervisor Payments: mode tabs now wrap on mobile instead of overflowing off-screen.
- Reconciling a Yoco transaction as a donation now lets staff pick who it's from.
- Reports payment-method breakdown now buckets into Cash/Card/EFT/Other everywhere,
  folding Apple Pay, Google Pay, and Yoco-portal payments into Card.
- Refunds now net against their original method's bucket (Cashup/Finance/Profit reports,
  My Payments filtering/display) instead of vanishing or falling into "Other".
- Refund form's method dropdown mirrors the real payment methods and auto-fills from the
  payment being refunded, replacing an ambiguous generic "Refund" option.
- Fixed donation remaining/unallocated balance inflating instead of shrinking when a
  donation is refunded (assign-donation endpoint, cashup reports, donations reports,
  and the Assign Donation panel all summed refund legs with the wrong sign).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-06 09:08:53 +02:00
joshuaandClaude Sonnet 5 70605923bf Bump version to 1.4.1
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-05 15:00:44 +02:00
joshuaandClaude Sonnet 5 424141f216 Fix Reports mobile layout overflow and desktop date range overflow
Report popup buttons and the page's search/Back controls overflowed
off-screen on mobile instead of wrapping, and the sidebar's custom
date range inputs spilled outside the filter box on desktop.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-05 14:57:09 +02:00
joshuaandClaude Sonnet 5 d6ea4c37d7 Sync root package.json version to 1.4.0
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-04 15:49:03 +02:00
joshua ebf521e64e Merge branch 'feature/reports-and-cashup-overhaul' (1.4.0) 2026-08-04 15:46:55 +02:00
joshuaandClaude Sonnet 5 e5fbcc2222 Bump version to 1.4.0
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-04 15:46:09 +02:00
joshuaandClaude Sonnet 5 1b4446c828 Hide dashboard sidebar on staff/supervisor/admin sub-pages; fix mobile overflow
- Sidebar now shows only on each role's root landing page
  (/dashboard/staff, /dashboard/supervisor, /dashboard/admin) and hides on
  every sub-page beneath them, not just Reports. Navbar's "Dashboard" link
  always leads back to the role root.
- Fixed two dropdown panels (EventsDropdown, the Email/WhatsApp Attendees
  recipient picker) that could extend past the viewport's right edge on
  narrow screens; both now stretch to their trigger's width like other
  dropdowns in the app already do.
- Wrapped the Admin Cashup event-costs table in the same overflow-auto
  container every sibling table on that page already uses, so it scrolls
  horizontally on narrow screens instead of squeezing its columns.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-04 15:30:56 +02:00
joshuaandClaude Sonnet 5 0de3f4be7d Fix financial double-counting, rebuild cashup accountability, and redesign the Reports page
Financial correctness (donation-leg model):
- Donations are no longer mutated when assigned to a registration; assignment now
  creates an immutable "leg" record referencing the original donation instead.
- Fixed several places where money was double-counted once a donation was partially
  or fully assigned (Payments, Revenue summary, Cashup reconciliation, Finance
  report, Profit report, Master Orders, Revenue Detailed).
- Payments now record who recorded them (recordedBy), separate from who they're for.

Cashup:
- Per-user cash denomination counting (optional, any time) replaces the single
  event-wide manual entry; the event's cash actual is the live sum of these counts.
- New "Payment accountability by staff member" breakdown across all methods, and a
  read-only "Report" tab that opens automatically once an event is closed.

Reports page redesign:
- New shell: sidebar of universal filters (events, date range, past/inactive/closed
  toggles), searchable/categorized report grid, and a popup viewer with
  Print/Email/Excel/WhatsApp actions plus an in-app Reporting Guide.
- Visual pass: colored stat tiles and bar charts on most reports, matching mockups.
- PDF exports (download/Print/Email/WhatsApp) now share a branded design mirroring
  the web report — colored header, stat tiles, bar chart, highlighted totals.
- Excel export now produces a styled .xlsx (via exceljs) instead of a plain CSV.
- Master Orders' "Donations made" table is now included in every export channel.

Bug fixes discovered while testing exports:
- Report emails now go through the shared, DB-configurable mail utility instead of
  a one-off transporter that ignored Site Settings SMTP config.
- WhatsApp report sends now surface the actual WAWP API error and auto-recover a
  disconnected session, instead of a bare axios status-code message.

Also: Admin-editable notification preference, richer Admin Registrations dashboard,
{{payment.link}} placeholder for Email/WhatsApp Attendees, and background
email/WhatsApp attendee sending.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-04 14:52:54 +02:00
joshua 56f2a9f7fc Merge branch 'fix/self-service-kiosk-lookup' 2026-08-03 13:03:12 +02:00
joshuaandClaude Sonnet 5 b081ed3c8b Rework self-service kiosk account lookup for privacy and safety (1.3.2)
Replaces the explicit "look up existing account" search field with automatic
lookup as email/phone are entered, requires operator confirmation before any
matched account's name/email/phone/preference is changed, adds a password
show/hide toggle, and fixes two bugs found during testing: entering a phone
number belonging to a different account could silently overwrite the form
with that account's details, and re-checking an unchanged field (e.g. from
tapping a ticket quantity button) could revert edits already made. Also adds
a server-side check rejecting registrations whose email and phone resolve to
two different existing accounts, as defense in depth.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-03 12:56:26 +02:00
joshua 8a75c9155b Bump version to 1.3.1 2026-07-28 16:17:37 +02:00
joshua 8850984055 Merge branch 'fix/self-service-kiosk' 2026-07-28 16:16:31 +02:00
joshua 21176e1e0b Populate email/phone from an unmatched kiosk search query
When the account lookup finds no match, carry the typed query into whichever
of Email/Phone it resembles (email-shaped or phone-shaped) instead of
discarding it, so the operator doesn't have to retype it. Leaves both blank
if it matches neither shape, and resets Name/notification preference for a
fresh entry.
2026-07-28 16:05:41 +02:00
joshua b4cd140168 Replace as-you-type account check with an explicit search field
The kiosk previously ran a debounced background lookup on every keystroke
in the visitor Email/Phone fields, silently autofilling matches. Replaced
with a dedicated "Look up existing account" field that only searches when
the operator presses Enter or clicks Search, and matches exactly against
either email or phone (never both from one query, to avoid digits in an
email being misread as an unrelated phone number). Also simplified the
"account already exists" banner wording.
2026-07-28 14:35:28 +02:00
joshua 5167706d1b Update changelog for self-service kiosk fixes 2026-07-28 14:24:21 +02:00
joshua 047f61b627 Fix self-service kiosk: hide closed events and autofill known visitors
- GET /api/events/all gains an opt-in excludeClosed=true param, used only by
  the kiosk, so closed events no longer show as selectable there while other
  admin/supervisor screens that still need to see closed events are unaffected.
- GET /api/users/check-exists now also returns the matched account's name,
  email, phone, and notification preference (safe fields only). The kiosk's
  existing debounced lookup uses this to autofill whichever fields are still
  blank when a visitor enters an email or phone that matches an existing
  account, without overwriting anything already typed.
2026-07-28 14:21:47 +02:00
joshuaandClaude Sonnet 5 1815f78c85 Bump version to 1.3.0
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-27 15:06:08 +02:00
joshuaandClaude Sonnet 5 29036d0612 Merge branch 'feature/door-checkin'
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-27 15:04:51 +02:00
joshuaandClaude Sonnet 5 c07e9c928a Add door check-in flow for Main Tickets
Lets staff redeem a registration's Main Tickets by quantity at the door
(via the Payment/registration flow) instead of scanning each QR code,
and automatically emails/WhatsApps a check-in confirmation to the
attendee. Also routes the At The Door "Open" button and post-payment
flow dynamically: paid registrations jump straight to Check-In instead
of a forced ticket print, since tickets are already sent automatically.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-27 14:56:31 +02:00
joshuaandClaude Sonnet 5 adb512a7d9 Bump version to 1.2.0
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-27 10:35:21 +02:00
275 changed files with 36538 additions and 9869 deletions
+7 -1
View File
@@ -33,4 +33,10 @@ Thumbs.db
# misc scratch / generated files
temp/
backend/public/uploads/
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
backend/data/banner.json
+491 -1
View File
@@ -7,6 +7,491 @@ and this project follows [Semantic Versioning](https://semver.org/).
## [Unreleased]
## [1.18.0] - 2026-10-19
### Added
- Ticket transfer. On My Events, "Transfer" hands a ticket to someone else before the event: enter their name and email or phone.
- The new holder gets the ticket by email/WhatsApp with a new QR code. If they're new, an inactive account is created and they get an activation link.
- The old QR code stops working, so screenshots of it won't scan. Scanning it says who the ticket was transferred to, and offline-mode sync flags it.
- For a ticket covering several people, one place is split off to the new holder.
- The payer stays responsible for the registration.
- Transfer rules:
- Only before the event starts, and only for tickets that haven't been scanned.
- It can be turned off per event (new "Ticket transfer" setting in the event wizard, on by default). Supervisors can always transfer, from At the door → Tickets.
- On youth events, the new holder's date of birth, and a parent's details for a minor, are needed, and a new consent request is sent.
- The previous holder's attendee form answers and consent medical details are removed.
- Every transfer is recorded in the audit log.
- New endpoints `POST /api/tickets/:id/transfer` and `GET /api/tickets/:id/transfers`.
- Requires the `add_ticket_transfer` database migration.
## [1.17.0] - 2026-10-19
### Added
- Offline ticket scanning. While online, staff pick an event and section on the scanner and tap "Enable offline mode". The section (or the whole event) is locked to their device and its tickets are downloaded to the device.
- With no connection, scans are checked against that list and queued on the device.
- The panel shows when the list was downloaded and how many scans are waiting.
- Queued scans sync automatically when the device is back online (or with "Sync now"), and check-in messages go out then.
- "Release section" ends offline mode and deletes the list from the device.
- Only one device can hold a section at a time (a whole-event lock clashes with every section). While a section is locked, other devices see who holds it and can't scan it, online or offline, so a ticket can never be redeemed twice by two offline devices.
- Syncing is idempotent and never redeems more than is left on a ticket. Anything that needs a person to look goes into a sync report: tickets not found, cancelled registrations, used-up tickets, scans outside the locked section, and check-ins without consent.
- Supervisor → Offline scanning page: active locks with force release (for a lost or flat phone; its scans still sync if it reconnects, and are flagged), and sync reports to review. Locks, releases, force-releases and syncs are recorded in the audit log.
- The scanner is installable as an app (web app manifest), and a service worker caches the scanner page and its scripts so it opens with no connection.
- The app now stays signed in with the last confirmed profile when it's reloaded without a connection, instead of sending the user to the login page.
- New `/api/scan-locks` endpoints.
- Requires the `add_offline_scanning` database migration.
### Changed
- `POST /api/tickets/scan/:qrCode` now accepts `sectionId` and checks it on the server; this was previously only checked in the browser.
- It now refuses (423) scans in a section that another device has locked for offline scanning.
## [1.16.0] - 2026-10-19
### Added
- Waitlist for sold-out tickets. A new event setting (event wizard → Visibility) turns it on, with how long offered places are held (24 hours by default) and whether someone can be offered fewer places than they asked for.
- Sold-out tickets show "Join waitlist" on the event page, the register page and the events list. Guests can join on events that allow guest registration.
- When places free up, the next person is emailed/WhatsApped a claim link, and the places are held for them so nobody else can buy them. Places free up through a cancellation (by the person or staff), a smaller booking, or a raised stock limit.
- The claim link opens the register page with the places pre-selected, and works without logging in.
- Unclaimed offers expire (checked every 10 minutes) and move on to the next person.
- People can leave a waitlist from My Events, which now lists their waitlists and any places held for them.
- Supervisor → Waitlist page: the queue per event, with offer now (even over the stock limit), move up/down and remove.
- The daily summary email shows each event's waitlist count.
- New `/api/waitlist` endpoints. `POST /api/registrations` accepts `waitlistToken`.
- Requires the `add_waitlist` database migration.
### Fixed
- Manual registrations (Manual registration, At the door, the kiosk) and adding places to an existing booking never checked stock, so either could take an option over its stock limit. Both are now checked like a normal registration.
- Stock is now counted in one place (`utils/stock.js`) for registration checks, `availableCount` and `isSoldOut`, including places held for waitlist offers.
## [1.15.0] - 2026-10-19
### Added
- Parental consent for youth events. A new "Requires parental consent" event setting (event wizard → Visibility) has its own consent text and optional medical info, emergency contact and photo consent questions. It turns on named attendees automatically.
- Registering then needs a name and date of birth for every main-ticket place. Anyone under 18 on the event's start date needs a parent or guardian's name, email and phone.
- This applies on the registration page, manual registration, at the door, the kiosk, and when editing names in My Events.
- The parent gets an email and a WhatsApp (always both) with a link to a new consent page (`/consent/<token>`). There they read the text, answer the optional questions, type their full name and confirm they're the parent or guardian, then give or decline.
- The system records the time, IP, device, signed name, and the exact consent text and version shown.
- Medical and emergency details are stored encrypted.
- The registrant and the event's notification recipients are told the outcome.
- Tickets are still issued, but the scanner blocks check-in until consent is given, showing why. Supervisors can override, and the override is recorded in the audit log. On a youth event, a ticket with no named attendee counts as missing consent.
- A daily 09:00 job reminds unanswered requests after 2 days, then every 3 days until the event. It also deletes medical and emergency details a set number of days after the event (Site Settings → Legal, default 30). `CONSENT_JOBS_ENABLED=false` turns it off.
- Supervisor → Parental consent page: consent status per attendee, resend one or all pending requests, re-request consent after the consent text changes, and a printable, confidential medical & emergency list. Every view of the list is recorded in the audit log.
- New reports: "Parental consent status" and "Medical & emergency list".
- My Events shows each attendee's consent status, with a "Resend consent" button.
- Anonymising or closing an account also clears that person's attendees' parent, date-of-birth, medical and emergency details.
- New endpoints under `/api/consent`. `GET /api/tickets/scan-preview/:qrCode` now returns `consentStatus`, and `POST /api/tickets/scan/:qrCode` accepts `overrideConsent`.
- Requires the `add_parental_consent` database migration.
## [1.14.0] - 2026-10-19
### Added
- Group tickets (named attendees). A new "Named attendees" event setting (event wizard → Visibility) lets someone registering several people give each main-ticket place a name, with an optional email and phone. It's offered on the registration page, Manual registration, At the door and the self-service kiosk.
- Each named person gets their own single-person ticket, with their name on the ticket PDF, the scanner's confirm screen and the staff ticket list. Places left unnamed share one quantity ticket as before.
- The payer chooses whether every ticket comes to them or each person gets their own by email/WhatsApp; anyone without contact details falls back to the payer.
- The payer stays the account owner for billing, invoices and payments.
- An attendee whose email or phone matches an existing account is linked to it (never when the email and phone belong to two different accounts). Their ticket then appears in their own My Events, marked "Booked by …", and they can open and submit their own attendee form.
- My Events → registration popup has a new "People" section for adding or changing names and contact details, switching ticket delivery, and sending an attendee a link to fill in their own form (`/forms?registrationId=…&attendeeId=…`). An attendee whose ticket has been scanned can't be removed.
- Attendee forms now show one block per named person, labelled with their name, and each response is saved against that attendee.
- New endpoints `PUT /api/registrations/:id/attendees` and `POST /api/registrations/:id/attendees/:attendeeId/send-form-link`. `POST /api/registrations` and `/manual` accept `attendees` and `ticketDelivery`.
- Requires the `add_named_attendees` database migration.
### Changed
- Reducing the quantity of a main ticket removes the newest names beyond the new quantity.
- Check-in confirmations for a named ticket go to that person when the registration sends each person their own tickets.
- Unauthenticated guest registration now matches an existing account by phone as well as email, and is refused (409) when the email and phone belong to two different accounts, instead of silently reusing whichever account the email matched.
- Free registrations now issue tickets through the same generator as paid ones, instead of a separate copy of the logic.
### Security
- Registration details (`GET /api/registrations/:id`) hide attendees' email addresses and phone numbers from anyone other than the payer and staff, such as an attendee opening their form link.
## [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.
## [1.9.1] - 2026-08-22
### Fixed
- The browser tab title and homepage "Welcome to..." heading always showed the app's built-in default name instead of the organisation name configured in Site Settings → Organisation.
- The backend's status page (`/`) and API docs page (`/docs`) always showed "Cross Code Events" instead of the configured organisation name.
## [1.9.0] - 2026-08-21
### Added
- Events now have an optional Location field (address), defaulting to the organisation's configured address when creating a new event. Wherever an address is shown — event admin form, public event page, event card listings, the Contact page, and Site Settings → Organisation — there's now a "Directions"/"View on map" link, and the event detail and Contact pages also show an embedded Google Maps view (no API key required).
## [1.8.0] - 2026-08-21
### Added
- Events can now be marked "contact-only" (e.g. baptism) — they appear on the public events list/detail pages with a "Contact us" button (opening a popup with name/phone/email) instead of a Register button, and have no ticket options or registration flow. Configurable from a new toggle in the admin event wizard's Basic Details step.
- Registration and payment notifications now include a branded PDF: registration confirmations attach an **invoice** (itemized breakdown, early-bird discount, balance due, and — when there's an outstanding balance — a clickable "pay now" link and QR code pointing at a Yoco checkout) whenever the registration isn't fully paid, and payment/donation confirmations attach a **payment receipt** (itemized breakdown plus the amount paid on that transaction). Sent as an email attachment and, over WhatsApp, as the PDF itself (with the existing message text as its caption) in place of the previous text-only send. Both documents pick up the org's configured name/logo/brand color from Site Settings → Branding.
- The user dashboard's registration detail popup now has an "Invoice" button and, next to each payment, a "Receipt" button — both pop up a small Email/WhatsApp choice and (re)send that document on demand, via two new endpoints (`POST /api/registrations/:id/send-invoice`, `POST /api/payments/:id/send-receipt`), restricted to the registration/payment's own owner. The invoice works regardless of balance, showing "Paid in full" when nothing is owed. The Payment history page (`/dashboard/user/payments`) got the same per-payment "Receipt" button too.
### Fixed
- The user dashboard's "(early bird)" label was a price-comparison heuristic — it fired on any line priced below the option's base price, including plain cheaper variants that were never actually early-bird tickets. It now checks the real applied-tier flag the backend already tracks.
- Buying more of an already-purchased ticket type after its early-bird tier expired re-priced the *entire* line at the new price instead of adding the new quantity at the new price (e.g. 5 tickets @ R50 + 1 more after the price rose to R100 came out to R600 instead of R350). Each purchase now gets its own price "tranche" recorded against the registration line, so previously-bought tickets keep their original price and only the newly added quantity uses the current price. Registration/reporting pages that showed a single blended price per line now render (or total) each tranche separately.
- An unpaid (or partially paid) registration's price only ever got refreshed when a payment was actually attempted — an early-bird tier that expired while tickets sat unpaid kept showing its old, no-longer-honoured price (and its "(early bird)" tag) indefinitely on the dashboard until the user tried to pay. Viewing a registration (dashboard, registration detail, or an event's registration list) now refreshes still-outstanding pricing on the spot, same as payment already did.
- The user dashboard's registration detail popup listed one line per price tranche in raw creation order, so a ticket bought across several separate registrations (e.g. some early-bird, some not) showed as a wall of near-duplicate lines. It now merges tranches with the same item/price/tier into one line and groups early-bird lines together, separately from standard-price lines.
- Registration confirmation, payment/donation, and reminder emails and WhatsApp messages computed "Total due"/"Balance" by loading a registration without its price tranches, so any line spanning more than one tranche (e.g. some tickets bought at the early-bird price, more added later at full price) fell back to charging the *entire* quantity at the most recent tranche's price — silently wiping out the early-bird discount from the outstanding balance shown to the user. These sends now load tranches and total each line the same tranche-aware way the dashboard already did; the itemized line amounts in those messages (which previously always showed the option's undiscounted base price) are now correct too.
- Editing an event option's early-bird tiers (even just tweaking one tier's deadline or price) deleted and recreated *every* tier for that option with brand-new ids, including tiers the admin didn't touch. Since past purchases point at a specific tier by id, this silently severed that link on every edit — wiping the "early bird" attribution (and locked-in stock-limit counts, risking oversold tiers) off historical registrations that were never meant to change. Tiers are now upserted by id, so their identity — and everything referencing them — survives an edit; a tier is only ever removed if it's no longer in the saved list *and* has no purchase history against it.
## [1.7.0] - 2026-08-20
### Added
- Site Settings → Branding now supports a full Primary/Secondary/Accent brand color system (previously a single accent color that only recolored the org name in the navbar) applied site-wide — buttons, nav highlights, hover states, and links — plus a favicon upload alongside the existing logo upload, and a live preview panel (with website/email and desktop/mobile views) that updates as colors are picked, before saving. Selecting a logo now also suggests brand colors extracted from it. Outgoing emails' header and call-to-action buttons now pick up the Primary color too, while semantic alert colors (success/warning/danger) stay fixed. The setup wizard's Branding step got the same preview and color-suggestion treatment.
### Fixed
- The setup wizard's logo/favicon upload was silently failing — by the time it ran, the admin account already existed and the upload endpoint required admin auth, but the request never attached the token. It now does, and a failed upload surfaces as a visible error instead of quietly leaving branding unset.
- A custom favicon saved via Site Settings never actually showed in the browser tab: the site had a static `favicon.ico` in the special Next.js `app/` convention path, which generates its own `<link rel="icon">` tag that always took priority over the admin's saved one. Moved the static file to `public/` so it's just the default fallback, not a competing tag.
- Every unconfigured-organisation default across the app (org name, email subjects, WhatsApp messages, report file metadata, backend status/API-docs pages, `From:` fallback address) said "Hope Events"/"Hope Family Church" instead of a neutral placeholder — now defaults to "Cross Code" (the platform vendor) consistently. Several email subject lines (login/welcome/activation/password-changed/account-closed) were also fully hardcoded regardless of the configured org name; they now interpolate it dynamically like the email body already did.
- The optional legal settings (operator name, Information Officer name/email, website URL, effective date, and the Legal pages' own email fallback) defaulted to this project's real founder's name/email/church details — actively misleading if ever shown on another organisation's deployed site before they're configured. These now default to obviously-generic placeholders (e.g. `example.com`, `[not yet configured]`) instead.
- Added `SETTINGS.md`, a repo-root reference documenting every setting's default behaviour when left unconfigured.
## [1.6.0] - 2026-08-08
### Added
- Supervisors can now unassign a donation that was previously applied to a registration, from a new "Assigned donations" list on the Payments page. This reverses the allocation (the registration's balance goes back up and the donation becomes available again), reverting the registration's status and revoking any tickets issued only because that allocation completed payment — blocked if a ticket has already been scanned. The registrant is notified by email/WhatsApp, mirroring the notification sent when a donation is first applied. The list can be searched (registrant, donor, or event) and filtered by event.
### Fixed
- The internal admin notification for a donation applied to a registration read "Payment recorded" / "Registration payment", indistinguishable from a real incoming payment even though no new money changed hands — it now says "Donation applied" throughout, matching the registrant-facing email's distinct wording.
### Performance
- Reconciling a Yoco card payment or sending a payment link on the supervisor Payments page no longer blocks the response on ticket-PDF generation and email/WhatsApp sends — these now run in the background, matching how manual payments already worked.
- Registration creation and payment capture now resolve per-option pricing, stock checks, and ticket generation concurrently instead of one option at a time.
- The supervisor Payments page no longer fires one request per registration to compute outstanding balances (that data was already included in the registrations response); post-action refreshes also run in parallel instead of sequentially.
- Added indexes for `Registration(createdAt, status)`, `Ticket(createdAt)`, and `Payment(originalPaymentId, isDonation)` to speed up dashboard stats and donation-leg lookups. `GET /api/registrations` now supports optional `page`/`limit` pagination.
## [1.5.4] - 2026-08-07
### Fixed
- The homepage "Join Us" button sent logged-in users to the registration page instead of somewhere useful. It now shows "My Dashboard" and links to their dashboard when a user is already logged in.
## [1.5.3] - 2026-08-07
### Fixed
- Help guide popups could render taller than the screen on mobile, with no way to reach the close button or "Got it" button: only the tab content area had a height cap, so the header, quick links, and footer weren't accounted for. The whole popup is now capped to the screen height, with just the tab content scrolling internally.
## [1.5.2] - 2026-08-07
### Fixed
- Stat card labels on the Admin and Supervisor dashboards (e.g. "Registrations (past month)") were getting cut off mid-word on mobile, where a cramped 2-column grid left too little room for the text. The mobile layout now shows one card per row, and labels wrap onto two lines instead of truncating.
- The "Revenue trend" and "Top performing events" cards on the Admin and Supervisor dashboards could render outside the viewport on mobile: a wide events table (long titles, four columns) forced the containing grid column past the screen width instead of scrolling internally. Long event titles are now truncated in the table, and the page properly contains horizontal overflow.
- The navbar Logout button sat about 2px lower than the other nav links (Home/Events/Contact/Dashboard) because it was missing the same underline-spacing classes those links use.
### Added
- Tab/mode switcher buttons on the Payments, At the door, Manual registration, Email attendees, WhatsApp attendees, and Cashup detail pages now show icons, matching the icons already used for the same tabs in each page's help guide.
## [1.5.1] - 2026-08-06
### Fixed
- Scheduling a WhatsApp message was silently sent as an email instead: the scheduled-job store never persisted the `channel` field, so the send worker always fell through to its email branch regardless of what was requested. Scheduled WhatsApp jobs now correctly send via WhatsApp.
- Scheduled emails/WhatsApp messages showed no content when viewing or editing them in "Manage scheduled", even though the message existed in storage: the list endpoint never returned the message body (`html`/`text` for email, `message` for WhatsApp), and editing a scheduled WhatsApp message saved to the wrong payload field (`text` instead of `message`), so edits were silently lost. Both are now fixed, and the email list also shows a body preview like the WhatsApp one already did.
### Added
- Scheduled emails/WhatsApp messages are now automatically purged from storage 24 hours after they're sent, instead of accumulating indefinitely.
- The "Manage scheduled" lists on the Email Attendees and WhatsApp Attendees pages now show who each scheduled job will be sent to, and each tab only shows jobs for its own channel (previously both tabs showed the same unfiltered list).
## [1.5.0] - 2026-08-06
### Added
- Site redesign, phase 3: the fixed indigo/purple brand palette is now applied consistently across the whole site — every remaining page that still used the old ad-hoc indigo/blue colors (home, events, registration flow, auth pages, legal pages, payment result pages, and every Admin/Supervisor/Staff/User tool page) now uses the same design tokens as the redesigned dashboards. Deliberate exceptions (status badges like "Confirmed", multi-option selectors like Email/WhatsApp/Both, the WhatsApp role badge) were left alone since they're meaningfully distinct colors, not leftover brand color.
- New help guides for account pages (login/register/forgot-password) and My Payments/Donate, replacing the generic fallback.
- Site redesign, deep pass: beyond the phase 3 color sweep, the home page, events list, and event detail page were rebuilt with real layout upgrades (hero section, icon-chip headers, image placeholders, a sticky two-column tickets layout on event detail). Every Admin/Supervisor/Staff/User tool page now has an icon-chip header consistent with the dashboards, and the smaller public/auth pages (Login, Register, Forgot Password, Activate Account, Reset Password, Site Banner, Registration Success, Payment Success/Failure/Cancel, the standalone Attendee Forms page) were restyled to match, including wrapping the previously bare Attendee Forms page in the site's Navbar/Footer for the first time.
- 17 new dedicated help guides were added (home, events, event detail, and every remaining Admin/Supervisor/Staff/User tool page), so the whole site now has page-specific help content instead of falling back to the generic guide.
- Site redesign, phase 1 (foundation): fixed indigo/purple design tokens wired into Tailwind and the previously-unstyled shadcn components (Button, Card, Badge, Table, Sheet, etc. were silently missing their CSS variables); a site-wide floating "Need help?" button, present on every page, showing a contextual guide with quick links (e.g. "Browse events", "View my tickets") — content is resolved per page via a new registry, with a general fallback (how to register / manage tickets) for any page without dedicated content yet.
- Dashboard sidebar (My Events, Profile & Security, Admin, Site Settings) now also shows on `/dashboard/user/profile` and `/dashboard/admin/settings`, not just each role's root page, and is now fixed in place so it no longer scrolls with page content.
- New shared components (`StatCard`, `QuickActionTile`, `AreaTrendChart`) scaffolded for the upcoming dashboard redesign.
- Contact now has its own dedicated page (`/contact`) instead of being a scroll-to section on the home page — same org email/phone/address, restyled to match the new design.
- The general (public) and My Events help guides now go into much more detail: the general guide explains guest vs. account-required registration with quick links to log in or create an account, and the My Events guide walks through registration status badges, attendee forms, early-bird pricing, editing/cancelling, bulk ticket actions, and the difference between paying a balance, payment history, and donations. The Admin, Supervisor, Staff, and Site Settings pages now have their own dedicated help guides too, instead of falling back to the general one.
- Site redesign, phase 2 (dashboards): Admin and Supervisor dashboards now show a KPI row (active events, revenue, donations, registrations, tickets sold — each with a "vs last month" comparison), a revenue trend chart for the current month, and a top-performing-events table, backed by a new `GET /api/stats/overview` endpoint. Staff and My Events dashboards are visually reskinned onto the new design tokens only — no new financial data for Staff, by design.
- Admin/Supervisor dashboard stat tiles now link somewhere useful: Revenue/Donations/Tickets sold jump straight into the matching Reports report, pre-filtered to this month (new `?report=&range=this_month` deep-linking support in Reports); Active events links to Manage events.
- Reports revenue trend chart now shows gridlines, axis labels, and a marker dot per data point, so a sparse day-or-two of data still renders as a visible chart instead of an empty-looking box.
- WhatsApp API management moved from its own page into a new "WhatsApp" tab on Site Settings (`/dashboard/admin/settings?tab=whatsapp`); the old `/dashboard/admin/whatsapp` route now just redirects there.
- Site Settings restyled to match the rest of the redesign (icon tab bar, card layout, brand colors).
- My Events dashboard now shows event thumbnails on registrations, upcoming events, and tickets, with upcoming events and tickets laid out side by side.
- Profile & Security page restyled into a two-column layout with a new "Account activity" section showing recent logins and password changes (device + timestamp), backed by a new append-only `SecurityEvent` log and `GET /api/users/activity` endpoint.
- Help guide modal now animates in instead of appearing abruptly, and got a general visual polish pass (rounded corners, active-tab styling, hover states).
### Fixed
- Admin/Supervisor dashboard "Revenue" figures (today/week/month) were overcounting: they didn't exclude donation-application "legs" (the money was already counted once via the original donation) the way Reports and the admin payments-stats endpoint already did. Both endpoints now use the same exclusion.
- Admin/Supervisor dashboard "Revenue" KPI, trend chart, and top-performing-events table were undercounting the other way — donations themselves weren't being counted as revenue at all, only tracked in the separate "Donations" figure. Donations now count toward revenue (donation-application legs are still excluded either way, since that money was already counted once via the original donation).
- Admin/Supervisor dashboard KPIs (revenue, donations, registrations, tickets sold) used a calendar-month-to-date window, which under-counted for most of the month (e.g. only 3 days' worth of data on the 3rd). They now use a rolling trailing-month window instead — today back one calendar month, e.g. 9 May through 8 June if today is 8 June — compared against the equal-length month before that. The "View report" links from those tiles now deep-link into the same trailing-month range.
- Admin dashboard was missing the "At the door" quick action that Supervisor already had (walk-in registration, payments, and ticket printing at the door) — added.
### Changed
- Reports' "Reporting guide" is now powered by the new site-wide help system instead of a Reports-only modal — same content, opened from the floating help button instead of a sidebar button.
- Navbar/bottom nav link colors are now fixed to the site's indigo brand color instead of the admin-configurable accent color; the accent color setting now only affects the org name text next to the logo.
- "Contact" links across the nav and help content now point to `/contact` instead of `/#contact`.
- Admin/Supervisor dashboard quick actions no longer list "Manage sections" or "Event tickets & printing" (redundant with the Events and At-the-door pages) or "Manage WhatsApp API" (moved into Site Settings). Staff keeps "Event tickets & printing" — their only other tool is ticket scanning, and they can't reach At-the-door.
- Admin dashboard no longer shows ticket-scanning stats ("Recent scans") — kept on Supervisor/Staff, where it's actually actionable.
- The old hand-rolled `Button` component (`components/shared/Button.tsx`) is gone — its one remaining caller now uses the standard `components/ui/button.tsx`.
- The site-wide help guide no longer opens itself automatically on first visit to a page — it only opens when the help button is clicked. The now-pointless "Don't show this again" checkbox was removed along with the per-page dismissal tracking it drove.
### Removed
- Deleted dead code found while migrating the last `Button` usage: an unused `EventForm` component, a stale pre-redesign `Reports.tsx`, and a stray `ReportsV2_backup.tsx` — none were imported anywhere.
## [1.4.2] - 2026-08-06
### Fixed
- Supervisor Payments page: the mode tabs (Payment/Refund/Donations/Reconcile/Payment Link) now wrap onto multiple lines on mobile instead of overflowing off-screen and becoming unreachable.
- Reports: the payments report and its "by method" breakdown/chart now bucket into the same 4 categories used everywhere else (Cash, Card, EFT, Other) instead of showing raw method strings — Apple Pay, Google Pay, and Yoco checkout-portal payments now fold into "Card".
- Cashup/Finance/Profit reports: refunds were being dropped entirely from the per-method breakdown (`paymentsByMethod`) instead of netting against the method they were refunded against, so e.g. a card refund silently vanished instead of reducing the "Card" total — the per-method figures now correctly sum back to total revenue.
- Refund method now nets against the correct bucket everywhere a payment's method is normalized for display/filtering (My Payments page, reports) — `card-refund` etc. was falling through to "Other" instead of being recognized as a refund of its base method.
- Supervisor Payments page: the Refund form's method dropdown now mirrors the actual payment methods (Cash/Card/EFT/Voucher refund) instead of offering an ambiguous generic "Refund" option that couldn't be attributed to any method bucket; it now auto-fills from the original payment's method when refunding a specific payment.
- Donations: refunding a donation (fully or partially) was inflating its remaining/unallocated balance by the refunded amount instead of reducing it, since the refund's negative amount was subtracted straight into the balance (subtracting a negative adds). Could let staff over-allocate a donation that had actually shrunk. Fixed in the donation-assignment leg totals used by the assign-donation endpoint, the Cashup/Finance/Profit reports' unallocated-donations figure, the Donations and Master Orders reports' Used/Unused breakdown, and the "Assign donation" panel on the Supervisor Payments page.
### Added
- Supervisor Payments page: reconciling a Yoco transaction as a donation now lets staff optionally pick who the donation is from, instead of it always being attributed to whatever the checkout metadata (or the reconciling staff member) happened to resolve to.
## [1.4.1] - 2026-08-05
### Fixed
- Reports: on mobile, the report popup's close button now sits pinned beside the title instead of getting cut off inline with the Print/Email/Excel/WhatsApp buttons, which now have their own wrapping row below.
- Reports: on mobile, the page header's search box and Back button no longer run off-screen — they now sit on their own row and shrink to fit.
- Reports: the sidebar's custom date range inputs (From/To) are now stacked instead of side-by-side, fixing them overflowing outside the filter box on desktop.
## [1.4.0] - 2026-08-04
### Added
- Admin Manage Users page: notification preference (Email/WhatsApp/Both) can now be viewed and edited directly from the user list, instead of only being self-editable from the user's own profile.
- Admin Registrations dashboard: added aggregate stat tiles (counts per status, total revenue, total outstanding) and per-registration paid/outstanding amounts, plus a "Payments" detail block per registration showing each payment's amount, method, date, and who recorded it.
- Reports: registration status breakdown now has a "Count by" toggle to switch between counting one per registration and counting by ticket quantity (so a registration with 3 tickets counts as 3).
- Reports: donations breakdown now shows Used/Unused amounts per event, reflecting the new donation-leg tracking below.
- Payment accountability: payments now record who recorded them (`recordedBy`), separate from who they're for. Self-service/webhook payments record the payer as the recorder. Surfaced across the payments report, supervisor payments page, and the registrations dashboard.
- Cashup: new "Payment accountability by staff member" section breaking down recorded payments per staff member for an event, by method (Cash/Card/EFT/Other) — cash also shows a live actual-vs-expected variance once staff enter physical denomination counts per person.
- Cashup: staff can now enter each other's actual cash denomination counts at any time (not required to close the event); the event's cash "actual" figure is the live sum of these per-person counts instead of one manual event-wide entry.
- Cashup: new "Report" tab presenting a clean read-only summary of the cashup, which opens automatically once an event is closed.
- Reports: complete redesign — a sidebar of universal filters (events, date range with presets, include past/inactive/closed events) that apply across whichever report is open, a searchable/categorized report card grid, and a popup viewer with report-specific filters and Print/Email/Excel/WhatsApp export actions. Includes a new in-app Reporting Guide.
- Reports: WhatsApp added as an export channel alongside Print, Email, and Excel — sends the report PDF to the current user's own WhatsApp number.
- Reports: visual pass on the report popup — colored stat tiles for key totals (Order Total, Paid, Paid via donations, Outstanding, Unassigned donations, etc.) and small bar charts (revenue by method, registration status, ticket usage, donations used/unused, registration types, income by method, net profit by event) added to most reports, plus search boxes on the Master Orders' Orders/Donations tables. The Reports page also hides the dashboard sidebar since it's a full-width workspace of its own.
- Reports: Master Orders Breakdown moved from "Orders" into the "Registration" category.
- Reports: the Reporting Guide's non-Overview tabs now use the same icon-card layout as Overview instead of plain bullet lists; "Need more help?" now points to the site administrator's email (admin@crosscode.co.za).
- Email/WhatsApp Attendees: new `{{payment.link}}` placeholder that generates a live, per-recipient Yoco payment link for their outstanding balance at send time.
- Reports: PDF exports (download, Print, Email, WhatsApp) now share a branded design matching the web report — a colored title band, the same colored stat tiles and bar chart shown on screen (where applicable), an explanatory note box, and a highlighted totals row in tables — instead of a plain black-and-white dump. Since Email and WhatsApp already reuse the same PDF renderer, both now send this styled PDF automatically.
- Reports: the "Excel" export now downloads a styled `.xlsx` workbook (colored header, stat rows, a chart rendered with a native data-bar, a bold colored table header, a highlighted totals row, and frozen header/auto-sized columns) instead of a plain CSV.
### Changed
- Donations are no longer mutated when assigned to a registration — assignment now creates an immutable "leg" record referencing the original donation, so a partially-used donation keeps its original amount and remains assignable for its remaining balance instead of losing its history.
- Email Attendees / WhatsApp Attendees now respond immediately after queuing recipients and send in the background, instead of blocking the page until every message has been sent.
- Dashboard: the sidebar (My Events, Profile, Admin, etc.) is now hidden on every sub-page under Staff, Supervisor, and Admin — not just Reports — so those pages get the full width for their own content and navigation. It's still shown on each role's root landing page (`/dashboard/staff`, `/dashboard/supervisor`, `/dashboard/admin`); the top navbar's "Dashboard" link always leads back there.
### Fixed
- Reports/Cashup: fixed several places where money was double-counted once a donation was partially or fully assigned to a registration (e.g. a R250 donation with R50 assigned was showing as R300 received). Payments between dates, Revenue summary, Cashup reconciliation, Finance report, and Profit report now count each real inflow exactly once.
- Reports: Revenue Detailed and Master Orders Breakdown no longer attribute a donation-funded portion of an order to the registrant as if they'd paid it themselves — "Paid" now reflects only what the person actually paid directly, with the donation-covered amount broken out separately and attributed to the donor.
- Reports: Finance report was double-counting a registration's ticket value once under "what was sold" and again under a separate "Donations" line when the order was funded (even partially) by a donation.
- Reports: Master Orders Breakdown's "Donations made" table (donor, amount, used/unused) is now included in the PDF, Excel, Email, and WhatsApp exports — previously only the Orders table was exported and the donations breakdown was visible on screen only. Also fixed a PDF rendering bug where a table title following another table (e.g. "Donations made" below the Orders table) could render at the page's right edge instead of the left margin.
- Reports: "Email" export failed with `connect ECONNREFUSED 127.0.0.1:587` — it built its own mail transporter directly from `EMAIL_HOST`/etc. env vars instead of using the shared, DB-configurable SMTP settings (Admin → Site Settings) that the rest of the app already sends through, so it never picked up a working mail server. Now reuses the same shared mailer as tickets and account emails, with matching branded HTML styling.
- Reports: "WhatsApp" export surfaced an unhelpful `Request failed with status code 500` on failure. WhatsApp send errors now report the actual reason from the WhatsApp API, and a disconnected WhatsApp session is now detected and auto-recovered the same way it already is for other WhatsApp actions (previously only ticket/text sends had this handling — PDF sends did not).
- Dashboard mobile: the Reports page's Events filter dropdown and the Email/WhatsApp Attendees recipient-picker dropdown could extend past the right edge of narrow screens instead of staying within the viewport; they now stretch to match their trigger's width like the other dropdowns in the app already do. The Cashup event-costs table on the Admin Cashup detail page now scrolls horizontally on narrow screens instead of squeezing its columns.
## [1.3.2] - 2026-08-03
### Added
- Self-service kiosk: all password fields (supervisor sign-in, change event, and the visitor "Choose a password" field) now have a show/hide toggle button, so staff can verify what they've typed on the touchscreen instead of typing blind.
### Fixed
- Self-service kiosk: removed the separate "Look up existing account" search field — for privacy, staff no longer type a visitor's email/phone into a dedicated search box. Instead, entering an email or phone number in the registration form itself (Email and Cell Number are now the first two fields, followed by Name) automatically checks for a matching account once that field is left.
- Self-service kiosk: matched accounts are no longer updated silently. If the operator's typed Name, Email, Cell Number, or "Send tickets via" preference differs from what's on file, a confirmation dialog now lists exactly what will change (old value → new value) and requires the operator to confirm before the account is updated.
- Self-service kiosk / manual registration: an existing account's name is now actually updated when confirmed changed (previously silently discarded), and email/phone corrections are applied even when the account already had a real value on file (previously only blank phone numbers or guest-placeholder emails could be replaced).
- Self-service kiosk: fixed a bug where changing the phone number to one belonging to a different account would silently replace the Name/Email fields with that other account's details, and re-editing the email back to the original value afterward would not re-check it — together this could result in a registration being (or looking like it would be) saved under the wrong account. Email and phone matches are now tracked independently; if they resolve to two different existing accounts, the kiosk shows a clear warning naming both accounts and blocks registration until the operator corrects one of the fields, instead of silently merging or overwriting details.
- Manual registration API: added a server-side check, independent of the kiosk UI, that rejects (`409`) a registration whose submitted email and phone number belong to two different existing accounts — a defense-in-depth safeguard against one account's contact details being overwritten with, or hijacked by, another's.
- Manual registration API: an existing account's notification preference is now validated against its final email/phone after any confirmed update (e.g. falls back off "WhatsApp"/"Both" if no valid phone remains, or onto "WhatsApp" if the email was cleared in favor of a real phone), instead of persisting a preference that no longer matches the account's actual contact info.
- Self-service kiosk: tapping anywhere else on the page (e.g. a ticket quantity +/− button) while Email or Cell Number was focused blurred that field and silently re-ran its account lookup; even though the match hadn't changed, this reset Name/Email/Phone/preference back to the matched account's original values, discarding any edits the operator had just made. The autofill now only applies once per distinct matched account instead of on every re-check.
## [1.3.1] - 2026-07-28
### Fixed
- Self-service kiosk: closed events no longer appear in the event picker — only open events are selectable.
- Self-service kiosk: added a "Look up existing account" search field (by email or phone, triggered only by Enter or the Search button — never as-you-type) that autofills a returning visitor's name, contact details, and notification preference from their exact matching account, instead of requiring staff to re-enter details already on file.
- Self-service kiosk: when the account lookup finds no match, the typed query now carries over into whichever of Email/Phone it resembles (instead of being discarded), while Name and everything else resets blank for a fresh entry.
## [1.3.0] - 2026-07-27
### Added
- At The Door: new "Check-In" tab (before "Tickets" in the tab order) for quickly redeeming a registration's Main Tickets by quantity (e.g. checking in 2 of 3 people on a booking) without scanning each QR code individually. Like the Payment tab, it only ever shows the currently opened registration — reached via the "Open" button, not a general search.
- Checking in a Main Ticket (via the new Check-In tab or the existing QR camera scanner) now automatically sends the ticket holder a confirmation email and WhatsApp message (respecting their notification preference) stating how many were checked in and how many remain.
### Changed
- At The Door: the "Open" button on a registration now jumps to the Payment tab only when a balance is still outstanding; fully paid registrations jump straight to the new Check-In tab instead.
- At The Door: recording a payment that fully settles a registration's balance now jumps straight to the Check-In tab instead of generating and print-previewing a paper ticket — tickets are already emailed/WhatsApped to the attendee automatically once the registration is paid, so a physical print is no longer forced on this path.
## [1.2.0] - 2026-07-27
### Added
- Supervisor dashboard: `/dashboard/supervisor/manual` now has a "Record Payment" tab (alongside "Register"), for capturing a payment without leaving the page. It pre-fills with the user and registration from the most recently created manual registration, but only switches tabs when a supervisor/admin clicks it themselves.
@@ -55,7 +540,12 @@ and this project follows [Semantic Versioning](https://semver.org/).
- Initial release of the Hope Family Church event management app (Next.js frontend + Express/Prisma backend).
[Unreleased]: https://git.crosscode.co.za/joshua/hope-events/compare/v1.1.0...main
[Unreleased]: https://git.crosscode.co.za/joshua/hope-events/compare/v1.4.0...main
[1.4.0]: https://git.crosscode.co.za/joshua/hope-events/compare/v1.3.2...v1.4.0
[1.3.2]: https://git.crosscode.co.za/joshua/hope-events/compare/v1.3.1...v1.3.2
[1.3.1]: https://git.crosscode.co.za/joshua/hope-events/compare/v1.3.0...v1.3.1
[1.3.0]: https://git.crosscode.co.za/joshua/hope-events/compare/v1.2.0...v1.3.0
[1.2.0]: https://git.crosscode.co.za/joshua/hope-events/compare/v1.1.0...v1.2.0
[1.1.0]: https://git.crosscode.co.za/joshua/hope-events/compare/v1.0.1...v1.1.0
[1.0.1]: https://git.crosscode.co.za/joshua/hope-events/compare/v1.0.0...v1.0.1
[1.0.0]: https://git.crosscode.co.za/joshua/hope-events/releases/tag/v1.0.0
+491
View File
@@ -0,0 +1,491 @@
# 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.
- **Group tickets** — on events with "Named attendees" turned on, someone booking
several places can name each person (with optional email/phone). Each named person
gets their own ticket and their own attendee form; unnamed places share one ticket.
Tickets go all to the payer, or each to the person (falling back to the payer).
Attendees matching an existing account see their ticket in My Events. Names can be
added or changed later from My Events, and attendees can be sent a link to fill in
their own form. Available on the registration page, manual registration, At the
door and the kiosk.
- **Waitlist** — on events with a waitlist, sold-out tickets show "Join waitlist"
(on the event page, the register page and the event list). When places free up — a
cancellation, a smaller booking or a higher stock limit — the next person is
emailed/WhatsApped a claim link and the places are held for them (24 hours by
default) so nobody else can buy them. Unclaimed offers expire and move on; people
who asked for more than came free can be offered what's free or skipped. Claiming
works without logging in. People can leave from My Events.
- **Parental consent (youth events)** — on events that require it, every attendee
gives a date of birth; for anyone under 18, a parent or guardian gets an email and
a WhatsApp with a consent link. They read the event's consent text, optionally give
medical info, an emergency contact and photo consent, type their name to sign, and
give or decline. Tickets are issued but can't be checked in until consent is given
(supervisors can override, audited). Reminders go after 2 days, then every 3 days.
Medical details are encrypted and deleted a set number of days after the event.
- **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).
- **Named tickets** — group bookings can give each person their own ticket, shown
with their name on the PDF, the scanner and the staff ticket list.
- **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.
- **Offline scanning** — while online, a staff member locks an event section (or the
whole event) to their device and downloads its tickets. They can then keep scanning
with no connection; scans are queued on the device and sync automatically when it's
back online (never redeeming more than is left). Only one device can hold a section,
and while it's locked nobody else can scan it, online or offline. Problems found
while syncing go into a report for a supervisor. Supervisors can force-release a
lost device. The scanner can be added to the home screen (PWA) and opens offline.
- **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.
- **Ticket transfer** — hand a ticket to someone else before the event (name plus
email/phone). They get a new ticket and QR code, and an inactive account with an
activation link if they're new. The old code stops working, and scanning it says it
was transferred, both online and in offline-mode sync reports. One place can be split
off a ticket covering several people. Billing stays with the payer. It's not allowed
once a ticket has been scanned, and it can be turned off per event (supervisors can
always transfer, from At the door → Tickets). On youth events the new holder needs
their own parental consent, and the previous holder's form answers are removed.
Every transfer is in the audit log.
- **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.
- **Offline scanning** (`/dashboard/supervisor/offline-scans`) — devices holding
offline locks (with force release) and sync reports to review.
- **Waitlist** (`/dashboard/supervisor/waitlist`) — the queue per event: offer a
place now, move people up or down, or remove them.
- **Parental consent** (`/dashboard/supervisor/consent`) — consent status per
attendee on youth events, resend (one or all pending), re-request after the
consent text changes, and a printable medical & emergency list (audited).
- **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) · Parental consent status · Medical & emergency list |
| 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 |
| Waitlist: expire unclaimed offers, offer freed places | Every 10 minutes (`WAITLIST_JOB_ENABLED=false` to disable) |
| Parental consent reminders, and deleting medical details after events | 09:00 daily (`CONSENT_JOBS_ENABLED=false` to disable) |
| 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 |
| `/consent/[token]` | Parent/guardian | Give or decline parental consent |
| `/waitlist/claim/[token]` | Public | Claim a waitlist place |
| `/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`, `/waitlist`, `/consent`, `/offline-scans`, `/email-attendees`, `/whatsapp-attendees`, `/forms`, `/reports`) | Supervisor | Supervisor tools |
| `/dashboard/admin` (+ `/users`, `/registrations`, `/forms`, `/cashup`, `/cashup/[id]`, `/audit-log`, `/settings`) | Admin | Admin tools |
+2
View File
@@ -111,6 +111,8 @@ Documentation: see Frontend README for environment and scripts.
- Backend API Documentation: ./backend/API_DOCUMENTATION.md
- Backend README: ./backend/README.md
- Frontend README: ./frontend/README.md
- Site Settings — defaults reference: ./SETTINGS.md
- Feature overview: ./FEATURES.md
## Recent Changes
+88
View File
@@ -0,0 +1,88 @@
# Site Settings — Defaults Reference
Every setting below is stored as a row in the `AppSetting` key-value table and
is editable at runtime via **Admin → Site Settings** (or, for the first few,
the `/setup` wizard on a fresh install). None of them are required — this
page documents exactly what happens when a given key has never been saved.
Settings changes take effect within 60 seconds everywhere (the backend's
in-process settings cache TTL) without a server restart.
## Organisation
| Setting | Default when unset | Notes |
|---|---|---|
| `org_name` | `"Cross Code"` | Falls back to `ORG_NAME` env var first, then this hardcoded default (the platform vendor's name, used as a neutral placeholder — `org_name` is a required field in the setup wizard, so this fallback is practically unreachable once a site has been set up). Shown in the navbar, email headers/subjects, and WhatsApp messages. |
| `org_tagline` | `"Connecting community through events"` | Falls back to `ORG_TAGLINE` env var first. Shown under the org name in the email header. |
| `org_email` | *(blank)* | No fallback in most places. **Exception:** the Terms of Use / Privacy Policy pages default to `privacy@example.com` (an obvious placeholder) if unset — see the Legal section below. |
| `org_phone` / `org_address` | *(blank)* | No fallback — simply omitted wherever shown if unset. |
| `app_base_url` | `APP_BASE_URL` or `FRONTEND_URL` env var, else `http://localhost:3001` | Used to build links in emails (password reset, ticket delivery, etc.). Should always be set in production. |
## Branding
| Setting | Default when unset | Notes |
|---|---|---|
| `primary_color` | Legacy `accent_color` value if present, else `#4F46E5` (indigo) on the website / `#1e3a5f` (navy) in emails | See [Two different fallbacks](#two-different-fallbacks-for-primary_color) below — this is the one setting with a genuine inconsistency worth knowing about. Drives buttons, links, nav highlights, and the email header/CTA. |
| `secondary_color` | `210 40% 96.1%` background / near-black text (light gray "wash", shadcn's stock theme) | The Branding tab's color picker *pre-fills* `#8B5CF6` as a suggested starting point, but that's only a UI default — if you never hit Save, the actual site uses the light-gray fallback, not violet. |
| `accent_color` | `210 40% 96.1%` background / near-black text (same light gray as Secondary) | Same story as Secondary — the picker pre-fills `#EC4899` (pink) as a suggestion only. |
| `logo_url` | Bundled logo (`frontend/src/app/church_logo.jpg`) | Shown in the navbar. This is still the original Hope Family Church artwork checked into the repo — swap the file (or upload a real logo via the Branding tab) before deploying for a different organisation. |
| `favicon_url` | `frontend/public/favicon.ico` (the site's original generic favicon) | Shown in the browser tab. |
### Two different fallbacks for `primary_color`
If `primary_color` has *never* been saved (no admin has ever used the new
3-color Branding tab) **and** there's no legacy `accent_color` value either,
the website and outgoing emails currently disagree on what "no color" means:
- **Website** (`globals.css` / `tailwind.config.js` static defaults): `#4F46E5`, a bright indigo.
- **Emails** (`backend/src/utils/email.js` `getOrg()`): `#1e3a5f`, a dark navy — unless overridden by the `EMAIL_HEADER_COLOR` env var.
In practice this only matters on a brand-new install before the setup wizard
or Branding tab has been saved even once — the wizard always writes
`primary_color`, so this gap closes itself the moment setup completes.
## Notifications
| Setting | Default when unset | Notes |
|---|---|---|
| `reg_notification_emails` | `REGISTRATIONS_EMAIL` env var, else *(blank)* | If truly blank, no internal notification email is sent for new registrations/payments/donations — it's silently skipped, not an error. |
## Email delivery (SMTP)
| Setting | Default when unset | Notes |
|---|---|---|
| `smtp_host` | `SMTP_HOST`/`EMAIL_HOST` env var, else *(blank)* | If blank, the app **does not send real email** — it uses nodemailer's `jsonTransport`, which just logs the would-be email to the console. This is the single biggest "nothing is configured" gotcha — ticket/payment/password-reset emails silently don't go anywhere until SMTP is set up. |
| `smtp_port` | `587` | |
| `smtp_secure` | `false` | |
| `smtp_user` / `smtp_pass` | *(blank)* | Stored AES-256-GCM encrypted (key derived from `JWT_SECRET`). |
| `smtp_from` | *(blank)* | If blank, outgoing mail's `From:` address falls back to the literal `no-reply@crosscode.local`. |
## Legal (Terms of Use / Privacy Policy)
| Setting | Default when unset | Notes |
|---|---|---|
| `legal_operator_name` | `"[operator details not yet configured]"` | Deliberately a generic, obviously-incomplete placeholder rather than any real person's or organisation's name. Unlike `org_name`, these legal fields are optional and can realistically stay unset on a live site — defaulting them to Cross Code's (or anyone else's) real identity would misrepresent who's legally responsible for that deployment. |
| `legal_io_name` | `"[not yet configured]"` | Same reasoning as `legal_operator_name`. |
| `legal_io_email` | `"io@example.com"` | Rendered as a `mailto:` link, so the placeholder stays a syntactically valid address on the IANA-reserved `example.com` domain rather than a bracketed non-email string that would break the link. |
| `legal_website_url` | `"example.com"` | Same reasoning — used to build an `https://` link. |
| `legal_effective_date` | `"[not yet set]"` | Plain text only, no link — a bracketed placeholder is fine here. |
| `consent_medical_retention_days` | `30` | Youth events: medical and emergency details from parental consent forms are deleted this many days after the event ends (daily 09:00 job). The consent record itself — who agreed, when, to which text — is kept. Set under Site Settings → Legal. |
| `org_email` (as used on legal pages specifically) | `"privacy@example.com"` | Different fallback than `org_email`'s general default (blank) — also rendered as a `mailto:` link, so it needs to stay a valid-looking address. |
## WhatsApp
| Setting | Default when unset | Notes |
|---|---|---|
| `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 |
|---|---|---|
| `setup_complete` | `false` (treated as `"not true"`) | Controls whether `/setup` redirects to `/login` or runs the first-time wizard. Set to `"true"` automatically when the wizard finishes. |
+25
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,19 @@ WAWP_INSTANCE_ID=your_wawp_instance_id
DAILY_SUMMARY_ENABLED=true
SCHEDULED_EMAILS_ENABLED=true
SCHEDULED_EMAILS_INTERVAL_MS=30000
# Daily 09:00 parental-consent job: reminders, and deleting medical details after events
CONSENT_JOBS_ENABLED=true
# Waitlist: expire unclaimed offers and offer freed places (every 10 minutes by default)
WAITLIST_JOB_ENABLED=true
# WAITLIST_JOB_INTERVAL_MS=600000
# 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:
+164 -4
View File
@@ -18,6 +18,11 @@ Tokens are issued by `POST /api/users/login`.
- [Event Options & Variants](#event-options--variants)
- [Event Attachments](#event-attachments)
- [Registrations](#registrations)
- [Coupons](#coupons)
- [Parental consent](#parental-consent)
- [Waitlist](#waitlist)
- [Offline scanning](#offline-scanning)
- [Ticket transfer](#ticket-transfer)
- [Payments](#payments)
- [Tickets](#tickets)
- [Broadcasts (Email)](#broadcasts-email)
@@ -43,7 +48,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 +58,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 +82,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 +232,11 @@ 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`) |
| PUT | `/api/registrations/:id/attendees` | user+ | Replace the named attendees (`{ attendees: [{ id?, eventOptionId, variantId?, name, email?, phone? }], ticketDelivery? }`); owner or staff |
| POST | `/api/registrations/:id/attendees/:attendeeId/send-form-link` | user+ | Email/WhatsApp an attendee the link to their own form |
| 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 +248,16 @@ 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`.
**Group tickets:** on events with `allowNamedAttendees`, `POST /api/registrations` and `/manual` accept `attendees: [{ eventOptionId, variantId?, name, email?, phone? }]` (main-ticket places only, no more than the places bought) and `ticketDelivery: "payer" | "each"`.
- Each named attendee gets their own quantity-1 ticket; unnamed places share one quantity ticket.
- An attendee whose email/phone matches one existing account is linked to it, so the ticket also appears in that person's `GET /api/tickets/mytickets`.
- With `"each"`, tickets are emailed/WhatsApped to attendees directly, falling back to the payer.
- Form responses can carry `attendeeId`.
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 +280,117 @@ For unauthenticated (guest) registration on events with `requiresAuth: false`:
---
## Ticket transfer
| Method | Path | Auth | Description |
|--------|------|------|-------------|
| POST | `/api/tickets/:id/transfer` | payer, holder or supervisor+ | `{ name, email?, phone?, dateOfBirth?, parentName?, parentEmail?, parentPhone? }` (date of birth, plus parent details for a minor, on youth events) |
| GET | `/api/tickets/:id/transfers` | payer, holder or staff+ | Transfer history |
Rules:
- Only before the event starts, and only for tickets that have never been scanned.
- `Event.allowTicketTransfer` turns it off for non-supervisors.
What a transfer does:
- The new holder becomes the ticket's attendee and gets a new QR code. A shared ticket for several places has one place split off into its own ticket.
- An account is matched by email/phone, or created inactive with an activation link.
- The previous holder's attendee form answers are deleted, and the medical details on their consent are cleared. A minor on a youth event gets a new consent request.
Old QR codes:
- `scan-preview` and `scan` return `410 { code: "TICKET_TRANSFERRED" }`.
- Offline sync reports them as `transferred`.
- Each transfer is audited as `ticket_transferred`.
## Offline scanning
| Method | Path | Auth | Description |
|--------|------|------|-------------|
| GET | `/api/scan-locks?eventId=` | staff+ | Active offline locks for an event |
| POST | `/api/scan-locks` | staff+ | Lock `{ eventId, sectionId?, deviceId, deviceName? }` to this device (409 if the scope is held elsewhere) |
| GET | `/api/scan-locks/:id/snapshot?deviceId=` | staff+ | Tickets valid in the lock's scope (lock holder only) |
| POST | `/api/scan-locks/:id/sync` | staff+ | `{ deviceId, scans: [{ clientScanId, qrCode, qty, scannedAt }] }` — idempotent; returns accepted, duplicates, conflicts |
| DELETE | `/api/scan-locks/:id` | staff+ | Release (`{ deviceId }`, lock holder only) |
| POST | `/api/scan-locks/:id/force-release` | supervisor+ | Release a lost device's lock |
| GET | `/api/scan-locks/reports?eventId=&unreviewed=` | supervisor+ | Sync reports |
| POST | `/api/scan-locks/reports/:id/review` | supervisor+ | Mark a report reviewed |
How locks work:
- One device per scope. A whole-event lock (`sectionId` null) clashes with every section lock on that event.
- `POST /api/tickets/scan/:qrCode` now accepts `sectionId` and `deviceId`. It enforces the section on the server, and returns `423 { code: "SCAN_LOCKED" }` when another device holds a lock covering the ticket.
How syncing works:
- Synced scans never redeem more than is left on a ticket.
- Conflicts are recorded in the sync report: `not_found`, `cancelled`, `wrong_scope`, `already_used`, `partly_used` and `consent`.
- Check-in messages are sent after the sync.
- Locks, releases, force-releases and syncs are written to the admin audit log.
## Waitlist
Events with `waitlistEnabled` (plus `waitlistOfferHours`, default 24, and `waitlistPartialOffers`, default true) take a queue for sold-out lines.
| Method | Path | Auth | Description |
|--------|------|------|-------------|
| POST | `/api/waitlist` | optional | Join: `{ eventId, eventOptionId, variantId?, quantity }` (+ `guestName`, `guestEmail`, `guestPhone` when not logged in on a guest event). Only when the line can't cover the quantity |
| GET | `/api/waitlist/mine` | user+ | My active entries |
| DELETE | `/api/waitlist/:id` | user+ | Leave (an open offer moves on) |
| GET | `/api/waitlist/claim/:token` | public | What a claim link offers |
| GET | `/api/waitlist/events/:eventId` | staff+ | The queue for an event |
| POST | `/api/waitlist/:id/offer` | supervisor+ | Offer now (`{ quantity? }`), even over the stock limit |
| POST | `/api/waitlist/:id/move` | supervisor+ | `{ direction: "up" \| "down" }` |
| POST | `/api/waitlist/:id/remove` | supervisor+ | Take someone off |
How offers work:
- Places held for a live offer count as taken everywhere stock is counted: `availableCount`, `isSoldOut` and registration stock checks.
- Offers are made automatically when places free up: a cancellation, a smaller booking, or a raised stock limit.
- A job every 10 minutes (`WAITLIST_JOB_ENABLED`, `WAITLIST_JOB_INTERVAL_MS`) expires unclaimed offers and re-offers.
- Claiming means registering with `POST /api/registrations` plus `waitlistToken`. The token stands in for logging in, and the held places are released to the claimer.
- Manual registration and adding places to an existing booking are now checked against stock too.
## Parental consent
Youth events (`requiresParentalConsent`, plus `consentText`, `consentCollectMedical`, `consentCollectEmergency` and `consentCollectPhoto` on the event) need every main place named with a `dateOfBirth`. Attendees under 18 on the start date also need `parentName`, `parentEmail` and `parentPhone`. Changing `consentText` bumps `consentTextVersion`.
| Method | Path | Auth | Description |
|--------|------|------|-------------|
| GET | `/api/consent/:token` | public | What the parent sees: attendee, event, consent text, fields to fill in |
| POST | `/api/consent/:token` | public | `{ decision: "give"\|"decline", signedName, isGuardian: true, medical?, emergency?, photoConsent? }` |
| GET | `/api/consent/events/:eventId` | staff+ | Consent status per attendee, with counts |
| POST | `/api/consent/requests/:id/resend` | user+ | Re-send one request with a new link (registrant or staff) |
| POST | `/api/consent/events/:eventId/resend-pending` | supervisor+ | Re-send every pending request |
| POST | `/api/consent/events/:eventId/rerequest` | supervisor+ | New requests for consent given on older text, and minors with none |
| GET | `/api/consent/events/:eventId/medical` | supervisor+ | Decrypted medical & emergency list (audited as `medical_list_viewed`) |
How requests work:
- Requests go to the parent by both email and WhatsApp, whatever anyone's notification preference.
- Links are random tokens; only an HMAC is stored. They expire when the event ends.
- The answer stores the signed name, time, IP, device, and the exact text and version shown.
- Medical and emergency details are AES-256-GCM encrypted. A daily job (09:00, `CONSENT_JOBS_ENABLED=false` to disable) sends reminders (day 2, then every 3 days) and deletes them `consent_medical_retention_days` (default 30) after the event.
Check-in rules:
- `GET /api/tickets/scan-preview/:qrCode` returns `consentStatus` (`not_required`, `missing`, `pending`, `given` or `declined`).
- `POST /api/tickets/scan/:qrCode` returns `403 { code: "CONSENT_REQUIRED" }` unless consent is given.
- Supervisors can pass `overrideConsent: true`, which is audited as `consent_override`.
## 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 |
@@ -452,6 +610,8 @@ Manages the WAWP WhatsApp API instance. Credentials are stored in `AppSetting` (
Runtime configuration stored in the `AppSetting` table. Sensitive values (SMTP credentials, WAWP token) are stored AES-256-GCM encrypted.
For what each individual setting key defaults to when it's never been saved, see [`SETTINGS.md`](../SETTINGS.md) at the repo root.
| Method | Path | Auth | Description |
|--------|------|------|-------------|
| GET | `/api/settings` | public | Public settings (org name, tagline, colour, logo, legal keys) |
+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) |
-6
View File
@@ -1,6 +0,0 @@
{
"message": "",
"type": "info",
"liveFrom": null,
"liveTill": null
}
-3
View File
@@ -1,3 +0,0 @@
{
"jobs": []
}
+4
View File
@@ -0,0 +1,4 @@
module.exports = {
testEnvironment: 'node',
testMatch: ['**/tests/**/*.test.js'],
};
+6188 -163
View File
File diff suppressed because it is too large Load Diff
+21 -6
View File
@@ -1,12 +1,12 @@
{
"name": "event-management-backend",
"version": "1.1.0",
"version": "1.18.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,22 +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,5 @@
-- AlterTable
ALTER TABLE "Payment" ADD COLUMN "feeAmount" DOUBLE PRECISION,
ADD COLUMN "feeChannel" TEXT,
ADD COLUMN "feePayer" TEXT,
ADD COLUMN "feeRate" DOUBLE PRECISION;
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Event" ADD COLUMN "feePayerOnline" TEXT;
@@ -0,0 +1,12 @@
/*
Warnings:
- You are about to drop the column `feePayerOnline` on the `Event` table. All the data in the column will be lost.
- You are about to drop the column `feePayer` on the `Payment` table. All the data in the column will be lost.
*/
-- AlterTable
ALTER TABLE "Event" DROP COLUMN "feePayerOnline";
-- AlterTable
ALTER TABLE "Payment" DROP COLUMN "feePayer";
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "EventCashupLine" ADD COLUMN "feeAmount" DOUBLE PRECISION;
@@ -0,0 +1,11 @@
-- AlterTable
ALTER TABLE "Payment" ADD COLUMN "recordedById" TEXT;
-- CreateIndex
CREATE INDEX "Payment_recordedById_idx" ON "Payment"("recordedById");
-- CreateIndex
CREATE INDEX "Payment_originalPaymentId_idx" ON "Payment"("originalPaymentId");
-- AddForeignKey
ALTER TABLE "Payment" ADD CONSTRAINT "Payment_recordedById_fkey" FOREIGN KEY ("recordedById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
@@ -0,0 +1,43 @@
-- CreateTable
CREATE TABLE "EventCashupPersonCount" (
"id" TEXT NOT NULL,
"eventId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"enteredById" TEXT,
"notes" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "EventCashupPersonCount_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "EventCashupPersonCountDenomination" (
"id" TEXT NOT NULL,
"countId" TEXT NOT NULL,
"value" DOUBLE PRECISION NOT NULL,
"count" INTEGER NOT NULL,
CONSTRAINT "EventCashupPersonCountDenomination_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "EventCashupPersonCount_eventId_idx" ON "EventCashupPersonCount"("eventId");
-- CreateIndex
CREATE UNIQUE INDEX "EventCashupPersonCount_eventId_userId_key" ON "EventCashupPersonCount"("eventId", "userId");
-- CreateIndex
CREATE INDEX "EventCashupPersonCountDenomination_countId_idx" ON "EventCashupPersonCountDenomination"("countId");
-- AddForeignKey
ALTER TABLE "EventCashupPersonCount" ADD CONSTRAINT "EventCashupPersonCount_eventId_fkey" FOREIGN KEY ("eventId") REFERENCES "Event"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "EventCashupPersonCount" ADD CONSTRAINT "EventCashupPersonCount_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "EventCashupPersonCount" ADD CONSTRAINT "EventCashupPersonCount_enteredById_fkey" FOREIGN KEY ("enteredById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "EventCashupPersonCountDenomination" ADD CONSTRAINT "EventCashupPersonCountDenomination_countId_fkey" FOREIGN KEY ("countId") REFERENCES "EventCashupPersonCount"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,20 @@
-- CreateEnum
CREATE TYPE "SecurityEventType" AS ENUM ('login', 'password_changed', 'password_reset');
-- CreateTable
CREATE TABLE "SecurityEvent" (
"id" TEXT NOT NULL,
"userId" TEXT,
"type" "SecurityEventType" NOT NULL,
"ip" TEXT,
"device" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "SecurityEvent_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "SecurityEvent_userId_createdAt_idx" ON "SecurityEvent"("userId", "createdAt");
-- AddForeignKey
ALTER TABLE "SecurityEvent" ADD CONSTRAINT "SecurityEvent_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
@@ -0,0 +1,11 @@
-- DropIndex
DROP INDEX "Payment_originalPaymentId_idx";
-- CreateIndex
CREATE INDEX "Payment_originalPaymentId_isDonation_idx" ON "Payment"("originalPaymentId", "isDonation");
-- CreateIndex
CREATE INDEX "Registration_createdAt_status_idx" ON "Registration"("createdAt", "status");
-- CreateIndex
CREATE INDEX "Ticket_createdAt_idx" ON "Ticket"("createdAt");
@@ -0,0 +1,16 @@
/*
Warnings:
- You are about to drop the column `feeAmount` on the `EventCashupLine` table. All the data in the column will be lost.
- You are about to drop the column `feeAmount` on the `Payment` table. All the data in the column will be lost.
- You are about to drop the column `feeChannel` on the `Payment` table. All the data in the column will be lost.
- You are about to drop the column `feeRate` on the `Payment` table. All the data in the column will be lost.
*/
-- AlterTable
ALTER TABLE "EventCashupLine" DROP COLUMN "feeAmount";
-- AlterTable
ALTER TABLE "Payment" DROP COLUMN "feeAmount",
DROP COLUMN "feeChannel",
DROP COLUMN "feeRate";
@@ -0,0 +1,29 @@
-- AlterTable
ALTER TABLE "Event" ADD COLUMN "contactEmail" TEXT,
ADD COLUMN "contactName" TEXT,
ADD COLUMN "contactPhone" TEXT,
ADD COLUMN "requiresRegistration" BOOLEAN NOT NULL DEFAULT true;
-- CreateTable
CREATE TABLE "RegistrationOptionTranche" (
"id" TEXT NOT NULL,
"registrationOptionId" TEXT NOT NULL,
"quantity" INTEGER NOT NULL,
"priceSnapshot" DOUBLE PRECISION NOT NULL,
"appliedTierId" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "RegistrationOptionTranche_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "RegistrationOptionTranche_registrationOptionId_idx" ON "RegistrationOptionTranche"("registrationOptionId");
-- CreateIndex
CREATE INDEX "RegistrationOptionTranche_appliedTierId_idx" ON "RegistrationOptionTranche"("appliedTierId");
-- AddForeignKey
ALTER TABLE "RegistrationOptionTranche" ADD CONSTRAINT "RegistrationOptionTranche_registrationOptionId_fkey" FOREIGN KEY ("registrationOptionId") REFERENCES "RegistrationOption"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "RegistrationOptionTranche" ADD CONSTRAINT "RegistrationOptionTranche_appliedTierId_fkey" FOREIGN KEY ("appliedTierId") REFERENCES "EarlyBirdTier"("id") ON DELETE SET NULL ON UPDATE CASCADE;
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Event" ADD COLUMN "location" TEXT;
@@ -0,0 +1,29 @@
-- CreateEnum
CREATE TYPE "AdminAuditAction" AS ENUM ('refund_created', 'donation_assigned', 'donation_unassigned', 'registration_created_manual', 'registration_cancelled', 'event_created', 'event_updated', 'event_deleted', 'settings_updated');
-- CreateTable
CREATE TABLE "AdminAuditLog" (
"id" TEXT NOT NULL,
"actorId" TEXT,
"actorRole" TEXT NOT NULL,
"action" "AdminAuditAction" NOT NULL,
"targetType" TEXT NOT NULL,
"targetId" TEXT,
"metadata" JSONB,
"ip" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "AdminAuditLog_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "AdminAuditLog_actorId_createdAt_idx" ON "AdminAuditLog"("actorId", "createdAt");
-- CreateIndex
CREATE INDEX "AdminAuditLog_action_createdAt_idx" ON "AdminAuditLog"("action", "createdAt");
-- CreateIndex
CREATE INDEX "AdminAuditLog_targetType_targetId_idx" ON "AdminAuditLog"("targetType", "targetId");
-- AddForeignKey
ALTER TABLE "AdminAuditLog" ADD CONSTRAINT "AdminAuditLog_actorId_fkey" FOREIGN KEY ("actorId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
@@ -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;
@@ -0,0 +1,69 @@
-- CreateEnum
CREATE TYPE "TicketDelivery" AS ENUM ('payer', 'each');
-- AlterTable
ALTER TABLE "Event" ADD COLUMN "allowNamedAttendees" BOOLEAN NOT NULL DEFAULT false;
-- AlterTable
ALTER TABLE "FormResponse" ADD COLUMN "attendeeId" TEXT;
-- AlterTable
ALTER TABLE "Registration" ADD COLUMN "ticketDelivery" "TicketDelivery" NOT NULL DEFAULT 'payer';
-- AlterTable
ALTER TABLE "Ticket" ADD COLUMN "attendeeId" TEXT,
ADD COLUMN "holderUserId" TEXT;
-- CreateTable
CREATE TABLE "Attendee" (
"id" TEXT NOT NULL,
"registrationId" TEXT NOT NULL,
"registrationOptionId" TEXT,
"eventOptionId" TEXT NOT NULL,
"variantId" TEXT,
"name" TEXT NOT NULL,
"email" TEXT,
"phone" TEXT,
"userId" TEXT,
"dateOfBirth" TIMESTAMP(3),
"gender" TEXT,
"order" INTEGER NOT NULL DEFAULT 0,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Attendee_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "Attendee_registrationId_idx" ON "Attendee"("registrationId");
-- CreateIndex
CREATE INDEX "Attendee_registrationOptionId_idx" ON "Attendee"("registrationOptionId");
-- CreateIndex
CREATE INDEX "Attendee_userId_idx" ON "Attendee"("userId");
-- CreateIndex
CREATE UNIQUE INDEX "Ticket_attendeeId_key" ON "Ticket"("attendeeId");
-- CreateIndex
CREATE INDEX "Ticket_holderUserId_idx" ON "Ticket"("holderUserId");
-- AddForeignKey
ALTER TABLE "Ticket" ADD CONSTRAINT "Ticket_attendeeId_fkey" FOREIGN KEY ("attendeeId") REFERENCES "Attendee"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Ticket" ADD CONSTRAINT "Ticket_holderUserId_fkey" FOREIGN KEY ("holderUserId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "FormResponse" ADD CONSTRAINT "FormResponse_attendeeId_fkey" FOREIGN KEY ("attendeeId") REFERENCES "Attendee"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Attendee" ADD CONSTRAINT "Attendee_registrationId_fkey" FOREIGN KEY ("registrationId") REFERENCES "Registration"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Attendee" ADD CONSTRAINT "Attendee_registrationOptionId_fkey" FOREIGN KEY ("registrationOptionId") REFERENCES "RegistrationOption"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Attendee" ADD CONSTRAINT "Attendee_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
@@ -0,0 +1,71 @@
-- CreateEnum
CREATE TYPE "ConsentStatus" AS ENUM ('pending', 'given', 'declined');
-- 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 'consent_override';
ALTER TYPE "AdminAuditAction" ADD VALUE 'consent_rerequested';
ALTER TYPE "AdminAuditAction" ADD VALUE 'medical_list_viewed';
-- AlterTable
ALTER TABLE "Attendee" ADD COLUMN "parentEmail" TEXT,
ADD COLUMN "parentName" TEXT,
ADD COLUMN "parentPhone" TEXT;
-- AlterTable
ALTER TABLE "Event" ADD COLUMN "consentCollectEmergency" BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN "consentCollectMedical" BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN "consentCollectPhoto" BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN "consentText" TEXT,
ADD COLUMN "consentTextVersion" INTEGER NOT NULL DEFAULT 1,
ADD COLUMN "requiresParentalConsent" BOOLEAN NOT NULL DEFAULT false;
-- CreateTable
CREATE TABLE "ConsentRequest" (
"id" TEXT NOT NULL,
"attendeeId" TEXT NOT NULL,
"eventId" TEXT NOT NULL,
"tokenHash" TEXT NOT NULL,
"status" "ConsentStatus" NOT NULL DEFAULT 'pending',
"textVersion" INTEGER NOT NULL,
"consentText" TEXT NOT NULL,
"parentName" TEXT,
"parentEmail" TEXT,
"parentPhone" TEXT,
"signedName" TEXT,
"ip" TEXT,
"device" TEXT,
"medical" TEXT,
"emergency" TEXT,
"photoConsent" BOOLEAN,
"respondedAt" TIMESTAMP(3),
"lastSentAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"remindersSent" INTEGER NOT NULL DEFAULT 0,
"expiresAt" TIMESTAMP(3) NOT NULL,
"medicalPurgedAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "ConsentRequest_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "ConsentRequest_tokenHash_key" ON "ConsentRequest"("tokenHash");
-- CreateIndex
CREATE INDEX "ConsentRequest_attendeeId_createdAt_idx" ON "ConsentRequest"("attendeeId", "createdAt");
-- CreateIndex
CREATE INDEX "ConsentRequest_eventId_status_idx" ON "ConsentRequest"("eventId", "status");
-- AddForeignKey
ALTER TABLE "ConsentRequest" ADD CONSTRAINT "ConsentRequest_attendeeId_fkey" FOREIGN KEY ("attendeeId") REFERENCES "Attendee"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ConsentRequest" ADD CONSTRAINT "ConsentRequest_eventId_fkey" FOREIGN KEY ("eventId") REFERENCES "Event"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,63 @@
-- CreateEnum
CREATE TYPE "WaitlistStatus" AS ENUM ('waiting', 'offered', 'claimed', 'expired', 'removed', 'left');
-- AlterTable
ALTER TABLE "Event" ADD COLUMN "waitlistEnabled" BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN "waitlistOfferHours" INTEGER NOT NULL DEFAULT 24,
ADD COLUMN "waitlistPartialOffers" BOOLEAN NOT NULL DEFAULT true;
-- CreateTable
CREATE TABLE "WaitlistEntry" (
"id" TEXT NOT NULL,
"eventId" TEXT NOT NULL,
"eventOptionId" TEXT NOT NULL,
"variantId" TEXT,
"userId" TEXT NOT NULL,
"quantity" INTEGER NOT NULL,
"position" INTEGER NOT NULL,
"status" "WaitlistStatus" NOT NULL DEFAULT 'waiting',
"offeredQuantity" INTEGER,
"offerTokenHash" TEXT,
"offeredAt" TIMESTAMP(3),
"offerExpiresAt" TIMESTAMP(3),
"claimedAt" TIMESTAMP(3),
"registrationId" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "WaitlistEntry_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "WaitlistEntry_offerTokenHash_key" ON "WaitlistEntry"("offerTokenHash");
-- CreateIndex
CREATE INDEX "WaitlistEntry_eventId_status_position_idx" ON "WaitlistEntry"("eventId", "status", "position");
-- CreateIndex
CREATE INDEX "WaitlistEntry_eventOptionId_status_idx" ON "WaitlistEntry"("eventOptionId", "status");
-- CreateIndex
CREATE INDEX "WaitlistEntry_variantId_idx" ON "WaitlistEntry"("variantId");
-- CreateIndex
CREATE INDEX "WaitlistEntry_userId_idx" ON "WaitlistEntry"("userId");
-- CreateIndex
CREATE INDEX "WaitlistEntry_registrationId_idx" ON "WaitlistEntry"("registrationId");
-- AddForeignKey
ALTER TABLE "WaitlistEntry" ADD CONSTRAINT "WaitlistEntry_eventId_fkey" FOREIGN KEY ("eventId") REFERENCES "Event"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "WaitlistEntry" ADD CONSTRAINT "WaitlistEntry_eventOptionId_fkey" FOREIGN KEY ("eventOptionId") REFERENCES "EventOption"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "WaitlistEntry" ADD CONSTRAINT "WaitlistEntry_variantId_fkey" FOREIGN KEY ("variantId") REFERENCES "OptionVariant"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "WaitlistEntry" ADD CONSTRAINT "WaitlistEntry_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "WaitlistEntry" ADD CONSTRAINT "WaitlistEntry_registrationId_fkey" FOREIGN KEY ("registrationId") REFERENCES "Registration"("id") ON DELETE SET NULL ON UPDATE CASCADE;
@@ -0,0 +1,93 @@
-- 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 'scan_lock_acquired';
ALTER TYPE "AdminAuditAction" ADD VALUE 'scan_lock_released';
ALTER TYPE "AdminAuditAction" ADD VALUE 'scan_lock_force_released';
ALTER TYPE "AdminAuditAction" ADD VALUE 'offline_sync';
-- AlterTable
ALTER TABLE "TicketUsage" ADD COLUMN "clientScanId" TEXT,
ADD COLUMN "offline" BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN "scanLockId" TEXT,
ADD COLUMN "syncedAt" TIMESTAMP(3);
-- CreateTable
CREATE TABLE "ScanLock" (
"id" TEXT NOT NULL,
"eventId" TEXT NOT NULL,
"sectionId" TEXT,
"deviceId" TEXT NOT NULL,
"deviceName" TEXT,
"userId" TEXT NOT NULL,
"lockedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"snapshotAt" TIMESTAMP(3),
"lastSyncAt" TIMESTAMP(3),
"releasedAt" TIMESTAMP(3),
"forceReleasedById" TEXT,
CONSTRAINT "ScanLock_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "OfflineSyncReport" (
"id" TEXT NOT NULL,
"scanLockId" TEXT NOT NULL,
"accepted" INTEGER NOT NULL DEFAULT 0,
"duplicates" INTEGER NOT NULL DEFAULT 0,
"conflicts" JSONB NOT NULL DEFAULT '[]',
"afterForceRelease" BOOLEAN NOT NULL DEFAULT false,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"reviewedAt" TIMESTAMP(3),
"reviewedById" TEXT,
CONSTRAINT "OfflineSyncReport_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "ScanLock_eventId_releasedAt_idx" ON "ScanLock"("eventId", "releasedAt");
-- CreateIndex
CREATE INDEX "ScanLock_userId_idx" ON "ScanLock"("userId");
-- CreateIndex
CREATE INDEX "ScanLock_sectionId_idx" ON "ScanLock"("sectionId");
-- CreateIndex
CREATE INDEX "ScanLock_forceReleasedById_idx" ON "ScanLock"("forceReleasedById");
-- CreateIndex
CREATE INDEX "OfflineSyncReport_scanLockId_idx" ON "OfflineSyncReport"("scanLockId");
-- CreateIndex
CREATE INDEX "OfflineSyncReport_reviewedById_idx" ON "OfflineSyncReport"("reviewedById");
-- CreateIndex
CREATE UNIQUE INDEX "TicketUsage_clientScanId_key" ON "TicketUsage"("clientScanId");
-- AddForeignKey
ALTER TABLE "TicketUsage" ADD CONSTRAINT "TicketUsage_scanLockId_fkey" FOREIGN KEY ("scanLockId") REFERENCES "ScanLock"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ScanLock" ADD CONSTRAINT "ScanLock_eventId_fkey" FOREIGN KEY ("eventId") REFERENCES "Event"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ScanLock" ADD CONSTRAINT "ScanLock_sectionId_fkey" FOREIGN KEY ("sectionId") REFERENCES "Section"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ScanLock" ADD CONSTRAINT "ScanLock_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ScanLock" ADD CONSTRAINT "ScanLock_forceReleasedById_fkey" FOREIGN KEY ("forceReleasedById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "OfflineSyncReport" ADD CONSTRAINT "OfflineSyncReport_scanLockId_fkey" FOREIGN KEY ("scanLockId") REFERENCES "ScanLock"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "OfflineSyncReport" ADD CONSTRAINT "OfflineSyncReport_reviewedById_fkey" FOREIGN KEY ("reviewedById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
@@ -0,0 +1,36 @@
-- AlterEnum
ALTER TYPE "AdminAuditAction" ADD VALUE 'ticket_transferred';
-- AlterTable
ALTER TABLE "Event" ADD COLUMN "allowTicketTransfer" BOOLEAN NOT NULL DEFAULT true;
-- CreateTable
CREATE TABLE "TicketTransfer" (
"id" TEXT NOT NULL,
"ticketId" TEXT NOT NULL,
"fromHolderName" TEXT NOT NULL,
"fromUserId" TEXT,
"toName" TEXT NOT NULL,
"toUserId" TEXT,
"byUserId" TEXT,
"oldQrCode" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "TicketTransfer_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "TicketTransfer_ticketId_idx" ON "TicketTransfer"("ticketId");
-- CreateIndex
CREATE INDEX "TicketTransfer_oldQrCode_idx" ON "TicketTransfer"("oldQrCode");
-- AddForeignKey
ALTER TABLE "TicketTransfer" ADD CONSTRAINT "TicketTransfer_ticketId_fkey" FOREIGN KEY ("ticketId") REFERENCES "Ticket"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "TicketTransfer" ADD CONSTRAINT "TicketTransfer_toUserId_fkey" FOREIGN KEY ("toUserId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "TicketTransfer" ADD CONSTRAINT "TicketTransfer_byUserId_fkey" FOREIGN KEY ("byUserId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
+591 -51
View File
@@ -47,6 +47,19 @@ enum EventCostType {
per_item
}
enum SecurityEventType {
login
password_changed
password_reset
two_factor_enabled
two_factor_disabled
}
enum TwoFactorMethod {
email
whatsapp
}
model User {
id String @id @default(uuid())
name String
@@ -61,8 +74,11 @@ 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")
tickets Ticket[]
ticketScans TicketUsage[]
passwordResets PasswordReset[]
@@ -74,48 +90,98 @@ model User {
eventsReopened Event[] @relation("EventReopenedBy")
cashupsPerformed EventCashup[]
notifyForEvents Event[] @relation("EventNotifyRecipients")
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")
attendeeProfiles Attendee[]
heldTickets Ticket[] @relation("TicketHolder")
waitlistEntries WaitlistEntry[]
scanLocks ScanLock[] @relation("ScanLockOwner")
transfersTo TicketTransfer[] @relation("TransferRecipient")
transfersBy TicketTransfer[] @relation("TransferActor")
scanLocksForced ScanLock[] @relation("ScanLockForcedBy")
syncReportsDone OfflineSyncReport[] @relation("SyncReportReviewer")
twoFactorChallenges TwoFactorChallenge[]
trustedDevices TrustedDevice[]
}
model Event {
id String @id @default(uuid())
title String
description String?
startDate DateTime
endDate DateTime
registrationDeadline DateTime?
goLiveAt DateTime @default(now())
price Float
picture String?
isActive Boolean @default(true)
isHidden Boolean @default(false)
requiresAuth Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
createdById String?
createdBy User? @relation(fields: [createdById], references: [id], onDelete: SetNull)
redirectUrl String? @unique
eventOptions EventOption[]
registrations Registration[]
payments Payment[]
tickets Ticket[]
attachments EventAttachment[]
form EventForm?
Section Section[]
id String @id @default(uuid())
title String
description String?
startDate DateTime
endDate DateTime
registrationDeadline DateTime?
goLiveAt DateTime @default(now())
price Float
picture String?
isActive Boolean @default(true)
isHidden Boolean @default(false)
requiresAuth Boolean @default(true)
requiresRegistration Boolean @default(true)
// Lets a payer name each person on a group registration (one named ticket each).
allowNamedAttendees Boolean @default(false)
// Youth event: every attendee gives a date of birth, and a parent/guardian must give
// consent for anyone under 18 before they can be checked in.
requiresParentalConsent Boolean @default(false)
consentText String?
consentTextVersion Int @default(1)
consentCollectMedical Boolean @default(false)
consentCollectEmergency Boolean @default(false)
consentCollectPhoto Boolean @default(false)
// Waitlist for sold-out options: when places free up, the next person is offered
// them for waitlistOfferHours. waitlistPartialOffers: offer what's free even if it's
// fewer places than asked for (otherwise skip to the next person who fits).
waitlistEnabled Boolean @default(false)
allowTicketTransfer Boolean @default(true)
waitlistOfferHours Int @default(24)
waitlistPartialOffers Boolean @default(true)
contactName String?
contactPhone String?
contactEmail String?
location String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
createdById String?
createdBy User? @relation(fields: [createdById], references: [id], onDelete: SetNull)
redirectUrl String? @unique
eventOptions EventOption[]
registrations Registration[]
payments Payment[]
tickets Ticket[]
attachments EventAttachment[]
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[]
consentRequests ConsentRequest[]
waitlistEntries WaitlistEntry[]
scanLocks ScanLock[]
@@index([createdById])
@@index([closedById])
@@ -129,27 +195,32 @@ 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[]
waitlistEntries WaitlistEntry[]
@@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[]
@@index([eventOptionId, deadline])
@@index([variantId])
@@ -161,10 +232,14 @@ 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[]
waitlistEntries WaitlistEntry[]
@@index([eventOptionId])
}
@@ -175,6 +250,12 @@ 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)
// Where named attendees' tickets go: all to the payer, or each to the attendee.
ticketDelivery TicketDelivery @default(payer)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Restrict)
@@ -183,26 +264,32 @@ model Registration {
payments Payment[]
formResponses FormResponse[]
formDrafts FormDraft[]
couponRedemptions CouponRedemption[]
attendees Attendee[]
waitlistEntries WaitlistEntry[]
@@index([userId])
@@index([eventId])
@@index([userId, status])
@@index([eventId, status])
@@index([createdAt, status])
}
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[]
attendees Attendee[]
@@index([registrationId])
@@index([eventOptionId])
@@ -210,11 +297,33 @@ model RegistrationOption {
@@index([appliedTierId])
}
// One row per purchase-at-a-price for a RegistrationOption. Never mutated after creation
// (mirrors the Payment model's append-only pattern) — this is what lets a single ticket
// type be bought in multiple batches at different early-bird prices without either batch's
// price bleeding into the other. RegistrationOption.quantity/priceSnapshot/appliedTierId
// stay in sync as an aggregate (quantity = sum of tranche quantities; priceSnapshot/appliedTierId
// 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)
@@index([registrationOptionId])
@@index([appliedTierId])
}
model Payment {
id String @id @default(uuid())
amount Float
method String
userId String
recordedById String?
registrationId String?
eventId String?
isDonation Boolean @default(false)
@@ -223,6 +332,7 @@ model Payment {
originalPaymentId String?
createdAt DateTime @default(now())
user User @relation(fields: [userId], references: [id], onDelete: Restrict)
recordedBy User? @relation("PaymentRecordedBy", fields: [recordedById], references: [id], onDelete: SetNull)
registration Registration? @relation(fields: [registrationId], references: [id], onDelete: SetNull)
event Event? @relation(fields: [eventId], references: [id], onDelete: SetNull)
originalPayment Payment? @relation("SplitPayments", fields: [originalPaymentId], references: [id], onDelete: SetNull)
@@ -230,9 +340,11 @@ model Payment {
YocoTransaction YocoTransaction[]
@@index([userId])
@@index([recordedById])
@@index([registrationId])
@@index([eventId])
@@index([createdAt])
@@index([originalPaymentId, isDonation])
}
model Ticket {
@@ -244,26 +356,42 @@ model Ticket {
quantity Int @default(1)
isUsed Boolean @default(false)
emailSent Boolean @default(false)
// Set on a named (group) ticket: the person it's for, and their account when it
// matched one. userId stays the payer.
attendeeId String? @unique
attendee Attendee? @relation(fields: [attendeeId], references: [id], onDelete: SetNull)
holderUserId String?
holder User? @relation("TicketHolder", fields: [holderUserId], references: [id], onDelete: SetNull)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
registrationOption RegistrationOption @relation(fields: [registrationOptionId], references: [id], onDelete: Restrict)
user User @relation(fields: [userId], references: [id], onDelete: Restrict)
event Event @relation(fields: [eventId], references: [id], onDelete: Restrict)
usages TicketUsage[]
transfers TicketTransfer[]
@@index([holderUserId])
@@index([eventId])
@@index([userId])
@@index([registrationOptionId])
@@index([createdAt])
}
model TicketUsage {
id String @id @default(uuid())
id String @id @default(uuid())
ticketId String
scannedById String
scannedAt DateTime @default(now())
quantityRedeemed Int @default(1)
ticket Ticket @relation(fields: [ticketId], references: [id], onDelete: Restrict)
scannedBy User @relation(fields: [scannedById], references: [id], onDelete: Restrict)
scannedAt DateTime @default(now())
quantityRedeemed Int @default(1)
// Offline scanning: the device's id for the scan (makes syncing idempotent), and
// the offline lock it was made under.
clientScanId String? @unique
offline Boolean @default(false)
scanLockId String?
syncedAt DateTime?
ticket Ticket @relation(fields: [ticketId], references: [id], onDelete: Restrict)
scannedBy User @relation(fields: [scannedById], references: [id], onDelete: Restrict)
scanLock ScanLock? @relation(fields: [scanLockId], references: [id], onDelete: SetNull)
@@index([ticketId])
@@index([scannedAt])
@@ -282,6 +410,100 @@ 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.
model SecurityEvent {
id String @id @default(uuid())
userId String?
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
type SecurityEventType
ip String?
device String?
createdAt DateTime @default(now())
@@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
consent_override
consent_rerequested
medical_list_viewed
scan_lock_acquired
scan_lock_released
scan_lock_force_released
offline_sync
ticket_transferred
}
// 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
@@ -336,6 +558,8 @@ model FormResponse {
registrationId String
createdAt DateTime @default(now())
registration Registration @relation(fields: [registrationId], references: [id], onDelete: Cascade)
attendeeId String?
attendee Attendee? @relation(fields: [attendeeId], references: [id], onDelete: SetNull)
answers FormAnswer[]
@@index([registrationId])
@@ -397,6 +621,7 @@ model Section {
event Event @relation(fields: [eventId], references: [id], onDelete: Cascade)
allowedOptions SectionOption[]
scanLocks ScanLock[]
@@index([eventId])
}
@@ -479,6 +704,36 @@ model EventCashupDenomination {
@@index([lineId])
}
// Actual physical cash counted for one staff member's recorded cash payments, entered any
// time (not part of the close flow) purely for accountability — compared against the
// system-expected amount (computeCashAccountabilityByUser) to show a per-person variance.
model EventCashupPersonCount {
id String @id @default(uuid())
eventId String
userId String
enteredById String?
notes String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
event Event @relation(fields: [eventId], references: [id], onDelete: Cascade)
user User @relation("EventCashupPersonCountFor", fields: [userId], references: [id], onDelete: Cascade)
enteredBy User? @relation("EventCashupPersonCountEnteredBy", fields: [enteredById], references: [id], onDelete: SetNull)
denominations EventCashupPersonCountDenomination[]
@@unique([eventId, userId])
@@index([eventId])
}
model EventCashupPersonCountDenomination {
id String @id @default(uuid())
countId String
value Float
count Int
personCount EventCashupPersonCount @relation(fields: [countId], references: [id], onDelete: Cascade)
@@index([countId])
}
// ─── Church Website Models (disabled — kept for reference, not active Prisma models) ──
//
// model Ministry {
@@ -634,3 +889,288 @@ model EventCashupDenomination {
// @@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])
}
// ─── Group tickets ────────────────────────────────────────────────────────────
enum TicketDelivery {
payer // every ticket goes to the person who registered
each // each named attendee gets their own (falls back to the payer)
}
// One named person on a group registration, for one main-ticket place. Gets their
// own quantity-1 ticket and their own attendee form response.
/// A ticket handed to someone else. oldQrCode is the code that stopped working (null
/// when one place was split off a shared group ticket, whose code stays valid).
model TicketTransfer {
id String @id @default(uuid())
ticketId String
fromHolderName String
fromUserId String?
toName String
toUserId String?
byUserId String?
oldQrCode String?
createdAt DateTime @default(now())
ticket Ticket @relation(fields: [ticketId], references: [id], onDelete: Cascade)
toUser User? @relation("TransferRecipient", fields: [toUserId], references: [id], onDelete: SetNull)
byUser User? @relation("TransferActor", fields: [byUserId], references: [id], onDelete: SetNull)
@@index([ticketId])
@@index([oldQrCode])
}
model Attendee {
id String @id @default(uuid())
registrationId String
registrationOptionId String?
eventOptionId String
variantId String?
name String
email String?
phone String?
userId String? // existing account matched by email/phone
dateOfBirth DateTime?
gender String?
parentName String?
parentEmail String?
parentPhone String?
order Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
registration Registration @relation(fields: [registrationId], references: [id], onDelete: Cascade)
registrationOption RegistrationOption? @relation(fields: [registrationOptionId], references: [id], onDelete: SetNull)
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
ticket Ticket?
formResponses FormResponse[]
consentRequests ConsentRequest[]
@@index([registrationId])
@@index([registrationOptionId])
@@index([userId])
}
// ─── Parental consent ─────────────────────────────────────────────────────────
enum ConsentStatus {
pending
given
declined
}
// A request for a parent/guardian to consent for one under-18 attendee. Only the hash
// of the link token is stored. A new request (e.g. after the consent text changes)
// supersedes earlier ones for the same attendee; the latest one decides check-in.
model ConsentRequest {
id String @id @default(uuid())
attendeeId String
eventId String
tokenHash String @unique
status ConsentStatus @default(pending)
textVersion Int
consentText String // the exact text the parent was shown
parentName String?
parentEmail String?
parentPhone String?
signedName String?
ip String?
device String?
medical String? // encrypted JSON (utils/encryption.js); purged after the event
emergency String? // encrypted JSON; purged after the event
photoConsent Boolean?
respondedAt DateTime?
lastSentAt DateTime @default(now())
remindersSent Int @default(0)
expiresAt DateTime
medicalPurgedAt DateTime?
createdAt DateTime @default(now())
attendee Attendee @relation(fields: [attendeeId], references: [id], onDelete: Cascade)
event Event @relation(fields: [eventId], references: [id], onDelete: Cascade)
@@index([attendeeId, createdAt])
@@index([eventId, status])
}
// ─── Waitlist ─────────────────────────────────────────────────────────────────
enum WaitlistStatus {
waiting
offered // places held for them until offerExpiresAt
claimed // they registered with the offer
expired // the offer ran out
removed // taken off by staff
left // they left the waitlist
}
model WaitlistEntry {
id String @id @default(uuid())
eventId String
eventOptionId String
variantId String?
userId String
quantity Int
position Int
status WaitlistStatus @default(waiting)
offeredQuantity Int?
offerTokenHash String? @unique
offeredAt DateTime?
offerExpiresAt DateTime?
claimedAt DateTime?
registrationId String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
event Event @relation(fields: [eventId], references: [id], onDelete: Cascade)
eventOption EventOption @relation(fields: [eventOptionId], references: [id], onDelete: Cascade)
variant OptionVariant? @relation(fields: [variantId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
registration Registration? @relation(fields: [registrationId], references: [id], onDelete: SetNull)
@@index([eventId, status, position])
@@index([eventOptionId, status])
@@index([variantId])
@@index([userId])
@@index([registrationId])
}
// ─── Offline scanning ─────────────────────────────────────────────────────────
// Locks a section (or the whole event, sectionId null) to one device for offline
// scanning, so two devices can never redeem the same ticket while offline.
// One active (releasedAt null) lock per scope — enforced in utils/scanLocks.js.
model ScanLock {
id String @id @default(uuid())
eventId String
sectionId String?
deviceId String
deviceName String?
userId String
lockedAt DateTime @default(now())
snapshotAt DateTime?
lastSyncAt DateTime?
releasedAt DateTime?
forceReleasedById String?
event Event @relation(fields: [eventId], references: [id], onDelete: Cascade)
section Section? @relation(fields: [sectionId], references: [id], onDelete: Cascade)
user User @relation("ScanLockOwner", fields: [userId], references: [id], onDelete: Cascade)
forceReleasedBy User? @relation("ScanLockForcedBy", fields: [forceReleasedById], references: [id], onDelete: SetNull)
usages TicketUsage[]
syncReports OfflineSyncReport[]
@@index([eventId, releasedAt])
@@index([userId])
@@index([sectionId])
@@index([forceReleasedById])
}
// One sync of queued offline scans; `conflicts` lists scans that needed a person to look.
model OfflineSyncReport {
id String @id @default(uuid())
scanLockId String
accepted Int @default(0)
duplicates Int @default(0)
conflicts Json @default("[]")
afterForceRelease Boolean @default(false)
createdAt DateTime @default(now())
reviewedAt DateTime?
reviewedById String?
scanLock ScanLock @relation(fields: [scanLockId], references: [id], onDelete: Cascade)
reviewedBy User? @relation("SyncReportReviewer", fields: [reviewedById], references: [id], onDelete: SetNull)
@@index([scanLockId])
@@index([reviewedById])
}
+4 -3
View File
@@ -64,8 +64,9 @@ const MAPPINGS = [
{ key: 'org_tagline', envVars: ['ORG_TAGLINE'] },
{ key: 'org_email', envVars: ['EMAIL_FROM', 'EMAIL_USER', 'SMTP_FROM', 'SMTP_USER'] },
// Branding
{ key: 'accent_color', envVars: ['EMAIL_HEADER_COLOR', 'BRAND_COLOR'] },
// Branding — these env vars historically fed the single "brand color" concept,
// which is now the Primary color in the 3-color Primary/Secondary/Accent system.
{ key: 'primary_color', envVars: ['EMAIL_HEADER_COLOR', 'BRAND_COLOR'] },
// Notifications
{ key: 'reg_notification_emails',envVars: ['REGISTRATIONS_EMAIL'] },
@@ -88,7 +89,7 @@ async function main() {
const prisma = new PrismaClient();
console.log(`\n${'─'.repeat(60)}`);
console.log(' Hope Events — .env → DB settings migration');
console.log(' Settings migration: .env → DB');
if (DRY_RUN) console.log(' MODE: DRY RUN (no changes will be made)');
if (FORCE) console.log(' MODE: FORCE (will overwrite existing DB values)');
console.log(`${'─'.repeat(60)}\n`);
+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 };
+24 -11
View File
@@ -29,14 +29,7 @@ function buildEventContext(event) {
return { eventTitle, eventStart, eventLink, eventLinkHtml };
}
function replacePlaceholders(str, ctx) {
if (!str) return str;
return String(str)
.replace(/\{\{\s*name\s*\}\}/g, ctx.name || '')
.replace(/\{\{\s*event\.title\s*\}\}/g, ctx.eventTitle || '')
.replace(/\{\{\s*event\.start\s*\}\}/g, ctx.eventStart || '')
.replace(/\{\{\s*event\.(link|url)\s*\}\}/g, (ctx.eventLinkHtml || ctx.eventLink || ''));
}
const { replacePlaceholders } = require('../utils/placeholders');
function parseFreeformEmails(lines) {
// Supports formats:
@@ -64,6 +57,23 @@ function parseFreeformEmails(lines) {
return recipients;
}
// Human-readable summary of who a broadcast will go to, for the scheduled-jobs admin UI
async function describeBroadcastRecipients({ userIds, emails }) {
const parts = [];
try {
const ids = Array.isArray(userIds) ? userIds.filter(x => typeof x === 'string' && x) : [];
if (ids.length) {
const users = await prisma.user.findMany({ where: { id: { in: ids } }, select: { name: true } });
const names = users.map(u => u.name).filter(Boolean);
parts.push(names.slice(0, 3).join(', ') + (names.length > 3 ? ` +${names.length - 3} more` : ''));
}
if (Array.isArray(emails) && emails.length) {
parts.push(`${emails.length} email address${emails.length === 1 ? '' : 'es'}`);
}
} catch {}
return parts.length ? parts.join('; ') : 'No recipients';
}
// @desc Preview broadcast recipients and sample
// @route POST /api/broadcasts/preview
// @access Private/Supervisor or Admin
@@ -166,9 +176,9 @@ const sendBroadcast = async (req, res) => {
eventLink: escapeHtml(eventCtx.eventLink),
eventLinkHtml: eventCtx.eventLinkHtml,
} : ctxBase;
const finalSubject = replacePlaceholders(subject, ctxBase);
const finalHtml = html ? replacePlaceholders(html, ctxForHtml) : undefined;
const finalText = (!html ? replacePlaceholders(text || '', ctxBase) : undefined);
const finalSubject = await replacePlaceholders(subject, ctxBase);
const finalHtml = html ? await replacePlaceholders(html, ctxForHtml) : undefined;
const finalText = (!html ? await replacePlaceholders(text || '', ctxBase) : undefined);
await sendMail({ to: rcpt.email, subject: finalSubject, html: finalHtml, text: finalText });
}));
@@ -198,11 +208,14 @@ const scheduleBroadcast = async (req, res) => {
const payload = { subject, html, text, userIds, emails, eventId };
const recipientSummary = await describeBroadcastRecipients({ userIds, emails: parseFreeformEmails(emails) });
const { addJob } = require('../utils/scheduledEmails');
const created = addJob({
broadcast: true,
scheduledAt: when.toISOString(),
createdById: req.user?.id || null,
recipientSummary,
payload,
});
+68 -16
View File
@@ -1,7 +1,7 @@
const prisma = require('../config/db');
const { v4: uuidv4 } = require('uuid');
const { safeErrorMessage } = require('../utils/errorUtils');
const { ALL_METHODS, assertEventOpen, computeEventFinancials } = require('../utils/cashupUtils');
const { ALL_METHODS, assertEventOpen, computeEventFinancials, computeAccountabilityByUser, computeEventCashActualFromPersonCounts, savePersonCashCount } = require('../utils/cashupUtils');
// @desc Cashup preview for an event: live expected/actual numbers, costs, donations-to-profit, and history
// @route GET /api/cashups/event/:eventId
@@ -15,6 +15,45 @@ const getEventCashup = async (req, res) => {
}
};
// @desc Payments recorded for an event, broken down by staff member and method (cash/card/eft/
// other) — cash also includes the live actual-vs-expected from per-person counts.
// @route GET /api/cashups/event/:eventId/cash-by-user
// @access Private/Supervisor
const getCashByRecordedUser = async (req, res) => {
try {
const [rows, cash] = await Promise.all([
computeAccountabilityByUser(req.params.eventId),
computeEventCashActualFromPersonCounts(req.params.eventId)
]);
res.json({ rows, cashActualTotal: cash.actual, cashDenominations: cash.denominations });
} catch (error) {
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
}
};
// @desc Enter (or update) one staff member's actual physical cash count for an event —
// optional, can be done any time, purely for per-person accountability. Never blocks
// or is required for closing the event.
// @route PUT /api/cashups/event/:eventId/person-cash/:userId
// @access Private/Supervisor
const savePersonCash = async (req, res) => {
try {
const { eventId, userId } = req.params;
const { denominations, notes } = req.body;
const targetUser = await prisma.user.findUnique({ where: { id: userId }, select: { id: true } });
if (!targetUser) {
res.status(404);
throw new Error('User not found');
}
const record = await savePersonCashCount(eventId, userId, { denominations, notes, enteredById: req.user.id });
res.json(record);
} catch (error) {
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
}
};
// @desc Save in-progress reconciliation entries without closing the event
// @route PUT /api/cashups/event/:eventId/draft
// @access Private/Admin
@@ -49,30 +88,41 @@ const closeEvent = async (req, res) => {
const financials = await computeEventFinancials(eventId);
const isFullCashup = Array.isArray(lines) && lines.length > 0;
const cashupLines = isFullCashup
? lines
.filter(l => l && ALL_METHODS.includes(l.method))
let cashupLines = [];
if (isFullCashup) {
const nonCashLines = lines
.filter(l => l && ALL_METHODS.includes(l.method) && l.method !== 'cash')
.map(l => {
const expected = financials.expectedCashByMethod[l.method] || 0;
const denominations = l.method === 'cash' && Array.isArray(l.denominations)
? l.denominations
.map(d => ({ value: parseFloat(d.value), count: parseInt(d.count, 10) || 0 }))
.filter(d => d.value > 0 && d.count > 0)
: [];
const actual = denominations.length > 0
? denominations.reduce((sum, d) => sum + d.value * d.count, 0)
: (l.actualAmount !== undefined && l.actualAmount !== null && l.actualAmount !== '' ? parseFloat(l.actualAmount) : null);
const actual = l.actualAmount !== undefined && l.actualAmount !== null && l.actualAmount !== '' ? parseFloat(l.actualAmount) : null;
return {
id: uuidv4(),
method: l.method,
expectedAmount: expected,
actualAmount: actual,
variance: actual !== null ? actual - expected : null,
notes: l.notes || null,
denominations: denominations.length > 0 ? { create: denominations } : undefined
notes: l.notes || null
};
})
: [];
});
// Cash is never entered as a single event-wide figure — it's always the live sum of every
// staff member's per-person count (see computeEventCashActualFromPersonCounts), so it's
// always sourced here rather than from whatever (if anything) the frontend sent for it.
const cashLineInput = lines.find(l => l && l.method === 'cash');
const { actual: cashActual, denominations: cashDenominations } = await computeEventCashActualFromPersonCounts(eventId);
const cashExpected = financials.expectedCashByMethod.cash || 0;
const cashLine = {
id: uuidv4(),
method: 'cash',
expectedAmount: cashExpected,
actualAmount: cashActual,
variance: cashActual !== null ? cashActual - cashExpected : null,
notes: cashLineInput?.notes || null,
denominations: cashDenominations.length > 0 ? { create: cashDenominations } : undefined
};
cashupLines = [cashLine, ...nonCashLines];
}
const totalActualRevenue = isFullCashup
? cashupLines.reduce((sum, l) => sum + (l.actualAmount !== null ? l.actualAmount : 0), 0)
@@ -179,6 +229,8 @@ const getCashupAudit = async (req, res) => {
module.exports = {
getEventCashup,
getCashByRecordedUser,
savePersonCash,
saveEventCashupDraft,
closeEvent,
reopenEvent,
@@ -0,0 +1,264 @@
const prisma = require('../config/db');
const { safeErrorMessage } = require('../utils/errorUtils');
const { logAdminAction } = require('../utils/adminAudit');
const { getClientIp } = require('../utils/requestUtils');
const { describeUserAgent } = require('../utils/userAgent');
const {
isMinor, consentStatusFor, validateConsentAnswer, sealJson, openJson, latestRequestsFor,
findRequestByToken, resendConsentRequest, createConsentRequest,
} = require('../utils/consent');
const STAFF = ['admin', 'supervisor', 'staff'];
const fail = (res, error) => res.status(error.statusCode || (res.statusCode === 200 ? 400 : res.statusCode)).json({ message: safeErrorMessage(error) });
// What the parent sees — never the registrant's contact details or other attendees.
function publicView(request) {
const ev = request.event;
const latestForAttendee = request._isLatest !== false;
return {
status: request.status,
superseded: !latestForAttendee,
expired: new Date(request.expiresAt) < new Date(),
respondedAt: request.respondedAt,
attendeeName: request.attendee.name,
parentName: request.parentName,
consentText: request.consentText,
textVersion: request.textVersion,
event: { title: ev.title, startDate: ev.startDate, endDate: ev.endDate, location: ev.location },
collect: { medical: ev.consentCollectMedical, emergency: ev.consentCollectEmergency, photo: ev.consentCollectPhoto },
};
}
// @desc Consent page data for a parent/guardian
// @route GET /api/consent/:token
// @access Public (the token is the credential)
const getConsent = async (req, res) => {
try {
const request = await findRequestByToken(req.params.token);
if (!request) { res.status(404); throw new Error('This consent link is not valid. It may have been replaced by a newer one — check your latest message.'); }
const latest = (await latestRequestsFor([request.attendeeId])).get(request.attendeeId);
res.json(publicView({ ...request, _isLatest: latest?.id === request.id }));
} catch (error) {
fail(res, error);
}
};
// @desc Give or decline consent
// @route POST /api/consent/:token
// @access Public (the token is the credential)
const answerConsent = async (req, res) => {
try {
const request = await findRequestByToken(req.params.token);
if (!request) { res.status(404); throw new Error('This consent link is not valid.'); }
if (request.status !== 'pending') { res.status(409); throw new Error(`Consent has already been ${request.status}.`); }
if (new Date(request.expiresAt) < new Date()) { res.status(410); throw new Error('This consent link has expired because the event has ended.'); }
const latest = (await latestRequestsFor([request.attendeeId])).get(request.attendeeId);
if (latest?.id !== request.id) { res.status(409); throw new Error('A newer consent request replaced this one — please use the link in your latest message.'); }
let answer;
try { answer = validateConsentAnswer(req.body || {}, request.event); } catch (e) { res.status(400); throw e; }
// Only flips pending → answered once, even if the form is submitted twice at once.
const updated = await prisma.consentRequest.updateMany({
where: { id: request.id, status: 'pending' },
data: {
status: answer.decision === 'give' ? 'given' : 'declined',
signedName: answer.signedName,
ip: getClientIp(req),
device: describeUserAgent(req.headers['user-agent']),
medical: sealJson(answer.medical),
emergency: sealJson(answer.emergency),
photoConsent: answer.photoConsent,
respondedAt: new Date(),
},
});
if (updated.count !== 1) { res.status(409); throw new Error('Consent has already been answered.'); }
notifyConsentAnswered(request.id).catch(e => console.error('[consent] notify failed:', e?.message || e));
res.json({ message: answer.decision === 'give' ? 'Thank you — consent has been given.' : 'Thank you — consent has been declined.', status: answer.decision === 'give' ? 'given' : 'declined' });
} catch (error) {
fail(res, error);
}
};
// Tells the registrant and the event's notify recipients how a parent answered.
async function notifyConsentAnswered(requestId) {
const request = await prisma.consentRequest.findUnique({
where: { id: requestId },
include: {
attendee: { include: { registration: { include: { user: { select: { id: true, name: true, email: true, phoneNumber: true, notificationPreference: true } } } } } },
event: { include: { createdBy: { select: { id: true, name: true, email: true } }, notifyRecipients: { select: { id: true, name: true, email: true } } } },
},
});
if (!request) return;
const { sendMail, emailWrapper } = require('../utils/email');
const { shouldEmail, waText } = require('../utils/notify');
const { buildEventNotifyRecipientList } = require('../utils/notifications');
const given = request.status === 'given';
const verb = given ? 'given' : 'declined';
const who = request.attendee.name;
const ev = request.event.title;
const payer = request.attendee.registration.user;
const line = given
? `${request.signedName} gave parental consent for ${who} for ${ev}.`
: `${request.signedName} declined parental consent for ${who} for ${ev}. ${who} can't be checked in — please contact the organisers about a refund.`;
const sends = [];
const payerHasEmail = payer?.email && !payer.email.endsWith('@guest.local') && !payer.email.endsWith('@deleted.invalid');
if (payerHasEmail && shouldEmail(payer) && payer.email !== request.parentEmail) {
sends.push(sendMail({ to: payer.email, subject: `Consent ${verb} for ${who}`, html: emailWrapper(`<p>${line}</p>`), text: line }));
}
if (payer) sends.push(waText(payer, line));
const staff = buildEventNotifyRecipientList(request.event);
if (staff.length) {
sends.push(sendMail({ to: staff.join(','), subject: `Consent ${verb}: ${who} — ${ev}`, html: emailWrapper(`<p>${line}</p>`), text: line }));
}
await Promise.allSettled(sends);
}
/** One row per attendee on the event, with consent state — for the consent tracker. */
async function consentRows(eventId) {
const event = await prisma.event.findUnique({ where: { id: eventId } });
if (!event) return { event: null, rows: [] };
const attendees = await prisma.attendee.findMany({
where: { registration: { eventId, status: { not: 'cancelled' } } },
include: { registration: { select: { id: true, status: true, user: { select: { id: true, name: true, email: true } } } } },
orderBy: [{ registrationId: 'asc' }, { order: 'asc' }],
});
const latest = await latestRequestsFor(attendees.map(a => a.id));
const rows = attendees.map(a => {
const r = latest.get(a.id) || null;
return {
attendeeId: a.id,
name: a.name,
dateOfBirth: a.dateOfBirth,
minor: isMinor(a.dateOfBirth, event.startDate),
parentName: a.parentName, parentEmail: a.parentEmail, parentPhone: a.parentPhone,
registrationId: a.registrationId,
registrationStatus: a.registration.status,
bookedBy: a.registration.user,
status: consentStatusFor(a, event, r),
request: r && {
id: r.id, status: r.status, textVersion: r.textVersion, signedName: r.signedName, respondedAt: r.respondedAt,
lastSentAt: r.lastSentAt, remindersSent: r.remindersSent, photoConsent: r.photoConsent,
outdated: r.status === 'given' && r.textVersion < (event.consentTextVersion || 1),
},
};
});
return { event, rows };
}
// @desc Consent status for every attendee on an event
// @route GET /api/consent/events/:eventId
// @access Private/Staff
const listEventConsent = async (req, res) => {
try {
const { event, rows } = await consentRows(req.params.eventId);
if (!event) { res.status(404); throw new Error('Event not found'); }
const counts = rows.reduce((c, r) => { c[r.status] = (c[r.status] || 0) + 1; return c; }, {});
res.json({
event: { id: event.id, title: event.title, startDate: event.startDate, requiresParentalConsent: event.requiresParentalConsent, consentTextVersion: event.consentTextVersion },
counts,
rows,
});
} catch (error) {
fail(res, error);
}
};
// @desc Re-send one consent request (with a fresh link)
// @route POST /api/consent/requests/:id/resend
// @access Private (the registrant, or staff)
const resendRequest = async (req, res) => {
try {
const request = await prisma.consentRequest.findUnique({ where: { id: req.params.id }, include: { attendee: { include: { registration: true } } } });
if (!request) { res.status(404); throw new Error('Consent request not found'); }
if (request.attendee.registration.userId !== req.user.id && !STAFF.includes(req.user.role)) { res.status(403); throw new Error('Not authorized'); }
const latest = (await latestRequestsFor([request.attendeeId])).get(request.attendeeId);
if (latest?.id !== request.id) { res.status(400); throw new Error('A newer consent request has replaced this one.'); }
const sent = await resendConsentRequest(request.id);
res.json({ message: sent.length ? `Consent request re-sent by ${sent.join(' and ')}.` : 'The consent request could not be sent — check the parent\'s email and phone.' });
} catch (error) {
fail(res, error);
}
};
// @desc Re-send every consent request still pending on an event
// @route POST /api/consent/events/:eventId/resend-pending
// @access Private/Supervisor
const resendPending = async (req, res) => {
try {
const { rows } = await consentRows(req.params.eventId);
const pending = rows.filter(r => r.status === 'pending' && r.request);
let sent = 0;
for (const r of pending) {
try { if ((await resendConsentRequest(r.request.id)).length) sent += 1; } catch {}
}
res.json({ message: `Re-sent ${sent} of ${pending.length} pending request(s).`, sent, pending: pending.length });
} catch (error) {
fail(res, error);
}
};
// @desc Ask again for consent where it was given on an older version of the text,
// and for minors who never got a request (e.g. names added later)
// @route POST /api/consent/events/:eventId/rerequest
// @access Private/Supervisor
const rerequestConsent = async (req, res) => {
try {
const { event, rows } = await consentRows(req.params.eventId);
if (!event) { res.status(404); throw new Error('Event not found'); }
if (!event.requiresParentalConsent) { res.status(400); throw new Error('This event does not need parental consent.'); }
const targets = rows.filter(r => r.minor && r.registrationStatus !== 'cancelled' && (r.status === 'missing' || r.request?.outdated));
let created = 0;
for (const r of targets) {
if (!r.parentEmail && !r.parentPhone) continue;
const attendee = await prisma.attendee.findUnique({ where: { id: r.attendeeId } });
await createConsentRequest(attendee, event);
created += 1;
}
logAdminAction({
actorId: req.user.id, actorRole: req.user.role, action: 'consent_rerequested',
targetType: 'Event', targetId: event.id, metadata: { requests: created, textVersion: event.consentTextVersion },
ip: getClientIp(req),
});
res.json({ message: `Sent ${created} new consent request(s).`, created });
} catch (error) {
fail(res, error);
}
};
// @desc Medical & emergency list for an event (decrypted) — every view is audited
// @route GET /api/consent/events/:eventId/medical
// @access Private/Supervisor
const medicalList = async (req, res) => {
try {
const event = await prisma.event.findUnique({ where: { id: req.params.eventId } });
if (!event) { res.status(404); throw new Error('Event not found'); }
const { rows } = await consentRows(event.id);
const ids = rows.map(r => r.request?.id).filter(Boolean);
const full = ids.length ? await prisma.consentRequest.findMany({ where: { id: { in: ids } } }) : [];
const byId = new Map(full.map(r => [r.id, r]));
const list = rows
.filter(r => r.request && byId.get(r.request.id)?.status === 'given')
.map(r => {
const fr = byId.get(r.request.id);
return {
name: r.name, dateOfBirth: r.dateOfBirth,
parentName: r.parentName, parentPhone: r.parentPhone, parentEmail: r.parentEmail,
medical: openJson(fr.medical), emergency: openJson(fr.emergency), photoConsent: fr.photoConsent,
purged: !!fr.medicalPurgedAt,
};
});
logAdminAction({
actorId: req.user.id, actorRole: req.user.role, action: 'medical_list_viewed',
targetType: 'Event', targetId: event.id, metadata: { attendees: list.length },
ip: getClientIp(req),
});
res.json({ event: { id: event.id, title: event.title, startDate: event.startDate }, rows: list });
} catch (error) {
fail(res, error);
}
};
module.exports = { getConsent, answerConsent, listEventConsent, resendRequest, resendPending, rerequestConsent, medicalList };
+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 };
+324 -106
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) {
@@ -41,7 +44,7 @@ function toAbsoluteUrl(req, url) {
// @access Private/Admin
const createEvent = async (req, res) => {
try {
const { title, description, startDate, endDate, registrationDeadline, goLiveAt, price, picture, redirectUrl, isHidden, requiresAuth } = req.body;
const { title, description, startDate, endDate, registrationDeadline, goLiveAt, price, picture, redirectUrl, isHidden, requiresAuth, requiresRegistration, allowNamedAttendees, requiresParentalConsent, consentText, consentCollectMedical, consentCollectEmergency, consentCollectPhoto, waitlistEnabled, waitlistOfferHours, waitlistPartialOffers, allowTicketTransfer, contactName, contactPhone, contactEmail, location } = req.body;
const data = {
id: uuidv4(),
@@ -58,14 +61,42 @@ const createEvent = async (req, res) => {
redirectUrl,
isHidden: isHidden === true || isHidden === 'true',
requiresAuth: requiresAuth === false || requiresAuth === 'false' ? false : true,
requiresRegistration: requiresRegistration === false || requiresRegistration === 'false' ? false : true,
// A youth event needs names (with dates of birth), so it always takes them.
allowNamedAttendees: allowNamedAttendees === true || allowNamedAttendees === 'true' || requiresParentalConsent === true,
requiresParentalConsent: requiresParentalConsent === true,
consentText: consentText ? String(consentText) : null,
consentCollectMedical: consentCollectMedical === true,
consentCollectEmergency: consentCollectEmergency === true,
consentCollectPhoto: consentCollectPhoto === true,
waitlistEnabled: waitlistEnabled === true,
allowTicketTransfer: allowTicketTransfer !== false,
waitlistOfferHours: Math.min(168, Math.max(1, parseInt(waitlistOfferHours, 10) || 24)),
waitlistPartialOffers: waitlistPartialOffers !== false,
contactName: contactName || null,
contactPhone: contactPhone || null,
contactEmail: contactEmail || null,
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
// 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.
try {
if (prisma && prisma.eventOption && typeof prisma.eventOption.create === 'function') {
if (data.requiresRegistration !== false && prisma && prisma.eventOption && typeof prisma.eventOption.create === 'function') {
await prisma.eventOption.create({
data: {
id: uuidv4(),
@@ -124,24 +155,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;
@@ -188,12 +223,8 @@ const getEvents = async (req, res) => {
const limitedOpts = (ev.eventOptions || []).filter(o => (o.stockLimit || 0) > 0);
let isSoldOut = false;
if (limitedOpts.length > 0) {
const soldCounts = await Promise.all(limitedOpts.map(opt =>
prisma.registrationOption.aggregate({
where: { eventOptionId: opt.id, registration: { status: { not: 'cancelled' } } },
_sum: { quantity: true },
}).then(r => r._sum?.quantity || 0)
));
const { takenCount } = require('../utils/stock');
const soldCounts = await Promise.all(limitedOpts.map(opt => takenCount({ eventOptionId: opt.id })));
isSoldOut = limitedOpts.every((opt, i) => soldCounts[i] >= opt.stockLimit);
}
return { ...ev, picture: toAbsoluteUrl(req, ev.picture), isSoldOut };
@@ -241,10 +272,12 @@ const getEventsAll = async (req, res) => {
try {
const includePast = req.query.includePast === 'true';
const includeInactive = req.query.includeInactive === 'true';
const excludeClosed = req.query.excludeClosed === 'true';
const where = {};
if (!includeInactive) where.isActive = true;
if (!includePast) where.endDate = { gte: new Date() };
if (excludeClosed) where.cashupStatus = { not: 'closed' };
const canIncludeTiers = !!(prisma && prisma.earlyBirdTier && typeof prisma.earlyBirdTier.findMany === 'function');
const canIncludeVariants = !!(prisma && prisma.optionVariant && typeof prisma.optionVariant.findMany === 'function');
@@ -392,22 +425,15 @@ const getEventById = async (req, res) => {
try {
if (canIncludeVariants) {
eventOptionsWithStock = await Promise.all((event.eventOptions || []).map(async opt => {
// Total sold quantity for this option (all non-cancelled)
const soldAgg = await prisma.registrationOption.aggregate({
where: { eventOptionId: opt.id, registration: { status: { not: 'cancelled' } } },
_sum: { quantity: true }
});
const soldCount = soldAgg._sum?.quantity || 0;
// Taken = non-cancelled registrations + places held for live waitlist offers
const { takenCount } = require('../utils/stock');
const soldCount = await takenCount({ eventOptionId: opt.id });
const availableCount = opt.stockLimit > 0 ? Math.max(0, opt.stockLimit - soldCount) : null;
// Per-variant stock
const variantsWithStock = await Promise.all((opt.variants || []).map(async v => {
if (!v.stockLimit) return { ...v, soldCount: 0, availableCount: null };
const vAgg = await prisma.registrationOption.aggregate({
where: { variantId: v.id, registration: { status: { not: 'cancelled' } } },
_sum: { quantity: true }
});
const vSold = vAgg._sum?.quantity || 0;
const vSold = await takenCount({ eventOptionId: opt.id, variantId: v.id });
return { ...v, soldCount: vSold, availableCount: Math.max(0, v.stockLimit - vSold) };
}));
@@ -434,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
@@ -452,7 +527,17 @@ const updateEvent = async (req, res) => {
// totals — same rule already enforced for payments/costs. Admin can reopen first.
await assertEventOpen(req.params.id, res);
const { title, description, startDate, endDate, registrationDeadline, goLiveAt, price, picture, isActive, redirectUrl, isHidden, requiresAuth } = req.body;
const logEventUpdate = () => logAdminAction({
actorId: req.user.id,
actorRole: req.user.role,
action: 'event_updated',
targetType: 'Event',
targetId: req.params.id,
metadata: { changedKeys: Object.keys(req.body || {}) },
ip: getClientIp(req),
});
const { title, description, startDate, endDate, registrationDeadline, goLiveAt, price, picture, isActive, redirectUrl, isHidden, requiresAuth, requiresRegistration, allowNamedAttendees, requiresParentalConsent, consentText, consentCollectMedical, consentCollectEmergency, consentCollectPhoto, waitlistEnabled, waitlistOfferHours, waitlistPartialOffers, allowTicketTransfer, contactName, contactPhone, contactEmail, location } = req.body;
const data = {
title: title || event.title,
@@ -466,6 +551,27 @@ const updateEvent = async (req, res) => {
isActive: isActive !== undefined ? isActive : event.isActive,
isHidden: isHidden !== undefined ? (isHidden === true || isHidden === 'true') : (event.isHidden ?? false),
requiresAuth: requiresAuth !== undefined ? !(requiresAuth === false || requiresAuth === 'false') : (event.requiresAuth ?? true),
requiresRegistration: requiresRegistration !== undefined ? !(requiresRegistration === false || requiresRegistration === 'false') : (event.requiresRegistration ?? true),
allowNamedAttendees: (requiresParentalConsent ?? event.requiresParentalConsent) === true
? true
: (allowNamedAttendees !== undefined ? (allowNamedAttendees === true || allowNamedAttendees === 'true') : event.allowNamedAttendees),
requiresParentalConsent: requiresParentalConsent !== undefined ? requiresParentalConsent === true : event.requiresParentalConsent,
consentCollectMedical: consentCollectMedical !== undefined ? consentCollectMedical === true : event.consentCollectMedical,
consentCollectEmergency: consentCollectEmergency !== undefined ? consentCollectEmergency === true : event.consentCollectEmergency,
consentCollectPhoto: consentCollectPhoto !== undefined ? consentCollectPhoto === true : event.consentCollectPhoto,
waitlistEnabled: waitlistEnabled !== undefined ? waitlistEnabled === true : event.waitlistEnabled,
allowTicketTransfer: allowTicketTransfer !== undefined ? allowTicketTransfer !== false : event.allowTicketTransfer,
waitlistOfferHours: waitlistOfferHours !== undefined ? Math.min(168, Math.max(1, parseInt(waitlistOfferHours, 10) || 24)) : event.waitlistOfferHours,
waitlistPartialOffers: waitlistPartialOffers !== undefined ? waitlistPartialOffers !== false : event.waitlistPartialOffers,
// Editing the consent text starts a new version; parents who already agreed keep
// their record of the old one (supervisors can re-request consent).
...(consentText !== undefined && String(consentText || '') !== String(event.consentText || '')
? { consentText: consentText ? String(consentText) : null, consentTextVersion: (event.consentTextVersion || 1) + 1 }
: {}),
contactName: contactName !== undefined ? (contactName || null) : event.contactName,
contactPhone: contactPhone !== undefined ? (contactPhone || null) : event.contactPhone,
contactEmail: contactEmail !== undefined ? (contactEmail || null) : event.contactEmail,
location: location !== undefined ? (location || null) : event.location,
updatedAt: new Date(),
redirectUrl: redirectUrl !== undefined ? redirectUrl : event.redirectUrl,
};
@@ -513,6 +619,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 || '');
@@ -520,12 +627,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;
@@ -610,12 +719,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
@@ -632,7 +759,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: {
@@ -642,6 +769,7 @@ const createEventOption = async (req, res) => {
price: parseFloat(price),
isMainTicket: isMainTicket || false,
stockLimit: stockLimit ? parseInt(stockLimit, 10) : 0,
staffPrice: parseStaffPrice(staffPrice),
}
});
@@ -668,7 +796,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 },
@@ -677,29 +805,53 @@ 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),
}
});
// If earlyBirdTiers provided, replace all tiers for this option (including per-variant ones)
// If earlyBirdTiers provided, upsert tiers for this option (including per-variant ones) by
// id — mirrors the variants upsert just below. Editing tiers used to delete-and-recreate
// every tier for the option on every save, which silently reassigned each one a brand-new
// id; RegistrationOption/RegistrationOptionTranche.appliedTierId (onDelete: SetNull) then
// pointed at nothing, so every past purchase under that tier lost its "early bird"
// attribution — even for tiers the admin didn't touch — and any stock-limit count for the
// tier reset to zero (allowing it to be oversold). Upserting by id keeps existing tiers'
// ids stable across edits so that history stays linked.
if (Array.isArray(earlyBirdTiers)) {
try { await prisma.earlyBirdTier.deleteMany({ where: { eventOptionId: updatedEventOption.id } }); } catch {}
const incomingIds = earlyBirdTiers.filter(t => t && t.id).map(t => t.id);
const existingTiers = await prisma.earlyBirdTier.findMany({ where: { eventOptionId: updatedEventOption.id } });
for (const et of existingTiers) {
if (incomingIds.includes(et.id)) continue;
// Never delete a tier that's still attributed on past purchases — just leave it
// orphaned from the option's active tier list rather than nulling out history.
const usageCount = await prisma.registrationOptionTranche.count({ where: { appliedTierId: et.id } });
if (usageCount === 0) {
try { await prisma.earlyBirdTier.delete({ where: { id: et.id } }); } catch {}
}
}
for (let i = 0; i < earlyBirdTiers.length; i++) {
const t = earlyBirdTiers[i];
if (!t || !t.deadline || (t.price === undefined || t.price === null)) continue;
const deadline = new Date(t.deadline);
const p = parseFloat(t.price);
if (!(deadline instanceof Date) || isNaN(deadline.getTime()) || !(p >= 0)) continue;
await prisma.earlyBirdTier.create({
data: {
id: require('uuid').v4(),
eventOptionId: updatedEventOption.id,
variantId: t.variantId || null,
deadline,
price: p,
order: typeof t.order === 'number' ? t.order : i,
stockLimit: t.stockLimit ? parseInt(t.stockLimit, 10) : 0,
}
});
const data = {
eventOptionId: updatedEventOption.id,
variantId: t.variantId || null,
deadline,
price: p,
order: typeof t.order === 'number' ? t.order : i,
stockLimit: t.stockLimit ? parseInt(t.stockLimit, 10) : 0,
};
if (t.id) {
await prisma.earlyBirdTier.upsert({
where: { id: t.id },
update: data,
create: { id: t.id, ...data },
});
} else {
await prisma.earlyBirdTier.create({ data: { id: require('uuid').v4(), ...data } });
}
}
}
@@ -722,10 +874,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({
@@ -735,6 +888,7 @@ const updateEventOption = async (req, res) => {
name: v.name,
price: variantPrice,
stockLimit: variantStockLimit,
staffPrice: variantStaffPrice,
order: i,
}
});
@@ -742,6 +896,10 @@ const updateEventOption = async (req, res) => {
}
}
if (stockLimit !== undefined || variants !== undefined) {
require('../utils/waitlist').queueWaitlist(eventOption.eventId);
}
const refreshed = await prisma.eventOption.findUnique({
where: { id: updatedEventOption.id },
include: { earlyBirdTiers: true, variants: { orderBy: { order: 'asc' } } }
@@ -812,7 +970,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: {
@@ -821,6 +979,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,
}
});
@@ -838,11 +997,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,
@@ -883,7 +1043,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);
}
});
@@ -1224,6 +1387,7 @@ const emailEventAttendees = async (req, res) => {
const { sendMail } = require('../utils/email');
const { computeRegistrationTotalDue } = require('../utils/pricing');
const { replacePlaceholders } = require('../utils/placeholders');
function fmtAmount(amt) {
const n = Number(amt || 0);
@@ -1232,17 +1396,6 @@ const emailEventAttendees = async (req, res) => {
function fmtDate(d) {
try { return new Date(d).toLocaleString(); } catch { return String(d); }
}
function replacePlaceholders(str, ctx) {
if (!str) return str;
return String(str)
.replace(/\{\{\s*name\s*\}\}/g, ctx.name || '')
.replace(/\{\{\s*event\.title\s*\}\}/g, ctx.eventTitle || '')
.replace(/\{\{\s*event\.start\s*\}\}/g, ctx.eventStart || '')
.replace(/\{\{\s*event\.(link|url)\s*\}\}/g, (ctx.eventLinkHtml || ctx.eventLink || ''))
.replace(/\{\{\s*promo\.title\s*\}\}/g, ctx.promoTitle || '')
.replace(/\{\{\s*promo\.(link|url)\s*\}\}/g, (ctx.promoLinkHtml || ctx.promoLink || ''))
.replace(/\{\{\s*balance\s*\}\}/g, ctx.balanceFmt || '');
}
// Build per-recipient registration aggregates for this event
const regsByEmail = new Map();
@@ -1253,42 +1406,66 @@ const emailEventAttendees = async (req, res) => {
regsByEmail.get(em).push(r);
}
let sent = 0;
const baseUrl = (process.env.FRONTEND_URL || process.env.APP_BASE_URL || 'http://localhost:3001').replace(/\/$/, '');
// Special handling for tickets template: trigger ticket emails containing attachments
// Generates a live Yoco checkout link for the recipient's first registration with an
// outstanding balance — only called when the template actually uses {{payment.link}}, to
// avoid an unnecessary Yoco API call per recipient otherwise.
function makePaymentLinkResolver(regs) {
return async () => {
const reg = (regs || []).find(r => {
const due = computeRegistrationTotalDue(r, new Date());
const paid = (r.payments || []).reduce((s, p) => s + (p.amount || 0), 0);
return due - paid > 0.01;
});
if (!reg) return '';
const { createRegistrationCheckoutInternal } = require('./paymentController');
const result = await createRegistrationCheckoutInternal(reg.id, reg.userId, {
successUrl: `${baseUrl}/payment/success`,
cancelUrl: `${baseUrl}/payment/cancel`,
failureUrl: `${baseUrl}/payment/failure`,
});
return result.redirectUrl;
};
}
// Fire-and-forget: respond immediately with a queued count, then send in the background.
// Large recipient lists used to block the request until every email was sent — now the
// caller gets an instant response and failures are just logged server-side.
if (template === 'tickets') {
const { emailTickets } = require('./ticketController');
for (const rcpt of recipients) {
try {
res.json({ eventId, matched: recipients.length, queued: recipients.length, template: 'tickets' });
(async () => {
const results = await Promise.allSettled(recipients.map(async rcpt => {
const regs = regsByEmail.get(rcpt.email) || [];
// Send tickets for each registration that belongs to this recipient for this event
for (const reg of regs) {
// Use the controller helper as in other parts of the code
const mockReq = { user: { id: reg.userId }, body: { registrationId: reg.id } };
const mockRes = { status: () => mockRes, json: () => {} };
await emailTickets(mockReq, mockRes);
}
sent++;
} catch (e) {
try { console.warn('[email-attendees tickets] Failed for', rcpt.email, e?.message || e); } catch {}
}
}
return res.json({ eventId, matched: recipients.length, sent, template: 'tickets' });
}));
results.forEach((r, i) => {
if (r.status === 'rejected') {
try { console.warn('[email-attendees tickets] Failed for', recipients[i]?.email, r.reason?.message || r.reason); } catch {}
}
});
})();
return;
}
const eventTitle = event?.title || 'the event';
const eventStart = event?.startDate ? fmtDate(event.startDate) : '';
// Build event and promo links for placeholders
const baseUrl = (process.env.FRONTEND_URL || process.env.APP_BASE_URL || 'http://localhost:3001').replace(/\/$/, '');
const eventLink = `${baseUrl}/events/${encodeURIComponent(event.id)}`;
const eventLinkHtml = `<a href="${eventLink}">${eventLink}</a>`;
const promoTitle = promoEvent?.title || '';
const promoLink = promoEvent ? `${baseUrl}/events/${encodeURIComponent(promoEvent.id)}` : '';
const promoLinkHtml = promoLink ? `<a href="${promoLink}">${promoLink}</a>` : '';
// Send individually with personalization
for (const rcpt of recipients) {
try {
res.json({ eventId, matched: recipients.length, queued: recipients.length, template: template || 'custom' });
(async () => {
const results = await Promise.allSettled(recipients.map(async rcpt => {
const regs = regsByEmail.get(rcpt.email) || [];
// Sum outstanding balance across this user's registrations for the event
let totalDue = 0; let totalPaid = 0;
@@ -1298,6 +1475,7 @@ const emailEventAttendees = async (req, res) => {
totalDue += due; totalPaid += paid;
}
const balance = Math.max(totalDue - totalPaid, 0);
const paymentLinkResolver = makePaymentLinkResolver(regs);
// Build context for placeholder replacement
const ctxBase = {
name: rcpt.name || '',
@@ -1308,6 +1486,7 @@ const emailEventAttendees = async (req, res) => {
promoLink,
balance,
balanceFmt: fmtAmount(balance),
paymentLinkResolver,
};
const ctxHtml = {
...ctxBase,
@@ -1345,18 +1524,18 @@ const emailEventAttendees = async (req, res) => {
}
// Always perform placeholder replacement on whatever we have
const finalSubject = replacePlaceholders(subj || '', ctxBase);
const finalHtml = h ? replacePlaceholders(h, ctxHtml) : undefined;
const finalText = (!h ? replacePlaceholders(t || '', ctxBase) : undefined);
const finalSubject = await replacePlaceholders(subj || '', ctxBase);
const finalHtml = h ? await replacePlaceholders(h, ctxHtml) : undefined;
const finalText = (!h ? await replacePlaceholders(t || '', ctxBase) : undefined);
await sendMail({ to: rcpt.email, subject: finalSubject, html: finalHtml, text: finalText });
sent++;
} catch (e) {
try { console.warn('[email-attendees] Failed for', rcpt.email, e?.message || e); } catch {}
}
}
return res.json({ eventId, matched: recipients.length, sent, template: template || 'custom' });
}));
results.forEach((r, i) => {
if (r.status === 'rejected') {
try { console.warn('[email-attendees] Failed for', recipients[i]?.email, r.reason?.message || r.reason); } catch {}
}
});
})();
} catch (error) {
return res.status(400).json({ message: error.message });
}
@@ -1443,6 +1622,7 @@ const whatsappEventAttendees = async (req, res) => {
const { sendText } = require('../utils/whatsapp');
const { computeRegistrationTotalDue } = require('../utils/pricing');
const { replacePlaceholders } = require('../utils/placeholders');
function fmtAmount(amt) {
const n = Number(amt || 0);
@@ -1451,15 +1631,6 @@ const whatsappEventAttendees = async (req, res) => {
function fmtDate(d) {
try { return new Date(d).toLocaleString(); } catch { return String(d); }
}
function replacePlaceholders(str, ctx) {
if (!str) return str;
return String(str)
.replace(/\{\{\s*name\s*\}\}/g, ctx.name || '')
.replace(/\{\{\s*event\.title\s*\}\}/g, ctx.eventTitle || '')
.replace(/\{\{\s*event\.start\s*\}\}/g, ctx.eventStart || '')
.replace(/\{\{\s*event\.(link|url)\s*\}\}/g, ctx.eventLink || '')
.replace(/\{\{\s*balance\s*\}\}/g, ctx.balanceFmt || '');
}
const regsByPhone = new Map();
for (const r of registrations) {
@@ -1474,29 +1645,52 @@ const whatsappEventAttendees = async (req, res) => {
const baseUrl = (process.env.FRONTEND_URL || process.env.APP_BASE_URL || 'http://localhost:3001').replace(/\/$/, '');
const eventLink = `${baseUrl}/events/${encodeURIComponent(event.id)}`;
let sent = 0;
// Generates a live Yoco checkout link for the recipient's first registration with an
// outstanding balance — only called when the template actually uses {{payment.link}}.
function makePaymentLinkResolver(regs) {
return async () => {
const reg = (regs || []).find(r => {
const due = computeRegistrationTotalDue(r, new Date());
const paid = (r.payments || []).reduce((s, p) => s + (p.amount || 0), 0);
return due - paid > 0.01;
});
if (!reg) return '';
const { createRegistrationCheckoutInternal } = require('./paymentController');
const result = await createRegistrationCheckoutInternal(reg.id, reg.userId, {
successUrl: `${baseUrl}/payment/success`,
cancelUrl: `${baseUrl}/payment/cancel`,
failureUrl: `${baseUrl}/payment/failure`,
});
return result.redirectUrl;
};
}
// Special handling for tickets template: send ticket PDFs via WhatsApp
// Fire-and-forget: respond immediately with a queued count, then send in the background.
if (template === 'tickets') {
const { emailTickets } = require('./ticketController');
for (const rcpt of recipients) {
try {
res.json({ eventId, matched: recipients.length, queued: recipients.length, template: 'tickets' });
(async () => {
const results = await Promise.allSettled(recipients.map(async rcpt => {
const regs = regsByPhone.get(rcpt.phone) || [];
for (const reg of regs) {
const mockReq = { user: { id: reg.userId }, body: { registrationId: reg.id } };
const mockRes = { status: () => mockRes, json: () => {} };
await emailTickets(mockReq, mockRes);
}
sent++;
} catch (e) {
try { console.warn('[whatsapp-attendees tickets] Failed for', rcpt.phone, e?.message || e); } catch {}
}
}
return res.json({ eventId, matched: recipients.length, sent, template: 'tickets' });
}));
results.forEach((r, i) => {
if (r.status === 'rejected') {
try { console.warn('[whatsapp-attendees tickets] Failed for', recipients[i]?.phone, r.reason?.message || r.reason); } catch {}
}
});
})();
return;
}
for (const rcpt of recipients) {
try {
res.json({ eventId, matched: recipients.length, queued: recipients.length, template: template || 'custom' });
(async () => {
const results = await Promise.allSettled(recipients.map(async rcpt => {
const regs = regsByPhone.get(rcpt.phone) || [];
let totalDue = 0; let totalPaid = 0;
for (const r of regs) {
@@ -1512,6 +1706,7 @@ const whatsappEventAttendees = async (req, res) => {
eventLink,
balance,
balanceFmt: fmtAmount(balance),
paymentLinkResolver: makePaymentLinkResolver(regs),
};
let msg = message;
@@ -1521,20 +1716,29 @@ const whatsappEventAttendees = async (req, res) => {
msg = `Hi {{name}}\n\nA quick reminder about {{event.title}}.\nStart: {{event.start}}\n\nWe look forward to seeing you!`;
}
const finalMessage = replacePlaceholders(msg || '', ctx);
const finalMessage = await replacePlaceholders(msg || '', ctx);
await sendText(rcpt.phone, finalMessage);
sent++;
} catch (e) {
try { console.warn('[whatsapp-attendees] Failed for', rcpt.phone, e?.message || e); } catch {}
}
}
return res.json({ eventId, matched: recipients.length, sent, template: template || 'custom' });
}));
results.forEach((r, i) => {
if (r.status === 'rejected') {
try { console.warn('[whatsapp-attendees] Failed for', recipients[i]?.phone, r.reason?.message || r.reason); } catch {}
}
});
})();
} catch (error) {
return res.status(400).json({ message: error.message });
}
};
// Human-readable summary of who an attendees-scoped send will go to, for the scheduled-jobs admin UI
function describeAttendeeFilter(filter) {
if (Array.isArray(filter?.attendeeIds) && filter.attendeeIds.length > 0) {
return `${filter.attendeeIds.length} selected attendee${filter.attendeeIds.length === 1 ? '' : 's'}`;
}
const labels = { paid: 'Paid attendees', unpaid: 'Unpaid attendees', partial_paid: 'Partially paid attendees', cancelled: 'Cancelled registrations' };
return labels[filter?.status] || 'All attendees';
}
// @desc Schedule email to attendees at a specific date/time
// @route POST /api/events/:id/email-attendees/schedule
// @access Private/Supervisor or Admin
@@ -1566,6 +1770,7 @@ const scheduleEmailEventAttendees = async (req, res) => {
eventId,
createdById: req.user?.id || null,
scheduledAt: when.toISOString(),
recipientSummary: `${event.title} — ${describeAttendeeFilter(filter)}`,
payload,
});
@@ -1603,6 +1808,7 @@ const scheduleWhatsappEventAttendees = async (req, res) => {
channel: 'whatsapp',
createdById: req.user?.id || null,
scheduledAt: when.toISOString(),
recipientSummary: `${event.title} — ${describeAttendeeFilter(filter)}`,
payload,
});
@@ -1617,9 +1823,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: {
@@ -1668,6 +1885,7 @@ module.exports = {
getAllEvents,
getEventsAll,
getEventById,
getEventIcs,
updateEvent,
getEventNotifyRecipients,
updateEventNotifyRecipients,
+298 -98
View File
@@ -2,10 +2,13 @@ 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');
const { assertEventOpen } = require('../utils/cashupUtils');
const { assertEventOpen, assertRegistrationEventOpen } = require('../utils/cashupUtils');
// @desc Create a new payment
// @route POST /api/payments
@@ -78,7 +81,7 @@ const createPayment = async (req, res) => {
registration = await prisma.registration.findUnique({
where: { id: registrationId },
include: {
registrationOptions: { include: { eventOption: { include: { earlyBirdTiers: true } } } },
registrationOptions: { include: { eventOption: { include: { earlyBirdTiers: true } }, tranches: true } },
payments: true,
user: { select: { id: true } }
}
@@ -134,7 +137,7 @@ const createPayment = async (req, res) => {
registration = await prisma.registration.findUnique({
where: { id: registrationId },
include: {
registrationOptions: { include: { eventOption: { include: { earlyBirdTiers: true } } } },
registrationOptions: { include: { eventOption: { include: { earlyBirdTiers: true } }, tranches: true } },
payments: true,
user: { select: { id: true } }
}
@@ -157,6 +160,7 @@ const createPayment = async (req, res) => {
amount: requestedAmount,
method,
userId,
recordedById: req.user.id,
registrationId: null,
eventId: registration.eventId,
isDonation: true,
@@ -164,6 +168,7 @@ const createPayment = async (req, res) => {
},
include: {
user: { select: { id: true, name: true, email: true } },
recordedBy: { select: { id: true, name: true, email: true } },
event: true
}
});
@@ -175,6 +180,7 @@ const createPayment = async (req, res) => {
amount: applyAmount,
method,
userId,
recordedById: req.user.id,
registrationId,
eventId: registrationEventId || eventId || null,
isDonation: false,
@@ -182,6 +188,7 @@ const createPayment = async (req, res) => {
},
include: {
user: { select: { id: true, name: true, email: true } },
recordedBy: { select: { id: true, name: true, email: true } },
registration: { include: { event: true } },
event: (registrationEventId || eventId) ? true : undefined
}
@@ -195,6 +202,7 @@ const createPayment = async (req, res) => {
amount: excess,
method,
userId,
recordedById: req.user.id,
registrationId: null,
eventId: registration.eventId,
isDonation: true,
@@ -212,6 +220,7 @@ const createPayment = async (req, res) => {
amount: parseFloat(amount),
method,
userId,
recordedById: req.user.id,
registrationId: registrationId || null,
eventId: registrationEventId || eventId || null,
isDonation: isDonation || false,
@@ -219,6 +228,7 @@ const createPayment = async (req, res) => {
},
include: {
user: { select: { id: true, name: true, email: true } },
recordedBy: { select: { id: true, name: true, email: true } },
registration: registrationId ? { include: { event: true } } : undefined,
event: (registrationEventId || eventId) ? true : undefined
}
@@ -235,7 +245,8 @@ const createPayment = async (req, res) => {
include: {
registrationOptions: {
include: {
eventOption: { include: { earlyBirdTiers: true } }
eventOption: { include: { earlyBirdTiers: true } },
tranches: true
}
},
payments: true
@@ -329,6 +340,7 @@ const getPayments = async (req, res) => {
const include = {
user: { select: { id: true, name: true, email: true } },
recordedBy: { select: { id: true, name: true, email: true } },
registration: {
include: {
event: true,
@@ -357,8 +369,16 @@ const USER_FACING_METHODS = ['cash', 'card', 'eft', 'voucher'];
const CARD_ALIASES = ['apple_pay', 'google_pay'];
const KNOWN_METHODS = [...USER_FACING_METHODS, ...CARD_ALIASES];
function normalizeUserMethod(method) {
// Refund methods are recorded as "<method>-refund" (e.g. "card-refund") so a refund nets
// against the same bucket its original payment counted under. Strip that suffix before
// bucketing so a card refund still displays/filters as "card", not "other".
function stripRefundSuffix(method) {
const m = String(method || '').toLowerCase();
return m.endsWith('-refund') ? m.slice(0, -'-refund'.length) : m;
}
function normalizeUserMethod(method) {
const m = stripRefundSuffix(method);
if (USER_FACING_METHODS.includes(m)) return m;
if (CARD_ALIASES.includes(m)) return 'card';
return 'other';
@@ -386,18 +406,21 @@ const getUserPayments = async (req, res) => {
if (req.query.method) {
const requested = String(req.query.method).toLowerCase();
if (requested === 'card') {
// "Card" also covers card-network wallet types (apple_pay, google_pay) — same
// settlement as a card payment, no separate float to reconcile.
// "Card" also covers card-network wallet types (apple_pay, google_pay) and card
// refunds — same settlement as a card payment, no separate float to reconcile.
where.AND = [{
OR: ['card', ...CARD_ALIASES].map(m => ({ method: { equals: m, mode: 'insensitive' } }))
OR: ['card', 'card-refund', ...CARD_ALIASES].map(m => ({ method: { equals: m, mode: 'insensitive' } }))
}];
} else if (requested === 'other') {
// "Other" covers every method that isn't one of the recognized buckets above.
// "Other" covers every method (and its refund variant) that isn't one of the
// recognized buckets above.
where.NOT = {
OR: KNOWN_METHODS.map(m => ({ method: { equals: m, mode: 'insensitive' } }))
OR: KNOWN_METHODS.flatMap(m => [m, `${m}-refund`]).map(m => ({ method: { equals: m, mode: 'insensitive' } }))
};
} else if (USER_FACING_METHODS.includes(requested)) {
where.method = { equals: requested, mode: 'insensitive' };
where.AND = [{
OR: [requested, `${requested}-refund`].map(m => ({ method: { equals: m, mode: 'insensitive' } }))
}];
}
}
@@ -452,6 +475,13 @@ const getPaymentById = async (req, res) => {
email: true
}
},
recordedBy: {
select: {
id: true,
name: true,
email: true
}
},
registration: {
include: {
event: true
@@ -507,6 +537,13 @@ const getPaymentsByRegistration = async (req, res) => {
name: true,
email: true
}
},
recordedBy: {
select: {
id: true,
name: true,
email: true
}
}
}
});
@@ -533,6 +570,7 @@ const getPaymentsByEvent = async (req, res) => {
},
include: {
user: { select: { id: true, name: true, email: true } },
recordedBy: { select: { id: true, name: true, email: true } },
registration: {
include: { user: { select: { id: true, name: true, email: true } } }
}
@@ -576,10 +614,16 @@ const assignDonationToRegistration = async (req, res) => {
throw new Error('Only donations can be assigned to registrations');
}
// Check if payment is already assigned to a registration
if (payment.registrationId) {
// 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 remainingDonation = computeDonationRemaining(payment.amount, existingLegs);
if (remainingDonation <= 0.000001) {
res.status(400);
throw new Error('This payment is already assigned to a registration');
throw new Error('This donation has already been fully allocated');
}
if (payment.eventId) {
@@ -596,7 +640,8 @@ const assignDonationToRegistration = async (req, res) => {
include: {
registrationOptions: {
include: {
eventOption: { include: { earlyBirdTiers: true } }
eventOption: { include: { earlyBirdTiers: true } },
tranches: true
}
},
payments: true
@@ -628,16 +673,16 @@ const assignDonationToRegistration = async (req, res) => {
}
// How much of the donation to apply — defaults to today's behaviour (as much as the
// donation covers, capped at what's owed) but staff can specify a smaller amount and
// deliberately leave the registrant owing a balance.
let allocateAmount = amount != null ? Number(amount) : Math.min(payment.amount, remainingAmount);
// donation's remaining balance covers, capped at what's owed) but staff can specify a
// smaller amount and deliberately leave the registrant owing a balance.
let allocateAmount = amount != null ? Number(amount) : Math.min(remainingDonation, remainingAmount);
if (!(allocateAmount > 0) || Number.isNaN(allocateAmount)) {
res.status(400);
throw new Error('Allocation amount must be greater than zero');
}
if (allocateAmount > payment.amount) {
if (allocateAmount > remainingDonation) {
res.status(400);
throw new Error('Cannot allocate more than the donation amount');
throw new Error(`Cannot allocate more than the donation's remaining balance of R${remainingDonation.toFixed(2)}`);
}
if (allocateAmount > remainingAmount) {
res.status(400);
@@ -647,39 +692,24 @@ const assignDonationToRegistration = async (req, res) => {
let updatedRegistration;
let generatedTickets = [];
let originalPaymentId = payment.id;
let splitPayment = null;
// Update the payment to be associated with the registration and adjust amount
await prisma.payment.update({
where: { id: payment.id },
// Create an immutable leg referencing the donation — the donation row itself is never
// touched, so its original amount and history stay intact and it can be assigned again
// later if this leg doesn't use it up.
const leg = await prisma.payment.create({
data: {
registrationId,
id: uuidv4(),
amount: allocateAmount,
isDonation: false
method: payment.method,
userId: payment.userId,
recordedById: req.user.id,
registrationId,
eventId: registration.eventId,
isDonation: false,
originalPaymentId: payment.id,
}
});
// If less than the full donation was allocated, the remainder stays as an unassigned
// donation (same donor, no notification — it's a bookkeeping split, not a new gift).
if (allocateAmount < payment.amount) {
const leftoverAmount = payment.amount - allocateAmount;
splitPayment = await prisma.payment.create({
data: {
id: uuidv4(),
amount: leftoverAmount,
method: payment.method,
userId: payment.userId,
eventId: payment.eventId,
isDonation: true,
externalId: payment.externalId ? `${payment.externalId}-split` : null,
status: payment.status,
originalPaymentId: payment.id,
createdAt: payment.createdAt
}
});
}
if (allocateAmount >= remainingAmount) {
// Fully covers what's owed
updatedRegistration = await prisma.registration.update({
@@ -707,9 +737,9 @@ const assignDonationToRegistration = async (req, res) => {
}
// Fire-and-forget: notify the registrant (not the donor — see sendDonationAssignmentEmails),
// then tickets (guarantees order). The split/leftover payment is never notified.
// then tickets (guarantees order).
const { sendDonationAssignmentEmails } = require('../utils/notifications');
const _adPaymentId = payment?.id;
const _adPaymentId = leg?.id;
const _adShouldEmailTickets = generatedTickets.length > 0;
const _adUserId = registration?.userId;
const _adRegId = registrationId;
@@ -730,18 +760,178 @@ const assignDonationToRegistration = async (req, res) => {
originalPaymentId,
updatedRegistration,
generatedTickets: generatedTickets.length > 0 ? generatedTickets : undefined,
splitPayment: splitPayment ? {
...splitPayment,
originalPaymentId: payment.id
} : null
leg,
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) });
}
};
// @desc Reverse a previous donation assignment — hard-deletes the leg payment and reverts
// the registration's status/tickets. The donation itself (never touched by assign)
// is unaffected, so its remaining balance simply goes back up.
// @route POST /api/payments/unassign-donation
// @access Private/Supervisor
const unassignDonationFromRegistration = async (req, res) => {
try {
const { legId } = req.body;
if (!legId) {
res.status(400);
throw new Error('legId is required');
}
const leg = await prisma.payment.findUnique({ where: { id: legId } });
if (!leg) {
res.status(404);
throw new Error('Payment not found');
}
// Mirror the frontend's isDonationLeg check: a real donation-assignment leg is a positive,
// non-donation payment that references a donation via originalPaymentId. This also rejects
// refund rows, which set originalPaymentId too but always with a negative amount.
if (leg.isDonation || !leg.originalPaymentId || !(leg.amount > 0)) {
res.status(400);
throw new Error('This payment is not a donation-assignment leg');
}
if (!leg.registrationId) {
res.status(400);
throw new Error('This leg is not linked to a registration');
}
const donation = await prisma.payment.findUnique({ where: { id: leg.originalPaymentId } });
if (!donation || !donation.isDonation) {
res.status(400);
throw new Error('The original donation for this leg could not be found');
}
await assertRegistrationEventOpen(leg.registrationId, res);
const registration = await prisma.registration.findUnique({
where: { id: leg.registrationId },
include: {
registrationOptions: { include: { eventOption: { include: { earlyBirdTiers: true } }, tranches: true } },
payments: true
}
});
if (!registration) {
res.status(404);
throw new Error('Registration not found');
}
const originalStatus = registration.status;
const totalPaidExisting = registration.payments.reduce((sum, p) => sum + p.amount, 0);
const totalDue = computeRegistrationTotalDue(registration, new Date());
const totalPaidAfter = totalPaidExisting - leg.amount;
const willDowngradeFromPaid = (originalStatus === 'paid') && (totalPaidAfter < totalDue);
if (willDowngradeFromPaid) {
// Same guard createRefund uses: block if any ticket on the registration has been scanned.
const regTickets = await prisma.ticket.findMany({
where: { registrationOption: { registrationId: leg.registrationId } },
include: { usages: true }
});
const hasUsed = regTickets.some(t => t.isUsed || (t.usages && t.usages.length > 0));
if (hasUsed) {
res.status(400);
throw new Error('Cannot unassign this donation because a ticket has already been used');
}
}
await prisma.payment.delete({ where: { id: legId } });
// Recompute status the same way createRefund does after removing money from a registration.
const updatedRegistration = await prisma.registration.findUnique({
where: { id: leg.registrationId },
include: {
registrationOptions: { include: { eventOption: { include: { earlyBirdTiers: true } }, tranches: true } },
payments: true
}
});
let finalRegistration = updatedRegistration;
if (updatedRegistration) {
const totalPaid = updatedRegistration.payments.reduce((sum, p) => sum + p.amount, 0);
const totalDueNow = computeRegistrationTotalDue(updatedRegistration, new Date());
let newStatus;
if (totalPaid >= totalDueNow) newStatus = 'paid';
else if (totalPaid > 0) newStatus = 'partial_paid';
else newStatus = 'pending';
finalRegistration = await prisma.registration.update({
where: { id: leg.registrationId },
data: { status: newStatus, updatedAt: new Date() }
});
// Same blunt scope createRefund uses — tickets aren't tagged per-leg, so a downgrade
// clears every unused ticket on the registration, not just the ones this leg funded.
if (originalStatus === 'paid' && newStatus !== 'paid') {
await prisma.ticket.deleteMany({
where: {
registrationOption: { registrationId: leg.registrationId },
isUsed: false
}
});
}
}
// Fire-and-forget notification
const { sendDonationUnassignmentEmails } = require('../utils/notifications');
const _udLeg = { id: leg.id, amount: leg.amount, createdAt: leg.createdAt, method: leg.method, externalId: leg.externalId, userId: leg.userId, registrationId: leg.registrationId };
(async () => {
try { await sendDonationUnassignmentEmails(_udLeg); }
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,
donationId: donation.id
});
} catch (error) {
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
}
};
/**
* 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 } = {}) {
@@ -752,9 +942,11 @@ async function createRegistrationCheckoutInternal(registrationId, userId, { succ
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 } },
event: true
}
@@ -770,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) {
@@ -866,7 +1039,7 @@ const createYocoCheckout = async (req, res) => {
const freshReg = await prisma.registration.findUnique({
where: { id: registrationId },
include: {
registrationOptions: { include: { eventOption: { include: { earlyBirdTiers: true } } } },
registrationOptions: { include: { eventOption: { include: { earlyBirdTiers: true } }, tranches: true } },
payments: true
}
});
@@ -889,9 +1062,11 @@ const createYocoCheckout = async (req, res) => {
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 } },
event: true
}
@@ -905,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) } });
@@ -1029,20 +1190,22 @@ const sendPaymentLink = async (req, res) => {
const { user, event } = registration;
const message = `Hi ${user.name || ''}, here's your payment link for ${event?.title || 'your registration'}: ${redirectUrl}`;
// Validation is synchronous (fast, no network); the actual send is backgrounded since
// SMTP/WAWP round trips shouldn't block this request.
if (channel === 'email') {
if (!user.email) { res.status(400); throw new Error('This user has no email address on file'); }
const { sendMail } = require('../utils/email');
await sendMail({
sendMail({
to: user.email,
subject: `Payment link — ${event?.title || 'Registration'}`,
text: message,
html: `<p>Hi ${user.name || ''},</p><p>Here's your payment link for <strong>${event?.title || 'your registration'}</strong>:</p><p><a href="${redirectUrl}">${redirectUrl}</a></p>`
});
}).catch(e => console.error('Failed to send payment link email:', e));
} else {
const { isValidZAPhone } = require('../utils/whatsapp');
if (!isValidZAPhone(user.phoneNumber)) { res.status(400); throw new Error('This user has no valid WhatsApp number on file'); }
const { waTextAny } = require('../utils/notify');
await waTextAny(user, message);
waTextAny(user, message).catch(e => console.error('Failed to send payment link via WhatsApp:', e));
}
return res.status(200).json({ sent: true, channel });
@@ -1117,7 +1280,7 @@ const createRefund = async (req, res) => {
const registration = await prisma.registration.findUnique({
where: { id: linkRegistrationId },
include: {
registrationOptions: { include: { eventOption: { include: { earlyBirdTiers: true } } } },
registrationOptions: { include: { eventOption: { include: { earlyBirdTiers: true } }, tranches: true } },
payments: true,
// Load tickets to check usage if needed
_count: true
@@ -1151,6 +1314,7 @@ const createRefund = async (req, res) => {
amount: -Math.abs(amt),
method: method || 'refund',
userId,
recordedById: req.user.id,
registrationId: linkRegistrationId,
eventId: linkEventId,
isDonation: false,
@@ -1159,6 +1323,7 @@ const createRefund = async (req, res) => {
},
include: {
user: { select: { id: true, name: true, email: true } },
recordedBy: { select: { id: true, name: true, email: true } },
registration: { include: { event: true } },
event: true
}
@@ -1169,7 +1334,7 @@ const createRefund = async (req, res) => {
const registration = await prisma.registration.findUnique({
where: { id: linkRegistrationId },
include: {
registrationOptions: { include: { eventOption: { include: { earlyBirdTiers: true } } } },
registrationOptions: { include: { eventOption: { include: { earlyBirdTiers: true } }, tranches: true } },
payments: true
}
});
@@ -1198,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) });
@@ -1210,6 +1385,12 @@ const getPaymentStats = async (req, res) => {
const lastWeek = new Date(startOfDay.getTime() - 7 * 24 * 60 * 60 * 1000);
const lastMonth = new Date(startOfDay.getTime() - 30 * 24 * 60 * 60 * 1000);
// Exclude donation-application legs — a leg re-labels part of an already-counted donation
// as applied to a registration, it isn't new money. Summing both would double-count it.
const excludeDonationLegs = {
NOT: { AND: [{ isDonation: false }, { originalPaymentId: { not: null } }, { amount: { gt: 0 } }] }
};
const [totalToday, totalWeek, totalMonth] = await Promise.all([
prisma.payment.aggregate({
_sum: {
@@ -1218,7 +1399,8 @@ const getPaymentStats = async (req, res) => {
where: {
createdAt: {
gte: startOfDay
}
},
...excludeDonationLegs
}
}),
prisma.payment.aggregate({
@@ -1228,7 +1410,8 @@ const getPaymentStats = async (req, res) => {
where: {
createdAt: {
gte: lastWeek
}
},
...excludeDonationLegs
}
}),
prisma.payment.aggregate({
@@ -1238,7 +1421,8 @@ const getPaymentStats = async (req, res) => {
where: {
createdAt: {
gte: lastMonth
}
},
...excludeDonationLegs
}
})
]);
@@ -1252,6 +1436,20 @@ const getPaymentStats = async (req, res) => {
}
}
// @desc Re-send the receipt PDF for one of the caller's own payments
// @route POST /api/payments/:id/send-receipt
// @access Private
const sendReceipt = async (req, res) => {
try {
const { channel } = req.body || {};
const { sendReceiptToUser } = require('../utils/notifications');
await sendReceiptToUser(req.params.id, req.user.id, channel);
res.json({ message: channel === 'whatsapp' ? 'Receipt sent to WhatsApp.' : 'Receipt emailed.' });
} catch (error) {
res.status(error.statusCode || 400).json({ message: error.message });
}
};
module.exports = {
createPayment,
getPayments,
@@ -1260,9 +1458,11 @@ module.exports = {
getPaymentsByRegistration,
getPaymentsByEvent,
assignDonationToRegistration,
unassignDonationFromRegistration,
createYocoCheckout,
createRegistrationCheckoutInternal,
sendPaymentLink,
createRefund,
getPaymentStats
getPaymentStats,
sendReceipt,
};
File diff suppressed because it is too large Load Diff
+454 -229
View File
@@ -1,269 +1,465 @@
const PDFDocument = require('pdfkit');
const ExcelJS = require('exceljs');
const fs = require('fs');
const path = require('path');
const nodemailer = require('nodemailer');
// Utility: draw a table
function drawTable(doc, startX, startY, colWidths, rows, header) {
let y = startY;
doc.font('Helvetica-Bold');
if (header && header.length) {
let x = startX;
header.forEach((h, i) => {
const w = colWidths[i] || 80;
doc.rect(x, y, w, 20).stroke();
doc.text(String(h || ''), x + 4, y + 6, { width: w - 8 });
x += w;
});
y += 20;
}
doc.font('Helvetica');
rows.forEach((row) => {
let x = startX;
row.forEach((cell, i) => {
const w = colWidths[i] || 80;
const h = 18;
doc.rect(x, y, w, h).stroke();
doc.text(String(cell ?? ''), x + 4, y + 4, { width: w - 8 });
x += w;
});
y += 18;
// New page if overflow
if (y > doc.page.height - 40) {
doc.addPage();
y = 20;
}
});
}
const { sendMail, emailWrapper } = require('../utils/email');
function a4Doc(orientation = 'portrait') {
return new PDFDocument({ size: 'A4', margin: 20, layout: orientation === 'landscape' ? 'landscape' : 'portrait' });
}
// Brand palette — mirrors the web reports' indigo theme and the dataviz-skill categorical
// palette used by the on-screen HorizontalBarChart, so exported PDFs/Excel look like the
// same report instead of a plain data dump.
const BRAND = '#4f46e5';
const BRAND_DARK = '#3730a3';
const BRAND_LIGHT = '#eef2ff';
const TEXT_DARK = '#111827';
const TEXT_MUTED = '#6b7280';
const BORDER = '#e5e7eb';
const TONE_COLORS = {
green: { bg: '#ecfdf5', accent: '#059669' },
blue: { bg: '#eff6ff', accent: '#2563eb' },
violet: { bg: '#f5f3ff', accent: '#7c3aed' },
amber: { bg: '#fffbeb', accent: '#d97706' },
rose: { bg: '#fff1f2', accent: '#e11d48' },
gray: { bg: '#f3f4f6', accent: '#4b5563' },
};
const CATEGORICAL_COLORS = ['#2a78d6', '#eb6834', '#1baf7a', '#eda100', '#e87ba4', '#4a3aa7', '#e34948'];
function isEmphasisRow(firstCell) {
const s = String(firstCell ?? '').trim();
return /^total$/i.test(s) || /net profit/i.test(s) || /unassigned donations/i.test(s) || /revenue per ticket/i.test(s);
}
function drawHeader(doc, title, subtitle) {
const pageWidth = doc.page.width - doc.page.margins.left - doc.page.margins.right;
const x = doc.page.margins.left;
const y = doc.page.margins.top;
const h = subtitle ? 46 : 32;
doc.save();
doc.roundedRect(x, y, pageWidth, h, 6).fill(BRAND);
doc.fillColor('#ffffff').font('Helvetica-Bold').fontSize(16).text(title || 'Report', x + 14, y + 9, { width: pageWidth - 28 });
if (subtitle) {
doc.font('Helvetica').fontSize(9).fillColor('#e0e7ff').text(subtitle, x + 14, y + 30, { width: pageWidth - 28 });
}
doc.restore();
doc.y = y + h + 14;
}
function drawStats(doc, stats) {
if (!Array.isArray(stats) || stats.length === 0) return;
const pageWidth = doc.page.width - doc.page.margins.left - doc.page.margins.right;
const perRow = Math.max(1, Math.min(5, Math.floor(pageWidth / 110)));
const gap = 8;
const boxW = (pageWidth - gap * (perRow - 1)) / perRow;
const boxH = 34;
let rowY = doc.y;
stats.forEach((s, i) => {
const col = i % perRow;
if (col === 0 && i !== 0) rowY += boxH + gap;
const bx = doc.page.margins.left + col * (boxW + gap);
const tone = TONE_COLORS[s.tone] || TONE_COLORS.gray;
doc.save();
doc.roundedRect(bx, rowY, boxW, boxH, 5).fill(tone.bg);
doc.fillColor(TEXT_MUTED).font('Helvetica').fontSize(7.5).text(String(s.label || ''), bx + 8, rowY + 6, { width: boxW - 16 });
doc.fillColor(tone.accent).font('Helvetica-Bold').fontSize(11).text(String(s.value || ''), bx + 8, rowY + 17, { width: boxW - 16 });
doc.restore();
});
doc.y = rowY + boxH + 16;
if (doc.y > doc.page.height - 60) doc.addPage();
}
function drawChart(doc, chart) {
if (!chart || !Array.isArray(chart.data) || chart.data.length === 0) return;
const pageWidth = doc.page.width - doc.page.margins.left - doc.page.margins.right;
const x = doc.page.margins.left;
if (chart.title) {
doc.fillColor(TEXT_MUTED).font('Helvetica-Bold').fontSize(9).text(chart.title, x, doc.y);
doc.moveDown(0.4);
}
const labelW = 110;
const valueW = 80;
const barAreaW = pageWidth - labelW - valueW - 16;
const max = Math.max(1, ...chart.data.map(d => Math.abs(d.value || 0)));
const rowH = 16;
chart.data.forEach((d, i) => {
const y = doc.y;
doc.fillColor(TEXT_DARK).font('Helvetica').fontSize(8).text(String(d.label || ''), x, y + 3, { width: labelW - 8 });
const trackX = x + labelW;
doc.roundedRect(trackX, y + 2, barAreaW, 8, 4).fill('#f3f4f6');
const w = Math.max(4, (Math.abs(d.value || 0) / max) * barAreaW);
doc.roundedRect(trackX, y + 2, w, 8, 4).fill(CATEGORICAL_COLORS[i % CATEGORICAL_COLORS.length]);
doc.fillColor(TEXT_DARK).font('Helvetica').fontSize(8).text(d.displayValue != null ? String(d.displayValue) : String(d.value), trackX + barAreaW + 8, y + 3, { width: valueW - 8, align: 'right' });
doc.y = y + rowH;
if (doc.y > doc.page.height - 60) doc.addPage();
});
doc.moveDown(0.8);
}
function drawNote(doc, note) {
if (!note) return;
const pageWidth = doc.page.width - doc.page.margins.left - doc.page.margins.right;
const x = doc.page.margins.left;
const y = doc.y;
doc.font('Helvetica-Oblique').fontSize(8.5);
const h = doc.heightOfString(note, { width: pageWidth - 20 }) + 14;
doc.save();
doc.roundedRect(x, y, pageWidth, h, 5).fill('#f9fafb');
doc.fillColor(TEXT_MUTED).text(note, x + 10, y + 7, { width: pageWidth - 20 });
doc.restore();
doc.y = y + h + 12;
if (doc.y > doc.page.height - 60) doc.addPage();
}
// Draws a single branded table (header band, zebra rows, highlighted total rows) at the
// document's current y — shared by the main table body and any extraTables sections below it.
function drawTable(doc, table) {
const columns = Array.isArray(table.columns) ? table.columns : [];
const colCount = columns.length || (table.rows[0] ? table.rows[0].length : 1);
const pageWidth = doc.page.width - doc.page.margins.left - doc.page.margins.right;
const baseWidth = Math.floor(pageWidth / Math.max(1, colCount));
const colWidths = new Array(colCount).fill(baseWidth);
if (colCount > 0) colWidths[0] = Math.floor(baseWidth * 1.2);
// Header band
if (columns.length) {
let x = doc.page.margins.left;
const y = doc.y;
doc.save();
doc.rect(x, y, pageWidth, 22).fill(BRAND_LIGHT);
doc.fillColor(BRAND_DARK).font('Helvetica-Bold').fontSize(10.5);
columns.forEach((h, i) => {
const w = colWidths[i] || baseWidth;
doc.text(String(h || ''), x + 6, y + 6, { width: w - 12 });
x += w;
});
doc.restore();
doc.moveDown(1.6);
}
// Rows — zebra striped, with a highlighted tint+bold for total/summary rows
const rows = table.rows;
rows.forEach((row) => {
const emphasis = isEmphasisRow(row[0]);
const rowY = doc.y;
const rowH = 18;
const bg = emphasis ? BRAND_LIGHT : (rows.indexOf(row) % 2 === 0 ? '#ffffff' : '#f9fafb');
doc.save();
doc.rect(doc.page.margins.left, rowY - 2, pageWidth, rowH + 4).fill(bg).restore();
let x = doc.page.margins.left;
row.forEach((cell, i) => {
const w = colWidths[i] || baseWidth;
doc.fillColor(emphasis ? BRAND_DARK : TEXT_DARK).font(emphasis ? 'Helvetica-Bold' : 'Helvetica').fontSize(9.5).text(String(cell ?? ''), x + 6, rowY, { width: w - 12 });
doc.strokeColor(BORDER).lineWidth(0.5).moveTo(x, rowY - 2).lineTo(x, rowY + rowH + 2).stroke();
x += w;
});
doc.strokeColor(BORDER).lineWidth(0.5).moveTo(doc.page.margins.left + pageWidth, rowY - 2).lineTo(doc.page.margins.left + pageWidth, rowY + rowH + 2).stroke();
doc.moveDown(1.1);
if (doc.y > doc.page.height - 40) {
doc.addPage();
}
});
doc.strokeColor(BORDER).lineWidth(0.5).moveTo(doc.page.margins.left, doc.y).lineTo(doc.page.margins.left + pageWidth, doc.y).stroke();
doc.moveDown(1);
}
// Shared drawing logic for all PDF-producing endpoints (download, email, WhatsApp) — draws a
// branded header, optional stat tiles / bar chart / note, then a 'table' or 'layered' body
// (plus any extraTables sections below it) onto whatever PDFDocument the caller gives it
// (streamed straight to the HTTP response for download, or to a temp file for email/WhatsApp).
function drawReportPdf(doc, { title, subtitle, kind, table, layered, stats, chart, note, extraTables }) {
drawHeader(doc, title, subtitle);
drawStats(doc, stats);
drawChart(doc, chart);
drawNote(doc, note);
// Path-drawing ops (rect/moveTo/lineTo, used throughout drawTable's borders) leave PDFKit's
// implicit text cursor (doc.x) at the last point drawn rather than the left margin, so every
// text() call below passes an explicit x — relying on the implicit cursor after a table has
// rendered puts the next label at the table's right edge instead of the margin.
const leftX = doc.page.margins.left;
if (kind === 'table' && table && Array.isArray(table.rows)) {
drawTable(doc, table);
} else if (kind === 'layered' && layered && Array.isArray(layered.sections)) {
if (layered.header) {
doc.font('Helvetica-Bold').fontSize(13).fillColor(TEXT_DARK).text(layered.header, leftX, doc.y);
doc.moveDown(0.3);
}
doc.font('Helvetica').fontSize(11);
for (const section of layered.sections) {
doc.fillColor(BRAND_DARK).font('Helvetica-Bold').text(String(section.title || ''), leftX, doc.y, { continued: false });
doc.moveDown(0.15);
doc.font('Helvetica').fontSize(10);
if (Array.isArray(section.items) && section.items.length) {
for (const item of section.items) {
doc.circle(doc.page.margins.left + 2, doc.y + 6, 1.5).fill(BRAND).stroke();
doc.fillColor(TEXT_DARK);
doc.text(' ' + String(item || ''), doc.page.margins.left + 8, doc.y, { width: doc.page.width - doc.page.margins.left - doc.page.margins.right - 8 });
doc.moveDown(0.2);
}
} else {
doc.fillColor(TEXT_MUTED).text('No items', leftX, doc.y);
}
doc.moveDown(0.5);
if (doc.y > doc.page.height - 60) doc.addPage();
}
} else {
doc.fillColor(TEXT_MUTED).font('Helvetica').text('No content', leftX, doc.y);
}
if (Array.isArray(extraTables)) {
extraTables.forEach(t => {
if (!t || !Array.isArray(t.rows) || t.rows.length === 0) return;
if (doc.y > doc.page.height - 100) doc.addPage();
doc.moveDown(0.6);
if (t.title) {
doc.fillColor(BRAND_DARK).font('Helvetica-Bold').fontSize(11).text(t.title, leftX, doc.y);
doc.moveDown(0.4);
}
drawTable(doc, t);
});
}
}
// Renders payload to a temp PDF file and returns its path — shared by emailPdf and whatsappPdf.
async function renderReportPdfToFile(payload) {
const tempDir = path.join(__dirname, '..', '..', 'temp');
if (!fs.existsSync(tempDir)) fs.mkdirSync(tempDir, { recursive: true });
const filePath = path.join(tempDir, `${(payload.title || 'report')}-${Date.now()}.pdf`.replace(/[^a-z0-9_.-]/gi, '_'));
await new Promise((resolve, reject) => {
const doc = a4Doc(payload.orientation === 'landscape' ? 'landscape' : 'portrait');
const ws = fs.createWriteStream(filePath);
doc.pipe(ws);
drawReportPdf(doc, payload);
doc.end();
ws.on('finish', resolve);
ws.on('error', reject);
});
return filePath;
}
// Builds a styled .xlsx workbook mirroring the same payload shape used for PDF — branded
// title band, stat label/value rows, a chart rendered as a mini-table with a native Excel
// data-bar conditional format (the closest free/no-extra-viz-dependency equivalent of the
// web's bar chart), then the main table with a bold colored header row and highlighted
// total rows.
async function buildReportWorkbook(payload) {
const { title, subtitle, kind, table, layered, stats, chart, note, extraTables } = payload || {};
const wb = new ExcelJS.Workbook();
const orgName = require('../utils/settingsCache').getSettingSync('org_name', process.env.ORG_NAME || 'Cross Code');
wb.creator = `${orgName} Events`;
wb.created = new Date();
const sheetName = (title || 'Report').replace(/[\\/*?:[\]]/g, ' ').slice(0, 31) || 'Report';
const ws = wb.addWorksheet(sheetName);
const colCount = Math.max(4, (table?.columns?.length || 0));
let r = 1;
ws.mergeCells(r, 1, r, colCount);
const titleCell = ws.getCell(r, 1);
titleCell.value = title || 'Report';
titleCell.font = { bold: true, size: 16, color: { argb: 'FFFFFFFF' } };
titleCell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FF4F46E5' } };
titleCell.alignment = { vertical: 'middle' };
ws.getRow(r).height = 26;
r++;
if (subtitle) {
ws.mergeCells(r, 1, r, colCount);
const subCell = ws.getCell(r, 1);
subCell.value = subtitle;
subCell.font = { italic: true, size: 10, color: { argb: 'FF6B7280' } };
r++;
}
r++;
if (Array.isArray(stats) && stats.length) {
stats.forEach(s => {
ws.getCell(r, 1).value = s.label;
ws.getCell(r, 1).font = { color: { argb: 'FF6B7280' }, size: 10 };
ws.getCell(r, 2).value = s.value;
ws.getCell(r, 2).font = { bold: true, size: 11 };
r++;
});
r++;
}
if (chart && Array.isArray(chart.data) && chart.data.length) {
if (chart.title) {
ws.getCell(r, 1).value = chart.title;
ws.getCell(r, 1).font = { bold: true, size: 10 };
r++;
}
const chartStartRow = r;
chart.data.forEach(d => {
ws.getCell(r, 1).value = d.label;
ws.getCell(r, 2).value = typeof d.value === 'number' ? d.value : Number(d.value) || 0;
r++;
});
ws.addConditionalFormatting({
ref: `B${chartStartRow}:B${r - 1}`,
rules: [{ type: 'dataBar', cfvo: [{ type: 'min' }, { type: 'max' }], color: { argb: 'FF2A78D6' } }]
});
r++;
}
if (note) {
ws.mergeCells(r, 1, r, colCount);
ws.getCell(r, 1).value = note;
ws.getCell(r, 1).font = { italic: true, size: 9, color: { argb: 'FF6B7280' } };
ws.getCell(r, 1).alignment = { wrapText: true };
r += 2;
}
if (kind === 'table' && table && Array.isArray(table.rows)) {
const columns = table.columns || [];
const { nextRow, dataStartRow } = writeTableRows(ws, r, columns, table.rows);
ws.views = [{ state: 'frozen', ySplit: dataStartRow - 1 }];
r = nextRow;
} else if (kind === 'layered' && layered && Array.isArray(layered.sections)) {
if (layered.header) {
ws.getCell(r, 1).value = layered.header;
ws.getCell(r, 1).font = { bold: true, size: 12 };
r += 2;
}
layered.sections.forEach(section => {
ws.getCell(r, 1).value = section.title;
ws.getCell(r, 1).font = { bold: true, color: { argb: 'FF3730A3' } };
r++;
(section.items || []).forEach(item => {
ws.getCell(r, 1).value = `• ${item}`;
r++;
});
r++;
});
ws.getColumn(1).width = 90;
}
if (Array.isArray(extraTables)) {
extraTables.forEach(t => {
if (!t || !Array.isArray(t.rows) || t.rows.length === 0) return;
r++;
if (t.title) {
ws.getCell(r, 1).value = t.title;
ws.getCell(r, 1).font = { bold: true, size: 12, color: { argb: 'FF3730A3' } };
r++;
}
const { nextRow } = writeTableRows(ws, r, t.columns || [], t.rows);
r = nextRow;
});
}
return wb;
}
// Writes a header row (bold, colored fill) + zebra/emphasis-highlighted data rows at the given
// sheet row — shared by the main table and any extraTables sections. Returns the next free row
// and the data's start row (for optional freeze-pane use by the caller).
function writeTableRows(ws, startRow, columns, rows) {
let r = startRow;
const headerRow = ws.getRow(r);
columns.forEach((c, i) => {
const cell = headerRow.getCell(i + 1);
cell.value = c;
cell.font = { bold: true, color: { argb: 'FF3730A3' } };
cell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFEEF2FF' } };
cell.border = { bottom: { style: 'thin', color: { argb: 'FFE5E7EB' } } };
});
headerRow.commit();
r++;
const dataStartRow = r;
rows.forEach((row, idx) => {
const excelRow = ws.getRow(r);
const emphasis = isEmphasisRow(row[0]);
row.forEach((val, i) => {
const cell = excelRow.getCell(i + 1);
cell.value = val === '' ? null : val;
if (emphasis) {
cell.font = { bold: true, color: { argb: 'FF3730A3' } };
cell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFEEF2FF' } };
} else if (idx % 2 === 1) {
cell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFF9FAFB' } };
}
});
excelRow.commit();
r++;
});
columns.forEach((c, i) => {
let maxLen = String(c || '').length;
rows.forEach(row => { const v = row[i]; if (v != null && v !== '') maxLen = Math.max(maxLen, String(v).length); });
const col = ws.getColumn(i + 1);
col.width = Math.max(col.width || 0, Math.min(40, Math.max(10, maxLen + 2)));
});
return { nextRow: r, dataStartRow };
}
// POST /api/reports/pdf
// body: { title: string, kind: 'table'|'layered', table?: { columns: string[], rows: string[][] }, layered?: { header?: string, sections: { title: string, items: string[] }[] } }
// body: { title, subtitle?, kind: 'table'|'layered', table?, layered?, stats?, chart?, note?, orientation? }
const generatePdf = async (req, res) => {
try {
const { title, kind, table, layered, orientation } = req.body || {};
const { title, subtitle, kind, table, layered, orientation, stats, chart, note, extraTables } = req.body || {};
res.setHeader('Content-Type', 'application/pdf');
const filename = `${(title || 'report').replace(/[^a-z0-9]/gi, '_').toLowerCase()}.pdf`;
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
const doc = a4Doc(orientation === 'landscape' ? 'landscape' : 'portrait');
doc.pipe(res);
// Title
doc.font('Helvetica-Bold').fontSize(16).text(title || 'Report', { align: 'left' });
doc.moveDown(0.5);
if (kind === 'table' && table && Array.isArray(table.rows)) {
const columns = Array.isArray(table.columns) ? table.columns : [];
const colCount = columns.length || (table.rows[0] ? table.rows[0].length : 1);
const pageWidth = doc.page.width - doc.page.margins.left - doc.page.margins.right;
// Slightly wider first column to mimic site tables
const baseWidth = Math.floor(pageWidth / Math.max(1, colCount));
const colWidths = new Array(colCount).fill(baseWidth);
if (colCount > 0) colWidths[0] = Math.floor(baseWidth * 1.2);
// Draw header band
if (columns.length) {
let x = doc.page.margins.left;
const y = doc.y;
doc.save();
doc.rect(x, y, pageWidth, 22).fill('#f3f4f6');
doc.fillColor('#111827').font('Helvetica-Bold').fontSize(11);
columns.forEach((h, i) => {
const w = colWidths[i] || baseWidth;
doc.text(String(h || ''), x + 6, y + 6, { width: w - 12 });
x += w;
});
doc.restore();
doc.moveDown(1.6);
}
// Zebra rows
const rows = table.rows;
rows.forEach((row, idx) => {
const rowY = doc.y;
const rowH = 18;
const bg = idx % 2 === 0 ? '#ffffff' : '#f9fafb';
doc.save();
doc.rect(doc.page.margins.left, rowY - 2, pageWidth, rowH + 4).fill(bg).restore();
let x = doc.page.margins.left;
row.forEach((cell, i) => {
const w = colWidths[i] || baseWidth;
// Cell text
doc.fillColor('#111827').font('Helvetica').fontSize(10).text(String(cell ?? ''), x + 6, rowY, { width: w - 12 });
// Vertical separators similar to table borders
doc.strokeColor('#e5e7eb').lineWidth(0.5).moveTo(x, rowY - 2).lineTo(x, rowY + rowH + 2).stroke();
x += w;
});
// Right border
doc.strokeColor('#e5e7eb').lineWidth(0.5).moveTo(doc.page.margins.left + pageWidth, rowY - 2).lineTo(doc.page.margins.left + pageWidth, rowY + rowH + 2).stroke();
doc.moveDown(1.1);
if (doc.y > doc.page.height - 40) {
doc.addPage();
}
});
// Bottom border
doc.strokeColor('#e5e7eb').lineWidth(0.5).moveTo(doc.page.margins.left, doc.y).lineTo(doc.page.margins.left + pageWidth, doc.y).stroke();
} else if (kind === 'layered' && layered && Array.isArray(layered.sections)) {
if (layered.header) {
doc.font('Helvetica-Bold').fontSize(13).text(layered.header);
doc.moveDown(0.3);
}
doc.font('Helvetica').fontSize(11);
for (const section of layered.sections) {
doc.fillColor('#111827').font('Helvetica-Bold').text(String(section.title || ''), { continued: false });
doc.moveDown(0.15);
doc.font('Helvetica').fontSize(10);
if (Array.isArray(section.items) && section.items.length) {
for (const item of section.items) {
// Bullet dot
doc.circle(doc.page.margins.left + 2, doc.y + 6, 1.5).fill('#374151').stroke();
doc.fillColor('#111827');
doc.text(' ' + String(item || ''), doc.page.margins.left + 8, doc.y, { width: doc.page.width - doc.page.margins.left - doc.page.margins.right - 8 });
doc.moveDown(0.2);
}
} else {
doc.text('No items');
}
doc.moveDown(0.5);
if (doc.y > doc.page.height - 60) doc.addPage();
}
} else {
doc.font('Helvetica').text('No content');
}
drawReportPdf(doc, { title, subtitle, kind, table, layered, stats, chart, note, extraTables });
doc.end();
} catch (e) {
res.status(400).json({ message: e.message });
}
};
// POST /api/reports/excel
// body: same payload shape as /pdf — used to produce a styled .xlsx mirroring the PDF/web report.
const generateExcel = async (req, res) => {
try {
const payload = req.body || {};
const wb = await buildReportWorkbook(payload);
const filename = `${(payload.title || 'report').replace(/[^a-z0-9]/gi, '_').toLowerCase()}.xlsx`;
res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
await wb.xlsx.write(res);
res.end();
} catch (e) {
res.status(400).json({ message: e.message });
}
};
// POST /api/reports/email
// body: { title, kind, table?, layered?, subject?, body? }
// body: { title, subtitle?, kind, table?, layered?, stats?, chart?, note?, subject?, body? }
const emailPdf = async (req, res) => {
try {
const { title, kind, table, layered, subject, body, orientation } = req.body || {};
const { title, subtitle, kind, table, layered, subject, body, orientation, stats, chart, note, extraTables } = req.body || {};
const user = req.user;
if (!user || !user.email) {
res.status(400);
throw new Error('User email not available');
}
// Ensure temp dir
const tempDir = path.join(__dirname, '..', '..', 'temp');
if (!fs.existsSync(tempDir)) fs.mkdirSync(tempDir, { recursive: true });
const filePath = path.join(tempDir, `${(title || 'report')}-${Date.now()}.pdf`.replace(/[^a-z0-9_.-]/gi, '_'));
const filePath = await renderReportPdfToFile({ title, subtitle, kind, table, layered, orientation, stats, chart, note, extraTables });
// Build PDF to file
await new Promise((resolve, reject) => {
const doc = a4Doc(orientation === 'landscape' ? 'landscape' : 'portrait');
const ws = fs.createWriteStream(filePath);
doc.pipe(ws);
doc.font('Helvetica-Bold').fontSize(16).text(title || 'Report');
doc.moveDown(0.5);
if (kind === 'table' && table && Array.isArray(table.rows)) {
const columns = Array.isArray(table.columns) ? table.columns : [];
const colCount = columns.length || (table.rows[0] ? table.rows[0].length : 1);
const pageWidth = doc.page.width - doc.page.margins.left - doc.page.margins.right;
// Slightly wider first column
const baseWidth = Math.floor(pageWidth / Math.max(1, colCount));
const colWidths = new Array(colCount).fill(baseWidth);
if (colCount > 0) colWidths[0] = Math.floor(baseWidth * 1.2);
// Header band
if (columns.length) {
let x = doc.page.margins.left;
const y = doc.y;
doc.save();
doc.rect(x, y, pageWidth, 22).fill('#f3f4f6');
doc.fillColor('#111827').font('Helvetica-Bold').fontSize(11);
columns.forEach((h, i) => {
const w = colWidths[i] || baseWidth;
doc.text(String(h || ''), x + 6, y + 6, { width: w - 12 });
x += w;
});
doc.restore();
doc.moveDown(1.6);
}
// Rows zebra
const rows = table.rows;
rows.forEach((row, idx) => {
const rowY = doc.y;
const rowH = 18;
const bg = idx % 2 === 0 ? '#ffffff' : '#f9fafb';
doc.save();
doc.rect(doc.page.margins.left, rowY - 2, pageWidth, rowH + 4).fill(bg).restore();
let x = doc.page.margins.left;
row.forEach((cell, i) => {
const w = colWidths[i] || baseWidth;
doc.fillColor('#111827').font('Helvetica').fontSize(10).text(String(cell ?? ''), x + 6, rowY, { width: w - 12 });
doc.strokeColor('#e5e7eb').lineWidth(0.5).moveTo(x, rowY - 2).lineTo(x, rowY + rowH + 2).stroke();
x += w;
});
doc.strokeColor('#e5e7eb').lineWidth(0.5).moveTo(doc.page.margins.left + pageWidth, rowY - 2).lineTo(doc.page.margins.left + pageWidth, rowY + rowH + 2).stroke();
doc.moveDown(1.1);
if (doc.y > doc.page.height - 40) {
doc.addPage();
}
});
doc.strokeColor('#e5e7eb').lineWidth(0.5).moveTo(doc.page.margins.left, doc.y).lineTo(doc.page.margins.left + pageWidth, doc.y).stroke();
} else if (kind === 'layered' && layered && Array.isArray(layered.sections)) {
if (layered.header) {
doc.font('Helvetica-Bold').fontSize(13).text(layered.header);
doc.moveDown(0.3);
}
doc.font('Helvetica').fontSize(11);
for (const section of layered.sections) {
doc.fillColor('#111827').font('Helvetica-Bold').text(String(section.title || ''), { continued: false });
doc.moveDown(0.15);
doc.font('Helvetica').fontSize(10);
if (Array.isArray(section.items) && section.items.length) {
for (const item of section.items) {
doc.circle(doc.page.margins.left + 2, doc.y + 6, 1.5).fill('#374151').stroke();
doc.fillColor('#111827');
doc.text(' ' + String(item || ''), doc.page.margins.left + 8, doc.y, { width: doc.page.width - doc.page.margins.left - doc.page.margins.right - 8 });
doc.moveDown(0.2);
}
} else {
doc.text('No items');
}
doc.moveDown(0.5);
if (doc.y > doc.page.height - 60) doc.addPage();
}
} else {
doc.font('Helvetica').text('No content');
}
doc.end();
ws.on('finish', resolve);
ws.on('error', reject);
});
// Send email using nodemailer (same config as tickets)
const transporter = nodemailer.createTransport({
host: process.env.EMAIL_HOST,
port: process.env.EMAIL_PORT,
secure: process.env.EMAIL_PORT === '465',
auth: { user: process.env.EMAIL_USER, pass: process.env.EMAIL_PASS }
});
await transporter.sendMail({
from: process.env.EMAIL_FROM,
// Use the shared mail utility (DB-configured SMTP via Admin -> Site Settings, with env
// fallback) instead of a one-off transporter — a bare `process.env.EMAIL_HOST` transporter
// ignores that configuration entirely and fails wherever SMTP is only set up via the DB.
const bodyText = body || 'Please find your report attached.';
await sendMail({
to: user.email,
subject: subject || (title ? `${title} PDF` : 'Report PDF'),
text: body || 'Please find your report attached.',
text: bodyText,
html: emailWrapper(
`<p style="margin:0 0 16px 0;color:#374151">${bodyText}</p>` +
`<p style="margin:0;color:#374151">Your report <strong>${title || 'Report'}</strong> is attached as a PDF.</p>`,
{ preheader: title || 'Report PDF' }
),
attachments: [{ filename: path.basename(filePath), path: filePath, contentType: 'application/pdf' }]
});
// Clean
try { fs.unlinkSync(filePath); } catch {}
res.json({ message: `Report emailed to ${user.email}` });
@@ -272,4 +468,33 @@ const emailPdf = async (req, res) => {
}
};
module.exports = { generatePdf, emailPdf };
// POST /api/reports/whatsapp
// body: { title, subtitle?, kind, table?, layered?, stats?, chart?, note?, caption? }
// Sends to the current user's own WhatsApp number (same self-service pattern as emailPdf).
const whatsappPdf = async (req, res) => {
let filePath = null;
try {
const { title, subtitle, kind, table, layered, caption, orientation, stats, chart, note, extraTables } = req.body || {};
const user = req.user;
const { isValidZAPhone } = require('../utils/whatsapp');
if (!user || !isValidZAPhone(user.phoneNumber)) {
res.status(400);
throw new Error('No valid WhatsApp number on your account. Add one in your profile to use this.');
}
filePath = await renderReportPdfToFile({ title, subtitle, kind, table, layered, orientation, stats, chart, note, extraTables });
const { sendPdf } = require('../utils/whatsapp');
const filename = `${(title || 'report').replace(/[^a-z0-9]/gi, '_').toLowerCase()}.pdf`;
await sendPdf(user.phoneNumber, filePath, filename, caption || title || 'Report');
res.json({ message: `Report sent to your WhatsApp` });
} catch (e) {
res.status(400).json({ message: e.message });
} finally {
if (filePath) { try { fs.unlinkSync(filePath); } catch {} }
}
};
module.exports = { generatePdf, generateExcel, emailPdf, whatsappPdf };
@@ -0,0 +1,151 @@
const prisma = require('../config/db');
const { safeErrorMessage } = require('../utils/errorUtils');
const { logAdminAction } = require('../utils/adminAudit');
const { getClientIp } = require('../utils/requestUtils');
const { LOCK_INCLUDE, activeLocks, acquireLock, snapshotFor, syncScans } = require('../utils/scanLocks');
const SUPERVISORS = ['admin', 'supervisor'];
const fail = (res, error) => res.status(error.statusCode || (res.statusCode === 200 ? 400 : res.statusCode)).json({ message: safeErrorMessage(error) });
const audit = (req, action, lock, metadata = {}) => logAdminAction({
actorId: req.user.id, actorRole: req.user.role, action, targetType: 'ScanLock', targetId: lock.id,
metadata: { eventId: lock.eventId, sectionId: lock.sectionId, deviceName: lock.deviceName, ...metadata },
ip: getClientIp(req),
});
const lockView = (l) => ({
id: l.id, eventId: l.eventId, sectionId: l.sectionId, sectionName: l.section?.name || null,
deviceId: l.deviceId, deviceName: l.deviceName, user: l.user, lockedAt: l.lockedAt,
snapshotAt: l.snapshotAt, lastSyncAt: l.lastSyncAt, releasedAt: l.releasedAt,
});
/** The lock, if the caller is the person holding it on this device. */
async function ownLock(req, res) {
const lock = await prisma.scanLock.findUnique({ where: { id: req.params.id }, include: LOCK_INCLUDE });
if (!lock) { res.status(404); throw new Error('Offline lock not found'); }
const deviceId = req.body?.deviceId || req.query?.deviceId;
if (lock.userId !== req.user.id || lock.deviceId !== deviceId) { res.status(403); throw new Error('This offline lock belongs to another device.'); }
return lock;
}
// @desc Active offline locks for an event
// @route GET /api/scan-locks?eventId=
// @access Private/Staff
const listLocks = async (req, res) => {
try {
if (!req.query.eventId) { res.status(400); throw new Error('eventId is required'); }
res.json((await activeLocks(String(req.query.eventId))).map(lockView));
} catch (error) {
fail(res, error);
}
};
// @desc Lock a section (or the whole event) to this device for offline scanning
// @route POST /api/scan-locks { eventId, sectionId?, deviceId, deviceName? }
// @access Private/Staff
const createLock = async (req, res) => {
try {
const { eventId, sectionId, deviceId, deviceName } = req.body || {};
if (!eventId) { res.status(400); throw new Error('eventId is required'); }
const lock = await acquireLock({ eventId, sectionId: sectionId || null, deviceId, deviceName, userId: req.user.id });
audit(req, 'scan_lock_acquired', lock);
res.status(201).json(lockView(lock));
} catch (error) {
fail(res, error);
}
};
// @desc Download the tickets valid for this lock, for offline scanning
// @route GET /api/scan-locks/:id/snapshot?deviceId=
// @access Private/Staff (lock holder)
const getSnapshot = async (req, res) => {
try {
const lock = await ownLock(req, res);
if (lock.releasedAt) { res.status(410); throw new Error('This offline lock has been released.'); }
res.json({ lock: lockView(lock), ...(await snapshotFor(lock)) });
} catch (error) {
fail(res, error);
}
};
// @desc Upload scans made offline
// @route POST /api/scan-locks/:id/sync { deviceId, scans: [{ clientScanId, qrCode, qty, scannedAt }] }
// @access Private/Staff (lock holder — also after a release, so nothing made offline is lost)
const syncLock = async (req, res) => {
try {
const lock = await ownLock(req, res);
const result = await syncScans(lock, req.body?.scans);
if (result.accepted || result.conflicts.length) {
audit(req, 'offline_sync', lock, { accepted: result.accepted, conflicts: result.conflicts.length, duplicates: result.duplicates, afterForceRelease: !!lock.forceReleasedById });
}
res.json({ accepted: result.accepted, duplicates: result.duplicates, conflicts: result.conflicts, reportId: result.report.id, released: !!lock.releasedAt });
} catch (error) {
fail(res, error);
}
};
// @desc End offline mode (the device should sync first)
// @route DELETE /api/scan-locks/:id { deviceId }
// @access Private/Staff (lock holder)
const releaseLock = async (req, res) => {
try {
const lock = await ownLock(req, res);
if (!lock.releasedAt) {
await prisma.scanLock.update({ where: { id: lock.id }, data: { releasedAt: new Date() } });
audit(req, 'scan_lock_released', lock);
}
res.json({ message: 'Offline mode ended.' });
} catch (error) {
fail(res, error);
}
};
// @desc Release someone else's lock (e.g. a lost or flat phone)
// @route POST /api/scan-locks/:id/force-release
// @access Private/Supervisor
const forceRelease = async (req, res) => {
try {
const lock = await prisma.scanLock.findUnique({ where: { id: req.params.id }, include: LOCK_INCLUDE });
if (!lock) { res.status(404); throw new Error('Offline lock not found'); }
if (lock.releasedAt) return res.json({ message: 'Already released.' });
await prisma.scanLock.update({ where: { id: lock.id }, data: { releasedAt: new Date(), forceReleasedById: req.user.id } });
audit(req, 'scan_lock_force_released', lock, { heldBy: lock.user?.name });
res.json({ message: `Released. If ${lock.user?.name || 'that device'} reconnects, its offline scans will still sync and be flagged for review.` });
} catch (error) {
fail(res, error);
}
};
// @desc Offline sync reports for an event (with anything that needs review)
// @route GET /api/scan-locks/reports?eventId=&unreviewed=true
// @access Private/Supervisor
const listReports = async (req, res) => {
try {
const where = {
scanLock: { eventId: String(req.query.eventId || '') },
...(req.query.unreviewed === 'true' ? { reviewedAt: null } : {}),
};
const reports = await prisma.offlineSyncReport.findMany({
where,
include: { scanLock: { include: LOCK_INCLUDE }, reviewedBy: { select: { name: true } } },
orderBy: { createdAt: 'desc' },
take: 200,
});
res.json(reports.map(r => ({ ...r, scanLock: lockView(r.scanLock) })));
} catch (error) {
fail(res, error);
}
};
// @desc Mark a sync report as reviewed
// @route POST /api/scan-locks/reports/:id/review
// @access Private/Supervisor
const reviewReport = async (req, res) => {
try {
const report = await prisma.offlineSyncReport.update({ where: { id: req.params.id }, data: { reviewedAt: new Date(), reviewedById: req.user.id } });
res.json(report);
} catch (error) {
fail(res, error);
}
};
module.exports = { SUPERVISORS, listLocks, createLock, getSnapshot, syncLock, releaseLock, forceRelease, listReports, reviewReport };
@@ -6,11 +6,14 @@ function toClient(job) {
const subject = job?.payload?.subject || '';
const html = job?.payload?.html || '';
const text = job?.payload?.text || '';
const message = job?.payload?.message || '';
return {
id: job.id,
kind,
eventId: job.eventId || null,
broadcast: !!job.broadcast,
channel: job.channel || 'email',
recipient: job.recipientSummary || null,
scheduledAt: job.scheduledAt,
createdAt: job.createdAt,
status: job.status,
@@ -18,26 +21,21 @@ function toClient(job) {
sentAt: job.sentAt || null,
lastError: job.lastError || null,
subject,
html,
text,
message,
hasHtml: !!html,
hasText: !!text,
};
}
// GET /api/scheduled-emails
// Returns jobs excluding emails sent more than a week ago
// GET /api/scheduled-emails?channel=email|whatsapp
// Sent jobs are purged from storage 24h after sending, so nothing older than that is ever returned here
const listScheduledEmails = async (req, res) => {
try {
const raw = listJobs();
const now = new Date();
const weekMs = 7 * 24 * 60 * 60 * 1000;
const filtered = raw.filter(j => {
if (j.status === 'sent' && j.sentAt) {
const sentAt = new Date(j.sentAt).getTime();
return (now.getTime() - sentAt) <= weekMs;
}
// Include queued, sending, error by default
return true;
})
const { channel } = req.query || {};
const raw = listJobs(channel ? { channel: String(channel) } : {});
const filtered = raw
// Provide most-relevant first: queued -> sending -> error -> recent sent
.sort((a, b) => {
const order = { queued: 0, sending: 1, error: 2, sent: 3 };
@@ -56,7 +54,7 @@ const listScheduledEmails = async (req, res) => {
};
// PATCH /api/scheduled-emails/:id
// Allows editing scheduledAt, subject, html/text on queued jobs only
// Allows editing scheduledAt and message content (subject/html/text for email jobs, message for WhatsApp jobs) on queued jobs only
const updateScheduledEmail = async (req, res) => {
try {
const { id } = req.params;
@@ -64,7 +62,7 @@ const updateScheduledEmail = async (req, res) => {
if (!job) return res.status(404).json({ message: 'Job not found' });
if (job.status !== 'queued') return res.status(400).json({ message: 'Only queued jobs can be edited' });
const { scheduledAt, subject, html, text } = req.body || {};
const { scheduledAt, subject, html, text, message } = req.body || {};
const patch = {};
if (scheduledAt) {
@@ -72,14 +70,12 @@ const updateScheduledEmail = async (req, res) => {
if (isNaN(when.getTime())) return res.status(400).json({ message: 'scheduledAt must be a valid ISO date-time' });
patch.scheduledAt = when.toISOString();
}
if (subject != null || html != null || text != null) {
if (subject != null || html != null || text != null || message != null) {
const payload = { ...(job.payload || {}) };
if (subject != null) payload.subject = subject;
if (html != null || text != null) {
// If html provided explicitly, set html; if text provided, set text
if (html != null) payload.html = html;
if (text != null) payload.text = text;
}
if (html != null) payload.html = html;
if (text != null) payload.text = text;
if (message != null) payload.message = message;
patch.payload = payload;
}
+31 -6
View File
@@ -4,11 +4,19 @@ 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 = [
'org_name', 'org_tagline', 'org_email', 'org_phone', 'org_address',
'accent_color', 'logo_url', 'setup_complete', 'app_base_url',
// Branding — primary_color/secondary_color/accent_color are the 3-color brand
// system (accent_color's meaning was repurposed from "the one brand color" to
// "tertiary accent color"; primary_color falls back to accent_color's legacy
// value wherever it's consumed, so older data still resolves sensibly until
// the admin re-saves the Branding tab).
'primary_color', 'secondary_color', 'accent_color', 'logo_url', 'favicon_url',
'setup_complete', 'app_base_url',
// Legal pages
'legal_operator_name', 'legal_io_name', 'legal_io_email',
'legal_website_url', 'legal_effective_date',
@@ -101,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) });
@@ -150,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) });
@@ -293,12 +317,13 @@ const testSmtp = async (req, res) => {
// Send a real test email to the authenticated user so there's visible proof
const adminEmail = req.user?.email;
if (adminEmail) {
const orgName = getSettingSync('org_name', process.env.ORG_NAME || 'Cross Code');
await transporter.sendMail({
from: from || user || 'no-reply@hope-events.local',
from: from || user || 'no-reply@crosscode.local',
to: adminEmail,
subject: 'SMTP test — Hope Events',
text: `This is a test email sent from the Hope Events admin panel to confirm that your SMTP settings are working correctly.\n\nHost: ${host}:${port}\nFrom: ${from || user}`,
html: `<p>This is a test email sent from the <strong>Hope Events</strong> admin panel to confirm that your SMTP settings are working correctly.</p><p><strong>Host:</strong> ${host}:${port}<br/><strong>From:</strong> ${from || user}</p>`,
subject: `SMTP test — ${orgName}`,
text: `This is a test email sent from the ${orgName} admin panel to confirm that your SMTP settings are working correctly.\n\nHost: ${host}:${port}\nFrom: ${from || user}`,
html: `<p>This is a test email sent from the <strong>${orgName}</strong> admin panel to confirm that your SMTP settings are working correctly.</p><p><strong>Host:</strong> ${host}:${port}<br/><strong>From:</strong> ${from || user}</p>`,
});
}
+151 -3
View File
@@ -1,5 +1,6 @@
const prisma = require('../config/db');
const { safeErrorMessage } = require('../utils/errorUtils');
const { isDonationLeg } = require('../utils/cashupUtils');
// Shared building blocks for the per-dashboard stats endpoints below. Each dashboard
// (staff/supervisor/admin) gets exactly one endpoint that returns only what it renders,
@@ -56,19 +57,26 @@ function getActiveEventsCount() {
return prisma.event.count({ where: { isActive: true, endDate: { gte: new Date() } } });
}
// A donation-application leg (isDonation:false, originalPaymentId set, amount>0) isn't new
// money — see isDonationLeg in cashupUtils.js. Matches the same exclusion already applied in
// paymentController.getPaymentStats and every report; without it these totals overcount.
const EXCLUDE_DONATION_LEGS = {
NOT: { AND: [{ isDonation: false }, { originalPaymentId: { not: null } }, { amount: { gt: 0 } }] },
};
async function computePaymentStats({ includeWeekMonth }) {
const startOfDay = new Date(new Date().setHours(0, 0, 0, 0));
const queries = [
prisma.payment.aggregate({ _sum: { amount: true }, where: { createdAt: { gte: startOfDay } } }),
prisma.payment.aggregate({ _sum: { amount: true }, where: { createdAt: { gte: startOfDay }, ...EXCLUDE_DONATION_LEGS } }),
prisma.payment.count({ where: { isDonation: true, createdAt: { gte: startOfDay } } }),
];
if (includeWeekMonth) {
const lastWeek = new Date(startOfDay.getTime() - 7 * 24 * 60 * 60 * 1000);
const lastMonth = new Date(startOfDay.getTime() - 30 * 24 * 60 * 60 * 1000);
queries.push(
prisma.payment.aggregate({ _sum: { amount: true }, where: { createdAt: { gte: lastWeek } } }),
prisma.payment.aggregate({ _sum: { amount: true }, where: { createdAt: { gte: lastMonth } } }),
prisma.payment.aggregate({ _sum: { amount: true }, where: { createdAt: { gte: lastWeek }, ...EXCLUDE_DONATION_LEGS } }),
prisma.payment.aggregate({ _sum: { amount: true }, where: { createdAt: { gte: lastMonth }, ...EXCLUDE_DONATION_LEGS } }),
);
}
@@ -134,8 +142,148 @@ const getAdminDashboardStats = async (req, res) => {
}
};
// Subtracts `months` calendar months from `date`, clamping to the last day of the target
// month if the original day doesn't exist there (e.g. 31 Mar - 1 month -> 28/29 Feb, not
// 3 Mar which is what naive setMonth() arithmetic would silently produce).
function subtractMonths(date, months) {
const d = new Date(date.getTime());
const originalDate = d.getDate();
d.setMonth(d.getMonth() - months, 1); // move to the 1st of the target month first, so setDate below can't spill into the following month
const daysInTargetMonth = new Date(d.getFullYear(), d.getMonth() + 1, 0).getDate();
d.setDate(Math.min(originalDate, daysInTargetMonth));
return d;
}
// Returns a rolling one-month window ending at the end of today (so today is fully
// included), and the equal-length window immediately before it — e.g. if today is 8 June,
// thisStart/thisEnd covers 9 May through the end of 8 June, and lastStart/lastEnd covers
// 9 April through 8 May. This replaced a calendar-month-to-date window (1st of the month
// through now), which under-counted for most of the month — e.g. on the 3rd it only
// covered 3 days' worth of data instead of a full trailing month.
function trailingMonthRanges(now = new Date()) {
const startOfToday = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 0, 0, 0, 0);
const thisEnd = new Date(startOfToday.getTime() + 24 * 60 * 60 * 1000); // exclusive upper bound, covers all of today
const thisStart = subtractMonths(thisEnd, 1);
const lastEnd = thisStart;
const lastStart = subtractMonths(thisStart, 1);
return { thisStart, thisEnd, lastStart, lastEnd };
}
function pctChange(current, previous) {
if (previous === 0) return current === 0 ? 0 : null;
return ((current - previous) / previous) * 100;
}
function fetchPaymentsWithOriginal(where) {
return prisma.payment.findMany({
where,
select: {
id: true, amount: true, isDonation: true, originalPaymentId: true, eventId: true, createdAt: true,
originalPayment: { select: { isDonation: true } },
},
});
}
// Classifies each payment the same way computeEventFinancials does (cashupUtils.js):
// donation-application legs are excluded entirely (not new money — see isDonationLeg).
// Everything else — registration payments, donations themselves, and refunds of either —
// is real money in/out and counts toward "revenue". "donations" is a breakdown *within*
// that revenue (donations plus any refund of a donation), not a separate bucket.
function classifyAndSum(payments) {
let revenue = 0;
let donations = 0;
for (const p of payments) {
if (isDonationLeg(p)) continue;
revenue += p.amount;
if (p.isDonation) {
donations += p.amount;
} else if (p.originalPaymentId && p.amount < 0 && p.originalPayment?.isDonation) {
donations += p.amount;
}
}
return { revenue, donations };
}
// @desc Registrations/tickets/revenue/donations for the trailing month (today back one
// month) vs the month before that, a daily revenue trend for the trailing month,
// and the top performing events overall.
// @route GET /api/stats/overview
// @access Private/Supervisor+
const getOverviewStats = async (req, res) => {
try {
const now = new Date();
const { thisStart, thisEnd, lastStart, lastEnd } = trailingMonthRanges(now);
const [thisPayments, lastPayments, thisRegistrations, lastRegistrations, thisTickets, lastTickets, activeEvents] = await Promise.all([
fetchPaymentsWithOriginal({ createdAt: { gte: thisStart, lt: thisEnd } }),
fetchPaymentsWithOriginal({ createdAt: { gte: lastStart, lt: lastEnd } }),
prisma.registration.count({ where: { createdAt: { gte: thisStart, lt: thisEnd }, status: { not: 'cancelled' } } }),
prisma.registration.count({ where: { createdAt: { gte: lastStart, lt: lastEnd }, status: { not: 'cancelled' } } }),
prisma.ticket.aggregate({ _sum: { quantity: true }, where: { createdAt: { gte: thisStart, lt: thisEnd } } }),
prisma.ticket.aggregate({ _sum: { quantity: true }, where: { createdAt: { gte: lastStart, lt: lastEnd } } }),
getActiveEventsCount(),
]);
const thisTotals = classifyAndSum(thisPayments);
const lastTotals = classifyAndSum(lastPayments);
const thisTicketsSold = thisTickets._sum.quantity || 0;
const lastTicketsSold = lastTickets._sum.quantity || 0;
// Daily revenue trend for the trailing month, from the payment rows already fetched above.
// Same definition as classifyAndSum: everything except donation-application legs.
const trendMap = new Map();
for (const p of thisPayments) {
if (isDonationLeg(p)) continue;
const day = p.createdAt.toISOString().slice(0, 10);
trendMap.set(day, (trendMap.get(day) || 0) + p.amount);
}
const trend = Array.from(trendMap.entries())
.sort(([a], [b]) => a.localeCompare(b))
.map(([date, revenue]) => ({ date, revenue }));
// Top performing events overall (not scoped to this month), ranked by revenue — same
// "real revenue" definition as the KPI above: everything except application legs.
const revenueByEvent = await prisma.payment.groupBy({
by: ['eventId'],
where: { ...EXCLUDE_DONATION_LEGS, eventId: { not: null } },
_sum: { amount: true },
orderBy: { _sum: { amount: 'desc' } },
take: 5,
});
const topEventIds = revenueByEvent.map(r => r.eventId).filter(Boolean);
const [events, regCounts, ticketSums] = await Promise.all([
prisma.event.findMany({ where: { id: { in: topEventIds } }, select: { id: true, title: true } }),
prisma.registration.groupBy({ by: ['eventId'], where: { eventId: { in: topEventIds }, status: { not: 'cancelled' } }, _count: { _all: true } }),
prisma.ticket.groupBy({ by: ['eventId'], where: { eventId: { in: topEventIds } }, _sum: { quantity: true } }),
]);
const eventTitleById = Object.fromEntries(events.map(e => [e.id, e.title]));
const regCountById = Object.fromEntries(regCounts.map(r => [r.eventId, r._count._all]));
const ticketSumById = Object.fromEntries(ticketSums.map(t => [t.eventId, t._sum.quantity || 0]));
const topEvents = revenueByEvent.map(r => ({
eventId: r.eventId,
title: eventTitleById[r.eventId] || 'Untitled event',
revenue: r._sum.amount || 0,
registrations: regCountById[r.eventId] || 0,
ticketsSold: ticketSumById[r.eventId] || 0,
}));
res.json({
activeEvents,
registrations: { thisMonth: thisRegistrations, lastMonth: lastRegistrations, pctChange: pctChange(thisRegistrations, lastRegistrations) },
ticketsSold: { thisMonth: thisTicketsSold, lastMonth: lastTicketsSold, pctChange: pctChange(thisTicketsSold, lastTicketsSold) },
revenue: { thisMonth: thisTotals.revenue, lastMonth: lastTotals.revenue, pctChange: pctChange(thisTotals.revenue, lastTotals.revenue) },
donations: { thisMonth: thisTotals.donations, lastMonth: lastTotals.donations, pctChange: pctChange(thisTotals.donations, lastTotals.donations) },
trend,
topEvents,
});
} catch (error) {
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
}
};
module.exports = {
getStaffDashboardStats,
getSupervisorDashboardStats,
getAdminDashboardStats,
getOverviewStats,
};
+186 -11
View File
@@ -81,9 +81,10 @@ const getTickets = async (req, res) => {
// @access Private
const getUserTickets = async (req, res) => {
try {
// Tickets this user paid for, plus named tickets someone else bought for them.
const tickets = await prisma.ticket.findMany({
where: {
userId: req.user.id,
OR: [{ userId: req.user.id }, { holderUserId: req.user.id }],
registrationOption: { registration: { status: { not: 'cancelled' } } }
},
include: {
@@ -94,6 +95,8 @@ const getUserTickets = async (req, res) => {
registration: true
}
},
attendee: { select: { id: true, name: true } },
user: { select: { id: true, name: true } },
event: true,
usages: {
include: {
@@ -261,6 +264,7 @@ const getScanPreview = async (req, res) => {
e.title AS "eventTitle",
u.id AS "userId",
u.name AS "userName",
a.name AS "attendeeName",
COALESCE(
json_agg(
json_build_object(
@@ -276,22 +280,29 @@ const getScanPreview = async (req, res) => {
LEFT JOIN "OptionVariant" ov ON ov.id = ro."variantId"
LEFT JOIN "Event" e ON e.id = t."eventId"
LEFT JOIN "User" u ON u.id = t."userId"
LEFT JOIN "Attendee" a ON a.id = t."attendeeId"
LEFT JOIN "TicketUsage" tu ON tu."ticketId" = t.id
WHERE t."qrCode" = ${req.params.qrCode}
GROUP BY t.id, ro.id, eo.id, ov.id, e.id, u.id
GROUP BY t.id, ro.id, eo.id, ov.id, e.id, u.id, a.id
`;
if (!rows || rows.length === 0) {
const { findTransferByOldCode, transferredMessage } = require('../utils/ticketTransfer');
const transfer = await findTransferByOldCode(req.params.qrCode);
if (transfer) return res.status(410).json({ message: transferredMessage(transfer), code: 'TICKET_TRANSFERRED' });
res.status(404);
throw new Error('Ticket not found');
}
const r = rows[0];
const { consentStatusForTickets } = require('../utils/consent');
const consentStatus = (await consentStatusForTickets([r.id])).get(r.id) || 'not_required';
res.json({
id: r.id,
quantity: Number(r.quantity),
isUsed: r.isUsed,
eventId: r.eventId,
consentStatus,
registrationOption: {
id: r.registrationOptionId,
eventOption: { id: r.eventOptionId, name: r.eventOptionName },
@@ -299,6 +310,7 @@ const getScanPreview = async (req, res) => {
},
event: { id: r.evId, title: r.eventTitle },
user: { id: r.userId, name: r.userName },
attendee: r.attendeeName ? { name: r.attendeeName } : null,
usages: r.usages || []
});
} catch (error) {
@@ -321,6 +333,7 @@ const scanTicket = async (req, res) => {
e.title AS "eventTitle",
eo.id AS "eventOptionId",
eo.name AS "eventOptionName",
eo."isMainTicket" AS "isMainTicket",
COALESCE(
json_agg(
json_build_object(
@@ -341,6 +354,9 @@ const scanTicket = async (req, res) => {
`;
if (!rows || rows.length === 0) {
const { findTransferByOldCode, transferredMessage } = require('../utils/ticketTransfer');
const transfer = await findTransferByOldCode(req.params.qrCode);
if (transfer) return res.status(410).json({ message: transferredMessage(transfer), code: 'TICKET_TRANSFERRED' });
res.status(404);
throw new Error('Ticket not found');
}
@@ -352,7 +368,7 @@ const scanTicket = async (req, res) => {
isUsed: r.isUsed,
eventId: r.eventId,
event: { title: r.eventTitle },
registrationOption: { eventOption: { id: r.eventOptionId, name: r.eventOptionName } },
registrationOption: { eventOption: { id: r.eventOptionId, name: r.eventOptionName, isMainTicket: r.isMainTicket } },
usages: r.usages || []
};
@@ -362,6 +378,27 @@ const scanTicket = async (req, res) => {
return res.status(400).json({ message: 'Ticket belongs to a different event', ticket });
}
// Section checks used to happen only in the browser; enforce them here too.
const sectionId = String((req.query?.sectionId || req.body?.sectionId) || '').trim() || null;
if (sectionId) {
const section = await prisma.section.findUnique({ where: { id: sectionId }, include: { allowedOptions: true } });
if (!section || section.eventId !== ticket.eventId) {
return res.status(400).json({ message: 'Section not found for this event', ticket });
}
if (!section.allowedOptions.some(o => o.eventOptionId === r.eventOptionId)) {
return res.status(400).json({ message: 'This ticket is not valid for this section', ticket });
}
}
// A section (or the whole event) locked to another device for offline scanning
// can't be scanned online — those scans would be invisible to the offline device.
const { blockingLock } = require('../utils/scanLocks');
const lock = await blockingLock({ eventId: ticket.eventId, optionId: r.eventOptionId }, { sectionId, deviceId: req.body?.deviceId || null });
if (lock) {
const scope = lock.section ? `"${lock.section.name}"` : 'This event';
return res.status(423).json({ message: `${scope} is offline-locked by ${lock.user?.name || 'another device'}${lock.deviceName ? ` on ${lock.deviceName}` : ''}. Scan it on that device, or ask a supervisor to release the lock.`, code: 'SCAN_LOCKED' });
}
// Compute how many have already been redeemed
const totalRedeemed = (ticket.usages || []).reduce((s, u) => s + (u.quantityRedeemed || 1), 0);
const remaining = (ticket.quantity || 1) - totalRedeemed;
@@ -374,6 +411,25 @@ const scanTicket = async (req, res) => {
});
}
// Youth events: no check-in without parental consent, unless a supervisor overrides.
const { consentStatusForTickets } = require('../utils/consent');
const consentStatus = (await consentStatusForTickets([ticket.id])).get(ticket.id) || 'not_required';
if (!['not_required', 'given'].includes(consentStatus)) {
const canOverride = ['admin', 'supervisor'].includes(req.user?.role);
if (!(req.body?.overrideConsent === true && canOverride)) {
const why = consentStatus === 'declined' ? 'Parental consent was declined' : consentStatus === 'pending' ? 'Parental consent is still pending' : 'Parental consent is missing';
return res.status(403).json({ message: `${why} — this ticket can't be checked in.`, code: 'CONSENT_REQUIRED', consentStatus, canOverride, ticket });
}
const { logAdminAction } = require('../utils/adminAudit');
const { getClientIp } = require('../utils/requestUtils');
logAdminAction({
actorId: req.user.id, actorRole: req.user.role, action: 'consent_override',
targetType: 'Ticket', targetId: ticket.id,
metadata: { consentStatus, eventId: ticket.eventId },
ip: getClientIp(req),
});
}
// Determine how many to redeem this scan (defaults to all remaining)
const requestedQty = parseInt(req.body?.qty || req.body?.quantity) || remaining;
const qtyToRedeem = Math.min(Math.max(1, requestedQty), remaining);
@@ -398,6 +454,14 @@ const scanTicket = async (req, res) => {
})
]);
// Only Main Tickets trigger a check-in notification (add-on tickets scanned via
// the QR camera flow are unaffected)
if (ticket.registrationOption?.eventOption?.isMainTicket) {
require('../utils/notifications')
.sendCheckInEmails(ticket.id, qtyToRedeem, newTotalRedeemed, newRemaining)
.catch(e => console.error('Failed to send check-in notification:', e));
}
res.json({
message: `Ticket scanned successfully (${qtyToRedeem} of ${ticket.quantity || 1} redeemed${newRemaining > 0 ? `, ${newRemaining} remaining` : ''})`,
ticketUsage,
@@ -423,6 +487,8 @@ const getTicketsByEvent = async (req, res) => {
include: {
registrationOption: { include: { eventOption: true, variant: true, registration: { select: { id: true } } } },
user: { select: { id: true, name: true, email: true, phoneNumber: true } },
attendee: { select: { id: true, name: true, email: true, phone: true } },
event: { select: { id: true, title: true, startDate: true, requiresParentalConsent: true, allowTicketTransfer: true } },
usages: { include: { scannedBy: { select: { id: true, name: true, email: true } } } }
},
orderBy: { createdAt: 'desc' },
@@ -632,6 +698,75 @@ const sendTicketsTo = async (req, res) => {
}
};
// @desc Transfer a ticket to someone else (new QR code; billing stays with the payer)
// @route POST /api/tickets/:id/transfer { name, email?, phone?, dateOfBirth?, parentName?, parentEmail?, parentPhone? }
// @access Private (payer or holder; supervisors always)
const transferTicketHandler = async (req, res) => {
try {
const { transferTicket } = require('../utils/ticketTransfer');
const { name, email, phone, dateOfBirth, parentName, parentEmail, parentPhone } = req.body || {};
const r = await transferTicket({ ticketId: req.params.id, actor: req.user, name, email, phone, dateOfBirth, parentName, parentEmail, parentPhone });
const { logAdminAction } = require('../utils/adminAudit');
const { getClientIp } = require('../utils/requestUtils');
logAdminAction({
actorId: req.user.id, actorRole: req.user.role, action: 'ticket_transferred', targetType: 'Ticket', targetId: r.target.id,
metadata: { eventId: r.event.id, registrationId: r.registrationId, from: r.fromHolderName, to: r.attendee.name, toUserId: r.toUser.id, split: !r.transfer.oldQrCode },
ip: getClientIp(req),
});
// Tell the new holder: activation link for a new account, their ticket, and
// anything they need to do themselves (consent, attendee form).
if (r.accountCreated) {
const { sendActivationLink } = require('./userController');
sendActivationLink(r.toUser);
}
const a = r.attendee;
const hasEmail = !!(a.email && a.email.includes('@'));
const recipient = { id: r.toUser.id, name: a.name, email: hasEmail ? a.email : null, phoneNumber: a.phone || null, notificationPreference: hasEmail && a.phone ? 'both' : hasEmail ? 'email' : 'whatsapp' };
const silent = { status: () => silent, json: () => silent };
emailTicketsInternal({ user: { id: r.target.userId }, body: { ticketIds: [r.target.id] }, _noSplit: true }, silent, recipient)
.catch(e => console.error('[transfer] ticket delivery failed:', e?.message || e));
if (r.needsConsent) {
const { createConsentRequest } = require('../utils/consent');
createConsentRequest(a, r.event).catch(e => console.error('[transfer] consent request failed:', e?.message || e));
}
const form = await prisma.eventForm.findUnique({ where: { eventId: r.event.id }, include: { fields: { select: { id: true } } } });
if (form && form.fields.length > 0) {
const { sendAttendeeFormLinkMessage } = require('../utils/notifications');
const full = await prisma.attendee.findUnique({ where: { id: a.id }, include: { registration: { include: { event: true, user: { select: { id: true, name: true } } } } } });
sendAttendeeFormLinkMessage(full).catch(() => {});
}
res.json({
message: `Ticket transferred to ${a.name}. We've sent them the new ticket${r.needsConsent ? ' and a parental consent request' : ''}. The old QR code no longer works.`,
ticketId: r.target.id,
});
} catch (error) {
res.status(error.statusCode || (res.statusCode === 200 ? 400 : res.statusCode)).json({ message: safeErrorMessage(error) });
}
};
// @desc Transfer history of a ticket
// @route GET /api/tickets/:id/transfers
// @access Private (payer or holder; staff)
const getTicketTransfers = async (req, res) => {
try {
const ticket = await prisma.ticket.findUnique({ where: { id: req.params.id }, select: { userId: true, holderUserId: true } });
if (!ticket) { res.status(404); throw new Error('Ticket not found'); }
const staffRole = ['admin', 'supervisor', 'staff'].includes(req.user.role);
if (!staffRole && ticket.userId !== req.user.id && ticket.holderUserId !== req.user.id) { res.status(403); throw new Error('Not authorized'); }
const transfers = await prisma.ticketTransfer.findMany({
where: { ticketId: req.params.id },
select: { id: true, fromHolderName: true, toName: true, createdAt: true, byUser: { select: { name: true } } },
orderBy: { createdAt: 'desc' },
});
res.json(transfers);
} catch (error) {
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
}
};
// Internal helper used by both emailTickets and sendTicketsTo
async function emailTicketsInternal(req, res, userOverride) {
const { ticketIds, registrationId } = req.body;
@@ -651,6 +786,13 @@ async function emailTicketsInternal(req, res, userOverride) {
return res.json({ message: 'No valid contact details on file — ticket delivery skipped.' });
}
const ticketInclude = {
event: true,
registrationOption: { include: { eventOption: true, variant: true, registration: { select: { ticketDelivery: true } } } },
attendee: true,
user: { select: { name: true } },
usages: true,
};
let tickets;
if (registrationId) {
const registration = await prisma.registration.findUnique({
@@ -660,22 +802,53 @@ async function emailTicketsInternal(req, res, userOverride) {
const registrationOptions = await prisma.registrationOption.findMany({ where: { registrationId } });
tickets = await prisma.ticket.findMany({
where: { registrationOptionId: { in: registrationOptions.map(o => o.id) }, userId },
include: { event: true, registrationOption: { include: { eventOption: true, variant: true } }, usages: true },
include: ticketInclude,
orderBy: { createdAt: 'asc' }
});
} else {
// The payer, or the named holder of a group ticket, can have it sent.
tickets = await prisma.ticket.findMany({
where: { id: { in: ticketIds }, userId },
include: { event: true, registrationOption: { include: { eventOption: true, variant: true } }, usages: true },
where: { id: { in: ticketIds }, OR: [{ userId }, { holderUserId: userId }] },
include: ticketInclude,
orderBy: { createdAt: 'asc' }
});
}
// Dedup by registrationOptionId
// Group tickets set to "send each person their own": attendees with contact details
// get theirs directly; the rest (and attendees without contact details) go to the
// payer below. Not when the caller asked for a specific recipient or channel.
if (!userOverride && !req._noSplit) {
const own = tickets.filter(t => t.attendee && (t.attendee.email || t.attendee.phone)
&& t.registrationOption?.registration?.ticketDelivery === 'each');
for (const t of own) {
const a = t.attendee;
const hasEmail = !!(a.email && a.email.includes('@'));
const recipient = {
id: a.userId || null,
name: a.name,
email: hasEmail ? a.email : null,
phoneNumber: a.phone || null,
notificationPreference: hasEmail && a.phone ? 'both' : hasEmail ? 'email' : 'whatsapp',
};
const silent = { status: () => silent, json: () => silent };
try {
await emailTicketsInternal({ user: { id: userId }, body: { ticketIds: [t.id] }, _noSplit: true }, silent, recipient);
} catch (e) {
console.error(`[tickets] Failed to send ${a.name}'s ticket:`, e?.message || e);
}
}
const sentIds = new Set(own.map(t => t.id));
tickets = tickets.filter(t => !sentIds.has(t.id));
if (tickets.length === 0 && own.length > 0) {
return res.json({ message: `${own.length} ticket(s) sent to the people they're for`, ticketIds: own.map(t => t.id) });
}
}
// Dedup by registrationOptionId — except named tickets, which are one per person.
{
const seen = new Map(); const deduped = [];
for (const t of tickets) {
const key = t.registrationOptionId;
const key = t.attendeeId ? `attendee:${t.attendeeId}` : t.registrationOptionId;
if (!seen.has(key)) { seen.set(key, t); deduped.push(t); }
else {
const existing = seen.get(key);
@@ -717,10 +890,10 @@ async function emailTicketsInternal(req, res, userOverride) {
doc.font('Helvetica').fontSize(10);
const variantSuffix = ticket.registrationOption.variant ? ` — ${ticket.registrationOption.variant.name}` : '';
doc.text(`Ticket Type: ${ticket.registrationOption.eventOption.name}${variantSuffix}`, x + 10, y + 30, { width: ticketWidth - 20 });
doc.text(`Qty: ${ticket.quantity || 1}`, x + 10, y + 45, { width: ticketWidth - 20 });
doc.text(ticket.attendee ? `Name: ${ticket.attendee.name}` : `Qty: ${ticket.quantity || 1}`, x + 10, y + 45, { width: ticketWidth - 20 });
const eventDate = new Date(ticket.event.startDate);
doc.text(`Date: ${new Intl.DateTimeFormat('en-GB', { day: '2-digit', month: 'long', year: 'numeric' }).format(eventDate)}`, x + 10, y + 60, { width: ticketWidth - 20 });
doc.text(`Purchased by: ${user.name}`, x + 10, y + 75, { width: ticketWidth - 20 });
doc.text(`Purchased by: ${ticket.user?.name || user.name}`, x + 10, y + 75, { width: ticketWidth - 20 });
const qrCodeSize = Math.min(ticketWidth, ticketHeight) * 0.45;
const qrX = x + (ticketWidth - qrCodeSize) / 2; const qrY = y + 90;
doc.image(qrCodeMap[ticket.id], qrX, qrY, { width: qrCodeSize, height: qrCodeSize });
@@ -791,5 +964,7 @@ module.exports = {
emailTickets,
sendTicketsTo,
getRecentScans,
getScanStats
getScanStats,
transferTicketHandler,
getTicketTransfers
};
+50 -3
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()));
}
});
@@ -70,6 +77,34 @@ const uploadLogo = multer({
}
});
// Favicon storage (same subfolder as the logo — both are "branding" assets)
const faviconStorage = multer.diskStorage({
destination: function (req, file, cb) {
const uploadPath = path.join(__dirname, '..', '..', 'public', 'uploads', 'branding');
try {
if (!fs.existsSync(uploadPath)) fs.mkdirSync(uploadPath, { recursive: true });
cb(null, uploadPath);
} catch (error) {
cb(new Error(`Cannot access upload directory: ${error.message}`));
}
},
filename: function (req, file, cb) {
cb(null, safeFilename('favicon', path.extname(file.originalname).toLowerCase()));
}
});
const uploadFavicon = multer({
storage: faviconStorage,
limits: { fileSize: 2 * 1024 * 1024 }, // 2 MB
fileFilter: function (req, file, cb) {
const ext = path.extname(file.originalname).toLowerCase();
if (!['.ico', '.png', '.svg'].includes(ext)) {
return cb(new Error('Only .ico, .png, or .svg files are allowed for the favicon'), false);
}
cb(null, true);
}
});
// Controller function
const uploadEventImage = (req, res) => {
// Check for multer errors which would be passed in req.multerError
@@ -95,9 +130,21 @@ const uploadLogoImage = (req, res) => {
res.status(200).json({ url: `/uploads/branding/${req.file.filename}` });
};
const uploadFaviconImage = (req, res) => {
if (req.multerError) {
return res.status(500).json({ message: `Upload failed: ${req.multerError.message}` });
}
if (!req.file) {
return res.status(400).json({ message: 'No file uploaded' });
}
res.status(200).json({ url: `/uploads/branding/${req.file.filename}` });
};
module.exports = {
upload,
uploadEventImage,
uploadLogo,
uploadLogoImage,
uploadFavicon,
uploadFaviconImage,
};
+377 -82
View File
@@ -2,10 +2,30 @@ const prisma = require('../config/db');
const { generateToken, hashPassword, comparePassword } = require('../config/auth');
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 ────────────────────────────────────────────────────────────────
// Org name for email subjects — falls back to Cross Code (the platform vendor)
// rather than any specific customer's name when unconfigured.
function getOrgName() {
return getSettingSync('org_name', process.env.ORG_NAME || 'Cross Code');
}
// Resolve a client IP from the request (works behind proxies)
function getClientIp(req) {
const forwarded = req.headers['x-forwarded-for'];
@@ -15,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 {
@@ -40,7 +107,7 @@ async function sendLoginNotification(user, req) {
const { buildWALogin } = require('../utils/waMessages');
const content = buildLoginNotificationEmail({ name: user.name, when, location, userAgent });
// Security: always email; also WhatsApp if preferred
await sendMail({ to: user.email, subject: 'New login to your Hope Events account', ...content });
await sendMail({ to: user.email, subject: `New login to your ${getOrgName()} account`, ...content });
const { waText } = require('../utils/notify');
await waText(user, buildWALogin({ name: user.name, when, location, userAgent })).catch(() => {});
} catch (e) {
@@ -62,13 +129,42 @@ async function sendWelcomeEmail(user) {
const content = buildWelcomeEmail({ name: user.name, events });
const { shouldEmail, waText } = require('../utils/notify');
// Welcome is always sent via email; also via WhatsApp if preferred
await sendMail({ to: user.email, subject: 'Welcome to Hope Events!', ...content });
await sendMail({ to: user.email, subject: `Welcome to ${getOrgName()}!`, ...content });
await waText(user, buildWAWelcome({ name: user.name, events })).catch(() => {});
} catch (e) {
console.warn('[welcome email] Failed:', e?.message || e);
}
}
// 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
@@ -143,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;
@@ -168,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 Hope Events 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 = require('../utils/settingsCache').getSettingSync('org_name', process.env.ORG_NAME || 'Hope Events');
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.');
}
@@ -268,19 +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(() => {});
// 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) });
}
@@ -300,6 +511,8 @@ const getUserProfile = async (req, res) => {
role: true,
phoneNumber: true,
notificationPreference: true,
twoFactorEnabled: true,
twoFactorMethod: true,
createdAt: true,
updatedAt: true,
isActive: true
@@ -307,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');
@@ -354,21 +567,20 @@ const updateUserProfile = async (req, res) => {
}
// Normalize phone
const { normalizeZAPhone, isValidZAPhone } = require('../utils/whatsapp');
const { normalizeZAPhone } = require('../utils/whatsapp');
let newPhone = user.phoneNumber;
if (phoneNumber !== undefined) {
newPhone = phoneNumber ? (normalizeZAPhone(phoneNumber) || phoneNumber.replace(/\D/g, '') || null) : null;
}
// Validate notification preference — WhatsApp requires a valid SA phone number
const allowedPrefs = ['email', 'whatsapp', 'both'];
let newPref = user.notificationPreference;
if (notificationPreference !== undefined) {
newPref = allowedPrefs.includes(notificationPreference) ? notificationPreference : user.notificationPreference;
if ((newPref === 'whatsapp' || newPref === 'both') && !isValidZAPhone(newPhone)) {
newPref = 'email';
}
}
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({
@@ -379,6 +591,7 @@ const updateUserProfile = async (req, res) => {
password: newHashedPassword ? newHashedPassword : user.password,
phoneNumber: newPhone,
notificationPreference: newPref,
twoFactorMethod: newTwoFactorMethod,
updatedAt: new Date()
},
select: {
@@ -388,6 +601,8 @@ const updateUserProfile = async (req, res) => {
role: true,
phoneNumber: true,
notificationPreference: true,
twoFactorEnabled: true,
twoFactorMethod: true,
createdAt: true,
updatedAt: true,
isActive: true,
@@ -397,18 +612,20 @@ 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 { getSettingSync } = require('../utils/settingsCache');
const supportEmail = getSettingSync('org_email', process.env.EMAIL_FROM || '');
const content = buildPasswordChangedEmail({ name: updatedUser.name, when: Date.now(), supportEmail });
sendMail({ to: updatedUser.email, subject: 'Your Hope Events password was changed', ...content })
sendMail({ to: updatedUser.email, subject: `Your ${getOrgName()} password was changed`, ...content })
.catch(e => console.warn('[email] Failed to send password changed alert:', e?.message || e));
const { waText } = require('../utils/notify');
waText(updatedUser, content.text).catch(() => {});
logSecurityEvent({ userId: updatedUser.id, type: 'password_changed', ip: getClientIp(req), userAgent: req.headers['user-agent'] }).catch(() => {});
}
res.json({
...updatedUser,
twoFactorRequired: requiresTwoFactor(updatedUser),
token: generateToken(updatedUser.id, updatedUser.role, updatedUser.tokenVersion)
});
} catch (error) {
@@ -427,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
@@ -482,13 +699,28 @@ const checkUserExists = async (req, res) => {
const existingUser = await prisma.user.findFirst({
where: { OR: searchClauses },
select: { email: true, phoneNumber: true },
select: { id: true, name: true, email: true, phoneNumber: true, notificationPreference: true },
});
const hasEmail = !!existingUser?.email && !existingUser.email.endsWith('@guest.local');
const hasPhone = !!existingUser?.phoneNumber;
res.json({
exists: !!existingUser,
hasEmail: !!existingUser?.email && !existingUser.email.endsWith('@guest.local'),
hasPhone: !!existingUser?.phoneNumber,
hasEmail,
hasPhone,
// Safe-to-display fields only, for autofilling a lookup form — never the password.
// Guest placeholder emails are withheld the same way hasEmail already treats them.
// `id` lets the kiosk tell two different matched accounts apart (e.g. when the
// typed email and phone number resolve to different people) — it's never shown,
// only compared client-side, and this endpoint is already Private/Supervisor.
user: existingUser ? {
id: existingUser.id,
name: existingUser.name,
email: hasEmail ? existingUser.email : null,
phoneNumber: hasPhone ? existingUser.phoneNumber : null,
notificationPreference: existingUser.notificationPreference,
} : null,
});
} catch (error) {
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
@@ -539,7 +771,11 @@ const updateUser = async (req, res) => {
throw new Error('User not found');
}
const { name, email, role, isActive, phoneNumber, password } = 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');
const newPref = resolveNotificationPreference(notificationPreference, newPhone, user.notificationPreference);
// Prepare data update, allow admin to set a new password
const data = {
@@ -547,7 +783,8 @@ const updateUser = async (req, res) => {
email: email || user.email,
role: role || user.role,
isActive: isActive !== undefined ? isActive : user.isActive,
phoneNumber: phoneNumber !== undefined ? (phoneNumber || null) : user.phoneNumber,
phoneNumber: newPhone,
notificationPreference: newPref,
updatedAt: new Date()
};
@@ -555,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,
@@ -564,12 +816,21 @@ const updateUser = async (req, res) => {
email: true,
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) });
@@ -620,10 +881,14 @@ 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);
await require('../utils/consent').clearConsentDataForUser(req.params.id);
res.json({ message: 'User data deleted' });
} catch (error) {
@@ -728,6 +993,9 @@ 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(() => {});
res.json({ message: 'Password has been reset successfully' });
} catch (error) {
@@ -810,6 +1078,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.
@@ -836,6 +1105,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) });
@@ -873,17 +1143,21 @@ 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);
await require('../utils/consent').clearConsentDataForUser(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');
const { waText } = require('../utils/notify');
const { buildWAAccountClosed } = require('../utils/waMessages');
const content = buildAccountClosedEmail({ name: realName, dataDeleted: true });
sendMail({ to: realEmail, subject: 'Your Hope Events account has been closed', ...content }).catch(() => {});
sendMail({ to: realEmail, subject: `Your ${getOrgName()} account has been closed`, ...content }).catch(() => {});
// WhatsApp while we still have phone (send before data wipe completes in-flight)
waText(user, buildWAAccountClosed({ name: realName, dataDeleted: true })).catch(() => {});
}
@@ -898,13 +1172,14 @@ 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');
const { waText } = require('../utils/notify');
const { buildWAAccountClosed } = require('../utils/waMessages');
const content = buildAccountClosedEmail({ name: realName, dataDeleted: false });
sendMail({ to: realEmail, subject: 'Your Hope Events account has been closed', ...content }).catch(() => {});
sendMail({ to: realEmail, subject: `Your ${getOrgName()} account has been closed`, ...content }).catch(() => {});
waText(user, buildWAAccountClosed({ name: realName, dataDeleted: false })).catch(() => {});
}
return res.json({ message: 'Your account has been deactivated.' });
@@ -914,9 +1189,27 @@ const closeAccount = async (req, res) => {
}
};
// @desc Recent account activity (logins, password changes) for the current user
// @route GET /api/users/activity
// @access Private
const getMyActivity = async (req, res) => {
try {
const events = await getRecentSecurityEvents(req.user.id, 10);
res.json(events);
} catch (error) {
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
}
};
module.exports = {
registerUser,
loginUser,
verifyLoginTwoFactor,
resendLoginTwoFactor,
startEnableTwoFactor,
confirmEnableTwoFactor,
disableTwoFactor,
verifyPassword,
getUserProfile,
updateUserProfile,
getUsers,
@@ -931,4 +1224,6 @@ module.exports = {
revokeMySession,
adminRevokeUserSessions,
closeAccount,
getMyActivity,
sendActivationLink,
};
@@ -0,0 +1,195 @@
const prisma = require('../config/db');
const { v4: uuidv4 } = require('uuid');
const { safeErrorMessage } = require('../utils/errorUtils');
const { findAccountByContact } = require('../utils/accountMatch');
const { remainingFor } = require('../utils/stock');
const { ACTIVE, makeOffer, queueWaitlist, findOfferByToken } = require('../utils/waitlist');
const fail = (res, error) => res.status(error.statusCode || (res.statusCode === 200 ? 400 : res.statusCode)).json({ message: safeErrorMessage(error) });
const ENTRY_INCLUDE = {
event: { select: { id: true, title: true, startDate: true, picture: true } },
eventOption: { select: { id: true, name: true } },
variant: { select: { id: true, name: true } },
};
// @desc Join the waitlist for a sold-out option
// @route POST /api/waitlist
// @access Public (login needed when the event requires it)
// Body: { eventId, eventOptionId, variantId?, quantity, guestName?, guestEmail?, guestPhone? }
const joinWaitlist = async (req, res) => {
try {
const { eventId, eventOptionId, variantId = null, quantity, guestName, guestEmail, guestPhone } = req.body || {};
const qty = parseInt(quantity, 10);
if (!eventId || !eventOptionId || !(qty >= 1)) { res.status(400); throw new Error('eventId, eventOptionId and a quantity of at least 1 are required'); }
const event = await prisma.event.findUnique({ where: { id: eventId }, include: { eventOptions: { include: { variants: true } } } });
if (!event || !event.isActive) { res.status(404); throw new Error('Event not found'); }
if (!event.waitlistEnabled) { res.status(400); throw new Error('This event does not have a waitlist.'); }
if (event.cashupStatus === 'closed' || new Date(event.endDate) < new Date()) { res.status(400); throw new Error('This event is no longer taking registrations.'); }
const option = event.eventOptions.find(o => o.id === eventOptionId);
if (!option) { res.status(400); throw new Error('Unknown ticket option for this event'); }
const variant = variantId ? (option.variants || []).find(v => v.id === variantId) : null;
if (variantId && !variant) { res.status(400); throw new Error('Unknown variant for this option'); }
const free = await remainingFor(option, variant);
if (free === null || free >= qty) { res.status(400); throw new Error('There are places available — you can register now.'); }
// Who's joining
let userId = req.user?.id;
if (!userId) {
if (event.requiresAuth !== false) { res.status(401); throw new Error('Log in to join the waitlist for this event.'); }
if (!guestName || !guestEmail) { res.status(400); throw new Error('Name and email are required to join the waitlist'); }
const match = await findAccountByContact({ email: guestEmail, phone: guestPhone });
if (match.conflict) { res.status(409); throw new Error('This email and phone number belong to two different accounts. Please log in, or check your details.'); }
userId = match.user?.id || (await prisma.user.create({
data: { id: uuidv4(), name: String(guestName).trim(), email: String(guestEmail).trim(), password: '', phoneNumber: guestPhone || null, isActive: false, updatedAt: new Date() },
})).id;
}
const existing = await prisma.waitlistEntry.findFirst({ where: { userId, eventOptionId, variantId, status: { in: ACTIVE } } });
if (existing) { res.status(409); throw new Error('You\'re already on the waitlist for this.'); }
const last = await prisma.waitlistEntry.aggregate({ where: { eventId }, _max: { position: true } });
const entry = await prisma.waitlistEntry.create({
data: { eventId, eventOptionId, variantId, userId, quantity: qty, position: (last._max.position || 0) + 1 },
include: ENTRY_INCLUDE,
});
const ahead = await prisma.waitlistEntry.count({ where: { eventId, eventOptionId, variantId, status: 'waiting', position: { lt: entry.position } } });
res.status(201).json({ ...entry, ahead, message: `You're on the waitlist${ahead ? ` — ${ahead} ahead of you` : ''}. We'll email or WhatsApp you if a place opens up.` });
} catch (error) {
fail(res, error);
}
};
// @desc The caller's waitlist entries
// @route GET /api/waitlist/mine
// @access Private
const myWaitlist = async (req, res) => {
try {
const entries = await prisma.waitlistEntry.findMany({
where: { userId: req.user.id, status: { in: ACTIVE }, event: { endDate: { gte: new Date() } } },
include: ENTRY_INCLUDE,
orderBy: { createdAt: 'desc' },
});
res.json(entries);
} catch (error) {
fail(res, error);
}
};
// @desc Leave the waitlist (an open offer is passed on)
// @route DELETE /api/waitlist/:id
// @access Private (owner)
const leaveWaitlist = async (req, res) => {
try {
const entry = await prisma.waitlistEntry.findUnique({ where: { id: req.params.id } });
if (!entry || entry.userId !== req.user.id) { res.status(404); throw new Error('Waitlist entry not found'); }
if (!ACTIVE.includes(entry.status)) { res.status(400); throw new Error('You\'re no longer on this waitlist.'); }
await prisma.waitlistEntry.update({ where: { id: entry.id }, data: { status: 'left', offerTokenHash: null } });
if (entry.status === 'offered') queueWaitlist(entry.eventId);
res.json({ message: 'You\'ve left the waitlist.' });
} catch (error) {
fail(res, error);
}
};
// @desc What a claim link offers
// @route GET /api/waitlist/claim/:token
// @access Public (the token is the credential)
const claimInfo = async (req, res) => {
try {
const entry = await findOfferByToken(req.params.token);
if (!entry) { res.status(404); throw new Error('This waitlist link is not valid or has already been used.'); }
const expired = entry.status !== 'offered' || new Date(entry.offerExpiresAt) < new Date();
res.json({
status: expired ? 'expired' : 'offered',
eventId: entry.eventId,
eventTitle: entry.event.title,
eventStart: entry.event.startDate,
eventOptionId: entry.eventOptionId,
optionName: entry.eventOption.name,
variantId: entry.variantId,
variantName: entry.variant?.name || null,
quantity: entry.offeredQuantity,
requested: entry.quantity,
expiresAt: entry.offerExpiresAt,
name: entry.user?.name,
});
} catch (error) {
fail(res, error);
}
};
// @desc The waitlist for an event
// @route GET /api/waitlist/events/:eventId
// @access Private/Staff
const eventWaitlist = async (req, res) => {
try {
const entries = await prisma.waitlistEntry.findMany({
where: { eventId: req.params.eventId, ...(req.query.all === 'true' ? {} : { status: { in: [...ACTIVE, 'claimed', 'expired'] } }) },
include: { ...ENTRY_INCLUDE, user: { select: { id: true, name: true, email: true, phoneNumber: true } } },
orderBy: { position: 'asc' },
});
res.json(entries);
} catch (error) {
fail(res, error);
}
};
// @desc Offer a place to someone on the waitlist now (even without free stock)
// @route POST /api/waitlist/:id/offer
// @access Private/Supervisor
const offerManually = async (req, res) => {
try {
const entry = await prisma.waitlistEntry.findUnique({ where: { id: req.params.id } });
if (!entry) { res.status(404); throw new Error('Waitlist entry not found'); }
if (!['waiting', 'expired'].includes(entry.status)) { res.status(400); throw new Error(`This person is ${entry.status === 'offered' ? 'already holding an offer' : `no longer waiting (${entry.status})`}.`); }
const qty = Math.max(1, Math.min(entry.quantity, parseInt(req.body?.quantity, 10) || entry.quantity));
const updated = await makeOffer(entry.id, qty);
res.json({ ...updated, message: `Offered ${qty} place(s); the claim link has been sent.` });
} catch (error) {
fail(res, error);
}
};
// @desc Move someone up or down the queue
// @route POST /api/waitlist/:id/move { direction: 'up' | 'down' }
// @access Private/Supervisor
const moveEntry = async (req, res) => {
try {
const entry = await prisma.waitlistEntry.findUnique({ where: { id: req.params.id } });
if (!entry) { res.status(404); throw new Error('Waitlist entry not found'); }
const up = req.body?.direction === 'up';
const neighbour = await prisma.waitlistEntry.findFirst({
where: { eventId: entry.eventId, status: 'waiting', position: up ? { lt: entry.position } : { gt: entry.position } },
orderBy: { position: up ? 'desc' : 'asc' },
});
if (!neighbour) return res.json({ message: 'Already at the ' + (up ? 'front.' : 'back.') });
await prisma.$transaction([
prisma.waitlistEntry.update({ where: { id: entry.id }, data: { position: neighbour.position } }),
prisma.waitlistEntry.update({ where: { id: neighbour.id }, data: { position: entry.position } }),
]);
res.json({ message: 'Moved.' });
} catch (error) {
fail(res, error);
}
};
// @desc Take someone off the waitlist (an open offer is passed on)
// @route POST /api/waitlist/:id/remove
// @access Private/Supervisor
const removeEntry = async (req, res) => {
try {
const entry = await prisma.waitlistEntry.findUnique({ where: { id: req.params.id } });
if (!entry) { res.status(404); throw new Error('Waitlist entry not found'); }
if (!ACTIVE.includes(entry.status)) { res.status(400); throw new Error('This person is not on the waitlist any more.'); }
await prisma.waitlistEntry.update({ where: { id: entry.id }, data: { status: 'removed', offerTokenHash: null } });
if (entry.status === 'offered') queueWaitlist(entry.eventId);
res.json({ message: 'Removed from the waitlist.' });
} catch (error) {
fail(res, error);
}
};
module.exports = { joinWaitlist, myWaitlist, leaveWaitlist, claimInfo, eventWaitlist, offerManually, moveEntry, removeEntry };
+10 -1
View File
@@ -323,6 +323,7 @@ const handlePaymentSucceeded = async (webhookData) => {
externalId: yocoPaymentId,
registrationId: registration?.id || null,
userId: resolvedUserId,
recordedById: resolvedUserId, // self-service webhook payment — payer is the recorder
eventId: registration?.eventId || metadata?.eventId || null,
isDonation: !registration?.id
},
@@ -334,6 +335,13 @@ const handlePaymentSucceeded = async (webhookData) => {
email: true
}
},
recordedBy: {
select: {
id: true,
name: true,
email: true
}
},
registration: registration ? {
include: {
event: true
@@ -439,7 +447,8 @@ const updateRegistrationStatus = async (registrationId) => {
include: {
earlyBirdTiers: true
}
}
},
tranches: true
}
},
payments: true
@@ -36,6 +36,22 @@ function parseFreeformPhones(lines) {
return recipients;
}
// Human-readable summary of who a WhatsApp broadcast will go to, for the scheduled-jobs admin UI
async function describeBroadcastRecipients({ userIds, phones }) {
const parts = [];
try {
const ids = Array.isArray(userIds) ? userIds.filter(x => typeof x === 'string' && x) : [];
if (ids.length) {
const users = await prisma.user.findMany({ where: { id: { in: ids } }, select: { name: true } });
const names = users.map(u => u.name).filter(Boolean);
parts.push(names.slice(0, 3).join(', ') + (names.length > 3 ? ` +${names.length - 3} more` : ''));
}
const extra = parseFreeformPhones(phones);
if (extra.length) parts.push(`${extra.length} phone number${extra.length === 1 ? '' : 's'}`);
} catch {}
return parts.length ? parts.join('; ') : 'No recipients';
}
// @desc Preview WhatsApp broadcast recipients
// @route POST /api/whatsapp-broadcasts/preview
// @access Private/Supervisor or Admin
@@ -152,12 +168,15 @@ const scheduleWhatsAppBroadcast = async (req, res) => {
const payload = { message, userIds, phones, eventId };
const recipientSummary = await describeBroadcastRecipients({ userIds, phones });
const { addJob } = require('../utils/scheduledEmails');
const created = addJob({
broadcast: true,
channel: 'whatsapp',
scheduledAt: when.toISOString(),
createdById: req.user?.id || null,
recipientSummary,
payload,
});
@@ -195,13 +195,14 @@ const handleWebhook = async (req, res) => {
const { getSettingSync } = require('../utils/settingsCache');
const adminEmail = getSettingSync('smtp_from', process.env.EMAIL_FROM || process.env.EMAIL_USER || '')
|| getSettingSync('org_email', process.env.EMAIL_FROM || process.env.EMAIL_USER || '');
const dashboardUrl = `${(process.env.FRONTEND_URL || 'http://localhost:3000').replace(/\/$/, '')}/dashboard/admin/whatsapp`;
const orgName = getSettingSync('org_name', process.env.ORG_NAME || 'Cross Code');
const dashboardUrl = `${(process.env.FRONTEND_URL || 'http://localhost:3000').replace(/\/$/, '')}/dashboard/admin/settings?tab=whatsapp`;
const when = new Date().toLocaleString('en-ZA', { timeZone: 'Africa/Johannesburg' });
await sendMail({
to: adminEmail,
subject: 'WhatsApp session is down — action required',
text: `The Hope Events WhatsApp session has failed and could not be automatically recovered.\n\nTime: ${when}\n\nPlease visit the admin dashboard to reconnect:\n${dashboardUrl}`,
html: `<p>The Hope Events WhatsApp session has failed and could not be automatically recovered after ${MAX_ATTEMPTS} attempts.</p><p><strong>Time:</strong> ${when}</p><p>Please <a href="${dashboardUrl}">visit the admin dashboard</a> to re-scan the QR code and reconnect.</p>`,
text: `The ${orgName} WhatsApp session has failed and could not be automatically recovered.\n\nTime: ${when}\n\nPlease visit the admin dashboard to reconnect:\n${dashboardUrl}`,
html: `<p>The ${orgName} WhatsApp session has failed and could not be automatically recovered after ${MAX_ATTEMPTS} attempts.</p><p><strong>Time:</strong> ${when}</p><p>Please <a href="${dashboardUrl}">visit the admin dashboard</a> to re-scan the QR code and reconnect.</p>`,
}).catch(() => {});
}
} catch (e) {
@@ -83,7 +83,7 @@ const reconcileYocoTransaction = async (req, res) => {
}
const { id } = req.params;
const { registrationId, eventId } = req.body || {};
const { registrationId, eventId, userId: bodyUserId } = req.body || {};
const ytx = await Yoco.findUnique({ where: { id } });
if (!ytx) return res.status(404).json({ success: false, message: 'YocoTransaction not found' });
@@ -118,7 +118,15 @@ const reconcileYocoTransaction = async (req, res) => {
let resolvedUserId = null;
if (registration?.userId) {
resolvedUserId = registration.userId;
} else if (ytx?.raw?.payload?.metadata?.userId) {
} else if (bodyUserId) {
// Staff explicitly picked the donor while reconciling (e.g. as a donation) — trust that
// over metadata guesswork, but still verify the user actually exists.
try {
const exists = await prisma.user.findUnique({ where: { id: String(bodyUserId) } });
if (exists) resolvedUserId = String(bodyUserId);
} catch {}
}
if (!resolvedUserId && ytx?.raw?.payload?.metadata?.userId) {
const metaUserId = String(ytx.raw.payload.metadata.userId);
try {
const exists = await prisma.user.findUnique({ where: { id: metaUserId } });
@@ -138,6 +146,7 @@ const reconcileYocoTransaction = async (req, res) => {
amount: amountFloat,
method: ytx.methodType || 'card',
userId: resolvedUserId,
recordedById: req.user?.id || null, // staff who performed the reconciliation
registrationId: registration?.id || null,
eventId: registration?.eventId || eventId || null,
isDonation: !registration?.id,
@@ -155,7 +164,10 @@ const reconcileYocoTransaction = async (req, res) => {
const createdAt = ytx.createdDate || ytx.createdAt || new Date();
const payment = await prisma.payment.create({ data: { ...paymentData, createdAt } });
// Optionally update registration status when applicable and generate/email tickets if paid
// Optionally update registration status when applicable and generate tickets if paid.
// Ticket generation stays synchronous so `generatedTickets` can be included in the response;
// emailing/WhatsApp-ing the tickets and payment confirmation are backgrounded below since
// they involve slow PDF rendering + SMTP/WAWP round trips that shouldn't block this request.
let generatedTickets = [];
if (registration) {
try {
@@ -163,15 +175,6 @@ const reconcileYocoTransaction = async (req, res) => {
if (updatedReg && updatedReg.status === 'paid') {
try {
generatedTickets = await generateTicketsForRegistration(registration.id);
if (Array.isArray(generatedTickets) && generatedTickets.length > 0) {
try {
const mockReq = { user: { id: registration.userId }, body: { registrationId: registration.id } };
const mockRes = { status: () => mockRes, json: () => {} };
await emailTickets(mockReq, mockRes);
} catch (emailErr) {
console.error('Error emailing tickets after Yoco reconciliation:', emailErr);
}
}
} catch (genErr) {
console.error('Error generating tickets after Yoco reconciliation:', genErr);
}
@@ -182,13 +185,20 @@ const reconcileYocoTransaction = async (req, res) => {
}
}
// Send emails for the reconciled payment
try {
const { sendPaymentEmails } = require('../utils/notifications');
await sendPaymentEmails(payment.id);
} catch (e) {
console.error('Failed to send payment emails after reconciliation:', e);
}
// Fire-and-forget: payment confirmation email, then ticket email/WhatsApp (guarantees order)
const { sendPaymentEmails } = require('../utils/notifications');
const _rxPaymentId = payment.id;
const _rxUserId = registration?.userId;
const _rxRegId = registration?.id;
const _rxShouldEmailTickets = Array.isArray(generatedTickets) && generatedTickets.length > 0;
(async () => {
try { await sendPaymentEmails(_rxPaymentId); } catch (e) { console.error('Failed to send payment emails after reconciliation:', e); }
if (_rxShouldEmailTickets && _rxUserId) {
const mockReq = { user: { id: _rxUserId }, body: { registrationId: _rxRegId } };
const mockRes = { status: () => mockRes, json: () => {} };
try { await emailTickets(mockReq, mockRes); } catch (e) { console.error('Error emailing tickets after Yoco reconciliation:', e); }
}
})();
// Update yoco transaction as reconciled
const updatedTx = await Yoco.update({
+300 -31
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,10 +174,26 @@ 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/consent', require('./routes/consentRoutes'));
app.use('/api/waitlist', require('./routes/waitlistRoutes'));
app.use('/api/scan-locks', require('./routes/scanLockRoutes'));
app.use('/api/backups', backupRoutes);
// Pre-warm the settings cache so synchronous helpers have DB values from startup
require('./utils/settingsCache').warmCache().catch(() => {});
app.use('/uploads', express.static('public/uploads'));
const { getSettingSync, warmCache } = require('./utils/settingsCache');
warmCache().catch(() => {});
// Uploaded branding assets can include SVGs, which may embed <script>/event
// handlers. Serving them inline lets a compromised/malicious upload run script
// in the site's origin if opened directly, so pin the safe response headers
// (no inline execution, no MIME-sniffing to HTML/script) on every asset here.
app.use('/uploads', express.static('public/uploads', {
setHeaders: (res) => {
res.setHeader('Content-Security-Policy', "default-src 'none'; style-src 'unsafe-inline'; sandbox");
res.setHeader('X-Content-Type-Options', 'nosniff');
},
}));
// ── Shared page helpers ────────────────────────────────────────────────────────
const jwt = require('jsonwebtoken');
@@ -244,8 +295,9 @@ app.get('/', async (req, res) => {
? `<span class="badge badge-warn">testing</span>`
: `<span class="badge badge-warn">development</span>`;
const html = pageShell('Hope Events API — Status', '#2563eb', `
<h1>Hope Events API</h1>
const orgName = getSettingSync('org_name', process.env.ORG_NAME || 'Cross Code');
const html = pageShell(`${orgName} Events API — Status`, '#2563eb', `
<h1>${orgName} Events API</h1>
<p class="subtitle">v${API_VERSION} &mdash; ${now}</p>
<div class="stat-grid">
@@ -311,7 +363,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 },
@@ -343,8 +395,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:[
@@ -376,6 +444,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:[
@@ -522,6 +613,10 @@ app.get('/docs', async (req, res) => {
{ method:'GET', path:'/api/registrations/event/:eventId', auth:'staff+', desc:'Get all registrations for a specific event',
pathParams:{ ':eventId':'Event UUID' },
responses:[{ status:200, desc:'Success', body:[{ id:'reg-uuid-...', user:{ id:'user-uuid-...', name:'Jane Doe', email:'jane@example.com', phoneNumber:'+27821234567' }, status:'paid' }]}]},
{ method:'PUT', path:'/api/registrations/:id/attendees', auth:'user+', desc:'Replace the named attendees on a registration (group tickets) and choose ticket delivery (payer | each). Owner or staff. Removing an attendee whose ticket was scanned is refused.',
request:{ body:{ attendees:[{ id:'att-uuid-...', eventOptionId:'opt-uuid-...', name:'Sam Smith', email:'sam@example.com' },{ eventOptionId:'opt-uuid-...', name:'Alex Smith' }], ticketDelivery:'each' }},
responses:[{ status:200, desc:'Saved', body:{ id:'reg-uuid-...', ticketDelivery:'each', attendees:[{ id:'att-uuid-...', name:'Sam Smith', ticket:{ id:'tkt-uuid-...', isUsed:false }}] }}]},
{ method:'POST', path:'/api/registrations/:id/attendees/:attendeeId/send-form-link', auth:'user+', desc:'Email/WhatsApp a named attendee the link to fill in their own attendee form' },
{ method:'POST', path:'/api/registrations/manual', auth:'supervisor+', desc:'Manually create a registration (at-the-door / walk-in). Resolves early-bird tier pricing (including per-variant tiers) and stores priceSnapshot + appliedTierId on each option. Creates or finds a matching user by email/phone. If a matching user is found, updates their notificationPreference and backfills a missing phone number or guest placeholder email with the newly supplied value (without overwriting existing contact info). Generates a Yoco checkout link for unpaid registrations to include in the self-service notification email.',
request:{ body:{ eventId:'ev-uuid-...', user:{ name:'Jane Doe', email:'jane@example.com', phoneNumber:'0821234567' }, options:[{ eventOptionId:'opt-uuid-...', quantity:1, variantId:'var-uuid-...' }], notificationPreference:'email' }},
responses:[{ status:201, desc:'Created', body:{ id:'reg-uuid-new', status:'pending' }}]},
@@ -529,11 +624,80 @@ app.get('/docs', async (req, res) => {
pathParams:{ ':id':'Registration UUID' },
request:{ body:{ answers:[{ fieldId:'field-uuid-...', value:'Yes' }, { fieldId:'field-uuid-2', value:'Vegetarian' }] }},
responses:[{ status:201, desc:'Submitted', body:{ id:'response-uuid-...', createdAt:'2025-06-01T10:05:00.000Z' }}]},
{ method:'POST', path:'/api/registrations/:id/send-invoice', auth:'user+', desc:'(Re)generate the invoice PDF for one of the caller\'s own registrations and send it on the requested channel — email or WhatsApp. Works regardless of balance (shows "Paid in full" when nothing is owed); fails only if there is no valid email/phone on file for the chosen channel.',
notes:'The PDF\'s invoiceNo is derived, not sequential: INV-<year of registration.createdAt>-<last 6 hex chars of the registration id, uppercased>. When there is an outstanding balance, generates a fresh Yoco checkout link for the invoice\'s QR code/pay-now link on each call.',
pathParams:{ ':id':'Registration UUID' },
request:{ body:{ channel:'email' }},
responses:[
{ status:200, desc:'Sent', body:{ message:'Invoice emailed.' }},
{ status:400, desc:'No valid contact for channel', body:{ message:'No valid email address on file. Add one in your profile.' }},
{ status:403, desc:'Not the owner', body:{ message:'Not authorized to access this registration' }},
]},
]},
{ title: 'Ticket transfer', base: '/api/tickets', endpoints: [
{ method:'POST', path:'/api/tickets/:id/transfer', auth:'payer, holder or supervisor+', desc:'Give a ticket to someone else before the event (new QR code; the old one stops working; billing stays with the payer)',
request:{ body:{ name:'Nia New', email:'nia@example.com', phone:'0821234567' }},
responses:[{ status:200, desc:'Transferred', body:{ message:'Ticket transferred to Nia New…', ticketId:'ticket-uuid-...' }}, { status:400, desc:'Not allowed', body:{ message:'This ticket has already been used (or partly used), so it can’t be transferred.' }}]},
{ method:'GET', path:'/api/tickets/:id/transfers', auth:'payer, holder or staff+', desc:'Transfer history of a ticket' },
]},
{ title: 'Offline scanning', base: '/api/scan-locks', endpoints: [
{ method:'GET', path:'/api/scan-locks', auth:'staff+', desc:'Active offline locks for an event (?eventId=)' },
{ method:'POST', path:'/api/scan-locks', auth:'staff+', desc:'Lock a section (or the whole event) to this device for offline scanning',
request:{ body:{ eventId:'ev-uuid-...', sectionId:'sec-uuid-...', deviceId:'device-uuid', deviceName:'Chrome on Android' }},
responses:[{ status:201, desc:'Locked', body:{ id:'lock-uuid-...', sectionName:'Main hall' }}, { status:409, desc:'Held elsewhere', body:{ message:'"Main hall" is offline-locked by Ann on Chrome on Android.' }}]},
{ method:'GET', path:'/api/scan-locks/:id/snapshot', auth:'staff+', desc:'Tickets valid in the lock scope, for the device to keep offline (lock holder, ?deviceId=)' },
{ method:'POST', path:'/api/scan-locks/:id/sync', auth:'staff+', desc:'Upload offline scans (idempotent by clientScanId; capped at what is left on each ticket)',
request:{ body:{ deviceId:'device-uuid', scans:[{ clientScanId:'scan-uuid', qrCode:'qr-uuid', qty:1, scannedAt:'2025-07-10T08:15:00.000Z' }] }},
responses:[{ status:200, desc:'Synced', body:{ accepted:1, duplicates:0, conflicts:[] }}]},
{ method:'DELETE', path:'/api/scan-locks/:id', auth:'staff+', desc:'End offline mode (lock holder)' },
{ method:'POST', path:'/api/scan-locks/:id/force-release', auth:'supervisor+', desc:'Release a lost device\'s lock' },
{ method:'GET', path:'/api/scan-locks/reports', auth:'supervisor+', desc:'Offline sync reports for an event' },
{ method:'POST', path:'/api/scan-locks/reports/:id/review', auth:'supervisor+', desc:'Mark a sync report reviewed' },
]},
{ title: 'Waitlist', base: '/api/waitlist', endpoints: [
{ method:'POST', path:'/api/waitlist', auth:'public', desc:'Join the waitlist for a sold-out line (login needed when the event requires it)',
request:{ body:{ eventId:'ev-uuid-...', eventOptionId:'opt-uuid-...', quantity:2 }},
responses:[{ status:201, desc:'Joined', body:{ id:'wl-uuid-...', status:'waiting', ahead:3 }}, { status:400, desc:'Places available', body:{ message:'There are places available — you can register now.' }}]},
{ method:'GET', path:'/api/waitlist/mine', auth:'user+', desc:'My active waitlist entries' },
{ method:'DELETE', path:'/api/waitlist/:id', auth:'user+', desc:'Leave the waitlist' },
{ method:'GET', path:'/api/waitlist/claim/:token', auth:'public', desc:'What a claim link offers (rate-limited)' },
{ method:'GET', path:'/api/waitlist/events/:eventId', auth:'staff+', desc:'The queue for an event' },
{ method:'POST', path:'/api/waitlist/:id/offer', auth:'supervisor+', desc:'Offer places now, even over the stock limit' },
{ method:'POST', path:'/api/waitlist/:id/move', auth:'supervisor+', desc:'Move up or down the queue', request:{ body:{ direction:'up' }} },
{ method:'POST', path:'/api/waitlist/:id/remove', auth:'supervisor+', desc:'Take someone off the waitlist' },
]},
{ title: 'Parental consent', base: '/api/consent', endpoints: [
{ method:'GET', path:'/api/consent/:token', auth:'public', desc:'Consent page data for a parent/guardian (the link token is the credential; rate-limited)',
responses:[{ status:200, desc:'Success', body:{ status:'pending', attendeeName:'Sam Smith', consentText:'I give permission…', event:{ title:'Youth Camp' }, collect:{ medical:true, emergency:true, photo:true } }}, { status:404, desc:'Unknown or replaced link', body:{ message:'This consent link is not valid.' }}]},
{ method:'POST', path:'/api/consent/:token', auth:'public', desc:'Give or decline consent. Records name, time, IP, device and the consent text version; medical/emergency details are stored encrypted.',
request:{ body:{ decision:'give', signedName:'Pat Smith', isGuardian:true, medical:{ allergies:'Peanuts' }, emergency:{ name:'Gran', phone:'0821234567' }, photoConsent:true }},
responses:[{ status:200, desc:'Answered', body:{ status:'given' }}, { status:409, desc:'Already answered', body:{ message:'Consent has already been given.' }}]},
{ method:'GET', path:'/api/consent/events/:eventId', auth:'staff+', desc:'Consent status for every attendee on an event' },
{ method:'POST', path:'/api/consent/requests/:id/resend', auth:'user+', desc:'Re-send a pending request with a fresh link (registrant or staff)' },
{ method:'POST', path:'/api/consent/events/:eventId/resend-pending', auth:'supervisor+', desc:'Re-send every pending request on an event' },
{ method:'POST', path:'/api/consent/events/:eventId/rerequest', auth:'supervisor+', desc:'Ask again where consent was given on an older version of the text, or never requested' },
{ method:'GET', path:'/api/consent/events/:eventId/medical', auth:'supervisor+', desc:'Decrypted medical & emergency list; every view is written to the admin audit log' },
]},
{ 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.hopehenley.co.za/payment/success', cancelUrl:'https://events.hopehenley.co.za/payment/cancel', failureUrl:'https://events.hopehenley.co.za/payment/failure' }},
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' }},
responses:[
{ status:200, desc:'Checkout created — proceed to Yoco', body:{ redirectUrl:'https://pay.yoco.com/checkout/abc123', checkoutId:'yoco-checkout-id', amount:450 }},
{ status:200, desc:'Early-bird price changed — checkout NOT created. Frontend must show warning and let user confirm before retrying.', body:{ priceUpdated:true, newTotal:500, message:'One or more early-bird prices have changed since your registration was created. Please review the updated total before proceeding.' }},
@@ -546,6 +710,14 @@ app.get('/docs', async (req, res) => {
{ method:'GET', path:'/api/payments/mypayments', auth:'user+', desc:'Get own payment history (paginated, excludes donations). Returned method is normalized to cash|card|eft|voucher|other — apple_pay/google_pay report as "card", any other gateway-reported value reports as "other"',
queryParams:{ page:'Page (default 1)', limit:'Per page (default 25, max 25)', startDate:'ISO date, filters createdAt >=', endDate:'ISO date, filters createdAt <=', method:'Filter by normalized method: cash|card|eft|voucher|other', kind:'payment|refund — filters by amount sign' },
responses:[{ status:200, desc:'Success', body:{ data:[{ id:'pay-uuid-...', amount:450, method:'card', status:'succeeded', createdAt:'2025-06-01T11:00:00.000Z' }], total:1, page:1, limit:25, pages:1 }}]},
{ method:'POST', path:'/api/payments/:id/send-receipt', auth:'user+', desc:'Regenerate the receipt PDF for one of the caller\'s own payments and send it on the requested channel — email or WhatsApp. Fails if there is no valid email/phone on file for the chosen channel.',
notes:'The PDF\'s receiptNo is derived, not sequential: RCPT-<year of payment.createdAt>-<last 6 hex chars of the payment id, uppercased>.',
pathParams:{ ':id':'Payment UUID' },
request:{ body:{ channel:'whatsapp' }},
responses:[
{ status:200, desc:'Sent', body:{ message:'Receipt sent to WhatsApp.' }},
{ status:403, desc:'Not the owner', body:{ message:'Not authorized to access this payment' }},
]},
{ method:'GET', path:'/api/payments', auth:'supervisor+', desc:'List all payments',
queryParams:{ page:'Page (default 1)', limit:'Per page (default 20)', eventId:'Filter by event', userId:'Filter by user', method:'Filter by method (cash|card|eft|donation)', startDate:'ISO date', endDate:'ISO date' },
responses:[{ status:200, desc:'Success', body:{ data:[{ id:'pay-uuid-...', amount:450, method:'card', user:{ name:'Jane Doe' }, registration:{ event:{ title:'Camp 2025' }}}], total:1 }}]},
@@ -750,7 +922,7 @@ app.get('/docs', async (req, res) => {
responses:[{ status:200, desc:'Saved', body:{ message:'WhatsApp config saved.' }}]},
{ method:'GET', path:'/api/whatsapp/status', auth:'admin', desc:'Get the current WAWP session status',
responses:[
{ status:200, desc:'Connected', body:{ status:'open', phoneNumber:'+27821234567', pushName:'Hope Events' }},
{ status:200, desc:'Connected', body:{ status:'open', phoneNumber:'+27821234567', pushName:'Cross Code' }},
{ status:200, desc:'Not connected', body:{ status:'close' }},
]},
{ method:'GET', path:'/api/whatsapp/qr', auth:'admin', desc:'Get a QR code image/string to link a WhatsApp account',
@@ -773,14 +945,14 @@ app.get('/docs', async (req, res) => {
]},
{ title: 'Settings', base: '/api/settings', endpoints: [
{ method:'GET', path:'/api/settings', auth:'public', desc:'Public settings — org name, accent colour, logo URL, legal page slugs, registration notification email (no secrets)',
responses:[{ status:200, desc:'Success', body:{ org_name:'Hope Family Church', org_tagline:'Where everyone belongs', accent_color:'#2563eb', logo_url:'/uploads/logo.png' }}]},
{ method:'GET', path:'/api/settings', auth:'public', desc:'Public settings — org name, brand colours (primary/secondary/accent), logo URL, favicon URL, legal page slugs, registration notification email (no secrets)',
responses:[{ status:200, desc:'Success', body:{ org_name:'Cross Code', org_tagline:'Event management, made simple', primary_color:'#4F46E5', secondary_color:'#8B5CF6', accent_color:'#EC4899', logo_url:'/uploads/branding/logo-123.png', favicon_url:'/uploads/branding/favicon-123.png' }}]},
{ method:'GET', path:'/api/settings/all', auth:'admin', desc:'All settings including SMTP config. smtp_pass is returned masked (••••••••); smtp_user is returned decrypted.',
responses:[{ status:200, desc:'Success', body:[{ key:'smtp_host', value:'smtp.example.com' },{ key:'smtp_user', value:'user@example.com' },{ key:'smtp_pass', value:'••••••••' }]}]},
{ method:'GET', path:'/api/settings/needs-setup', auth:'public', desc:'Returns true until the setup wizard has been completed (setup_complete flag is set).',
responses:[{ status:200, desc:'Success', body:{ needsSetup:true }}]},
{ method:'PUT', path:'/api/settings', auth:'admin', desc:'Upsert one or more settings. smtp_user and smtp_pass are AES-256-GCM encrypted before storage. Sending •••••••• for smtp_pass is a no-op.',
request:{ body:{ org_name:'Hope Family Church', smtp_host:'smtp.gmail.com', smtp_port:'587', smtp_user:'user@gmail.com', smtp_pass:'app-password' }},
request:{ body:{ org_name:'Cross Code', smtp_host:'smtp.gmail.com', smtp_port:'587', smtp_user:'user@gmail.com', smtp_pass:'app-password' }},
responses:[{ status:200, desc:'Saved', body:{ message:'Settings saved.' }}]},
{ method:'POST', path:'/api/settings/test-smtp', auth:'admin (or setup token)', desc:'Test the SMTP connection with provided credentials. On success, sends a real test email to the authenticated admin and returns a friendly message. On failure, returns a human-readable message plus a raw field containing the original SMTP error for debugging. Error code 530 (Microsoft "Client not authenticated") maps to the authentication-failure message.',
request:{ body:{ host:'smtp.gmail.com', port:587, secure:false, user:'me@gmail.com', pass:'app-password', from:'me@gmail.com' }},
@@ -798,7 +970,7 @@ app.get('/docs', async (req, res) => {
{ status:400, desc:'Already exists', body:{ message:'Setup already completed — users exist.' }},
]},
{ method:'POST', path:'/api/setup', auth:'setup token or admin', desc:'Final step of the setup wizard. Saves initial site settings. Requires the JWT returned by POST /api/setup/register.',
request:{ body:{ settings:{ org_name:'Hope Family Church', smtp_host:'smtp.gmail.com', smtp_port:'587', smtp_user:'user@gmail.com', smtp_pass:'app-password', mail_from:'noreply@example.com' }}},
request:{ body:{ settings:{ org_name:'Cross Code', smtp_host:'smtp.gmail.com', smtp_port:'587', smtp_user:'user@gmail.com', smtp_pass:'app-password', mail_from:'noreply@example.com' }}},
responses:[{ status:200, desc:'Setup complete', body:{ message:'Setup complete.' }}]},
]},
];
@@ -964,12 +1136,12 @@ app.get('/docs', async (req, res) => {
{ trigger: 'Scheduled — every day at 07:00 local server time, once per active event that has gone live and has not yet started', channels: ['Email'], recipients: 'Admin — registrations inbox + event notify recipients (falls back to the event creator)', subject: 'Daily summary: {event} — {date}', content: 'Stat tiles (registrations, paid, awaiting payment, revenue), full registrations table with balances, full payments & donations table.' },
]},
{ category: 'Account & security', items: [
{ trigger: 'New account registered', channels: ['Email (always)', 'WhatsApp (if preferred)'], recipients: 'New user', subject: 'Welcome to Hope Events!', content: 'Welcome message plus a list of upcoming events.' },
{ trigger: 'Login attempt on an account that is not yet active', channels: ['Email (if a real address is on file)', 'WhatsApp (fallback when there is no usable email)'], recipients: 'User', subject: 'Activate your Hope Events account', content: 'One-time activation link; expires after 24 hours.' },
{ trigger: 'Successful login', channels: ['Email (always)', 'WhatsApp (if preferred)'], recipients: 'User', subject: 'New login to your Hope Events account', content: 'Login time, approximate location, device/user agent. Security alert — always emailed regardless of the user\'s notification preference.' },
{ trigger: 'Password changed via profile update', channels: ['Email (always)', 'WhatsApp (if preferred)'], recipients: 'User', subject: 'Your Hope Events password was changed', content: 'Confirms the change and gives a support contact to use if it wasn\'t them.' },
{ trigger: 'New account registered', channels: ['Email (always)', 'WhatsApp (if preferred)'], recipients: 'New user', subject: 'Welcome to {org}!', content: 'Welcome message plus a list of upcoming events.' },
{ trigger: 'Login attempt on an account that is not yet active', channels: ['Email (if a real address is on file)', 'WhatsApp (fallback when there is no usable email)'], recipients: 'User', subject: 'Activate your {org} account', content: 'One-time activation link; expires after 24 hours.' },
{ trigger: 'Successful login', channels: ['Email (always)', 'WhatsApp (if preferred)'], recipients: 'User', subject: 'New login to your {org} account', content: 'Login time, approximate location, device/user agent. Security alert — always emailed regardless of the user\'s notification preference.' },
{ trigger: 'Password changed via profile update', channels: ['Email (always)', 'WhatsApp (if preferred)'], recipients: 'User', subject: 'Your {org} password was changed', content: 'Confirms the change and gives a support contact to use if it wasn\'t them.' },
{ trigger: 'Forgot-password request', channels: ['Email (always)', 'WhatsApp (if preferred)'], recipients: 'User', subject: 'Reset your password', content: 'Password reset link.' },
{ trigger: 'Account closed (self-service — "Deactivate" or "Delete my data")', channels: ['Email', 'WhatsApp (if preferred)'], recipients: 'User (sent to their last-known address/number just before data is wiped)', subject: 'Your Hope Events account has been closed', content: 'Confirms closure; wording differs slightly when personal data was also erased.' },
{ trigger: 'Account closed (self-service — "Deactivate" or "Delete my data")', channels: ['Email', 'WhatsApp (if preferred)'], recipients: 'User (sent to their last-known address/number just before data is wiped)', subject: 'Your {org} account has been closed', content: 'Confirms closure; wording differs slightly when personal data was also erased.' },
]},
{ category: 'Admin-triggered broadcasts', items: [
{ trigger: 'Bulk email to event attendees, or an ad-hoc email broadcast — sent immediately or on a schedule', channels: ['Email'], recipients: 'Selected attendees / users / ad-hoc addresses chosen by the sender', subject: 'Admin-authored', content: 'Free-form subject & body written by the sender, supporting {{name}}, {{event.title}}, {{event.link}} placeholders. Used for reminders, thank-yous, promos, and multi-step automations.' },
@@ -1013,13 +1185,14 @@ app.get('/docs', async (req, res) => {
}
const notificationsHtml = NOTIFICATIONS.map(renderNotificationCategory).join('');
const orgName = getSettingSync('org_name', process.env.ORG_NAME || 'Cross Code');
const html = `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Hope Events — API Docs</title>
<title>${orgName} Events — API Docs</title>
<style>
*{box-sizing:border-box;margin:0;padding:0}
body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;background:#f3f4f6;color:#1f2937;min-height:100vh;padding:24px 16px}
@@ -1043,7 +1216,7 @@ app.get('/docs', async (req, res) => {
<body>
<div class="wrap">
<div style="display:flex;align-items:baseline;justify-content:space-between;flex-wrap:wrap;gap:8px;margin-bottom:6px">
<h1 style="font-size:1.4rem;font-weight:700;color:#111827">Hope Events — API Reference</h1>
<h1 style="font-size:1.4rem;font-weight:700;color:#111827">${orgName} Events — API Reference</h1>
<a href="/" style="font-size:.82rem;color:#6b7280">&#8592; Status page</a>
</div>
<p style="font-size:.82rem;color:#6b7280;margin-bottom:20px">
@@ -1070,7 +1243,7 @@ app.get('/docs', async (req, res) => {
${notificationsHtml}
<p style="font-size:.72rem;color:#9ca3af;margin-top:28px;text-align:center">
Hope Events API v${API_VERSION} &mdash; ${new Date().toISOString()}
${orgName} Events API v${API_VERSION} &mdash; ${new Date().toISOString()}
</p>
</div>
<script>
@@ -1098,6 +1271,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
@@ -1161,6 +1342,59 @@ app.listen(PORT, () => {
console.warn('[daily summaries] Not scheduled:', e?.message || e);
}
// Waitlist job — expire unclaimed offers and offer any free places to the next people.
try {
const enabled = String(process.env.WAITLIST_JOB_ENABLED || 'true').toLowerCase() !== 'false';
if (enabled) {
const { runWaitlistJob } = require('./utils/waitlist');
const everyMs = parseInt(process.env.WAITLIST_JOB_INTERVAL_MS || String(10 * 60 * 1000), 10);
setInterval(async () => {
try {
const r = await runWaitlistJob();
if (r.expired || r.offers) console.log(`[waitlist] ${r.expired} event(s) with expired offers, ${r.offers} new offer(s).`);
} catch (e) {
console.warn('[waitlist] Job failed:', e?.message || e);
}
}, everyMs);
console.log(`[waitlist] Job every ${Math.round(everyMs / 60000)} min.`);
} else {
console.log('[waitlist] Job disabled by env WAITLIST_JOB_ENABLED=false');
}
} catch (e) {
console.warn('[waitlist] Not scheduled:', e?.message || e);
}
// Daily parental-consent job — reminders for unanswered requests, and purging
// medical/emergency details a set number of days after each event.
try {
const enabled = String(process.env.CONSENT_JOBS_ENABLED || 'true').toLowerCase() !== 'false';
if (enabled) {
const { sendConsentReminders, purgeConsentMedicalData } = require('./utils/consent');
const runConsentJobs = async () => {
try {
const reminded = await sendConsentReminders(new Date());
const purged = await purgeConsentMedicalData(new Date());
if (reminded || purged) console.log(`[consent] Sent ${reminded} reminder(s), purged medical details on ${purged} request(s).`);
} catch (e) {
console.warn('[consent] Daily job failed:', e?.message || e);
}
};
const scheduleConsent = () => {
const now = new Date();
const next = new Date(now);
next.setHours(9, 0, 0, 0);
if (next <= now) next.setDate(next.getDate() + 1);
setTimeout(async () => { await runConsentJobs(); scheduleConsent(); }, next.getTime() - now.getTime());
};
scheduleConsent();
console.log('[consent] Scheduler initialized (09:00 local time).');
} else {
console.log('[consent] Scheduler disabled by env CONSENT_JOBS_ENABLED=false');
}
} catch (e) {
console.warn('[consent] Not scheduled:', e?.message || e);
}
// Daily temp-file cleanup — removes files older than 7 days from /temp
try {
const { cleanupTempFiles } = require('./utils/cleanupTemp');
@@ -1188,17 +1422,48 @@ 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';
if (enabled) {
const { getDueJobs, updateJob } = require('./utils/scheduledEmails');
const { getDueJobs, updateJob, purgeSentJobs } = require('./utils/scheduledEmails');
const { emailEventAttendees, whatsappEventAttendees } = require('./controllers/eventController');
const { sendBroadcast } = require('./controllers/broadcastController');
const { sendWhatsAppBroadcast } = require('./controllers/whatsappBroadcastController');
const intervalMs = parseInt(process.env.SCHEDULED_EMAILS_INTERVAL_MS || '30000', 10);
setInterval(async () => {
try {
try { purgeSentJobs(24 * 60 * 60 * 1000); } catch (e) { console.warn('[scheduled emails] purge failed:', e?.message || e); }
const due = getDueJobs(new Date());
if (!due || due.length === 0) return;
for (const job of due) {
@@ -1244,11 +1509,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;
+3 -1
View File
@@ -1,10 +1,12 @@
const express = require('express');
const router = express.Router();
const { getEventCashup, saveEventCashupDraft, closeEvent, reopenEvent, getCashupAudit } = require('../controllers/cashupController');
const { getEventCashup, getCashByRecordedUser, savePersonCash, saveEventCashupDraft, closeEvent, reopenEvent, getCashupAudit } = require('../controllers/cashupController');
const { protect, supervisor, admin } = require('../middleware/authMiddleware');
router.get('/audit', protect, supervisor, getCashupAudit);
router.get('/event/:eventId', protect, supervisor, getEventCashup);
router.get('/event/:eventId/cash-by-user', protect, supervisor, getCashByRecordedUser);
router.put('/event/:eventId/person-cash/:userId', protect, supervisor, savePersonCash);
router.put('/event/:eventId/draft', protect, admin, saveEventCashupDraft);
router.post('/event/:eventId/close', protect, admin, closeEvent);
router.post('/event/:eventId/reopen', protect, admin, reopenEvent);
+20
View File
@@ -0,0 +1,20 @@
const express = require('express');
const rateLimit = require('express-rate-limit');
const router = express.Router();
const { getConsent, answerConsent, listEventConsent, resendRequest, resendPending, rerequestConsent, medicalList } = require('../controllers/consentController');
const { protect, staff, supervisor } = require('../middleware/authMiddleware');
// The token in the link is the parent's only credential, so keep guessing slow.
const consentLimiter = rateLimit({ windowMs: 60 * 1000, max: 30, message: { message: 'Too many attempts. Please try again in a minute.' } });
router.get('/events/:eventId', protect, staff, listEventConsent);
router.post('/events/:eventId/resend-pending', protect, supervisor, resendPending);
router.post('/events/:eventId/rerequest', protect, supervisor, rerequestConsent);
router.get('/events/:eventId/medical', protect, supervisor, medicalList);
router.post('/requests/:id/resend', protect, resendRequest);
// Public consent page
router.get('/:token', consentLimiter, getConsent);
router.post('/:token', consentLimiter, answerConsent);
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
+5 -1
View File
@@ -8,10 +8,12 @@ const {
getPaymentsByRegistration,
getPaymentsByEvent,
assignDonationToRegistration,
unassignDonationFromRegistration,
createYocoCheckout,
sendPaymentLink,
createRefund,
getPaymentStats
getPaymentStats,
sendReceipt,
} = require('../controllers/paymentController');
const { protect, supervisor, staff, admin} = require('../middleware/authMiddleware');
@@ -20,6 +22,7 @@ router.post('/', protect, supervisor, createPayment);
router.post('/yoco-checkout', protect, createYocoCheckout);
router.post('/yoco-checkout/send', protect, supervisor, sendPaymentLink);
router.get('/mypayments', protect, getUserPayments);
router.post('/:id/send-receipt', protect, sendReceipt);
router.get('/:id', protect, getPaymentById);
router.get('/registration/:registrationId', protect, getPaymentsByRegistration);
@@ -27,6 +30,7 @@ router.get('/registration/:registrationId', protect, getPaymentsByRegistration);
router.get('/', protect, supervisor, getPayments);
router.get('/event/:eventId', protect, staff, getPaymentsByEvent);
router.put('/assign-donation', protect, supervisor, assignDonationToRegistration);
router.post('/unassign-donation', protect, supervisor, unassignDonationFromRegistration);
router.post('/refund', protect, supervisor, createRefund);
router.get('/admin/stats', protect, admin, getPaymentStats);
+8
View File
@@ -12,6 +12,9 @@ const {
updateRegistrationOptions,
submitFormResponses, replaceFormResponses,
getFormDraft, saveFormDraft,
sendInvoice,
applyCoupon, removeCoupon,
updateAttendees, sendAttendeeFormLink,
} = require('../controllers/registrationController');
const { protect, supervisor, staff, optionalAuth } = require('../middleware/authMiddleware');
@@ -20,6 +23,11 @@ router.post('/', optionalAuth, createRegistration);
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);
router.put('/:id/attendees', protect, updateAttendees);
router.post('/:id/attendees/:attendeeId/send-form-link', protect, sendAttendeeFormLink);
// Registration detail + forms — optionalAuth so guests can access with just the registrationId
router.get('/:id', optionalAuth, getRegistrationById);
+7 -1
View File
@@ -1,12 +1,18 @@
const express = require('express');
const router = express.Router();
const { generatePdf, emailPdf } = require('../controllers/reportController');
const { generatePdf, generateExcel, emailPdf, whatsappPdf } = require('../controllers/reportController');
const { protect } = require('../middleware/authMiddleware');
// Generate and download PDF
router.post('/pdf', protect, generatePdf);
// Generate and download styled Excel (.xlsx)
router.post('/excel', protect, generateExcel);
// Email PDF to current user
router.post('/email', protect, emailPdf);
// Send PDF to current user's own WhatsApp
router.post('/whatsapp', protect, whatsappPdf);
module.exports = router;
+15
View File
@@ -0,0 +1,15 @@
const express = require('express');
const router = express.Router();
const { listLocks, createLock, getSnapshot, syncLock, releaseLock, forceRelease, listReports, reviewReport } = require('../controllers/scanLockController');
const { protect, staff, supervisor } = require('../middleware/authMiddleware');
router.get('/', protect, staff, listLocks);
router.post('/', protect, staff, createLock);
router.get('/reports', protect, supervisor, listReports);
router.post('/reports/:id/review', protect, supervisor, reviewReport);
router.get('/:id/snapshot', protect, staff, getSnapshot);
router.post('/:id/sync', protect, staff, syncLock);
router.post('/:id/force-release', protect, supervisor, forceRelease);
router.delete('/:id', protect, staff, releaseLock);
module.exports = router;
+5 -1
View File
@@ -1,6 +1,6 @@
const express = require('express');
const router = express.Router();
const { getStaffDashboardStats, getSupervisorDashboardStats, getAdminDashboardStats } = require('../controllers/statsController');
const { getStaffDashboardStats, getSupervisorDashboardStats, getAdminDashboardStats, getOverviewStats } = require('../controllers/statsController');
const { protect, staff, supervisor, admin } = require('../middleware/authMiddleware');
// One endpoint per dashboard — each returns exactly what that dashboard renders in a
@@ -10,4 +10,8 @@ router.get('/staff', protect, staff, getStaffDashboardStats);
router.get('/supervisor', protect, supervisor, getSupervisorDashboardStats);
router.get('/admin', protect, admin, getAdminDashboardStats);
// Month-over-month KPIs + trend + top events, shared by the Admin and Supervisor dashboards.
// Staff never sees financial data, so this has no staff-accessible route.
router.get('/overview', protect, supervisor, getOverviewStats);
module.exports = router;
+5 -1
View File
@@ -13,7 +13,9 @@ const {
emailTickets,
sendTicketsTo,
getRecentScans,
getScanStats
getScanStats,
transferTicketHandler,
getTicketTransfers
} = require('../controllers/ticketController');
const { protect, admin, supervisor, staff } = require('../middleware/authMiddleware');
@@ -21,6 +23,8 @@ const { protect, admin, supervisor, staff } = require('../middleware/authMiddlew
router.get('/mytickets', protect, getUserTickets);
router.get('/:id', protect, getTicketById);
router.post('/email', protect, emailTickets);
router.post('/:id/transfer', protect, transferTicketHandler);
router.get('/:id/transfers', protect, getTicketTransfers);
// Staff routes
router.post('/send-to', protect, staff, sendTicketsTo);
+18 -7
View File
@@ -1,7 +1,7 @@
const express = require('express');
const router = express.Router();
const { upload, uploadEventImage, uploadLogo, uploadLogoImage } = require('../controllers/uploadController');
const { upload, uploadEventImage, uploadLogo, uploadLogoImage, uploadFavicon, uploadFaviconImage } = require('../controllers/uploadController');
const { protect, supervisor, admin } = require('../middleware/authMiddleware');
const prisma = require('../config/db');
@@ -17,11 +17,9 @@ router.post('/event-image', protect, supervisor, (req, res, next) => {
});
}, uploadEventImage);
// @route POST /api/uploads/logo
// @desc Upload site logo — admin, OR allowed during first-time setup (no users yet)
// @access Admin or setup
async function logoAccess(req, res, next) {
// Shared access rule for branding assets (logo, favicon): admin, OR allowed
// during first-time setup (no users yet).
async function brandingAssetAccess(req, res, next) {
try {
const count = await prisma.user.count();
if (count === 0) return next(); // first-time setup
@@ -31,11 +29,24 @@ async function logoAccess(req, res, next) {
}
}
router.post('/logo', logoAccess, (req, res, next) => {
// @route POST /api/uploads/logo
// @desc Upload site logo — admin, OR allowed during first-time setup (no users yet)
// @access Admin or setup
router.post('/logo', brandingAssetAccess, (req, res, next) => {
uploadLogo.single('image')(req, res, (err) => {
if (err) req.multerError = err;
next();
});
}, uploadLogoImage);
// @route POST /api/uploads/favicon
// @desc Upload site favicon — admin, OR allowed during first-time setup (no users yet)
// @access Admin or setup
router.post('/favicon', brandingAssetAccess, (req, res, next) => {
uploadFavicon.single('image')(req, res, (err) => {
if (err) req.multerError = err;
next();
});
}, uploadFaviconImage);
module.exports = router;
+14
View File
@@ -3,6 +3,12 @@ const router = express.Router();
const {
registerUser,
loginUser,
verifyLoginTwoFactor,
resendLoginTwoFactor,
startEnableTwoFactor,
confirmEnableTwoFactor,
disableTwoFactor,
verifyPassword,
getUserProfile,
updateUserProfile,
getUsers,
@@ -17,12 +23,15 @@ const {
revokeMySession,
adminRevokeUserSessions,
closeAccount,
getMyActivity,
} = require('../controllers/userController');
const { protect, admin, supervisor, loginLimiter} = require('../middleware/authMiddleware');
// 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);
@@ -34,6 +43,11 @@ 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('/')
+19
View File
@@ -0,0 +1,19 @@
const express = require('express');
const rateLimit = require('express-rate-limit');
const router = express.Router();
const { joinWaitlist, myWaitlist, leaveWaitlist, claimInfo, eventWaitlist, offerManually, moveEntry, removeEntry } = require('../controllers/waitlistController');
const { protect, staff, supervisor, optionalAuth } = require('../middleware/authMiddleware');
// Claim links are credentials, so keep guessing slow.
const claimLimiter = rateLimit({ windowMs: 60 * 1000, max: 30, message: { message: 'Too many attempts. Please try again in a minute.' } });
router.post('/', optionalAuth, joinWaitlist);
router.get('/mine', protect, myWaitlist);
router.get('/claim/:token', claimLimiter, claimInfo);
router.get('/events/:eventId', protect, staff, eventWaitlist);
router.post('/:id/offer', protect, supervisor, offerManually);
router.post('/:id/move', protect, supervisor, moveEntry);
router.post('/:id/remove', protect, supervisor, removeEntry);
router.delete('/:id', protect, leaveWaitlist);
module.exports = router;
+67
View File
@@ -0,0 +1,67 @@
/**
* Finding an existing account from an email and/or phone number.
*
* Email and phone are looked up separately rather than with one OR query, so an email
* belonging to one account and a phone belonging to another can never be silently
* collapsed into whichever matches first — that would let a registration hijack or
* corrupt someone else's account. Phones are tried in both 27xxx and 0xxx forms.
*/
const prisma = require('../config/db');
function hasValidEmail(email) {
return !!(email && typeof email === 'string' && email.includes('@'));
}
/**
* @returns {Promise<{ phone: string, emailUser: object|null, phoneUser: object|null,
* conflict: boolean, user: object|null }>}
* `user` is the single matched account (null when none, or when email and phone point
* at two different accounts — `conflict` is then true).
*/
async function findAccountByContact({ email, phone: rawPhone }, db = prisma) {
const { normalizeZAPhone } = require('./whatsapp');
const phone = normalizeZAPhone(rawPhone) || String(rawPhone || '').replace(/\D/g, '');
const phoneAlt = phone && phone.startsWith('27') ? '0' + phone.slice(2) : (phone && phone.length === 9 ? '27' + phone : null);
const [emailUser, phoneUser] = await Promise.all([
hasValidEmail(email) ? db.user.findUnique({ where: { email: String(email).trim() } }) : null,
phone ? db.user.findFirst({ where: { OR: [{ phoneNumber: phone }, ...(phoneAlt ? [{ phoneNumber: phoneAlt }] : [])] } }) : null,
]);
const conflict = !!(emailUser && phoneUser && emailUser.id !== phoneUser.id);
return { phone, emailUser, phoneUser, conflict, user: conflict ? null : (emailUser || phoneUser || null) };
}
/**
* The account for these contact details, or a new inactive one (same as guest
* registration: a placeholder email for phone-only people, a random password, and an
* activation link sent by the caller when `created` is true).
*/
async function findOrCreateAccount({ name, email, phone }, db = prisma) {
const match = await findAccountByContact({ email, phone }, db);
if (match.conflict) {
const err = new Error('That email address and phone number belong to two different accounts. Use just one of them.');
err.statusCode = 400;
throw err;
}
if (match.user) return { user: match.user, created: false };
const { v4: uuidv4 } = require('uuid');
const { hashPassword } = require('../config/auth');
const validEmail = hasValidEmail(email);
const user = await db.user.create({
data: {
id: uuidv4(),
name,
email: validEmail ? String(email).trim() : `guest+${uuidv4().slice(0, 8)}@guest.local`,
password: await hashPassword(uuidv4()),
phoneNumber: match.phone || null,
isActive: false,
notificationPreference: validEmail && match.phone ? 'both' : validEmail ? 'email' : 'whatsapp',
updatedAt: new Date(),
},
});
return { user, created: true };
}
module.exports = { findAccountByContact, findOrCreateAccount, hasValidEmail };
+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 };
+209
View File
@@ -0,0 +1,209 @@
/**
* Group tickets — named attendees on a registration.
*
* Rules:
* - Only main-ticket places can be named, and only on events with
* allowNamedAttendees. Each named attendee gets their own quantity-1 ticket; any
* places left unnamed share one quantity ticket, as before.
* - The payer stays the registration owner (billing, invoices, payments). An attendee
* whose email/phone matches an existing account is linked to it, so the ticket also
* shows in that person's dashboard — unless email and phone point at two different
* accounts, in which case nothing is linked.
* - Registration.ticketDelivery = 'each' sends each attendee their own ticket by
* email/WhatsApp; attendees with no contact details fall back to the payer.
* - Youth events (requiresParentalConsent): every main place must be named with a date
* of birth, and anyone under 18 on the start date needs a parent/guardian name, email
* and phone (see utils/consent.js).
*/
const prisma = require('../config/db');
const { findAccountByContact } = require('./accountMatch');
const { isMinor } = require('./consent');
const EMAIL_RE = /^[^@\s]+@[^@\s]+\.[^@\s]+$/;
const lineKey = (eventOptionId, variantId) => `${eventOptionId}::${variantId || ''}`;
function attendeeError(message) {
const err = new Error(message);
err.statusCode = 400;
return err;
}
/**
* Checks attendees against the event and the number of places on each line. Pure.
* Returns the cleaned list (trimmed strings, nulls for blanks).
*
* @param {object} event - with allowNamedAttendees and eventOptions[{ id, isMainTicket, name }]
* @param {object} placesByKey - total places each line will have, keyed by lineKey()
* @param {Array} attendees - [{ eventOptionId, variantId?, name, email?, phone? }]
* @param {object} [namedByKey] - places on those lines already named (and being kept)
*/
function validateAttendees(event, placesByKey, attendees, namedByKey = {}) {
const consent = !!event.requiresParentalConsent;
if ((!attendees || attendees.length === 0) && !consent) return [];
if (!event.allowNamedAttendees && !consent) throw attendeeError('This event doesn\'t take attendee names.');
if (!Array.isArray(attendees || [])) throw attendeeError('attendees must be a list');
const countByKey = {};
const cleaned = (attendees || []).map((a, i) => {
const option = (event.eventOptions || []).find(o => o.id === a?.eventOptionId);
if (!option) throw attendeeError(`Attendee ${i + 1}: unknown ticket option.`);
if (!option.isMainTicket) throw attendeeError(`Attendee ${i + 1}: names can only be given for "${option.name}" main tickets.`);
const name = String(a.name || '').trim();
if (!name) throw attendeeError(`Attendee ${i + 1}: enter a name.`);
const email = String(a.email || '').trim() || null;
if (email && !EMAIL_RE.test(email)) throw attendeeError(`Attendee ${i + 1}: "${email}" isn't a valid email address.`);
const phone = String(a.phone || '').trim() || null;
const key = lineKey(a.eventOptionId, a.variantId);
countByKey[key] = (countByKey[key] || 0) + 1;
if (countByKey[key] + (namedByKey[key] || 0) > (placesByKey[key] || 0)) {
throw attendeeError(`More names than "${option.name}" tickets.`);
}
// Date of birth (always kept if given) and, for youth events, parent details.
let dateOfBirth = null;
if (a.dateOfBirth) {
dateOfBirth = new Date(a.dateOfBirth);
if (isNaN(dateOfBirth.getTime()) || dateOfBirth > new Date()) throw attendeeError(`${name}: enter a valid date of birth.`);
} else if (consent) {
throw attendeeError(`${name}: enter a date of birth.`);
}
const parentName = String(a.parentName || '').trim() || null;
const parentEmail = String(a.parentEmail || '').trim() || null;
const parentPhone = String(a.parentPhone || '').trim() || null;
if (consent && isMinor(dateOfBirth, event.startDate)) {
if (!parentName || !parentEmail || !parentPhone) {
throw attendeeError(`${name} is under 18, so a parent or guardian's name, email and phone are needed for consent.`);
}
if (!EMAIL_RE.test(parentEmail)) throw attendeeError(`${name}: "${parentEmail}" isn't a valid parent email address.`);
}
return { eventOptionId: a.eventOptionId, variantId: a.variantId || null, name, email, phone, dateOfBirth, parentName, parentEmail, parentPhone };
});
// Youth events need every main place named (with a date of birth).
if (consent) {
for (const o of event.eventOptions || []) {
if (!o.isMainTicket) continue;
for (const [key, places] of Object.entries(placesByKey)) {
if (!key.startsWith(`${o.id}::`)) continue;
if ((countByKey[key] || 0) + (namedByKey[key] || 0) < places) {
throw attendeeError(`This is a youth event — add a name and date of birth for every "${o.name}" ticket.`);
}
}
}
}
return cleaned;
}
/**
* Works out the tickets one registration line should have, given its named attendees.
* Pure — returns operations for the caller to run.
*
* - Every attendee gets a quantity-1 ticket (holderUserId follows the attendee's account).
* - Unnamed places share one quantity ticket; a scanned one is never deleted or shrunk
* below what's been redeemed.
* - Tickets for attendees that no longer exist are removed unless already scanned.
*
* @param {object} line - { quantity }
* @param {Array} attendees - [{ id, userId }]
* @param {Array} tickets - existing [{ id, attendeeId, quantity, holderUserId, usages: [] }]
* @returns {{ create: Array<{attendeeId, holderUserId, quantity}>, update: Array<{id, data}>, remove: string[] }}
*/
function planLineTickets(line, attendees, tickets) {
const scanned = (t) => (t.usages || []).length > 0;
const redeemed = (t) => (t.usages || []).reduce((s, u) => s + (u.quantityRedeemed || 0), 0);
const ops = { create: [], update: [], remove: [] };
const attendeeIds = new Set(attendees.map(a => a.id));
const byAttendee = new Map();
const orphanNamed = [];
const shared = [];
for (const t of tickets) {
if (t.attendeeId && attendeeIds.has(t.attendeeId)) byAttendee.set(t.attendeeId, t);
else if (t.attendeeId) orphanNamed.push(t);
else shared.push(t);
}
// Tickets whose attendee was removed: drop unless already used (then keep, as shared).
let keptScannedOrphans = 0;
for (const t of orphanNamed) {
if (scanned(t)) keptScannedOrphans += t.quantity || 1;
else ops.remove.push(t.id);
}
for (const a of attendees) {
const t = byAttendee.get(a.id);
if (!t) {
ops.create.push({ attendeeId: a.id, holderUserId: a.userId || null, quantity: 1 });
} else if ((t.holderUserId || null) !== (a.userId || null) || (t.quantity || 1) !== 1) {
ops.update.push({ id: t.id, data: { holderUserId: a.userId || null, ...(scanned(t) ? {} : { quantity: 1 }) } });
}
}
// Unnamed places share one ticket.
const remainder = Math.max(0, (line.quantity || 0) - attendees.length - keptScannedOrphans);
const sorted = [...shared].sort((a, b) => (scanned(b) ? 1 : 0) - (scanned(a) ? 1 : 0));
const [primary, ...extras] = sorted;
if (primary) {
const floor = scanned(primary) ? redeemed(primary) : 0;
if (remainder === 0 && !scanned(primary)) ops.remove.push(primary.id);
else {
const qty = Math.max(remainder, floor);
if (qty !== primary.quantity) ops.update.push({ id: primary.id, data: { quantity: qty } });
}
} else if (remainder > 0) {
ops.create.push({ attendeeId: null, holderUserId: null, quantity: remainder });
}
for (const t of extras) if (!scanned(t)) ops.remove.push(t.id);
return ops;
}
/**
* Saves new attendees on a registration, linking each to its registration line and to
* a matching account when there is exactly one.
*/
async function createAttendees(registrationId, attendees, db = prisma) {
if (!attendees || attendees.length === 0) return [];
const lines = await db.registrationOption.findMany({ where: { registrationId } });
const existing = await db.attendee.findMany({ where: { registrationId }, select: { registrationOptionId: true } });
const orderByLine = {};
existing.forEach(a => { orderByLine[a.registrationOptionId] = (orderByLine[a.registrationOptionId] || 0) + 1; });
const created = [];
for (const a of attendees) {
const line = lines.find(l => l.eventOptionId === a.eventOptionId && (l.variantId || null) === (a.variantId || null));
const match = (a.email || a.phone) ? await findAccountByContact({ email: a.email, phone: a.phone }, db) : { user: null };
const order = orderByLine[line?.id] || 0;
orderByLine[line?.id] = order + 1;
created.push(await db.attendee.create({
data: {
registrationId,
registrationOptionId: line?.id || null,
eventOptionId: a.eventOptionId,
variantId: a.variantId || null,
name: a.name,
email: a.email,
phone: a.phone,
dateOfBirth: a.dateOfBirth || null,
parentName: a.parentName || null,
parentEmail: a.parentEmail || null,
parentPhone: a.parentPhone || null,
userId: match.user?.id || null,
order,
},
}));
}
return created;
}
/** Named places already on a registration, per line key — for validating additions. */
async function namedPlacesByKey(registrationId, db = prisma) {
const rows = await db.attendee.findMany({ where: { registrationId }, select: { eventOptionId: true, variantId: true } });
const out = {};
rows.forEach(r => { const k = lineKey(r.eventOptionId, r.variantId); out[k] = (out[k] || 0) + 1; });
return out;
}
module.exports = { lineKey, validateAttendees, planLineTickets, createAttendees, namedPlacesByKey, attendeeError };
+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 };
+180 -13
View File
@@ -21,6 +21,16 @@ function bucketForMethod(method) {
return 'other';
}
// A donation is never mutated once created — assigning it to a registration creates a separate
// "leg" Payment row (isDonation:false, originalPaymentId -> the donation, amount > 0) instead.
// That leg is not new money: it just re-labels part of an already-counted donation as applied
// to a registration. Revenue/cash totals must count each real inflow exactly once, so legs are
// excluded everywhere money is summed — the money was already counted via the donation itself.
// (Refunds also set originalPaymentId, but always with a negative amount, so they're unaffected.)
function isDonationLeg(p) {
return !p.isDonation && !!p.originalPaymentId && p.amount > 0;
}
// Throws if the event is closed. Callers wrap this in their existing try/catch
// (res.statusCode is set before throwing, matching the rest of the controllers).
async function assertEventOpen(eventId, res) {
@@ -62,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 } }] }
}),
@@ -73,7 +83,7 @@ async function computeEventFinancials(eventId) {
}),
prisma.registrationOption.findMany({
where: { registration: { eventId, status: 'paid' } },
include: { eventOption: { select: { id: true, name: true, price: true } } }
include: { eventOption: { select: { id: true, name: true, price: true } }, tranches: true }
}),
prisma.eventCashup.findMany({
where: { eventId },
@@ -82,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)
@@ -93,16 +105,35 @@ async function computeEventFinancials(eventId) {
quantityByOption[optId] = (quantityByOption[optId] || 0) + t.quantity;
}
const nonRefundPayments = payments.filter(p => p.amount > 0);
const unallocatedDonations = payments.filter(p => p.isDonation && !p.registrationId);
const unallocatedDonationsTotal = unallocatedDonations.reduce((sum, p) => sum + p.amount, 0);
const totalDonations = payments.filter(p => p.isDonation).reduce((sum, p) => sum + p.amount, 0);
// Excludes donation-application legs, which would otherwise double-count money already
// counted once via the source donation (e.g. a R250 donation with R50 assigned to a
// registration must total R250 received, not R300). Refunds (negative amount) are kept in —
// a card refund must subtract from the 'card' bucket it was refunded against, not vanish from
// the per-method breakdown while still being netted out of totalRevenue below.
const realPayments = payments.filter(p => !isDonationLeg(p));
// Donations are never mutated once assigned — assignment creates a separate "leg" Payment
// row (isDonation:false, originalPaymentId -> the donation), so a donation's registrationId
// stays null forever. Its actual unallocated amount is its original amount minus every leg
// that already references it, not simply "every donation with no registrationId". A refund
// of the donation itself also creates such a leg, with a negative amount — Math.abs() so a
// refund reduces the unallocated balance instead of inflating it (a raw signed sum would
// subtract a negative, adding the refund back on top).
const legsByDonationId = new Map();
for (const p of payments) {
if (p.originalPaymentId && !p.isDonation) {
legsByDonationId.set(p.originalPaymentId, (legsByDonationId.get(p.originalPaymentId) || 0) + Math.abs(p.amount));
}
}
const donationPayments = payments.filter(p => p.isDonation);
const unallocatedDonations = donationPayments.filter(p => (p.amount - (legsByDonationId.get(p.id) || 0)) > 0.000001);
const unallocatedDonationsTotal = unallocatedDonations.reduce((sum, p) => sum + Math.max(p.amount - (legsByDonationId.get(p.id) || 0), 0), 0);
const totalDonations = donationPayments.reduce((sum, p) => sum + p.amount, 0);
const paymentsByMethod = emptyByMethod();
for (const p of nonRefundPayments) {
for (const p of realPayments) {
paymentsByMethod[bucketForMethod(p.method)] += p.amount;
}
const totalRevenue = payments.reduce((sum, p) => sum + p.amount, 0);
const totalRevenue = payments.reduce((sum, p) => sum + (isDonationLeg(p) ? 0 : p.amount), 0);
// Costs, with computed totals and attribution to a payment method's float (if tagged)
const costBreakdown = costs.map(c => {
@@ -162,17 +193,21 @@ async function computeEventFinancials(eventId) {
const effectiveTotalRevenue = ALL_METHODS.reduce((s, m) => s + effectiveGrossIncomeByMethod[m], 0);
const netProfit = effectiveTotalRevenue - totalCosts;
// What was actually sold, by ticket type — for the Finance report's income-stream breakdown
// What was actually sold, by ticket type — for the Finance report's income-stream breakdown.
// Revenue is tranche-aware: a line spanning two early-bird prices contributes each tranche
// at the price it was actually bought at, not one blended/stale price for the whole line.
const { computeOptionLineTotal } = require('./pricing');
const salesByOptionMap = {};
for (const ro of salesRows) {
const opt = ro.eventOption;
if (!opt) continue;
if (!salesByOptionMap[opt.id]) salesByOptionMap[opt.id] = { eventOptionId: opt.id, name: opt.name, quantitySold: 0, revenue: 0 };
const unitPrice = ro.priceSnapshot != null ? ro.priceSnapshot : opt.price;
salesByOptionMap[opt.id].quantitySold += ro.quantity;
salesByOptionMap[opt.id].revenue += unitPrice * ro.quantity;
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,
@@ -191,16 +226,148 @@ async function computeEventFinancials(eventId) {
unallocatedDonationsTotal,
totalDonations,
salesByOption,
discountsTotal,
history
};
}
// Payment accountability, per staff member who recorded the payment, broken down by every
// method (not just cash) — lets a cashup reconcile not just the total float but who is
// responsible for which portion of it. Cash also folds in any actual physical count entered for
// that person (EventCashupPersonCount, entered any time, independent of the event-wide close) to
// show an actual-vs-expected variance per person — the event's cash actual is the sum of these
// per-person counts (see computeEventCashActualFromPersonCounts), not a separate manual entry.
// Card/EFT/Other have no physical "count" concept, so they're just recorded amounts.
async function computeAccountabilityByUser(eventId) {
const [payments, personCounts] = await Promise.all([
prisma.payment.findMany({
where: { OR: [{ eventId }, { registration: { eventId } }] },
include: { recordedBy: { select: { id: true, name: true, email: true } } }
}),
prisma.eventCashupPersonCount.findMany({
where: { eventId },
include: {
user: { select: { id: true, name: true, email: true } },
enteredBy: { select: { id: true, name: true } },
denominations: true
}
})
]);
const emptyMethodTotals = () => ({ total: 0, count: 0 });
const emptyEntry = (userId, name, email) => ({
userId: userId || null,
name: name || 'Unknown / legacy',
email: email || null,
cash: { ...emptyMethodTotals(), actual: null, variance: null, denominations: [], enteredBy: null, countUpdatedAt: null, notes: null },
card: emptyMethodTotals(),
eft: emptyMethodTotals(),
other: emptyMethodTotals()
});
const byUser = new Map();
for (const p of payments) {
// A donation-application leg isn't new money — it's the same money already recorded once,
// as the donation. Counting it again here would double-attribute it to whoever did the
// assignment, on top of whoever originally recorded the donation.
if (isDonationLeg(p)) continue;
const method = bucketForMethod(p.method);
const key = p.recordedById || 'unknown';
const entry = byUser.get(key) || emptyEntry(p.recordedById, p.recordedBy?.name, p.recordedBy?.email);
entry[method].total += p.amount;
entry[method].count += 1;
byUser.set(key, entry);
}
for (const pc of personCounts) {
const key = pc.userId;
const entry = byUser.get(key) || emptyEntry(pc.userId, pc.user?.name, pc.user?.email);
const actual = pc.denominations.reduce((s, d) => s + d.value * d.count, 0);
entry.cash.actual = actual;
entry.cash.variance = actual - entry.cash.total;
entry.cash.denominations = pc.denominations.map(d => ({ value: d.value, count: d.count }));
entry.cash.enteredBy = pc.enteredBy ? { id: pc.enteredBy.id, name: pc.enteredBy.name } : null;
entry.cash.countUpdatedAt = pc.updatedAt;
entry.cash.notes = pc.notes || null;
byUser.set(key, entry);
}
return Array.from(byUser.values()).sort((a, b) => {
const totalA = a.cash.total + a.card.total + a.eft.total + a.other.total;
const totalB = b.cash.total + b.card.total + b.eft.total + b.other.total;
return totalB - totalA;
});
}
// The event's cash "actual" is the live sum of every staff member's entered physical count —
// there is no separate event-wide entry any more. Used both to display a live figure before
// close and to source the closed cashup's permanent Cash line.
async function computeEventCashActualFromPersonCounts(eventId) {
const personCounts = await prisma.eventCashupPersonCount.findMany({
where: { eventId },
include: { denominations: true }
});
if (personCounts.length === 0) return { actual: null, denominations: [] };
const byValue = new Map();
let actual = 0;
for (const pc of personCounts) {
for (const d of pc.denominations) {
actual += d.value * d.count;
byValue.set(d.value, (byValue.get(d.value) || 0) + d.count);
}
}
const denominations = Array.from(byValue.entries())
.map(([value, count]) => ({ value, count }))
.sort((a, b) => b.value - a.value);
return { actual, denominations };
}
// Upsert one staff member's actual physical cash count for an event — optional, can be entered
// any time (not required to close the event), purely for per-person accountability.
async function savePersonCashCount(eventId, userId, { denominations, notes, enteredById }) {
const cleanDenoms = (Array.isArray(denominations) ? denominations : [])
.map(d => ({ value: Number(d.value), count: parseInt(d.count, 10) || 0 }))
.filter(d => d.value > 0 && d.count > 0);
const existing = await prisma.eventCashupPersonCount.findUnique({
where: { eventId_userId: { eventId, userId } }
});
const record = existing
? await prisma.eventCashupPersonCount.update({
where: { id: existing.id },
data: {
notes: notes || null,
enteredById: enteredById || null,
denominations: { deleteMany: {}, create: cleanDenoms }
},
include: { denominations: true }
})
: await prisma.eventCashupPersonCount.create({
data: {
eventId,
userId,
notes: notes || null,
enteredById: enteredById || null,
denominations: { create: cleanDenoms }
},
include: { denominations: true }
});
return record;
}
module.exports = {
METHOD_BUCKETS,
ALL_METHODS,
ZAR_DENOMINATIONS,
bucketForMethod,
isDonationLeg,
assertEventOpen,
assertRegistrationEventOpen,
computeEventFinancials
computeEventFinancials,
computeAccountabilityByUser,
computeEventCashActualFromPersonCounts,
savePersonCashCount
};
+332
View File
@@ -0,0 +1,332 @@
/**
* Parental consent for youth events.
*
* Rules:
* - On an event with requiresParentalConsent, every attendee gives a date of birth.
* Anyone under 18 on the event's start date needs a parent/guardian (name, email,
* phone), who is sent a consent request by email AND WhatsApp — regardless of anyone's
* notification preference.
* - A request's link token is random; only its HMAC is stored. Links expire when the
* event ends and can be re-sent (which issues a new token).
* - The parent's answer records the time, IP, device, typed name, and the exact consent
* text and version they saw. Medical/emergency details are stored encrypted and purged
* a set number of days after the event (setting consent_medical_retention_days).
* - Tickets are issued as normal but can't be checked in until consent is given — the
* latest request for the attendee decides. On a consent event, a ticket with no named
* attendee is treated as missing consent. Supervisors can override (audited).
* - Reminders go 2 days after the request, then every 3 days, until answered or the event.
*/
const crypto = require('crypto');
const prisma = require('../config/db');
const DAY = 24 * 60 * 60 * 1000;
const FIRST_REMINDER_AFTER = 2 * DAY;
const REMINDER_EVERY = 3 * DAY;
const DEFAULT_RETENTION_DAYS = 30;
function consentError(message, statusCode = 400) {
const err = new Error(message);
err.statusCode = statusCode;
return err;
}
// ─── Pure helpers ─────────────────────────────────────────────────────────────
/** Whole years old on `onDate`. */
function ageOn(dateOfBirth, onDate) {
const dob = new Date(dateOfBirth);
const on = new Date(onDate);
let age = on.getUTCFullYear() - dob.getUTCFullYear();
const beforeBirthday = on.getUTCMonth() < dob.getUTCMonth()
|| (on.getUTCMonth() === dob.getUTCMonth() && on.getUTCDate() < dob.getUTCDate());
if (beforeBirthday) age -= 1;
return age;
}
/** Under 18 on the event's start date. */
function isMinor(dateOfBirth, eventStart) {
if (!dateOfBirth) return false;
return ageOn(dateOfBirth, eventStart) < 18;
}
/**
* Consent state for one attendee: 'not_required' | 'missing' | 'pending' | 'given' | 'declined'.
* `latest` is the attendee's most recent ConsentRequest (or null).
*/
function consentStatusFor(attendee, event, latest) {
if (!event?.requiresParentalConsent) return 'not_required';
if (!attendee) return 'missing'; // unnamed place on a consent event
if (!isMinor(attendee.dateOfBirth, event.startDate)) return 'not_required';
if (!latest) return 'missing';
return latest.status;
}
/** Whether a pending request is due a reminder now. */
function reminderDue(request, now = new Date()) {
if (!request || request.status !== 'pending') return false;
const t = new Date(now).getTime();
if (t >= new Date(request.expiresAt).getTime()) return false;
const gap = request.remindersSent === 0 ? FIRST_REMINDER_AFTER : REMINDER_EVERY;
return t - new Date(request.lastSentAt).getTime() >= gap;
}
const str = (v, max = 500) => String(v ?? '').trim().slice(0, max);
/**
* Checks a parent's answer on the consent page. Pure. Returns the cleaned answer.
* @param {object} body - { decision: 'give'|'decline', signedName, isGuardian, medical, emergency, photoConsent }
* @param {object} event - with consentCollectMedical / Emergency / Photo
*/
function validateConsentAnswer(body, event) {
const decision = body?.decision;
if (decision !== 'give' && decision !== 'decline') throw consentError('Choose to give or decline consent.');
const signedName = str(body.signedName, 200);
if (signedName.length < 2) throw consentError('Type your full name to sign.');
if (body.isGuardian !== true) throw consentError('Confirm that you are the parent or legal guardian.');
const out = { decision, signedName, medical: null, emergency: null, photoConsent: null };
if (decision === 'decline') return out;
if (event.consentCollectMedical) {
const m = body.medical || {};
out.medical = {
allergies: str(m.allergies), medication: str(m.medication),
conditions: str(m.conditions), medicalAid: str(m.medicalAid, 200),
};
}
if (event.consentCollectEmergency) {
const em = body.emergency || {};
out.emergency = { name: str(em.name, 200), phone: str(em.phone, 40), relationship: str(em.relationship, 100) };
if (!out.emergency.name || !out.emergency.phone) throw consentError('Add an emergency contact name and phone number.');
}
if (event.consentCollectPhoto) {
if (body.photoConsent !== true && body.photoConsent !== false) throw consentError('Answer the photo consent question.');
out.photoConsent = body.photoConsent;
}
return out;
}
// ─── Tokens ───────────────────────────────────────────────────────────────────
const hashToken = (token) => crypto.createHmac('sha256', process.env.JWT_SECRET || '').update(`consent:${token}`).digest('hex');
const newToken = () => crypto.randomBytes(24).toString('hex');
function consentLink(token) {
const base = (process.env.FRONTEND_URL || process.env.APP_BASE_URL || 'http://localhost:3001').split(',')[0].trim().replace(/\/$/, '');
return `${base}/consent/${token}`;
}
// ─── Encryption of medical/emergency details ──────────────────────────────────
function sealJson(value) {
if (!value) return null;
const { encrypt } = require('./encryption');
return encrypt(JSON.stringify(value));
}
function openJson(value) {
if (!value) return null;
try {
const { decrypt } = require('./encryption');
return JSON.parse(decrypt(value));
} catch {
return null;
}
}
// ─── Database helpers ─────────────────────────────────────────────────────────
/** Latest request per attendee id. */
async function latestRequestsFor(attendeeIds, db = prisma) {
if (!attendeeIds.length) return new Map();
const rows = await db.consentRequest.findMany({ where: { attendeeId: { in: attendeeIds } }, orderBy: { createdAt: 'desc' } });
const out = new Map();
for (const r of rows) if (!out.has(r.attendeeId)) out.set(r.attendeeId, r);
return out;
}
async function sendConsentMessage(request, token, { reminder = false } = {}) {
const { sendMail, emailWrapper, ctaButton, fallbackLink } = require('./email');
const { waTextAny } = require('./notify');
const { isValidZAPhone } = require('./whatsapp');
const { getSettingSync } = require('./settingsCache');
const orgName = getSettingSync('org_name', process.env.ORG_NAME || 'Cross Code');
const ff = `-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif`;
const event = request.event || await prisma.event.findUnique({ where: { id: request.eventId } });
const attendee = request.attendee || await prisma.attendee.findUnique({ where: { id: request.attendeeId } });
const link = consentLink(token);
const when = event?.startDate ? new Date(event.startDate).toLocaleDateString('en-GB', { day: 'numeric', month: 'long', year: 'numeric' }) : '';
const subject = `${reminder ? 'Reminder: ' : ''}Consent needed for ${attendee.name} — ${event.title}`;
const intro = `${attendee.name} has been registered for <strong>${event.title}</strong>${when ? ` on <strong>${when}</strong>` : ''}. As their parent or guardian, please review the details and give (or decline) consent.`;
const sent = [];
if (request.parentEmail && request.parentEmail.includes('@')) {
const html = emailWrapper(`
<p style="font-size:22px;font-weight:800;color:#0f172a;margin:0 0 8px 0">${reminder ? 'A reminder: ' : ''}Parental consent needed</p>
<p style="margin:0 0 8px 0;color:#374151;font-family:${ff}">Hi <strong>${request.parentName || 'there'}</strong>,</p>
<p style="margin:0 0 24px 0;color:#374151;font-family:${ff}">${intro}</p>
${ctaButton('Review and give consent', link)}
${fallbackLink(link)}
<p style="font-size:13px;color:#64748b;margin:16px 0 0 0;font-family:${ff}">${attendee.name} can't be checked in at the event until consent is given.</p>
`, { preheader: `Consent needed for ${attendee.name}` });
try {
await sendMail({ to: request.parentEmail, subject, html, text: `Hi ${request.parentName || 'there'},\n\n${intro.replace(/<[^>]+>/g, '')}\n\nReview and give consent: ${link}\n\n${orgName}` });
sent.push('email');
} catch (e) {
console.warn('[consent] email failed:', e?.message || e);
}
}
if (request.parentPhone && isValidZAPhone(request.parentPhone)) {
await waTextAny({ name: request.parentName, phoneNumber: request.parentPhone },
`${reminder ? '⏰ Reminder: ' : ''}Hi ${request.parentName || 'there'}, ${attendee.name} has been registered for *${event.title}*${when ? ` on ${when}` : ''}. Please give (or decline) parental consent here: ${link}`);
sent.push('WhatsApp');
}
return sent;
}
/**
* Creates (and sends) a consent request for an attendee. Earlier requests stay as the
* record of what was agreed before; this one now decides check-in.
*/
async function createConsentRequest(attendee, event) {
const token = newToken();
const request = await prisma.consentRequest.create({
data: {
attendeeId: attendee.id,
eventId: event.id,
tokenHash: hashToken(token),
textVersion: event.consentTextVersion || 1,
consentText: event.consentText || '',
parentName: attendee.parentName,
parentEmail: attendee.parentEmail,
parentPhone: attendee.parentPhone,
expiresAt: event.endDate,
},
});
const sent = await sendConsentMessage({ ...request, event, attendee }, token);
return { request, sent };
}
/**
* Makes sure every under-18 attendee on a registration has a consent request — creates
* and sends one for anyone who has none yet, or whose parent details have changed since.
*/
async function ensureConsentRequests(registrationId) {
const reg = await prisma.registration.findUnique({ where: { id: registrationId }, include: { event: true, attendees: true } });
if (!reg || !reg.event.requiresParentalConsent || reg.status === 'cancelled') return 0;
const minors = reg.attendees.filter(a => isMinor(a.dateOfBirth, reg.event.startDate));
const latest = await latestRequestsFor(minors.map(a => a.id));
let created = 0;
for (const a of minors) {
const r = latest.get(a.id);
const parentChanged = r && (r.parentEmail !== a.parentEmail || r.parentPhone !== a.parentPhone);
if (!r || (parentChanged && r.status !== 'given')) {
await createConsentRequest(a, reg.event);
created += 1;
}
}
return created;
}
/** Re-sends a request with a fresh link (the old link stops working). */
async function resendConsentRequest(requestId) {
const request = await prisma.consentRequest.findUnique({ where: { id: requestId }, include: { event: true, attendee: true } });
if (!request) throw consentError('Consent request not found', 404);
if (request.status !== 'pending') throw consentError(`Consent has already been ${request.status}.`);
if (new Date(request.expiresAt) < new Date()) throw consentError('This event has ended.');
const token = newToken();
await prisma.consentRequest.update({ where: { id: request.id }, data: { tokenHash: hashToken(token), lastSentAt: new Date() } });
return sendConsentMessage(request, token, { reminder: true });
}
async function findRequestByToken(token) {
if (!token || typeof token !== 'string' || token.length > 100) return null;
return prisma.consentRequest.findUnique({
where: { tokenHash: hashToken(token) },
include: { event: true, attendee: { include: { registration: { include: { user: { select: { id: true, name: true, email: true, phoneNumber: true, notificationPreference: true } } } } } } },
});
}
/**
* Consent state for each ticket id — for the scan gate and staff screens.
* @returns {Promise<Map<string, string>>} ticketId → consent status
*/
async function consentStatusForTickets(ticketIds) {
const tickets = await prisma.ticket.findMany({ where: { id: { in: ticketIds } }, include: { event: true, attendee: true } });
const latest = await latestRequestsFor(tickets.map(t => t.attendeeId).filter(Boolean));
const out = new Map();
for (const t of tickets) {
out.set(t.id, consentStatusFor(t.attendee, t.event, t.attendeeId ? latest.get(t.attendeeId) : null));
}
return out;
}
/** Daily job: reminders for unanswered requests. */
async function sendConsentReminders(now = new Date()) {
const pending = await prisma.consentRequest.findMany({
where: { status: 'pending', expiresAt: { gt: now } },
include: { event: true, attendee: true },
});
// Only the latest request per attendee is still live.
const latest = await latestRequestsFor([...new Set(pending.map(r => r.attendeeId))]);
let sent = 0;
for (const r of pending) {
if (latest.get(r.attendeeId)?.id !== r.id || !reminderDue(r, now)) continue;
if (new Date(r.event.startDate) <= now) continue; // the event has started
const token = newToken();
await prisma.consentRequest.update({ where: { id: r.id }, data: { tokenHash: hashToken(token), lastSentAt: now, remindersSent: { increment: 1 } } });
await sendConsentMessage(r, token, { reminder: true }).catch(e => console.warn('[consent] reminder failed:', e?.message || e));
sent += 1;
}
return sent;
}
/** Daily job: delete medical/emergency details a set number of days after the event. */
async function purgeConsentMedicalData(now = new Date()) {
const { getSettingSync } = require('./settingsCache');
const days = Math.max(0, parseInt(getSettingSync('consent_medical_retention_days', String(DEFAULT_RETENTION_DAYS)), 10) || DEFAULT_RETENTION_DAYS);
const cutoff = new Date(new Date(now).getTime() - days * DAY);
const res = await prisma.consentRequest.updateMany({
where: { medicalPurgedAt: null, event: { endDate: { lt: cutoff } }, OR: [{ medical: { not: null } }, { emergency: { not: null } }] },
data: { medical: null, emergency: null, medicalPurgedAt: now },
});
return res.count;
}
/** Clears parent, medical and emergency details for a person's registrations (anonymise). */
async function clearConsentDataForUser(userId, db = prisma) {
const attendees = await db.attendee.findMany({ where: { OR: [{ userId }, { registration: { userId } }] }, select: { id: true } });
const ids = attendees.map(a => a.id);
if (!ids.length) return 0;
await db.attendee.updateMany({ where: { id: { in: ids } }, data: { parentName: null, parentEmail: null, parentPhone: null, dateOfBirth: null, email: null, phone: null } });
const res = await db.consentRequest.updateMany({
where: { attendeeId: { in: ids } },
data: { medical: null, emergency: null, parentName: null, parentEmail: null, parentPhone: null, ip: null, device: null, medicalPurgedAt: new Date() },
});
return res.count;
}
module.exports = {
DEFAULT_RETENTION_DAYS,
consentError,
ageOn,
isMinor,
consentStatusFor,
reminderDue,
validateConsentAnswer,
hashToken,
consentLink,
sealJson,
openJson,
latestRequestsFor,
createConsentRequest,
ensureConsentRequests,
resendConsentRequest,
findRequestByToken,
consentStatusForTickets,
sendConsentReminders,
purgeConsentMedicalData,
clearConsentDataForUser,
};
+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 };
+59 -8
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')) {
@@ -56,7 +61,7 @@ async function sendMail({ to, subject, html, text, attachments }) {
return;
}
const { transporter, cfg } = _getTransporter();
const from = cfg.from || 'no-reply@hope-events.local';
const from = cfg.from || 'no-reply@crosscode.local';
const info = await transporter.sendMail({ from, to, subject, html, text, ...(attachments ? { attachments } : {}) });
if (transporter.options && transporter.options.jsonTransport) {
@@ -74,12 +79,17 @@ async function sendMail({ to, subject, html, text, attachments }) {
function getOrg() {
const urlFallback = process.env.APP_BASE_URL || process.env.FRONTEND_URL || 'http://localhost:3001';
// headerColor prefers the new primary_color setting; falls back to the
// legacy accent_color value (which used to double as "the one brand color"
// before the 3-color Primary/Secondary/Accent system existed) so emails
// stay branded correctly until the admin re-saves the Branding tab.
const primary = getSettingSync('primary_color', '') || getSettingSync('accent_color', '');
return {
name: getSettingSync('org_name', process.env.ORG_NAME || 'Hope Events'),
name: getSettingSync('org_name', process.env.ORG_NAME || 'Cross Code'),
tagline: getSettingSync('org_tagline', process.env.ORG_TAGLINE || 'Connecting community through events'),
email: getSettingSync('smtp_from', process.env.EMAIL_FROM || process.env.EMAIL_USER || ''),
url: getSettingSync('app_base_url', urlFallback).replace(/\/$/, ''),
headerColor: getSettingSync('accent_color', process.env.EMAIL_HEADER_COLOR || '#1e3a5f'),
headerColor: primary || process.env.EMAIL_HEADER_COLOR || '#1e3a5f',
};
}
@@ -137,8 +147,9 @@ ${preheader ? `<div style="display:none;font-size:1px;line-height:1px;max-height
</html>`;
}
/** Renders a prominent CTA button. */
function ctaButton(label, url, { bg = '#2563eb', fg = '#ffffff' } = {}) {
/** Renders a prominent CTA button. Defaults to the org's brand color unless a semantic override (e.g. green for "activate", dark neutral for "log in") is passed explicitly. */
function ctaButton(label, url, { bg, fg = '#ffffff' } = {}) {
bg = bg || getOrg().headerColor;
return `<table role="presentation" cellpadding="0" cellspacing="0" border="0" style="margin:28px auto 8px auto">
<tr><td align="center" style="border-radius:8px;background-color:${bg};mso-padding-alt:0px">
<a href="${url}" target="_blank"
@@ -150,8 +161,17 @@ function ctaButton(label, url, { bg = '#2563eb', fg = '#ffffff' } = {}) {
/** Renders a fallback link below a CTA button. */
function fallbackLink(url) {
const color = getOrg().headerColor;
return `<p style="text-align:center;margin:4px 0 0 0;font-size:12px;color:#94a3b8;word-break:break-all">
Or copy this link: <a href="${url}" style="color:#2563eb">${url}</a>
Or copy this link: <a href="${url}" style="color:${color}">${url}</a>
</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>`;
}
@@ -160,7 +180,12 @@ function divider() {
return `<div style="border-top:1px solid #f1f5f9;margin:32px 0"></div>`;
}
/** Coloured callout box. type: info | success | warning | danger | neutral */
/**
* Coloured callout box. type: info | success | warning | danger | neutral
* These 4 semantic colors are deliberately NOT brand-driven — a "this wasn't
* you" security warning must always read as urgent/red regardless of the
* org's brand color, so don't wire these to getOrg().headerColor.
*/
function callout(content, type = 'info') {
const map = {
info: { bg: '#eff6ff', border: '#3b82f6', color: '#1e40af' },
@@ -177,9 +202,10 @@ function callout(content, type = 'info') {
/** Numbered payment option row. */
function paymentOption(num, title, detail) {
const bg = getOrg().headerColor;
return `<tr>
<td style="padding:14px 16px 14px 0;vertical-align:top;width:28px">
<div style="width:26px;height:26px;border-radius:50%;background:#2563eb;color:#fff;font-size:13px;font-weight:700;text-align:center;line-height:26px;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif">${num}</div>
<div style="width:26px;height:26px;border-radius:50%;background:${bg};color:#fff;font-size:13px;font-weight:700;text-align:center;line-height:26px;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif">${num}</div>
</td>
<td style="padding:14px 0;border-bottom:1px solid #f1f5f9;vertical-align:top">
<p style="margin:0 0 4px 0;font-size:14px;font-weight:700;color:#1e293b;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif">${title}</p>
@@ -213,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;
@@ -374,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 };
@@ -0,0 +1,16 @@
const { isValidZAPhone } = require('./whatsapp');
const ALLOWED_NOTIFICATION_PREFERENCES = ['email', 'whatsapp', 'both'];
// WhatsApp/Both requires a valid SA phone number — silently falls back to email otherwise,
// since a user without a usable phone number can never receive WhatsApp notifications anyway.
function resolveNotificationPreference(requested, phoneNumber, fallback) {
if (requested === undefined) return fallback;
let pref = ALLOWED_NOTIFICATION_PREFERENCES.includes(requested) ? requested : fallback;
if ((pref === 'whatsapp' || pref === 'both') && !isValidZAPhone(phoneNumber)) {
pref = 'email';
}
return pref;
}
module.exports = { ALLOWED_NOTIFICATION_PREFERENCES, resolveNotificationPreference };
+578 -38
View File
@@ -1,6 +1,8 @@
const fs = require('fs');
const prisma = require('../config/db');
const { sendMail, emailWrapper, ctaButton, fallbackLink, divider, callout, paymentOption } = require('./email');
const { computeRegistrationTotalDue } = require('./pricing');
const { sendMail, emailWrapper, ctaButton, fallbackLink, secondaryLink, divider, callout, paymentOption } = require('./email');
const { computeRegistrationTotalDue, computeOptionLineTotal } = require('./pricing');
const { couponDiscountLine } = require('./pdfDocs');
// ─── Formatting helpers ───────────────────────────────────────────────────────
@@ -21,9 +23,23 @@ 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 || 'Hope Events'),
name: getSettingSync('org_name', process.env.ORG_NAME || 'Cross Code'),
email: process.env.EMAIL_FROM || process.env.EMAIL_USER || '',
url: (process.env.FRONTEND_URL || process.env.APP_BASE_URL || 'http://localhost:3001').replace(/\/$/, ''),
};
@@ -72,14 +88,46 @@ async function loadRegistrationFull(registrationId) {
return prisma.registration.findUnique({
where: { id: registrationId },
include: {
registrationOptions: { include: { eventOption: { include: { earlyBirdTiers: true } } } },
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 } } } },
},
});
}
async function loadTicketFull(ticketId) {
return prisma.ticket.findUnique({
where: { id: ticketId },
include: {
user: { select: { id: true, name: true, email: true, phoneNumber: true, notificationPreference: true } },
event: true,
registrationOption: { include: { eventOption: true, variant: true, registration: { select: { ticketDelivery: true } } } },
attendee: true,
},
});
}
/**
* Who a ticket's messages go to: a named attendee with contact details when the
* registration sends each person their own tickets, otherwise the payer.
*/
function ticketRecipient(ticket) {
const a = ticket.attendee;
if (a && (a.email || a.phone) && ticket.registrationOption?.registration?.ticketDelivery === 'each') {
const hasEmail = !!(a.email && a.email.includes('@'));
return {
id: a.userId || null,
name: a.name,
email: hasEmail ? a.email : null,
phoneNumber: a.phone || null,
notificationPreference: hasEmail && a.phone ? 'both' : hasEmail ? 'email' : 'whatsapp',
};
}
return ticket.user;
}
async function loadPaymentFull(paymentId) {
return prisma.payment.findUnique({
where: { id: paymentId },
@@ -87,8 +135,9 @@ async function loadPaymentFull(paymentId) {
user: { select: { id: true, name: true, email: true, phoneNumber: true, notificationPreference: true } },
registration: {
include: {
registrationOptions: { include: { eventOption: { include: { earlyBirdTiers: true } } } },
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 } } } },
},
@@ -100,21 +149,28 @@ 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;
const price = ro.eventOption?.price || 0;
const lineTotal = computeOptionLineTotal(ro, null, new Date());
return `<tr>
<td style="padding:10px 16px 10px 0;font-size:14px;color:#374151;font-family:${ff};border-bottom:1px solid #f1f5f9">${name}</td>
<td style="padding:10px 0;font-size:14px;color:#374151;text-align:center;font-family:${ff};border-bottom:1px solid #f1f5f9">×${qty}</td>
<td style="padding:10px 0 10px 16px;font-size:14px;color:#374151;text-align:right;font-weight:500;font-family:${ff};border-bottom:1px solid #f1f5f9">${fmtAmount(price * qty)}</td>
<td style="padding:10px 0 10px 16px;font-size:14px;color:#374151;text-align:right;font-weight:500;font-family:${ff};border-bottom:1px solid #f1f5f9">${fmtAmount(lineTotal)}</td>
</tr>`;
});
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>
@@ -125,6 +181,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;
@@ -253,13 +317,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((ro.eventOption?.price || 0) * ro.quantity)}`).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 }) };
@@ -293,13 +358,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((ro.eventOption?.price || 0) * ro.quantity)}`).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
@@ -327,7 +393,7 @@ function buildRegistrationAdminNotice(reg, { isNew = true, isUpdated = false } =
`<tr>
<td style="padding:8px 12px;font-size:13px;color:#374151;font-family:${ff};border-bottom:1px solid #f1f5f9">${ro.eventOption?.name || 'Option'}</td>
<td style="padding:8px 12px;font-size:13px;color:#374151;text-align:center;font-family:${ff};border-bottom:1px solid #f1f5f9">×${ro.quantity}</td>
<td style="padding:8px 12px;font-size:13px;color:#374151;text-align:right;font-family:${ff};border-bottom:1px solid #f1f5f9">${fmtAmount((ro.eventOption?.price || 0) * ro.quantity)}</td>
<td style="padding:8px 12px;font-size:13px;color:#374151;text-align:right;font-family:${ff};border-bottom:1px solid #f1f5f9">${fmtAmount(computeOptionLineTotal(ro, null, new Date()))}</td>
</tr>`).join('');
const body = `
@@ -488,6 +554,33 @@ function buildPaymentReceipt(payment) {
return { subject, text, html: emailWrapper(body, { preheader }) };
}
// ─── Check-in confirmation (Main Tickets, door check-in) ───────────────────────
function buildCheckInConfirmation(ticket, qtyRedeemed, totalRedeemed, remaining) {
const org = getOrg();
const eventTitle = ticket.event?.title || 'the event';
const optionName = ticket.registrationOption?.eventOption?.name || 'Main Ticket';
const fullyCheckedIn = remaining <= 0;
const subject = `Checked in – ${eventTitle}`;
const preheader = `${qtyRedeemed} checked in for ${eventTitle}.`;
const body = `
<p style="font-size:22px;font-weight:800;color:#0f172a;margin:0 0 8px 0;letter-spacing:-0.3px">You're checked in! ✅</p>
<p style="font-size:14px;color:#64748b;margin:0 0 32px 0">See you inside</p>
<p style="margin:0 0 4px 0;color:#374151;font-family:${ff}">Hi <strong>${ticket.user?.name || 'there'}</strong>,</p>
<p style="margin:0 0 28px 0;color:#374151;font-family:${ff}">
<strong>${qtyRedeemed}</strong> ${optionName}${qtyRedeemed === 1 ? '' : 's'} just checked in for <strong>${eventTitle}</strong>.
</p>
${callout(`<strong style="font-size:15px">${totalRedeemed} of ${ticket.quantity || 1} checked in</strong>${
fullyCheckedIn ? '' : `<br/><span style="font-size:13px">${remaining} remaining on this ticket</span>`
}`, fullyCheckedIn ? 'success' : 'info')}`;
const text = `You're checked in!\n\nHi ${ticket.user?.name || 'there'},\n\n${qtyRedeemed} ${optionName}(s) just checked in for ${eventTitle}.\n\n${totalRedeemed} of ${ticket.quantity || 1} checked in${fullyCheckedIn ? '' : `, ${remaining} remaining`}.\n\n${org.name} — ${org.email}`;
return { subject, text, html: emailWrapper(body, { preheader }) };
}
// ─── Donation applied to a registration ────────────────────────────────────────
//
// Distinct from buildPaymentReceipt: this is sent to the REGISTRANT when staff apply
@@ -601,6 +694,109 @@ function buildPaymentAdminNotice(payment) {
return { to, subject, text, html: emailWrapper(body) };
}
// ─── Donation unassignment ─────────────────────────────────────────────────────
//
// Sent when staff reverse a previous donation-assignment. Distinct from
// buildDonationAppliedToRegistrant: the leg payment no longer exists by the time this runs
// (it's hard-deleted before the notification fires), so callers pass a synthetic payment-shaped
// object — { amount, createdAt, registration } — built from the leg's captured values plus a
// freshly re-fetched registration so the balance table reflects the post-removal total.
// Kept anonymous (no donor name), same reasoning as the "applied" email.
function buildDonationUnassignedFromRegistrant(payment) {
const org = getOrg();
const reg = payment.registration;
const eventTitle = reg?.event?.title || 'the event';
const totalDue = computeRegistrationTotalDue(reg, new Date());
const totalPaid = (reg?.payments || []).reduce((s, p) => s + (p.amount || 0), 0);
const balance = Math.max(totalDue - totalPaid, 0);
const isUserActive = reg?.user?.isActive;
const subject = `A donation was removed from your registration – ${eventTitle}`;
const preheader = `A donation of ${fmtAmount(payment.amount)} was removed from your registration for ${eventTitle}.`;
const body = `
<p style="font-size:22px;font-weight:800;color:#0f172a;margin:0 0 8px 0;letter-spacing:-0.3px">A donation was removed from your registration</p>
<p style="font-size:14px;color:#64748b;margin:0 0 32px 0">Your balance has changed</p>
<p style="margin:0 0 4px 0;color:#374151;font-family:${ff}">Hi <strong>${reg?.user?.name || 'there'}</strong>,</p>
<p style="margin:0 0 28px 0;color:#374151;font-family:${ff}">
A donation of <strong>${fmtAmount(payment.amount)}</strong> previously applied to your registration for <strong>${eventTitle}</strong> has been removed by our team.
</p>
${callout(`<strong style="font-size:15px">${fmtAmount(payment.amount)} removed</strong><br/>
<span style="font-size:13px">Date: ${fmtDate(new Date())}</span>`,
'warning')}
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="margin:24px 0">
<tr>
<td style="font-size:13px;color:#64748b;padding:5px 0;font-family:${ff}">Total due</td>
<td style="font-size:13px;text-align:right;font-weight:500;color:#374151;padding:5px 0;font-family:${ff}">${fmtAmount(totalDue)}</td>
</tr>
<tr>
<td style="font-size:13px;color:#64748b;padding:5px 0;font-family:${ff}">Total paid</td>
<td style="font-size:13px;text-align:right;font-weight:500;color:#374151;padding:5px 0;font-family:${ff}">${fmtAmount(totalPaid)}</td>
</tr>
<tr>
<td style="font-size:14px;font-weight:700;color:#0f172a;padding:10px 0 5px 0;border-top:1px solid #e2e8f0;font-family:${ff}">${balance <= 0 ? 'Fully paid ✓' : 'Balance remaining'}</td>
<td style="font-size:14px;font-weight:700;color:#${balance <= 0 ? '059669' : '0f172a'};text-align:right;padding:10px 0 5px 0;border-top:1px solid #e2e8f0;font-family:${ff}">${fmtAmount(balance)}</td>
</tr>
</table>
${balance > 0
? callout(`<strong>A balance is now owing.</strong> Please arrange payment of ${fmtAmount(balance)} to secure your registration.`, 'warning')
: ''}
${accountCta(isUserActive, org.url)}`;
const text = `A donation was removed from your registration\n\nHi ${reg?.user?.name || 'there'},\n\nA donation of ${fmtAmount(payment.amount)} previously applied to your registration for ${eventTitle} has been removed by our team.\n\nTotal due: ${fmtAmount(totalDue)}\nTotal paid: ${fmtAmount(totalPaid)}\nBalance: ${fmtAmount(balance)}\n\n${org.name} — ${org.email}`;
return { subject, text, html: emailWrapper(body, { preheader }) };
}
// Internal admin notice for a donation-assignment — same table layout as
// buildPaymentAdminNotice (which reads payment.user as "Payer" — for a leg that's the donor,
// since legs copy userId from the original donation, not the registrant), relabeled so it
// doesn't read as a fresh incoming payment: no new money changed hands here, an
// already-recorded donation was just reallocated to a registration.
function buildDonationAssignmentAdminNotice(payment) {
const notice = buildPaymentAdminNotice(payment);
const eventTitle = payment.registration?.event?.title || 'Event';
const payerName = payment.user?.name || '—';
const subject = `Donation applied: ${fmtAmount(payment.amount)} — ${payerName} (${eventTitle})`;
return {
...notice,
subject,
html: notice.html
.replace('Payment recorded', 'Donation applied')
.replace('Internal notification', 'Internal notification — donation applied to a registration')
.replace('>Registration payment<', '>Donation applied<'),
text: notice.text
.replace('Payment recorded', 'Donation applied')
.replace('Type: Registration payment', 'Type: Donation applied'),
};
}
// Internal admin notice for a donation-unassignment — same table layout as
// buildPaymentAdminNotice (which reads payment.user as "Payer" — for a leg that's the donor,
// since legs copy userId from the original donation, not the registrant), with copy adjusted
// for a removal rather than a new payment.
function buildDonationUnassignmentAdminNotice(payment) {
const notice = buildPaymentAdminNotice(payment);
const eventTitle = payment.registration?.event?.title || 'Event';
const payerName = payment.user?.name || '—';
const subject = `Donation unassigned: ${fmtAmount(payment.amount)} — ${payerName} (${eventTitle})`;
return {
...notice,
subject,
html: notice.html
.replace('Payment recorded', 'Donation unassigned')
.replace('Internal notification', 'Internal notification — donation removed from registration')
.replace('>Registration payment<', '>Donation unassigned<'),
text: notice.text
.replace('Payment recorded', 'Donation unassigned')
.replace('Type: Registration payment', 'Type: Donation unassigned'),
};
}
// ─── Refund email ─────────────────────────────────────────────────────────────
function buildRefundEmail(payment) {
@@ -636,7 +832,7 @@ function buildRefundEmail(payment) {
// ─── Daily event summary ──────────────────────────────────────────────────────
function buildDailySummary(ev, registrations, payments, now) {
function buildDailySummary(ev, registrations, payments, now, waitlistCount = 0) {
const org = getOrg();
const subject = `Daily summary: ${ev.title} — ${new Date(now).toLocaleDateString('en-GB', { day: 'numeric', month: 'long', year: 'numeric' })}`;
@@ -721,6 +917,7 @@ function buildDailySummary(ev, registrations, payments, now) {
${statsRow('Total registrations', totalRegistrations)}
${statsRow('Confirmed paid', paidCount, '#059669')}
${statsRow('Awaiting payment', pendingCount, '#d97706')}
${ev.waitlistEnabled ? statsRow('On the waitlist', waitlistCount, '#7c3aed') : ''}
<td style="text-align:center;padding:16px">
<div style="font-size:24px;font-weight:800;color:#2563eb;font-family:${ff}">${fmtAmount(totalRevenue)}</div>
<div style="font-size:12px;color:#64748b;margin-top:4px;font-family:${ff}">Total collected</div>
@@ -743,7 +940,7 @@ function buildDailySummary(ev, registrations, payments, now) {
`Daily Summary — ${ev.title}`,
new Date(now).toLocaleDateString(),
'',
`Registrations: ${totalRegistrations} | Paid: ${paidCount} | Pending: ${pendingCount} | Revenue: ${fmtAmount(totalRevenue)}`,
`Registrations: ${totalRegistrations} | Paid: ${paidCount} | Pending: ${pendingCount}${ev.waitlistEnabled ? ` | Waitlist: ${waitlistCount}` : ''} | Revenue: ${fmtAmount(totalRevenue)}`,
'',
'Registrations:',
...(regRows.length ? regRows.map(r => ` ${r.name} <${r.email}> — ${r.status} — due: ${fmtAmount(r.totalDue)} paid: ${fmtAmount(r.totalPaid)} balance: ${fmtAmount(r.balance)}`) : [' (none)']),
@@ -757,25 +954,74 @@ function buildDailySummary(ev, registrations, payments, now) {
// ─── Send functions ───────────────────────────────────────────────────────────
/**
* Generates an invoice PDF for a registration when it still has an outstanding balance,
* reusing (or, if none was supplied, creating) a Yoco checkout link so the invoice can carry
* a clickable "pay now" link and a scannable QR code. Returns null when nothing is owed or
* PDF generation fails — callers fall back to the existing text-only notification either way.
*/
/**
* Resolves a Yoco checkout link for an invoice's "pay now" link/QR code. Returns the given
* hint as-is when supplied; otherwise creates a fresh checkout, or null if nothing is owed
* (no point paying) or checkout creation fails (invoice still generates, just without a link).
*/
async function resolveInvoicePaymentUrl(reg, { totalDue, totalPaid, paymentUrlHint = null }) {
if (paymentUrlHint) return paymentUrlHint;
if (totalDue - totalPaid <= 0.01) return null;
try {
const { createRegistrationCheckoutInternal } = require('../controllers/paymentController');
const baseUrl = process.env.FRONTEND_URL || process.env.APP_BASE_URL || 'http://localhost:3001';
const checkout = await createRegistrationCheckoutInternal(reg.id, reg.userId, {
successUrl: `${baseUrl}/payment/success`,
cancelUrl: `${baseUrl}/payment/cancel`,
failureUrl: `${baseUrl}/payment/failure`,
});
return checkout.redirectUrl || null;
} catch (e) {
console.warn('Could not create Yoco checkout for invoice PDF:', e?.message || e);
return null;
}
}
// Only used by the automatic registration-confirmation sends, which should stay silent (no
// invoice attached) when nothing is owed — receipts already cover the fully-paid case.
async function maybeGenerateInvoicePdf(reg, { totalDue, totalPaid, paymentUrlHint = null }) {
if (totalDue - totalPaid <= 0.01) return null;
const paymentUrl = await resolveInvoicePaymentUrl(reg, { totalDue, totalPaid, paymentUrlHint });
try {
const { generateInvoicePdf } = require('./pdfDocs');
return await generateInvoicePdf(reg, { paymentUrl, totalDue, totalPaid });
} catch (e) {
console.error('Failed to generate invoice PDF:', e);
return null;
}
}
async function sendRegistrationEmails(registrationId) {
let invoicePdf = null;
try {
const reg = await loadRegistrationFull(registrationId);
if (!reg) return;
const { shouldEmail, waText } = require('./notify');
const { shouldEmail, waPdf, waText } = require('./notify');
const { buildWARegistration } = require('./waMessages');
const { computeRegistrationTotalDue } = require('./pricing');
const sends = [];
const totalDue = computeRegistrationTotalDue(reg, new Date());
const totalPaid = (reg.payments || []).reduce((s, p) => s + (p.amount || 0), 0);
invoicePdf = await maybeGenerateInvoicePdf(reg, { totalDue, totalPaid });
// Email: only for real addresses (skip guest.local placeholders)
if (reg.user?.email && !reg.user.email.endsWith('@guest.local')) {
const msg = buildRegistrationConfirmation(reg, { isNew: true });
if (shouldEmail(reg.user)) sends.push(sendMail({ to: reg.user.email, subject: msg.subject, html: msg.html, text: msg.text }));
if (shouldEmail(reg.user)) sends.push(sendMail({
to: reg.user.email, subject: msg.subject, html: msg.html, text: msg.text,
...(invoicePdf ? { attachments: [{ filename: invoicePdf.filename, path: invoicePdf.filePath, contentType: 'application/pdf' }] } : {}),
}));
}
// WhatsApp: always attempt — waText checks canWhatsApp (preference + valid phone) internally
sends.push(waText(reg.user, buildWARegistration(reg, { isNew: true, totalDue, totalPaid, balance: Math.max(totalDue - totalPaid, 0) })));
// WhatsApp: always attempt — waText/waPdf check canWhatsApp (preference + valid phone) internally
const waCaption = buildWARegistration(reg, { isNew: true, totalDue, totalPaid, balance: Math.max(totalDue - totalPaid, 0) });
sends.push(invoicePdf ? waPdf(reg.user, invoicePdf.filePath, invoicePdf.filename, waCaption) : waText(reg.user, waCaption));
const adminMsg = buildRegistrationAdminNotice(reg, { isNew: true });
if (adminMsg.to && adminMsg.to.length) {
@@ -784,25 +1030,34 @@ async function sendRegistrationEmails(registrationId) {
await Promise.all(sends);
} catch (e) {
console.error('Failed to send registration emails:', e);
} finally {
if (invoicePdf) try { fs.unlinkSync(invoicePdf.filePath); } catch {}
}
}
async function sendRegistrationUpdatedEmails(registrationId) {
let invoicePdf = null;
try {
const reg = await loadRegistrationFull(registrationId);
if (!reg) return;
const { shouldEmail, waText } = require('./notify');
const { shouldEmail, waPdf, waText } = require('./notify');
const { buildWARegistration } = require('./waMessages');
const { computeRegistrationTotalDue } = require('./pricing');
const sends = [];
const totalDue = computeRegistrationTotalDue(reg, new Date());
const totalPaid = (reg.payments || []).reduce((s, p) => s + (p.amount || 0), 0);
invoicePdf = await maybeGenerateInvoicePdf(reg, { totalDue, totalPaid });
if (reg.user?.email && !reg.user.email.endsWith('@guest.local')) {
const msg = buildRegistrationConfirmation(reg, { isNew: false });
if (shouldEmail(reg.user)) sends.push(sendMail({ to: reg.user.email, subject: msg.subject, html: msg.html, text: msg.text }));
if (shouldEmail(reg.user)) sends.push(sendMail({
to: reg.user.email, subject: msg.subject, html: msg.html, text: msg.text,
...(invoicePdf ? { attachments: [{ filename: invoicePdf.filename, path: invoicePdf.filePath, contentType: 'application/pdf' }] } : {}),
}));
}
sends.push(waText(reg.user, buildWARegistration(reg, { isNew: false, totalDue, totalPaid, balance: Math.max(totalDue - totalPaid, 0) })));
const waCaption = buildWARegistration(reg, { isNew: false, totalDue, totalPaid, balance: Math.max(totalDue - totalPaid, 0) });
sends.push(invoicePdf ? waPdf(reg.user, invoicePdf.filePath, invoicePdf.filename, waCaption) : waText(reg.user, waCaption));
const adminMsg = buildRegistrationAdminNotice(reg, { isNew: false, isUpdated: true });
if (adminMsg.to && adminMsg.to.length) {
sends.push(sendMail({ to: adminMsg.to.join(','), subject: adminMsg.subject, html: adminMsg.html, text: adminMsg.text }));
@@ -810,25 +1065,34 @@ async function sendRegistrationUpdatedEmails(registrationId) {
await Promise.all(sends);
} catch (e) {
console.error('Failed to send registration updated emails:', e);
} finally {
if (invoicePdf) try { fs.unlinkSync(invoicePdf.filePath); } catch {}
}
}
async function sendSelfServiceRegistrationEmails(registrationId, { paymentUrl = null, formRequired = false, isNew = true } = {}) {
let invoicePdf = null;
try {
const reg = await loadRegistrationFull(registrationId);
if (!reg) return;
const { shouldEmail, waText } = require('./notify');
const { shouldEmail, waPdf, waText } = require('./notify');
const { buildWARegistration } = require('./waMessages');
const { computeRegistrationTotalDue } = require('./pricing');
const sends = [];
const totalDue = computeRegistrationTotalDue(reg, new Date());
const totalPaid = (reg.payments || []).reduce((s, p) => s + (p.amount || 0), 0);
invoicePdf = await maybeGenerateInvoicePdf(reg, { totalDue, totalPaid, paymentUrlHint: paymentUrl });
if (reg.user?.email && !reg.user.email.endsWith('@guest.local')) {
const msg = buildAdminInitiatedRegistrationConfirmation(reg, { yocoLink: paymentUrl, formRequired, isNew });
if (shouldEmail(reg.user)) sends.push(sendMail({ to: reg.user.email, subject: msg.subject, html: msg.html, text: msg.text }));
if (shouldEmail(reg.user)) sends.push(sendMail({
to: reg.user.email, subject: msg.subject, html: msg.html, text: msg.text,
...(invoicePdf ? { attachments: [{ filename: invoicePdf.filename, path: invoicePdf.filePath, contentType: 'application/pdf' }] } : {}),
}));
}
sends.push(waText(reg.user, buildWARegistration(reg, { isNew, totalDue, totalPaid, balance: Math.max(totalDue - totalPaid, 0) })));
const waCaption = buildWARegistration(reg, { isNew, totalDue, totalPaid, balance: Math.max(totalDue - totalPaid, 0) });
sends.push(invoicePdf ? waPdf(reg.user, invoicePdf.filePath, invoicePdf.filename, waCaption) : waText(reg.user, waCaption));
const adminMsg = buildRegistrationAdminNotice(reg, { isNew, isUpdated: !isNew });
if (adminMsg.to && adminMsg.to.length) {
sends.push(sendMail({ to: adminMsg.to.join(','), subject: adminMsg.subject, html: adminMsg.html, text: adminMsg.text }));
@@ -836,28 +1100,40 @@ async function sendSelfServiceRegistrationEmails(registrationId, { paymentUrl =
await Promise.all(sends);
} catch (e) {
console.error('Failed to send self-service registration emails:', e);
} finally {
if (invoicePdf) try { fs.unlinkSync(invoicePdf.filePath); } catch {}
}
}
async function sendPaymentEmails(paymentId) {
let receiptPdf = null;
try {
const payment = await loadPaymentFull(paymentId);
if (!payment) return;
const user = payment.registration?.user || payment.user;
const { shouldEmail, waText, waTextAny } = require('./notify');
const { shouldEmail, waPdf, waPdfAny, waText, waTextAny } = require('./notify');
const { buildWAPayment } = require('./waMessages');
const { generateReceiptPdf } = require('./pdfDocs');
try { receiptPdf = await generateReceiptPdf(payment); } catch (e) { console.error('Failed to generate receipt PDF:', e); }
const sends = [];
const hasValidEmail = user?.email && !user.email.endsWith('@guest.local') && !user.email.endsWith('@deleted.invalid');
if (hasValidEmail) {
const msg = buildPaymentReceipt(payment);
if (shouldEmail(user)) sends.push(sendMail({ to: user.email, subject: msg.subject, html: msg.html, text: msg.text }));
if (shouldEmail(user)) sends.push(sendMail({
to: user.email, subject: msg.subject, html: msg.html, text: msg.text,
...(receiptPdf ? { attachments: [{ filename: receiptPdf.filename, path: receiptPdf.filePath, contentType: 'application/pdf' }] } : {}),
}));
}
// WhatsApp: respect preference when email is available; use as unconditional fallback when it isn't
// WhatsApp: respect preference when email is available; use as unconditional fallback when it isn't.
// Send the receipt PDF (with the usual text as its caption) when generation succeeded,
// otherwise fall back to the plain text message so a PDF failure never blocks delivery.
const waCaption = buildWAPayment(payment);
if (hasValidEmail) {
sends.push(waText(user, buildWAPayment(payment)));
sends.push(receiptPdf ? waPdf(user, receiptPdf.filePath, receiptPdf.filename, waCaption) : waText(user, waCaption));
} else {
sends.push(waTextAny(user, buildWAPayment(payment)));
sends.push(receiptPdf ? waPdfAny(user, receiptPdf.filePath, receiptPdf.filename, waCaption) : waTextAny(user, waCaption));
}
const hasEvent = !!(payment.registration?.eventId || payment.eventId);
if (hasEvent) {
@@ -869,6 +1145,8 @@ async function sendPaymentEmails(paymentId) {
await Promise.all(sends);
} catch (e) {
console.error('Failed to send payment emails:', e);
} finally {
if (receiptPdf) try { fs.unlinkSync(receiptPdf.filePath); } catch {}
}
}
@@ -893,6 +1171,32 @@ async function sendRefundEmail(refundPaymentId) {
}
}
async function sendCheckInEmails(ticketId, qtyRedeemed, totalRedeemed, remaining) {
try {
const ticket = await loadTicketFull(ticketId);
if (!ticket) return;
const user = ticketRecipient(ticket);
const { shouldEmail, waText, waTextAny } = require('./notify');
const { buildWACheckIn } = require('./waMessages');
const sends = [];
const hasValidEmail = user?.email && !user.email.endsWith('@guest.local') && !user.email.endsWith('@deleted.invalid');
if (hasValidEmail && shouldEmail(user)) {
const msg = buildCheckInConfirmation(ticket, qtyRedeemed, totalRedeemed, remaining);
sends.push(sendMail({ to: user.email, subject: msg.subject, html: msg.html, text: msg.text }));
}
const waMsg = buildWACheckIn(ticket, qtyRedeemed, totalRedeemed, remaining);
if (hasValidEmail) {
sends.push(waText(user, waMsg));
} else {
sends.push(waTextAny(user, waMsg));
}
await Promise.all(sends);
} catch (e) {
console.error('Failed to send check-in emails:', e);
}
}
// Sent when staff apply a donation to someone's registration. Only the registrant is
// notified (anonymously, per design) — the donor already received their donation-received
// notification when the donation was originally made, so they are deliberately not emailed
@@ -916,7 +1220,7 @@ async function sendDonationAssignmentEmails(paymentId) {
} else {
sends.push(waTextAny(user, buildWADonationAppliedToRegistrant(payment)));
}
const adminMsg = buildPaymentAdminNotice(payment);
const adminMsg = buildDonationAssignmentAdminNotice(payment);
if (adminMsg.to && adminMsg.to.length) {
sends.push(sendMail({ to: adminMsg.to.join(','), subject: adminMsg.subject, html: adminMsg.html, text: adminMsg.text }));
}
@@ -926,19 +1230,209 @@ async function sendDonationAssignmentEmails(paymentId) {
}
}
// Sent when staff reverse a previous donation-assignment (the leg payment is hard-deleted
// before this runs, so it can't be re-fetched by id like loadPaymentFull does elsewhere —
// callers pass a snapshot of the leg's fields captured just before deletion instead).
// Notifies the registrant (balance likely increased) and logs an internal admin notice showing
// the donor as "Payer", same as the original assignment notice did.
async function sendDonationUnassignmentEmails(leg) {
try {
const [registration, donor] = await Promise.all([
prisma.registration.findUnique({
where: { id: leg.registrationId },
include: {
registrationOptions: { include: { eventOption: { include: { earlyBirdTiers: true } }, tranches: true } },
payments: 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 } } } },
},
}),
leg.userId ? prisma.user.findUnique({ where: { id: leg.userId }, select: { id: true, name: true, email: true } }) : null,
]);
if (!registration) return;
// Synthetic payment-shaped object matching what buildDonationUnassignedFromRegistrant and
// buildPaymentAdminNotice (via buildDonationUnassignmentAdminNotice) expect.
const pseudoPayment = {
id: leg.id,
amount: leg.amount,
createdAt: leg.createdAt,
method: leg.method,
externalId: leg.externalId,
registrationId: leg.registrationId,
registration,
user: donor,
};
const user = registration.user;
const { shouldEmail, waText, waTextAny } = require('./notify');
const { buildWADonationUnassignedFromRegistrant } = require('./waMessages');
const sends = [];
const hasValidEmail = user?.email && !user.email.endsWith('@guest.local') && !user.email.endsWith('@deleted.invalid');
if (hasValidEmail) {
const msg = buildDonationUnassignedFromRegistrant(pseudoPayment);
if (shouldEmail(user)) sends.push(sendMail({ to: user.email, subject: msg.subject, html: msg.html, text: msg.text }));
}
if (hasValidEmail) {
sends.push(waText(user, buildWADonationUnassignedFromRegistrant(pseudoPayment)));
} else {
sends.push(waTextAny(user, buildWADonationUnassignedFromRegistrant(pseudoPayment)));
}
const adminMsg = buildDonationUnassignmentAdminNotice(pseudoPayment);
if (adminMsg.to && adminMsg.to.length) {
sends.push(sendMail({ to: adminMsg.to.join(','), subject: adminMsg.subject, html: adminMsg.html, text: adminMsg.text }));
}
await Promise.all(sends);
} catch (e) {
console.error('Failed to send donation-unassignment emails:', e);
}
}
// ─── Manual "send to me" requests ──────────────────────────────────────────────
// Unlike the fire-and-forget sends above, these are awaited directly from a
// request handler (dashboard "Email/WhatsApp invoice|receipt" buttons) — they
// throw an Error with a `statusCode` on failure instead of swallowing it, so the
// controller can surface a real error to the user rather than failing silently.
function userFacingError(message, statusCode) {
const e = new Error(message);
e.statusCode = statusCode;
return e;
}
/**
* (re)generates and sends a registration's invoice PDF to the registrant on a single,
* explicitly-chosen channel. Only the registrant themself may request their own invoice.
* @param {string} registrationId
* @param {string} requesterId - req.user.id of the caller
* @param {'email'|'whatsapp'} channel
*/
async function sendInvoiceToUser(registrationId, requesterId, channel) {
if (channel !== 'email' && channel !== 'whatsapp') throw userFacingError('Invalid channel', 400);
const reg = await loadRegistrationFull(registrationId);
if (!reg) throw userFacingError('Registration not found', 404);
if (reg.userId !== requesterId) throw userFacingError('Not authorized to access this registration', 403);
if (channel === 'email' && (!reg.user?.email || reg.user.email.endsWith('@guest.local'))) {
throw userFacingError('No valid email address on file. Add one in your profile.', 400);
}
if (channel === 'whatsapp' && !reg.user?.phoneNumber) {
throw userFacingError('No phone number on file. Add one in your profile.', 400);
}
const totalDue = computeRegistrationTotalDue(reg, new Date());
const totalPaid = (reg.payments || []).reduce((s, p) => s + (p.amount || 0), 0);
// Unlike the automatic post-registration send, a manually-requested invoice is generated
// regardless of balance — someone may want it as a paid-in-full record too.
const paymentUrl = await resolveInvoicePaymentUrl(reg, { totalDue, totalPaid });
let invoicePdf;
try {
const { generateInvoicePdf } = require('./pdfDocs');
invoicePdf = await generateInvoicePdf(reg, { paymentUrl, totalDue, totalPaid });
} catch (e) {
console.error('Failed to generate invoice PDF:', e);
throw userFacingError('Could not generate the invoice right now. Please try again shortly.', 500);
}
try {
const eventTitle = reg.event?.title || 'your registration';
if (channel === 'email') {
await sendMail({
to: reg.user.email,
subject: `Your invoice for ${eventTitle}`,
html: emailWrapper(
`<p style="font-size:18px;font-weight:700;color:#0f172a;margin:0 0 12px 0">Your invoice is attached</p>
<p style="margin:0;color:#374151;font-family:${ff}">Hi <strong>${reg.user?.name || 'there'}</strong>, here's the invoice you requested for <strong>${eventTitle}</strong>.</p>`
),
text: `Hi ${reg.user?.name || 'there'},\n\nHere's the invoice you requested for ${eventTitle} — see the attached PDF.`,
attachments: [{ filename: invoicePdf.filename, path: invoicePdf.filePath, contentType: 'application/pdf' }],
});
} else {
// Explicit user-requested channel — bypass the notification-preference gate (waPdfAny)
// the same way ticket resends do, so "WhatsApp invoice" works even if the user's saved
// preference is email-only.
const { waPdfAny } = require('./notify');
const { buildWARegistration } = require('./waMessages');
const caption = buildWARegistration(reg, { isNew: false, totalDue, totalPaid, balance: Math.max(totalDue - totalPaid, 0) });
await waPdfAny(reg.user, invoicePdf.filePath, invoicePdf.filename, caption);
}
} finally {
try { fs.unlinkSync(invoicePdf.filePath); } catch {}
}
}
/**
* (Re)generates and sends a payment's receipt PDF to the payer on a single, explicitly-chosen
* channel. Only the person the payment belongs to (via the payment itself or its registration)
* may request their own receipt.
* @param {string} paymentId
* @param {string} requesterId - req.user.id of the caller
* @param {'email'|'whatsapp'} channel
*/
async function sendReceiptToUser(paymentId, requesterId, channel) {
if (channel !== 'email' && channel !== 'whatsapp') throw userFacingError('Invalid channel', 400);
const payment = await loadPaymentFull(paymentId);
if (!payment) throw userFacingError('Payment not found', 404);
const owner = payment.registration?.user || payment.user;
const ownerId = payment.registration?.userId || payment.userId;
if (ownerId !== requesterId) throw userFacingError('Not authorized to access this payment', 403);
if (channel === 'email' && (!owner?.email || owner.email.endsWith('@guest.local'))) {
throw userFacingError('No valid email address on file. Add one in your profile.', 400);
}
if (channel === 'whatsapp' && !owner?.phoneNumber) {
throw userFacingError('No phone number on file. Add one in your profile.', 400);
}
const { generateReceiptPdf } = require('./pdfDocs');
let receiptPdf;
try {
receiptPdf = await generateReceiptPdf(payment);
} catch (e) {
console.error('Failed to generate receipt PDF:', e);
throw userFacingError('Could not generate the receipt right now. Please try again shortly.', 500);
}
try {
const eventTitle = payment.registration?.event?.title || payment.event?.title || 'your registration';
if (channel === 'email') {
await sendMail({
to: owner.email,
subject: `Your payment receipt for ${eventTitle}`,
html: emailWrapper(
`<p style="font-size:18px;font-weight:700;color:#0f172a;margin:0 0 12px 0">Your receipt is attached</p>
<p style="margin:0;color:#374151;font-family:${ff}">Hi <strong>${owner?.name || 'there'}</strong>, here's the receipt you requested for your payment of <strong>${fmtAmount(payment.amount)}</strong> towards <strong>${eventTitle}</strong>.</p>`
),
text: `Hi ${owner?.name || 'there'},\n\nHere's the receipt you requested for your payment of ${fmtAmount(payment.amount)} towards ${eventTitle} — see the attached PDF.`,
attachments: [{ filename: receiptPdf.filename, path: receiptPdf.filePath, contentType: 'application/pdf' }],
});
} else {
const { waPdfAny } = require('./notify');
const { buildWAPayment } = require('./waMessages');
await waPdfAny(owner, receiptPdf.filePath, receiptPdf.filename, buildWAPayment(payment));
}
} finally {
try { fs.unlinkSync(receiptPdf.filePath); } catch {}
}
}
async function sendDailyEventSummaries(now = new Date()) {
try {
const today = new Date(now);
const notifyInclude = { createdBy: { select: { id: true, name: true, email: true } }, notifyRecipients: { select: { id: true, name: true, email: true } } };
let events = await prisma.event.findMany({
where: { isActive: true, startDate: { gte: today } },
where: { isActive: true, startDate: { gte: today }, requiresRegistration: true },
include: notifyInclude,
orderBy: { startDate: 'asc' },
});
try {
events = await prisma.event.findMany({
where: { isActive: true, startDate: { gte: today }, goLiveAt: { lte: today } },
where: { isActive: true, startDate: { gte: today }, goLiveAt: { lte: today }, requiresRegistration: true },
include: notifyInclude,
orderBy: { startDate: 'asc' },
});
@@ -949,7 +1443,7 @@ async function sendDailyEventSummaries(now = new Date()) {
where: { eventId: ev.id },
include: {
user: { select: { id: true, name: true, email: true, phoneNumber: true } },
registrationOptions: { include: { eventOption: { include: { earlyBirdTiers: true } } } },
registrationOptions: { include: { eventOption: { include: { earlyBirdTiers: true } }, tranches: true } },
payments: true,
},
orderBy: { createdAt: 'asc' },
@@ -964,7 +1458,10 @@ async function sendDailyEventSummaries(now = new Date()) {
orderBy: { createdAt: 'asc' },
});
const { subject, text, html } = buildDailySummary(ev, registrations, payments, now);
const waitlistCount = ev.waitlistEnabled
? await prisma.waitlistEntry.count({ where: { eventId: ev.id, status: { in: ['waiting', 'offered'] } } })
: 0;
const { subject, text, html } = buildDailySummary(ev, registrations, payments, now, waitlistCount);
const to = buildEventNotifyRecipientList(ev);
if (to.length) await sendMail({ to: to.join(','), subject, html, text });
}));
@@ -973,7 +1470,45 @@ async function sendDailyEventSummaries(now = new Date()) {
}
}
// Sent when a payer (or staff) asks for a named attendee to fill in their own form.
// Always tries both channels the attendee has, regardless of anyone's preference.
async function sendAttendeeFormLinkMessage(attendee) {
const org = getOrg();
const reg = attendee.registration;
const eventTitle = reg?.event?.title || 'the event';
const link = `${org.url}/forms?registrationId=${encodeURIComponent(attendee.registrationId)}&attendeeId=${encodeURIComponent(attendee.id)}`;
const payer = reg?.user?.name || 'Someone';
const sent = [];
if (attendee.email && attendee.email.includes('@')) {
const html = emailWrapper(`
<p style="font-size:22px;font-weight:800;color:#0f172a;margin:0 0 8px 0">Please fill in your form</p>
<p style="margin:0 0 8px 0;color:#374151;font-family:${ff}">Hi <strong>${attendee.name}</strong>,</p>
<p style="margin:0 0 24px 0;color:#374151;font-family:${ff}">${payer} registered you for <strong>${eventTitle}</strong>. Please fill in your attendee form.</p>
${ctaButton('Fill in my form', link)}
${fallbackLink(link)}
`, { preheader: `Your attendee form for ${eventTitle}` });
try {
await sendMail({ to: attendee.email, subject: `Your attendee form for ${eventTitle}`, html, text: `Hi ${attendee.name},\n\n${payer} registered you for ${eventTitle}. Please fill in your attendee form:\n${link}\n\n${org.name}` });
sent.push('email');
} catch (e) {
console.warn('[attendee form link] email failed:', e?.message || e);
}
}
if (attendee.phone) {
const { waTextAny } = require('./notify');
const { isValidZAPhone } = require('./whatsapp');
if (isValidZAPhone(attendee.phone)) {
await waTextAny({ name: attendee.name, phoneNumber: attendee.phone }, `Hi ${attendee.name}, ${payer} registered you for *${eventTitle}*. Please fill in your attendee form: ${link}`);
sent.push('WhatsApp');
}
}
return sent;
}
module.exports = {
buildEventNotifyRecipientList,
sendAttendeeFormLinkMessage,
sendRegistrationEmails,
sendRegistrationUpdatedEmails,
sendPaymentEmails,
@@ -981,4 +1516,9 @@ module.exports = {
sendRefundEmail,
sendSelfServiceRegistrationEmails,
sendDonationAssignmentEmails,
sendDonationUnassignmentEmails,
sendCheckInEmails,
buildCheckInConfirmation,
sendInvoiceToUser,
sendReceiptToUser,
};
+372
View File
@@ -0,0 +1,372 @@
/**
* Branded PDF documents: payment receipts and registration invoices.
*
* Both are generated with pdfkit into backend/temp and returned as
* { filePath, filename } for callers to attach to an email/WhatsApp send and
* clean up afterwards (see notifications.js).
*
* Line items are built the same tranche-aware way the user dashboard renders
* them (backend/src/utils/pricing.js) — merging tranches that share a
* name/price/tier and separating early-bird lines from standard-price ones —
* so the PDF total always matches computeRegistrationTotalDue().
*/
const fs = require('fs');
const path = require('path');
const PDFDocument = require('pdfkit');
const QRCode = require('qrcode');
const { getSetting } = require('./settingsCache');
function fmtAmount(amt) {
return `R${Number(amt || 0).toFixed(2)}`;
}
function fmtDate(d) {
try { return new Date(d).toLocaleDateString('en-GB', { day: 'numeric', month: 'long', year: 'numeric' }); } catch { return ''; }
}
function formatMethod(method) {
const m = String(method || '').toLowerCase();
if (!m) return '—';
if (m === 'eft') return 'EFT';
return m.replace(/^./, c => c.toUpperCase());
}
function tempDir() {
const dir = path.join(__dirname, '..', '..', 'temp');
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
return dir;
}
function docNumber(prefix, id, date) {
const year = new Date(date || Date.now()).getFullYear();
const short = String(id || '').replace(/-/g, '').slice(-6).toUpperCase() || '000000';
return `${prefix}-${year}-${short}`;
}
async function getBranding() {
const [name, address, email, phone, primary, accent, logoUrl] = await Promise.all([
getSetting('org_name', process.env.ORG_NAME || 'Cross Code'),
getSetting('org_address', ''),
getSetting('org_email', process.env.EMAIL_FROM || process.env.EMAIL_USER || ''),
getSetting('org_phone', ''),
getSetting('primary_color', ''),
getSetting('accent_color', ''),
getSetting('logo_url', ''),
]);
const url = (process.env.FRONTEND_URL || process.env.APP_BASE_URL || 'http://localhost:3001').replace(/\/$/, '');
let logoPath = null;
if (logoUrl) {
const p = path.join(__dirname, '..', '..', 'public', logoUrl.replace(/^\//, ''));
if (fs.existsSync(p)) logoPath = p;
}
return { name, address, email, phone, brandColor: primary || accent || '#1e3a5f', url, logoPath };
}
/**
* Merge a registration's tranches into display rows, same grouping logic as
* the user dashboard: one row per (name, unit price, early-bird flag), with
* `basePrice` carried along so callers can work out the early-bird discount.
*/
function buildLineItems(registrationOptions) {
const rows = [];
for (const ro of (registrationOptions || [])) {
const variantLabel = ro.variant?.name ? ` (${ro.variant.name})` : '';
const label = `${ro.eventOption?.name || 'Option'}${variantLabel}`;
const basePrice = Number((ro.variant?.price ?? ro.eventOption?.price ?? 0));
const tranches = Array.isArray(ro.tranches) && ro.tranches.length > 0
? ro.tranches
: [{
quantity: ro.quantity,
priceSnapshot: (ro.priceSnapshot !== null && ro.priceSnapshot !== undefined) ? Number(ro.priceSnapshot) : basePrice,
appliedTierId: ro.appliedTierId,
}];
for (const t of tranches) {
const unitPrice = Number(t.priceSnapshot || 0);
const isEarlyBird = !!t.appliedTierId;
const key = `${label}__${isEarlyBird}__${unitPrice}`;
let row = rows.find(r => r.key === key);
if (!row) { row = { key, label, quantity: 0, unitPrice, isEarlyBird, basePrice }; rows.push(row); }
row.quantity += (t.quantity || 0);
}
}
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;
// Fixed-width columns anchored to the right edge so amounts never wrap,
// regardless of the overall table width (receipt vs. narrower invoice table).
const totalColW = 75, priceColW = 65, qtyColW = 35;
const colTotal = x + width - totalColW;
const colPrice = colTotal - priceColW;
const colQty = colPrice - qtyColW;
const rowH = 22;
if (headerLight) {
doc.rect(x, y, width, rowH).fill('#f8fafc');
doc.fillColor('#64748b').font('Helvetica-Bold').fontSize(9);
} else {
doc.rect(x, y, width, rowH).fill(brandColor);
doc.fillColor('#ffffff').font('Helvetica-Bold').fontSize(9);
}
doc.text('DESCRIPTION', colDesc + 8, y + 7);
doc.text('QTY', colQty, y + 7, { width: qtyColW - 8, align: 'right' });
doc.text('PRICE', colPrice, y + 7, { width: priceColW - 8, align: 'right' });
doc.text('TOTAL', colTotal, y + 7, { width: totalColW - 8, align: 'right' });
y += rowH;
doc.font('Helvetica').fontSize(10).fillColor('#374151');
for (const row of rows) {
doc.text(row.label + (row.isEarlyBird ? ' (early bird)' : ''), colDesc + 8, y + 6, { width: colQty - colDesc - 12 });
doc.text(String(row.quantity), colQty, y + 6, { width: qtyColW - 8, align: 'right' });
doc.text(fmtAmount(row.unitPrice), colPrice, y + 6, { width: priceColW - 8, align: 'right' });
doc.text(fmtAmount(row.unitPrice * row.quantity), colTotal, y + 6, { width: totalColW - 8, align: 'right' });
doc.moveTo(x, y + rowH).lineTo(x + width, y + rowH).strokeColor('#e2e8f0').lineWidth(0.5).stroke();
y += rowH;
}
return y;
}
// ─── Payment receipt ──────────────────────────────────────────────────────────
/**
* @param {object} payment - from notifications.js loadPaymentFull: amount, method, externalId,
* createdAt, user, registration { event, registrationOptions[{eventOption,variant,tranches}] }
* @returns {Promise<{ filePath: string, filename: string }>}
*/
async function generateReceiptPdf(payment) {
const org = await getBranding();
const reg = payment.registration;
const user = reg?.user || payment.user;
const eventTitle = reg?.event?.title || payment.event?.title || 'Event';
const eventStartDate = reg?.event?.startDate || payment.event?.startDate || null;
const eventDate = eventStartDate ? fmtDate(eventStartDate) : '';
const receiptNo = docNumber('RCPT', payment.id, payment.createdAt);
const rows = reg ? buildLineItems(reg.registrationOptions) : [];
const filename = `receipt-${receiptNo}.pdf`;
const filePath = path.join(tempDir(), `${Date.now()}-${filename}`);
const doc = new PDFDocument({ size: 'A4', margin: 0 });
const writeStream = fs.createWriteStream(filePath);
doc.pipe(writeStream);
const pageWidth = doc.page.width;
const pageHeight = doc.page.height;
const marginX = 40;
// Header banner
const bannerH = 150;
doc.rect(0, 0, pageWidth, bannerH).fill(org.brandColor);
if (org.logoPath) {
try { doc.image(org.logoPath, marginX, 28, { fit: [36, 36] }); } catch {}
}
doc.fillColor('#ffffff').font('Helvetica-Bold').fontSize(13).text(org.name, marginX + (org.logoPath ? 46 : 0), 38, { width: 260 });
doc.font('Helvetica-Bold').fontSize(26).text('Payment Receipt', marginX, 68, { width: 320 });
doc.font('Helvetica').fontSize(11).text('Thank you for your payment.', marginX, 102, { width: 320 });
if (eventStartDate) doc.font('Helvetica-Bold').fontSize(10).text('We look forward to seeing you at the event!', marginX, 122, { width: 320 });
const metaX = pageWidth - 250;
const meta = [
['Receipt No.', receiptNo],
['Date', fmtDate(payment.createdAt)],
['Payment Method', formatMethod(payment.method)],
['Transaction ID', payment.externalId || payment.id.slice(0, 12)],
];
let metaY = 34;
for (const [label, value] of meta) {
doc.font('Helvetica').fontSize(9).fillColor('#ffffff').fillOpacity(0.75).text(label, metaX, metaY, { width: 90 });
doc.font('Helvetica-Bold').fontSize(9).fillColor('#ffffff').fillOpacity(1).text(value, metaX + 90, metaY, { width: 120, align: 'right' });
metaY += 24;
}
// Body
let y = bannerH + 30;
doc.font('Helvetica-Bold').fontSize(10).fillColor(org.brandColor).text('PAYER DETAILS', marginX, y);
doc.font('Helvetica-Bold').fontSize(10).fillColor(org.brandColor).text('EVENT DETAILS', marginX + 280, y);
y += 16;
doc.font('Helvetica-Bold').fontSize(11).fillColor('#0f172a').text(user?.name || 'Guest', marginX, y);
doc.font('Helvetica-Bold').fontSize(11).fillColor('#0f172a').text(eventTitle, marginX + 280, y, { width: 240 });
y += 16;
doc.font('Helvetica').fontSize(9).fillColor('#374151').text(user?.email || '', marginX, y);
if (eventDate) doc.font('Helvetica').fontSize(9).fillColor('#374151').text(eventDate, marginX + 280, y);
y += 14;
if (user?.phoneNumber) doc.font('Helvetica').fontSize(9).fillColor('#374151').text(user.phoneNumber, marginX, y);
y += 30;
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;
doc.font('Helvetica-Bold').fontSize(10).fillColor(org.brandColor).text('TOTAL PAID', pageWidth - marginX - boxW - 110, y + 10, { width: 90, align: 'right' });
doc.roundedRect(pageWidth - marginX - boxW, y, boxW, boxH, 4).fill(org.brandColor);
doc.font('Helvetica-Bold').fontSize(14).fillColor('#ffffff').text(fmtAmount(payment.amount), pageWidth - marginX - boxW, y + 9, { width: boxW, align: 'center' });
y += boxH + 30;
// Footer
const footerH = 60;
const footerY = Math.max(pageHeight - footerH, y + 20);
doc.rect(0, footerY, pageWidth, footerH).fill('#0f172a');
doc.font('Helvetica-BoldOblique').fontSize(16).fillColor('#ffffff').text('Thank you!', marginX, footerY + 20);
doc.font('Helvetica').fontSize(9).fillColor('#94a3b8').text(org.url.replace(/^https?:\/\//, ''), 0, footerY + 24, { width: pageWidth - marginX, align: 'right' });
doc.end();
await new Promise((resolve, reject) => { writeStream.on('finish', resolve); writeStream.on('error', reject); });
return { filePath, filename };
}
// ─── Invoice ───────────────────────────────────────────────────────────────────
/**
* @param {object} registration - from notifications.js loadRegistrationFull: id, createdAt,
* user, event, registrationOptions[{eventOption,variant,tranches}], payments
* @param {{ paymentUrl?: string|null, totalDue?: number, totalPaid?: number }} opts
* @returns {Promise<{ filePath: string, filename: string }>}
*/
async function generateInvoicePdf(registration, { paymentUrl = null, totalDue = null, totalPaid = null } = {}) {
const org = await getBranding();
const { computeRegistrationTotalDue } = require('./pricing');
const due = totalDue !== null ? totalDue : computeRegistrationTotalDue(registration, new Date());
const paid = totalPaid !== null ? totalPaid : (registration.payments || []).reduce((s, p) => s + (p.amount || 0), 0);
const balance = Math.max(due - paid, 0);
const invoiceNo = docNumber('INV', registration.id, registration.createdAt);
const rows = buildLineItems(registration.registrationOptions);
// Subtotal/discount: only early-bird rows are compared against today's base price, so an
// ordinary base-price change over time never shows up as a false "discount" on standard rows.
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}`);
const doc = new PDFDocument({ size: 'A4', margin: 0 });
const writeStream = fs.createWriteStream(filePath);
doc.pipe(writeStream);
const pageWidth = doc.page.width;
const pageHeight = doc.page.height;
const sidebarW = 190;
const mainX = sidebarW + 30;
const mainW = pageWidth - mainX - 40;
// Sidebar
doc.rect(0, 0, sidebarW, pageHeight).fill('#111827');
let sy = 40;
if (org.logoPath) {
try { doc.image(org.logoPath, 28, sy, { fit: [32, 32] }); sy += 0; } catch {}
}
doc.font('Helvetica-Bold').fontSize(12).fillColor('#ffffff').text(org.name, org.logoPath ? 68 : 28, sy + 8, { width: sidebarW - (org.logoPath ? 96 : 56) });
sy += 60;
doc.font('Helvetica-Bold').fontSize(20).fillColor('#ffffff').text('INVOICE', 28, sy, { width: sidebarW - 56 });
sy += 28;
doc.font('Helvetica-Bold').fontSize(11).fillColor(org.brandColor).text(invoiceNo, 28, sy, { width: sidebarW - 56 });
sy += 34;
doc.font('Helvetica').fontSize(8).fillColor('#94a3b8').text('DATE ISSUED', 28, sy);
doc.font('Helvetica-Bold').fontSize(9).fillColor('#ffffff').text(fmtDate(registration.createdAt), 28, sy + 11);
sy += 40;
doc.font('Helvetica').fontSize(8).fillColor(org.brandColor).text('BILL TO', 28, sy);
sy += 13;
doc.font('Helvetica-Bold').fontSize(10).fillColor('#ffffff').text(registration.user?.name || 'Guest', 28, sy, { width: sidebarW - 56 });
sy += 15;
doc.font('Helvetica').fontSize(8).fillColor('#cbd5e1').text(registration.user?.email || '', 28, sy, { width: sidebarW - 56 });
sy += 12;
if (registration.user?.phoneNumber) { doc.font('Helvetica').fontSize(8).fillColor('#cbd5e1').text(registration.user.phoneNumber, 28, sy, { width: sidebarW - 56 }); sy += 12; }
let by = pageHeight - 140;
doc.font('Helvetica-Bold').fontSize(9).fillColor('#ffffff').text(org.name, 28, by, { width: sidebarW - 56 });
by += 13;
if (org.address) { doc.font('Helvetica').fontSize(8).fillColor('#94a3b8').text(org.address, 28, by, { width: sidebarW - 56 }); by += 12 * Math.ceil(org.address.length / 28); }
if (org.email) { doc.font('Helvetica').fontSize(8).fillColor('#94a3b8').text(org.email, 28, by, { width: sidebarW - 56 }); by += 12; }
doc.font('Helvetica').fontSize(8).fillColor('#94a3b8').text(org.url.replace(/^https?:\/\//, ''), 28, by, { width: sidebarW - 56 });
// Main content
let y = 40;
doc.font('Helvetica-Bold').fontSize(9).fillColor(org.brandColor).text('EVENT', mainX, y);
y += 14;
doc.font('Helvetica-Bold').fontSize(14).fillColor('#0f172a').text(registration.event?.title || 'Event', mainX, y, { width: mainW });
y += 18;
if (registration.event?.startDate) { doc.font('Helvetica').fontSize(9).fillColor('#64748b').text(fmtDate(registration.event.startDate), mainX, y); y += 14; }
y += 16;
y = drawItemsTable(doc, { x: mainX, width: mainW, y, rows, brandColor: org.brandColor, headerLight: false });
y += 16;
const totalsX = mainX + mainW - 220;
const totalLine = (label, value, opts = {}) => {
doc.font(opts.bold ? 'Helvetica-Bold' : 'Helvetica').fontSize(opts.size || 10).fillColor(opts.color || '#374151')
.text(label, totalsX, y, { width: 120 });
doc.font(opts.bold ? 'Helvetica-Bold' : 'Helvetica').fontSize(opts.size || 10).fillColor(opts.color || '#374151')
.text(value, totalsX + 120, y, { width: 100, align: 'right' });
y += (opts.size || 10) + 10;
};
if (discount > 0.01 || coupon) {
totalLine('SUBTOTAL', fmtAmount(fullPriceTotal));
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;
}
totalLine('TOTAL DUE', fmtAmount(due), { bold: true, size: 13, color: org.brandColor });
if (paid > 0) totalLine('Already paid', `-${fmtAmount(paid)}`, { size: 9 });
if (paid > 0) totalLine('BALANCE DUE', fmtAmount(balance), { bold: true, size: 12, color: balance > 0 ? org.brandColor : '#059669' });
y += 20;
// Payment section
if (balance > 0.01) {
doc.font('Helvetica-Bold').fontSize(10).fillColor(org.brandColor).text('PAYMENT', mainX, y);
y += 16;
if (paymentUrl) {
const qrSize = 100;
try {
const qrBuffer = await QRCode.toBuffer(paymentUrl, { width: qrSize, margin: 1 });
doc.image(qrBuffer, mainX + mainW - qrSize, y, { width: qrSize, height: qrSize });
doc.font('Helvetica').fontSize(8).fillColor('#94a3b8').text('Scan to pay', mainX + mainW - qrSize, y + qrSize + 4, { width: qrSize, align: 'center' });
} catch {}
doc.font('Helvetica').fontSize(9).fillColor('#374151').text('Pay online — tap the link or scan the QR code:', mainX, y, { width: mainW - 120 });
doc.font('Helvetica-Bold').fontSize(10).fillColor('#2563eb').text(paymentUrl, mainX, y + 16, { width: mainW - 120, link: paymentUrl, underline: true });
y += 60;
} else {
doc.font('Helvetica').fontSize(9).fillColor('#374151').text(`Pay online at ${org.url} or at the door (cash/card).`, mainX, y, { width: mainW });
y += 24;
}
} else {
doc.font('Helvetica-Bold').fontSize(11).fillColor('#059669').text('PAID IN FULL', mainX, y);
y += 24;
}
// Footer
const footerH = 40;
const footerY = Math.max(pageHeight - footerH, y + 20);
doc.rect(sidebarW, footerY, pageWidth - sidebarW, footerH).fill(org.brandColor);
doc.font('Helvetica-Bold').fontSize(11).fillColor('#ffffff').text('Thank you for your support!', sidebarW, footerY + 13, { width: pageWidth - sidebarW, align: 'center' });
doc.end();
await new Promise((resolve, reject) => { writeStream.on('finish', resolve); writeStream.on('error', reject); });
return { filePath, filename };
}
module.exports = { generateReceiptPdf, generateInvoicePdf, buildLineItems, couponDiscountLine, getBranding, docNumber };
+30
View File
@@ -0,0 +1,30 @@
// Shared template placeholder substitution for bulk emails/WhatsApp messages and broadcasts.
// Async because {{payment.link}} needs to create a live Yoco checkout session per recipient —
// every other placeholder is a plain synchronous string replace.
async function replacePlaceholders(str, ctx = {}) {
if (!str) return str;
let out = String(str)
.replace(/\{\{\s*name\s*\}\}/g, ctx.name || '')
.replace(/\{\{\s*event\.title\s*\}\}/g, ctx.eventTitle || '')
.replace(/\{\{\s*event\.start\s*\}\}/g, ctx.eventStart || '')
.replace(/\{\{\s*event\.(link|url)\s*\}\}/g, (ctx.eventLinkHtml || ctx.eventLink || ''))
.replace(/\{\{\s*promo\.title\s*\}\}/g, ctx.promoTitle || '')
.replace(/\{\{\s*promo\.(link|url)\s*\}\}/g, (ctx.promoLinkHtml || ctx.promoLink || ''))
.replace(/\{\{\s*balance\s*\}\}/g, ctx.balanceFmt || '');
if (/\{\{\s*payment\.link\s*\}\}/.test(out) && typeof ctx.paymentLinkResolver === 'function') {
let link = '';
try {
link = (await ctx.paymentLinkResolver()) || '';
} catch (e) {
try { console.warn('[placeholders] payment.link resolution failed:', e?.message || e); } catch {}
}
out = out.replace(/\{\{\s*payment\.link\s*\}\}/g, link);
} else {
out = out.replace(/\{\{\s*payment\.link\s*\}\}/g, '');
}
return out;
}
module.exports = { replacePlaceholders };
+179 -46
View File
@@ -7,15 +7,47 @@
* Used at registration-creation time and again at payment-initiation time.
* - getEffectiveUnitPrice: deadline-only check; used for line-item display in Yoco checkout and
* as a fallback for legacy RegistrationOption rows that have no priceSnapshot.
* - computeRegistrationTotalDue: uses priceSnapshot when present (authoritative after
* refreshPricingForRegistration runs), otherwise falls back to getEffectiveUnitPrice.
* - refreshPricingForRegistration: re-runs resolveOptionPrice for every RegistrationOption
* that has an appliedTierId; updates priceSnapshot + appliedTierId in the DB if the tier
* is now expired or its stock is exhausted.
* - A RegistrationOption is an aggregate line (one per registration+option+variant); each
* purchase-at-a-price is its own RegistrationOptionTranche row underneath it (quantity +
* priceSnapshot + appliedTierId, never mutated after creation — mirrors the Payment model's
* append-only pattern). This is what lets someone buy more of the same ticket type after an
* early-bird tier expires without the new price bleeding onto tickets already bought.
* RegistrationOption.quantity/priceSnapshot/appliedTierId are kept as a maintained mirror
* (quantity = sum of tranche quantities; priceSnapshot/appliedTierId = most recent tranche)
* for code that only needs "how many" or a single display price.
* - 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
* never touched.
*/
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.
@@ -55,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 : [])
@@ -74,12 +111,15 @@ async function resolveOptionPrice(option, requestedQty = 1) {
// Skip expired tiers
if (now >= new Date(tier.deadline)) continue;
// Check stock limit if one is set
// Check stock limit if one is set. Sold-so-far is summed across tranches (not
// RegistrationOption rows directly) because one RegistrationOption can now span
// multiple tiers across its tranches — the row's own appliedTierId/quantity only
// reflects its most recent tranche.
if (tier.stockLimit > 0) {
const soldAgg = await prisma.registrationOption.aggregate({
const soldAgg = await prisma.registrationOptionTranche.aggregate({
where: {
appliedTierId: tier.id,
registration: { status: { not: 'cancelled' } }
registrationOption: { registration: { status: { not: 'cancelled' } } }
},
_sum: { quantity: true }
});
@@ -102,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)
@@ -125,8 +170,8 @@ async function resolveVariantTierPrice(option, variantId, requestedQty = 1) {
for (const tier of tiers) {
if (now >= new Date(tier.deadline)) continue;
if (tier.stockLimit > 0) {
const soldAgg = await prisma.registrationOption.aggregate({
where: { appliedTierId: tier.id, registration: { status: { not: 'cancelled' } } },
const soldAgg = await prisma.registrationOptionTranche.aggregate({
where: { appliedTierId: tier.id, registrationOption: { registration: { status: { not: 'cancelled' } } } },
_sum: { quantity: true }
});
const tierSold = soldAgg._sum?.quantity || 0;
@@ -154,58 +199,117 @@ async function refreshPricingForRegistration(registrationId) {
include: {
registrationOptions: {
include: {
eventOption: { include: { earlyBirdTiers: true, variants: true } }
eventOption: { include: { earlyBirdTiers: true, variants: true } },
tranches: true,
}
}
},
payments: true,
user: { select: { role: true } },
}
});
if (!registration) return { changed: false };
let anyChanged = false;
const totalPaid = (registration.payments || []).reduce((sum, p) => sum + (p.amount || 0), 0);
// For each option, only its single newest tranche is ever a repricing candidate — older
// tranches were already locked in at purchase time and must never be touched. A newest
// tranche is only touched once payments-so-far are established to not yet cover it (i.e.
// it's the still-unpaid remainder) — legacy rows with no tranches are left to the
// getEffectiveUnitPrice fallback in computeRegistrationTotalDue instead.
let registrationOlderTotal = 0;
const candidates = [];
for (const ro of registration.registrationOptions) {
// Only refresh options that were priced via a tier
if (!ro.appliedTierId) continue;
const tranches = ro.tranches || [];
if (tranches.length === 0) continue;
const sorted = [...tranches].sort((a, b) => new Date(a.createdAt) - new Date(b.createdAt));
const newest = sorted[sorted.length - 1];
const older = sorted.slice(0, -1);
registrationOlderTotal += older.reduce((s, t) => s + Number(t.quantity || 0) * Number(t.priceSnapshot || 0), 0);
candidates.push({ ro, newest });
}
// Payments don't even cover the already-locked-in older tranches yet — leave everything
// alone rather than guessing which portion is "paid for".
if (totalPaid < registrationOlderTotal - 0.001) return { changed: false };
// Each candidate is independent, so resolve/update them concurrently instead of one at a
// time — this loop sits directly in the payment-capture path.
const results = await Promise.all(candidates.map(async ({ ro, newest }) => {
// Only refresh tranches that were priced via a tier
if (!newest.appliedTierId) return false;
// Find the currently applied tier
const currentTier = (ro.eventOption.earlyBirdTiers || []).find(t => t.id === ro.appliedTierId);
const currentTier = (ro.eventOption.earlyBirdTiers || []).find(t => t.id === newest.appliedTierId);
if (currentTier && new Date() < new Date(currentTier.deadline)) {
// The tier's deadline is still in the future — honor the locked price.
continue;
return false;
}
// Deadline has passed (or tier record missing) — resolve the next applicable tier
const resolved = ro.variantId
? await resolveVariantTierPrice(ro.eventOption, ro.variantId, ro.quantity)
: await resolveOptionPrice(ro.eventOption, ro.quantity);
? await resolveVariantTierPrice(ro.eventOption, ro.variantId, newest.quantity, registration.user)
: await resolveOptionPrice(ro.eventOption, newest.quantity, registration.user);
const tierChanged = resolved.tierId !== ro.appliedTierId;
const priceChanged = ro.priceSnapshot !== null && Math.abs(resolved.price - ro.priceSnapshot) > 0.001;
const tierChanged = resolved.tierId !== newest.appliedTierId;
const priceChanged = Math.abs(resolved.price - newest.priceSnapshot) > 0.001;
if (tierChanged || priceChanged) {
await prisma.registrationOptionTranche.update({
where: { id: newest.id },
data: { priceSnapshot: resolved.price, appliedTierId: resolved.tierId }
});
// Mirror onto the aggregate row — it's this option's most recent tranche.
await prisma.registrationOption.update({
where: { id: ro.id },
data: {
priceSnapshot: resolved.price,
appliedTierId: resolved.tierId
}
data: { priceSnapshot: resolved.price, appliedTierId: resolved.tierId }
});
anyChanged = true;
return true;
}
return false;
}));
return { changed: results.some(Boolean) };
}
/**
* Compute the total for a single RegistrationOption line — sums quantity*priceSnapshot
* across its tranches (each priced at whatever was in effect when it was purchased).
* Falls back to the legacy single priceSnapshot/getEffectiveUnitPrice path for rows
* created before tranches existed.
*
* @param {object} ro - RegistrationOption, optionally with .tranches[] included
* @param {Date|null} lastPaymentAt - used only for the legacy getEffectiveUnitPrice fallback
* @param {Date} atTime - evaluation time (used only for the legacy fallback)
* @returns {number}
*/
function computeOptionLineTotal(ro, lastPaymentAt, atTime) {
if (Array.isArray(ro.tranches) && ro.tranches.length > 0) {
return ro.tranches.reduce((s, t) => s + Number(t.quantity || 0) * Number(t.priceSnapshot || 0), 0);
}
return { changed: anyChanged };
const qty = Number(ro.quantity || 0);
let unit;
if (ro.priceSnapshot !== null && ro.priceSnapshot !== undefined) {
// priceSnapshot is authoritative — set at registration creation and kept current
// by refreshPricingForRegistration at payment initiation time.
unit = ro.priceSnapshot;
} else {
// Fallback: legacy row without a snapshot — re-evaluate from tier deadlines
const eo = ro.eventOption || {};
unit = getEffectiveUnitPrice(eo, lastPaymentAt, atTime);
}
return qty * unit;
}
/**
* Compute total due for a registration at a given time.
*
* Uses priceSnapshot when present (authoritative — set at registration time and refreshed
* before payment via refreshPricingForRegistration). Falls back to getEffectiveUnitPrice
* for legacy rows without a snapshot.
* Sums computeOptionLineTotal() across each RegistrationOption (tranche-aware when
* `.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}
* @param {object} registration - includes registrationOptions[].{priceSnapshot, quantity, eventOption, tranches?}
* and optionally payments[]
* @param {Date} atTime - evaluation time (used for legacy fallback only)
* @returns {number}
@@ -221,22 +325,46 @@ function computeRegistrationTotalDue(registration, atTime) {
}
} catch {}
return registration.registrationOptions.reduce((sum, ro) => {
const qty = Number(ro.quantity || 0);
let unit;
const subtotal = computeRegistrationSubtotal(registration, lastPaymentAt, atTime);
return Math.max(0, subtotal - Number(registration.discountTotal || 0));
}
if (ro.priceSnapshot !== null && ro.priceSnapshot !== undefined) {
// priceSnapshot is authoritative — set at registration creation and kept current
// by refreshPricingForRegistration at payment initiation time.
unit = ro.priceSnapshot;
} else {
// Fallback: legacy row without a snapshot — re-evaluate from tier deadlines
const eo = ro.eventOption || {};
unit = getEffectiveUnitPrice(eo, lastPaymentAt, atTime);
/** 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);
}
/**
* Attach computed, tranche-aware display totals to a registration in place: `lineTotal` on
* each RegistrationOption and `totalDueComputed` on the registration itself. Lets API
* responses hand the frontend an exact total instead of every page re-deriving
* unitPrice*quantity client-side (which goes wrong once a line spans multiple tranches).
*
* @param {object} registration - requires registrationOptions[].tranches included
* @returns {object} the same registration, mutated
*/
function attachComputedTotals(registration) {
if (!registration) return registration;
let lastPaymentAt = null;
try {
if (registration.payments && Array.isArray(registration.payments) && registration.payments.length > 0) {
lastPaymentAt = new Date(Math.max(...registration.payments.map(p => new Date(p.createdAt).getTime())));
}
} catch {}
const now = new Date();
if (Array.isArray(registration.registrationOptions)) {
for (const ro of registration.registrationOptions) {
ro.lineTotal = computeOptionLineTotal(ro, lastPaymentAt, now);
}
}
registration.subtotalComputed = computeRegistrationSubtotal(registration, lastPaymentAt, now);
registration.totalDueComputed = computeRegistrationTotalDue(registration, now);
return registration;
}
return sum + qty * unit;
}, 0);
function attachComputedTotalsToList(registrations) {
return (registrations || []).map(attachComputedTotals);
}
module.exports = {
@@ -244,5 +372,10 @@ module.exports = {
resolveOptionPrice,
resolveVariantTierPrice,
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 };
+218
View File
@@ -0,0 +1,218 @@
/**
* Offline scanning — locks, snapshots and syncing.
*
* Rules:
* - Offline mode is per section (or the whole event), and only one device may hold a
* scope at a time: while offline, devices can't see each other's scans, so two
* devices on the same scope could redeem one ticket twice. A whole-event lock clashes
* with every section of that event, and a section lock with the whole event.
* - While a scope is locked, other devices can't scan it online either.
* - The lock holder downloads a snapshot of valid tickets; scans made offline are
* queued on the device and synced later. Syncing is idempotent (each scan carries a
* clientScanId) and never redeems more than is left on a ticket.
* - Anything that needs a person to look — unknown ticket, cancelled registration, used
* up, outside the locked scope, consent not given — goes into an OfflineSyncReport.
* - A supervisor can force-release a lock (lost phone). If that device syncs later its
* scans are still recorded, and the report is flagged.
*/
const prisma = require('../config/db');
function lockError(message, statusCode = 400) {
const err = new Error(message);
err.statusCode = statusCode;
return err;
}
// ─── Pure helpers ─────────────────────────────────────────────────────────────
/** Whether a new lock on `sectionId` (null = whole event) clashes with `existing`. */
function locksClash(existing, sectionId) {
if (!existing.sectionId || !sectionId) return true;
return existing.sectionId === sectionId;
}
/**
* Whether `lock` covers a scan of a ticket with option `optionId`. When the scanner has
* picked a section, only a lock on that section (or the whole event) covers it; with no
* section picked, any lock on a section that admits the ticket does.
* @param {object} lock - { sectionId, section?: { allowedOptions: [{ eventOptionId }] } }
*/
function lockCovers(lock, { sectionId = null, optionId }) {
if (!lock.sectionId) return true;
if (sectionId) return sectionId === lock.sectionId;
return (lock.section?.allowedOptions || []).some(o => o.eventOptionId === optionId);
}
/**
* What to do with one synced scan, given the ticket as the server sees it now. Pure.
* @param {object|null} ticket - { remaining, cancelled, inScope, consentOk }
* @param {number} qty - quantity the device redeemed
* @returns {{ redeem: number, conflict: string|null }}
*/
function judgeSyncedScan(ticket, qty) {
if (!ticket) return { redeem: 0, conflict: 'not_found' };
if (ticket.transferred) return { redeem: 0, conflict: 'transferred' };
if (ticket.cancelled) return { redeem: 0, conflict: 'cancelled' };
if (!ticket.inScope) return { redeem: 0, conflict: 'wrong_scope' };
if (ticket.remaining <= 0) return { redeem: 0, conflict: 'already_used' };
const redeem = Math.min(Math.max(1, qty || 1), ticket.remaining);
if (!ticket.consentOk) return { redeem, conflict: 'consent' };
return { redeem, conflict: redeem < (qty || 1) ? 'partly_used' : null };
}
const CONFLICT_TEXT = {
not_found: 'Ticket not found',
transferred: 'Old QR code of a ticket that was transferred to someone else',
cancelled: 'Registration was cancelled',
wrong_scope: 'Ticket is not valid for this section',
already_used: 'Ticket had already been fully used',
partly_used: 'Fewer places were left than were checked in offline',
consent: 'Checked in without parental consent',
};
// ─── Database helpers ─────────────────────────────────────────────────────────
const LOCK_INCLUDE = {
section: { include: { allowedOptions: true } },
user: { select: { id: true, name: true } },
};
async function activeLocks(eventId, db = prisma) {
return db.scanLock.findMany({ where: { eventId, releasedAt: null }, include: LOCK_INCLUDE, orderBy: { lockedAt: 'asc' } });
}
/** Locks a scope to this device. Re-locking by the same device and person returns the existing lock. */
async function acquireLock({ eventId, sectionId = null, deviceId, deviceName, userId }) {
if (!deviceId || typeof deviceId !== 'string' || deviceId.length > 100) throw lockError('A device id is required');
return prisma.$transaction(async (tx) => {
await tx.$executeRaw`SELECT pg_advisory_xact_lock(hashtext(${'scanlock:' + eventId}))`;
const event = await tx.event.findUnique({ where: { id: eventId } });
if (!event) throw lockError('Event not found', 404);
if (sectionId) {
const section = await tx.section.findUnique({ where: { id: sectionId } });
if (!section || section.eventId !== eventId) throw lockError('Section not found for this event', 404);
}
const existing = await activeLocks(eventId, tx);
const same = existing.find(l => (l.sectionId || null) === (sectionId || null) && l.deviceId === deviceId && l.userId === userId);
if (same) return same;
const clash = existing.find(l => locksClash(l, sectionId));
if (clash) {
const scope = clash.section ? `"${clash.section.name}"` : 'the whole event';
throw lockError(`${scope} is offline-locked by ${clash.user.name}${clash.deviceName ? ` on ${clash.deviceName}` : ''}.`, 409);
}
return tx.scanLock.create({ data: { eventId, sectionId: sectionId || null, deviceId, deviceName: deviceName ? String(deviceName).slice(0, 100) : null, userId }, include: LOCK_INCLUDE });
});
}
/**
* The lock (other than the scanner's own device) that stops this online scan, or null.
* @param {object} ticket - { eventId, optionId }
*/
async function blockingLock(ticket, { sectionId = null, deviceId = null } = {}) {
const locks = await activeLocks(ticket.eventId);
return locks.find(l => l.deviceId !== deviceId && lockCovers(l, { sectionId, optionId: ticket.optionId })) || null;
}
/** Tickets valid in a lock's scope, for the device to keep offline. */
async function snapshotFor(lock) {
const optionIds = lock.section ? lock.section.allowedOptions.map(o => o.eventOptionId) : null;
const tickets = await prisma.ticket.findMany({
where: {
eventId: lock.eventId,
registrationOption: { registration: { status: { not: 'cancelled' } }, ...(optionIds ? { eventOptionId: { in: optionIds } } : {}) },
},
include: {
usages: { select: { quantityRedeemed: true } },
attendee: { select: { name: true } },
user: { select: { name: true } },
registrationOption: { include: { eventOption: { select: { id: true, name: true, isMainTicket: true } }, variant: { select: { name: true } } } },
},
});
const { consentStatusForTickets } = require('./consent');
const consent = await consentStatusForTickets(tickets.map(t => t.id));
const snapshotAt = new Date();
await prisma.scanLock.update({ where: { id: lock.id }, data: { snapshotAt } });
return {
snapshotAt,
tickets: tickets.map(t => {
const redeemed = t.usages.reduce((s, u) => s + (u.quantityRedeemed || 1), 0);
return {
id: t.id,
qrCode: t.qrCode,
holder: t.attendee?.name || t.user?.name || '',
bookedBy: t.attendee ? t.user?.name || '' : '',
option: t.registrationOption.eventOption.name,
variant: t.registrationOption.variant?.name || null,
quantity: t.quantity || 1,
remaining: Math.max(0, (t.quantity || 1) - redeemed),
consentStatus: consent.get(t.id) || 'not_required',
};
}),
};
}
/**
* Records queued offline scans. Each scan: { clientScanId, qrCode, qty, scannedAt }.
* @returns {Promise<{ accepted, duplicates, conflicts, report }>}
*/
async function syncScans(lock, scans) {
const results = { accepted: 0, duplicates: 0, conflicts: [] };
const optionIds = lock.section ? new Set(lock.section.allowedOptions.map(o => o.eventOptionId)) : null;
const { consentStatusForTickets } = require('./consent');
const checkIns = [];
for (const scan of (scans || []).slice(0, 2000)) {
const clientScanId = String(scan?.clientScanId || '').slice(0, 100);
if (!clientScanId) continue;
if (await prisma.ticketUsage.findUnique({ where: { clientScanId } })) { results.duplicates += 1; continue; }
const qty = Math.max(1, parseInt(scan.qty, 10) || 1);
const scannedAt = scan.scannedAt && !isNaN(new Date(scan.scannedAt)) ? new Date(scan.scannedAt) : new Date();
const ticket = await prisma.ticket.findUnique({
where: { qrCode: String(scan.qrCode || '') },
include: { usages: true, registrationOption: { include: { registration: { select: { status: true } }, eventOption: { select: { isMainTicket: true } } } } },
});
let state = null;
if (!ticket) {
const { findTransferByOldCode } = require('./ticketTransfer');
if (await findTransferByOldCode(scan.qrCode)) state = { transferred: true };
} else if (ticket.eventId === lock.eventId) {
const redeemed = ticket.usages.reduce((s, u) => s + (u.quantityRedeemed || 1), 0);
const consent = (await consentStatusForTickets([ticket.id])).get(ticket.id);
state = {
remaining: (ticket.quantity || 1) - redeemed,
cancelled: ticket.registrationOption.registration.status === 'cancelled',
inScope: !optionIds || optionIds.has(ticket.registrationOption.eventOptionId),
consentOk: ['not_required', 'given'].includes(consent),
};
}
const verdict = judgeSyncedScan(state, qty);
if (verdict.redeem > 0) {
await prisma.ticketUsage.create({
data: { ticketId: ticket.id, scannedById: lock.userId, scannedAt, quantityRedeemed: verdict.redeem, clientScanId, offline: true, scanLockId: lock.id, syncedAt: new Date() },
});
await prisma.ticket.update({ where: { id: ticket.id }, data: { isUsed: state.remaining - verdict.redeem <= 0, updatedAt: new Date() } });
results.accepted += 1;
if (ticket.registrationOption.eventOption.isMainTicket) {
checkIns.push({ ticketId: ticket.id, qty: verdict.redeem, total: (ticket.quantity || 1) - state.remaining + verdict.redeem, left: state.remaining - verdict.redeem });
}
}
if (verdict.conflict) {
results.conflicts.push({ clientScanId, qrCode: scan.qrCode, ticketId: ticket?.id || null, qty, redeemed: verdict.redeem, scannedAt, reason: verdict.conflict, message: CONFLICT_TEXT[verdict.conflict] });
}
}
await prisma.scanLock.update({ where: { id: lock.id }, data: { lastSyncAt: new Date() } });
const report = await prisma.offlineSyncReport.create({
data: { scanLockId: lock.id, accepted: results.accepted, duplicates: results.duplicates, conflicts: results.conflicts, afterForceRelease: !!lock.forceReleasedById },
});
// Check-in confirmations go out after sync, not at the moment of the offline scan.
const { sendCheckInEmails } = require('./notifications');
for (const c of checkIns) sendCheckInEmails(c.ticketId, c.qty, c.total, c.left).catch(() => {});
return { ...results, report };
}
module.exports = { lockError, locksClash, lockCovers, judgeSyncedScan, CONFLICT_TEXT, LOCK_INCLUDE, activeLocks, acquireLock, blockingLock, snapshotFor, syncScans };
+19
View File
@@ -46,6 +46,8 @@ function addJob(job) {
id,
eventId: job.eventId || null,
broadcast: !!job.broadcast,
channel: job.channel || 'email',
recipientSummary: job.recipientSummary || null,
createdById: job.createdById || null,
scheduledAt: job.scheduledAt,
createdAt: now.toISOString(),
@@ -66,10 +68,26 @@ function listJobs(filter = {}) {
return jobs.filter(j => {
if (filter.status && j.status !== filter.status) return false;
if (filter.eventId && j.eventId !== filter.eventId) return false;
if (filter.channel && (j.channel || 'email') !== filter.channel) return false;
return true;
});
}
/**
* Permanently remove jobs that finished sending more than maxAgeMs ago.
* Only touches 'sent' jobs - queued/sending/error jobs are left for admins to review.
*/
function purgeSentJobs(maxAgeMs = 24 * 60 * 60 * 1000) {
const jobs = loadAll();
const now = Date.now();
const kept = jobs.filter(j => {
if (j.status !== 'sent' || !j.sentAt) return true;
return (now - new Date(j.sentAt).getTime()) <= maxAgeMs;
});
if (kept.length !== jobs.length) saveAll(kept);
return jobs.length - kept.length;
}
function getDueJobs(now = new Date()) {
const jobs = loadAll();
const t = now instanceof Date ? now : new Date(now);
@@ -105,4 +123,5 @@ module.exports = {
updateJob,
getJob,
deleteJob,
purgeSentJobs,
};
+34
View File
@@ -0,0 +1,34 @@
const prisma = require('../config/db');
const { describeUserAgent } = require('./userAgent');
// Fire-and-forget by design — a logging failure must never break login/password-change,
// so this swallows its own errors rather than propagating them to the caller (same
// posture as the existing email/WhatsApp notification sends elsewhere in this codebase).
async function logSecurityEvent({ userId, type, ip, userAgent }) {
try {
await prisma.securityEvent.create({
data: {
userId: userId || null,
type,
ip: ip || null,
device: describeUserAgent(userAgent),
},
});
} catch (e) {
console.error('Failed to log security event:', e?.message);
}
}
// Recent activity for a user's Profile & Security page — no raw IP in the response,
// just what the mockup shows (what happened, on what device, when).
async function getRecentSecurityEvents(userId, limit = 10) {
const rows = await prisma.securityEvent.findMany({
where: { userId },
orderBy: { createdAt: 'desc' },
take: limit,
select: { id: true, type: true, device: true, createdAt: true },
});
return rows;
}
module.exports = { logSecurityEvent, getRecentSecurityEvents };
+52
View File
@@ -0,0 +1,52 @@
/**
* Ticket stock — the one place that decides how many places are taken.
*
* Taken = places on registrations that aren't cancelled + places held for a live
* waitlist offer (until it expires). An option's stock limit covers all its variants;
* a variant can also have its own limit. 0 means unlimited.
*/
const prisma = require('../config/db');
/**
* @param {{ eventOptionId: string, variantId?: string|null }} line - variantId given → that variant only
* @param {{ excludeEntryId?: string, db?: object }} [opts] - excludeEntryId: ignore this
* waitlist offer's hold (the person claiming it)
*/
async function takenCount({ eventOptionId, variantId = null }, { excludeEntryId = null, db = prisma } = {}) {
const scope = variantId ? { variantId } : { eventOptionId };
const [registered, held] = await Promise.all([
db.registrationOption.aggregate({
where: { ...scope, registration: { status: { not: 'cancelled' } } },
_sum: { quantity: true },
}),
db.waitlistEntry.aggregate({
where: {
...scope,
status: 'offered',
offerExpiresAt: { gt: new Date() },
...(excludeEntryId ? { id: { not: excludeEntryId } } : {}),
},
_sum: { offeredQuantity: true },
}),
]);
return (registered._sum?.quantity || 0) + (held._sum?.offeredQuantity || 0);
}
/**
* Places still free on a line: the tighter of the option and variant limits.
* @returns {Promise<number|null>} null when unlimited
*/
async function remainingFor(option, variant, opts = {}) {
const limits = [];
if (option?.stockLimit > 0) {
limits.push(option.stockLimit - await takenCount({ eventOptionId: option.id }, opts));
}
if (variant?.stockLimit > 0) {
limits.push(variant.stockLimit - await takenCount({ eventOptionId: option.id, variantId: variant.id }, opts));
}
if (limits.length === 0) return null;
return Math.max(0, Math.min(...limits));
}
module.exports = { takenCount, remainingFor };
+140
View File
@@ -0,0 +1,140 @@
/**
* Ticket transfer — handing a ticket to someone else before the event.
*
* - The new holder becomes the ticket's attendee (name, contact details, account). The
* payer stays responsible for the registration; billing doesn't move.
* - The QR code is replaced, so screenshots of the old ticket stop working. The old code
* is kept on the TicketTransfer so scanners can say "transferred" instead of "not found".
* - A shared (unnamed) ticket for several places has one place split off into a new
* ticket for the new holder; the rest stay on the shared ticket and its code.
* - The previous holder's attendee form answers are removed, and on youth events the new
* holder needs their own parental consent.
*/
const { v4: uuidv4 } = require('uuid');
const prisma = require('../config/db');
const { validateAttendees } = require('./attendees');
const { isMinor } = require('./consent');
const SUPERVISORS = ['admin', 'supervisor'];
function transferError(message, statusCode = 400) {
const err = new Error(message);
err.statusCode = statusCode;
return err;
}
/**
* Why this ticket can't be transferred by `actor`, or null when it can. Pure.
* @param {object} t - { userId, holderUserId, usages[], event: { startDate, allowTicketTransfer }, registrationStatus }
* @param {object} actor - { id, role }
*/
function transferBlocker(t, actor, now = new Date()) {
const supervisor = SUPERVISORS.includes(actor.role);
if (!supervisor && t.userId !== actor.id && t.holderUserId !== actor.id) return { status: 403, message: 'Only the person who booked or holds this ticket can transfer it.' };
if (t.registrationStatus === 'cancelled') return { status: 400, message: 'This registration was cancelled.' };
if (new Date(t.event.startDate) <= now) return { status: 400, message: 'Tickets can only be transferred before the event starts.' };
if ((t.usages || []).length > 0) return { status: 400, message: 'This ticket has already been used (or partly used), so it can’t be transferred.' };
if (t.event.allowTicketTransfer === false && !supervisor) return { status: 400, message: 'Ticket transfers are turned off for this event.' };
return null;
}
/**
* Transfers a ticket. Returns what the caller needs to notify people afterwards.
* @param {object} input - { ticketId, actor, name, email?, phone?, dateOfBirth?, parentName?, parentEmail?, parentPhone? }
*/
async function transferTicket({ ticketId, actor, ...holder }) {
const { findOrCreateAccount } = require('./accountMatch');
const ticket = await prisma.ticket.findUnique({
where: { id: ticketId },
include: {
usages: { select: { id: true } },
attendee: true,
holder: { select: { id: true, name: true } },
user: { select: { id: true, name: true } },
event: { include: { eventOptions: true } },
registrationOption: { include: { registration: { select: { id: true, status: true } } } },
},
});
if (!ticket) throw transferError('Ticket not found', 404);
const blocker = transferBlocker({ ...ticket, registrationStatus: ticket.registrationOption.registration.status }, actor);
if (blocker) throw transferError(blocker.message, blocker.status);
const ro = ticket.registrationOption;
const event = ticket.event;
// Same rules as naming an attendee at registration (incl. date of birth and parent
// details on youth events), for exactly one place.
const [clean] = validateAttendees({ ...event, allowNamedAttendees: true }, { [`${ro.eventOptionId}::${ro.variantId || ''}`]: 1 }, [{
eventOptionId: ro.eventOptionId, variantId: ro.variantId, ...holder,
}]);
if (!clean.email && !clean.phone) throw transferError('Enter an email address or phone number for the new holder, so we can send them the ticket.');
const { user: toUser, created } = await findOrCreateAccount({ name: clean.name, email: clean.email, phone: clean.phone });
const currentHolderId = ticket.attendee ? ticket.attendee.userId || ticket.holderUserId : ticket.holderUserId || ticket.userId;
if (currentHolderId && currentHolderId === toUser.id) throw transferError(`${clean.name} already holds this ticket.`);
const fromHolderName = ticket.attendee?.name || ticket.holder?.name || ticket.user?.name || '';
const attendeeData = {
name: clean.name, email: clean.email, phone: clean.phone, userId: toUser.id,
dateOfBirth: clean.dateOfBirth, parentName: clean.parentName, parentEmail: clean.parentEmail, parentPhone: clean.parentPhone,
};
const result = await prisma.$transaction(async (tx) => {
// Re-check inside the transaction: a scan or another transfer may have just happened.
const fresh = await tx.ticket.findUnique({ where: { id: ticket.id }, include: { usages: { select: { id: true } } } });
if (!fresh || fresh.qrCode !== ticket.qrCode || fresh.usages.length > 0) throw transferError('This ticket changed while you were transferring it. Please try again.', 409);
let target, attendee, oldQrCode;
if (ticket.attendeeId) {
attendee = await tx.attendee.update({ where: { id: ticket.attendeeId }, data: attendeeData });
// The previous holder's answers and medical details aren't the new holder's.
await tx.formResponse.deleteMany({ where: { attendeeId: attendee.id } });
await tx.consentRequest.updateMany({ where: { attendeeId: attendee.id }, data: { medical: null, emergency: null, medicalPurgedAt: new Date() } });
target = await tx.ticket.update({ where: { id: ticket.id }, data: { qrCode: uuidv4(), holderUserId: toUser.id, emailSent: false } });
oldQrCode = ticket.qrCode;
} else {
const order = await tx.attendee.count({ where: { registrationId: ro.registration.id } });
attendee = await tx.attendee.create({
data: { registrationId: ro.registration.id, registrationOptionId: ro.id, eventOptionId: ro.eventOptionId, variantId: ro.variantId, order, ...attendeeData },
});
if ((ticket.quantity || 1) > 1) {
// Split one place off the shared ticket; its code stays valid for the rest.
await tx.ticket.update({ where: { id: ticket.id }, data: { quantity: ticket.quantity - 1 } });
target = await tx.ticket.create({
data: { qrCode: uuidv4(), registrationOptionId: ro.id, userId: ticket.userId, eventId: ticket.eventId, quantity: 1, attendeeId: attendee.id, holderUserId: toUser.id },
});
oldQrCode = null;
} else {
target = await tx.ticket.update({ where: { id: ticket.id }, data: { qrCode: uuidv4(), attendeeId: attendee.id, holderUserId: toUser.id, emailSent: false } });
oldQrCode = ticket.qrCode;
}
}
const transfer = await tx.ticketTransfer.create({
data: { ticketId: target.id, fromHolderName, fromUserId: currentHolderId || null, toName: clean.name, toUserId: toUser.id, byUserId: actor.id, oldQrCode },
});
return { target, attendee, transfer };
});
return {
...result,
toUser,
accountCreated: created,
fromHolderName,
event,
registrationId: ro.registration.id,
needsConsent: !!event.requiresParentalConsent && isMinor(clean.dateOfBirth, event.startDate),
};
}
/** The transfer that retired this QR code, if any. */
async function findTransferByOldCode(qrCode) {
if (!qrCode) return null;
return prisma.ticketTransfer.findFirst({ where: { oldQrCode: String(qrCode) }, orderBy: { createdAt: 'desc' } });
}
function transferredMessage(transfer) {
const when = new Date(transfer.createdAt).toLocaleDateString('en-ZA', { day: 'numeric', month: 'short' });
return `This ticket was transferred to ${transfer.toName} on ${when}. The old QR code no longer works — ask for the new ticket.`;
}
module.exports = { transferError, transferBlocker, transferTicket, findTransferByOldCode, transferredMessage };
+51 -60
View File
@@ -1,11 +1,14 @@
const prisma = require('../config/db');
const { v4: uuidv4 } = require('uuid');
const { planLineTickets } = require('./attendees');
/**
* Generate tickets for a registration when payment status is "paid".
*
* Rules:
* - Exactly ONE ticket per registrationOption (keyed by eventOptionId).
* - Each named attendee (group tickets) gets their own quantity-1 ticket; the rest of a
* line's places share ONE quantity ticket per registrationOption (see
* utils/attendees.js planLineTickets).
* - If duplicate registrationOptions exist for the same eventOptionId (old data),
* consolidate them: merge quantities, keep the one with scan history, delete the rest.
* - If duplicate ticket records exist for the same registrationOption (old data),
@@ -54,88 +57,76 @@ const generateTicketsForRegistration = async (registrationId) => {
byOption.get(key).push(ro);
}
// For each group with duplicates, merge into the one that has tickets (or the first)
for (const [, group] of byOption) {
if (group.length <= 1) continue;
// For each group with duplicates, merge into the one that has tickets (or the first).
// Different (eventOptionId, variantId) groups touch disjoint rows, so process groups
// concurrently instead of one at a time.
await Promise.all(Array.from(byOption.values()).map(async (group) => {
if (group.length <= 1) return;
// Prefer the option that already has tickets
const withTickets = group.filter(ro => (ro.tickets || []).length > 0);
const primary = withTickets.length > 0 ? withTickets[0] : group[0];
const duplicates = group.filter(ro => ro.id !== primary.id);
// Move all tickets from duplicates to primary, then delete duplicate options
for (const dup of duplicates) {
for (const t of (dup.tickets || [])) {
await prisma.ticket.update({ where: { id: t.id }, data: { registrationOptionId: primary.id, updatedAt: new Date() } });
}
const totalMergedQty = duplicates.reduce((s, d) => s + (d.quantity || 0), 0);
await prisma.registrationOption.update({
where: { id: primary.id },
data: { quantity: (primary.quantity || 0) + totalMergedQty, }
});
await prisma.registrationOption.delete({ where: { id: dup.id } });
}
// Move all tickets from duplicates to primary (independent rows — safe to parallelize)
await Promise.all(duplicates.map(dup => Promise.all(
(dup.tickets || []).map(t =>
prisma.ticket.update({ where: { id: t.id }, data: { registrationOptionId: primary.id, updatedAt: new Date() } })
)
)));
// Re-load the primary's current quantity after merge
// Single write of the merged quantity, then delete the now-empty duplicate options
const totalMergedQty = duplicates.reduce((s, d) => s + (d.quantity || 0), 0);
await prisma.registrationOption.update({
where: { id: primary.id },
data: { quantity: (primary.quantity || 0) + totalMergedQty }
});
await Promise.all(duplicates.map(dup => prisma.registrationOption.delete({ where: { id: dup.id } })));
// Re-load the primary's current quantity + tickets after merge
const updated = await prisma.registrationOption.findUnique({ where: { id: primary.id } });
primary.quantity = updated?.quantity ?? primary.quantity;
// Reload tickets
primary.tickets = await prisma.ticket.findMany({
where: { registrationOptionId: primary.id },
include: { usages: true },
orderBy: { createdAt: 'asc' }
});
}
// ── Step 2: For each unique option, ensure exactly one ticket with correct qty ──
const generatedTickets = [];
}));
// ── Step 2: For each unique option, sync its tickets: one per named attendee, plus
// one shared quantity ticket for any unnamed places ──
// Re-read fresh list (some options may have been deleted above)
const freshOptions = await prisma.registrationOption.findMany({
where: { registrationId },
include: {
tickets: { include: { usages: true }, orderBy: { createdAt: 'asc' } }
tickets: { include: { usages: true }, orderBy: { createdAt: 'asc' } },
attendees: { orderBy: { order: 'asc' } },
}
});
for (const option of freshOptions) {
const targetQty = option.quantity || 1;
const existingTickets = option.tickets || [];
// Each option owns disjoint tickets, so resolve them concurrently instead of one at a time.
const perOptionResults = await Promise.all(freshOptions.map(async (option) => {
const plan = planLineTickets({ quantity: option.quantity || 1 }, option.attendees || [], option.tickets || []);
await Promise.all([
plan.remove.length ? prisma.ticket.deleteMany({ where: { id: { in: plan.remove } } }) : null,
...plan.update.map(u => prisma.ticket.update({ where: { id: u.id }, data: { ...u.data, updatedAt: new Date() } })),
]);
return Promise.all(plan.create.map(c => prisma.ticket.create({
data: {
id: uuidv4(),
qrCode: uuidv4(),
registrationOptionId: option.id,
userId: registration.userId,
eventId: registration.eventId,
quantity: c.quantity,
attendeeId: c.attendeeId,
holderUserId: c.holderUserId,
updatedAt: new Date()
}
})));
}));
if (existingTickets.length === 0) {
// Create one ticket
const ticket = await prisma.ticket.create({
data: {
id: uuidv4(),
qrCode: uuidv4(),
registrationOptionId: option.id,
userId: registration.userId,
eventId: registration.eventId,
quantity: targetQty,
updatedAt: new Date()
}
});
generatedTickets.push(ticket);
continue;
}
// Pick primary: prefer scanned, otherwise oldest
const withUsages = existingTickets.filter(t => (t.usages || []).length > 0);
const primary = withUsages.length > 0 ? withUsages[0] : existingTickets[0];
// Update quantity on primary if needed
if (primary.quantity !== targetQty) {
await prisma.ticket.update({ where: { id: primary.id }, data: { quantity: targetQty, updatedAt: new Date() } });
}
// Delete unscanned duplicates
const dups = existingTickets.filter(t => t.id !== primary.id && (t.usages || []).length === 0);
if (dups.length > 0) {
await prisma.ticket.deleteMany({ where: { id: { in: dups.map(t => t.id) } } });
}
}
return generatedTickets;
return perOptionResults.flat();
} catch (error) {
console.error('Error generating tickets:', error);
throw error;
+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,
};
+36
View File
@@ -0,0 +1,36 @@
// Lightweight User-Agent -> "Browser on OS" summary for account-activity logging.
// No UA-parsing library is installed in this project; this covers the common cases
// (desktop/mobile browsers, major OSes) without pulling one in for a single display string.
function detectBrowser(ua) {
if (/Edg\//.test(ua)) return 'Edge';
if (/OPR\//.test(ua) || /Opera/.test(ua)) return 'Opera';
if (/SamsungBrowser/.test(ua)) return 'Samsung Internet';
if (/CriOS/.test(ua)) return 'Chrome';
if (/FxiOS/.test(ua)) return 'Firefox';
if (/Firefox\//.test(ua)) return 'Firefox';
if (/Chrome\//.test(ua)) return 'Chrome';
if (/Safari\//.test(ua) && /Version\//.test(ua)) return 'Safari';
return null;
}
function detectOS(ua) {
if (/Windows/.test(ua)) return 'Windows';
if (/iPhone|iPad|iPod/.test(ua)) return 'iOS';
if (/Mac OS X/.test(ua)) return 'macOS';
if (/Android/.test(ua)) return 'Android';
if (/Linux/.test(ua)) return 'Linux';
return null;
}
function describeUserAgent(uaString) {
if (!uaString) return 'Unknown device';
const browser = detectBrowser(uaString);
const os = detectOS(uaString);
if (browser && os) return `${browser} on ${os}`;
if (browser) return browser;
if (os) return os;
return 'Unknown device';
}
module.exports = { describeUserAgent };
+82 -4
View File
@@ -23,7 +23,7 @@ const { getSettingSync } = require('./settingsCache');
function getOrg() {
return {
name: getSettingSync('org_name', process.env.ORG_NAME || 'Hope Events'),
name: getSettingSync('org_name', process.env.ORG_NAME || 'Cross Code'),
email: process.env.EMAIL_FROM || process.env.EMAIL_USER || '',
url: (process.env.FRONTEND_URL || process.env.APP_BASE_URL || 'http://localhost:3001').replace(/\/$/, ''),
};
@@ -46,9 +46,13 @@ function buildWARegistration(reg, { isNew = true, balance, totalDue, totalPaid }
? `You're registered for *${eventTitle}*${eventDate ? ` on ${eventDate}` : ''}.`
: `Your registration for *${eventTitle}* has been updated.`;
const items = (reg.registrationOptions || [])
.map(ro => `- ${ro.eventOption?.name || 'Option'} ×${ro.quantity} — ${fmtAmount((ro.eventOption?.price || 0) * ro.quantity)}`)
.join('\n');
const { computeOptionLineTotal } = require('./pricing');
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;
@@ -107,6 +111,26 @@ function buildWAPayment(payment) {
].join('\n');
}
// ─── Check-in confirmation (Main Tickets, door check-in) ───────────────────────
function buildWACheckIn(ticket, qtyRedeemed, totalRedeemed, remaining) {
const org = getOrg();
const eventTitle = ticket.event?.title || 'the event';
const name = ticket.user?.name || 'there';
const fullyCheckedIn = remaining <= 0;
return [
`✅ *Checked In*`,
'',
`Hi ${name},`,
'',
`*${qtyRedeemed}* checked in for *${eventTitle}*.`,
`*${totalRedeemed} of ${ticket.quantity || 1}* checked in${fullyCheckedIn ? '' : `, *${remaining}* remaining`}.`,
'',
`_${org.name}_ | ${org.url}`,
].join('\n');
}
// ─── Login notification ───────────────────────────────────────────────────────
function buildWALogin({ name, when, location, userAgent }) {
@@ -128,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 }) {
@@ -246,13 +289,48 @@ function buildWADonationAppliedToRegistrant(payment) {
].join('\n');
}
// Mirrors buildWADonationAppliedToRegistrant for the reverse action — payment.createdAt here is
// the leg's original creation time, not "now"; use the current date for the balance display.
function buildWADonationUnassignedFromRegistrant(payment) {
const org = getOrg();
const reg = payment.registration;
const eventTitle = reg?.event?.title || 'the event';
const name = reg?.user?.name || 'there';
const amount = fmtAmount(payment.amount);
let balLine = '';
if (reg) {
const { computeRegistrationTotalDue } = require('./pricing');
const totalDue = computeRegistrationTotalDue(reg, new Date());
const totalPaid = (reg.payments || []).reduce((s, p) => s + (p.amount || 0), 0);
const balance = Math.max(totalDue - totalPaid, 0);
balLine = balance > 0
? `\n*Balance now owing:* ${fmtAmount(balance)}\n_Please arrange payment at ${org.url} or at the door._`
: '';
}
return [
`⚠️ *Donation Removed*`,
'',
`Hi ${name},`,
'',
`A donation of *${amount}* previously applied to your registration for *${eventTitle}* has been removed by our team.`,
balLine,
'',
`_${org.name}_ | ${org.url}`,
].join('\n');
}
module.exports = {
buildWARegistration,
buildWAPayment,
buildWARefund,
buildWADonationAppliedToRegistrant,
buildWADonationUnassignedFromRegistrant,
buildWALogin,
buildWATwoFactorCode,
buildWAWelcome,
buildWAAccountClosed,
buildWATicketCaption,
buildWACheckIn,
};
+201
View File
@@ -0,0 +1,201 @@
/**
* Waitlist for sold-out ticket options.
*
* Rules:
* - Only on events with waitlistEnabled, and only for a line that's actually sold out.
* - When places free up (a cancellation, a smaller booking, a raised stock limit, or an
* offer expiring), processWaitlist offers them to the next people in line. An offer
* holds the places for event.waitlistOfferHours — they count as taken (utils/stock.js)
* so the public site can't sell them — and sends a claim link by email/WhatsApp.
* - If fewer places are free than someone asked for: with waitlistPartialOffers they're
* offered what's free; otherwise they're skipped for the next person who fits.
* - Claiming = registering with the link (createRegistration's waitlistToken). Early-bird
* and coupon rules apply as usual. An unclaimed offer expires and moves on.
*/
const crypto = require('crypto');
const prisma = require('../config/db');
const { remainingFor } = require('./stock');
const ACTIVE = ['waiting', 'offered'];
function waitlistError(message, statusCode = 400) {
const err = new Error(message);
err.statusCode = statusCode;
return err;
}
const hashToken = (token) => crypto.createHmac('sha256', process.env.JWT_SECRET || '').update(`waitlist:${token}`).digest('hex');
function claimLink(token) {
const base = (process.env.FRONTEND_URL || process.env.APP_BASE_URL || 'http://localhost:3001').split(',')[0].trim().replace(/\/$/, '');
return `${base}/waitlist/claim/${token}`;
}
/**
* Who gets offered what, given the free places on a line. Pure.
* @param {Array} waiting - entries in queue order [{ id, quantity }]
* @param {number} free - places free
* @param {boolean} partial - offer what's free when it's less than asked for
* @returns {Array<{ entryId: string, quantity: number }>}
*/
function pickOffers(waiting, free, partial) {
const offers = [];
let left = free;
for (const entry of waiting) {
if (left <= 0) break;
if (entry.quantity <= left) {
offers.push({ entryId: entry.id, quantity: entry.quantity });
left -= entry.quantity;
} else if (partial) {
// First in line gets what's free; nobody behind them jumps the queue.
offers.push({ entryId: entry.id, quantity: left });
left = 0;
}
// else: skip to the next person who fits
}
return offers;
}
async function notifyOffer(entry, token) {
const { sendMail, emailWrapper, ctaButton, fallbackLink } = require('./email');
const { shouldEmail, waText, waTextAny } = require('./notify');
const ff = `-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif`;
const user = entry.user;
const link = claimLink(token);
const item = `${entry.eventOption.name}${entry.variant ? ` (${entry.variant.name})` : ''}`;
const until = new Date(entry.offerExpiresAt).toLocaleString('en-GB', { day: 'numeric', month: 'long', hour: '2-digit', minute: '2-digit' });
const partialNote = entry.offeredQuantity < entry.quantity
? ` You asked for ${entry.quantity}; ${entry.offeredQuantity} came free, and you can take those now.` : '';
const line = `${entry.offeredQuantity} × ${item} for ${entry.event.title} ${entry.offeredQuantity === 1 ? 'is' : 'are'} now available for you.${partialNote} We'll hold ${entry.offeredQuantity === 1 ? 'it' : 'them'} until ${until}.`;
const hasEmail = user?.email && !user.email.endsWith('@guest.local') && !user.email.endsWith('@deleted.invalid');
const sends = [];
if (hasEmail && shouldEmail(user)) {
const html = emailWrapper(`
<p style="font-size:22px;font-weight:800;color:#0f172a;margin:0 0 8px 0">A place has opened up!</p>
<p style="margin:0 0 8px 0;color:#374151;font-family:${ff}">Hi <strong>${user.name}</strong>,</p>
<p style="margin:0 0 24px 0;color:#374151;font-family:${ff}">${line}</p>
${ctaButton('Claim my place', link)}
${fallbackLink(link)}
`, { preheader: `Your waitlist place for ${entry.event.title}` });
sends.push(sendMail({ to: user.email, subject: `A place is available: ${entry.event.title}`, html, text: `Hi ${user.name},\n\n${line}\n\nClaim it here: ${link}` }));
}
const wa = `🎟️ Hi ${user?.name || 'there'}, ${line} Claim it here: ${link}`;
sends.push(hasEmail ? waText(user, wa) : waTextAny(user, wa));
await Promise.allSettled(sends);
}
const OFFER_INCLUDE = { event: true, eventOption: true, variant: true, user: { select: { id: true, name: true, email: true, phoneNumber: true, notificationPreference: true } } };
/** Puts an entry on offer (inside the caller's transaction); returns the raw token. */
async function writeOffer(db, entry, quantity, hours) {
const token = crypto.randomBytes(24).toString('hex');
await db.waitlistEntry.update({
where: { id: entry.id },
data: {
status: 'offered',
offeredQuantity: quantity,
offerTokenHash: hashToken(token),
offeredAt: new Date(),
offerExpiresAt: new Date(Date.now() + (hours || 24) * 60 * 60 * 1000),
},
});
return token;
}
async function notifyOffers(offers) {
for (const { entryId, token } of offers) {
const entry = await prisma.waitlistEntry.findUnique({ where: { id: entryId }, include: OFFER_INCLUDE });
if (entry) notifyOffer(entry, token).catch(e => console.warn('[waitlist] notify failed:', e?.message || e));
}
}
/** Offers `quantity` places to one entry (e.g. a supervisor's manual offer) and notifies. */
async function makeOffer(entryId, quantity) {
const entry = await prisma.waitlistEntry.findUnique({ where: { id: entryId }, include: { event: true } });
const token = await writeOffer(prisma, entry, quantity, entry.event.waitlistOfferHours);
await notifyOffers([{ entryId, token }]);
return prisma.waitlistEntry.findUnique({ where: { id: entryId } });
}
/**
* Offers any free places on an event to the people waiting for them. Safe to call any
* time places might have freed up; runs one event at a time under an advisory lock.
* @returns {Promise<number>} offers made
*/
async function processWaitlist(eventId) {
const event = await prisma.event.findUnique({
where: { id: eventId },
include: { eventOptions: { include: { variants: true } } },
});
if (!event || !event.waitlistEnabled || !event.isActive || event.cashupStatus === 'closed') return 0;
if (new Date(event.endDate) < new Date()) return 0;
const made = [];
await prisma.$transaction(async (tx) => {
await tx.$executeRaw`SELECT pg_advisory_xact_lock(hashtext(${'waitlist:' + eventId}))`;
const waiting = await tx.waitlistEntry.findMany({ where: { eventId, status: 'waiting' }, orderBy: { position: 'asc' } });
const lines = [...new Map(waiting.map(w => [`${w.eventOptionId}::${w.variantId || ''}`, w])).values()];
for (const sample of lines) {
const option = event.eventOptions.find(o => o.id === sample.eventOptionId);
const variant = sample.variantId ? (option?.variants || []).find(v => v.id === sample.variantId) : null;
if (!option) continue;
const free = await remainingFor(option, variant, { db: tx });
if (free === null || free <= 0) continue;
const queue = waiting.filter(w => w.eventOptionId === sample.eventOptionId && (w.variantId || null) === (sample.variantId || null));
for (const offer of pickOffers(queue, free, event.waitlistPartialOffers)) {
const token = await writeOffer(tx, { id: offer.entryId }, offer.quantity, event.waitlistOfferHours);
made.push({ entryId: offer.entryId, token });
}
}
}, { timeout: 20000 });
await notifyOffers(made);
return made.length;
}
/** Marks lapsed offers as expired. @returns event ids that now have places to re-offer. */
async function expireOffers(now = new Date()) {
const lapsed = await prisma.waitlistEntry.findMany({ where: { status: 'offered', offerExpiresAt: { lte: now } }, select: { id: true, eventId: true } });
if (lapsed.length === 0) return [];
await prisma.waitlistEntry.updateMany({ where: { id: { in: lapsed.map(l => l.id) } }, data: { status: 'expired', offerTokenHash: null } });
return [...new Set(lapsed.map(l => l.eventId))];
}
/** Background job: expire lapsed offers, then re-offer free places everywhere waiting. */
async function runWaitlistJob() {
const expiredEvents = await expireOffers();
const waitingEvents = await prisma.waitlistEntry.findMany({ where: { status: 'waiting' }, distinct: ['eventId'], select: { eventId: true } });
const ids = new Set([...expiredEvents, ...waitingEvents.map(w => w.eventId)]);
let offers = 0;
for (const id of ids) offers += await processWaitlist(id).catch(() => 0);
return { expired: expiredEvents.length, offers };
}
/** Fire-and-forget re-offer after something freed places on an event. */
function queueWaitlist(eventId) {
if (!eventId) return;
processWaitlist(eventId).catch(e => console.warn('[waitlist] process failed:', e?.message || e));
}
/** A live offer from its claim token, or null. */
async function findOfferByToken(token) {
if (!token || typeof token !== 'string' || token.length > 100) return null;
return prisma.waitlistEntry.findUnique({
where: { offerTokenHash: hashToken(token) },
include: { event: true, eventOption: true, variant: true, user: { select: { id: true, name: true } } },
});
}
module.exports = {
ACTIVE,
waitlistError,
hashToken,
pickOffers,
makeOffer,
processWaitlist,
expireOffers,
runWaitlistJob,
queueWaitlist,
findOfferByToken,
};

Some files were not shown because too many files have changed in this diff Show More