Compare commits

..
Author SHA1 Message Date
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
82 changed files with 12244 additions and 3285 deletions
+2
View File
@@ -34,6 +34,8 @@ Thumbs.db
# misc scratch / generated files
temp/
backend/public/uploads/
backend/backups/
hope-events-deployment-setup.txt
# runtime data stores (mutated by the running app, not source)
backend/data/scheduled-emails.json
+90
View File
@@ -7,6 +7,96 @@ and this project follows [Semantic Versioning](https://semver.org/).
## [Unreleased]
## [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
+6
View File
@@ -74,6 +74,12 @@ or Branding tab has been saved even once — the wizard always writes
|---|---|---|
| `WAWP_ACCESS_TOKEN` / `WAWP_INSTANCE_ID` | `WAWP_ACCESS_TOKEN`/`WAWP_INSTANCE_ID` env vars, else *(blank)* | No hardcoded default — WhatsApp sending is simply unavailable until both are configured (Admin → Site Settings → WhatsApp). |
## Backups
| Setting | Default when unset | Notes |
|---|---|---|
| `backup_retain_count` | `14` | How many nightly backups to keep on disk (oldest deleted beyond this count). Editable at Admin → Site Settings → Backups. See `BACKUP_ENABLED` (env var) to turn the nightly job off entirely. |
## Setup state
| Setting | Default when unset | Notes |
+15
View File
@@ -8,6 +8,13 @@ JWT_SECRET=your_jwt_secret_here_minimum_32_characters
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 +47,14 @@ WAWP_INSTANCE_ID=your_wawp_instance_id
DAILY_SUMMARY_ENABLED=true
SCHEDULED_EMAILS_ENABLED=true
SCHEDULED_EMAILS_INTERVAL_MS=30000
# Nightly database backup at 02:00 (requires the `pg_dump` binary on this host).
# Retention count is set via Admin → Site Settings → Backups (default 14).
BACKUP_ENABLED=true
# ─── Error monitoring (Sentry) — optional ─────────────────────────────────────
# Leave unset to disable entirely (a no-op, not an error). Set NEXT_PUBLIC_SENTRY_DSN
# in frontend/.env too if you want frontend errors captured.
# SENTRY_DSN=https://xxxxx@oxxxxxx.ingest.sentry.io/xxxxx
# ─── Note ─────────────────────────────────────────────────────────────────────
# The following are managed via Admin → Site Settings and stored in the database:
+1
View File
@@ -84,6 +84,7 @@ Create `backend/.env` from `.env.example`. The only variables you must set are:
| `APP_BASE_URL` | — | Public frontend URL — used in email links (fallback if not set via admin panel) |
| `BACKEND_URL` | — | Public backend URL — used to serve ticket PDFs over WhatsApp |
| `PORT` | — | Port to listen on (default `3000`) |
| `TRUST_PROXY` | — | Set when running behind a reverse proxy (e.g. nginx on a separate server), so `req.ip`/`X-Forwarded-For` are read correctly by rate limiting. Accepts a hop count (`1`), `true`/`false`, or comma-separated trusted proxy IP(s)/CIDR(s). |
| `NODE_ENV` | — | `production` or `development` |
| `WAWP_ACCESS_TOKEN` | — | WAWP fallback token (preferred: set via Admin → Site Settings) |
| `WAWP_INSTANCE_ID` | — | WAWP fallback instance ID (preferred: set via Admin → Site Settings) |
+4
View File
@@ -0,0 +1,4 @@
module.exports = {
testEnvironment: 'node',
testMatch: ['**/tests/**/*.test.js'],
};
+4833 -132
View File
File diff suppressed because it is too large Load Diff
+13 -6
View File
@@ -1,12 +1,12 @@
{
"name": "event-management-backend",
"version": "1.7.0",
"version": "1.10.2",
"description": "Event Management System Backend",
"main": "src/index.js",
"scripts": {
"start": "node src/index.js",
"dev": "nodemon src/index.js",
"test": "echo \"Error: no test specified\" && exit 1",
"test": "jest",
"postinstall": "prisma generate",
"prisma:generate": "prisma generate",
"prisma:deploy": "prisma migrate deploy && prisma generate",
@@ -17,23 +17,30 @@
},
"dependencies": {
"@prisma/client": "^5.4.2",
"@sentry/node": "^10.71.0",
"axios": "^1.11.0",
"bcryptjs": "^2.4.3",
"cors": "^2.8.5",
"dotenv": "^16.3.1",
"exceljs": "^4.4.0",
"express": "^4.18.2",
"jsonwebtoken": "^9.0.2",
"multer": "^2.0.2",
"express-rate-limit": "^8.6.2",
"ics": "^3.12.0",
"jsonwebtoken": "^9.0.3",
"multer": "^2.2.0",
"node-fetch": "^2.7.0",
"nodemailer": "^7.0.5",
"nodemailer": "^9.0.6",
"pdfkit": "^0.17.1",
"qrcode": "^1.5.4",
"raw-body": "^3.0.0",
"uuid": "^9.0.1"
"uuid": "^11.1.1"
},
"devDependencies": {
"jest": "^30.4.2",
"nodemon": "^3.0.1",
"prisma": "^5.4.2"
},
"overrides": {
"uuid": "^11.1.1"
}
}
@@ -0,0 +1,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;
+62
View File
@@ -86,6 +86,7 @@ model User {
personCashCountsEntered EventCashupPersonCount[] @relation("EventCashupPersonCountEnteredBy")
securityEvents SecurityEvent[]
adminAuditLogs AdminAuditLog[] @relation("AdminAuditActor")
}
model Event {
@@ -101,6 +102,11 @@ model Event {
isActive Boolean @default(true)
isHidden Boolean @default(false)
requiresAuth Boolean @default(true)
requiresRegistration Boolean @default(true)
contactName String?
contactPhone String?
contactEmail String?
location String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
createdById String?
@@ -163,6 +169,7 @@ model EarlyBirdTier {
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])
@@ -217,6 +224,7 @@ model RegistrationOption {
variant OptionVariant? @relation(fields: [variantId], references: [id], onDelete: SetNull)
appliedTier EarlyBirdTier? @relation(fields: [appliedTierId], references: [id], onDelete: SetNull)
tickets Ticket[]
tranches RegistrationOptionTranche[]
@@index([registrationId])
@@index([eventOptionId])
@@ -224,6 +232,27 @@ 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
@@ -316,6 +345,39 @@ model SecurityEvent {
@@index([userId, createdAt])
}
enum AdminAuditAction {
refund_created
donation_assigned
donation_unassigned
registration_created_manual
registration_cancelled
event_created
event_updated
event_deleted
settings_updated
}
// Append-only audit trail for admin/supervisor-initiated actions with money or
// data-integrity impact — separate from SecurityEvent (user-account-security-specific,
// fixed enum). Nullable FK with SetNull mirrors SecurityEvent's pattern so entries
// survive account close/anonymization.
model AdminAuditLog {
id String @id @default(uuid())
actorId String?
actor User? @relation("AdminAuditActor", fields: [actorId], references: [id], onDelete: SetNull)
actorRole String
action AdminAuditAction
targetType String
targetId String?
metadata Json?
ip String?
createdAt DateTime @default(now())
@@index([actorId, createdAt])
@@index([action, createdAt])
@@index([targetType, targetId])
}
model EventAttachment {
id String @id @default(uuid())
eventId String
@@ -0,0 +1,17 @@
const { getAdminAuditLog } = require('../utils/adminAudit');
const { safeErrorMessage } = require('../utils/errorUtils');
// @desc Paginated admin/supervisor action audit trail, with optional filters
// @route GET /api/admin/audit-log
// @access Admin
const listAuditLog = async (req, res) => {
try {
const { page, limit, actorId, action, from, to } = req.query;
const result = await getAdminAuditLog({ page, limit, actorId, action, from, to });
res.json(result);
} catch (e) {
res.status(500).json({ message: safeErrorMessage(e) });
}
};
module.exports = { listAuditLog };
@@ -0,0 +1,53 @@
const path = require('path');
const fs = require('fs');
const { runBackup, listBackups, BACKUP_DIR, BACKUP_FILENAME_RE } = require('../utils/backupUtils');
const { safeErrorMessage } = require('../utils/errorUtils');
// @desc List local database backups (newest first)
// @route GET /api/backups
// @access Admin
const getBackups = async (req, res) => {
try {
const backups = await listBackups();
res.json(backups);
} catch (e) {
res.status(500).json({ message: safeErrorMessage(e) });
}
};
// @desc Manually trigger a backup now
// @route POST /api/backups/run
// @access Admin
const triggerBackup = async (req, res) => {
try {
const result = await runBackup();
res.status(201).json(result);
} catch (e) {
res.status(500).json({ message: safeErrorMessage(e) });
}
};
// @desc Download a backup file
// @route GET /api/backups/:filename/download
// @access Admin
const downloadBackup = async (req, res) => {
try {
const { filename } = req.params;
// Reject anything that isn't exactly the shape this app generates, before ever
// touching the filesystem — same posture as the event-alias path-safety fix.
if (!BACKUP_FILENAME_RE.test(filename)) {
res.status(400);
throw new Error('Invalid backup filename');
}
const filePath = path.join(BACKUP_DIR, filename);
if (!fs.existsSync(filePath)) {
res.status(404);
throw new Error('Backup not found');
}
res.download(filePath, filename);
} catch (e) {
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(e) });
}
};
module.exports = { getBackups, triggerBackup, downloadBackup };
+129 -11
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, contactName, contactPhone, contactEmail, location } = req.body;
const data = {
id: uuidv4(),
@@ -58,14 +61,20 @@ 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,
contactName: contactName || null,
contactPhone: contactPhone || null,
contactEmail: contactEmail || null,
location: location || null,
};
try {
const event = await prisma.event.create({ data });
// 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(),
@@ -436,6 +445,50 @@ const getEventById = async (req, res) => {
}
};
// @desc Download a .ics calendar file for an event
// @route GET /api/events/:id/ics
// @access Public (same visibility gating as getEventById)
const getEventIcs = async (req, res) => {
try {
const eventId = req.params.id;
const event = await prisma.event.findUnique({
where: { id: eventId },
select: { id: true, title: true, description: true, startDate: true, endDate: true, location: true, isActive: true, goLiveAt: true },
});
if (!event) {
res.status(404);
throw new Error('Event not found');
}
// Same public-visibility gate as getEventById — a hidden/not-yet-live event's
// details shouldn't be enumerable via this route either.
const isStaffOrHigher = !!(req.user && ['admin', 'supervisor', 'staff'].includes(req.user.role));
if (!isStaffOrHigher) {
if (event.isActive === false) {
res.status(404);
throw new Error('Event not found');
}
let goLiveAt = null;
try { goLiveAt = event.goLiveAt ? new Date(event.goLiveAt) : null; } catch (e) {}
if (goLiveAt && new Date() < goLiveAt) {
res.status(404);
throw new Error('Event not found');
}
}
const { buildEventIcs } = require('../utils/icsUtils');
const frontendUrl = (process.env.FRONTEND_URL || process.env.APP_BASE_URL || 'http://localhost:3001').replace(/\/$/, '');
const ics = buildEventIcs(event, `${frontendUrl}/events/${event.id}`);
res.setHeader('Content-Type', 'text/calendar; charset=utf-8');
res.setHeader('Content-Disposition', `attachment; filename="${event.title.replace(/[^a-z0-9 -]/gi, '').slice(0, 60) || 'event'}.ics"`);
res.send(ics);
} catch (error) {
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: error.message });
}
};
// @desc Update event
// @route PUT /api/events/:id
// @access Private/Admin
@@ -454,7 +507,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, contactName, contactPhone, contactEmail, location } = req.body;
const data = {
title: title || event.title,
@@ -468,6 +531,11 @@ 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),
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,
};
@@ -515,6 +583,7 @@ const updateEvent = async (req, res) => {
return res.status(400).json({ message: 'Failed to save event form/fields', detail: msg, hint: 'Ensure Prisma migrations are applied and Prisma Client is regenerated, then restart the server.' });
}
logEventUpdate();
return res.json(updatedEvent);
} catch (err) {
const msg = String(err?.message || '');
@@ -522,12 +591,14 @@ const updateEvent = async (req, res) => {
// @ts-ignore
delete data.registrationDeadline;
const updatedEvent = await prisma.event.update({ where: { id: req.params.id }, data });
logEventUpdate();
return res.json(updatedEvent);
}
if (msg.includes('Unknown argument `goLiveAt`')) {
// @ts-ignore
delete data.goLiveAt;
const updatedEvent = await prisma.event.update({ where: { id: req.params.id }, data });
logEventUpdate();
return res.json(updatedEvent);
}
throw err;
@@ -612,6 +683,15 @@ const deleteEvent = async (req, res) => {
}
});
logAdminAction({
actorId: req.user.id,
actorRole: req.user.role,
action: 'event_deleted',
targetType: 'Event',
targetId: req.params.id,
ip: getClientIp(req),
});
res.json({ message: 'Event deactivated' });
} catch (error) {
res.status(400).json({ message: error.message });
@@ -682,26 +762,49 @@ const updateEventOption = async (req, res) => {
}
});
// 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(),
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 } });
}
}
}
@@ -885,7 +988,10 @@ const attachmentsStorage = multer.diskStorage({
}
},
filename: function (req, file, cb) {
const unique = `${Date.now()}-${file.originalname}`;
// Extension only — file.originalname is untrusted and joining it into a
// path allows `../` traversal to write outside the upload directory.
const ext = path.extname(file.originalname).toLowerCase();
const unique = `event-file-${Date.now()}-${crypto.randomBytes(8).toString('hex')}${ext}`;
cb(null, unique);
}
});
@@ -1662,9 +1768,20 @@ const scheduleWhatsappEventAttendees = async (req, res) => {
* @route GET /api/events/by-alias/:redirectUrl
* @access Public
*/
// Aliases are admin-set slugs (e.g. "camp-2025") — see the event wizard's "URL
// Alias" field. This endpoint is public and also the target of the frontend's
// catch-all [redirectUrl] route, so it's what every bot/scanner probe hitting
// an unmatched top-level path (/wp-login.php, /.env, etc.) ends up calling.
// Rejecting non-slug-shaped values here skips a DB round-trip for that traffic.
const VALID_ALIAS = /^[a-zA-Z0-9_-]{1,100}$/;
const getEventByAlias = async (req, res) => {
const { redirectUrl } = req.params;
if (!VALID_ALIAS.test(redirectUrl)) {
return res.status(404).json({ message: 'Event not found' });
}
try {
const event = await prisma.event.findFirst({
where: {
@@ -1713,6 +1830,7 @@ module.exports = {
getAllEvents,
getEventsAll,
getEventById,
getEventIcs,
updateEvent,
getEventNotifyRecipients,
updateEventNotifyRecipients,
+65 -18
View File
@@ -2,6 +2,9 @@ const prisma = require('../config/db');
const { v4: uuidv4 } = require('uuid');
const { generateTicketsForRegistration } = require('../utils/ticketUtils');
const { computeRegistrationTotalDue, refreshPricingForRegistration } = require('../utils/pricing');
const { computeDonationRemaining } = require('../utils/donationUtils');
const { logAdminAction } = require('../utils/adminAudit');
const { getClientIp } = require('../utils/requestUtils');
const axios = require('axios');
const { emailTickets } = require('./ticketController');
const { safeErrorMessage } = require('../utils/errorUtils');
@@ -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 } }
}
@@ -242,7 +245,8 @@ const createPayment = async (req, res) => {
include: {
registrationOptions: {
include: {
eventOption: { include: { earlyBirdTiers: true } }
eventOption: { include: { earlyBirdTiers: true } },
tranches: true
}
},
payments: true
@@ -610,17 +614,12 @@ const assignDonationToRegistration = async (req, res) => {
throw new Error('Only donations can be assigned to registrations');
}
// Donations are never mutated once created — their remaining balance is the original
// amount minus every leg (a Payment row with isDonation:false and originalPaymentId
// pointing back at this donation) already allocated from it. A refund of the donation
// itself also creates such a leg, with a negative amount — Math.abs() so a refund reduces
// the remaining balance (money that's left the building) instead of increasing it (which a
// raw signed sum would do, since subtracting a negative adds).
// See computeDonationRemaining's doc comment for why refund legs (negative amount) reduce
// rather than inflate the remaining balance.
const existingLegs = await prisma.payment.findMany({
where: { originalPaymentId: payment.id, isDonation: false }
});
const alreadyUsed = existingLegs.reduce((sum, leg) => sum + Math.abs(leg.amount), 0);
const remainingDonation = payment.amount - alreadyUsed;
const remainingDonation = computeDonationRemaining(payment.amount, existingLegs);
if (remainingDonation <= 0.000001) {
res.status(400);
@@ -641,7 +640,8 @@ const assignDonationToRegistration = async (req, res) => {
include: {
registrationOptions: {
include: {
eventOption: { include: { earlyBirdTiers: true } }
eventOption: { include: { earlyBirdTiers: true } },
tranches: true
}
},
payments: true
@@ -764,6 +764,16 @@ const assignDonationToRegistration = async (req, res) => {
donationRemaining: remainingDonation - allocateAmount
};
logAdminAction({
actorId: req.user.id,
actorRole: req.user.role,
action: 'donation_assigned',
targetType: 'Registration',
targetId: registrationId,
metadata: { paymentId, legId: leg.id, allocateAmount },
ip: getClientIp(req),
});
res.status(200).json(result);
} catch (error) {
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
@@ -812,7 +822,7 @@ const unassignDonationFromRegistration = async (req, res) => {
const registration = await prisma.registration.findUnique({
where: { id: leg.registrationId },
include: {
registrationOptions: { include: { eventOption: { include: { earlyBirdTiers: true } } } },
registrationOptions: { include: { eventOption: { include: { earlyBirdTiers: true } }, tranches: true } },
payments: true
}
});
@@ -846,7 +856,7 @@ const unassignDonationFromRegistration = async (req, res) => {
const updatedRegistration = await prisma.registration.findUnique({
where: { id: leg.registrationId },
include: {
registrationOptions: { include: { eventOption: { include: { earlyBirdTiers: true } } } },
registrationOptions: { include: { eventOption: { include: { earlyBirdTiers: true } }, tranches: true } },
payments: true
}
});
@@ -883,6 +893,16 @@ const unassignDonationFromRegistration = async (req, res) => {
catch (e) { console.error('Failed to send emails after unassigning donation:', e); }
})();
logAdminAction({
actorId: req.user.id,
actorRole: req.user.role,
action: 'donation_unassigned',
targetType: 'Registration',
targetId: leg.registrationId,
metadata: { legId: leg.id, donationId: donation.id, amount: leg.amount },
ip: getClientIp(req),
});
return res.status(200).json({
message: 'Donation unassigned',
updatedRegistration: finalRegistration,
@@ -903,6 +923,7 @@ async function createRegistrationCheckoutInternal(registrationId, userId, { succ
include: {
eventOption: { include: { earlyBirdTiers: true } },
variant: true,
tranches: true,
}
},
payments: true,
@@ -1017,7 +1038,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
}
});
@@ -1040,6 +1061,7 @@ const createYocoCheckout = async (req, res) => {
include: {
eventOption: { include: { earlyBirdTiers: true } },
variant: true,
tranches: true,
}
},
payments: true,
@@ -1270,7 +1292,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
@@ -1324,7 +1346,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
}
});
@@ -1353,6 +1375,16 @@ const createRefund = async (req, res) => {
const { sendRefundEmail } = require('../utils/notifications');
sendRefundEmail(negativePayment.id).catch(e => console.error('Failed to send refund email:', e));
logAdminAction({
actorId: req.user.id,
actorRole: req.user.role,
action: 'refund_created',
targetType: 'Payment',
targetId: negativePayment.id,
metadata: { amount: amt, method: method || 'refund', reason: reason || null, registrationId: linkRegistrationId },
ip: getClientIp(req),
});
return res.status(201).json(negativePayment);
} catch (error) {
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
@@ -1416,6 +1448,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,
@@ -1429,5 +1475,6 @@ module.exports = {
createRegistrationCheckoutInternal,
sendPaymentLink,
createRefund,
getPaymentStats
getPaymentStats,
sendReceipt,
};
+326 -73
View File
@@ -1,11 +1,12 @@
const prisma = require('../config/db');
const { v4: uuidv4 } = require('uuid');
const axios = require("axios");
const { generateTicketsForRegistration } = require('../utils/ticketUtils');
const { emailTickets } = require('./ticketController');
const { hashPassword } = require('../config/auth');
const { resolveOptionPrice, resolveVariantTierPrice, computeRegistrationTotalDue } = require('../utils/pricing');
const { resolveOptionPrice, resolveVariantTierPrice, computeRegistrationTotalDue, refreshPricingForRegistration, attachComputedTotals, attachComputedTotalsToList } = require('../utils/pricing');
const { assertEventOpen } = require('../utils/cashupUtils');
const { logAdminAction } = require('../utils/adminAudit');
const { getClientIp } = require('../utils/requestUtils');
/**
* Check overall stock availability for an EventOption.
@@ -226,17 +227,31 @@ const createRegistration = async (req, res) => {
let registration;
let isNewRegistration = false;
if (existingReg) {
// Merge: upsert each requested option into the existing registration
// Merge: add a new price tranche per requested option into the existing registration.
// Never overwrite an existing row's priceSnapshot/quantity in place — that would blend
// tickets bought at different early-bird prices into a single (wrong) price. Each
// purchase gets its own tranche; the RegistrationOption row stays a maintained aggregate.
for (const opt of resolvedOptions) {
// Match on eventOptionId + variantId for correct row
const existing = existingReg.registrationOptions.find(
ro => ro.eventOptionId === opt.eventOptionId && (ro.variantId || null) === (opt.variantId || null)
);
if (existing) {
await prisma.registrationOption.update({
await prisma.$transaction([
prisma.registrationOptionTranche.create({
data: {
id: uuidv4(),
registrationOptionId: existing.id,
quantity: opt.quantity,
priceSnapshot: opt.priceSnapshot,
appliedTierId: opt.appliedTierId || null,
}
}),
prisma.registrationOption.update({
where: { id: existing.id },
data: { quantity: existing.quantity + opt.quantity, priceSnapshot: opt.priceSnapshot, appliedTierId: opt.appliedTierId || null }
});
})
]);
} else {
await prisma.registrationOption.create({
data: {
@@ -247,6 +262,14 @@ const createRegistration = async (req, res) => {
variantId: opt.variantId || null,
appliedTierId: opt.appliedTierId || null,
priceSnapshot: opt.priceSnapshot,
tranches: {
create: [{
id: uuidv4(),
quantity: opt.quantity,
priceSnapshot: opt.priceSnapshot,
appliedTierId: opt.appliedTierId || null,
}]
}
}
});
}
@@ -263,7 +286,7 @@ const createRegistration = async (req, res) => {
registration = await prisma.registration.findUnique({
where: { id: existingReg.id },
include: {
registrationOptions: { include: { eventOption: true } },
registrationOptions: { include: { eventOption: true, tranches: true } },
event: true,
user: { select: { id: true, name: true, email: true, phoneNumber: true } }
}
@@ -286,11 +309,19 @@ const createRegistration = async (req, res) => {
variantId: option.variantId || null,
appliedTierId: option.appliedTierId || null,
priceSnapshot: option.priceSnapshot,
tranches: {
create: [{
id: uuidv4(),
quantity: option.quantity,
priceSnapshot: option.priceSnapshot,
appliedTierId: option.appliedTierId || null,
}]
}
}))
}
},
include: {
registrationOptions: { include: { eventOption: true } },
registrationOptions: { include: { eventOption: true, tranches: true } },
event: true,
user: { select: { id: true, name: true, email: true, phoneNumber: true } }
}
@@ -368,7 +399,7 @@ const createRegistration = async (req, res) => {
}
})();
res.status(201).json(registration);
res.status(201).json(attachComputedTotals(registration));
} catch (error) {
res.status(400).json({ message: error.message });
}
@@ -385,6 +416,7 @@ const getRegistrations = async (req, res) => {
include: {
eventOption: { include: { earlyBirdTiers: true } },
variant: { select: { id: true, name: true, price: true } },
tranches: true,
}
},
event: true,
@@ -403,7 +435,7 @@ const getRegistrations = async (req, res) => {
// the { data, total, page, limit, pages } shape used by /api/payments and /api/users.
if (typeof req.query.page === 'undefined' && typeof req.query.limit === 'undefined') {
const registrations = await prisma.registration.findMany({ include });
return res.json(registrations);
return res.json(attachComputedTotalsToList(registrations));
}
const page = Math.max(1, parseInt(req.query.page) || 1);
@@ -415,7 +447,7 @@ const getRegistrations = async (req, res) => {
prisma.registration.count()
]);
res.json({ data: registrations, total, page, limit, pages: Math.ceil(total / limit) });
res.json({ data: attachComputedTotalsToList(registrations), total, page, limit, pages: Math.ceil(total / limit) });
} catch (error) {
res.status(400).json({ message: error.message });
}
@@ -434,6 +466,18 @@ const getUserRegistrations = async (req, res) => {
event: { endDate: { gte: now }, cashupStatus: { not: 'closed' } }
};
// Keep pending/partial-paid registrations' prices current before serving them — an
// early-bird tier can expire while items sit unpaid in someone's registration, and
// without this the dashboard would keep showing a price that was never actually locked
// in by a payment, indefinitely, until the user happens to attempt a payment.
const staleCandidates = await prisma.registration.findMany({
where: { ...whereClause, status: { in: ['pending', 'partial_paid'] } },
select: { id: true }
});
if (staleCandidates.length > 0) {
await Promise.all(staleCandidates.map(r => refreshPricingForRegistration(r.id).catch(() => {})));
}
const registrations = await prisma.registration.findMany({
where: whereClause,
include: {
@@ -441,6 +485,7 @@ const getUserRegistrations = async (req, res) => {
include: {
eventOption: { include: { earlyBirdTiers: true } },
variant: { select: { id: true, name: true, price: true } },
tranches: true,
}
},
// Nest the event's form so the frontend can tell whether attendee forms are
@@ -453,12 +498,13 @@ const getUserRegistrations = async (req, res) => {
email: true,
phoneNumber: true
}
}
},
payments: true,
},
orderBy: { createdAt: 'desc' }
});
res.json(registrations);
res.json(attachComputedTotalsToList(registrations));
} catch (error) {
res.status(400).json({ message: error.message });
}
@@ -469,14 +515,13 @@ const getUserRegistrations = async (req, res) => {
// @access Private
const getRegistrationById = async (req, res) => {
try {
const registration = await prisma.registration.findUnique({
where: { id: req.params.id },
include: {
const include = {
registrationOptions: {
include: {
eventOption: { include: { earlyBirdTiers: true } },
variant: { select: { id: true, name: true, price: true } },
tickets: true
tickets: true,
tranches: true
}
},
event: true,
@@ -490,14 +535,22 @@ const getRegistrationById = async (req, res) => {
},
payments: { include: { recordedBy: { select: { id: true, name: true, email: true } } } },
formResponses: { include: { answers: true } }
}
});
};
let registration = await prisma.registration.findUnique({ where: { id: req.params.id }, include });
if (!registration) {
res.status(404);
throw new Error('Registration not found');
}
// See getUserRegistrations — keep an unpaid/partially-paid registration's price current
// whenever it's viewed, not just at payment time.
if (registration.status === 'pending' || registration.status === 'partial_paid') {
await refreshPricingForRegistration(registration.id).catch(() => {});
registration = await prisma.registration.findUnique({ where: { id: req.params.id }, include });
}
// Guests (no auth) can view by knowing the registrationId (UUID = unguessable)
// Authenticated users must be the owner or staff+
if (req.user && registration.userId !== req.user.id && req.user.role !== 'admin' && req.user.role !== 'supervisor' && req.user.role !== 'staff') {
@@ -505,7 +558,7 @@ const getRegistrationById = async (req, res) => {
throw new Error('Not authorized to view this registration');
}
res.json(registration);
res.json(attachComputedTotals(registration));
} catch (error) {
res.status(400).json({ message: error.message });
}
@@ -647,6 +700,20 @@ const cancelRegistration = async (req, res) => {
}
});
// Only log when staff cancels on someone else's behalf — a routine self-service
// cancellation isn't an admin action worth cluttering the audit trail with.
if (registration.userId !== req.user.id) {
logAdminAction({
actorId: req.user.id,
actorRole: req.user.role,
action: 'registration_cancelled',
targetType: 'Registration',
targetId: req.params.id,
metadata: { registrationOwnerId: registration.userId },
ip: getClientIp(req),
});
}
res.json({ message: 'Registration cancelled', registration: updatedRegistration });
} catch (error) {
res.status(400).json({ message: error.message });
@@ -659,6 +726,17 @@ const cancelRegistration = async (req, res) => {
const getRegistrationsByEvent = async (req, res) => {
try {
const { search } = req.query;
// See getUserRegistrations — keep pending/partial-paid registrations' prices current
// before serving them, rather than only at payment time.
const staleCandidates = await prisma.registration.findMany({
where: { eventId: req.params.eventId, status: { in: ['pending', 'partial_paid'] } },
select: { id: true }
});
if (staleCandidates.length > 0) {
await Promise.all(staleCandidates.map(r => refreshPricingForRegistration(r.id).catch(() => {})));
}
let registrations = await prisma.registration.findMany({
where: { eventId: req.params.eventId },
include: {
@@ -667,6 +745,7 @@ const getRegistrationsByEvent = async (req, res) => {
eventOption: { include: { earlyBirdTiers: true } },
variant: { select: { id: true, name: true, price: true } },
tickets: true,
tranches: true,
}
},
payments: { include: { recordedBy: { select: { id: true, name: true, email: true } } } },
@@ -684,7 +763,7 @@ const getRegistrationsByEvent = async (req, res) => {
);
}
res.json(registrations);
res.json(attachComputedTotalsToList(registrations));
} catch (error) {
res.status(400).json({ message: error.message });
}
@@ -696,7 +775,7 @@ const getRegistrationsByEvent = async (req, res) => {
const createManualRegistration = async (req, res) => {
let userRecord;
try {
const { eventId, options, user, guestOnly, notificationPreference: prefFromBody } = req.body;
const { eventId, options, user, notificationPreference: prefFromBody, skipActivationNotice } = req.body;
if (!eventId || !options || !user || !user.name || (!user.email && !user.phoneNumber)) {
res.status(400);
@@ -780,7 +859,7 @@ const createManualRegistration = async (req, res) => {
? prefFromBody
: (hasValidEmail && phone ? 'both' : phone ? 'whatsapp' : 'email');
// Always search by email AND/OR phone regardless of guestOnly.
// Always search by email AND/OR phone.
// Resolve each channel independently (rather than a single findFirst with an OR
// across both) so that an email belonging to one account and a phone number
// belonging to a *different* account can never be silently collapsed into
@@ -843,25 +922,36 @@ const createManualRegistration = async (req, res) => {
if (Object.keys(updateData).length > 0) {
await prisma.user.update({ where: { id: userId }, data: updateData }).catch(() => {});
}
} else if (!guestOnly && hasValidEmail) {
// Create a real active account (non-guest with email)
try {
const password = 'Hope123';
const response = await axios.post(
`${process.env.NEXT_PUBLIC_API_URL || 'http://localhost:5000'}/api/users`,
{ name: user.name, email: user.email, password, phoneNumber: phone || null }
);
const createdUser = response.data.user || response.data;
if (!createdUser?.id) { res.status(400); throw new Error('User creation failed: No user ID returned'); }
userId = createdUser.id;
// Set derived preference on the new account
await prisma.user.update({ where: { id: userId }, data: { notificationPreference: derivedPref } }).catch(() => {});
} catch (userErr) {
res.status(400);
throw new Error(`Failed to create user: ${userErr.response?.data?.message || userErr.message}`);
}
} else {
// Guest path: phone-only, guestOnly=true, or no valid email
const suppliedPassword = typeof user.password === 'string' && user.password.trim().length >= 6
? user.password.trim()
: null;
if (hasValidEmail && suppliedPassword) {
// Caller supplied their own password (the self-service kiosk, where the
// visitor sets it themselves on the spot) — activate immediately, since
// there's nothing left for them to do via an activation link.
const hashed = await hashPassword(suppliedPassword);
const created = await prisma.user.create({
data: {
id: uuidv4(),
name: user.name,
email: user.email,
password: hashed,
phoneNumber: phone || null,
isActive: true,
notificationPreference: derivedPref,
updatedAt: new Date(),
}
});
userId = created.id;
} else {
// New account: uses the real email if a valid one was given, otherwise a
// guest.local placeholder (phone-only registration). Always created inactive
// with a random password — the visitor activates it themselves via the link
// sent immediately below (email or WhatsApp), unless the caller explicitly
// opted out of that nudge (e.g. a self-service visitor who declined to
// create an account at all).
const placeholderEmail = hasValidEmail
? user.email
: `guest+${uuidv4().slice(0, 8)}@guest.local`;
@@ -879,6 +969,11 @@ const createManualRegistration = async (req, res) => {
}
});
userId = created.id;
if (!skipActivationNotice) {
const { sendActivationLink } = require('./userController');
sendActivationLink(created);
}
}
}
// Merge into existing non-cancelled registration if one exists, otherwise create new
@@ -891,20 +986,33 @@ const createManualRegistration = async (req, res) => {
let isNewRegistration = false;
if (existingReg) {
// Upsert each requested option into the existing registration (all in parallel)
// Add a new price tranche per requested option into the existing registration (all in
// parallel) — never overwrite an existing row's priceSnapshot/quantity in place, or
// tickets bought at different early-bird prices would blend into a single wrong price.
await Promise.all(resolvedManualOptions.map(opt => {
const existing = existingReg.registrationOptions.find(
ro => ro.eventOptionId === opt.eventOptionId && (ro.variantId || null) === (opt.variantId || null)
);
if (existing) {
return prisma.registrationOption.update({
return prisma.$transaction([
prisma.registrationOptionTranche.create({
data: {
id: uuidv4(),
registrationOptionId: existing.id,
quantity: opt.quantity,
priceSnapshot: opt.priceSnapshot,
appliedTierId: opt.appliedTierId || null,
}
}),
prisma.registrationOption.update({
where: { id: existing.id },
data: {
quantity: existing.quantity + opt.quantity,
priceSnapshot: opt.priceSnapshot,
appliedTierId: opt.appliedTierId || null,
}
});
})
]);
}
return prisma.registrationOption.create({
data: {
@@ -915,6 +1023,14 @@ const createManualRegistration = async (req, res) => {
variantId: opt.variantId || null,
appliedTierId: opt.appliedTierId || null,
priceSnapshot: opt.priceSnapshot,
tranches: {
create: [{
id: uuidv4(),
quantity: opt.quantity,
priceSnapshot: opt.priceSnapshot,
appliedTierId: opt.appliedTierId || null,
}]
}
}
});
}));
@@ -922,7 +1038,7 @@ const createManualRegistration = async (req, res) => {
const freshForStatus = await prisma.registration.findUnique({
where: { id: existingReg.id },
include: {
registrationOptions: { include: { eventOption: { include: { earlyBirdTiers: true } } } },
registrationOptions: { include: { eventOption: { include: { earlyBirdTiers: true } }, tranches: true } },
payments: true,
}
});
@@ -937,7 +1053,7 @@ const createManualRegistration = async (req, res) => {
registration = await prisma.registration.findUnique({
where: { id: existingReg.id },
include: {
registrationOptions: { include: { eventOption: true } },
registrationOptions: { include: { eventOption: true, tranches: true } },
event: true,
user: { select: { id: true, name: true, email: true, phoneNumber: true } },
},
@@ -960,11 +1076,19 @@ const createManualRegistration = async (req, res) => {
variantId: option.variantId || null,
appliedTierId: option.appliedTierId || null,
priceSnapshot: option.priceSnapshot,
tranches: {
create: [{
id: uuidv4(),
quantity: option.quantity,
priceSnapshot: option.priceSnapshot,
appliedTierId: option.appliedTierId || null,
}]
}
})),
},
},
include: {
registrationOptions: { include: { eventOption: true } },
registrationOptions: { include: { eventOption: true, tranches: true } },
event: true,
user: { select: { id: true, name: true, email: true, phoneNumber: true } },
},
@@ -1035,7 +1159,17 @@ const createManualRegistration = async (req, res) => {
}
})();
return res.status(201).json(registration);
logAdminAction({
actorId: req.user.id,
actorRole: req.user.role,
action: 'registration_created_manual',
targetType: 'Registration',
targetId: registrationId,
metadata: { eventId: registration.eventId, forUserId: userId },
ip: getClientIp(req),
});
return res.status(201).json(attachComputedTotals(registration));
} catch (error) {
console.error(error);
return res.status(400).json({message: error.message});
@@ -1063,6 +1197,7 @@ const updateRegistrationOptions = async (req, res) => {
registrationOptions: {
include: {
tickets: true,
tranches: true,
eventOption: { include: { earlyBirdTiers: true, variants: true } }
}
},
@@ -1134,46 +1269,98 @@ const updateRegistrationOptions = async (req, res) => {
}
const mergedOptions = Array.from(mergedOptionsMap.values());
// Resolve pricing for each incoming option (variant-aware, with stock check)
// Group existing registrationOptions by eventOptionId::variantId so tickets that
// have already been issued are never deleted, only ever updated in place. Computed
// before pricing resolution because pricing now depends on whether a quantity is
// increasing (and by how much) — an unchanged or reduced quantity must never
// re-price tickets already locked in at an earlier price (see tranche design).
const oldByKey = new Map();
for (const ro of registration.registrationOptions) {
const key = `${ro.eventOptionId}::${ro.variantId || ''}`;
if (!oldByKey.has(key)) oldByKey.set(key, []);
oldByKey.get(key).push(ro);
}
// A legacy row (created before the tranche migration) has no tranches — fall back to
// its own priceSnapshot/quantity as a single implicit tranche for totals purposes.
const trancheSum = (tranches) => (tranches || []).reduce((s, t) => s + Number(t.quantity || 0) * Number(t.priceSnapshot || 0), 0);
const rowsTotal = (rows) => rows.reduce((sum, ro) => {
const tranches = ro.tranches || [];
if (tranches.length > 0) return sum + trancheSum(tranches);
return sum + Number(ro.quantity || 0) * Number(ro.priceSnapshot ?? ro.eventOption?.price ?? 0);
}, 0);
// Remove `qtyToRemove` units from a set of tranches, newest-first (LIFO) — mirrors the
// ticket-floor invariant below: issued tickets always map to the oldest tranches, so the
// newest (least-committed) tranches are the ones trimmed first on a quantity decrease.
const planLIFORemoval = (tranches, qtyToRemove) => {
const sorted = [...tranches].sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt));
let remaining = qtyToRemove;
const ops = [];
const kept = [];
for (const t of sorted) {
if (remaining <= 0) { kept.push(t); continue; }
if (t.quantity <= remaining) {
remaining -= t.quantity;
ops.push({ type: 'delete', trancheId: t.id });
} else {
const newQuantity = t.quantity - remaining;
remaining = 0;
ops.push({ type: 'update', trancheId: t.id, newQuantity });
kept.push({ ...t, quantity: newQuantity });
}
}
return { kept, ops };
};
// Resolve pricing for each incoming option (variant-aware, with stock check) — but only
// for the delta being newly added. Unchanged or decreasing quantities never re-resolve.
const eventOptionsMap = new Map((registration.event?.eventOptions || []).map(eo => [eo.id, eo]));
const resolvedUpdateOptions = [];
let newTotalDue = 0;
for (const opt of mergedOptions) {
const key = `${opt.eventOptionId}::${opt.variantId || ''}`;
const existingRows = oldByKey.get(key) || [];
const existingQty = existingRows.reduce((sum, ro) => sum + (ro.quantity || 0), 0);
const delta = (opt.quantity || 0) - existingQty;
const existingTotal = rowsTotal(existingRows);
const eventOption = eventOptionsMap.get(opt.eventOptionId);
const variantId = opt.variantId || null;
let priceSnapshot = null;
let appliedTierId = null;
let removalPlan = null;
if (delta > 0) {
try {
if (variantId) {
const variantResolved = await resolveVariantTierPrice(eventOption, variantId, opt.quantity);
const variantResolved = await resolveVariantTierPrice(eventOption, variantId, delta);
priceSnapshot = variantResolved.price;
appliedTierId = variantResolved.tierId;
} else {
const resolved = await resolveOptionPrice(eventOption, opt.quantity);
const resolved = await resolveOptionPrice(eventOption, delta);
priceSnapshot = resolved.price;
appliedTierId = resolved.tierId;
}
} catch (e) {
priceSnapshot = Number(eventOption?.price || 0);
}
resolvedUpdateOptions.push({ ...opt, variantId, priceSnapshot, appliedTierId });
newTotalDue += existingTotal + delta * (priceSnapshot || 0);
} else if (delta < 0) {
const existingTranches = existingRows.flatMap(ro => ro.tranches || []);
removalPlan = planLIFORemoval(existingTranches, -delta);
newTotalDue += trancheSum(removalPlan.kept);
} else {
newTotalDue += existingTotal;
}
const newTotalDue = resolvedUpdateOptions.reduce((sum, opt) => sum + (opt.priceSnapshot || 0) * (opt.quantity || 0), 0);
resolvedUpdateOptions.push({ ...opt, variantId, priceSnapshot, appliedTierId, existingRows, existingQty, delta, removalPlan });
}
if (newTotalDue < totalPaid) {
res.status(400);
throw new Error('Cannot reduce items below the amount already paid');
}
// Group existing registrationOptions by eventOptionId::variantId so tickets that
// have already been issued are never deleted, only ever updated in place.
const oldByKey = new Map();
for (const ro of registration.registrationOptions) {
const key = `${ro.eventOptionId}::${ro.variantId || ''}`;
if (!oldByKey.has(key)) oldByKey.set(key, []);
oldByKey.get(key).push(ro);
}
// Per-item floor: a ticket is only ever created once a registration is paid, and it is
// never deleted or shrunk — only grown. So an option can never be reduced (or removed)
// below the quantity of any ticket already issued for it.
@@ -1208,20 +1395,63 @@ const updateRegistrationOptions = async (req, res) => {
const existingRows = oldByKey.get(key);
if (existingRows && existingRows.length > 0) {
const [primary, ...dupes] = existingRows;
await tx.registrationOption.update({
where: { id: primary.id },
data: {
quantity: opt.quantity,
appliedTierId: opt.appliedTierId || null,
priceSnapshot: opt.priceSnapshot,
}
});
// Consolidate dupes into primary first: move their tranches and tickets rather
// than discarding them, so price history (and money) survives duplicate cleanup.
for (const dup of dupes) {
if ((dup.tranches || []).length > 0) {
await tx.registrationOptionTranche.updateMany({ where: { registrationOptionId: dup.id }, data: { registrationOptionId: primary.id } });
}
if ((dup.tickets || []).length > 0) {
await tx.ticket.updateMany({ where: { registrationOptionId: dup.id }, data: { registrationOptionId: primary.id } });
}
await tx.registrationOption.delete({ where: { id: dup.id } });
}
if (opt.delta > 0) {
// Increase: a new tranche for the delta at the freshly-resolved price — never
// overwrite the existing tranches' locked-in prices.
await tx.registrationOptionTranche.create({
data: {
id: uuidv4(),
registrationOptionId: primary.id,
quantity: opt.delta,
priceSnapshot: opt.priceSnapshot,
appliedTierId: opt.appliedTierId || null,
}
});
await tx.registrationOption.update({
where: { id: primary.id },
data: { quantity: opt.quantity, appliedTierId: opt.appliedTierId || null, priceSnapshot: opt.priceSnapshot }
});
} else if (opt.delta < 0 && opt.removalPlan) {
// Decrease: trim tranches newest-first (LIFO); issued tickets always map to the
// oldest tranches, and the floor check above already guarantees this never dips
// below issued-ticket quantity.
for (const op of opt.removalPlan.ops) {
if (op.type === 'delete') {
await tx.registrationOptionTranche.delete({ where: { id: op.trancheId } });
} else {
await tx.registrationOptionTranche.update({ where: { id: op.trancheId }, data: { quantity: op.newQuantity } });
}
}
const newest = [...opt.removalPlan.kept].sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt))[0];
await tx.registrationOption.update({
where: { id: primary.id },
data: {
quantity: opt.quantity,
appliedTierId: newest ? (newest.appliedTierId || null) : null,
priceSnapshot: newest ? newest.priceSnapshot : null,
}
});
} else {
// Unchanged quantity: only reflect dupe-consolidation in the aggregate; leave
// the locked-in price/tier untouched.
await tx.registrationOption.update({
where: { id: primary.id },
data: { quantity: opt.quantity }
});
}
} else {
await tx.registrationOption.create({
data: {
@@ -1232,6 +1462,14 @@ const updateRegistrationOptions = async (req, res) => {
variantId: opt.variantId || null,
appliedTierId: opt.appliedTierId || null,
priceSnapshot: opt.priceSnapshot,
tranches: {
create: [{
id: uuidv4(),
quantity: opt.quantity,
priceSnapshot: opt.priceSnapshot,
appliedTierId: opt.appliedTierId || null,
}]
}
}
});
}
@@ -1253,7 +1491,7 @@ const updateRegistrationOptions = async (req, res) => {
const updated = await prisma.registration.findUnique({
where: { id: registrationId },
include: {
registrationOptions: { include: { eventOption: true } },
registrationOptions: { include: { eventOption: true, tranches: true } },
event: true,
user: { select: { id: true, name: true, email: true, phoneNumber: true } },
payments: true,
@@ -1277,7 +1515,7 @@ const updateRegistrationOptions = async (req, res) => {
})();
}
return res.json(updated);
return res.json(attachComputedTotals(updated));
} catch (error) {
return res.status(400).json({ message: error.message });
}
@@ -1296,7 +1534,7 @@ const submitFormResponses = async (req, res) => {
const registration = await prisma.registration.findUnique({
where: { id: registrationId },
include: {
registrationOptions: { include: { eventOption: true } },
registrationOptions: { include: { eventOption: true, tranches: true } },
event: true,
}
});
@@ -1388,7 +1626,7 @@ const replaceFormResponses = async (req, res) => {
const registration = await prisma.registration.findUnique({
where: { id: registrationId },
include: {
registrationOptions: { include: { eventOption: true } },
registrationOptions: { include: { eventOption: true, tranches: true } },
event: true,
}
});
@@ -1550,6 +1788,20 @@ const saveFormDraft = async (req, res) => {
}
};
// @desc Re-send the current invoice PDF for one of the caller's own registrations
// @route POST /api/registrations/:id/send-invoice
// @access Private
const sendInvoice = async (req, res) => {
try {
const { channel } = req.body || {};
const { sendInvoiceToUser } = require('../utils/notifications');
await sendInvoiceToUser(req.params.id, req.user.id, channel);
res.json({ message: channel === 'whatsapp' ? 'Invoice sent to WhatsApp.' : 'Invoice emailed.' });
} catch (error) {
res.status(error.statusCode || 400).json({ message: error.message });
}
};
module.exports = {
createRegistration,
getRegistrations,
@@ -1564,4 +1816,5 @@ module.exports = {
replaceFormResponses,
getFormDraft,
saveFormDraft,
sendInvoice,
};
@@ -4,6 +4,8 @@ const { safeErrorMessage } = require('../utils/errorUtils');
const { v4: uuidv4 } = require('uuid');
const { invalidate: invalidateSettingsCache, warmCache, ENCRYPTED_KEYS } = require('../utils/settingsCache');
const { encrypt, decrypt, isEncrypted } = require('../utils/encryption');
const { logAdminAction } = require('../utils/adminAudit');
const { getClientIp } = require('../utils/requestUtils');
// Keys safe to return without auth — includes legal keys needed by public legal pages
const PUBLIC_KEYS = [
@@ -107,6 +109,20 @@ const updateSettings = async (req, res) => {
if (ops.length) await prisma.$transaction(ops);
invalidateSettingsCache();
await warmCache(); // ensure in-memory cache reflects the new values before responding
// Log which keys changed, never the values — some settings are secrets (e.g. the
// WAWP token) that aren't even encrypted at rest, let alone fit for an audit log.
if (ops.length) {
logAdminAction({
actorId: req.user.id,
actorRole: req.user.role,
action: 'settings_updated',
targetType: 'AppSetting',
metadata: { changedKeys: Object.keys(updates).filter(k => updates[k] !== undefined && updates[k] !== null) },
ip: getClientIp(req),
});
}
res.json({ message: 'Settings saved' });
} catch (e) {
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(e) });
+11 -4
View File
@@ -1,7 +1,15 @@
const path = require('path');
const fs = require('fs');
const crypto = require('crypto');
const multer = require('multer');
// Builds a filename multer can never be tricked into escaping the upload
// directory with — extension only, no attacker-controlled path segments.
// (file.originalname is untrusted; joining it into a path allows `../` traversal.)
function safeFilename(prefix, ext) {
return `${prefix}-${Date.now()}-${crypto.randomBytes(8).toString('hex')}${ext}`;
}
// Setup multer storage
const storage = multer.diskStorage({
destination: function (req, file, cb) {
@@ -24,8 +32,7 @@ const storage = multer.diskStorage({
}
},
filename: function (req, file, cb) {
const uniqueName = `${Date.now()}-${file.originalname}`;
cb(null, uniqueName);
cb(null, safeFilename('event', path.extname(file.originalname).toLowerCase()));
}
});
@@ -54,7 +61,7 @@ const logoStorage = multer.diskStorage({
}
},
filename: function (req, file, cb) {
cb(null, `logo-${Date.now()}${path.extname(file.originalname).toLowerCase()}`);
cb(null, safeFilename('logo', path.extname(file.originalname).toLowerCase()));
}
});
@@ -82,7 +89,7 @@ const faviconStorage = multer.diskStorage({
}
},
filename: function (req, file, cb) {
cb(null, `favicon-${Date.now()}${path.extname(file.originalname).toLowerCase()}`);
cb(null, safeFilename('favicon', path.extname(file.originalname).toLowerCase()));
}
});
+53 -50
View File
@@ -23,6 +23,53 @@ function getClientIp(req) {
const PRIVATE_IP_RE = /^(::1|::ffff:127\.|127\.|10\.|172\.(1[6-9]|2\d|3[01])\.|192\.168\.)/;
// 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 {
@@ -176,61 +223,16 @@ const loginUser = async (req, res) => {
// Check if user is active
if (!user.isActive) {
// If the account has a real email (not a guest placeholder), send an activation link via email
// Resend the activation link on each failed login attempt against an inactive
// account, in case the original one (sent at creation, or a prior attempt) expired.
await sendActivationLink(user);
// If the account has a real email (not a guest placeholder), it went out via email
if (user.email && !user.email.endsWith('@guest.local')) {
try {
const token = uuidv4();
const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000); // 24h
await prisma.passwordReset.updateMany({
where: { userId: user.id, used: false },
data: { used: true }
});
await prisma.passwordReset.create({
data: { id: uuidv4(), userId: user.id, token, expiresAt, used: false }
});
const baseUrl = process.env.FRONTEND_URL || process.env.APP_BASE_URL || 'http://localhost:3001';
const activationUrl = `${baseUrl.replace(/\/$/, '')}/activate-account?token=${encodeURIComponent(token)}`;
const { sendMail, buildAccountActivationEmail } = require('../utils/email');
const content = buildAccountActivationEmail({ name: user.name, activationUrl });
sendMail({ to: user.email, subject: `Activate your ${getOrgName()} account`, ...content })
.catch(e => console.warn('[activation email] Failed:', e?.message || e));
} catch (e) {
console.warn('[activation token] Failed to create activation token:', e?.message || e);
}
res.status(401);
throw new Error('Your account is not yet active. We\'ve sent you an email with a link to activate your account.');
}
// No real email — if they have a phone number, send the activation link via WhatsApp
// No real email — if they have a phone number, it went out via WhatsApp
if (user.phoneNumber) {
try {
const token = uuidv4();
const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000); // 24h
await prisma.passwordReset.updateMany({
where: { userId: user.id, used: false },
data: { used: true }
});
await prisma.passwordReset.create({
data: { id: uuidv4(), userId: user.id, token, expiresAt, used: false }
});
const baseUrl = process.env.FRONTEND_URL || process.env.APP_BASE_URL || 'http://localhost:3001';
const activationUrl = `${baseUrl.replace(/\/$/, '')}/activate-account?token=${encodeURIComponent(token)}`;
const orgName = getOrgName();
const waMessage = [
`🔓 *Activate your ${orgName} account*`,
'',
`Hi ${user.name || 'there'},`,
'',
`Your account needs to be activated before you can log in. Tap the link below to set a password and activate your account:`,
'',
activationUrl,
'',
`_This link expires in 24 hours._`,
].join('\n');
const { waTextAny } = require('../utils/notify');
waTextAny(user, waMessage).catch(e => console.warn('[activation WA] Failed:', e?.message || e));
} catch (e) {
console.warn('[activation token WA] Failed to create activation token:', e?.message || e);
}
res.status(401);
throw new Error('Your account is not yet active. We\'ve sent you a WhatsApp message with a link to activate your account.');
}
@@ -970,4 +972,5 @@ module.exports = {
adminRevokeUserSessions,
closeAccount,
getMyActivity,
sendActivationLink,
};
+2 -1
View File
@@ -447,7 +447,8 @@ const updateRegistrationStatus = async (registrationId) => {
include: {
earlyBirdTiers: true
}
}
},
tranches: true
}
},
payments: true
+127 -19
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,22 @@ app.use('/api/setup', setupRoutes);
app.use('/api/stats', statsRoutes);
app.use('/api', costRoutes);
app.use('/api/cashups', cashupRoutes);
app.use('/api/admin', adminRoutes);
app.use('/api/backups', backupRoutes);
// Pre-warm the settings cache so synchronous helpers have DB values from startup
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 +291,9 @@ app.get('/', async (req, res) => {
? `<span class="badge badge-warn">testing</span>`
: `<span class="badge badge-warn">development</span>`;
const html = pageShell('Cross Code Events API — Status', '#2563eb', `
<h1>Cross Code 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 +359,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 },
@@ -529,6 +577,15 @@ 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: 'Payments', base: '/api/payments', endpoints: [
@@ -546,6 +603,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 }}]},
@@ -1013,13 +1078,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>Cross Code 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 +1109,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">Cross Code Events API Reference</h1>
<h1 style="font-size:1.4rem;font-weight:700;color:#111827">${orgName} Events API Reference</h1>
<a href="/" style="font-size:.82rem;color:#6b7280">&#8592; Status page</a>
</div>
<p style="font-size:.82rem;color:#6b7280;margin-bottom:20px">
@@ -1070,7 +1136,7 @@ app.get('/docs', async (req, res) => {
${notificationsHtml}
<p style="font-size:.72rem;color:#9ca3af;margin-top:28px;text-align:center">
Cross Code Events API v${API_VERSION} &mdash; ${new Date().toISOString()}
${orgName} Events API v${API_VERSION} &mdash; ${new Date().toISOString()}
</p>
</div>
<script>
@@ -1098,6 +1164,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
@@ -1188,6 +1262,36 @@ app.listen(PORT, () => {
console.warn('[temp cleanup] Not scheduled:', e?.message || e);
}
// Nightly database backup at 02:00 local time (before the 03:00 temp cleanup)
try {
const enabled = String(process.env.BACKUP_ENABLED || 'true').toLowerCase() !== 'false';
if (enabled) {
const { runBackup } = require('./utils/backupUtils');
function scheduleNightlyBackup() {
const now = new Date();
const next = new Date(now);
next.setHours(2, 0, 0, 0);
if (next <= now) next.setDate(next.getDate() + 1);
setTimeout(async () => {
try {
const result = await runBackup();
console.log(`[backup] Completed: ${result.filename}`);
} catch (e) {
console.error('[backup] Failed:', e?.message || e);
} finally {
scheduleNightlyBackup();
}
}, next.getTime() - now.getTime());
}
scheduleNightlyBackup();
console.log('[backup] Scheduler initialized (02:00 local time). Set BACKUP_ENABLED=false to disable.');
} else {
console.log('[backup] Scheduler disabled by env BACKUP_ENABLED=false');
}
} catch (e) {
console.warn('[backup] Not scheduled:', e?.message || e);
}
// Scheduled emails worker (polling)
try {
const enabled = String(process.env.SCHEDULED_EMAILS_ENABLED || 'true').toLowerCase() !== 'false';
@@ -1245,11 +1349,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 };
+4 -3
View File
@@ -13,8 +13,9 @@ const protect = async (req, res, next) => {
// Get token from header
token = req.headers.authorization.split(' ')[1];
// Verify token
const decoded = jwt.verify(token, process.env.JWT_SECRET);
// Verify token — pin the algorithm so a token signed with an
// unexpected/attacker-chosen algorithm is never accepted.
const decoded = jwt.verify(token, process.env.JWT_SECRET, { algorithms: ['HS256'] });
// Get user from the token (exclude password)
req.user = await prisma.user.findUnique({
@@ -105,7 +106,7 @@ const optionalAuth = async (req, res, next) => {
}
try {
const token = req.headers.authorization.split(' ')[1];
const decoded = jwt.verify(token, process.env.JWT_SECRET);
const decoded = jwt.verify(token, process.env.JWT_SECRET, { algorithms: ['HS256'] });
const user = await prisma.user.findUnique({
where: { id: decoded.id },
select: { id: true, name: true, email: true, role: true, isActive: true, createdAt: true, updatedAt: true, phoneNumber: true, tokenVersion: true }
+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;
+2
View File
@@ -6,6 +6,7 @@ const {
getAllEvents,
getEventsAll,
getEventById,
getEventIcs,
updateEvent,
getEventNotifyRecipients,
updateEventNotifyRecipients,
@@ -44,6 +45,7 @@ router.post('/attachments/sync', protect, admin, attachmentsSync);
// optionalAuth populates req.user when a valid token is present so staff/supervisor/admin
// can still load inactive events (e.g. for cashup or editing) without being 404'd.
router.get('/:id', optionalAuth, getEventById);
router.get('/:id/ics', optionalAuth, getEventIcs);
router.get('/by-alias/:redirectUrl', getEventByAlias);
// Create/Update/Delete event
+3 -1
View File
@@ -12,7 +12,8 @@ const {
createYocoCheckout,
sendPaymentLink,
createRefund,
getPaymentStats
getPaymentStats,
sendReceipt,
} = require('../controllers/paymentController');
const { protect, supervisor, staff, admin} = require('../middleware/authMiddleware');
@@ -21,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);
+2
View File
@@ -12,6 +12,7 @@ const {
updateRegistrationOptions,
submitFormResponses, replaceFormResponses,
getFormDraft, saveFormDraft,
sendInvoice,
} = require('../controllers/registrationController');
const { protect, supervisor, staff, optionalAuth } = require('../middleware/authMiddleware');
@@ -20,6 +21,7 @@ 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);
// Registration detail + forms — optionalAuth so guests can access with just the registrationId
router.get('/:id', optionalAuth, getRegistrationById);
+52
View File
@@ -0,0 +1,52 @@
const prisma = require('../config/db');
// Fire-and-forget by design — a logging failure must never break the underlying admin
// action, so this swallows its own errors rather than propagating them to the caller
// (same posture as logSecurityEvent).
async function logAdminAction({ actorId, actorRole, action, targetType, targetId, metadata, ip }) {
try {
await prisma.adminAuditLog.create({
data: {
actorId: actorId || null,
actorRole,
action,
targetType,
targetId: targetId || null,
metadata: metadata || undefined,
ip: ip || null,
},
});
} catch (e) {
console.error('Failed to log admin action:', e?.message);
}
}
// Paginated listing for the admin audit-log page, with optional actor/action/date filters.
async function getAdminAuditLog({ page = 1, limit = 50, actorId, action, from, to } = {}) {
const where = {};
if (actorId) where.actorId = actorId;
if (action) where.action = action;
if (from || to) {
where.createdAt = {};
if (from) where.createdAt.gte = new Date(from);
if (to) where.createdAt.lte = new Date(to);
}
const take = Math.min(Math.max(Number(limit) || 50, 1), 200);
const skip = (Math.max(Number(page) || 1, 1) - 1) * take;
const [rows, total] = await Promise.all([
prisma.adminAuditLog.findMany({
where,
orderBy: { createdAt: 'desc' },
take,
skip,
include: { actor: { select: { id: true, name: true, email: true } } },
}),
prisma.adminAuditLog.count({ where }),
]);
return { rows, total, page: Math.max(Number(page) || 1, 1), limit: take };
}
module.exports = { logAdminAction, getAdminAuditLog };
+87
View File
@@ -0,0 +1,87 @@
const { spawn } = require('child_process');
const { pipeline } = require('stream/promises');
const zlib = require('zlib');
const fs = require('fs');
const path = require('path');
const { getSettingSync } = require('./settingsCache');
const BACKUP_DIR = path.join(__dirname, '..', '..', 'backups');
// Local-disk-only by design (no offsite/cloud upload) — matches the exact filename shape
// this module generates, and is reused to validate download requests against path traversal.
const BACKUP_FILENAME_RE = /^backup-\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}-\d{3}Z\.sql\.gz$/;
function ensureBackupDir() {
if (!fs.existsSync(BACKUP_DIR)) fs.mkdirSync(BACKUP_DIR, { recursive: true });
}
function backupFilename(date = new Date()) {
return `backup-${date.toISOString().replace(/[:.]/g, '-')}.sql.gz`;
}
/**
* Run `pg_dump` against DATABASE_URL, gzip its output, and write it to backend/backups/.
* Requires the `pg_dump` binary to be installed on this host (Postgres itself is hosted
* separately) a deploy-environment prerequisite, not something this code can satisfy.
*
* @returns {Promise<{ filename: string, path: string }>}
*/
async function runBackup() {
ensureBackupDir();
const databaseUrl = process.env.DATABASE_URL;
if (!databaseUrl) throw new Error('DATABASE_URL is not configured');
const filename = backupFilename();
const filePath = path.join(BACKUP_DIR, filename);
const pgDump = spawn('pg_dump', [databaseUrl, '--no-owner', '--no-privileges'], { stdio: ['ignore', 'pipe', 'pipe'] });
let stderr = '';
pgDump.stderr.on('data', (d) => { stderr += d.toString(); });
const exitPromise = new Promise((resolve, reject) => {
pgDump.on('error', (err) => reject(new Error(`Failed to start pg_dump: ${err.message}. Is it installed on this host?`)));
pgDump.on('close', (code) => {
if (code === 0) resolve();
else reject(new Error(`pg_dump exited with code ${code}: ${stderr.slice(0, 500)}`));
});
});
const pipelinePromise = pipeline(pgDump.stdout, zlib.createGzip(), fs.createWriteStream(filePath));
try {
// Both must succeed: the process exiting cleanly, and the gzip write finishing —
// a mid-dump failure must not leave a truncated file looking like a real backup.
await Promise.all([exitPromise, pipelinePromise]);
} catch (err) {
await fs.promises.unlink(filePath).catch(() => {});
throw err;
}
const retainCount = parseInt(getSettingSync('backup_retain_count', '14'), 10) || 14;
await rotateBackups(retainCount);
return { filename, path: filePath };
}
/** @returns {Promise<Array<{ filename: string, size: number, createdAt: Date }>>} newest first */
async function listBackups() {
ensureBackupDir();
const files = await fs.promises.readdir(BACKUP_DIR);
const backups = await Promise.all(
files.filter((f) => BACKUP_FILENAME_RE.test(f)).map(async (f) => {
const stat = await fs.promises.stat(path.join(BACKUP_DIR, f));
return { filename: f, size: stat.size, createdAt: stat.mtime };
})
);
return backups.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
}
/** Deletes backups beyond the retention count, oldest first. */
async function rotateBackups(retainCount) {
const backups = await listBackups();
const toDelete = backups.slice(retainCount);
await Promise.all(toDelete.map((b) => fs.promises.unlink(path.join(BACKUP_DIR, b.filename)).catch(() => {})));
return { deleted: toDelete.length };
}
module.exports = { runBackup, listBackups, rotateBackups, BACKUP_DIR, BACKUP_FILENAME_RE };
+6 -4
View File
@@ -83,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 },
@@ -191,15 +191,17 @@ 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);
+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 };
+9
View File
@@ -162,6 +162,14 @@ function fallbackLink(url) {
</p>`;
}
/** Renders a subtle, labeled secondary link — distinct from the primary CTA button. */
function secondaryLink(label, url) {
const color = getOrg().headerColor;
return `<p style="text-align:center;margin:16px 0 0 0;font-size:13px;color:#64748b">
<a href="${url}" style="color:${color}">${label}</a>
</p>`;
}
/** Horizontal rule. */
function divider() {
return `<div style="border-top:1px solid #f1f5f9;margin:32px 0"></div>`;
@@ -390,6 +398,7 @@ module.exports = {
emailWrapper,
ctaButton,
fallbackLink,
secondaryLink,
divider,
callout,
paymentOption,
+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 };
+261 -30
View File
@@ -1,6 +1,7 @@
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');
// ─── Formatting helpers ───────────────────────────────────────────────────────
@@ -21,6 +22,20 @@ function fmtDateShort(d) {
const { getSettingSync } = require('./settingsCache');
// The .ics calendar-download link lives on the backend (not the frontend site), same
// as the ticket-PDF URLs sent to WhatsApp — see whatsapp.js's BACKEND_URL usage.
function getBackendUrl() {
return (process.env.BACKEND_URL || '').replace(/\/$/, '');
}
// Only rendered when BACKEND_URL is actually configured — the .ics endpoint lives on
// the backend, and there's no reliable way to derive that URL otherwise.
function calendarLinkRow(eventId) {
const backendUrl = getBackendUrl();
if (!backendUrl || !eventId) return '';
return secondaryLink('Add to calendar', `${backendUrl}/api/events/${eventId}/ics`);
}
function getOrg() {
return {
name: getSettingSync('org_name', process.env.ORG_NAME || 'Cross Code'),
@@ -72,7 +87,7 @@ 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,
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 } } } },
@@ -98,7 +113,7 @@ 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,
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 } } } },
@@ -116,11 +131,11 @@ function selectionsTable(registrationOptions) {
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>`;
});
@@ -268,9 +283,10 @@ function buildRegistrationConfirmation(reg, { isNew = true } = {}) {
${financialSummary(totalDue, totalPaid, balance)}
${paymentSection({ balance, yocoLink: null, source: 'user', siteUrl: org.url, formRequired: false, isUserActive })}
${accountCta(isUserActive, org.url)}`;
${accountCta(isUserActive, org.url)}
${calendarLinkRow(reg.eventId)}`;
const itemsText = (reg.registrationOptions || []).map(ro => `${ro.eventOption?.name || 'Option'} ×${ro.quantity}${fmtAmount((ro.eventOption?.price || 0) * ro.quantity)}`).join('\n');
const itemsText = (reg.registrationOptions || []).map(ro => `${ro.eventOption?.name || 'Option'} ×${ro.quantity}${fmtAmount(computeOptionLineTotal(ro, null, new Date()))}`).join('\n');
const text = `${heading}\n\nHi ${reg.user?.name || 'there'},\n\n${isNew ? `You are registered for ${eventTitle}` : `Your registration for ${eventTitle} has been updated`}${eventDate ? ' on ' + eventDate : ''}.\n\nYour selections:\n${itemsText || ' —'}\n\nTotal due: ${fmtAmount(totalDue)}\nAmount paid: ${fmtAmount(totalPaid)}\nBalance: ${fmtAmount(balance)}\n\n${balance > 0 ? `Payment options:\n 1. On our website: ${org.url}\n 2. At the door (cash or card)\n\nYour tickets will be sent once payment is confirmed.` : 'No payment required — your tickets have been sent separately.'}\n\n${org.name}${org.email}\n${org.url}`;
return { subject, text, html: emailWrapper(body, { preheader }) };
@@ -308,9 +324,10 @@ function buildAdminInitiatedRegistrationConfirmation(reg, { yocoLink = null, for
${financialSummary(totalDue, totalPaid, balance)}
${paymentSection({ balance, yocoLink, source: 'admin', siteUrl: org.url, formRequired, isUserActive })}
${accountCta(isUserActive, org.url)}`;
${accountCta(isUserActive, org.url)}
${calendarLinkRow(reg.eventId)}`;
const itemsText = (reg.registrationOptions || []).map(ro => `${ro.eventOption?.name || 'Option'} ×${ro.quantity}${fmtAmount((ro.eventOption?.price || 0) * ro.quantity)}`).join('\n');
const itemsText = (reg.registrationOptions || []).map(ro => `${ro.eventOption?.name || 'Option'} ×${ro.quantity}${fmtAmount(computeOptionLineTotal(ro, null, new Date()))}`).join('\n');
const payText = balance > 0
? `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
@@ -338,7 +355,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 = `
@@ -898,25 +915,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) {
@@ -925,25 +991,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 }));
@@ -951,25 +1026,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 }));
@@ -977,28 +1061,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) {
@@ -1010,6 +1106,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 {}
}
}
@@ -1104,7 +1202,7 @@ async function sendDonationUnassignmentEmails(leg) {
prisma.registration.findUnique({
where: { id: leg.registrationId },
include: {
registrationOptions: { include: { eventOption: { include: { earlyBirdTiers: true } } } },
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 } } } },
@@ -1152,19 +1250,150 @@ async function sendDonationUnassignmentEmails(leg) {
}
}
// ─── 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' },
});
@@ -1175,7 +1404,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' },
@@ -1210,4 +1439,6 @@ module.exports = {
sendDonationUnassignmentEmails,
sendCheckInEmails,
buildCheckInConfirmation,
sendInvoiceToUser,
sendReceiptToUser,
};
+351
View File
@@ -0,0 +1,351 @@
/**
* 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;
}
/** 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 });
}
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 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) {
totalLine('SUBTOTAL', fmtAmount(fullPriceTotal));
totalLine('DISCOUNT', `-${fmtAmount(discount)}`, { 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, getBranding, docNumber };
+132 -44
View File
@@ -7,11 +7,21 @@
* 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).
* - 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');
@@ -74,12 +84,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 }
});
@@ -125,8 +138,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,21 +167,46 @@ async function refreshPricingForRegistration(registrationId) {
include: {
registrationOptions: {
include: {
eventOption: { include: { earlyBirdTiers: true, variants: true } }
}
eventOption: { include: { earlyBirdTiers: true, variants: true } },
tranches: true,
}
},
payments: true,
}
});
if (!registration) return { changed: false };
// Each registrationOption 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(registration.registrationOptions.map(async (ro) => {
// Only refresh options that were priced via a tier
if (!ro.appliedTierId) return 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) {
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.
@@ -177,19 +215,21 @@ async function refreshPricingForRegistration(registrationId) {
// Deadline has passed (or tier record missing) — resolve the next applicable tier
const resolved = ro.variantId
? await resolveVariantTierPrice(ro.eventOption, ro.variantId, ro.quantity)
: await resolveOptionPrice(ro.eventOption, ro.quantity);
? await resolveVariantTierPrice(ro.eventOption, ro.variantId, newest.quantity)
: await resolveOptionPrice(ro.eventOption, newest.quantity);
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 }
});
return true;
}
@@ -199,14 +239,43 @@ async function refreshPricingForRegistration(registrationId) {
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);
}
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).
*
* @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}
@@ -222,22 +291,38 @@ function computeRegistrationTotalDue(registration, atTime) {
}
} catch {}
return registration.registrationOptions.reduce((sum, ro) => {
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 registration.registrationOptions.reduce((sum, ro) => sum + computeOptionLineTotal(ro, lastPaymentAt, atTime), 0);
}
return sum + qty * unit;
}, 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.totalDueComputed = computeRegistrationTotalDue(registration, now);
return registration;
}
function attachComputedTotalsToList(registrations) {
return (registrations || []).map(attachComputedTotals);
}
module.exports = {
@@ -245,5 +330,8 @@ module.exports = {
resolveOptionPrice,
resolveVariantTierPrice,
refreshPricingForRegistration,
computeOptionLineTotal,
computeRegistrationTotalDue,
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 };
+2 -1
View File
@@ -46,8 +46,9 @@ function buildWARegistration(reg, { isNew = true, balance, totalDue, totalPaid }
? `You're registered for *${eventTitle}*${eventDate ? ` on ${eventDate}` : ''}.`
: `Your registration for *${eventTitle}* has been updated.`;
const { computeOptionLineTotal } = require('./pricing');
const items = (reg.registrationOptions || [])
.map(ro => `- ${ro.eventOption?.name || 'Option'} ×${ro.quantity}${fmtAmount((ro.eventOption?.price || 0) * ro.quantity)}`)
.map(ro => `- ${ro.eventOption?.name || 'Option'} ×${ro.quantity}${fmtAmount(computeOptionLineTotal(ro, null, new Date()))}`)
.join('\n');
const paid = totalPaid ?? (reg.payments || []).reduce((s, p) => s + (p.amount || 0), 0);
+46
View File
@@ -0,0 +1,46 @@
const { computeDonationRemaining } = require('../src/utils/donationUtils');
describe('computeDonationRemaining', () => {
test('a donation with no legs has its full amount remaining', () => {
expect(computeDonationRemaining(500, [])).toBe(500);
});
test('an allocation leg reduces the remaining balance', () => {
const legs = [{ amount: 200 }];
expect(computeDonationRemaining(500, legs)).toBe(300);
});
test('multiple allocation legs reduce the remaining balance cumulatively', () => {
const legs = [{ amount: 200 }, { amount: 150 }];
expect(computeDonationRemaining(500, legs)).toBe(150);
});
test('the 1.4.2 regression: refunding the donation itself (a negative-amount leg) reduces remaining balance, not inflates it', () => {
// 500 donation, never allocated, R200 of it refunded directly back to the donor
// (a leg with amount: -200). That R200 is no longer available to allocate — remaining
// must drop to 300. The pre-1.4.2 bug summed legs without Math.abs(), so
// remaining = 500 - (-200) = 700 (inflated) instead of 500 - 200 = 300 (correct).
const legs = [{ amount: -200 }];
expect(computeDonationRemaining(500, legs)).toBe(300);
});
test('an allocation and a separate direct refund both reduce the remaining balance', () => {
// 500 donation: R200 allocated to a registration, R100 separately refunded to the donor.
// Remaining = 500 - 200 - 100 = 200.
const legs = [{ amount: 200 }, { amount: -100 }];
expect(computeDonationRemaining(500, legs)).toBe(200);
});
test('unassigning an allocation removes its leg entirely rather than adding an offsetting one', () => {
// unassignDonationFromRegistration deletes the leg row outright (confirmed in
// paymentController.js), so the "leg no longer exists" case — not a negative-amount
// leg — is how an unassigned allocation becomes available again.
const legsAfterUnassign = [];
expect(computeDonationRemaining(500, legsAfterUnassign)).toBe(500);
});
test('handles a null/undefined legs array', () => {
expect(computeDonationRemaining(500, null)).toBe(500);
expect(computeDonationRemaining(500, undefined)).toBe(500);
});
});
+146
View File
@@ -0,0 +1,146 @@
const {
getEffectiveUnitPrice,
computeOptionLineTotal,
computeRegistrationTotalDue,
attachComputedTotals,
} = require('../src/utils/pricing');
const DAY = 24 * 60 * 60 * 1000;
const NOW = new Date('2026-06-01T00:00:00Z');
const PAST = new Date(NOW.getTime() - DAY);
const FUTURE = new Date(NOW.getTime() + DAY);
describe('getEffectiveUnitPrice', () => {
test('returns base price when there are no early-bird tiers', () => {
const option = { price: 100, earlyBirdTiers: [] };
expect(getEffectiveUnitPrice(option, null, NOW)).toBe(100);
});
test('returns tier price when the tier deadline is still in the future', () => {
const option = {
price: 100,
earlyBirdTiers: [{ id: 't1', price: 50, deadline: FUTURE }],
};
expect(getEffectiveUnitPrice(option, null, NOW)).toBe(50);
});
test('falls back to base price once the tier deadline has passed', () => {
const option = {
price: 100,
earlyBirdTiers: [{ id: 't1', price: 50, deadline: PAST }],
};
expect(getEffectiveUnitPrice(option, null, NOW)).toBe(100);
});
test('requires the deadline to be after both referenceTime and atTime', () => {
const midDeadline = new Date(NOW.getTime() - DAY / 2);
const option = {
price: 100,
earlyBirdTiers: [{ id: 't1', price: 50, deadline: midDeadline }],
};
// referenceTime (PAST) is before the deadline, but atTime (NOW) is after it — tier no longer applies
expect(getEffectiveUnitPrice(option, PAST, NOW)).toBe(100);
});
test('with multiple applicable tiers, picks the one with the earliest deadline', () => {
const soonerDeadline = new Date(NOW.getTime() + DAY);
const laterDeadline = new Date(NOW.getTime() + 2 * DAY);
const option = {
price: 100,
earlyBirdTiers: [
{ id: 'later', price: 80, deadline: laterDeadline },
{ id: 'sooner', price: 60, deadline: soonerDeadline },
],
};
expect(getEffectiveUnitPrice(option, null, NOW)).toBe(60);
});
test('returns 0 for a missing eventOption', () => {
expect(getEffectiveUnitPrice(null, null, NOW)).toBe(0);
});
});
describe('computeOptionLineTotal', () => {
test('the 1.8.0 regression: buying more after an early-bird tier expires only re-prices the new quantity', () => {
// 5 tickets bought at R50 (early-bird), then 1 more bought after the price rose to R100.
// Must total 5*50 + 1*100 = 350, not 6*100 = 600.
const ro = {
quantity: 6,
priceSnapshot: 100,
tranches: [
{ quantity: 5, priceSnapshot: 50, createdAt: PAST },
{ quantity: 1, priceSnapshot: 100, createdAt: NOW },
],
};
expect(computeOptionLineTotal(ro, null, NOW)).toBe(350);
});
test('sums a single tranche correctly', () => {
const ro = { quantity: 3, priceSnapshot: 40, tranches: [{ quantity: 3, priceSnapshot: 40 }] };
expect(computeOptionLineTotal(ro, null, NOW)).toBe(120);
});
test('legacy fallback: no tranches, uses priceSnapshot directly', () => {
const ro = { quantity: 4, priceSnapshot: 25, tranches: [] };
expect(computeOptionLineTotal(ro, null, NOW)).toBe(100);
});
test('legacy fallback: no tranches and no priceSnapshot, re-evaluates from tier deadlines', () => {
const ro = {
quantity: 2,
priceSnapshot: null,
tranches: [],
eventOption: { price: 100, earlyBirdTiers: [{ id: 't1', price: 70, deadline: FUTURE }] },
};
expect(computeOptionLineTotal(ro, null, NOW)).toBe(140);
});
});
describe('computeRegistrationTotalDue', () => {
test('sums tranche-aware totals across multiple RegistrationOptions', () => {
const registration = {
registrationOptions: [
{
quantity: 6,
priceSnapshot: 100,
tranches: [
{ quantity: 5, priceSnapshot: 50, createdAt: PAST },
{ quantity: 1, priceSnapshot: 100, createdAt: NOW },
],
},
{
quantity: 2,
priceSnapshot: 20,
tranches: [{ quantity: 2, priceSnapshot: 20, createdAt: PAST }],
},
],
payments: [],
};
// 350 (first option, see 1.8.0 regression case) + 40 (second option)
expect(computeRegistrationTotalDue(registration, NOW)).toBe(390);
});
test('returns 0 for a registration with no options', () => {
expect(computeRegistrationTotalDue({ registrationOptions: [] }, NOW)).toBe(0);
expect(computeRegistrationTotalDue(null, NOW)).toBe(0);
});
});
describe('attachComputedTotals', () => {
test('mutates the registration with per-line and total computed amounts', () => {
const registration = {
registrationOptions: [
{ quantity: 2, priceSnapshot: 50, tranches: [{ quantity: 2, priceSnapshot: 50 }] },
],
payments: [],
};
const result = attachComputedTotals(registration);
expect(result).toBe(registration); // mutated in place
expect(result.registrationOptions[0].lineTotal).toBe(100);
expect(result.totalDueComputed).toBe(100);
});
test('handles a null registration gracefully', () => {
expect(attachComputedTotals(null)).toBeNull();
});
});
+19
View File
@@ -0,0 +1,19 @@
module.exports = {
apps: [
{
name: 'hope-events-backend',
cwd: __dirname + '/backend',
script: 'src/index.js',
env: { NODE_ENV: 'production' },
max_memory_restart: '500M',
},
{
name: 'hope-events-frontend',
cwd: __dirname + '/frontend',
script: 'npm',
args: 'start -- -p 3000',
env: { NODE_ENV: 'production' },
max_memory_restart: '500M',
},
],
};
+6 -1
View File
@@ -1,4 +1,5 @@
import type { NextConfig } from "next";
import { withSentryConfig } from "@sentry/nextjs";
const nextConfig: NextConfig = {
images: {
@@ -12,4 +13,8 @@ const nextConfig: NextConfig = {
},
};
export default nextConfig;
// A no-op wrap when SENTRY_DSN isn't configured for this deployment — safe in
// every environment (dev, or a fresh deploy that hasn't set up Sentry yet).
export default process.env.NEXT_PUBLIC_SENTRY_DSN
? withSentryConfig(nextConfig, { silent: true, disableLogger: true })
: nextConfig;
+3886 -1725
View File
File diff suppressed because it is too large Load Diff
+9 -3
View File
@@ -1,6 +1,6 @@
{
"name": "hope-events-frontend",
"version": "1.7.0",
"version": "1.10.2",
"private": true,
"scripts": {
"dev": "next dev --turbopack",
@@ -27,12 +27,13 @@
"@radix-ui/react-tabs": "^1.1.12",
"@radix-ui/react-toast": "^1.2.14",
"@radix-ui/react-tooltip": "^1.2.7",
"@sentry/nextjs": "^10.71.0",
"@zxing/browser": "^0.1.5",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"date-fns": "^4.1.0",
"lucide-react": "^0.536.0",
"next": "15.4.5",
"next": "^15.5.24",
"qrcode": "^1.5.4",
"react": "19.1.0",
"react-day-picker": "^9.8.1",
@@ -51,9 +52,14 @@
"@types/react-dom": "^19",
"autoprefixer": "^10.4.21",
"eslint": "^9",
"eslint-config-next": "15.4.5",
"eslint-config-next": "^15.5.24",
"postcss": "^8.5.6",
"tailwindcss": "3.4",
"typescript": "^5"
},
"overrides": {
"next": {
"postcss": "^8.5.23"
}
}
}
+10
View File
@@ -3,16 +3,26 @@ import { apiFetch } from "@/lib/api";
export const revalidate = 60;
// Event aliases are admin-set slugs (e.g. "camp-2025", "movie-night") — see the
// "URL Alias" field in the event wizard. This catch-all route matches *any*
// unmatched top-level path, so it's also what every bot/scanner probe hits
// (/wp-login.php, /.env, /xmlrpc.php, etc.). Rejecting anything that isn't a
// plausible slug here skips a live DB query for that background noise instead
// of forwarding it straight to the backend.
const VALID_ALIAS = /^[a-zA-Z0-9_-]{1,100}$/;
export default async function EventRedirectPage({ params }: { params: Promise<{ redirectUrl: string }> }) {
const { redirectUrl } = await params;
let event: any = null;
if (VALID_ALIAS.test(redirectUrl)) {
try {
event = await apiFetch<any>(`/api/events/by-alias/${redirectUrl}`);
} catch (error) {
console.error("Failed to fetch event:", error);
}
}
if (!event || event.message?.toLowerCase().includes("not found")) {
return (
+7
View File
@@ -5,6 +5,7 @@ import { Navbar } from "@/components/layout/Navbar";
import { Footer } from "@/components/layout/Footer";
import { useSiteSettings } from "@/contexts/SiteSettingsContext";
import { appName } from "@/lib/siteConfig";
import { LocationMap } from "@/components/events/LocationMap";
export default function ContactPage() {
const { settings, loading } = useSiteSettings();
@@ -68,6 +69,12 @@ export default function ContactPage() {
</div>
)}
</div>
{address && (
<div className="mt-8 border rounded-xl p-5 bg-white shadow-sm">
<LocationMap address={address} />
</div>
)}
</main>
<Footer />
</div>
@@ -0,0 +1,218 @@
"use client";
import React, { useCallback, useEffect, useMemo, useState } from "react";
import { useAuth } from "@/hooks/useAuth";
import { useRouter } from "next/navigation";
import { apiFetch } from "@/lib/api";
import { useDismissingState } from "@/hooks/useDismissingState";
import { History } from "lucide-react";
import { Table, TableHeader, TableBody, TableRow, TableHead, TableCell } from "@/components/ui/table";
const ACTIONS = [
"refund_created",
"donation_assigned",
"donation_unassigned",
"registration_created_manual",
"registration_cancelled",
"event_created",
"event_updated",
"event_deleted",
"settings_updated",
] as const;
const ACTION_LABELS: Record<string, string> = {
refund_created: "Refund created",
donation_assigned: "Donation assigned",
donation_unassigned: "Donation unassigned",
registration_created_manual: "Manual registration created",
registration_cancelled: "Registration cancelled (staff)",
event_created: "Event created",
event_updated: "Event updated",
event_deleted: "Event deactivated",
settings_updated: "Settings updated",
};
interface AuditLogEntry {
id: string;
actorId: string | null;
actorRole: string;
action: string;
targetType: string;
targetId: string | null;
metadata: Record<string, unknown> | null;
ip: string | null;
createdAt: string;
actor: { id: string; name: string; email: string } | null;
}
function formatMetadata(metadata: Record<string, unknown> | null): string {
if (!metadata) return "";
try {
return Object.entries(metadata)
.filter(([, v]) => v !== null && v !== undefined && v !== "")
.map(([k, v]) => `${k}: ${Array.isArray(v) ? v.join(", ") : String(v)}`)
.join(" · ");
} catch {
return "";
}
}
export default function AdminAuditLogPage() {
const { user, loading, token } = useAuth();
const router = useRouter();
const isAdmin = useMemo(() => user?.role === "admin", [user]);
useEffect(() => {
if (loading) return;
if (!user) router.replace("/login");
}, [user, loading, router]);
const [entries, setEntries] = useState<AuditLogEntry[]>([]);
const [fetching, setFetching] = useState(false);
const [error, setError] = useDismissingState<string | null>(null);
const [page, setPage] = useState(1);
const [total, setTotal] = useState(0);
const pageSize = 50;
const totalPages = Math.max(1, Math.ceil(total / pageSize));
const [actionFilter, setActionFilter] = useState<string>("");
const [fromFilter, setFromFilter] = useState<string>("");
const [toFilter, setToFilter] = useState<string>("");
const buildQuery = useCallback((p: number) => {
const qs = new URLSearchParams({ page: String(p), limit: String(pageSize) });
if (actionFilter) qs.set("action", actionFilter);
if (fromFilter) qs.set("from", fromFilter);
if (toFilter) qs.set("to", toFilter);
return `/api/admin/audit-log?${qs.toString()}`;
}, [actionFilter, fromFilter, toFilter]);
const load = useCallback(async (p = 1) => {
if (!token) return;
setError(null);
setFetching(true);
try {
const res = await apiFetch<{ rows: AuditLogEntry[]; total: number; page: number }>(buildQuery(p), { authToken: token });
setEntries(res?.rows || []);
setTotal(res?.total ?? 0);
setPage(p);
} catch (e: any) {
setError(e?.message || "Failed to load audit log");
} finally {
setFetching(false);
}
}, [token, buildQuery]);
useEffect(() => { if (token) load(1); }, [token, actionFilter, fromFilter, toFilter]);
return (
<div className="max-w-6xl mx-auto w-full p-6">
<div className="flex items-center gap-3 mb-4">
<div className="w-10 h-10 rounded-xl bg-brand-50 flex items-center justify-center shrink-0">
<History className="w-5 h-5 text-brand-600" />
</div>
<div>
<h1 className="text-2xl font-semibold text-gray-900">Admin Audit Log</h1>
<p className="text-sm text-gray-500">{total} action{total !== 1 ? "s" : ""} recorded</p>
</div>
</div>
{!isAdmin && (
<div className="p-3 border rounded bg-yellow-50 text-yellow-800 text-sm mb-4">
You need admin access to view the audit log.
</div>
)}
{error && <div className="mb-3 p-3 border rounded bg-red-50 text-red-800 text-sm">{error}</div>}
<div className="border rounded-xl p-4 bg-white shadow-sm">
<div className="flex flex-wrap items-end gap-3 mb-4">
<div>
<label className="block text-xs text-gray-600 mb-1">Action</label>
<select className="border rounded px-2 py-1.5 text-sm" value={actionFilter} onChange={e => setActionFilter(e.target.value)}>
<option value="">All actions</option>
{ACTIONS.map(a => <option key={a} value={a}>{ACTION_LABELS[a]}</option>)}
</select>
</div>
<div>
<label className="block text-xs text-gray-600 mb-1">From</label>
<input type="date" className="border rounded px-2 py-1.5 text-sm" value={fromFilter} onChange={e => setFromFilter(e.target.value)} />
</div>
<div>
<label className="block text-xs text-gray-600 mb-1">To</label>
<input type="date" className="border rounded px-2 py-1.5 text-sm" value={toFilter} onChange={e => setToFilter(e.target.value)} />
</div>
<button className="text-sm px-2 py-1.5 rounded bg-gray-100 hover:bg-gray-200" onClick={() => load(page)} disabled={fetching}>
{fetching ? "Refreshing…" : "Refresh"}
</button>
</div>
<Table>
<TableHeader>
<TableRow>
<TableHead>When</TableHead>
<TableHead>Actor</TableHead>
<TableHead>Action</TableHead>
<TableHead>Target</TableHead>
<TableHead>Details</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{entries.map(e => (
<TableRow key={e.id}>
<TableCell className="whitespace-nowrap text-gray-600">{new Date(e.createdAt).toLocaleString()}</TableCell>
<TableCell>
{e.actor ? (
<>
<div className="font-medium">{e.actor.name}</div>
<div className="text-xs text-gray-500">{e.actor.email}</div>
</>
) : (
<span className="text-gray-400 italic">Deleted user</span>
)}
<div className="text-xs text-gray-400 capitalize">{e.actorRole}</div>
</TableCell>
<TableCell>{ACTION_LABELS[e.action] || e.action}</TableCell>
<TableCell className="text-xs text-gray-600">{e.targetType}{e.targetId ? ` #${e.targetId.slice(0, 8)}` : ""}</TableCell>
<TableCell className="text-xs text-gray-500 max-w-[280px] truncate" title={formatMetadata(e.metadata)}>{formatMetadata(e.metadata)}</TableCell>
</TableRow>
))}
{entries.length === 0 && !fetching && (
<TableRow>
<TableCell colSpan={5} className="text-gray-500">No matching audit entries.</TableCell>
</TableRow>
)}
{fetching && (
<TableRow>
<TableCell colSpan={5} className="text-gray-400">Loading</TableCell>
</TableRow>
)}
</TableBody>
</Table>
{totalPages > 1 && (
<div className="flex items-center justify-between mt-4 text-sm">
<span className="text-gray-500">Page {page} of {totalPages}</span>
<div className="flex gap-1">
<button
className="px-2 py-1 rounded bg-gray-100 hover:bg-gray-200 disabled:opacity-40"
disabled={page <= 1 || fetching}
onClick={() => load(page - 1)}
>
Prev
</button>
<button
className="px-2 py-1 rounded bg-gray-100 hover:bg-gray-200 disabled:opacity-40"
disabled={page >= totalPages || fetching}
onClick={() => load(page + 1)}
>
Next
</button>
</div>
</div>
)}
</div>
</div>
);
}
+2 -1
View File
@@ -8,7 +8,7 @@ import { useStableState } from "@/hooks/useStableState";
import { useVisiblePolling } from "@/hooks/useVisiblePolling";
import {
Calendar, Banknote, Gift, Users, Ticket, QrCode, ClipboardList,
UserPlus, FileText, MessageCircle, BarChart2, Mail, Wallet, DoorOpen,
UserPlus, FileText, MessageCircle, BarChart2, Mail, Wallet, DoorOpen, History,
} from "lucide-react";
import { StatCard, StatCardRow } from "@/components/shared/StatCard";
import { QuickActionTile, QuickActionGrid } from "@/components/shared/QuickActionTile";
@@ -33,6 +33,7 @@ const QUICK_ACTIONS = [
{ href: "/dashboard/supervisor/email-attendees", label: "Email attendees", description: "Send a message to attendees of an event", icon: Mail },
{ href: "/dashboard/supervisor/whatsapp-attendees", label: "WhatsApp attendees", description: "Send a WhatsApp message to event attendees", icon: MessageCircle },
{ href: "/dashboard/admin/cashup", label: "Post-event Cashup", description: "Set costs, reconcile takings, and close out events", icon: Wallet },
{ href: "/dashboard/admin/audit-log", label: "Audit log", description: "Review refunds, manual registrations, event and settings changes", icon: History },
] as const;
type OverviewMetric = { thisMonth: number; lastMonth: number; pctChange: number | null };
@@ -158,7 +158,9 @@ export default function AdminRegistrationsPage() {
return "text-gray-700 bg-gray-50";
};
const totalDueFor = (r: any) => (r.registrationOptions || []).reduce((sum: number, opt: any) => {
// Backend attaches a tranche-aware totalDueComputed (exact even when a line spans multiple
// early-bird prices) — fall back to the old client-side estimate only for stale payloads.
const totalDueFor = (r: any) => r.totalDueComputed ?? (r.registrationOptions || []).reduce((sum: number, opt: any) => {
const unit = (opt.priceSnapshot !== null && opt.priceSnapshot !== undefined)
? Number(opt.priceSnapshot)
: (opt.eventOption?.price || 0);
@@ -334,25 +336,40 @@ export default function AdminRegistrationsPage() {
<div className="mb-3">
<div className="text-xs font-semibold text-gray-600 mb-1 uppercase tracking-wide">Ticket options</div>
<div className="grid sm:grid-cols-2 gap-2">
{r.registrationOptions.map((opt: any) => (
{r.registrationOptions.map((opt: any) => {
// A line can span multiple price tranches (e.g. tickets bought
// before and after an early-bird tier expired) — show one row per
// tranche so its own price/tier status is accurate, not blended.
const tranches = Array.isArray(opt.tranches) && opt.tranches.length > 0
? opt.tranches
: [{
id: opt.id,
quantity: opt.quantity,
priceSnapshot: (opt.priceSnapshot !== null && opt.priceSnapshot !== undefined)
? Number(opt.priceSnapshot)
: (opt.variant?.price ?? opt.eventOption?.price ?? 0),
appliedTierId: opt.appliedTierId,
}];
return (
<div key={opt.id} className="bg-white border rounded p-2 text-xs">
<div className="font-medium">
{opt.eventOption?.name || opt.eventOptionId}
{opt.variant?.name && <span className="text-gray-500"> ({opt.variant.name})</span>}
</div>
<div className="text-gray-500">
{(() => {
const unit = (opt.priceSnapshot !== null && opt.priceSnapshot !== undefined)
? Number(opt.priceSnapshot)
: (opt.variant?.price ?? opt.eventOption?.price ?? 0);
return `Qty: ${opt.quantity} × R ${unit.toFixed(2)} = R ${(unit * (opt.quantity || 0)).toFixed(2)}`;
})()}
</div>
{opt.appliedTierId && (
<div className="text-green-700 text-[10px] mt-0.5">Early-bird price applied</div>
{tranches.map((t: any, idx: number) => {
const unit = Number(t.priceSnapshot || 0);
return (
<div key={t.id || idx} className="text-gray-500">
{`Qty: ${t.quantity} × R ${unit.toFixed(2)} = R ${(unit * (t.quantity || 0)).toFixed(2)}`}
{t.appliedTierId && (
<span className="text-green-700 text-[10px] ml-1">(early bird)</span>
)}
</div>
))}
);
})}
</div>
);
})}
</div>
<div className="text-xs text-gray-700 mt-1 font-medium">Total: R {totalDue.toFixed(2)}</div>
</div>
@@ -6,12 +6,14 @@ import { useRouter, useSearchParams } from "next/navigation";
import { apiFetch, API_BASE, resolveToApiOrigin } from "@/lib/api";
import { useSiteSettings } from "@/contexts/SiteSettingsContext";
import { useDismissingState } from "@/hooks/useDismissingState";
import { Building2, Palette, Bell, Mail, Scale, MessageCircle, type LucideIcon } from "lucide-react";
import { Building2, Palette, Bell, Mail, Scale, MessageCircle, DatabaseBackup, type LucideIcon } from "lucide-react";
import { ColorPickerField } from "@/components/admin/ColorPickerField";
import { BrandingPreviewPanel } from "@/components/admin/BrandingPreviewPanel";
import { BackupsTab } from "@/components/admin/BackupsTab";
import { extractDominantColors } from "@/lib/extractColors";
import { mapsSearchUrl } from "@/lib/maps";
type TabId = "organisation" | "branding" | "notifications" | "email" | "legal" | "whatsapp";
type TabId = "organisation" | "branding" | "notifications" | "email" | "legal" | "whatsapp" | "backups";
const TABS: { id: TabId; label: string; icon: LucideIcon }[] = [
{ id: "organisation", label: "Organisation", icon: Building2 },
@@ -20,6 +22,7 @@ const TABS: { id: TabId; label: string; icon: LucideIcon }[] = [
{ id: "email", label: "Email", icon: Mail },
{ id: "legal", label: "Legal", icon: Scale },
{ id: "whatsapp", label: "WhatsApp", icon: MessageCircle },
{ id: "backups", label: "Backups", icon: DatabaseBackup },
];
const inputCls =
@@ -410,9 +413,15 @@ function SiteSettingsPageInner() {
value={orgPhone} onChange={e => setOrgPhone(e.target.value)} />
</Field>
</div>
<Field label="Address">
<Field label="Address" hint="Used as the default location for new events.">
<input className={inputCls} placeholder="123 Church St, City"
value={orgAddress} onChange={e => setOrgAddress(e.target.value)} />
{orgAddress.trim() && (
<a href={mapsSearchUrl(orgAddress.trim())} target="_blank" rel="noopener noreferrer"
className="inline-block text-xs text-brand-600 hover:underline mt-1">
View on map
</a>
)}
</Field>
<Field label="Site URL" hint="The public URL of this site — used in email links (e.g. password reset, ticket delivery). e.g. https://events.yourchurch.org">
<input className={inputCls} placeholder="https://events.yourchurch.org"
@@ -655,6 +664,9 @@ function SiteSettingsPageInner() {
{/* ── WhatsApp ──────────────────────────────────────────────────────── */}
{activeTab === "whatsapp" && <WhatsAppTab active={activeTab === "whatsapp"} />}
{/* ── Backups ───────────────────────────────────────────────────────── */}
{activeTab === "backups" && <BackupsTab active={activeTab === "backups"} />}
</div>
{activeTab === "branding" && (
@@ -242,7 +242,6 @@ export default function AtTheDoorPage() {
authToken: token,
body: {
eventId,
guestOnly: pendingUser.guestOnly,
user: {
name: pendingUser.name,
...(pendingUser.email ? { email: pendingUser.email } : {}),
@@ -282,7 +281,7 @@ export default function AtTheDoorPage() {
});
setQuantities(qtyMap);
setMinQuantities({});
setPendingUser({ guestOnly: true, name, email: email || null, phone: phone || null, notifPref });
setPendingUser({ name, email: email || null, phone: phone || null, notifPref });
setPendingEditReg(null);
setShowNewAttendeeModal(false);
setShowOptionsModal(true);
@@ -608,8 +607,9 @@ function DoorPaymentPanel({ token, registration, onSuccess, setError }: any) {
const options = registration.options || registration.registrationOptions || [];
const payments = registration.payments || [];
const totalValue = options.reduce((sum: number, opt: any) => {
// Use priceSnapshot (authoritative backend price, variant-aware) if available
// Backend attaches a tranche-aware totalDueComputed (exact even when a line spans
// multiple early-bird prices) — fall back to the old client-side estimate otherwise.
const totalValue = registration.totalDueComputed ?? options.reduce((sum: number, opt: any) => {
const price = (opt.priceSnapshot !== null && opt.priceSnapshot !== undefined)
? Number(opt.priceSnapshot)
: (opt.eventOption?.price ?? opt.price ?? 0);
@@ -650,7 +650,7 @@ function DoorPaymentPanel({ token, registration, onSuccess, setError }: any) {
const updatedOptions = updated.options || updated.registrationOptions || [];
const updatedPayments = updated.payments || [];
const totalValue = updatedOptions.reduce((sum: number, opt: any) => {
const totalValue = updated.totalDueComputed ?? updatedOptions.reduce((sum: number, opt: any) => {
const price = (opt.priceSnapshot !== null && opt.priceSnapshot !== undefined)
? Number(opt.priceSnapshot)
: (opt.eventOption?.price ?? opt.price ?? 0);
@@ -1737,12 +1737,12 @@ function DoorRefundPanel({ token, eventId, setError, setInfo }: any) {
<div className="border rounded p-3 text-center">
<div className="text-xs text-gray-500">TOTAL</div>
<div className="font-semibold">
R {(selectedReg.options || selectedReg.registrationOptions || []).reduce((s: number, o: any) => {
R {(selectedReg.totalDueComputed ?? (selectedReg.options || selectedReg.registrationOptions || []).reduce((s: number, o: any) => {
const price = o.priceSnapshot !== null && o.priceSnapshot !== undefined
? Number(o.priceSnapshot)
: (o.eventOption?.price || o.price || 0);
return s + price * (o.quantity || 0);
}, 0).toFixed(2)}
}, 0)).toFixed(2)}
</div>
</div>
<div className="border rounded p-3 text-center bg-red-50">
@@ -21,7 +21,7 @@ function toLocalDateTimeInputValue(input: string | number | Date | null | undefi
return `${y}-${m}-${day}T${hh}:${mm}`;
}
function EarlyBirdTiersEditor({ option, onSave }: { option: any; onSave: (tiers: { deadline: string; price: number; order?: number }[]) => void }) {
function EarlyBirdTiersEditor({ option, onSave }: { option: any; onSave: (tiers: { id?: string; deadline: string; price: number; order?: number }[]) => void }) {
const [rows, setRows] = React.useState<{ id?: string; deadline: string; price: string; order?: number }[]>([]);
const [open, setOpen] = React.useState(false);
const [saving, setSaving] = React.useState(false);
@@ -54,7 +54,7 @@ function EarlyBirdTiersEditor({ option, onSave }: { option: any; onSave: (tiers:
try {
const tiers = rows
.filter((r) => !!r.deadline && String(r.price).trim() !== '')
.map((r, i) => ({ deadline: new Date(r.deadline).toISOString(), price: parseFloat(r.price), order: typeof r.order === 'number' ? r.order : i }))
.map((r, i) => ({ id: r.id, deadline: new Date(r.deadline).toISOString(), price: parseFloat(r.price), order: typeof r.order === 'number' ? r.order : i }))
.filter((t) => t.price >= 0 && !isNaN(new Date(t.deadline).getTime()));
onSave(tiers);
} finally {
@@ -6,6 +6,7 @@ import { useAuth } from "@/hooks/useAuth";
import { useRouter } from "next/navigation";
import { apiFetch, resolveToApiOrigin } from "@/lib/api";
import { useDismissingState } from "@/hooks/useDismissingState";
import { useSiteSettings } from "@/contexts/SiteSettingsContext";
import { Calendar } from "lucide-react";
// ─── helpers ────────────────────────────────────────────────────────────────
@@ -755,12 +756,16 @@ interface EventDraft {
title: string; description: string; startDate: string; endDate: string;
registrationDeadline: string; goLiveAt: string; price: string; picture: string;
redirectUrl: string; isActive: boolean; isHidden: boolean; requiresAuth: boolean;
requiresRegistration: boolean; contactName: string; contactPhone: string; contactEmail: string;
location: string;
}
const blankDraft = (): EventDraft => ({
const blankDraft = (location = ""): EventDraft => ({
title: "", description: "", startDate: "", endDate: "",
registrationDeadline: "", goLiveAt: "", price: "", picture: "",
redirectUrl: "", isActive: true, isHidden: false, requiresAuth: true,
requiresRegistration: true, contactName: "", contactPhone: "", contactEmail: "",
location,
});
const blankOptions = (): OptionDraft[] => [
@@ -779,19 +784,25 @@ interface EventModalProps {
function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) {
const { token, user } = useAuth();
const isAdmin = user?.role === "admin";
const { settings } = useSiteSettings();
const [step, setStep] = useState<StepIdx>(0);
const [pricingSubstep, setPricingSubstep] = useState<PricingSubstep>(0);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
// ── event draft ──
// New events default their location to the organisation's address (settings.org_address);
// editing an existing event always reflects its own saved location instead.
const [draft, setDraft] = useState<EventDraft>(() => ev ? {
title: ev.title || "", description: ev.description || "",
startDate: toLocalDT(ev.startDate), endDate: toLocalDT(ev.endDate),
registrationDeadline: toLocalDT(ev.registrationDeadline), goLiveAt: toLocalDT(ev.goLiveAt),
price: String(ev.price ?? ""), picture: ev.picture || "", redirectUrl: ev.redirectUrl || "",
isActive: ev.isActive !== false, isHidden: !!ev.isHidden, requiresAuth: ev.requiresAuth !== false,
} : blankDraft());
requiresRegistration: ev.requiresRegistration !== false,
contactName: ev.contactName || "", contactPhone: ev.contactPhone || "", contactEmail: ev.contactEmail || "",
location: ev.location || "",
} : blankDraft(settings.org_address || ""));
// ── options (with per-variant tiers) ──
const [options, setOptions] = useState<OptionDraft[]>(() => {
@@ -873,6 +884,17 @@ function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) {
// ── save helpers ──
const saveOptions = async (eventId: string) => {
if (!draft.requiresRegistration) {
// Contact-only events have no ticket options to save — but a form (if any) still needs
// saving in edit mode; for create mode the form is already included in the POST body.
if (mode === "edit") {
await apiFetch(`/api/events/${eventId}`, {
method: "PUT", authToken: token || undefined,
body: { form: { isRequired: !!formDef.isRequired, fields: formDef.fields.filter(f => f.label?.trim()).map((f, i) => ({ type: f.type, label: f.label, isRequired: !!f.isRequired, order: i, helpText: f.helpText || null })) } }
});
}
return;
}
if (mode === "edit") {
for (const opt of options) {
// Build flat tier array: option-level + all variant tiers
@@ -1032,7 +1054,7 @@ function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) {
const handleSave = async () => {
if (!draft.title.trim()) { setError("Title is required"); setStep(0); return; }
if (!draft.startDate || !draft.endDate) { setError("Start and end dates are required"); setStep(0); return; }
if (mode === "create") {
if (mode === "create" && draft.requiresRegistration) {
if (isPriceInvalid(draft.price)) { setError("Base price is required (enter 0 for a free event)"); setStep(0); return; }
if (options.some(o => isPriceInvalid(o.price))) { setError("Every option needs a price (enter 0 for a free option)"); setStep(1); setPricingSubstep(0); return; }
}
@@ -1043,9 +1065,14 @@ function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) {
startDate: new Date(draft.startDate).toISOString(), endDate: new Date(draft.endDate).toISOString(),
registrationDeadline: draft.registrationDeadline ? new Date(draft.registrationDeadline).toISOString() : undefined,
goLiveAt: draft.goLiveAt ? new Date(draft.goLiveAt).toISOString() : undefined,
price: draft.price ? parseFloat(draft.price) : 0,
price: draft.requiresRegistration ? (draft.price ? parseFloat(draft.price) : 0) : 0,
picture: draft.picture || undefined, isHidden: draft.isHidden, requiresAuth: draft.requiresAuth,
redirectUrl: draft.redirectUrl?.trim().replace(/\s+/g, "-") || undefined,
requiresRegistration: draft.requiresRegistration,
contactName: draft.contactName || undefined,
contactPhone: draft.contactPhone || undefined,
contactEmail: draft.contactEmail || undefined,
location: draft.location.trim(),
};
if (mode === "edit") {
@@ -1115,10 +1142,11 @@ function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) {
const isOnLastSubstep = step !== 1 || pricingSubstep === 2;
// Basic Details step requires title, start/end dates, and a base price before moving on
// (mirrors handleSave's own checks). Base price is only compulsory when creating a new event.
const basicDetailsIncomplete = step === 0 && (!draft.title.trim() || !draft.startDate || !draft.endDate || (mode === "create" && isPriceInvalid(draft.price)));
const basicDetailsIncomplete = step === 0 && (!draft.title.trim() || !draft.startDate || !draft.endDate || (mode === "create" && draft.requiresRegistration && isPriceInvalid(draft.price)));
// Items & Pricing: every option needs a valid price before leaving the step (checked across
// all pricing substeps so switching to Variants/Early Birds can't be used to skip the gate).
const optionsIncomplete = mode === "create" && step === 1 && options.some(o => isPriceInvalid(o.price));
// Not applicable to contact-only events, which have no ticket options at all.
const optionsIncomplete = mode === "create" && draft.requiresRegistration && step === 1 && options.some(o => isPriceInvalid(o.price));
const nextDisabled = basicDetailsIncomplete || optionsIncomplete;
// ── render ──
@@ -1189,11 +1217,45 @@ function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) {
<DTInput label="Registration Deadline (optional)" value={draft.registrationDeadline} onChange={v => upd({ registrationDeadline: v })} />
<DTInput label="Go Live At (optional)" value={draft.goLiveAt} onChange={v => upd({ goLiveAt: v })} hint="Leave blank to show immediately" />
</div>
<div>
<label className="block text-xs text-gray-600 mb-1">Location</label>
<input className="w-full border rounded px-3 py-2 text-sm" value={draft.location} onChange={e => upd({ location: e.target.value })} placeholder="e.g. 123 Church St, City" />
<p className="text-[10px] text-gray-400 mt-0.5">Defaults to your organisation&apos;s address shown to attendees with a map link.</p>
</div>
<div className="flex items-start gap-2 p-3 border rounded bg-gray-50">
<input
type="checkbox"
id="contact-only-toggle"
className="mt-0.5"
checked={!draft.requiresRegistration}
onChange={e => upd({ requiresRegistration: !e.target.checked, price: e.target.checked ? "0" : draft.price })}
/>
<label htmlFor="contact-only-toggle" className="text-xs text-gray-700">
<span className="font-medium">This is a contact-only event</span> (e.g. baptism) no online registration or payment. Shows a "Contact us" button on the public site instead of "Register".
</label>
</div>
{draft.requiresRegistration ? (
<div>
<label className="block text-xs text-gray-600 mb-1">Base Price (R) {mode === "create" && <span className="text-red-500">*</span>}</label>
<input type="number" step="1" min="0" required={mode === "create"} className="w-full border rounded px-3 py-2 text-sm" value={draft.price} onChange={e => upd({ price: e.target.value })} placeholder="0" />
{mode === "create" && <p className="text-[10px] text-gray-400 mt-0.5">Auto-fills the first ticket option below enter 0 for a free event.</p>}
</div>
) : (
<div className="space-y-3 p-3 border rounded">
<div>
<label className="block text-xs text-gray-600 mb-1">Contact Name</label>
<input className="w-full border rounded px-3 py-2 text-sm" value={draft.contactName} onChange={e => upd({ contactName: e.target.value })} placeholder="e.g. Pastor John" />
</div>
<div>
<label className="block text-xs text-gray-600 mb-1">Contact Phone</label>
<input className="w-full border rounded px-3 py-2 text-sm" value={draft.contactPhone} onChange={e => upd({ contactPhone: e.target.value })} placeholder="e.g. 082 123 4567" />
</div>
<div>
<label className="block text-xs text-gray-600 mb-1">Contact Email</label>
<input type="email" className="w-full border rounded px-3 py-2 text-sm" value={draft.contactEmail} onChange={e => upd({ contactEmail: e.target.value })} placeholder="e.g. info@hopefamilychurch.org" />
</div>
</div>
)}
<div>
<label className="block text-xs text-gray-600 mb-1">Picture</label>
{draft.picture && <img src={resolveToApiOrigin(draft.picture) || undefined} alt="" className="h-20 w-20 object-cover rounded border mb-2" />}
@@ -18,7 +18,6 @@ export default function ManualRegistrationPage() {
const [name, setName] = useState("");
const [email, setEmail] = useState("");
const [phoneNumber, setPhoneNumber] = useState("");
const [registerAsGuest, setRegisterAsGuest] = useState(false);
const [busy, setBusy] = useState(false);
const [error, setError] = useDismissingState<string | null>(null);
const [createdReg, setCreatedReg] = useState<any | null>(null);
@@ -43,7 +42,6 @@ export default function ManualRegistrationPage() {
eventId,
options: [{ eventOptionId: optionId, quantity }],
user: { name, ...(email ? { email } : {}), ...(phoneNumber ? { phoneNumber } : {}) },
guestOnly: registerAsGuest,
},
});
setCreatedReg(res);
@@ -89,10 +87,7 @@ export default function ManualRegistrationPage() {
<input className="w-full border rounded px-3 py-2" value={name} onChange={(e) => setName(e.target.value)} required />
</div>
<div>
<div className="flex items-center justify-between">
<label className="block text-sm font-medium">Email</label>
<label className="text-xs flex items-center gap-2"><input type="checkbox" checked={registerAsGuest} onChange={e=>setRegisterAsGuest(e.target.checked)} /> Guest (no account)</label>
</div>
<input type="email" className="w-full border rounded px-3 py-2" value={email} onChange={(e) => setEmail(e.target.value)} placeholder="email@example.com" />
</div>
</div>
@@ -100,7 +95,7 @@ export default function ManualRegistrationPage() {
<label className="block text-sm font-medium">Cell Number</label>
<input type="tel" className="w-full border rounded px-3 py-2" value={phoneNumber} onChange={(e) => setPhoneNumber(e.target.value)} placeholder="+27…" />
</div>
<p className="text-xs text-gray-500">At least one of email or cell number is required. If no email is provided, a guest account is created automatically.</p>
<p className="text-xs text-gray-500">At least one of email or cell number is required. The account is created inactive, and an activation link is sent immediately (by email if provided, otherwise WhatsApp) so the attendee can set their own password.</p>
{error && <p className="text-sm text-red-600">{error}</p>}
<button type="submit" disabled={busy} className="bg-brand-600 hover:bg-brand-700 text-white rounded px-4 py-2 disabled:opacity-60">
{busy ? "Submitting..." : "Create"}
@@ -76,7 +76,6 @@ export default function ManualRegistrationPage() {
const [allUsers, setAllUsers] = useState<any[]>([]);
const [guest, setGuest] = useState({ name: "", email: "", phoneNumber: "" });
const [registerAsGuest, setRegisterAsGuest] = useState(false);
const [notifPref, setNotifPref] = useState<"email" | "whatsapp" | "both">("email");
const [quantities, setQuantities] = useState<Record<string, number>>({});
@@ -126,7 +125,7 @@ export default function ManualRegistrationPage() {
const regOutstanding = useMemo(() => {
const map: Record<string, { totalDue: number; totalPaid: number; outstanding: number }> = {};
for (const r of allRegistrations) {
const totalDue = (r.registrationOptions || []).reduce((sum: number, opt: any) => sum + optionUnitPrice(opt) * (opt.quantity || 0), 0);
const totalDue = r.totalDueComputed ?? (r.registrationOptions || []).reduce((sum: number, opt: any) => sum + optionUnitPrice(opt) * (opt.quantity || 0), 0);
const totalPaid = (r.payments || []).reduce((s: number, p: any) => s + (p.amount || 0), 0);
map[r.id] = { totalDue, totalPaid, outstanding: Math.max(0, totalDue - totalPaid) };
}
@@ -287,7 +286,7 @@ export default function ManualRegistrationPage() {
setError(null);
setMessage(null);
if (!selectedEventId) { setError("Please select an event."); return; }
if (!guest.name || (!registerAsGuest && !guest.email)) { setError("Guest name and email are required."); return; }
if (!guest.name || (!guest.email.trim() && !guest.phoneNumber.trim())) { setError("Guest name and at least one of email or phone are required."); return; }
const opts = Object.entries(quantities)
.filter(([, qty]) => qty > 0)
.map(([key, quantity]) => {
@@ -308,7 +307,6 @@ export default function ManualRegistrationPage() {
eventId: selectedEventId,
options: opts,
user: guest,
guestOnly: registerAsGuest,
notificationPreference: resolvedPref,
}
});
@@ -324,7 +322,6 @@ export default function ManualRegistrationPage() {
// Reset guest/ticket fields so the next registration starts from a clean slate
setGuest({ name: "", email: "", phoneNumber: "" });
setRegisterAsGuest(false);
setNotifPref("email");
setUserQuery("");
setDropdownOpen(false);
@@ -442,13 +439,6 @@ export default function ManualRegistrationPage() {
)}
</div>
<div className="border-t pt-3 mb-3">
<div className="flex items-center gap-2">
<input id="registerAsGuest" type="checkbox" checked={registerAsGuest} onChange={e => setRegisterAsGuest(e.target.checked)} />
<label htmlFor="registerAsGuest" className="text-sm text-gray-700">Guest (do not link to an existing account)</label>
</div>
</div>
<div className="grid gap-2">
<input
className="border rounded px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-brand-400"
@@ -458,11 +448,10 @@ export default function ManualRegistrationPage() {
/>
<input
className="border rounded px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-brand-400"
placeholder={registerAsGuest ? "Email (optional for guest)" : "Email"}
placeholder="Email (or provide a phone number below)"
type="email"
value={guest.email}
onChange={e => setGuest({ ...guest, email: e.target.value })}
required={!registerAsGuest}
/>
<input
className="border rounded px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-brand-400"
@@ -199,8 +199,9 @@ function PaymentsContent() {
const now = new Date();
const map: Record<string, { totalDue: number; totalPaid: number; outstanding: number }> = {};
for (const r of list) {
// totalDue uses priceSnapshot — not time-dependent
const totalDue = (r.registrationOptions || []).reduce((sum: number, opt: any) => sum + optionUnitPrice(opt, null, now) * (opt.quantity || 0), 0);
// Backend attaches a tranche-aware totalDueComputed (exact even when a line spans
// multiple early-bird prices) — fall back to the old client-side estimate otherwise.
const totalDue = r.totalDueComputed ?? (r.registrationOptions || []).reduce((sum: number, opt: any) => sum + optionUnitPrice(opt, null, now) * (opt.quantity || 0), 0);
const totalPaid = (r.payments || []).reduce((s: number, p: any) => s + (p.amount || 0), 0);
map[r.id] = { totalDue, totalPaid, outstanding: Math.max(0, totalDue - totalPaid) };
}
+141 -62
View File
@@ -89,7 +89,9 @@ export default function UserDashboardPage() {
// Registration details modal
const [activeRegId, setActiveRegId] = useState<string | null>(null);
const [dialog, setDialog] = useState<{ open: boolean; message: string; loading?: boolean; loadingTitle?: string; loadingSubtitle?: string }>({ open: false, message: "", loading: false });
const [dialog, setDialog] = useState<{ open: boolean; message: string; loading?: boolean; loadingTitle?: string; loadingSubtitle?: string; title?: string }>({ open: false, message: "", loading: false });
// Channel-choice popup shown by the single "Invoice"/"Receipt" buttons — asks Email or WhatsApp.
const [channelPicker, setChannelPicker] = useState<{ kind: 'invoice' | 'receipt'; id: string } | null>(null);
// Track whether the active registration's event has attendee forms
const [activeEventHasForm, setActiveEventHasForm] = useState<boolean | null>(null);
@@ -140,12 +142,11 @@ export default function UserDashboardPage() {
setRegistrations(myRegs);
setTickets(myTicks);
// Compute totalDue from priceSnapshot (authoritative backend price, variant-aware).
// priceSnapshot is set at registration time and refreshed before each payment.
const now = new Date();
// Use the backend's tranche-aware totalDueComputed (exact even when a line spans
// multiple early-bird prices) rather than re-deriving from priceSnapshot client-side.
const totals: Record<string, { totalDue: number; totalPaid: number; outstanding: number; payments: any[] }> = {};
for (const r of myRegs) {
const totalDue = (r.registrationOptions || []).reduce((sum: number, opt: any) => sum + optionUnitPrice(opt, null, now) * (opt.quantity || 0), 0);
const totalDue = r.totalDueComputed ?? (r.registrationOptions || []).reduce((sum: number, opt: any) => sum + optionUnitPrice(opt, null, new Date()) * (opt.quantity || 0), 0);
totals[r.id] = { totalDue, totalPaid: 0, outstanding: totalDue, payments: [] };
}
setBilling(totals);
@@ -156,8 +157,8 @@ export default function UserDashboardPage() {
try {
const pays = await apiFetch<any[]>(`/api/payments/registration/${encodeURIComponent(r.id)}`, { authToken: token });
const totalPaid = pays.reduce((s, p) => s + (p.amount || 0), 0);
// totalDue uses priceSnapshot — not time-dependent, no need to recompute per payment time
const totalDue = (r.registrationOptions || []).reduce((sum: number, opt: any) => sum + optionUnitPrice(opt, null, now) * (opt.quantity || 0), 0);
// totalDueComputed is not time-dependent, no need to recompute per payment time
const totalDue = r.totalDueComputed ?? (r.registrationOptions || []).reduce((sum: number, opt: any) => sum + optionUnitPrice(opt, null, new Date()) * (opt.quantity || 0), 0);
const outstanding = Math.max(0, totalDue - totalPaid);
setBilling(prev => ({
...prev,
@@ -254,26 +255,6 @@ export default function UserDashboardPage() {
}
};
const emailRegistration = async (registrationId: string) => {
if (!token) return;
setError(null);
setInfo(null);
// Show loading dialog while sending
setDialog({ open: true, message: "Sending tickets…", loading: true });
try {
const res: any = await apiFetch("/api/tickets/email", {
method: "POST",
body: { registrationId },
authToken: token,
});
const msg = (res && res.message) ? res.message : `Tickets for registration emailed successfully.`;
setDialog({ open: true, message: msg, loading: false });
} catch (e: any) {
setDialog({ open: false, message: "", loading: false });
setError(e?.message || "Failed to email registration tickets");
}
};
const whatsappTickets = async (ticketIds: string[]) => {
if (!token || ticketIds.length === 0) return;
if (!user?.phoneNumber) { setError("No phone number on your account. Add one in your profile."); return; }
@@ -294,26 +275,60 @@ export default function UserDashboardPage() {
}
};
const whatsappRegistration = async (registrationId: string) => {
const sendInvoice = async (registrationId: string, channel: 'email' | 'whatsapp') => {
if (!token) return;
if (!user?.phoneNumber) { setError("No phone number on your account. Add one in your profile."); return; }
setError(null);
setInfo(null);
setDialog({ open: true, message: "Sending to WhatsApp…", loading: true });
setDialog({
open: true, loading: true, message: "",
loadingTitle: channel === 'whatsapp' ? "Sending invoice to WhatsApp…" : "Sending invoice…",
loadingSubtitle: "Please wait while we prepare your invoice.",
});
try {
const res: any = await apiFetch("/api/tickets/email", {
const res: any = await apiFetch(`/api/registrations/${encodeURIComponent(registrationId)}/send-invoice`, {
method: "POST",
body: { registrationId, channel: "whatsapp" },
body: { channel },
authToken: token,
});
const msg = (res && res.message) ? res.message : `Tickets sent to WhatsApp.`;
setDialog({ open: true, message: msg, loading: false });
setDialog({ open: true, loading: false, title: "Invoice sent", message: (res && res.message) || "Invoice sent." });
} catch (e: any) {
setDialog({ open: false, message: "", loading: false });
setError(e?.message || "Failed to send tickets to WhatsApp");
setError(e?.message || "Failed to send invoice");
}
};
const sendReceipt = async (paymentId: string, channel: 'email' | 'whatsapp') => {
if (!token) return;
setError(null);
setInfo(null);
setDialog({
open: true, loading: true, message: "",
loadingTitle: channel === 'whatsapp' ? "Sending receipt to WhatsApp…" : "Sending receipt…",
loadingSubtitle: "Please wait while we prepare your receipt.",
});
try {
const res: any = await apiFetch(`/api/payments/${encodeURIComponent(paymentId)}/send-receipt`, {
method: "POST",
body: { channel },
authToken: token,
});
setDialog({ open: true, loading: false, title: "Receipt sent", message: (res && res.message) || "Receipt sent." });
} catch (e: any) {
setDialog({ open: false, message: "", loading: false });
setError(e?.message || "Failed to send receipt");
}
};
// Resolves the channel-picker popup: dispatches to the invoice or receipt sender for
// whichever id it was opened with, then closes the popup.
const chooseChannel = (channel: 'email' | 'whatsapp') => {
if (!channelPicker) return;
const { kind, id } = channelPicker;
setChannelPicker(null);
if (kind === 'invoice') sendInvoice(id, channel);
else sendReceipt(id, channel);
};
// Creates a Yoco checkout for the full outstanding balance and redirects there directly —
// choosing a partial amount is only available from the supervisor payments dashboard.
const payNow = async (registrationId: string) => {
@@ -638,11 +653,10 @@ export default function UserDashboardPage() {
const myTicks = await apiFetch<any[]>("/api/tickets/mytickets", { authToken: token });
setTickets(myTicks);
} catch {}
// Recompute billing totals using priceSnapshot
const now2 = new Date();
// Recompute billing totals using the backend's tranche-aware totalDueComputed
const totals: Record<string, { totalDue: number; totalPaid: number; outstanding: number; payments: any[] }> = {};
for (const r of myRegs) {
const totalDue = (r.registrationOptions || []).reduce((sum: number, opt: any) => sum + optionUnitPrice(opt, null, now2) * (opt.quantity || 0), 0);
const totalDue = r.totalDueComputed ?? (r.registrationOptions || []).reduce((sum: number, opt: any) => sum + optionUnitPrice(opt, null, new Date()) * (opt.quantity || 0), 0);
totals[r.id] = { totalDue, totalPaid: 0, outstanding: totalDue, payments: [] };
}
setBilling(totals);
@@ -969,24 +983,64 @@ export default function UserDashboardPage() {
<button disabled={editLoading} onClick={beginEdit} className="px-2.5 py-1 text-xs bg-brand-600 text-white rounded hover:bg-brand-700 disabled:opacity-50">Edit</button>
)}
</div>
{!editMode ? (
<ul className="text-sm list-disc pl-5 space-y-1">
{(activeReg.registrationOptions || []).map((opt: any) => {
{!editMode ? (() => {
// A line can span multiple price tranches (e.g. tickets bought before
// and after an early-bird tier expired, or across repeat registrations).
// Merge tranches that share a name/price/tier into one row, then group
// early-bird rows separately from standard-price rows so repeat purchases
// of the same item don't show as a wall of near-duplicate lines.
const rows: { key: string; label: string; quantity: number; unitPrice: number; isEarlyBird: boolean }[] = [];
(activeReg.registrationOptions || []).forEach((opt: any) => {
const variantLabel = opt.variant?.name ? ` (${opt.variant.name})` : '';
const unitPrice = (opt.priceSnapshot !== null && opt.priceSnapshot !== undefined)
const label = `${opt.eventOption?.name || ''}${variantLabel}`;
const tranches = Array.isArray(opt.tranches) && opt.tranches.length > 0
? opt.tranches
: [{
quantity: opt.quantity,
priceSnapshot: (opt.priceSnapshot !== null && opt.priceSnapshot !== undefined)
? Number(opt.priceSnapshot)
: (opt.variant?.price ?? opt.eventOption?.price ?? 0);
return (
<li key={opt.id}>
{opt.eventOption?.name}{variantLabel} x {opt.quantity} {formatRand(unitPrice * (opt.quantity || 0))}
{unitPrice < (opt.eventOption?.price || 0) && (
: (opt.variant?.price ?? opt.eventOption?.price ?? 0),
appliedTierId: opt.appliedTierId,
}];
tranches.forEach((t: any) => {
const unitPrice = Number(t.priceSnapshot || 0);
const isEarlyBird = !!t.appliedTierId;
const key = `${label}__${isEarlyBird}__${unitPrice}`;
const existing = rows.find(r => r.key === key);
if (existing) {
existing.quantity += (t.quantity || 0);
} else {
rows.push({ key, label, quantity: t.quantity || 0, unitPrice, isEarlyBird });
}
});
});
const earlyBirdRows = rows.filter(r => r.isEarlyBird);
const standardRows = rows.filter(r => !r.isEarlyBird);
const showGroupLabels = earlyBirdRows.length > 0 && standardRows.length > 0;
const renderGroup = (heading: string, groupRows: typeof rows) => groupRows.length > 0 && (
<div key={heading}>
{showGroupLabels && (
<div className="text-xs font-semibold text-gray-500 uppercase tracking-wide mt-2 first:mt-0">{heading}</div>
)}
<ul className="text-sm list-disc pl-5 space-y-1">
{groupRows.map(r => (
<li key={r.key}>
{r.label} x {r.quantity} {formatRand(r.unitPrice * r.quantity)}
{r.isEarlyBird && (
<span className="ml-1 text-xs text-green-700">(early bird)</span>
)}
</li>
);
})}
))}
</ul>
) : (
</div>
);
return (
<div>
{renderGroup('Early bird', earlyBirdRows)}
{renderGroup('Standard price', standardRows)}
</div>
);
})() : (
<div className="border rounded p-3 space-y-2 bg-gray-50">
{editError && <p className="text-xs text-red-600">{editError}</p>}
{editLoading ? (
@@ -1091,9 +1145,12 @@ export default function UserDashboardPage() {
{activeBill && activeBill.payments.length > 0 && (
<div>
<div className="font-medium mb-1">Payments</div>
<ul className="text-sm list-disc pl-5 space-y-1">
<ul className="text-sm space-y-1">
{activeBill.payments.map((p: any) => (
<li key={p.id}>{new Date(p.createdAt).toLocaleString()} {formatRand(p.amount)} ({formatPaymentMethod(p.method)})</li>
<li key={p.id} className="flex items-center justify-between gap-2 py-0.5">
<span>{new Date(p.createdAt).toLocaleString()} {formatRand(p.amount)} ({formatPaymentMethod(p.method)})</span>
<button className="text-xs text-brand-700 hover:underline shrink-0" onClick={() => setChannelPicker({ kind: 'receipt', id: p.id })}>Receipt</button>
</li>
))}
</ul>
</div>
@@ -1105,24 +1162,21 @@ export default function UserDashboardPage() {
onClick={() => router.push(`/dashboard/user/forms?registrationId=${encodeURIComponent(activeReg.id)}`)}
>Attendee forms</button>
)}
{canModifyActive && activeBill && activeBill.outstanding > 0 ? (
<button
className="px-3 py-1.5 text-sm rounded bg-gray-100 hover:bg-gray-200 text-gray-800 shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-1"
onClick={() => setChannelPicker({ kind: 'invoice', id: activeReg.id })}
>Invoice</button>
{canModifyActive && activeBill && activeBill.outstanding > 0 && (
<button
className="px-3 py-1.5 text-sm bg-green-600 text-white rounded hover:bg-green-700 shadow-sm focus:outline-none focus:ring-2 focus:ring-green-500 focus:ring-offset-1"
onClick={() => payNow(activeReg.id)}
>Make payment</button>
) : (
<>
)}
<button className="px-3 py-1.5 text-sm rounded bg-gray-100 hover:bg-gray-200 text-gray-800 shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-1" onClick={() => {
const regOptIds = new Set((activeReg.registrationOptions || []).map((o: any) => o.id));
const list = tickets.filter(t => regOptIds.has(t.registrationOptionId));
printTickets(list);
}}>Print all tickets</button>
<button className="px-3 py-1.5 text-sm bg-brand-600 text-white rounded hover:bg-brand-700 shadow-sm focus:outline-none focus:ring-2 focus:ring-brand-500 focus:ring-offset-1" onClick={() => emailRegistration(activeReg.id)}>Email all tickets</button>
{user?.phoneNumber && (
<button className="px-3 py-1.5 text-sm bg-green-600 text-white rounded hover:bg-green-700 shadow-sm focus:outline-none focus:ring-2 focus:ring-green-500 focus:ring-offset-1" onClick={() => whatsappRegistration(activeReg.id)}>WhatsApp tickets</button>
)}
</>
)}
</div>
{/* Cancel registration — only shown when no payments have been made and the event still permits changes */}
@@ -1156,6 +1210,31 @@ export default function UserDashboardPage() {
</div>
)}
{channelPicker && (
<div
className="fixed inset-0 bg-black/40 flex items-center justify-center z-[60]"
onClick={() => setChannelPicker(null)}
>
<div className="bg-white rounded-lg shadow-lg w-full max-w-xs mx-4 p-5" onClick={e => e.stopPropagation()}>
<div className="text-base font-semibold mb-1">Send {channelPicker.kind === 'invoice' ? 'invoice' : 'receipt'}</div>
<p className="text-sm text-gray-600 mb-4">How would you like to receive it?</p>
<div className="flex flex-col gap-2">
<button
className="px-3 py-2 text-sm bg-brand-600 text-white rounded hover:bg-brand-700 shadow-sm focus:outline-none focus:ring-2 focus:ring-brand-500 focus:ring-offset-1"
onClick={() => chooseChannel('email')}
>Email</button>
{user?.phoneNumber && (
<button
className="px-3 py-2 text-sm bg-green-600 text-white rounded hover:bg-green-700 shadow-sm focus:outline-none focus:ring-2 focus:ring-green-500 focus:ring-offset-1"
onClick={() => chooseChannel('whatsapp')}
>WhatsApp</button>
)}
</div>
<button className="mt-3 text-xs text-gray-500 hover:underline" onClick={() => setChannelPicker(null)}>Cancel</button>
</div>
</div>
)}
{dialog.open && (
<div
className="fixed inset-0 bg-black/40 flex items-center justify-center z-50"
@@ -1170,7 +1249,7 @@ export default function UserDashboardPage() {
</div>
) : (
<>
<div className="text-lg font-semibold mb-2">Tickets sent</div>
<div className="text-lg font-semibold mb-2">{dialog.title || "Tickets sent"}</div>
<p className="text-sm text-gray-700 mb-4">{dialog.message}</p>
<div className="flex justify-end">
<button
@@ -33,10 +33,35 @@ export default function UserPaymentsPage() {
const [payments, setPayments] = useState<PaymentItem[]>([]);
const [fetching, setFetching] = useState(false);
const [error, setError] = useDismissingState<string | null>(null);
const [info, setInfo] = useDismissingState<string | null>(null);
const [page, setPage] = useState(1);
const [totalPages, setTotalPages] = useState(1);
const [total, setTotal] = useState(0);
// Receipt send: which payment's channel-choice popup is open, and whether a send is in flight.
const [receiptPickerId, setReceiptPickerId] = useState<string | null>(null);
const [sendingReceipt, setSendingReceipt] = useState(false);
const sendReceipt = async (paymentId: string, channel: 'email' | 'whatsapp') => {
if (!token) return;
setReceiptPickerId(null);
setError(null);
setInfo(null);
setSendingReceipt(true);
try {
const res: any = await apiFetch<any>(`/api/payments/${encodeURIComponent(paymentId)}/send-receipt`, {
method: "POST",
body: { channel },
authToken: token,
});
setInfo((res && res.message) || "Receipt sent.");
} catch (e: any) {
setError(e?.message || "Failed to send receipt");
} finally {
setSendingReceipt(false);
}
};
// Filters
const [startDate, setStartDate] = useState("");
const [endDate, setEndDate] = useState("");
@@ -89,6 +114,8 @@ export default function UserPaymentsPage() {
</div>
{error && <p className="text-red-600 text-sm mb-3">{error}</p>}
{info && <p className="text-green-700 text-sm mb-3">{info}</p>}
{sendingReceipt && <p className="text-gray-500 text-sm mb-3">Sending receipt</p>}
<div className="border rounded-xl p-4 bg-white shadow-sm">
<div className="flex flex-wrap items-end gap-3 mb-4">
@@ -156,8 +183,19 @@ export default function UserPaymentsPage() {
</div>
<div className="text-xs text-gray-500">{formatDateTime(p.createdAt)}</div>
</div>
<div className="flex items-center justify-between gap-2">
<div>
<div className="text-xs text-gray-600">Method: {formatPaymentMethod(p.method)}</div>
{eventTitle && <div className="text-xs text-gray-600">Event: {eventTitle}</div>}
</div>
{!isRefund && (
<button
className="text-xs text-brand-700 hover:underline shrink-0"
disabled={sendingReceipt}
onClick={() => setReceiptPickerId(p.id)}
>Receipt</button>
)}
</div>
</li>
);
})}
@@ -204,6 +242,31 @@ export default function UserPaymentsPage() {
</div>
)}
</div>
{receiptPickerId && (
<div
className="fixed inset-0 bg-black/40 flex items-center justify-center z-50"
onClick={() => setReceiptPickerId(null)}
>
<div className="bg-white rounded-lg shadow-lg w-full max-w-xs mx-4 p-5" onClick={e => e.stopPropagation()}>
<div className="text-base font-semibold mb-1">Send receipt</div>
<p className="text-sm text-gray-600 mb-4">How would you like to receive it?</p>
<div className="flex flex-col gap-2">
<button
className="px-3 py-2 text-sm bg-brand-600 text-white rounded hover:bg-brand-700 shadow-sm focus:outline-none focus:ring-2 focus:ring-brand-500 focus:ring-offset-1"
onClick={() => sendReceipt(receiptPickerId, 'email')}
>Email</button>
{user?.phoneNumber && (
<button
className="px-3 py-2 text-sm bg-green-600 text-white rounded hover:bg-green-700 shadow-sm focus:outline-none focus:ring-2 focus:ring-green-500 focus:ring-offset-1"
onClick={() => sendReceipt(receiptPickerId, 'whatsapp')}
>WhatsApp</button>
)}
</div>
<button className="mt-3 text-xs text-gray-500 hover:underline" onClick={() => setReceiptPickerId(null)}>Cancel</button>
</div>
</div>
)}
</div>
);
}
+10 -1
View File
@@ -1,7 +1,8 @@
"use client";
import { Share2, QrCode } from "lucide-react";
import { Share2, QrCode, CalendarPlus } from "lucide-react";
import QRCode from "qrcode";
import { API_BASE } from "@/lib/api";
export default function ClientActions({ event }: { event: any }) {
return (
@@ -43,6 +44,14 @@ export default function ClientActions({ event }: { event: any }) {
<QrCode className="w-4 h-4" />
Save QR
</button>
<a
href={`${API_BASE}/api/events/${event.id}/ics`}
download
className="flex items-center gap-2 px-4 py-2 bg-gray-100 rounded-lg hover:bg-gray-200 transition-colors text-sm font-medium"
>
<CalendarPlus className="w-4 h-4" />
Add to calendar
</a>
</div>
);
}
+65 -8
View File
@@ -1,7 +1,10 @@
import { Metadata } from "next";
import { notFound } from "next/navigation";
import { Navbar } from "@/components/layout/Navbar";
import { Footer } from "@/components/layout/Footer";
import ClientActions from "@/app/events/[id]/ClientActions";
import { ContactButton } from "@/components/events/ContactButton";
import { LocationMap } from "@/components/events/LocationMap";
import { Calendar, Ticket, Paperclip, Sparkles } from "lucide-react";
export const revalidate = 60;
@@ -32,9 +35,14 @@ type Event = {
eventOptions?: EventOption[];
attachments?: EventAttachment[];
requiresAuth?: boolean;
requiresRegistration?: boolean;
contactName?: string | null;
contactPhone?: string | null;
contactEmail?: string | null;
location?: string | null;
};
import { apiFetch, ApiError } from "@/lib/api";
import { apiFetch, ApiError, resolveToApiOrigin } from "@/lib/api";
import { ApiImage } from "@/components/shared/ApiImage";
import { formatDateTimeRange } from "@/lib/date";
@@ -51,6 +59,16 @@ function lowStockThreshold(stockLimit: number): number {
}
function RegisterCta({ event }: { event: Event }) {
if (event.requiresRegistration === false) {
return (
<ContactButton
contactName={event.contactName}
contactPhone={event.contactPhone}
contactEmail={event.contactEmail}
className="block w-full text-center bg-brand-600 text-white px-4 py-2.5 rounded-lg hover:bg-brand-700 font-medium transition-colors"
/>
);
}
const now = new Date();
const end = new Date(event.endDate);
const deadline = event.registrationDeadline ? new Date(event.registrationDeadline) : null;
@@ -88,17 +106,49 @@ function RegisterCta({ event }: { event: Event }) {
);
}
export default async function EventDetailPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
let event: Event;
// Next dedupes an identical fetch (same URL + cache options) made during the same
// request, so calling this again from the page component below is free.
async function loadEvent(id: string): Promise<Event | null> {
try {
event = await apiFetch<Event>(`/api/events/${id}`, { nextOptions: { next: { revalidate } } });
return await apiFetch<Event>(`/api/events/${id}`, { nextOptions: { next: { revalidate } } });
} catch (e) {
// The event endpoint 404s for missing, inactive, or not-yet-live events —
// render the standard not-found page instead of crashing.
if (e instanceof ApiError && e.status === 404) notFound();
if (e instanceof ApiError && e.status === 404) return null;
throw e;
}
}
export async function generateMetadata({ params }: { params: Promise<{ id: string }> }): Promise<Metadata> {
const { id } = await params;
const event = await loadEvent(id);
if (!event) return {};
const description = event.description
? event.description.slice(0, 200)
: `${event.title}${formatDateTimeRange(event.startDate, event.endDate)}`;
const imageUrl = event.picture ? resolveToApiOrigin(event.picture) : null;
return {
title: event.title,
description,
openGraph: {
title: event.title,
description,
type: "website",
...(imageUrl ? { images: [{ url: imageUrl }] } : {}),
},
twitter: {
card: "summary_large_image",
title: event.title,
description,
...(imageUrl ? { images: [imageUrl] } : {}),
},
};
}
export default async function EventDetailPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const event = await loadEvent(id);
if (!event) notFound();
return (
<div className="min-h-screen flex flex-col">
@@ -127,6 +177,13 @@ export default async function EventDetailPage({ params }: { params: Promise<{ id
<p className="text-gray-700 whitespace-pre-line">{event.description}</p>
{event.location && (
<div className="border rounded-xl p-5 bg-white shadow-sm">
<h2 className="text-base font-semibold text-gray-900 mb-3">Location</h2>
<LocationMap address={event.location} />
</div>
)}
{event.attachments && event.attachments.length > 0 && (
<div className="border rounded-xl p-5 bg-white shadow-sm">
<div className="flex items-center gap-2 mb-3">
+6
View File
@@ -1,3 +1,4 @@
import { Metadata } from "next";
import { Navbar } from "@/components/layout/Navbar";
import { Footer } from "@/components/layout/Footer";
import { EventCard } from "@/components/events/EventCard";
@@ -6,6 +7,11 @@ import { Calendar, CalendarX } from "lucide-react";
export const revalidate = 60;
export const metadata: Metadata = {
title: "All Events",
description: "Browse and register for upcoming events.",
};
type Event = {
id: string;
title: string;
+20 -2
View File
@@ -10,6 +10,7 @@ import { SetupGuard } from "@/components/shared/SetupGuard";
import HelpFab from "@/components/shared/HelpFab";
import { API_BASE, resolveToApiOrigin } from "@/lib/api";
import { buildThemeCssVars } from "@/lib/colorScale";
import { appUrl } from "@/lib/siteConfig";
const geistSans = Geist({
variable: "--font-geist-sans",
@@ -44,13 +45,30 @@ async function getServerSettings(): Promise<SiteSettings> {
export async function generateMetadata(): Promise<Metadata> {
const settings = await getServerSettings();
const faviconUrl = settings.favicon_url ? resolveToApiOrigin(settings.favicon_url) : null;
const logoUrl = settings.logo_url ? resolveToApiOrigin(settings.logo_url) : null;
const displayName = settings.org_name || appName;
const description = settings.org_tagline || `Manage and register for events with ${displayName}`;
return {
title: appName,
description: `Manage and register for events with ${appName}`,
metadataBase: new URL(appUrl),
title: { default: displayName, template: `%s | ${displayName}` },
description,
icons: {
icon: faviconUrl || "/favicon.ico",
},
openGraph: {
type: "website",
siteName: displayName,
title: { default: displayName, template: `%s | ${displayName}` },
description,
...(logoUrl ? { images: [{ url: logoUrl }] } : {}),
},
twitter: {
card: "summary_large_image",
title: displayName,
description,
...(logoUrl ? { images: [logoUrl] } : {}),
},
};
}
+17 -2
View File
@@ -3,6 +3,7 @@ import { Navbar } from "@/components/layout/Navbar";
import { Footer } from "@/components/layout/Footer";
import { JoinUsButton } from "@/components/home/JoinUsButton";
import { appName } from "@/lib/siteConfig";
import { API_BASE } from "@/lib/api";
import { Calendar, ArrowRight, CalendarCheck, Users, Heart, ShieldCheck } from "lucide-react";
type Event = {
@@ -26,8 +27,22 @@ const FEATURES = [
{ icon: Heart, title: "Make an Impact", description: "Be part of what God is doing and make a difference together." },
];
async function getOrgName(): Promise<string> {
try {
const res = await fetch(`${API_BASE}/api/settings`, { next: { revalidate: 60 } });
if (!res.ok) return appName;
const settings = await res.json();
return settings?.org_name || appName;
} catch {
return appName;
}
}
export default async function HomePage() {
const events = await apiFetch<Event[]>("/api/events", { nextOptions: { next: { revalidate: 60 } } });
const [events, displayName] = await Promise.all([
apiFetch<Event[]>("/api/events", { nextOptions: { next: { revalidate: 60 } } }),
getOrgName(),
]);
const now = Date.now();
const upcoming = (events || []).filter(e => {
const t = new Date(e.startDate).getTime();
@@ -52,7 +67,7 @@ export default async function HomePage() {
{sorted.length} upcoming event{sorted.length === 1 ? "" : "s"}
</div>
)}
<h1 className="text-4xl sm:text-5xl font-bold mb-4 text-gray-900">Welcome to {appName}</h1>
<h1 className="text-4xl sm:text-5xl font-bold mb-4 text-gray-900">Welcome to {displayName}</h1>
<p className="text-gray-600 text-lg mb-8">Experience unforgettable moments. Powered by purpose.</p>
<div className="flex flex-wrap items-center justify-center gap-3">
<a href="/events" className="inline-flex items-center gap-2 px-6 py-2.5 bg-brand-600 text-white rounded-xl hover:bg-brand-700 shadow-sm font-medium transition-colors">
+7 -1
View File
@@ -1,4 +1,4 @@
import { notFound } from "next/navigation";
import { notFound, redirect } from "next/navigation";
import { Navbar } from "@/components/layout/Navbar";
import { Footer } from "@/components/layout/Footer";
import { apiFetch, ApiError } from "@/lib/api";
@@ -18,6 +18,12 @@ export default async function RegisterPage({ params }: { params: Promise<{ event
throw e;
}
// Contact-only events (e.g. baptism) have no registration flow — bounce a stale/direct
// link back to the event detail page, which renders the Contact affordance instead.
if (event.requiresRegistration === false) {
redirect(`/events/${eventId}`);
}
return (
<div className="min-h-screen flex flex-col">
<Navbar />
@@ -56,6 +56,9 @@ function RegistrationSuccessContent() {
const totalDue = React.useMemo(() => {
if (!reg) return 0;
// Backend attaches a tranche-aware totalDueComputed (exact even when a line spans
// multiple early-bird prices) — fall back to the old client-side estimate otherwise.
if (reg.totalDueComputed !== null && reg.totalDueComputed !== undefined) return reg.totalDueComputed;
try {
return (reg.registrationOptions || []).reduce((s: number, ro: any) => {
const unit = (ro.priceSnapshot !== null && ro.priceSnapshot !== undefined)
+15
View File
@@ -0,0 +1,15 @@
import type { MetadataRoute } from "next";
import { appUrl } from "@/lib/siteConfig";
export default function robots(): MetadataRoute.Robots {
const base = appUrl.replace(/\/$/, "");
return {
rules: {
userAgent: "*",
allow: "/",
disallow: ["/dashboard", "/self-service", "/set-banner", "/lockdown-rules"],
},
sitemap: `${base}/sitemap.xml`,
};
}
+3 -1
View File
@@ -386,7 +386,9 @@ export default function SelfServicePage() {
...(visitorEmail.trim() ? { email: visitorEmail.trim() } : {}),
...(visitorPhone.trim() ? { phoneNumber: visitorPhone.trim() } : {}),
},
guestOnly: !createAccount,
// If the visitor declined to create an account, don't push an unsolicited
// activation email/WhatsApp at them afterward.
skipActivationNotice: !createAccount,
notificationPreference: notificationPref,
};
if (createAccount && visitorPassword) {
+34
View File
@@ -0,0 +1,34 @@
import type { MetadataRoute } from "next";
import { apiFetch } from "@/lib/api";
import { appUrl } from "@/lib/siteConfig";
type Event = { id: string; updatedAt?: string; startDate: string };
export const revalidate = 3600;
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
const base = appUrl.replace(/\/$/, "");
const staticEntries: MetadataRoute.Sitemap = [
{ url: `${base}/`, changeFrequency: "daily", priority: 1 },
{ url: `${base}/events`, changeFrequency: "daily", priority: 0.9 },
{ url: `${base}/contact`, changeFrequency: "monthly", priority: 0.5 },
];
let events: Event[] = [];
try {
events = await apiFetch<Event[]>("/api/events", { nextOptions: { next: { revalidate } } });
} catch {
// Backend unreachable at build/revalidate time — ship the static entries only,
// same degrade-gracefully posture as layout.tsx's getServerSettings.
}
const eventEntries: MetadataRoute.Sitemap = (events || []).map((e) => ({
url: `${base}/events/${e.id}`,
lastModified: e.updatedAt ? new Date(e.updatedAt) : undefined,
changeFrequency: "weekly",
priority: 0.7,
}));
return [...staticEntries, ...eventEntries];
}
@@ -0,0 +1,188 @@
"use client";
import React, { useCallback, useEffect, useState } from "react";
import { useAuth } from "@/hooks/useAuth";
import { apiFetch, API_BASE } from "@/lib/api";
import { useDismissingState } from "@/hooks/useDismissingState";
import { DatabaseBackup, Download } from "lucide-react";
interface BackupEntry {
filename: string;
size: number;
createdAt: string;
}
function formatSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
export function BackupsTab({ active }: { active: boolean }) {
const { token } = useAuth();
const [backups, setBackups] = useState<BackupEntry[]>([]);
const [loading, setLoading] = useState(true);
const [running, setRunning] = useState(false);
const [message, setMessage] = useDismissingState<{ type: "ok" | "err"; text: string } | null>(null);
const [retainCount, setRetainCount] = useState("14");
const [savingRetain, setSavingRetain] = useState(false);
const load = useCallback(async () => {
if (!token) return;
setLoading(true);
try {
const [list, allSettings] = await Promise.all([
apiFetch<BackupEntry[]>("/api/backups", { authToken: token }),
apiFetch<Record<string, string>>("/api/settings/all", { authToken: token }),
]);
setBackups(list || []);
setRetainCount(allSettings?.backup_retain_count || "14");
} catch (e: any) {
setMessage({ type: "err", text: e?.message || "Failed to load backups" });
} finally {
setLoading(false);
}
}, [token]);
const saveRetainCount = async () => {
if (!token) return;
setSavingRetain(true);
try {
await apiFetch("/api/settings", { method: "PUT", authToken: token, body: { backup_retain_count: retainCount } });
setMessage({ type: "ok", text: "Retention setting saved." });
} catch (e: any) {
setMessage({ type: "err", text: e?.message || "Failed to save retention setting" });
} finally {
setSavingRetain(false);
}
};
useEffect(() => { if (active) load(); }, [active, load]);
const runBackup = async () => {
if (!token) return;
setRunning(true);
setMessage(null);
try {
const res = await apiFetch<{ filename: string }>("/api/backups/run", { method: "POST", authToken: token });
setMessage({ type: "ok", text: `Backup created: ${res.filename}` });
await load();
} catch (e: any) {
setMessage({ type: "err", text: e?.message || "Backup failed" });
} finally {
setRunning(false);
}
};
return (
<div className="space-y-4">
<div className="flex items-center gap-3">
<div className="w-9 h-9 rounded-lg bg-brand-50 flex items-center justify-center shrink-0">
<DatabaseBackup className="w-4 h-4 text-brand-600" />
</div>
<div>
<h2 className="text-lg font-semibold text-gray-900">Database backups</h2>
<p className="text-xs text-gray-500">Nightly automatic backups, stored locally on this server. Not uploaded anywhere else.</p>
</div>
</div>
{message && (
<div className={`text-sm p-2.5 rounded-lg ${message.type === "ok" ? "bg-green-50 text-green-700" : "bg-red-50 text-red-700"}`}>
{message.text}
</div>
)}
<button
type="button"
onClick={runBackup}
disabled={running}
className="px-4 py-2 bg-brand-600 hover:bg-brand-700 disabled:opacity-50 text-white rounded-lg text-sm font-medium"
>
{running ? "Running…" : "Run backup now"}
</button>
<div className="flex items-end gap-2 pt-2 border-t">
<div>
<label className="block text-xs text-gray-600 mb-1">Keep the most recent</label>
<input
type="number"
min={1}
className="w-24 border rounded-lg px-3 py-1.5 text-sm"
value={retainCount}
onChange={(e) => setRetainCount(e.target.value)}
/>
</div>
<span className="text-sm text-gray-500 pb-1.5">backups, delete the rest</span>
<button
type="button"
onClick={saveRetainCount}
disabled={savingRetain}
className="ml-auto px-3 py-1.5 text-sm rounded-lg bg-gray-100 hover:bg-gray-200 disabled:opacity-50"
>
{savingRetain ? "Saving…" : "Save"}
</button>
</div>
<div className="border rounded-lg overflow-hidden mt-2">
<table className="min-w-full text-sm">
<thead>
<tr className="text-left text-gray-600 border-b bg-gray-50">
<th className="p-2.5">Created</th>
<th className="p-2.5">Size</th>
<th className="p-2.5">Download</th>
</tr>
</thead>
<tbody>
{backups.map((b) => (
<tr key={b.filename} className="border-t">
<td className="p-2.5">{new Date(b.createdAt).toLocaleString()}</td>
<td className="p-2.5 text-gray-500">{formatSize(b.size)}</td>
<td className="p-2.5">
<a
href={`${API_BASE}/api/backups/${encodeURIComponent(b.filename)}/download`}
className="inline-flex items-center gap-1.5 text-brand-600 hover:underline"
onClick={(e) => {
// authenticated download: fetch as blob rather than a bare link,
// since this route requires an admin bearer token
e.preventDefault();
if (!token) return;
fetch(`${API_BASE}/api/backups/${encodeURIComponent(b.filename)}/download`, {
headers: { Authorization: `Bearer ${token}` },
})
.then((res) => res.blob())
.then((blob) => {
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = b.filename;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
})
.catch(() => setMessage({ type: "err", text: "Download failed" }));
}}
>
<Download className="w-3.5 h-3.5" />
Download
</a>
</td>
</tr>
))}
{backups.length === 0 && !loading && (
<tr>
<td className="p-3 text-gray-500" colSpan={3}>No backups yet.</td>
</tr>
)}
{loading && (
<tr>
<td className="p-3 text-gray-400" colSpan={3}>Loading</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
);
}
@@ -0,0 +1,72 @@
"use client";
import { useState } from "react";
import { X, Phone, Mail, User } from "lucide-react";
type ContactButtonProps = {
contactName?: string | null;
contactPhone?: string | null;
contactEmail?: string | null;
className?: string;
label?: string;
};
export function ContactButton({ contactName, contactPhone, contactEmail, className, label = "Contact us" }: ContactButtonProps) {
const [open, setOpen] = useState(false);
const hasDetails = !!(contactName || contactPhone || contactEmail);
return (
<>
<button
type="button"
onClick={() => setOpen(true)}
className={className || "flex-1 text-center text-sm text-white bg-brand-600 rounded-lg py-2 hover:bg-brand-700 transition-colors"}
>
{label}
</button>
{open && (
<div
className="fixed inset-0 bg-black/40 flex items-center justify-center z-50"
onClick={() => setOpen(false)}
>
<div className="bg-white rounded-lg shadow-lg max-w-sm w-full mx-4 p-5" onClick={e => e.stopPropagation()}>
<div className="flex items-center justify-between mb-3">
<h3 className="text-lg font-semibold">Contact us</h3>
<button
className="p-1 rounded hover:bg-gray-100 text-gray-500"
onClick={() => setOpen(false)}
aria-label="Close"
>
<X className="w-4 h-4" />
</button>
</div>
{hasDetails ? (
<div className="space-y-2 text-sm">
{contactName && (
<div className="flex items-center gap-2 text-gray-800">
<User className="w-4 h-4 text-gray-400 shrink-0" />
<span>{contactName}</span>
</div>
)}
{contactPhone && (
<div className="flex items-center gap-2">
<Phone className="w-4 h-4 text-gray-400 shrink-0" />
<a href={`tel:${contactPhone}`} className="text-brand-600 hover:underline">{contactPhone}</a>
</div>
)}
{contactEmail && (
<div className="flex items-center gap-2">
<Mail className="w-4 h-4 text-gray-400 shrink-0" />
<a href={`mailto:${contactEmail}`} className="text-brand-600 hover:underline">{contactEmail}</a>
</div>
)}
</div>
) : (
<p className="text-sm text-gray-500">No contact details have been provided for this event.</p>
)}
</div>
</div>
)}
</>
);
}
+22 -1
View File
@@ -1,5 +1,6 @@
import { ApiImage } from "@/components/shared/ApiImage";
import { Calendar } from "lucide-react";
import { Calendar, MapPin } from "lucide-react";
import { ContactButton } from "@/components/events/ContactButton";
type Event = {
id: string;
@@ -12,6 +13,11 @@ import { Calendar } from "lucide-react";
price: number;
picture?: string;
isSoldOut?: boolean;
requiresRegistration?: boolean;
contactName?: string | null;
contactPhone?: string | null;
contactEmail?: string | null;
location?: string | null;
};
import { formatDateTimeRange } from "@/lib/date";
@@ -35,6 +41,12 @@ export const EventCard = ({ event }: { event: Event }) => {
<Calendar className="w-3.5 h-3.5 shrink-0" />
{dateRange}
</p>
{event.location && (
<p className="text-sm text-gray-500 flex items-center gap-1.5 mt-1">
<MapPin className="w-3.5 h-3.5 shrink-0" />
<span className="truncate">{event.location}</span>
</p>
)}
<p className="text-sm text-gray-600 mt-2 line-clamp-2">{event.description}</p>
</div>
</a>
@@ -47,6 +59,15 @@ export const EventCard = ({ event }: { event: Event }) => {
View details
</a>
{(() => {
if (event.requiresRegistration === false) {
return (
<ContactButton
contactName={event.contactName}
contactPhone={event.contactPhone}
contactEmail={event.contactEmail}
/>
);
}
const now = new Date();
const end = new Date(event.endDate);
const deadline = event.registrationDeadline ? new Date(event.registrationDeadline) : null;
@@ -0,0 +1,33 @@
import { MapPin, ExternalLink } from "lucide-react";
import { mapsSearchUrl, mapsEmbedUrl } from "@/lib/maps";
export function LocationMap({ address, className }: { address?: string | null; className?: string }) {
if (!address) return null;
return (
<div className={className}>
<div className="flex items-start justify-between gap-3">
<p className="text-sm text-gray-700 flex items-start gap-1.5">
<MapPin className="w-4 h-4 shrink-0 mt-0.5 text-gray-400" />
<span>{address}</span>
</p>
<a
href={mapsSearchUrl(address)}
target="_blank"
rel="noopener noreferrer"
className="text-xs text-brand-600 hover:underline flex items-center gap-1 shrink-0 whitespace-nowrap"
>
Directions <ExternalLink className="w-3 h-3" />
</a>
</div>
<div className="mt-2 rounded-lg overflow-hidden border">
<iframe
title={`Map showing ${address}`}
src={mapsEmbedUrl(address)}
className="w-full h-48 border-0"
loading="lazy"
referrerPolicy="no-referrer-when-downgrade"
/>
</div>
</div>
);
}
+20 -13
View File
@@ -434,10 +434,14 @@ export default function ReportsV2({ onBack }: { onBack?: () => void } = {}) {
Object.keys(registrationsByEvent).forEach(evId => {
(registrationsByEvent[evId] || []).forEach((r: any) => {
const lastAt = lastPaymentAtByReg.get(r.id) || null;
const dueNow = (r.registrationOptions || []).reduce((sum: number, ro: any) => sum + optionUnitPrice(ro, lastAt, now) * (ro.quantity || 0), 0);
// Backend attaches a tranche-aware totalDueComputed, which locks each tranche's price
// at the time it was purchased — it's already time-invariant, so the "dueNow vs
// dueAtLast" lock-in dance below is only needed as a fallback for legacy rows without it.
const hasComputed = r.totalDueComputed !== null && r.totalDueComputed !== undefined;
const dueNow = hasComputed ? r.totalDueComputed : (r.registrationOptions || []).reduce((sum: number, ro: any) => sum + optionUnitPrice(ro, lastAt, now) * (ro.quantity || 0), 0);
const paid = paidByReg.get(r.id) || 0;
let outstanding = Math.max(dueNow - paid, 0);
if (lastAt) {
if (lastAt && !hasComputed) {
const dueAtLast = (r.registrationOptions || []).reduce((sum: number, ro: any) => sum + optionUnitPrice(ro, lastAt, lastAt) * (ro.quantity || 0), 0);
if (paid >= dueAtLast) outstanding = 0;
}
@@ -722,10 +726,18 @@ export default function ReportsV2({ onBack }: { onBack?: () => void } = {}) {
const ro = (r.registrationOptions || [])
.find((x: any) => x.eventOption?.id === opt.id);
const price = ro ? optionUnitPrice(ro, null, new Date()) : 0;
// Backend attaches a tranche-aware lineTotal (exact even when this line spans
// multiple early-bird prices) — fall back to the old blended-price estimate
// otherwise. __prices stores an *average* unit price derived from that, purely
// for display; __revenue carries the real total used for aggregation below.
const lineTotal = ro
? (ro.lineTotal !== null && ro.lineTotal !== undefined ? ro.lineTotal : optionUnitPrice(ro, null, new Date()) * qty)
: 0;
baseRow.__prices[opt.name] = price; // 👈 store price
baseRow.orderTotal += price * qty;
baseRow.__prices[opt.name] = qty > 0 ? lineTotal / qty : 0;
baseRow.__revenue = baseRow.__revenue || {};
baseRow.__revenue[opt.name] = lineTotal;
baseRow.orderTotal += lineTotal;
});
rows.push(baseRow);
@@ -768,14 +780,9 @@ export default function ReportsV2({ onBack }: { onBack?: () => void } = {}) {
const qty = row[opt.name] || 0;
totals[opt.name] += qty;
// revenue per option
const price =
Number(
masterRows
.find(r => r === row)?.__prices?.[opt.name] ?? 0
);
totals[`${opt.name}_revenue`] += qty * price;
// revenue per option — use the tranche-aware per-row total computed above rather
// than re-deriving qty*price from a blended average price.
totals[`${opt.name}_revenue`] += Number(row.__revenue?.[opt.name] ?? 0);
});
});
+7 -1
View File
@@ -1,5 +1,5 @@
import React from "react";
import { Calendar, Ticket, CreditCard, HandHeart, Printer, CheckCircle2, ClipboardList, Clock, SquarePen, History, CheckSquare, Receipt } from "lucide-react";
import { Calendar, Ticket, CreditCard, HandHeart, Printer, CheckCircle2, ClipboardList, Clock, SquarePen, History, CheckSquare, Receipt, FileText } from "lucide-react";
import { GuideItem } from "@/components/shared/GuideItem";
import type { HelpContent } from "./types";
@@ -33,6 +33,9 @@ export const dashboardUserHelpContent: HelpContent = {
<GuideItem icon={SquarePen} title="Editing or cancelling" tone="violet">
Tap a registration to change quantities or options, pay what&apos;s outstanding, or cancel it entirely.
</GuideItem>
<GuideItem icon={FileText} title="Invoice" tone="rose">
The Invoice button sends a PDF breakdown of the registration itemized cost, any early-bird discount, and what&apos;s still owing (or &quot;Paid in full&quot;) to your email or WhatsApp, whichever you pick.
</GuideItem>
<GuideItem icon={History} title="Show past events" tone="gray">
Registrations page defaults to upcoming events only tick &quot;Show past events&quot; at the top to bring back ones that have already happened.
</GuideItem>
@@ -72,6 +75,9 @@ export const dashboardUserHelpContent: HelpContent = {
<GuideItem icon={Receipt} title="Payment history" tone="green">
The Payment history page lists every payment you&apos;ve made across all your registrations, past and present.
</GuideItem>
<GuideItem icon={Receipt} title="Receipts" tone="amber">
Every payment in a registration&apos;s Payments list, or on the Payment history page has its own Receipt button. Pick email or WhatsApp to get a PDF receipt for that specific payment.
</GuideItem>
<GuideItem icon={HandHeart} title="Donations" tone="rose">
A donation isn&apos;t tied to any one registration make one any time to support an event or the ministry directly. It can later be used to help cover an outstanding balance.
</GuideItem>
@@ -14,7 +14,7 @@ export const supervisorManualHelpContent: HelpContent = {
icon: UserPlus,
content: (
<div className="space-y-4">
<p>Pick an event, then either search for an existing user or enter guest details (name, email, phone) and tick &quot;Guest&quot; to skip linking an account. Choose ticket options and quantities early-bird pricing is applied automatically then create the registration.</p>
<p>Pick an event, then either search for an existing user or enter guest details (name, and at least one of email or phone). Choose ticket options and quantities early-bird pricing is applied automatically then create the registration. A new guest account is created inactive, and an activation link is sent immediately (by email if provided, otherwise WhatsApp) so they can set their own password.</p>
<p className="text-xs text-gray-500">After creating a registration, the Record Payment tab is pre-filled with it switch tabs to take payment right away.</p>
</div>
),
+17
View File
@@ -0,0 +1,17 @@
// A no-op when NEXT_PUBLIC_SENTRY_DSN isn't set, so this is safe in every environment
// (dev, or a fresh deploy that hasn't configured Sentry yet). The dynamic import (rather
// than a top-level `import * as Sentry`) keeps the Sentry client SDK out of every visitor's
// bundle entirely when it's unconfigured — Next inlines NEXT_PUBLIC_* at build time, so an
// unset DSN lets the bundler dead-code-eliminate this whole block, import included.
if (process.env.NEXT_PUBLIC_SENTRY_DSN) {
const dsn = process.env.NEXT_PUBLIC_SENTRY_DSN;
import("@sentry/nextjs").then((Sentry) => {
Sentry.init({
dsn,
environment: process.env.NODE_ENV || "development",
// Small single-VM deployment, not high-traffic — start conservative and raise
// this once real usage is visible in Sentry, rather than sampling every request.
tracesSampleRate: 0.1,
});
});
}
+17
View File
@@ -0,0 +1,17 @@
// Next.js server instrumentation entry point. A no-op when NEXT_PUBLIC_SENTRY_DSN isn't
// set — see instrumentation-client.ts for the browser-side counterpart.
//
// Node runtime only, deliberately — this app's middleware.ts (which runs in Next's edge
// runtime) is a trivial pass-through with no real error surface, so an edge-runtime branch
// here would only inflate that shared middleware bundle for no actual coverage benefit.
export async function register() {
if (!process.env.NEXT_PUBLIC_SENTRY_DSN) return;
if (process.env.NEXT_RUNTIME !== "nodejs") return;
const Sentry = await import("@sentry/nextjs");
Sentry.init({
dsn: process.env.NEXT_PUBLIC_SENTRY_DSN,
environment: process.env.NODE_ENV || "development",
tracesSampleRate: 0.1,
});
}
+9
View File
@@ -0,0 +1,9 @@
/** Opens Google Maps with the address pre-filled — no API key required. */
export function mapsSearchUrl(address: string): string {
return `https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(address)}`;
}
/** Embeddable Google Maps iframe src for the given address — no API key required. */
export function mapsEmbedUrl(address: string): string {
return `https://maps.google.com/maps?q=${encodeURIComponent(address)}&output=embed`;
}
+9 -915
View File
File diff suppressed because it is too large Load Diff
+1 -4
View File
@@ -1,6 +1,6 @@
{
"name": "hope-events",
"version": "1.7.0",
"version": "1.10.2",
"main": "index.js",
"scripts": {
"dev:backend": "cd backend && npm run dev",
@@ -17,8 +17,5 @@
"description": "",
"devDependencies": {
"concurrently": "^9.2.1"
},
"dependencies": {
"express-rate-limit": "^8.3.1"
}
}