Compare commits
74
Commits
v1.5.2
...
feat/waitlist
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f66968768f | ||
|
|
2166f94247 | ||
|
|
bae9675485 | ||
|
|
13fd3a4cbb | ||
|
|
809b6a11d7 | ||
|
|
f1e0b62c73 | ||
|
|
72ecf500c8 | ||
|
|
df0bf09a0e | ||
|
|
4b413788ac | ||
|
|
95dc3998ea | ||
|
|
709ea38c10 | ||
|
|
7ce125e36c | ||
|
|
96bfeff205 | ||
|
|
c3012cb3f6 | ||
|
|
0306c4dcb1 | ||
|
|
fc4e65b80a | ||
|
|
e52bf67015 | ||
|
|
e909f43fea | ||
|
|
889995e450 | ||
|
|
73fdce1879 | ||
|
|
7e661abd24 | ||
|
|
1f59916efa | ||
|
|
e728a06b9a | ||
|
|
1d9b87500d | ||
|
|
97faff1c89 | ||
|
|
5b2183677d | ||
|
|
af6dffe534 | ||
|
|
798156efe6 | ||
|
|
c79e0f2ce8 | ||
|
|
49b6ddc397 | ||
|
|
f3a2e812bf | ||
|
|
032d3c032e | ||
|
|
5a416916c9 | ||
|
|
44a9e0857c | ||
|
|
54b89d4f4b | ||
|
|
98ac26bf70 | ||
|
|
7e235637c4 | ||
|
|
0e8d5f93c9 | ||
|
|
a0ccce04a3 | ||
|
|
fbb84b037c | ||
|
|
2dfe8d32c4 | ||
|
|
305499ee91 | ||
|
|
c2112bf707 | ||
|
|
e63fb2cc27 | ||
|
|
d6da2c8227 | ||
|
|
7eed7a01df | ||
|
|
a7be2baab2 | ||
|
|
5a9137e252 | ||
|
|
4e63a78e8e | ||
|
|
2a067a3687 | ||
|
|
bdae0c6b08 | ||
|
|
c84cc257d0 | ||
|
|
7cbb147b00 | ||
|
|
b75be18a87 | ||
|
|
05840541c2 | ||
|
|
96aa9b10cc | ||
|
|
bc64069021 | ||
|
|
e9cb238ce1 | ||
|
|
f0f8d4c242 | ||
|
|
f2c3172e16 | ||
|
|
fd9e917b62 | ||
|
|
86af7093ac | ||
|
|
2d99b7cafe | ||
|
|
906c79a8e8 | ||
|
|
47b79181b7 | ||
|
|
34f9826829 | ||
|
|
252f80fadb | ||
|
|
0a5b08020f | ||
|
|
6759e9c2d3 | ||
|
|
8799eec717 | ||
|
|
56e0c8a31f | ||
|
|
4bcb07f9d1 | ||
|
|
a0a3dc2416 | ||
|
|
51639322e2 |
@@ -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
|
||||
|
||||
+279
@@ -7,6 +7,285 @@ and this project follows [Semantic Versioning](https://semver.org/).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [1.16.0] - 2026-10-19
|
||||
|
||||
### Added
|
||||
|
||||
- Waitlist for sold-out tickets. A new event setting (event wizard → Visibility) turns it on, with how long offered places are held (24 hours by default) and whether someone can be offered fewer places than they asked for.
|
||||
- Sold-out tickets show "Join waitlist" on the event page, the register page and the events list. Guests can join on events that allow guest registration.
|
||||
- When places free up, the next person is emailed/WhatsApped a claim link, and the places are held for them so nobody else can buy them. Places free up through a cancellation (by the person or staff), a smaller booking, or a raised stock limit.
|
||||
- The claim link opens the register page with the places pre-selected, and works without logging in.
|
||||
- Unclaimed offers expire (checked every 10 minutes) and move on to the next person.
|
||||
- People can leave a waitlist from My Events, which now lists their waitlists and any places held for them.
|
||||
- Supervisor → Waitlist page: the queue per event, with offer now (even over the stock limit), move up/down and remove.
|
||||
- The daily summary email shows each event's waitlist count.
|
||||
- New `/api/waitlist` endpoints. `POST /api/registrations` accepts `waitlistToken`.
|
||||
- Requires the `add_waitlist` database migration.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Manual registrations (Manual registration, At the door, the kiosk) and adding places to an existing booking never checked stock, so either could take an option over its stock limit. Both are now checked like a normal registration.
|
||||
- Stock is now counted in one place (`utils/stock.js`) for registration checks, `availableCount` and `isSoldOut`, including places held for waitlist offers.
|
||||
|
||||
## [1.15.0] - 2026-10-19
|
||||
|
||||
### Added
|
||||
|
||||
- Parental consent for youth events. A new "Requires parental consent" event setting (event wizard → Visibility) has its own consent text and optional medical info, emergency contact and photo consent questions. It turns on named attendees automatically.
|
||||
- Registering then needs a name and date of birth for every main-ticket place. Anyone under 18 on the event's start date needs a parent or guardian's name, email and phone.
|
||||
- This applies on the registration page, manual registration, at the door, the kiosk, and when editing names in My Events.
|
||||
- The parent gets an email and a WhatsApp (always both) with a link to a new consent page (`/consent/<token>`). There they read the text, answer the optional questions, type their full name and confirm they're the parent or guardian, then give or decline.
|
||||
- The system records the time, IP, device, signed name, and the exact consent text and version shown.
|
||||
- Medical and emergency details are stored encrypted.
|
||||
- The registrant and the event's notification recipients are told the outcome.
|
||||
- Tickets are still issued, but the scanner blocks check-in until consent is given, showing why. Supervisors can override, and the override is recorded in the audit log. On a youth event, a ticket with no named attendee counts as missing consent.
|
||||
- A daily 09:00 job reminds unanswered requests after 2 days, then every 3 days until the event. It also deletes medical and emergency details a set number of days after the event (Site Settings → Legal, default 30). `CONSENT_JOBS_ENABLED=false` turns it off.
|
||||
- Supervisor → Parental consent page: consent status per attendee, resend one or all pending requests, re-request consent after the consent text changes, and a printable, confidential medical & emergency list. Every view of the list is recorded in the audit log.
|
||||
- New reports: "Parental consent status" and "Medical & emergency list".
|
||||
- My Events shows each attendee's consent status, with a "Resend consent" button.
|
||||
- Anonymising or closing an account also clears that person's attendees' parent, date-of-birth, medical and emergency details.
|
||||
- New endpoints under `/api/consent`. `GET /api/tickets/scan-preview/:qrCode` now returns `consentStatus`, and `POST /api/tickets/scan/:qrCode` accepts `overrideConsent`.
|
||||
- Requires the `add_parental_consent` database migration.
|
||||
|
||||
## [1.14.0] - 2026-10-19
|
||||
|
||||
### Added
|
||||
|
||||
- Group tickets (named attendees). A new "Named attendees" event setting (event wizard → Visibility) lets someone registering several people give each main-ticket place a name, with an optional email and phone. It's offered on the registration page, Manual registration, At the door and the self-service kiosk.
|
||||
- Each named person gets their own single-person ticket, with their name on the ticket PDF, the scanner's confirm screen and the staff ticket list. Places left unnamed share one quantity ticket as before.
|
||||
- The payer chooses whether every ticket comes to them or each person gets their own by email/WhatsApp; anyone without contact details falls back to the payer.
|
||||
- The payer stays the account owner for billing, invoices and payments.
|
||||
- An attendee whose email or phone matches an existing account is linked to it (never when the email and phone belong to two different accounts). Their ticket then appears in their own My Events, marked "Booked by …", and they can open and submit their own attendee form.
|
||||
- My Events → registration popup has a new "People" section for adding or changing names and contact details, switching ticket delivery, and sending an attendee a link to fill in their own form (`/forms?registrationId=…&attendeeId=…`). An attendee whose ticket has been scanned can't be removed.
|
||||
- Attendee forms now show one block per named person, labelled with their name, and each response is saved against that attendee.
|
||||
- New endpoints `PUT /api/registrations/:id/attendees` and `POST /api/registrations/:id/attendees/:attendeeId/send-form-link`. `POST /api/registrations` and `/manual` accept `attendees` and `ticketDelivery`.
|
||||
- Requires the `add_named_attendees` database migration.
|
||||
|
||||
### Changed
|
||||
|
||||
- Reducing the quantity of a main ticket removes the newest names beyond the new quantity.
|
||||
- Check-in confirmations for a named ticket go to that person when the registration sends each person their own tickets.
|
||||
- Unauthenticated guest registration now matches an existing account by phone as well as email, and is refused (409) when the email and phone belong to two different accounts, instead of silently reusing whichever account the email matched.
|
||||
- Free registrations now issue tickets through the same generator as paid ones, instead of a separate copy of the logic.
|
||||
|
||||
### Security
|
||||
|
||||
- Registration details (`GET /api/registrations/:id`) hide attendees' email addresses and phone numbers from anyone other than the payer and staff, such as an attendee opening their form link.
|
||||
|
||||
## [1.13.0] - 2026-10-19
|
||||
|
||||
### Added
|
||||
|
||||
- Coupon codes. Supervisors create percentage, fixed-amount or free ("comp") codes under Supervisor → Coupon codes.
|
||||
- Codes can be limited to particular events, ticket options or variants, a date window, total and per-person uses (one per person by default), a minimum number of tickets, and specific people, roles (e.g. Staff and above) or an email domain. Restricted codes need a logged-in account, so they can't be shared around.
|
||||
- By default the better of the code and the early-bird price wins; a code can instead stack on top of early-bird pricing.
|
||||
- People enter codes under "Have a code?" on the registration page and in My Events (for registrations not yet paid in full). Staff can apply them in Manual registration, At the door and the self-service kiosk.
|
||||
- The discount is locked in when it's applied, the same way early-bird prices are. It appears as its own line on invoices, receipts, registration and payment emails, WhatsApp messages and the Yoco checkout.
|
||||
- Editing a registration's items recalculates percentage and free codes; fixed-amount codes keep their amount. Cancelling a registration frees the use. A free code that covers everything marks the registration paid and sends the tickets.
|
||||
- Use limits are enforced under a database lock, so the last available use can't be claimed twice.
|
||||
- Staff price: a ticket option or variant can carry a staff price (event wizard → Items & pricing) that Staff and above pay automatically when it's lower than the normal price.
|
||||
- New "Discounts (coupon codes)" report (Reports → Financial), showing uses and value given away per code and event. The Finance report's "What was sold" is now shown after discounts.
|
||||
- Coupon activity in the admin audit log: codes created, edited and turned off, and codes applied or removed by staff.
|
||||
- New endpoints: `POST /api/coupons/validate`, `GET|POST /api/coupons`, `GET|PUT /api/coupons/:id`, `GET /api/coupons/redemptions`, `POST|DELETE /api/registrations/:id/coupon`. `POST /api/registrations` and `/manual` accept `couponCode`.
|
||||
- Registration responses now include `discountTotal` and `subtotalComputed`.
|
||||
- Requires the `add_coupon_codes` database migration (`npm run prisma:deploy` in `backend`).
|
||||
|
||||
### Fixed
|
||||
|
||||
- Yoco checkout line items priced each item at its most recent price times the whole quantity. When a line spanned several price tranches (some tickets at early-bird, some at full price), the item lines didn't match the amount charged. They now list each price actually charged.
|
||||
- The free-registration check on `POST /api/registrations` and `/manual` had the same fault: it used the latest price for the whole quantity instead of the real total.
|
||||
|
||||
## [1.12.0] - 2026-10-19
|
||||
|
||||
### Added
|
||||
|
||||
- Two-factor authentication is now required for Admin and Supervisor accounts. An admin or supervisor without it is sent a code at their next login (by email, or WhatsApp when SMTP isn't set up), and entering it turns two-factor on — the same code step as before, with a note explaining why. Trusted devices don't skip this first code. They can change whether codes come by email or WhatsApp from Profile & Security, but can no longer turn two-factor off (`POST /api/users/2fa/disable` returns 403 for them).
|
||||
- Existing admin and supervisor sessions without two-factor are ended on their next request (`401` with `code: "TWO_FACTOR_REQUIRED"`), and the login page explains why. Promoting a user to supervisor or admin also ends their sessions if they don't have two-factor yet, so they enrol at their next login. An admin "Reset 2FA" for a supervisor/admin now means they set it up again at their next login.
|
||||
- The requirement is skipped when no channel can deliver a code — no SMTP configured and no WhatsApp — so a fresh install (or the setup wizard, which also gets a short grace period) never locks the first admin out. `TWO_FACTOR_REQUIRED_ROLES=false` turns the requirement off as an emergency switch.
|
||||
|
||||
- `FEATURES.md`, a repo-root overview of everything the platform does — roles, public site, events, registration, payments, tickets, messaging, dashboards, cashup, reports, settings, security, background jobs, and a page map.
|
||||
|
||||
### Changed
|
||||
|
||||
- `info.md` (the overview for prospective churches) brought up to date with features added since it was written — donations, invoices and receipts, cashup and costs, profit reporting, audit trail, scheduled messaging, self-service kiosk, scan points, backups, branding, and privacy controls.
|
||||
|
||||
## [1.11.0] - 2026-10-08
|
||||
|
||||
### Added
|
||||
|
||||
- Optional two-factor authentication. Any user can turn it on from Profile & Security and choose to receive a 6-digit code by email or WhatsApp; the chosen channel is confirmed with a code before it takes effect. At login the code is asked for after the password, expires after 10 minutes and allows 5 attempts, with a "Resend code" option. If a WhatsApp code can't be sent, it falls back to the account's email address.
|
||||
- "Trust this device for 30 days" on the code step, so a remembered device skips the code. Trusted devices are forgotten when the password is changed or reset, when sessions are signed out everywhere, and when two-factor is turned off.
|
||||
- Admins can reset two-factor authentication for a user who can no longer receive codes ("Reset 2FA" on the Users page). Turning two-factor on or off shows in Account activity.
|
||||
- The self-service kiosk asks supervisors with two-factor on for their code at setup. Changing the event now re-confirms the password against the existing kiosk session (`POST /api/users/verify-password`) instead of logging in again.
|
||||
|
||||
### Changed
|
||||
|
||||
- `POST /api/users/login` returns `{ twoFactorRequired, challengeId, method, destination }` instead of a token for accounts with two-factor on; the token is issued by the new `POST /api/users/login/2fa`. Accounts without two-factor are unaffected. Requires the `add_two_factor` database migration (`npm run prisma:deploy` in `backend`).
|
||||
|
||||
## [1.10.6] - 2026-10-08
|
||||
|
||||
### Fixed
|
||||
|
||||
- The frontend production build (`next build`) no longer hangs or fails when the backend isn't reachable. With no `NEXT_PUBLIC_API_URL`/`API_URL` set, the root layout fetched a relative `/api/settings` URL that never settled during prerender, so every page timed out after 60 seconds; server-side fetches now fail fast when no API base is configured. The home page and the Events page also no longer fail the build when `/api/events` can't be reached — they build with an empty list and fill in on the first revalidation. At runtime a failed fetch still errors, so the last good page keeps being served.
|
||||
|
||||
### Security
|
||||
|
||||
- Updated dependencies to clear `npm audit` advisories: `express`, `multer`, `proxy-addr`, `qs` and others in the backend, `next` (15.5.27) and `sharp` (0.35.5) in the frontend, and `nodemailer` from 9 to 10 (requires Node.js 20 or newer). The root package now overrides `shell-quote` to a patched release, since every `concurrently` version pins a vulnerable one. Backend production dependencies now report no known vulnerabilities; the remaining advisories are in development tooling (`nodemon`, `jest`, `tailwindcss` 3) and have no non-breaking fix.
|
||||
- Dependency install scripts are now explicitly allow-listed via `allowScripts` in the backend and frontend `package.json` (Prisma, `@sentry/cli`, `unrs-resolver`), so npm no longer blocks them.
|
||||
|
||||
## [1.10.5] - 2026-10-01
|
||||
|
||||
### Fixed
|
||||
|
||||
- Events that have already started but not yet finished now show on the home page and the Events page. Both pages were hiding an event as soon as its start time passed; they now keep it listed until its end time, which matches what `GET /api/events` already returns.
|
||||
|
||||
## [1.10.4] - 2026-09-01
|
||||
|
||||
### Fixed
|
||||
|
||||
- "Add to calendar" (`GET /api/events/:id/ics`) always failed with a 400 in production: the `ics` library's `url` field requires a scheme (`https://...`), but `FRONTEND_URL`/`APP_BASE_URL` was configured without one, so `createEvent()` rejected it before generating any output. A missing scheme is now assumed to be `https://` instead of failing.
|
||||
|
||||
## [1.10.3] - 2026-08-28
|
||||
|
||||
### Fixed
|
||||
|
||||
- Creating an event was never recorded in the admin audit log — 1.10.0 added `event_created` as a logged action on the frontend's filter list, but the backend's `createEvent` never actually called `logAdminAction`, only `updateEvent`/`deleteEvent` did. Event creation is now logged the same way.
|
||||
- Staff cancelling a registration via `PUT /api/registrations/:id` (the staff status-change endpoint, separate from the owner-facing `DELETE /:id` cancel route) wasn't logged at all — only the `DELETE` path logged `registration_cancelled`. Both paths now log it, verified against all six audit categories promised in 1.10.0 (refunds, donation assign/unassign, manual registrations, staff-initiated cancellations, event create/update/delete, settings changes) with no other gaps found.
|
||||
- Added a "Back to dashboard" link to Admin → Audit log, matching the back-link pattern already used on the Cashup page.
|
||||
|
||||
## [1.10.2] - 2026-08-28
|
||||
|
||||
### Fixed
|
||||
|
||||
- Sentry wasn't instrumenting Express (`[Sentry] express is not instrumented` at startup): `express`, `cors`, and `@prisma/client` were required at the top of `backend/src/index.js` before `Sentry.init()` ran, but Sentry's auto-instrumentation patches those modules via a require hook that only works if `Sentry.init()` runs first. `Sentry.init()` now runs immediately after `dotenv.config()`, before any of the libraries it instruments are required.
|
||||
|
||||
## [1.10.1] - 2026-08-28
|
||||
|
||||
### Security
|
||||
|
||||
- Fixed a path-traversal vulnerability in event-image, branding (logo/favicon), and event-attachment uploads: the stored filename embedded the client-supplied `originalname` unsanitized, so a crafted filename (e.g. containing `../`) could write the uploaded file outside the intended `public/uploads` subfolder anywhere the server process could write. Uploaded files are now always saved under a server-generated random name; the original filename is preserved only as display metadata.
|
||||
- `express-rate-limit` was declared as a root-only dependency despite being required directly by the backend (`backend/src/index.js`) — a plain `cd backend && npm install`, as documented in the deployment guide, would not have installed it. It's now a proper `backend/package.json` dependency.
|
||||
- Bumped `next` (frontend) off a version affected by a critical unauthenticated RCE in the React Flight protocol (GHSA-9qr9-h5gf-34mp) and several other CVEs, and switched it from an exact pin to `^15.5.24` so future patch releases install automatically.
|
||||
- Bumped `multer`, `nodemailer`, `jsonwebtoken`, and `uuid` (backend) to versions fixing DoS, SMTP/CRLF-injection, HMAC-verification, and buffer-bounds advisories; added an `overrides` entry so the vulnerable `uuid` nested under `exceljs` is also patched. Ran `npm audit fix` across all three workspaces (root/backend/frontend) — 0 known vulnerabilities remain.
|
||||
- `jwt.verify()` now pins `algorithms: ['HS256']` explicitly rather than trusting the algorithm from the token header.
|
||||
- Uploaded assets served from `/uploads` now get `Content-Security-Policy: default-src 'none'; sandbox` and `X-Content-Type-Options: nosniff`, so an uploaded SVG containing a `<script>` can no longer execute if opened directly.
|
||||
|
||||
## [1.10.0] - 2026-08-28
|
||||
|
||||
### Added
|
||||
|
||||
- Event detail pages now have an "Add to calendar" button, and registration confirmation emails include an "Add to calendar" link (requires `BACKEND_URL` to be set), both downloading a `.ics` file for the event.
|
||||
- SEO: the site now serves a `sitemap.xml` (every public page plus every event) and `robots.txt`, and event/event-list pages have proper Open Graph/Twitter metadata for link previews.
|
||||
- Error monitoring via Sentry — set `SENTRY_DSN` (backend) and `NEXT_PUBLIC_SENTRY_DSN` (frontend) to enable; a no-op otherwise. Captures unhandled backend errors/promise rejections and frontend errors.
|
||||
- Nightly local database backups (`pg_dump`, gzipped, 02:00, 14-day retention by default) with a new Site Settings → Backups tab to view, manually trigger, and download them. Requires `pg_dump` to be installed on the app server; set `BACKUP_ENABLED=false` to disable. Local-disk only — not uploaded anywhere else.
|
||||
- Admin audit trail: refunds, donation assign/unassign, manual registrations, staff-initiated cancellations, event create/update/delete, and settings changes are now logged with who/when/what, viewable at Admin → Audit log.
|
||||
- Starter backend test suite (Jest) covering the early-bird tranche pricing logic and donation-balance calculation — the two areas with the most complex money-handling history in this changelog.
|
||||
|
||||
## [1.9.5] - 2026-08-27
|
||||
|
||||
### Fixed
|
||||
|
||||
- The public event-alias route (`/:redirectUrl`, used for short links like `/camp-2025`) is a catch-all matching any unmatched top-level path, so every bot/scanner probe for a nonexistent page (`/wp-login.php`, `/.env`, etc.) was hitting the backend and firing a live database query. Both the frontend route and the `GET /api/events/by-alias/:redirectUrl` endpoint now reject anything that isn't a plausible alias (letters/numbers/hyphens/underscores) before touching the database, instead of forwarding scanner noise straight through — this traffic pattern could exhaust the database connection pool and take the server down under load.
|
||||
|
||||
### Added
|
||||
|
||||
- PM2 (`ecosystem.config.js`) now restarts either process if it exceeds 500MB of memory, instead of letting an unbounded leak run until the OS OOM-kills it.
|
||||
|
||||
## [1.9.4] - 2026-08-26
|
||||
|
||||
### Added
|
||||
|
||||
- New `TRUST_PROXY` backend env var — set it when the app runs behind a reverse proxy (e.g. nginx on a separate server) so rate limiting reads the real client IP from `X-Forwarded-For` instead of the proxy's. Accepts a hop count, `true`/`false`, or trusted proxy IP(s)/CIDR(s).
|
||||
|
||||
## [1.9.3] - 2026-08-26
|
||||
|
||||
### Fixed
|
||||
|
||||
- The server used to hard-crash (`process.exit(1)`) on any unhandled promise rejection, so a single missed error handler anywhere in the app's fire-and-forget notification code (email/WhatsApp sending) could take the whole server down. It now logs the error and keeps running.
|
||||
- Accounts created on someone's behalf (at-the-door walk-in registration, or manual registration from the Admin/Supervisor dashboard) now get their activation link (email or WhatsApp, whichever they have) sent immediately when the account is created, instead of only on their first failed login attempt — matching what the Terms of Use already promised.
|
||||
- Manual registration with a real email address used to create the account already active with a fixed, undisclosed password (`Hope123`) — the visitor had no way to know it. That account is now created inactive and gets the same immediate activation link, so the visitor sets their own password — unless a password was supplied directly (see below), in which case it's activated immediately with no link needed.
|
||||
- The self-service kiosk's "Create an account" password field never actually worked — the account was always created with a different password behind the scenes, so visitors who set one couldn't log in with it. Manual registration now honours a caller-supplied password and activates the account immediately instead of discarding it.
|
||||
- Removed the "Guest (no account)" checkboxes from the Manual Registration pages (both the current one and the legacy form) and the equivalent flag from the at-the-door kiosk — they stopped affecting backend behaviour once every walk-in account started being created inactive with an activation link. The self-service kiosk's own "Create an account" toggle still controls whether that link is sent, since that one is the visitor's own choice rather than staff acting on their behalf.
|
||||
|
||||
## [1.9.2] - 2026-08-22
|
||||
|
||||
### Fixed
|
||||
|
||||
- Contact-only events (no registration possible) were still getting the daily summary email — it now skips them since there's nothing to summarize.
|
||||
|
||||
## [1.9.1] - 2026-08-22
|
||||
|
||||
### Fixed
|
||||
|
||||
- The browser tab title and homepage "Welcome to..." heading always showed the app's built-in default name instead of the organisation name configured in Site Settings → Organisation.
|
||||
- The backend's status page (`/`) and API docs page (`/docs`) always showed "Cross Code Events" instead of the configured organisation name.
|
||||
|
||||
## [1.9.0] - 2026-08-21
|
||||
|
||||
### Added
|
||||
|
||||
- Events now have an optional Location field (address), defaulting to the organisation's configured address when creating a new event. Wherever an address is shown — event admin form, public event page, event card listings, the Contact page, and Site Settings → Organisation — there's now a "Directions"/"View on map" link, and the event detail and Contact pages also show an embedded Google Maps view (no API key required).
|
||||
|
||||
## [1.8.0] - 2026-08-21
|
||||
|
||||
### Added
|
||||
|
||||
- Events can now be marked "contact-only" (e.g. baptism) — they appear on the public events list/detail pages with a "Contact us" button (opening a popup with name/phone/email) instead of a Register button, and have no ticket options or registration flow. Configurable from a new toggle in the admin event wizard's Basic Details step.
|
||||
- Registration and payment notifications now include a branded PDF: registration confirmations attach an **invoice** (itemized breakdown, early-bird discount, balance due, and — when there's an outstanding balance — a clickable "pay now" link and QR code pointing at a Yoco checkout) whenever the registration isn't fully paid, and payment/donation confirmations attach a **payment receipt** (itemized breakdown plus the amount paid on that transaction). Sent as an email attachment and, over WhatsApp, as the PDF itself (with the existing message text as its caption) in place of the previous text-only send. Both documents pick up the org's configured name/logo/brand color from Site Settings → Branding.
|
||||
- The user dashboard's registration detail popup now has an "Invoice" button and, next to each payment, a "Receipt" button — both pop up a small Email/WhatsApp choice and (re)send that document on demand, via two new endpoints (`POST /api/registrations/:id/send-invoice`, `POST /api/payments/:id/send-receipt`), restricted to the registration/payment's own owner. The invoice works regardless of balance, showing "Paid in full" when nothing is owed. The Payment history page (`/dashboard/user/payments`) got the same per-payment "Receipt" button too.
|
||||
|
||||
### Fixed
|
||||
|
||||
- The user dashboard's "(early bird)" label was a price-comparison heuristic — it fired on any line priced below the option's base price, including plain cheaper variants that were never actually early-bird tickets. It now checks the real applied-tier flag the backend already tracks.
|
||||
- Buying more of an already-purchased ticket type after its early-bird tier expired re-priced the *entire* line at the new price instead of adding the new quantity at the new price (e.g. 5 tickets @ R50 + 1 more after the price rose to R100 came out to R600 instead of R350). Each purchase now gets its own price "tranche" recorded against the registration line, so previously-bought tickets keep their original price and only the newly added quantity uses the current price. Registration/reporting pages that showed a single blended price per line now render (or total) each tranche separately.
|
||||
- An unpaid (or partially paid) registration's price only ever got refreshed when a payment was actually attempted — an early-bird tier that expired while tickets sat unpaid kept showing its old, no-longer-honoured price (and its "(early bird)" tag) indefinitely on the dashboard until the user tried to pay. Viewing a registration (dashboard, registration detail, or an event's registration list) now refreshes still-outstanding pricing on the spot, same as payment already did.
|
||||
- The user dashboard's registration detail popup listed one line per price tranche in raw creation order, so a ticket bought across several separate registrations (e.g. some early-bird, some not) showed as a wall of near-duplicate lines. It now merges tranches with the same item/price/tier into one line and groups early-bird lines together, separately from standard-price lines.
|
||||
- Registration confirmation, payment/donation, and reminder emails and WhatsApp messages computed "Total due"/"Balance" by loading a registration without its price tranches, so any line spanning more than one tranche (e.g. some tickets bought at the early-bird price, more added later at full price) fell back to charging the *entire* quantity at the most recent tranche's price — silently wiping out the early-bird discount from the outstanding balance shown to the user. These sends now load tranches and total each line the same tranche-aware way the dashboard already did; the itemized line amounts in those messages (which previously always showed the option's undiscounted base price) are now correct too.
|
||||
- Editing an event option's early-bird tiers (even just tweaking one tier's deadline or price) deleted and recreated *every* tier for that option with brand-new ids, including tiers the admin didn't touch. Since past purchases point at a specific tier by id, this silently severed that link on every edit — wiping the "early bird" attribution (and locked-in stock-limit counts, risking oversold tiers) off historical registrations that were never meant to change. Tiers are now upserted by id, so their identity — and everything referencing them — survives an edit; a tier is only ever removed if it's no longer in the saved list *and* has no purchase history against it.
|
||||
|
||||
## [1.7.0] - 2026-08-20
|
||||
|
||||
### Added
|
||||
|
||||
- Site Settings → Branding now supports a full Primary/Secondary/Accent brand color system (previously a single accent color that only recolored the org name in the navbar) applied site-wide — buttons, nav highlights, hover states, and links — plus a favicon upload alongside the existing logo upload, and a live preview panel (with website/email and desktop/mobile views) that updates as colors are picked, before saving. Selecting a logo now also suggests brand colors extracted from it. Outgoing emails' header and call-to-action buttons now pick up the Primary color too, while semantic alert colors (success/warning/danger) stay fixed. The setup wizard's Branding step got the same preview and color-suggestion treatment.
|
||||
|
||||
### Fixed
|
||||
|
||||
- The setup wizard's logo/favicon upload was silently failing — by the time it ran, the admin account already existed and the upload endpoint required admin auth, but the request never attached the token. It now does, and a failed upload surfaces as a visible error instead of quietly leaving branding unset.
|
||||
- A custom favicon saved via Site Settings never actually showed in the browser tab: the site had a static `favicon.ico` in the special Next.js `app/` convention path, which generates its own `<link rel="icon">` tag that always took priority over the admin's saved one. Moved the static file to `public/` so it's just the default fallback, not a competing tag.
|
||||
- Every unconfigured-organisation default across the app (org name, email subjects, WhatsApp messages, report file metadata, backend status/API-docs pages, `From:` fallback address) said "Hope Events"/"Hope Family Church" instead of a neutral placeholder — now defaults to "Cross Code" (the platform vendor) consistently. Several email subject lines (login/welcome/activation/password-changed/account-closed) were also fully hardcoded regardless of the configured org name; they now interpolate it dynamically like the email body already did.
|
||||
- The optional legal settings (operator name, Information Officer name/email, website URL, effective date, and the Legal pages' own email fallback) defaulted to this project's real founder's name/email/church details — actively misleading if ever shown on another organisation's deployed site before they're configured. These now default to obviously-generic placeholders (e.g. `example.com`, `[not yet configured]`) instead.
|
||||
- Added `SETTINGS.md`, a repo-root reference documenting every setting's default behaviour when left unconfigured.
|
||||
|
||||
## [1.6.0] - 2026-08-08
|
||||
|
||||
### Added
|
||||
|
||||
- Supervisors can now unassign a donation that was previously applied to a registration, from a new "Assigned donations" list on the Payments page. This reverses the allocation (the registration's balance goes back up and the donation becomes available again), reverting the registration's status and revoking any tickets issued only because that allocation completed payment — blocked if a ticket has already been scanned. The registrant is notified by email/WhatsApp, mirroring the notification sent when a donation is first applied. The list can be searched (registrant, donor, or event) and filtered by event.
|
||||
|
||||
### Fixed
|
||||
|
||||
- The internal admin notification for a donation applied to a registration read "Payment recorded" / "Registration payment", indistinguishable from a real incoming payment even though no new money changed hands — it now says "Donation applied" throughout, matching the registrant-facing email's distinct wording.
|
||||
|
||||
### Performance
|
||||
|
||||
- Reconciling a Yoco card payment or sending a payment link on the supervisor Payments page no longer blocks the response on ticket-PDF generation and email/WhatsApp sends — these now run in the background, matching how manual payments already worked.
|
||||
- Registration creation and payment capture now resolve per-option pricing, stock checks, and ticket generation concurrently instead of one option at a time.
|
||||
- The supervisor Payments page no longer fires one request per registration to compute outstanding balances (that data was already included in the registrations response); post-action refreshes also run in parallel instead of sequentially.
|
||||
- Added indexes for `Registration(createdAt, status)`, `Ticket(createdAt)`, and `Payment(originalPaymentId, isDonation)` to speed up dashboard stats and donation-leg lookups. `GET /api/registrations` now supports optional `page`/`limit` pagination.
|
||||
|
||||
## [1.5.4] - 2026-08-07
|
||||
|
||||
### Fixed
|
||||
|
||||
- The homepage "Join Us" button sent logged-in users to the registration page instead of somewhere useful. It now shows "My Dashboard" and links to their dashboard when a user is already logged in.
|
||||
|
||||
## [1.5.3] - 2026-08-07
|
||||
|
||||
### Fixed
|
||||
|
||||
- Help guide popups could render taller than the screen on mobile, with no way to reach the close button or "Got it" button: only the tab content area had a height cap, so the header, quick links, and footer weren't accounted for. The whole popup is now capped to the screen height, with just the tab content scrolling internally.
|
||||
|
||||
## [1.5.2] - 2026-08-07
|
||||
|
||||
### Fixed
|
||||
|
||||
+473
@@ -0,0 +1,473 @@
|
||||
# Hope Events — Feature Overview
|
||||
|
||||
A complete map of what the platform does, grouped by who uses it. For setup and
|
||||
deployment see [README.md](./README.md); for the API see
|
||||
[backend/API_DOCUMENTATION.md](./backend/API_DOCUMENTATION.md); for setting
|
||||
defaults see [SETTINGS.md](./SETTINGS.md); for history see
|
||||
[CHANGELOG.md](./CHANGELOG.md).
|
||||
|
||||
**Stack:** Next.js (React, Tailwind, shadcn/ui) frontend · Express + Prisma +
|
||||
PostgreSQL backend · Yoco for card payments · SMTP for email · WAWP for
|
||||
WhatsApp · PM2 for process management · optional Sentry for error monitoring.
|
||||
|
||||
---
|
||||
|
||||
## Contents
|
||||
|
||||
1. [Roles](#roles)
|
||||
2. [Public site](#public-site)
|
||||
3. [Events](#events)
|
||||
4. [Registration](#registration)
|
||||
5. [Payments, donations & refunds](#payments-donations--refunds)
|
||||
6. [Tickets & check-in](#tickets--check-in)
|
||||
7. [Attendee forms](#attendee-forms)
|
||||
8. [Messaging & notifications](#messaging--notifications)
|
||||
9. [User dashboard (My Events)](#user-dashboard-my-events)
|
||||
10. [Staff tools](#staff-tools)
|
||||
11. [Supervisor tools](#supervisor-tools)
|
||||
12. [Admin tools](#admin-tools)
|
||||
13. [Cashup & event costs](#cashup--event-costs)
|
||||
14. [Reports](#reports)
|
||||
15. [Site settings & setup wizard](#site-settings--setup-wizard)
|
||||
16. [Accounts & security](#accounts--security)
|
||||
17. [Help system](#help-system)
|
||||
18. [Background jobs](#background-jobs)
|
||||
19. [Operations](#operations)
|
||||
20. [Page map](#page-map)
|
||||
|
||||
---
|
||||
|
||||
## Roles
|
||||
|
||||
Four roles, each including everything the roles below it can do:
|
||||
|
||||
| Role | Can do |
|
||||
|------|--------|
|
||||
| **User** | Browse events, register, pay, donate, view tickets, fill in forms, manage their own profile |
|
||||
| **Staff** | Scan tickets, view/print event tickets, view registrations and form responses |
|
||||
| **Supervisor** | Create and manage events, record payments/refunds, assign donations, at-the-door desk, manual registration, bulk email/WhatsApp, reports, site banner, self-service kiosk |
|
||||
| **Admin** | Manage users and roles, all registrations, event costs and cashup (close/reopen events), audit log, site settings, WhatsApp connection, backups |
|
||||
|
||||
---
|
||||
|
||||
## Public site
|
||||
|
||||
- **Home page** — hero section with the organisation's name and branding, upcoming
|
||||
and in-progress events, and a "Join Us" button (becomes "My Dashboard" when
|
||||
logged in).
|
||||
- **Events list** (`/events`) — event cards with picture, dates, location,
|
||||
price, and a Register / Sold Out / Contact us button.
|
||||
- **Event detail** (`/events/[id]`) — description, ticket options with
|
||||
variant and early-bird pricing, low-stock and sold-out badges, downloadable
|
||||
attachments, embedded Google Map with "Directions" link, and an
|
||||
**Add to calendar** (`.ics`) button.
|
||||
- **Short links** — events can have an alias (e.g. `/camp-2025`) that redirects
|
||||
to the event. Only plausible aliases hit the database, so bot probes are
|
||||
rejected cheaply.
|
||||
- **Contact page** (`/contact`) — organisation email, phone, address and map.
|
||||
- **Legal pages** — Terms of Use and Privacy Policy, filled from Site Settings.
|
||||
- **Site banner** — a site-wide announcement bar set by supervisors.
|
||||
- **Lockdown rules** (`/lockdown-rules`) — static rules page for the youth
|
||||
lockdown event.
|
||||
- **SEO** — `sitemap.xml` (every public page and event), `robots.txt`, and
|
||||
Open Graph/Twitter link-preview metadata.
|
||||
- **Theming** — brand colours, logo and favicon come from Site Settings.
|
||||
|
||||
---
|
||||
|
||||
## Events
|
||||
|
||||
Created and edited by supervisors through a 6-step wizard: **Basic Details →
|
||||
Items & Pricing → Sections → Form → Visibility → Attachments**.
|
||||
|
||||
- **Basic details** — title, description, start/end dates, registration
|
||||
deadline, picture (upload or URL), location (defaults to the org's address),
|
||||
contact person.
|
||||
- **Contact-only events** — for things like baptisms: shown publicly with a
|
||||
"Contact us" popup instead of registration, with no tickets.
|
||||
- **Ticket options** — multiple options per event (e.g. Adult, Child, Dinner,
|
||||
T-shirt), each with a price and optional **stock limit**. One option is marked
|
||||
as the main ticket, which is what gets checked in at the door.
|
||||
- **Variants** — sub-types of an option (sizes, colours, Adult/Child/VIP), each
|
||||
with an optional price override and its own stock limit.
|
||||
- **Early-bird tiers** — deadline- and/or stock-limited cheaper prices per
|
||||
option or variant. Prices are locked in per purchase ("tranches"), so buying
|
||||
more later doesn't re-price what was already bought.
|
||||
- **Sections** — named scan points (e.g. "Main hall", "Dinner") limited to
|
||||
certain ticket options, so a scanner at that point only accepts those tickets.
|
||||
- **Visibility** — active/inactive, hidden (reachable only by link), scheduled
|
||||
go-live date, require login vs. allow guest registration, or no registration
|
||||
at all.
|
||||
- **Attachments** — downloadable files (maps, schedules, waivers) shown on the
|
||||
event page.
|
||||
- **Notify recipients** — choose which staff get registration/payment alerts and
|
||||
the daily summary for the event.
|
||||
- **Sold-out handling** — an event is sold out when every stock-limited option
|
||||
is exhausted. "X remaining" badges appear at a threshold that scales with
|
||||
capacity.
|
||||
|
||||
---
|
||||
|
||||
## Registration
|
||||
|
||||
- **Online registration** (`/register/[eventId]`) — pick options, variants and
|
||||
quantities; see early-bird prices and stock warnings; fill in the event form.
|
||||
- **Guest registration** — when an event doesn't require login, an inactive
|
||||
account is created and an activation link is sent by email or WhatsApp.
|
||||
- **Statuses** — Pending, Confirmed, Partially Paid, Paid, Cancelled.
|
||||
- **Edit options** — users can add or change items on their registration
|
||||
(not for past or closed events).
|
||||
- **Self-cancellation** — allowed only before any payment; admins can cancel
|
||||
any time.
|
||||
- **Pricing refresh** — when an unpaid registration is viewed or paid, expired
|
||||
early-bird prices are re-evaluated, and the user is warned if the total
|
||||
changed.
|
||||
- **Manual registration** (supervisor) — register someone on their behalf,
|
||||
creating an account if needed. Includes a "Record Payment" tab.
|
||||
- **Duplicate-account protection** — if an email and phone belong to two
|
||||
different accounts, the registration is blocked instead of merging them.
|
||||
- **Group tickets** — on events with "Named attendees" turned on, someone booking
|
||||
several places can name each person (with optional email/phone). Each named person
|
||||
gets their own ticket and their own attendee form; unnamed places share one ticket.
|
||||
Tickets go all to the payer, or each to the person (falling back to the payer).
|
||||
Attendees matching an existing account see their ticket in My Events. Names can be
|
||||
added or changed later from My Events, and attendees can be sent a link to fill in
|
||||
their own form. Available on the registration page, manual registration, At the
|
||||
door and the kiosk.
|
||||
- **Waitlist** — on events with a waitlist, sold-out tickets show "Join waitlist"
|
||||
(on the event page, the register page and the event list). When places free up — a
|
||||
cancellation, a smaller booking or a higher stock limit — the next person is
|
||||
emailed/WhatsApped a claim link and the places are held for them (24 hours by
|
||||
default) so nobody else can buy them. Unclaimed offers expire and move on; people
|
||||
who asked for more than came free can be offered what's free or skipped. Claiming
|
||||
works without logging in. People can leave from My Events.
|
||||
- **Parental consent (youth events)** — on events that require it, every attendee
|
||||
gives a date of birth; for anyone under 18, a parent or guardian gets an email and
|
||||
a WhatsApp with a consent link. They read the event's consent text, optionally give
|
||||
medical info, an emergency contact and photo consent, type their name to sign, and
|
||||
give or decline. Tickets are issued but can't be checked in until consent is given
|
||||
(supervisors can override, audited). Reminders go after 2 days, then every 3 days.
|
||||
Medical details are encrypted and deleted a set number of days after the event.
|
||||
- **Coupon codes** — percentage, fixed-amount or free (comp) codes, entered under
|
||||
"Have a code?" on the registration page, in My Events (for unpaid registrations),
|
||||
manual registration, At the door and the kiosk. Codes can be limited to events,
|
||||
ticket options or variants, dates, total and per-person uses, a minimum quantity,
|
||||
and specific people, roles or an email domain (restricted codes need a login).
|
||||
Early-bird prices either stack or the better price wins. The discount is locked in
|
||||
when applied and shows on invoices, receipts, emails and the Yoco checkout;
|
||||
cancelling frees the use. Managed from Supervisor → Coupon codes.
|
||||
- **Staff price** — a ticket option or variant can have a staff price that Staff and
|
||||
above get automatically, without a code.
|
||||
|
||||
---
|
||||
|
||||
## Payments, donations & refunds
|
||||
|
||||
- **Online card payments via Yoco** — checkout for the full outstanding balance,
|
||||
confirmed by a signed webhook.
|
||||
- **Payment links** — supervisors can generate and send a Yoco link for any
|
||||
amount (including partial).
|
||||
- **Manual payments** — cash, card, EFT, voucher or other, recorded by staff.
|
||||
Each payment records **who recorded it** as well as who it's for.
|
||||
- **Refunds** — negative payments against an original payment, netted against
|
||||
the original method.
|
||||
- **Donations** — users can donate from their dashboard (`/dashboard/user/donate`).
|
||||
Supervisors can **assign** a donation (fully or partly) to someone's
|
||||
registration and **unassign** it later. Assignments are kept as separate
|
||||
records, so the original donation never changes.
|
||||
- **Yoco reconciliation** — imported Yoco transactions can be matched to a
|
||||
registration, recorded as a donation (optionally choosing the donor), or
|
||||
ignored.
|
||||
- **Invoices & receipts** — branded PDFs: an invoice (itemised, early-bird
|
||||
discount, balance due, pay-now link and QR code) and a payment receipt. Sent
|
||||
automatically and re-sendable on demand by email or WhatsApp.
|
||||
- **Method normalisation** — Apple Pay / Google Pay / checkout payments count as
|
||||
"Card" in reports and user views.
|
||||
|
||||
---
|
||||
|
||||
## Tickets & check-in
|
||||
|
||||
- **Automatic tickets** — QR-coded tickets are generated when a registration is
|
||||
fully paid (or free), and sent as a PDF by email and/or WhatsApp.
|
||||
- **Quantity tickets** — one ticket can cover several people and be redeemed
|
||||
partially (e.g. check in 2 of 3).
|
||||
- **Named tickets** — group bookings can give each person their own ticket, shown
|
||||
with their name on the PDF, the scanner and the staff ticket list.
|
||||
- **QR scanner** — works in any mobile browser. A preview checks the ticket is
|
||||
for the selected event/section, then a confirmation screen shows holder, type
|
||||
and remaining quantity.
|
||||
- **Check-in confirmation** — holders get an email/WhatsApp saying how many were
|
||||
checked in and how many remain.
|
||||
- **Scan stats** — recent scans and per-event scan counts.
|
||||
- **Printing** — A4 sheets (8 tickets per page) and A6 single tickets.
|
||||
- **Resend** — send tickets to a different email/phone at the door.
|
||||
|
||||
---
|
||||
|
||||
## Attendee forms
|
||||
|
||||
- **Form builder** per event — field types: Yes/No, Text, Paragraph, Number,
|
||||
Date, plus Statement and Heading blocks; required/optional, help text,
|
||||
reordering. The whole form can be marked required.
|
||||
- **Drafts** — partially filled forms are auto-saved.
|
||||
- **Responses** — view, filter (event, user, past/inactive) and print one
|
||||
response per A4 page. Supervisors can edit responses.
|
||||
|
||||
---
|
||||
|
||||
## Messaging & notifications
|
||||
|
||||
Every user picks a **notification preference**: Email, WhatsApp, or Both.
|
||||
|
||||
**Automatic notifications**
|
||||
|
||||
| Trigger | Goes to |
|
||||
|---------|---------|
|
||||
| Registration created / updated | Registrant (with invoice if unpaid) + event notify recipients |
|
||||
| Payment recorded / donation applied / unassigned | Registrant (with receipt) + admins |
|
||||
| Refund processed | Registrant + admins |
|
||||
| Tickets generated | Registrant (ticket PDF) |
|
||||
| Ticket checked in | Ticket holder |
|
||||
| Daily summary, 07:00, for each live event that hasn't started yet | Registrations inbox + event notify recipients |
|
||||
| Account registered, activation, login, password changed/reset, account closed | The account holder |
|
||||
|
||||
**Bulk messaging** (supervisor)
|
||||
|
||||
- **Email attendees** and **WhatsApp attendees** — filter by status or pick
|
||||
specific attendees, preview, send now or schedule for later.
|
||||
- **Placeholders** — `{{name}}`, `{{event.title}}`, `{{event.link}}`,
|
||||
`{{event.start}}`, `{{balance}}`, and `{{payment.link}}` (a live Yoco link for each recipient's
|
||||
outstanding balance).
|
||||
- **Broadcasts** — ad-hoc email/WhatsApp to any users or addresses, not tied to
|
||||
one event.
|
||||
- **Automations** — schedule a sequence of lifecycle emails for an event
|
||||
(pre-event, final reminder, thank-you, promo for another event).
|
||||
- **Manage scheduled** — view, edit or delete queued jobs per channel. Sends
|
||||
run in the background and are purged 24 h after sending.
|
||||
|
||||
**WhatsApp** is connected through the WAWP API: an admin links a number in
|
||||
Site Settings → WhatsApp (QR code or pairing code), with status, restart and
|
||||
logout controls. Disconnected sessions are recovered automatically.
|
||||
|
||||
---
|
||||
|
||||
## User dashboard (My Events)
|
||||
|
||||
- Registrations with status badges, event thumbnails, and Closed/Past/Inactive
|
||||
tags. Past and closed events are hidden by default.
|
||||
- Registration detail: itemised bill (early-bird lines grouped), payments,
|
||||
**Invoice** and **Receipt** buttons, edit, cancel, **Make payment**.
|
||||
- Upcoming events and tickets side by side, with bulk ticket email/WhatsApp.
|
||||
- **Payment history** (`/dashboard/user/payments`) — paginated, with filters by
|
||||
date, method and payment/refund.
|
||||
- **Donate** page.
|
||||
- **Forms** — complete outstanding attendee forms.
|
||||
- **Profile & Security** — edit details and notification preference, change
|
||||
password, see recent account activity (logins, password changes, device),
|
||||
sign out everywhere, deactivate or delete my data.
|
||||
|
||||
---
|
||||
|
||||
## Staff tools
|
||||
|
||||
- **Staff dashboard** — scan stats and quick actions.
|
||||
- **Ticket scanning** (`/dashboard/staff/ticket-scanning`) — camera QR scanner
|
||||
with event and section filters.
|
||||
- **Event tickets & printing** (`/dashboard/staff/event-tickets`) — list, search
|
||||
and print an event's tickets.
|
||||
|
||||
---
|
||||
|
||||
## Supervisor tools
|
||||
|
||||
- **Dashboard** — KPIs (active events, revenue, donations, registrations,
|
||||
tickets sold, each vs. the previous month), revenue trend chart, top events,
|
||||
and recent scans. Tiles link to the matching report.
|
||||
- **Manage events** — the 6-step event wizard, ticket options, variants,
|
||||
sections, form and attachments.
|
||||
- **At the door** — search by name/phone/ticket ID, walk-in registration,
|
||||
payments, a **Check-In** tab for redeeming main tickets by quantity, and
|
||||
ticket printing/resending.
|
||||
- **Self-service kiosk** (`/self-service`) — a touchscreen page a supervisor
|
||||
unlocks for an event, where visitors register themselves. Existing accounts
|
||||
are matched by email/phone, and any change to their details needs
|
||||
confirmation.
|
||||
- **Manual registration** — register someone on their behalf and record their
|
||||
payment.
|
||||
- **Payments** — tabs for Payment, Refund, Donations (assign/unassign),
|
||||
Reconcile (Yoco transactions), and Payment Link.
|
||||
- **Coupon codes** (`/dashboard/supervisor/coupons`) — create, edit and turn off
|
||||
discount codes, and see who used each one.
|
||||
- **Waitlist** (`/dashboard/supervisor/waitlist`) — the queue per event: offer a
|
||||
place now, move people up or down, or remove them.
|
||||
- **Parental consent** (`/dashboard/supervisor/consent`) — consent status per
|
||||
attendee on youth events, resend (one or all pending), re-request after the
|
||||
consent text changes, and a printable medical & emergency list (audited).
|
||||
- **Email attendees / WhatsApp attendees** — bulk and scheduled messaging.
|
||||
- **Forms** — view, filter, edit and print form responses.
|
||||
- **Reports** — see [Reports](#reports).
|
||||
- **Site banner** (`/set-banner`) — set the announcement bar.
|
||||
|
||||
---
|
||||
|
||||
## Admin tools
|
||||
|
||||
- **Admin dashboard** — same KPIs as supervisor, plus admin quick actions.
|
||||
- **Manage users** — server-side search and filters (role, active), paging,
|
||||
edit role and notification preference, revoke sessions, delete or
|
||||
**anonymise** personal data.
|
||||
- **Registrations** — all registrations, with filters (event, status, text),
|
||||
status counts, revenue and outstanding totals, and per-registration options,
|
||||
form answers and payments (with who recorded each one).
|
||||
- **Cashup** — see below.
|
||||
- **Audit log** (`/dashboard/admin/audit-log`) — who did what and when for
|
||||
refunds, donation assign/unassign, manual registrations, staff
|
||||
cancellations, event create/update/delete, settings changes, and coupon codes
|
||||
(created, edited, turned off, applied or removed by staff).
|
||||
- **Site settings** — see below.
|
||||
|
||||
---
|
||||
|
||||
## Cashup & event costs
|
||||
|
||||
- **Event costs** — once-off or per-item costs (optionally linked to a ticket
|
||||
option), with a "paid from takings" method so cash on hand is correct.
|
||||
- **Expected vs. actual** — expected takings per method (cash/card/EFT/other)
|
||||
compared with actual amounts, with variance shown.
|
||||
- **Cash denomination counts** — entered per staff member, at any time. The
|
||||
event's actual cash is the sum of these counts.
|
||||
- **Payment accountability by staff member** — what each person recorded, by
|
||||
method, with live cash variance.
|
||||
- **Close / quick close / reopen** an event — closing locks it from further
|
||||
edits and hides it from the kiosk and user actions. Drafts are saved as you
|
||||
go.
|
||||
- **Report tab** — a read-only cashup summary, shown automatically once the
|
||||
event is closed.
|
||||
- **Cashup audit trail** — every close/reopen with totals and who did it.
|
||||
|
||||
---
|
||||
|
||||
## Reports
|
||||
|
||||
A full-width workspace with shared filters (events, date range with presets,
|
||||
include past/inactive/closed) and a searchable grid of reports, each opening in
|
||||
a viewer with its own filters, stat tiles and bar charts.
|
||||
|
||||
| Category | Reports |
|
||||
|----------|---------|
|
||||
| Financial | Payments between dates · Revenue summary (by method) · Revenue detailed · Finance report (revenue & costs, after discounts) · Profit report · Discounts (coupon codes) |
|
||||
| Registration | Attendees per event (grouped by option) · Registration type counts · Master orders breakdown · Registration status breakdown (count by registration or ticket quantity) · Parental consent status · Medical & emergency list |
|
||||
| Ticketing | Ticket usage summary |
|
||||
| Donations | Donations breakdown (used / unused per event) |
|
||||
| Cashup | Cashup reconciliation · Cashup audit trail |
|
||||
|
||||
**Export:** Print, branded PDF download, styled Excel (`.xlsx`) workbook,
|
||||
Email (to yourself), or WhatsApp (the PDF to your own number). Dashboard tiles
|
||||
deep-link into reports with `?report=&range=`.
|
||||
|
||||
---
|
||||
|
||||
## Site settings & setup wizard
|
||||
|
||||
**Setup wizard** (`/setup`) — on a fresh database, walks through organisation
|
||||
details, the first admin account and branding. No manual seeding needed.
|
||||
|
||||
**Site Settings** (`/dashboard/admin/settings`) — stored in the database and
|
||||
applied without a restart. Secrets are encrypted with AES-256-GCM.
|
||||
|
||||
| Tab | Contains |
|
||||
|-----|----------|
|
||||
| Organisation | Name, tagline, contact email/phone, address (with map link) |
|
||||
| Branding | Primary/secondary/accent colours, logo, favicon, colours suggested from the logo, live website/email preview on desktop and mobile |
|
||||
| Notifications | Registrations inbox and admin notification emails |
|
||||
| Email | SMTP host/port/TLS/user/password/from, with a "Test connection" button |
|
||||
| Legal | Operator, Information Officer, website, effective date for the Terms/Privacy pages |
|
||||
| WhatsApp | WAWP credentials, connect a number, status/restart/logout |
|
||||
| Backups | List, run now, download database backups |
|
||||
|
||||
See [SETTINGS.md](./SETTINGS.md) for the default of every setting.
|
||||
|
||||
---
|
||||
|
||||
## Accounts & security
|
||||
|
||||
- Register, log in, forgot/reset password, activate account (for accounts
|
||||
created on someone's behalf).
|
||||
- JWT authentication (HS256 pinned) with **session revocation** (token
|
||||
versioning) — users can sign out everywhere, and admins can do it for any user.
|
||||
- **Two-factor authentication** — a 6-digit login code by email or WhatsApp
|
||||
(10-minute expiry, 5 attempts, resend), with "Trust this device for 30 days".
|
||||
**Required for Admins and Supervisors**: their next login sends a code that turns
|
||||
it on, and they can change the channel but not turn it off (skipped only when no
|
||||
email or WhatsApp channel can deliver a code; `TWO_FACTOR_REQUIRED_ROLES=false`
|
||||
is the emergency switch). Optional for everyone else from Profile & Security.
|
||||
Admins can reset it for a locked-out user, and the kiosk asks for the code at
|
||||
setup.
|
||||
- **Login lockout** after repeated failed attempts, plus login rate limiting
|
||||
(`TRUST_PROXY` to read real IPs behind a proxy).
|
||||
- **Security event log** — logins, password changes and resets, with device
|
||||
and IP.
|
||||
- **Account closure** — deactivate, or anonymise personal data while keeping
|
||||
financial records.
|
||||
- **Upload hardening** — server-generated filenames, and uploads served with a
|
||||
sandbox CSP and `nosniff`.
|
||||
|
||||
---
|
||||
|
||||
## Help system
|
||||
|
||||
A floating **"Need help?"** button on every page opens a page-specific guide
|
||||
(tabs, quick links). Every page has dedicated content (`frontend/src/content/help/`),
|
||||
falling back to a general guide.
|
||||
|
||||
---
|
||||
|
||||
## Background jobs
|
||||
|
||||
| Job | Schedule |
|
||||
|-----|----------|
|
||||
| Scheduled email/WhatsApp worker | Polls every 30 s (`SCHEDULED_EMAILS_INTERVAL_MS`) and purges sent jobs after 24 h |
|
||||
| Daily event summary email | 07:00 daily |
|
||||
| Waitlist: expire unclaimed offers, offer freed places | Every 10 minutes (`WAITLIST_JOB_ENABLED=false` to disable) |
|
||||
| Parental consent reminders, and deleting medical details after events | 09:00 daily (`CONSENT_JOBS_ENABLED=false` to disable) |
|
||||
| Database backup (`pg_dump`, gzipped, 14-day retention) | 02:00 daily (`BACKUP_ENABLED=false` to disable) |
|
||||
| Temp file cleanup (files older than 7 days in `/temp`) | Daily |
|
||||
|
||||
---
|
||||
|
||||
## Operations
|
||||
|
||||
- **PM2** (`ecosystem.config.js`) runs backend and frontend, restarting either
|
||||
one above 500 MB of memory.
|
||||
- **Sentry** error monitoring when `SENTRY_DSN` / `NEXT_PUBLIC_SENTRY_DSN` are
|
||||
set.
|
||||
- Unhandled promise rejections are logged and don't crash the server.
|
||||
- Backend status page (`/`) and built-in API docs page (`/docs`).
|
||||
- Jest tests for early-bird tranche pricing and donation balances.
|
||||
- Versioning via git tags and [CHANGELOG.md](./CHANGELOG.md).
|
||||
|
||||
---
|
||||
|
||||
## Page map
|
||||
|
||||
| Path | Who | What |
|
||||
|------|-----|------|
|
||||
| `/` | Public | Home |
|
||||
| `/events`, `/events/[id]` | Public | Event list and detail |
|
||||
| `/[alias]` | Public | Event short link |
|
||||
| `/register/[eventId]` | Public / User | Registration |
|
||||
| `/registration/success` | Public / User | Confirmation, form and pay |
|
||||
| `/payment/success`, `/payment/failure`, `/payment/cancel` | Public | Yoco return pages |
|
||||
| `/forms` | Public / User | Attendee forms |
|
||||
| `/contact`, `/legal/terms`, `/legal/privacy`, `/lockdown-rules` | Public | Info pages |
|
||||
| `/login`, `/register`, `/forgot-password`, `/reset-password`, `/activate-account` | Public | Accounts |
|
||||
| `/setup` | First run | Setup wizard |
|
||||
| `/consent/[token]` | Parent/guardian | Give or decline parental consent |
|
||||
| `/waitlist/claim/[token]` | Public | Claim a waitlist place |
|
||||
| `/self-service` | Supervisor-unlocked | Kiosk |
|
||||
| `/set-banner` | Supervisor | Site banner |
|
||||
| `/dashboard/user` (+ `/payments`, `/donate`, `/forms`, `/profile`, `/reset-password`) | User | My Events |
|
||||
| `/dashboard/staff` (+ `/ticket-scanning`, `/event-tickets`) | Staff | Staff tools |
|
||||
| `/dashboard/supervisor` (+ `/events`, `/event-options`, `/at-the-door`, `/manual`, `/manual-registration`, `/payments`, `/coupons`, `/waitlist`, `/consent`, `/email-attendees`, `/whatsapp-attendees`, `/forms`, `/reports`) | Supervisor | Supervisor tools |
|
||||
| `/dashboard/admin` (+ `/users`, `/registrations`, `/forms`, `/cashup`, `/cashup/[id]`, `/audit-log`, `/settings`) | Admin | Admin tools |
|
||||
@@ -111,6 +111,8 @@ Documentation: see Frontend README for environment and scripts.
|
||||
- Backend API Documentation: ./backend/API_DOCUMENTATION.md
|
||||
- Backend README: ./backend/README.md
|
||||
- Frontend README: ./frontend/README.md
|
||||
- Site Settings — defaults reference: ./SETTINGS.md
|
||||
- Feature overview: ./FEATURES.md
|
||||
|
||||
## Recent Changes
|
||||
|
||||
|
||||
+88
@@ -0,0 +1,88 @@
|
||||
# Site Settings — Defaults Reference
|
||||
|
||||
Every setting below is stored as a row in the `AppSetting` key-value table and
|
||||
is editable at runtime via **Admin → Site Settings** (or, for the first few,
|
||||
the `/setup` wizard on a fresh install). None of them are required — this
|
||||
page documents exactly what happens when a given key has never been saved.
|
||||
|
||||
Settings changes take effect within 60 seconds everywhere (the backend's
|
||||
in-process settings cache TTL) without a server restart.
|
||||
|
||||
## Organisation
|
||||
|
||||
| Setting | Default when unset | Notes |
|
||||
|---|---|---|
|
||||
| `org_name` | `"Cross Code"` | Falls back to `ORG_NAME` env var first, then this hardcoded default (the platform vendor's name, used as a neutral placeholder — `org_name` is a required field in the setup wizard, so this fallback is practically unreachable once a site has been set up). Shown in the navbar, email headers/subjects, and WhatsApp messages. |
|
||||
| `org_tagline` | `"Connecting community through events"` | Falls back to `ORG_TAGLINE` env var first. Shown under the org name in the email header. |
|
||||
| `org_email` | *(blank)* | No fallback in most places. **Exception:** the Terms of Use / Privacy Policy pages default to `privacy@example.com` (an obvious placeholder) if unset — see the Legal section below. |
|
||||
| `org_phone` / `org_address` | *(blank)* | No fallback — simply omitted wherever shown if unset. |
|
||||
| `app_base_url` | `APP_BASE_URL` or `FRONTEND_URL` env var, else `http://localhost:3001` | Used to build links in emails (password reset, ticket delivery, etc.). Should always be set in production. |
|
||||
|
||||
## Branding
|
||||
|
||||
| Setting | Default when unset | Notes |
|
||||
|---|---|---|
|
||||
| `primary_color` | Legacy `accent_color` value if present, else `#4F46E5` (indigo) on the website / `#1e3a5f` (navy) in emails | See [Two different fallbacks](#two-different-fallbacks-for-primary_color) below — this is the one setting with a genuine inconsistency worth knowing about. Drives buttons, links, nav highlights, and the email header/CTA. |
|
||||
| `secondary_color` | `210 40% 96.1%` background / near-black text (light gray "wash", shadcn's stock theme) | The Branding tab's color picker *pre-fills* `#8B5CF6` as a suggested starting point, but that's only a UI default — if you never hit Save, the actual site uses the light-gray fallback, not violet. |
|
||||
| `accent_color` | `210 40% 96.1%` background / near-black text (same light gray as Secondary) | Same story as Secondary — the picker pre-fills `#EC4899` (pink) as a suggestion only. |
|
||||
| `logo_url` | Bundled logo (`frontend/src/app/church_logo.jpg`) | Shown in the navbar. This is still the original Hope Family Church artwork checked into the repo — swap the file (or upload a real logo via the Branding tab) before deploying for a different organisation. |
|
||||
| `favicon_url` | `frontend/public/favicon.ico` (the site's original generic favicon) | Shown in the browser tab. |
|
||||
|
||||
### Two different fallbacks for `primary_color`
|
||||
|
||||
If `primary_color` has *never* been saved (no admin has ever used the new
|
||||
3-color Branding tab) **and** there's no legacy `accent_color` value either,
|
||||
the website and outgoing emails currently disagree on what "no color" means:
|
||||
|
||||
- **Website** (`globals.css` / `tailwind.config.js` static defaults): `#4F46E5`, a bright indigo.
|
||||
- **Emails** (`backend/src/utils/email.js` `getOrg()`): `#1e3a5f`, a dark navy — unless overridden by the `EMAIL_HEADER_COLOR` env var.
|
||||
|
||||
In practice this only matters on a brand-new install before the setup wizard
|
||||
or Branding tab has been saved even once — the wizard always writes
|
||||
`primary_color`, so this gap closes itself the moment setup completes.
|
||||
|
||||
## Notifications
|
||||
|
||||
| Setting | Default when unset | Notes |
|
||||
|---|---|---|
|
||||
| `reg_notification_emails` | `REGISTRATIONS_EMAIL` env var, else *(blank)* | If truly blank, no internal notification email is sent for new registrations/payments/donations — it's silently skipped, not an error. |
|
||||
|
||||
## Email delivery (SMTP)
|
||||
|
||||
| Setting | Default when unset | Notes |
|
||||
|---|---|---|
|
||||
| `smtp_host` | `SMTP_HOST`/`EMAIL_HOST` env var, else *(blank)* | If blank, the app **does not send real email** — it uses nodemailer's `jsonTransport`, which just logs the would-be email to the console. This is the single biggest "nothing is configured" gotcha — ticket/payment/password-reset emails silently don't go anywhere until SMTP is set up. |
|
||||
| `smtp_port` | `587` | |
|
||||
| `smtp_secure` | `false` | |
|
||||
| `smtp_user` / `smtp_pass` | *(blank)* | Stored AES-256-GCM encrypted (key derived from `JWT_SECRET`). |
|
||||
| `smtp_from` | *(blank)* | If blank, outgoing mail's `From:` address falls back to the literal `no-reply@crosscode.local`. |
|
||||
|
||||
## Legal (Terms of Use / Privacy Policy)
|
||||
|
||||
| Setting | Default when unset | Notes |
|
||||
|---|---|---|
|
||||
| `legal_operator_name` | `"[operator details not yet configured]"` | Deliberately a generic, obviously-incomplete placeholder rather than any real person's or organisation's name. Unlike `org_name`, these legal fields are optional and can realistically stay unset on a live site — defaulting them to Cross Code's (or anyone else's) real identity would misrepresent who's legally responsible for that deployment. |
|
||||
| `legal_io_name` | `"[not yet configured]"` | Same reasoning as `legal_operator_name`. |
|
||||
| `legal_io_email` | `"io@example.com"` | Rendered as a `mailto:` link, so the placeholder stays a syntactically valid address on the IANA-reserved `example.com` domain rather than a bracketed non-email string that would break the link. |
|
||||
| `legal_website_url` | `"example.com"` | Same reasoning — used to build an `https://` link. |
|
||||
| `legal_effective_date` | `"[not yet set]"` | Plain text only, no link — a bracketed placeholder is fine here. |
|
||||
| `consent_medical_retention_days` | `30` | Youth events: medical and emergency details from parental consent forms are deleted this many days after the event ends (daily 09:00 job). The consent record itself — who agreed, when, to which text — is kept. Set under Site Settings → Legal. |
|
||||
| `org_email` (as used on legal pages specifically) | `"privacy@example.com"` | Different fallback than `org_email`'s general default (blank) — also rendered as a `mailto:` link, so it needs to stay a valid-looking address. |
|
||||
|
||||
## WhatsApp
|
||||
|
||||
| Setting | Default when unset | Notes |
|
||||
|---|---|---|
|
||||
| `WAWP_ACCESS_TOKEN` / `WAWP_INSTANCE_ID` | `WAWP_ACCESS_TOKEN`/`WAWP_INSTANCE_ID` env vars, else *(blank)* | No hardcoded default — WhatsApp sending is simply unavailable until both are configured (Admin → Site Settings → WhatsApp). |
|
||||
|
||||
## Backups
|
||||
|
||||
| Setting | Default when unset | Notes |
|
||||
|---|---|---|
|
||||
| `backup_retain_count` | `14` | How many nightly backups to keep on disk (oldest deleted beyond this count). Editable at Admin → Site Settings → Backups. See `BACKUP_ENABLED` (env var) to turn the nightly job off entirely. |
|
||||
|
||||
## Setup state
|
||||
|
||||
| Setting | Default when unset | Notes |
|
||||
|---|---|---|
|
||||
| `setup_complete` | `false` (treated as `"not true"`) | Controls whether `/setup` redirects to `/login` or runs the first-time wizard. Set to `"true"` automatically when the wizard finishes. |
|
||||
@@ -4,10 +4,22 @@ DATABASE_URL="postgresql://username:password@localhost:5432/hope_events"
|
||||
# ─── Auth ─────────────────────────────────────────────────────────────────────
|
||||
JWT_SECRET=your_jwt_secret_here_minimum_32_characters
|
||||
|
||||
# Two-factor authentication is required for admins and supervisors whenever
|
||||
# email (SMTP) or WhatsApp can deliver a code. Set to false only as an emergency
|
||||
# switch, e.g. if the mail server is down and admins are locked out.
|
||||
# TWO_FACTOR_REQUIRED_ROLES=true
|
||||
|
||||
# ─── Server ───────────────────────────────────────────────────────────────────
|
||||
PORT=5000
|
||||
NODE_ENV=development
|
||||
|
||||
# Set this if the app runs behind a reverse proxy (e.g. nginx on a separate
|
||||
# server) so rate limiting reads the real client IP instead of the proxy's.
|
||||
# Accepts a hop count ("1"), "true"/"false", or comma-separated IP(s)/CIDR(s)
|
||||
# of your trusted proxy (e.g. "10.0.0.5" or "10.0.0.0/8"). Leave unset if the
|
||||
# app is not behind a proxy.
|
||||
# TRUST_PROXY=1
|
||||
|
||||
# ─── CORS ─────────────────────────────────────────────────────────────────────
|
||||
# Comma-separated list of allowed frontend origins
|
||||
FRONTEND_URL=http://localhost:3000
|
||||
@@ -40,6 +52,19 @@ WAWP_INSTANCE_ID=your_wawp_instance_id
|
||||
DAILY_SUMMARY_ENABLED=true
|
||||
SCHEDULED_EMAILS_ENABLED=true
|
||||
SCHEDULED_EMAILS_INTERVAL_MS=30000
|
||||
# Daily 09:00 parental-consent job: reminders, and deleting medical details after events
|
||||
CONSENT_JOBS_ENABLED=true
|
||||
# Waitlist: expire unclaimed offers and offer freed places (every 10 minutes by default)
|
||||
WAITLIST_JOB_ENABLED=true
|
||||
# WAITLIST_JOB_INTERVAL_MS=600000
|
||||
# Nightly database backup at 02:00 (requires the `pg_dump` binary on this host).
|
||||
# Retention count is set via Admin → Site Settings → Backups (default 14).
|
||||
BACKUP_ENABLED=true
|
||||
|
||||
# ─── Error monitoring (Sentry) — optional ─────────────────────────────────────
|
||||
# Leave unset to disable entirely (a no-op, not an error). Set NEXT_PUBLIC_SENTRY_DSN
|
||||
# in frontend/.env too if you want frontend errors captured.
|
||||
# SENTRY_DSN=https://xxxxx@oxxxxxx.ingest.sentry.io/xxxxx
|
||||
|
||||
# ─── Note ─────────────────────────────────────────────────────────────────────
|
||||
# The following are managed via Admin → Site Settings and stored in the database:
|
||||
|
||||
@@ -18,6 +18,9 @@ Tokens are issued by `POST /api/users/login`.
|
||||
- [Event Options & Variants](#event-options--variants)
|
||||
- [Event Attachments](#event-attachments)
|
||||
- [Registrations](#registrations)
|
||||
- [Coupons](#coupons)
|
||||
- [Parental consent](#parental-consent)
|
||||
- [Waitlist](#waitlist)
|
||||
- [Payments](#payments)
|
||||
- [Tickets](#tickets)
|
||||
- [Broadcasts (Email)](#broadcasts-email)
|
||||
@@ -43,7 +46,9 @@ Tokens are issued by `POST /api/users/login`.
|
||||
| Method | Path | Auth | Description |
|
||||
|--------|------|------|-------------|
|
||||
| POST | `/api/users` | public | Register new account |
|
||||
| POST | `/api/users/login` | public | Login, returns JWT |
|
||||
| POST | `/api/users/login` | public | Login, returns JWT — or a two-factor challenge when the account has 2FA on |
|
||||
| POST | `/api/users/login/2fa` | public | Exchange a two-factor code for the JWT |
|
||||
| POST | `/api/users/login/2fa/resend` | public | Send a new code for a pending login challenge (60s cooldown) |
|
||||
| POST | `/api/users/forgot` | public | Request password reset email |
|
||||
| POST | `/api/users/reset` | public | Reset password with token |
|
||||
| POST | `/api/users/activate` | public | Activate account from email link |
|
||||
@@ -51,9 +56,13 @@ Tokens are issued by `POST /api/users/login`.
|
||||
| PUT | `/api/users/profile` | user+ | Update own profile |
|
||||
| POST | `/api/users/revoke-sessions` | user+ | Revoke own sessions |
|
||||
| POST | `/api/users/close-account` | user+ | Deactivate own account |
|
||||
| POST | `/api/users/2fa/enable` | user+ | Start turning on 2FA — sends a code to the chosen channel |
|
||||
| POST | `/api/users/2fa/confirm` | user+ | Finish turning on 2FA with that code |
|
||||
| POST | `/api/users/2fa/disable` | user+ | Turn off 2FA (requires password) |
|
||||
| POST | `/api/users/verify-password` | user+ | Re-confirm own password without starting a new session |
|
||||
| GET | `/api/users` | supervisor+ | List users — supports `?search=`, `?role=`, `?isActive=true\|false`, `?page=`, `?limit=` |
|
||||
| GET | `/api/users/:id` | admin | Get user by ID |
|
||||
| PUT | `/api/users/:id` | admin | Update user (name, email, role, isActive, phoneNumber) |
|
||||
| PUT | `/api/users/:id` | admin | Update user (name, email, role, isActive, phoneNumber). `twoFactorEnabled: false` resets a user's 2FA; it cannot be switched on this way |
|
||||
| DELETE | `/api/users/:id` | admin | Deactivate user (`isActive: false`) |
|
||||
| POST | `/api/users/:id/revoke-sessions` | admin | Revoke all sessions for a user |
|
||||
| POST | `/api/users/:id/anonymize` | admin | Erase personal data — sets name to "Deleted User", clears email/phone, deactivates account |
|
||||
@@ -71,10 +80,32 @@ Returns: `{ _id, name, email, role, token }`
|
||||
|
||||
### POST /api/users/login
|
||||
```json
|
||||
{ "email": "jane@example.com", "password": "securepass123" }
|
||||
{ "email": "jane@example.com", "password": "securepass123", "deviceToken": "optional-trusted-device-token" }
|
||||
```
|
||||
Returns: `{ _id, name, email, role, token }`
|
||||
|
||||
If the account has two-factor authentication on and `deviceToken` is missing, expired or not recognised, no token is issued. A 6-digit code is sent by email or WhatsApp and the response is instead:
|
||||
```json
|
||||
{ "twoFactorRequired": true, "challengeId": "uuid", "method": "email", "destination": "j•••@example.com" }
|
||||
```
|
||||
|
||||
**Required for admins and supervisors.** An admin or supervisor without 2FA gets the same challenge with `"enrolling": true` (trusted devices don't skip it), sent to their email when SMTP is configured, otherwise WhatsApp. Verifying it turns 2FA on with that channel. If neither channel can deliver a code, the requirement is skipped so the account isn't locked out. `TWO_FACTOR_REQUIRED_ROLES=false` turns the requirement off.
|
||||
|
||||
### POST /api/users/login/2fa
|
||||
```json
|
||||
{ "challengeId": "uuid", "code": "123456", "rememberDevice": true }
|
||||
```
|
||||
Returns: `{ _id, name, email, role, token }`, plus `deviceToken` when `rememberDevice` is `true`. Send that `deviceToken` with later logins to skip the code for 30 days. Codes expire after 10 minutes and allow 5 attempts. Trusted devices are cleared when the password changes or sessions are revoked.
|
||||
|
||||
### Two-factor setup (`/api/users/2fa/*`)
|
||||
- `POST /api/users/2fa/enable` — `{ "method": "email" | "whatsapp", "password": "..." }` sends a code to that channel and returns `{ challengeId, method, destination }`. WhatsApp needs a valid phone number on the account and a connected WhatsApp session.
|
||||
- `POST /api/users/2fa/confirm` — `{ "challengeId": "uuid", "code": "123456" }` turns 2FA on.
|
||||
- `POST /api/users/2fa/disable` — `{ "password": "..." }` turns it off. Returns 403 for admins and supervisors, who must keep it on (they can switch channel with `enable`/`confirm`).
|
||||
|
||||
An admin or supervisor session without 2FA (from before the requirement, or after a promotion or 2FA reset) is rejected by every protected route with `401 { "code": "TWO_FACTOR_REQUIRED" }`; logging in again enrols them. Promoting a user to supervisor/admin ends their sessions when they don't have 2FA yet.
|
||||
|
||||
At login, if a WhatsApp code can't be sent, the code falls back to the account's email address.
|
||||
|
||||
### PUT /api/users/profile
|
||||
```json
|
||||
{ "name": "Jane Doe", "phoneNumber": "+27829999999", "notificationPreference": "email" }
|
||||
@@ -199,7 +230,11 @@ Accepted file types: `.pdf`, `.doc`, `.docx`, `.xls`, `.xlsx`, `.ppt`, `.pptx`,
|
||||
| GET | `/api/registrations` | staff+ | All registrations |
|
||||
| PUT | `/api/registrations/:id` | staff+ | Update registration status |
|
||||
| GET | `/api/registrations/event/:eventId` | staff+ | Registrations for an event |
|
||||
| POST | `/api/registrations/manual` | supervisor+ | Manual registration |
|
||||
| POST | `/api/registrations/manual` | supervisor+ | Manual registration (accepts `couponCode`) |
|
||||
| PUT | `/api/registrations/:id/attendees` | user+ | Replace the named attendees (`{ attendees: [{ id?, eventOptionId, variantId?, name, email?, phone? }], ticketDelivery? }`); owner or staff |
|
||||
| POST | `/api/registrations/:id/attendees/:attendeeId/send-form-link` | user+ | Email/WhatsApp an attendee the link to their own form |
|
||||
| POST | `/api/registrations/:id/coupon` | user+ | Apply a code to an existing registration (owner, or staff on someone's behalf) |
|
||||
| DELETE | `/api/registrations/:id/coupon` | staff+ | Remove the code (refused once a ticket on a paid registration has been scanned) |
|
||||
|
||||
### POST /api/registrations
|
||||
```json
|
||||
@@ -211,6 +246,16 @@ Accepted file types: `.pdf`, `.doc`, `.docx`, `.xls`, `.xlsx`, `.ppt`, `.pptx`,
|
||||
]
|
||||
}
|
||||
```
|
||||
Add `"couponCode": "STAFF50"` to apply a discount code. The code is checked before anything is saved (400 with the reason if it can't be used). If the last use is taken by someone else in the moment between checking and saving, the registration is still created at full price and the response includes `couponError`.
|
||||
|
||||
**Group tickets:** on events with `allowNamedAttendees`, `POST /api/registrations` and `/manual` accept `attendees: [{ eventOptionId, variantId?, name, email?, phone? }]` (main-ticket places only, no more than the places bought) and `ticketDelivery: "payer" | "each"`.
|
||||
- Each named attendee gets their own quantity-1 ticket; unnamed places share one quantity ticket.
|
||||
- An attendee whose email/phone matches one existing account is linked to it, so the ticket also appears in that person's `GET /api/tickets/mytickets`.
|
||||
- With `"each"`, tickets are emailed/WhatsApped to attendees directly, falling back to the payer.
|
||||
- Form responses can carry `attendeeId`.
|
||||
|
||||
Registration responses include `discountTotal` (the coupon discount), `subtotalComputed` (items before the discount), `totalDueComputed` (after it) and `couponRedemptions` (the active code).
|
||||
|
||||
For unauthenticated (guest) registration on events with `requiresAuth: false`:
|
||||
```json
|
||||
{
|
||||
@@ -233,6 +278,73 @@ For unauthenticated (guest) registration on events with `requiresAuth: false`:
|
||||
|
||||
---
|
||||
|
||||
## Waitlist
|
||||
|
||||
Events with `waitlistEnabled` (plus `waitlistOfferHours`, default 24, and `waitlistPartialOffers`, default true) take a queue for sold-out lines.
|
||||
|
||||
| Method | Path | Auth | Description |
|
||||
|--------|------|------|-------------|
|
||||
| POST | `/api/waitlist` | optional | Join: `{ eventId, eventOptionId, variantId?, quantity }` (+ `guestName`, `guestEmail`, `guestPhone` when not logged in on a guest event). Only when the line can't cover the quantity |
|
||||
| GET | `/api/waitlist/mine` | user+ | My active entries |
|
||||
| DELETE | `/api/waitlist/:id` | user+ | Leave (an open offer moves on) |
|
||||
| GET | `/api/waitlist/claim/:token` | public | What a claim link offers |
|
||||
| GET | `/api/waitlist/events/:eventId` | staff+ | The queue for an event |
|
||||
| POST | `/api/waitlist/:id/offer` | supervisor+ | Offer now (`{ quantity? }`), even over the stock limit |
|
||||
| POST | `/api/waitlist/:id/move` | supervisor+ | `{ direction: "up" \| "down" }` |
|
||||
| POST | `/api/waitlist/:id/remove` | supervisor+ | Take someone off |
|
||||
|
||||
How offers work:
|
||||
- Places held for a live offer count as taken everywhere stock is counted: `availableCount`, `isSoldOut` and registration stock checks.
|
||||
- Offers are made automatically when places free up: a cancellation, a smaller booking, or a raised stock limit.
|
||||
- A job every 10 minutes (`WAITLIST_JOB_ENABLED`, `WAITLIST_JOB_INTERVAL_MS`) expires unclaimed offers and re-offers.
|
||||
- Claiming means registering with `POST /api/registrations` plus `waitlistToken`. The token stands in for logging in, and the held places are released to the claimer.
|
||||
- Manual registration and adding places to an existing booking are now checked against stock too.
|
||||
|
||||
## Parental consent
|
||||
|
||||
Youth events (`requiresParentalConsent`, plus `consentText`, `consentCollectMedical`, `consentCollectEmergency` and `consentCollectPhoto` on the event) need every main place named with a `dateOfBirth`. Attendees under 18 on the start date also need `parentName`, `parentEmail` and `parentPhone`. Changing `consentText` bumps `consentTextVersion`.
|
||||
|
||||
| Method | Path | Auth | Description |
|
||||
|--------|------|------|-------------|
|
||||
| GET | `/api/consent/:token` | public | What the parent sees: attendee, event, consent text, fields to fill in |
|
||||
| POST | `/api/consent/:token` | public | `{ decision: "give"\|"decline", signedName, isGuardian: true, medical?, emergency?, photoConsent? }` |
|
||||
| GET | `/api/consent/events/:eventId` | staff+ | Consent status per attendee, with counts |
|
||||
| POST | `/api/consent/requests/:id/resend` | user+ | Re-send one request with a new link (registrant or staff) |
|
||||
| POST | `/api/consent/events/:eventId/resend-pending` | supervisor+ | Re-send every pending request |
|
||||
| POST | `/api/consent/events/:eventId/rerequest` | supervisor+ | New requests for consent given on older text, and minors with none |
|
||||
| GET | `/api/consent/events/:eventId/medical` | supervisor+ | Decrypted medical & emergency list (audited as `medical_list_viewed`) |
|
||||
|
||||
How requests work:
|
||||
- Requests go to the parent by both email and WhatsApp, whatever anyone's notification preference.
|
||||
- Links are random tokens; only an HMAC is stored. They expire when the event ends.
|
||||
- The answer stores the signed name, time, IP, device, and the exact text and version shown.
|
||||
- Medical and emergency details are AES-256-GCM encrypted. A daily job (09:00, `CONSENT_JOBS_ENABLED=false` to disable) sends reminders (day 2, then every 3 days) and deletes them `consent_medical_retention_days` (default 30) after the event.
|
||||
|
||||
Check-in rules:
|
||||
- `GET /api/tickets/scan-preview/:qrCode` returns `consentStatus` (`not_required`, `missing`, `pending`, `given` or `declined`).
|
||||
- `POST /api/tickets/scan/:qrCode` returns `403 { code: "CONSENT_REQUIRED" }` unless consent is given.
|
||||
- Supervisors can pass `overrideConsent: true`, which is audited as `consent_override`.
|
||||
|
||||
## Coupons
|
||||
|
||||
| Method | Path | Auth | Description |
|
||||
|--------|------|------|-------------|
|
||||
| POST | `/api/coupons/validate` | optional | Check a code against a basket (`{ code, eventId, options, forUserId? }`) |
|
||||
| GET | `/api/coupons` | supervisor+ | List codes with use counts and total discount |
|
||||
| POST | `/api/coupons` | supervisor+ | Create a code |
|
||||
| GET | `/api/coupons/:id` | supervisor+ | One code with its uses |
|
||||
| PUT | `/api/coupons/:id` | supervisor+ | Edit or turn off a code |
|
||||
| GET | `/api/coupons/redemptions` | supervisor+ | Uses for the Discounts report (`eventIds`, `from`, `to`) |
|
||||
|
||||
- **Types:** `percent` (value 1–100), `fixed` (rand amount), `comp` (free).
|
||||
- **Scope:** `eventIds` (none = every event) and `options: [{ eventOptionId, variantId? }]` (none = the whole registration).
|
||||
- **Restrictions:** `userIds`, `restrictRoles` and `restrictDomain`. A match on any one is enough, and restricted codes need a known account.
|
||||
- **Limits:** `validFrom`, `validUntil`, `maxUses`, `maxUsesPerUser` (default 1) and `minQuantity`.
|
||||
- **Early-bird:** `stackEarlyBird: false` means the better price wins; `true` discounts the early-bird price.
|
||||
- **Locked amount:** the discount is locked when applied. Editing items recalculates percentage and comp codes, but fixed codes keep their amount. Cancelling releases the use.
|
||||
|
||||
Options and variants also accept an optional `staffPrice`, charged automatically to Staff and above when it's lower than the normal price.
|
||||
|
||||
## Payments
|
||||
|
||||
| Method | Path | Auth | Description |
|
||||
@@ -452,6 +564,8 @@ Manages the WAWP WhatsApp API instance. Credentials are stored in `AppSetting` (
|
||||
|
||||
Runtime configuration stored in the `AppSetting` table. Sensitive values (SMTP credentials, WAWP token) are stored AES-256-GCM encrypted.
|
||||
|
||||
For what each individual setting key defaults to when it's never been saved, see [`SETTINGS.md`](../SETTINGS.md) at the repo root.
|
||||
|
||||
| Method | Path | Auth | Description |
|
||||
|--------|------|------|-------------|
|
||||
| GET | `/api/settings` | public | Public settings (org name, tagline, colour, logo, legal keys) |
|
||||
|
||||
@@ -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) |
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
module.exports = {
|
||||
testEnvironment: 'node',
|
||||
testMatch: ['**/tests/**/*.test.js'],
|
||||
};
|
||||
Generated
+5363
-172
File diff suppressed because it is too large
Load Diff
+20
-6
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "event-management-backend",
|
||||
"version": "1.5.2",
|
||||
"version": "1.16.0",
|
||||
"description": "Event Management System Backend",
|
||||
"main": "src/index.js",
|
||||
"scripts": {
|
||||
"start": "node src/index.js",
|
||||
"dev": "nodemon src/index.js",
|
||||
"test": "echo \"Error: no test specified\" && exit 1",
|
||||
"test": "jest",
|
||||
"postinstall": "prisma generate",
|
||||
"prisma:generate": "prisma generate",
|
||||
"prisma:deploy": "prisma migrate deploy && prisma generate",
|
||||
@@ -17,23 +17,37 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@prisma/client": "^5.4.2",
|
||||
"@sentry/node": "^10.71.0",
|
||||
"axios": "^1.11.0",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^16.3.1",
|
||||
"exceljs": "^4.4.0",
|
||||
"express": "^4.18.2",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"multer": "^2.0.2",
|
||||
"express-rate-limit": "^8.6.2",
|
||||
"ics": "^3.12.0",
|
||||
"jsonwebtoken": "^9.0.3",
|
||||
"multer": "^2.2.0",
|
||||
"node-fetch": "^2.7.0",
|
||||
"nodemailer": "^7.0.5",
|
||||
"nodemailer": "^10.0.16",
|
||||
"pdfkit": "^0.17.1",
|
||||
"qrcode": "^1.5.4",
|
||||
"raw-body": "^3.0.0",
|
||||
"uuid": "^9.0.1"
|
||||
"uuid": "^11.1.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"jest": "^30.4.2",
|
||||
"nodemon": "^3.0.1",
|
||||
"prisma": "^5.4.2"
|
||||
},
|
||||
"overrides": {
|
||||
"uuid": "^11.1.1"
|
||||
},
|
||||
"allowScripts": {
|
||||
"prisma@5.22.0": true,
|
||||
"@prisma/client@5.22.0": true,
|
||||
"@prisma/engines@5.22.0": true,
|
||||
"unrs-resolver@1.12.2": true,
|
||||
"@parcel/watcher": false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
-- DropIndex
|
||||
DROP INDEX "Payment_originalPaymentId_idx";
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Payment_originalPaymentId_isDonation_idx" ON "Payment"("originalPaymentId", "isDonation");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Registration_createdAt_status_idx" ON "Registration"("createdAt", "status");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Ticket_createdAt_idx" ON "Ticket"("createdAt");
|
||||
@@ -0,0 +1,16 @@
|
||||
/*
|
||||
Warnings:
|
||||
|
||||
- You are about to drop the column `feeAmount` on the `EventCashupLine` table. All the data in the column will be lost.
|
||||
- You are about to drop the column `feeAmount` on the `Payment` table. All the data in the column will be lost.
|
||||
- You are about to drop the column `feeChannel` on the `Payment` table. All the data in the column will be lost.
|
||||
- You are about to drop the column `feeRate` on the `Payment` table. All the data in the column will be lost.
|
||||
|
||||
*/
|
||||
-- AlterTable
|
||||
ALTER TABLE "EventCashupLine" DROP COLUMN "feeAmount";
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "Payment" DROP COLUMN "feeAmount",
|
||||
DROP COLUMN "feeChannel",
|
||||
DROP COLUMN "feeRate";
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Event" ADD COLUMN "contactEmail" TEXT,
|
||||
ADD COLUMN "contactName" TEXT,
|
||||
ADD COLUMN "contactPhone" TEXT,
|
||||
ADD COLUMN "requiresRegistration" BOOLEAN NOT NULL DEFAULT true;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "RegistrationOptionTranche" (
|
||||
"id" TEXT NOT NULL,
|
||||
"registrationOptionId" TEXT NOT NULL,
|
||||
"quantity" INTEGER NOT NULL,
|
||||
"priceSnapshot" DOUBLE PRECISION NOT NULL,
|
||||
"appliedTierId" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "RegistrationOptionTranche_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "RegistrationOptionTranche_registrationOptionId_idx" ON "RegistrationOptionTranche"("registrationOptionId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "RegistrationOptionTranche_appliedTierId_idx" ON "RegistrationOptionTranche"("appliedTierId");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "RegistrationOptionTranche" ADD CONSTRAINT "RegistrationOptionTranche_registrationOptionId_fkey" FOREIGN KEY ("registrationOptionId") REFERENCES "RegistrationOption"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "RegistrationOptionTranche" ADD CONSTRAINT "RegistrationOptionTranche_appliedTierId_fkey" FOREIGN KEY ("appliedTierId") REFERENCES "EarlyBirdTier"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Event" ADD COLUMN "location" TEXT;
|
||||
@@ -0,0 +1,29 @@
|
||||
-- CreateEnum
|
||||
CREATE TYPE "AdminAuditAction" AS ENUM ('refund_created', 'donation_assigned', 'donation_unassigned', 'registration_created_manual', 'registration_cancelled', 'event_created', 'event_updated', 'event_deleted', 'settings_updated');
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "AdminAuditLog" (
|
||||
"id" TEXT NOT NULL,
|
||||
"actorId" TEXT,
|
||||
"actorRole" TEXT NOT NULL,
|
||||
"action" "AdminAuditAction" NOT NULL,
|
||||
"targetType" TEXT NOT NULL,
|
||||
"targetId" TEXT,
|
||||
"metadata" JSONB,
|
||||
"ip" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "AdminAuditLog_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "AdminAuditLog_actorId_createdAt_idx" ON "AdminAuditLog"("actorId", "createdAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "AdminAuditLog_action_createdAt_idx" ON "AdminAuditLog"("action", "createdAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "AdminAuditLog_targetType_targetId_idx" ON "AdminAuditLog"("targetType", "targetId");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "AdminAuditLog" ADD CONSTRAINT "AdminAuditLog_actorId_fkey" FOREIGN KEY ("actorId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,62 @@
|
||||
-- CreateEnum
|
||||
CREATE TYPE "TwoFactorMethod" AS ENUM ('email', 'whatsapp');
|
||||
|
||||
-- AlterEnum
|
||||
-- This migration adds more than one value to an enum.
|
||||
-- With PostgreSQL versions 11 and earlier, this is not possible
|
||||
-- in a single migration. This can be worked around by creating
|
||||
-- multiple migrations, each migration adding only one value to
|
||||
-- the enum.
|
||||
|
||||
|
||||
ALTER TYPE "SecurityEventType" ADD VALUE 'two_factor_enabled';
|
||||
ALTER TYPE "SecurityEventType" ADD VALUE 'two_factor_disabled';
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "User" ADD COLUMN "twoFactorEnabled" BOOLEAN NOT NULL DEFAULT false,
|
||||
ADD COLUMN "twoFactorMethod" "TwoFactorMethod";
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "TwoFactorChallenge" (
|
||||
"id" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"purpose" TEXT NOT NULL,
|
||||
"method" "TwoFactorMethod" NOT NULL,
|
||||
"codeHash" TEXT NOT NULL,
|
||||
"attempts" INTEGER NOT NULL DEFAULT 0,
|
||||
"lastSentAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"expiresAt" TIMESTAMP(3) NOT NULL,
|
||||
"usedAt" TIMESTAMP(3),
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "TwoFactorChallenge_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "TrustedDevice" (
|
||||
"id" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"tokenHash" TEXT NOT NULL,
|
||||
"device" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"lastUsedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"expiresAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "TrustedDevice_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "TwoFactorChallenge_userId_idx" ON "TwoFactorChallenge"("userId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "TrustedDevice_tokenHash_key" ON "TrustedDevice"("tokenHash");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "TrustedDevice_userId_idx" ON "TrustedDevice"("userId");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "TwoFactorChallenge" ADD CONSTRAINT "TwoFactorChallenge_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "TrustedDevice" ADD CONSTRAINT "TrustedDevice_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
-- CreateEnum
|
||||
CREATE TYPE "CouponType" AS ENUM ('percent', 'fixed', 'comp');
|
||||
|
||||
-- AlterEnum
|
||||
-- This migration adds more than one value to an enum.
|
||||
-- With PostgreSQL versions 11 and earlier, this is not possible
|
||||
-- in a single migration. This can be worked around by creating
|
||||
-- multiple migrations, each migration adding only one value to
|
||||
-- the enum.
|
||||
|
||||
|
||||
ALTER TYPE "AdminAuditAction" ADD VALUE 'coupon_created';
|
||||
ALTER TYPE "AdminAuditAction" ADD VALUE 'coupon_updated';
|
||||
ALTER TYPE "AdminAuditAction" ADD VALUE 'coupon_disabled';
|
||||
ALTER TYPE "AdminAuditAction" ADD VALUE 'coupon_applied_manual';
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "EventOption" ADD COLUMN "staffPrice" DOUBLE PRECISION;
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "OptionVariant" ADD COLUMN "staffPrice" DOUBLE PRECISION;
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "Registration" ADD COLUMN "discountTotal" DOUBLE PRECISION NOT NULL DEFAULT 0;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Coupon" (
|
||||
"id" TEXT NOT NULL,
|
||||
"code" TEXT NOT NULL,
|
||||
"description" TEXT,
|
||||
"type" "CouponType" NOT NULL,
|
||||
"value" DOUBLE PRECISION NOT NULL DEFAULT 0,
|
||||
"stackEarlyBird" BOOLEAN NOT NULL DEFAULT false,
|
||||
"validFrom" TIMESTAMP(3),
|
||||
"validUntil" TIMESTAMP(3),
|
||||
"maxUses" INTEGER,
|
||||
"maxUsesPerUser" INTEGER DEFAULT 1,
|
||||
"minQuantity" INTEGER,
|
||||
"restrictRoles" "UserRole"[],
|
||||
"restrictDomain" TEXT,
|
||||
"active" BOOLEAN NOT NULL DEFAULT true,
|
||||
"createdById" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "Coupon_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "CouponEvent" (
|
||||
"id" TEXT NOT NULL,
|
||||
"couponId" TEXT NOT NULL,
|
||||
"eventId" TEXT NOT NULL,
|
||||
|
||||
CONSTRAINT "CouponEvent_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "CouponOption" (
|
||||
"id" TEXT NOT NULL,
|
||||
"couponId" TEXT NOT NULL,
|
||||
"eventOptionId" TEXT NOT NULL,
|
||||
"variantId" TEXT,
|
||||
|
||||
CONSTRAINT "CouponOption_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "CouponUser" (
|
||||
"id" TEXT NOT NULL,
|
||||
"couponId" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
|
||||
CONSTRAINT "CouponUser_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "CouponRedemption" (
|
||||
"id" TEXT NOT NULL,
|
||||
"couponId" TEXT NOT NULL,
|
||||
"registrationId" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"discountAmount" DOUBLE PRECISION NOT NULL,
|
||||
"appliedById" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"releasedAt" TIMESTAMP(3),
|
||||
|
||||
CONSTRAINT "CouponRedemption_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Coupon_code_key" ON "Coupon"("code");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Coupon_createdById_idx" ON "Coupon"("createdById");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "CouponEvent_eventId_idx" ON "CouponEvent"("eventId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "CouponEvent_couponId_eventId_key" ON "CouponEvent"("couponId", "eventId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "CouponOption_couponId_idx" ON "CouponOption"("couponId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "CouponOption_eventOptionId_idx" ON "CouponOption"("eventOptionId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "CouponOption_variantId_idx" ON "CouponOption"("variantId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "CouponUser_userId_idx" ON "CouponUser"("userId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "CouponUser_couponId_userId_key" ON "CouponUser"("couponId", "userId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "CouponRedemption_couponId_releasedAt_idx" ON "CouponRedemption"("couponId", "releasedAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "CouponRedemption_registrationId_idx" ON "CouponRedemption"("registrationId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "CouponRedemption_userId_idx" ON "CouponRedemption"("userId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "CouponRedemption_appliedById_idx" ON "CouponRedemption"("appliedById");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Coupon" ADD CONSTRAINT "Coupon_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "CouponEvent" ADD CONSTRAINT "CouponEvent_couponId_fkey" FOREIGN KEY ("couponId") REFERENCES "Coupon"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "CouponEvent" ADD CONSTRAINT "CouponEvent_eventId_fkey" FOREIGN KEY ("eventId") REFERENCES "Event"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "CouponOption" ADD CONSTRAINT "CouponOption_couponId_fkey" FOREIGN KEY ("couponId") REFERENCES "Coupon"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "CouponOption" ADD CONSTRAINT "CouponOption_eventOptionId_fkey" FOREIGN KEY ("eventOptionId") REFERENCES "EventOption"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "CouponOption" ADD CONSTRAINT "CouponOption_variantId_fkey" FOREIGN KEY ("variantId") REFERENCES "OptionVariant"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "CouponUser" ADD CONSTRAINT "CouponUser_couponId_fkey" FOREIGN KEY ("couponId") REFERENCES "Coupon"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "CouponUser" ADD CONSTRAINT "CouponUser_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "CouponRedemption" ADD CONSTRAINT "CouponRedemption_couponId_fkey" FOREIGN KEY ("couponId") REFERENCES "Coupon"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "CouponRedemption" ADD CONSTRAINT "CouponRedemption_registrationId_fkey" FOREIGN KEY ("registrationId") REFERENCES "Registration"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "CouponRedemption" ADD CONSTRAINT "CouponRedemption_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "CouponRedemption" ADD CONSTRAINT "CouponRedemption_appliedById_fkey" FOREIGN KEY ("appliedById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,69 @@
|
||||
-- CreateEnum
|
||||
CREATE TYPE "TicketDelivery" AS ENUM ('payer', 'each');
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "Event" ADD COLUMN "allowNamedAttendees" BOOLEAN NOT NULL DEFAULT false;
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "FormResponse" ADD COLUMN "attendeeId" TEXT;
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "Registration" ADD COLUMN "ticketDelivery" "TicketDelivery" NOT NULL DEFAULT 'payer';
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "Ticket" ADD COLUMN "attendeeId" TEXT,
|
||||
ADD COLUMN "holderUserId" TEXT;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Attendee" (
|
||||
"id" TEXT NOT NULL,
|
||||
"registrationId" TEXT NOT NULL,
|
||||
"registrationOptionId" TEXT,
|
||||
"eventOptionId" TEXT NOT NULL,
|
||||
"variantId" TEXT,
|
||||
"name" TEXT NOT NULL,
|
||||
"email" TEXT,
|
||||
"phone" TEXT,
|
||||
"userId" TEXT,
|
||||
"dateOfBirth" TIMESTAMP(3),
|
||||
"gender" TEXT,
|
||||
"order" INTEGER NOT NULL DEFAULT 0,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "Attendee_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Attendee_registrationId_idx" ON "Attendee"("registrationId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Attendee_registrationOptionId_idx" ON "Attendee"("registrationOptionId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Attendee_userId_idx" ON "Attendee"("userId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Ticket_attendeeId_key" ON "Ticket"("attendeeId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Ticket_holderUserId_idx" ON "Ticket"("holderUserId");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Ticket" ADD CONSTRAINT "Ticket_attendeeId_fkey" FOREIGN KEY ("attendeeId") REFERENCES "Attendee"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Ticket" ADD CONSTRAINT "Ticket_holderUserId_fkey" FOREIGN KEY ("holderUserId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "FormResponse" ADD CONSTRAINT "FormResponse_attendeeId_fkey" FOREIGN KEY ("attendeeId") REFERENCES "Attendee"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Attendee" ADD CONSTRAINT "Attendee_registrationId_fkey" FOREIGN KEY ("registrationId") REFERENCES "Registration"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Attendee" ADD CONSTRAINT "Attendee_registrationOptionId_fkey" FOREIGN KEY ("registrationOptionId") REFERENCES "RegistrationOption"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Attendee" ADD CONSTRAINT "Attendee_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
-- CreateEnum
|
||||
CREATE TYPE "ConsentStatus" AS ENUM ('pending', 'given', 'declined');
|
||||
|
||||
-- AlterEnum
|
||||
-- This migration adds more than one value to an enum.
|
||||
-- With PostgreSQL versions 11 and earlier, this is not possible
|
||||
-- in a single migration. This can be worked around by creating
|
||||
-- multiple migrations, each migration adding only one value to
|
||||
-- the enum.
|
||||
|
||||
|
||||
ALTER TYPE "AdminAuditAction" ADD VALUE 'consent_override';
|
||||
ALTER TYPE "AdminAuditAction" ADD VALUE 'consent_rerequested';
|
||||
ALTER TYPE "AdminAuditAction" ADD VALUE 'medical_list_viewed';
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "Attendee" ADD COLUMN "parentEmail" TEXT,
|
||||
ADD COLUMN "parentName" TEXT,
|
||||
ADD COLUMN "parentPhone" TEXT;
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "Event" ADD COLUMN "consentCollectEmergency" BOOLEAN NOT NULL DEFAULT false,
|
||||
ADD COLUMN "consentCollectMedical" BOOLEAN NOT NULL DEFAULT false,
|
||||
ADD COLUMN "consentCollectPhoto" BOOLEAN NOT NULL DEFAULT false,
|
||||
ADD COLUMN "consentText" TEXT,
|
||||
ADD COLUMN "consentTextVersion" INTEGER NOT NULL DEFAULT 1,
|
||||
ADD COLUMN "requiresParentalConsent" BOOLEAN NOT NULL DEFAULT false;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "ConsentRequest" (
|
||||
"id" TEXT NOT NULL,
|
||||
"attendeeId" TEXT NOT NULL,
|
||||
"eventId" TEXT NOT NULL,
|
||||
"tokenHash" TEXT NOT NULL,
|
||||
"status" "ConsentStatus" NOT NULL DEFAULT 'pending',
|
||||
"textVersion" INTEGER NOT NULL,
|
||||
"consentText" TEXT NOT NULL,
|
||||
"parentName" TEXT,
|
||||
"parentEmail" TEXT,
|
||||
"parentPhone" TEXT,
|
||||
"signedName" TEXT,
|
||||
"ip" TEXT,
|
||||
"device" TEXT,
|
||||
"medical" TEXT,
|
||||
"emergency" TEXT,
|
||||
"photoConsent" BOOLEAN,
|
||||
"respondedAt" TIMESTAMP(3),
|
||||
"lastSentAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"remindersSent" INTEGER NOT NULL DEFAULT 0,
|
||||
"expiresAt" TIMESTAMP(3) NOT NULL,
|
||||
"medicalPurgedAt" TIMESTAMP(3),
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "ConsentRequest_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "ConsentRequest_tokenHash_key" ON "ConsentRequest"("tokenHash");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "ConsentRequest_attendeeId_createdAt_idx" ON "ConsentRequest"("attendeeId", "createdAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "ConsentRequest_eventId_status_idx" ON "ConsentRequest"("eventId", "status");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "ConsentRequest" ADD CONSTRAINT "ConsentRequest_attendeeId_fkey" FOREIGN KEY ("attendeeId") REFERENCES "Attendee"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "ConsentRequest" ADD CONSTRAINT "ConsentRequest_eventId_fkey" FOREIGN KEY ("eventId") REFERENCES "Event"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
-- CreateEnum
|
||||
CREATE TYPE "WaitlistStatus" AS ENUM ('waiting', 'offered', 'claimed', 'expired', 'removed', 'left');
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "Event" ADD COLUMN "waitlistEnabled" BOOLEAN NOT NULL DEFAULT false,
|
||||
ADD COLUMN "waitlistOfferHours" INTEGER NOT NULL DEFAULT 24,
|
||||
ADD COLUMN "waitlistPartialOffers" BOOLEAN NOT NULL DEFAULT true;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "WaitlistEntry" (
|
||||
"id" TEXT NOT NULL,
|
||||
"eventId" TEXT NOT NULL,
|
||||
"eventOptionId" TEXT NOT NULL,
|
||||
"variantId" TEXT,
|
||||
"userId" TEXT NOT NULL,
|
||||
"quantity" INTEGER NOT NULL,
|
||||
"position" INTEGER NOT NULL,
|
||||
"status" "WaitlistStatus" NOT NULL DEFAULT 'waiting',
|
||||
"offeredQuantity" INTEGER,
|
||||
"offerTokenHash" TEXT,
|
||||
"offeredAt" TIMESTAMP(3),
|
||||
"offerExpiresAt" TIMESTAMP(3),
|
||||
"claimedAt" TIMESTAMP(3),
|
||||
"registrationId" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "WaitlistEntry_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "WaitlistEntry_offerTokenHash_key" ON "WaitlistEntry"("offerTokenHash");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "WaitlistEntry_eventId_status_position_idx" ON "WaitlistEntry"("eventId", "status", "position");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "WaitlistEntry_eventOptionId_status_idx" ON "WaitlistEntry"("eventOptionId", "status");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "WaitlistEntry_variantId_idx" ON "WaitlistEntry"("variantId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "WaitlistEntry_userId_idx" ON "WaitlistEntry"("userId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "WaitlistEntry_registrationId_idx" ON "WaitlistEntry"("registrationId");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "WaitlistEntry" ADD CONSTRAINT "WaitlistEntry_eventId_fkey" FOREIGN KEY ("eventId") REFERENCES "Event"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "WaitlistEntry" ADD CONSTRAINT "WaitlistEntry_eventOptionId_fkey" FOREIGN KEY ("eventOptionId") REFERENCES "EventOption"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "WaitlistEntry" ADD CONSTRAINT "WaitlistEntry_variantId_fkey" FOREIGN KEY ("variantId") REFERENCES "OptionVariant"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "WaitlistEntry" ADD CONSTRAINT "WaitlistEntry_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "WaitlistEntry" ADD CONSTRAINT "WaitlistEntry_registrationId_fkey" FOREIGN KEY ("registrationId") REFERENCES "Registration"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
+440
-51
@@ -51,6 +51,13 @@ enum SecurityEventType {
|
||||
login
|
||||
password_changed
|
||||
password_reset
|
||||
two_factor_enabled
|
||||
two_factor_disabled
|
||||
}
|
||||
|
||||
enum TwoFactorMethod {
|
||||
email
|
||||
whatsapp
|
||||
}
|
||||
|
||||
model User {
|
||||
@@ -67,6 +74,8 @@ model User {
|
||||
lockUntil DateTime?
|
||||
tokenVersion Int @default(0)
|
||||
notificationPreference NotificationPreference @default(email)
|
||||
twoFactorEnabled Boolean @default(false)
|
||||
twoFactorMethod TwoFactorMethod?
|
||||
registrations Registration[]
|
||||
payments Payment[]
|
||||
paymentsRecorded Payment[] @relation("PaymentRecordedBy")
|
||||
@@ -82,53 +91,90 @@ model User {
|
||||
cashupsPerformed EventCashup[]
|
||||
notifyForEvents Event[] @relation("EventNotifyRecipients")
|
||||
|
||||
personCashCountsFor EventCashupPersonCount[] @relation("EventCashupPersonCountFor")
|
||||
personCashCountsEntered EventCashupPersonCount[] @relation("EventCashupPersonCountEnteredBy")
|
||||
personCashCountsFor EventCashupPersonCount[] @relation("EventCashupPersonCountFor")
|
||||
personCashCountsEntered EventCashupPersonCount[] @relation("EventCashupPersonCountEnteredBy")
|
||||
|
||||
securityEvents SecurityEvent[]
|
||||
adminAuditLogs AdminAuditLog[] @relation("AdminAuditActor")
|
||||
|
||||
couponsCreated Coupon[] @relation("CouponCreatedBy")
|
||||
couponAccess CouponUser[]
|
||||
couponRedemptions CouponRedemption[] @relation("CouponRedemptionUser")
|
||||
couponsApplied CouponRedemption[] @relation("CouponRedemptionAppliedBy")
|
||||
|
||||
attendeeProfiles Attendee[]
|
||||
heldTickets Ticket[] @relation("TicketHolder")
|
||||
waitlistEntries WaitlistEntry[]
|
||||
|
||||
twoFactorChallenges TwoFactorChallenge[]
|
||||
trustedDevices TrustedDevice[]
|
||||
}
|
||||
|
||||
model Event {
|
||||
id String @id @default(uuid())
|
||||
title String
|
||||
description String?
|
||||
startDate DateTime
|
||||
endDate DateTime
|
||||
registrationDeadline DateTime?
|
||||
goLiveAt DateTime @default(now())
|
||||
price Float
|
||||
picture String?
|
||||
isActive Boolean @default(true)
|
||||
isHidden Boolean @default(false)
|
||||
requiresAuth Boolean @default(true)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
createdById String?
|
||||
createdBy User? @relation(fields: [createdById], references: [id], onDelete: SetNull)
|
||||
redirectUrl String? @unique
|
||||
eventOptions EventOption[]
|
||||
registrations Registration[]
|
||||
payments Payment[]
|
||||
tickets Ticket[]
|
||||
attachments EventAttachment[]
|
||||
form EventForm?
|
||||
Section Section[]
|
||||
id String @id @default(uuid())
|
||||
title String
|
||||
description String?
|
||||
startDate DateTime
|
||||
endDate DateTime
|
||||
registrationDeadline DateTime?
|
||||
goLiveAt DateTime @default(now())
|
||||
price Float
|
||||
picture String?
|
||||
isActive Boolean @default(true)
|
||||
isHidden Boolean @default(false)
|
||||
requiresAuth Boolean @default(true)
|
||||
requiresRegistration Boolean @default(true)
|
||||
// Lets a payer name each person on a group registration (one named ticket each).
|
||||
allowNamedAttendees Boolean @default(false)
|
||||
// Youth event: every attendee gives a date of birth, and a parent/guardian must give
|
||||
// consent for anyone under 18 before they can be checked in.
|
||||
requiresParentalConsent Boolean @default(false)
|
||||
consentText String?
|
||||
consentTextVersion Int @default(1)
|
||||
consentCollectMedical Boolean @default(false)
|
||||
consentCollectEmergency Boolean @default(false)
|
||||
consentCollectPhoto Boolean @default(false)
|
||||
// Waitlist for sold-out options: when places free up, the next person is offered
|
||||
// them for waitlistOfferHours. waitlistPartialOffers: offer what's free even if it's
|
||||
// fewer places than asked for (otherwise skip to the next person who fits).
|
||||
waitlistEnabled Boolean @default(false)
|
||||
waitlistOfferHours Int @default(24)
|
||||
waitlistPartialOffers Boolean @default(true)
|
||||
contactName String?
|
||||
contactPhone String?
|
||||
contactEmail String?
|
||||
location String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
createdById String?
|
||||
createdBy User? @relation(fields: [createdById], references: [id], onDelete: SetNull)
|
||||
redirectUrl String? @unique
|
||||
eventOptions EventOption[]
|
||||
registrations Registration[]
|
||||
payments Payment[]
|
||||
tickets Ticket[]
|
||||
attachments EventAttachment[]
|
||||
form EventForm?
|
||||
Section Section[]
|
||||
|
||||
cashupStatus CashupStatus @default(open)
|
||||
cashupDraft Json?
|
||||
closedAt DateTime?
|
||||
closedById String?
|
||||
closedBy User? @relation("EventClosedBy", fields: [closedById], references: [id], onDelete: SetNull)
|
||||
reopenedAt DateTime?
|
||||
reopenedById String?
|
||||
reopenedBy User? @relation("EventReopenedBy", fields: [reopenedById], references: [id], onDelete: SetNull)
|
||||
costs EventCost[]
|
||||
cashups EventCashup[]
|
||||
cashupStatus CashupStatus @default(open)
|
||||
cashupDraft Json?
|
||||
closedAt DateTime?
|
||||
closedById String?
|
||||
closedBy User? @relation("EventClosedBy", fields: [closedById], references: [id], onDelete: SetNull)
|
||||
reopenedAt DateTime?
|
||||
reopenedById String?
|
||||
reopenedBy User? @relation("EventReopenedBy", fields: [reopenedById], references: [id], onDelete: SetNull)
|
||||
costs EventCost[]
|
||||
cashups EventCashup[]
|
||||
personCashCounts EventCashupPersonCount[]
|
||||
|
||||
// Users who should receive registration/payment/daily-summary notifications for this
|
||||
// event. Falls back to `createdBy` when empty (see backend/src/utils/notifications.js).
|
||||
notifyRecipients User[] @relation("EventNotifyRecipients")
|
||||
notifyRecipients User[] @relation("EventNotifyRecipients")
|
||||
coupons CouponEvent[]
|
||||
consentRequests ConsentRequest[]
|
||||
waitlistEntries WaitlistEntry[]
|
||||
|
||||
@@index([createdById])
|
||||
@@index([closedById])
|
||||
@@ -142,27 +188,32 @@ model EventOption {
|
||||
price Float
|
||||
isMainTicket Boolean
|
||||
stockLimit Int @default(0)
|
||||
// Applied automatically for Staff and above instead of `price` (when lower). Null = no staff price.
|
||||
staffPrice Float?
|
||||
event Event @relation(fields: [eventId], references: [id], onDelete: Restrict)
|
||||
registrationOptions RegistrationOption[]
|
||||
earlyBirdTiers EarlyBirdTier[]
|
||||
SectionOption SectionOption[]
|
||||
variants OptionVariant[]
|
||||
costs EventCost[]
|
||||
couponScopes CouponOption[]
|
||||
waitlistEntries WaitlistEntry[]
|
||||
|
||||
@@index([eventId])
|
||||
}
|
||||
|
||||
model EarlyBirdTier {
|
||||
id String @id @default(uuid())
|
||||
id String @id @default(uuid())
|
||||
eventOptionId String
|
||||
variantId String?
|
||||
deadline DateTime
|
||||
price Float
|
||||
order Int @default(0)
|
||||
stockLimit Int @default(0)
|
||||
eventOption EventOption @relation(fields: [eventOptionId], references: [id], onDelete: Cascade)
|
||||
variant OptionVariant? @relation(fields: [variantId], references: [id], onDelete: Cascade)
|
||||
order Int @default(0)
|
||||
stockLimit Int @default(0)
|
||||
eventOption EventOption @relation(fields: [eventOptionId], references: [id], onDelete: Cascade)
|
||||
variant OptionVariant? @relation(fields: [variantId], references: [id], onDelete: Cascade)
|
||||
registrationOptions RegistrationOption[]
|
||||
tranches RegistrationOptionTranche[]
|
||||
|
||||
@@index([eventOptionId, deadline])
|
||||
@@index([variantId])
|
||||
@@ -174,10 +225,14 @@ model OptionVariant {
|
||||
name String
|
||||
price Float?
|
||||
stockLimit Int @default(0)
|
||||
// Staff price override for this variant (falls back to the option's staffPrice).
|
||||
staffPrice Float?
|
||||
order Int @default(0)
|
||||
eventOption EventOption @relation(fields: [eventOptionId], references: [id], onDelete: Cascade)
|
||||
registrationOptions RegistrationOption[]
|
||||
earlyBirdTiers EarlyBirdTier[]
|
||||
couponScopes CouponOption[]
|
||||
waitlistEntries WaitlistEntry[]
|
||||
|
||||
@@index([eventOptionId])
|
||||
}
|
||||
@@ -188,6 +243,12 @@ model Registration {
|
||||
eventId String
|
||||
status RegistrationStatus @default(pending)
|
||||
checkoutId String? @unique
|
||||
// Sum of this registration's active coupon discounts, kept in step with
|
||||
// couponRedemptions. Stored here so every total-due calculation sees it no matter
|
||||
// which relations the caller loaded (see utils/pricing.js computeRegistrationTotalDue).
|
||||
discountTotal Float @default(0)
|
||||
// Where named attendees' tickets go: all to the payer, or each to the attendee.
|
||||
ticketDelivery TicketDelivery @default(payer)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Restrict)
|
||||
@@ -196,26 +257,32 @@ model Registration {
|
||||
payments Payment[]
|
||||
formResponses FormResponse[]
|
||||
formDrafts FormDraft[]
|
||||
couponRedemptions CouponRedemption[]
|
||||
attendees Attendee[]
|
||||
waitlistEntries WaitlistEntry[]
|
||||
|
||||
@@index([userId])
|
||||
@@index([eventId])
|
||||
@@index([userId, status])
|
||||
@@index([eventId, status])
|
||||
@@index([createdAt, status])
|
||||
}
|
||||
|
||||
model RegistrationOption {
|
||||
id String @id @default(uuid())
|
||||
id String @id @default(uuid())
|
||||
registrationId String
|
||||
eventOptionId String
|
||||
quantity Int @default(1)
|
||||
quantity Int @default(1)
|
||||
variantId String?
|
||||
appliedTierId String?
|
||||
priceSnapshot Float?
|
||||
registration Registration @relation(fields: [registrationId], references: [id], onDelete: Restrict)
|
||||
eventOption EventOption @relation(fields: [eventOptionId], references: [id], onDelete: Restrict)
|
||||
variant OptionVariant? @relation(fields: [variantId], references: [id], onDelete: SetNull)
|
||||
appliedTier EarlyBirdTier? @relation(fields: [appliedTierId], references: [id], onDelete: SetNull)
|
||||
registration Registration @relation(fields: [registrationId], references: [id], onDelete: Restrict)
|
||||
eventOption EventOption @relation(fields: [eventOptionId], references: [id], onDelete: Restrict)
|
||||
variant OptionVariant? @relation(fields: [variantId], references: [id], onDelete: SetNull)
|
||||
appliedTier EarlyBirdTier? @relation(fields: [appliedTierId], references: [id], onDelete: SetNull)
|
||||
tickets Ticket[]
|
||||
tranches RegistrationOptionTranche[]
|
||||
attendees Attendee[]
|
||||
|
||||
@@index([registrationId])
|
||||
@@index([eventOptionId])
|
||||
@@ -223,6 +290,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
|
||||
@@ -249,7 +337,7 @@ model Payment {
|
||||
@@index([registrationId])
|
||||
@@index([eventId])
|
||||
@@index([createdAt])
|
||||
@@index([originalPaymentId])
|
||||
@@index([originalPaymentId, isDonation])
|
||||
}
|
||||
|
||||
model Ticket {
|
||||
@@ -261,6 +349,12 @@ model Ticket {
|
||||
quantity Int @default(1)
|
||||
isUsed Boolean @default(false)
|
||||
emailSent Boolean @default(false)
|
||||
// Set on a named (group) ticket: the person it's for, and their account when it
|
||||
// matched one. userId stays the payer.
|
||||
attendeeId String? @unique
|
||||
attendee Attendee? @relation(fields: [attendeeId], references: [id], onDelete: SetNull)
|
||||
holderUserId String?
|
||||
holder User? @relation("TicketHolder", fields: [holderUserId], references: [id], onDelete: SetNull)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
registrationOption RegistrationOption @relation(fields: [registrationOptionId], references: [id], onDelete: Restrict)
|
||||
@@ -268,9 +362,11 @@ model Ticket {
|
||||
event Event @relation(fields: [eventId], references: [id], onDelete: Restrict)
|
||||
usages TicketUsage[]
|
||||
|
||||
@@index([holderUserId])
|
||||
@@index([eventId])
|
||||
@@index([userId])
|
||||
@@index([registrationOptionId])
|
||||
@@index([createdAt])
|
||||
}
|
||||
|
||||
model TicketUsage {
|
||||
@@ -299,6 +395,40 @@ model PasswordReset {
|
||||
@@index([userId])
|
||||
}
|
||||
|
||||
// A 6-digit code sent by email/WhatsApp, either as the second step of a login
|
||||
// ('login') or to prove the channel works before 2FA is switched on ('enable').
|
||||
// Only the HMAC of the code is stored (see backend/src/utils/twoFactor.js).
|
||||
model TwoFactorChallenge {
|
||||
id String @id @default(uuid())
|
||||
userId String
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
purpose String // 'login' | 'enable'
|
||||
method TwoFactorMethod
|
||||
codeHash String
|
||||
attempts Int @default(0)
|
||||
lastSentAt DateTime @default(now())
|
||||
expiresAt DateTime
|
||||
usedAt DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@index([userId])
|
||||
}
|
||||
|
||||
// A browser that passed the 2FA code step with "Trust this device" ticked — logins
|
||||
// presenting its token skip the code until it expires or is cleared.
|
||||
model TrustedDevice {
|
||||
id String @id @default(uuid())
|
||||
userId String
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
tokenHash String @unique
|
||||
device String?
|
||||
createdAt DateTime @default(now())
|
||||
lastUsedAt DateTime @default(now())
|
||||
expiresAt DateTime
|
||||
|
||||
@@index([userId])
|
||||
}
|
||||
|
||||
// Append-only "Account activity" log for the Profile & Security page (logins, password
|
||||
// changes). Nullable FK with SetNull (not Cascade) so entries survive account
|
||||
// close/anonymization, the same pattern EventCashup uses for its performedBy audit trail.
|
||||
@@ -314,6 +444,46 @@ model SecurityEvent {
|
||||
@@index([userId, createdAt])
|
||||
}
|
||||
|
||||
enum AdminAuditAction {
|
||||
refund_created
|
||||
donation_assigned
|
||||
donation_unassigned
|
||||
registration_created_manual
|
||||
registration_cancelled
|
||||
event_created
|
||||
event_updated
|
||||
event_deleted
|
||||
settings_updated
|
||||
coupon_created
|
||||
coupon_updated
|
||||
coupon_disabled
|
||||
coupon_applied_manual
|
||||
consent_override
|
||||
consent_rerequested
|
||||
medical_list_viewed
|
||||
}
|
||||
|
||||
// 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
|
||||
@@ -368,6 +538,8 @@ model FormResponse {
|
||||
registrationId String
|
||||
createdAt DateTime @default(now())
|
||||
registration Registration @relation(fields: [registrationId], references: [id], onDelete: Cascade)
|
||||
attendeeId String?
|
||||
attendee Attendee? @relation(fields: [attendeeId], references: [id], onDelete: SetNull)
|
||||
answers FormAnswer[]
|
||||
|
||||
@@index([registrationId])
|
||||
@@ -532,11 +704,11 @@ model EventCashupPersonCount {
|
||||
}
|
||||
|
||||
model EventCashupPersonCountDenomination {
|
||||
id String @id @default(uuid())
|
||||
id String @id @default(uuid())
|
||||
countId String
|
||||
value Float
|
||||
count Int
|
||||
personCount EventCashupPersonCount @relation(fields: [countId], references: [id], onDelete: Cascade)
|
||||
personCount EventCashupPersonCount @relation(fields: [countId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([countId])
|
||||
}
|
||||
@@ -696,3 +868,220 @@ model EventCashupPersonCountDenomination {
|
||||
// @@index([createdAt])
|
||||
// @@index([method])
|
||||
// }
|
||||
|
||||
// ─── Coupon codes ─────────────────────────────────────────────────────────────
|
||||
// A code gives a percentage, fixed-amount or 100% (comp) discount on a registration.
|
||||
// The discount is locked into a CouponRedemption when applied, like early-bird
|
||||
// tranches: later pricing refreshes never re-price it (see utils/coupons.js).
|
||||
|
||||
enum CouponType {
|
||||
percent
|
||||
fixed
|
||||
comp
|
||||
}
|
||||
|
||||
model Coupon {
|
||||
id String @id @default(uuid())
|
||||
code String @unique // stored uppercase; matched case-insensitively
|
||||
description String?
|
||||
type CouponType
|
||||
value Float @default(0) // percent (0-100) or rand amount; ignored for comp
|
||||
stackEarlyBird Boolean @default(false) // true: discount the early-bird price; false: best price wins
|
||||
validFrom DateTime?
|
||||
validUntil DateTime?
|
||||
maxUses Int?
|
||||
maxUsesPerUser Int? @default(1)
|
||||
minQuantity Int?
|
||||
restrictRoles UserRole[]
|
||||
restrictDomain String?
|
||||
active Boolean @default(true)
|
||||
createdById String?
|
||||
createdBy User? @relation("CouponCreatedBy", fields: [createdById], references: [id], onDelete: SetNull)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
events CouponEvent[] // none = valid for every event
|
||||
options CouponOption[] // none = whole event
|
||||
allowedUsers CouponUser[]
|
||||
redemptions CouponRedemption[]
|
||||
|
||||
@@index([createdById])
|
||||
}
|
||||
|
||||
model CouponEvent {
|
||||
id String @id @default(uuid())
|
||||
couponId String
|
||||
eventId String
|
||||
coupon Coupon @relation(fields: [couponId], references: [id], onDelete: Cascade)
|
||||
event Event @relation(fields: [eventId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([couponId, eventId])
|
||||
@@index([eventId])
|
||||
}
|
||||
|
||||
// Limits a coupon to specific ticket options, or to one variant of an option.
|
||||
model CouponOption {
|
||||
id String @id @default(uuid())
|
||||
couponId String
|
||||
eventOptionId String
|
||||
variantId String?
|
||||
coupon Coupon @relation(fields: [couponId], references: [id], onDelete: Cascade)
|
||||
eventOption EventOption @relation(fields: [eventOptionId], references: [id], onDelete: Cascade)
|
||||
variant OptionVariant? @relation(fields: [variantId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([couponId])
|
||||
@@index([eventOptionId])
|
||||
@@index([variantId])
|
||||
}
|
||||
|
||||
model CouponUser {
|
||||
id String @id @default(uuid())
|
||||
couponId String
|
||||
userId String
|
||||
coupon Coupon @relation(fields: [couponId], references: [id], onDelete: Cascade)
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([couponId, userId])
|
||||
@@index([userId])
|
||||
}
|
||||
|
||||
model CouponRedemption {
|
||||
id String @id @default(uuid())
|
||||
couponId String
|
||||
registrationId String
|
||||
userId String
|
||||
discountAmount Float // locked at apply time
|
||||
appliedById String? // staff member, when applied on someone's behalf
|
||||
createdAt DateTime @default(now())
|
||||
releasedAt DateTime? // set when the registration is cancelled — frees the use
|
||||
coupon Coupon @relation(fields: [couponId], references: [id], onDelete: Restrict)
|
||||
registration Registration @relation(fields: [registrationId], references: [id], onDelete: Cascade)
|
||||
user User @relation("CouponRedemptionUser", fields: [userId], references: [id], onDelete: Restrict)
|
||||
appliedBy User? @relation("CouponRedemptionAppliedBy", fields: [appliedById], references: [id], onDelete: SetNull)
|
||||
|
||||
@@index([couponId, releasedAt])
|
||||
@@index([registrationId])
|
||||
@@index([userId])
|
||||
@@index([appliedById])
|
||||
}
|
||||
|
||||
// ─── Group tickets ────────────────────────────────────────────────────────────
|
||||
|
||||
enum TicketDelivery {
|
||||
payer // every ticket goes to the person who registered
|
||||
each // each named attendee gets their own (falls back to the payer)
|
||||
}
|
||||
|
||||
// One named person on a group registration, for one main-ticket place. Gets their
|
||||
// own quantity-1 ticket and their own attendee form response.
|
||||
model Attendee {
|
||||
id String @id @default(uuid())
|
||||
registrationId String
|
||||
registrationOptionId String?
|
||||
eventOptionId String
|
||||
variantId String?
|
||||
name String
|
||||
email String?
|
||||
phone String?
|
||||
userId String? // existing account matched by email/phone
|
||||
dateOfBirth DateTime?
|
||||
gender String?
|
||||
parentName String?
|
||||
parentEmail String?
|
||||
parentPhone String?
|
||||
order Int @default(0)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
registration Registration @relation(fields: [registrationId], references: [id], onDelete: Cascade)
|
||||
registrationOption RegistrationOption? @relation(fields: [registrationOptionId], references: [id], onDelete: SetNull)
|
||||
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
|
||||
ticket Ticket?
|
||||
formResponses FormResponse[]
|
||||
consentRequests ConsentRequest[]
|
||||
|
||||
@@index([registrationId])
|
||||
@@index([registrationOptionId])
|
||||
@@index([userId])
|
||||
}
|
||||
|
||||
// ─── Parental consent ─────────────────────────────────────────────────────────
|
||||
|
||||
enum ConsentStatus {
|
||||
pending
|
||||
given
|
||||
declined
|
||||
}
|
||||
|
||||
// A request for a parent/guardian to consent for one under-18 attendee. Only the hash
|
||||
// of the link token is stored. A new request (e.g. after the consent text changes)
|
||||
// supersedes earlier ones for the same attendee; the latest one decides check-in.
|
||||
model ConsentRequest {
|
||||
id String @id @default(uuid())
|
||||
attendeeId String
|
||||
eventId String
|
||||
tokenHash String @unique
|
||||
status ConsentStatus @default(pending)
|
||||
textVersion Int
|
||||
consentText String // the exact text the parent was shown
|
||||
parentName String?
|
||||
parentEmail String?
|
||||
parentPhone String?
|
||||
signedName String?
|
||||
ip String?
|
||||
device String?
|
||||
medical String? // encrypted JSON (utils/encryption.js); purged after the event
|
||||
emergency String? // encrypted JSON; purged after the event
|
||||
photoConsent Boolean?
|
||||
respondedAt DateTime?
|
||||
lastSentAt DateTime @default(now())
|
||||
remindersSent Int @default(0)
|
||||
expiresAt DateTime
|
||||
medicalPurgedAt DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
attendee Attendee @relation(fields: [attendeeId], references: [id], onDelete: Cascade)
|
||||
event Event @relation(fields: [eventId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([attendeeId, createdAt])
|
||||
@@index([eventId, status])
|
||||
}
|
||||
|
||||
// ─── Waitlist ─────────────────────────────────────────────────────────────────
|
||||
|
||||
enum WaitlistStatus {
|
||||
waiting
|
||||
offered // places held for them until offerExpiresAt
|
||||
claimed // they registered with the offer
|
||||
expired // the offer ran out
|
||||
removed // taken off by staff
|
||||
left // they left the waitlist
|
||||
}
|
||||
|
||||
model WaitlistEntry {
|
||||
id String @id @default(uuid())
|
||||
eventId String
|
||||
eventOptionId String
|
||||
variantId String?
|
||||
userId String
|
||||
quantity Int
|
||||
position Int
|
||||
status WaitlistStatus @default(waiting)
|
||||
offeredQuantity Int?
|
||||
offerTokenHash String? @unique
|
||||
offeredAt DateTime?
|
||||
offerExpiresAt DateTime?
|
||||
claimedAt DateTime?
|
||||
registrationId String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
event Event @relation(fields: [eventId], references: [id], onDelete: Cascade)
|
||||
eventOption EventOption @relation(fields: [eventOptionId], references: [id], onDelete: Cascade)
|
||||
variant OptionVariant? @relation(fields: [variantId], references: [id], onDelete: Cascade)
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
registration Registration? @relation(fields: [registrationId], references: [id], onDelete: SetNull)
|
||||
|
||||
@@index([eventId, status, position])
|
||||
@@index([eventOptionId, status])
|
||||
@@index([variantId])
|
||||
@@index([userId])
|
||||
@@index([registrationId])
|
||||
}
|
||||
|
||||
@@ -64,8 +64,9 @@ const MAPPINGS = [
|
||||
{ key: 'org_tagline', envVars: ['ORG_TAGLINE'] },
|
||||
{ key: 'org_email', envVars: ['EMAIL_FROM', 'EMAIL_USER', 'SMTP_FROM', 'SMTP_USER'] },
|
||||
|
||||
// Branding
|
||||
{ key: 'accent_color', envVars: ['EMAIL_HEADER_COLOR', 'BRAND_COLOR'] },
|
||||
// Branding — these env vars historically fed the single "brand color" concept,
|
||||
// which is now the Primary color in the 3-color Primary/Secondary/Accent system.
|
||||
{ key: 'primary_color', envVars: ['EMAIL_HEADER_COLOR', 'BRAND_COLOR'] },
|
||||
|
||||
// Notifications
|
||||
{ key: 'reg_notification_emails',envVars: ['REGISTRATIONS_EMAIL'] },
|
||||
@@ -88,7 +89,7 @@ async function main() {
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
console.log(`\n${'─'.repeat(60)}`);
|
||||
console.log(' Hope Events — .env → DB settings migration');
|
||||
console.log(' Settings migration: .env → DB');
|
||||
if (DRY_RUN) console.log(' MODE: DRY RUN (no changes will be made)');
|
||||
if (FORCE) console.log(' MODE: FORCE (will overwrite existing DB values)');
|
||||
console.log(`${'─'.repeat(60)}\n`);
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
const jwt = require('jsonwebtoken');
|
||||
const bcrypt = require('bcryptjs');
|
||||
|
||||
const generateToken = (userId, role, tokenVersion = 0) => {
|
||||
// `extra` adds claims, e.g. { setup: true } for the first-run setup wizard's token.
|
||||
const generateToken = (userId, role, tokenVersion = 0, extra = {}) => {
|
||||
return jwt.sign(
|
||||
{ id: userId, role, tokenVersion },
|
||||
{ ...extra, id: userId, role, tokenVersion },
|
||||
process.env.JWT_SECRET,
|
||||
{ expiresIn: '30d' }
|
||||
);
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
const { getAdminAuditLog } = require('../utils/adminAudit');
|
||||
const { safeErrorMessage } = require('../utils/errorUtils');
|
||||
|
||||
// @desc Paginated admin/supervisor action audit trail, with optional filters
|
||||
// @route GET /api/admin/audit-log
|
||||
// @access Admin
|
||||
const listAuditLog = async (req, res) => {
|
||||
try {
|
||||
const { page, limit, actorId, action, from, to } = req.query;
|
||||
const result = await getAdminAuditLog({ page, limit, actorId, action, from, to });
|
||||
res.json(result);
|
||||
} catch (e) {
|
||||
res.status(500).json({ message: safeErrorMessage(e) });
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = { listAuditLog };
|
||||
@@ -0,0 +1,53 @@
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const { runBackup, listBackups, BACKUP_DIR, BACKUP_FILENAME_RE } = require('../utils/backupUtils');
|
||||
const { safeErrorMessage } = require('../utils/errorUtils');
|
||||
|
||||
// @desc List local database backups (newest first)
|
||||
// @route GET /api/backups
|
||||
// @access Admin
|
||||
const getBackups = async (req, res) => {
|
||||
try {
|
||||
const backups = await listBackups();
|
||||
res.json(backups);
|
||||
} catch (e) {
|
||||
res.status(500).json({ message: safeErrorMessage(e) });
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Manually trigger a backup now
|
||||
// @route POST /api/backups/run
|
||||
// @access Admin
|
||||
const triggerBackup = async (req, res) => {
|
||||
try {
|
||||
const result = await runBackup();
|
||||
res.status(201).json(result);
|
||||
} catch (e) {
|
||||
res.status(500).json({ message: safeErrorMessage(e) });
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Download a backup file
|
||||
// @route GET /api/backups/:filename/download
|
||||
// @access Admin
|
||||
const downloadBackup = async (req, res) => {
|
||||
try {
|
||||
const { filename } = req.params;
|
||||
// Reject anything that isn't exactly the shape this app generates, before ever
|
||||
// touching the filesystem — same posture as the event-alias path-safety fix.
|
||||
if (!BACKUP_FILENAME_RE.test(filename)) {
|
||||
res.status(400);
|
||||
throw new Error('Invalid backup filename');
|
||||
}
|
||||
const filePath = path.join(BACKUP_DIR, filename);
|
||||
if (!fs.existsSync(filePath)) {
|
||||
res.status(404);
|
||||
throw new Error('Backup not found');
|
||||
}
|
||||
res.download(filePath, filename);
|
||||
} catch (e) {
|
||||
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(e) });
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = { getBackups, triggerBackup, downloadBackup };
|
||||
@@ -0,0 +1,264 @@
|
||||
const prisma = require('../config/db');
|
||||
const { safeErrorMessage } = require('../utils/errorUtils');
|
||||
const { logAdminAction } = require('../utils/adminAudit');
|
||||
const { getClientIp } = require('../utils/requestUtils');
|
||||
const { describeUserAgent } = require('../utils/userAgent');
|
||||
const {
|
||||
isMinor, consentStatusFor, validateConsentAnswer, sealJson, openJson, latestRequestsFor,
|
||||
findRequestByToken, resendConsentRequest, createConsentRequest,
|
||||
} = require('../utils/consent');
|
||||
|
||||
const STAFF = ['admin', 'supervisor', 'staff'];
|
||||
const fail = (res, error) => res.status(error.statusCode || (res.statusCode === 200 ? 400 : res.statusCode)).json({ message: safeErrorMessage(error) });
|
||||
|
||||
// What the parent sees — never the registrant's contact details or other attendees.
|
||||
function publicView(request) {
|
||||
const ev = request.event;
|
||||
const latestForAttendee = request._isLatest !== false;
|
||||
return {
|
||||
status: request.status,
|
||||
superseded: !latestForAttendee,
|
||||
expired: new Date(request.expiresAt) < new Date(),
|
||||
respondedAt: request.respondedAt,
|
||||
attendeeName: request.attendee.name,
|
||||
parentName: request.parentName,
|
||||
consentText: request.consentText,
|
||||
textVersion: request.textVersion,
|
||||
event: { title: ev.title, startDate: ev.startDate, endDate: ev.endDate, location: ev.location },
|
||||
collect: { medical: ev.consentCollectMedical, emergency: ev.consentCollectEmergency, photo: ev.consentCollectPhoto },
|
||||
};
|
||||
}
|
||||
|
||||
// @desc Consent page data for a parent/guardian
|
||||
// @route GET /api/consent/:token
|
||||
// @access Public (the token is the credential)
|
||||
const getConsent = async (req, res) => {
|
||||
try {
|
||||
const request = await findRequestByToken(req.params.token);
|
||||
if (!request) { res.status(404); throw new Error('This consent link is not valid. It may have been replaced by a newer one — check your latest message.'); }
|
||||
const latest = (await latestRequestsFor([request.attendeeId])).get(request.attendeeId);
|
||||
res.json(publicView({ ...request, _isLatest: latest?.id === request.id }));
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Give or decline consent
|
||||
// @route POST /api/consent/:token
|
||||
// @access Public (the token is the credential)
|
||||
const answerConsent = async (req, res) => {
|
||||
try {
|
||||
const request = await findRequestByToken(req.params.token);
|
||||
if (!request) { res.status(404); throw new Error('This consent link is not valid.'); }
|
||||
if (request.status !== 'pending') { res.status(409); throw new Error(`Consent has already been ${request.status}.`); }
|
||||
if (new Date(request.expiresAt) < new Date()) { res.status(410); throw new Error('This consent link has expired because the event has ended.'); }
|
||||
const latest = (await latestRequestsFor([request.attendeeId])).get(request.attendeeId);
|
||||
if (latest?.id !== request.id) { res.status(409); throw new Error('A newer consent request replaced this one — please use the link in your latest message.'); }
|
||||
|
||||
let answer;
|
||||
try { answer = validateConsentAnswer(req.body || {}, request.event); } catch (e) { res.status(400); throw e; }
|
||||
|
||||
// Only flips pending → answered once, even if the form is submitted twice at once.
|
||||
const updated = await prisma.consentRequest.updateMany({
|
||||
where: { id: request.id, status: 'pending' },
|
||||
data: {
|
||||
status: answer.decision === 'give' ? 'given' : 'declined',
|
||||
signedName: answer.signedName,
|
||||
ip: getClientIp(req),
|
||||
device: describeUserAgent(req.headers['user-agent']),
|
||||
medical: sealJson(answer.medical),
|
||||
emergency: sealJson(answer.emergency),
|
||||
photoConsent: answer.photoConsent,
|
||||
respondedAt: new Date(),
|
||||
},
|
||||
});
|
||||
if (updated.count !== 1) { res.status(409); throw new Error('Consent has already been answered.'); }
|
||||
|
||||
notifyConsentAnswered(request.id).catch(e => console.error('[consent] notify failed:', e?.message || e));
|
||||
res.json({ message: answer.decision === 'give' ? 'Thank you — consent has been given.' : 'Thank you — consent has been declined.', status: answer.decision === 'give' ? 'given' : 'declined' });
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
// Tells the registrant and the event's notify recipients how a parent answered.
|
||||
async function notifyConsentAnswered(requestId) {
|
||||
const request = await prisma.consentRequest.findUnique({
|
||||
where: { id: requestId },
|
||||
include: {
|
||||
attendee: { include: { registration: { include: { user: { select: { id: true, name: true, email: true, phoneNumber: true, notificationPreference: true } } } } } },
|
||||
event: { include: { createdBy: { select: { id: true, name: true, email: true } }, notifyRecipients: { select: { id: true, name: true, email: true } } } },
|
||||
},
|
||||
});
|
||||
if (!request) return;
|
||||
const { sendMail, emailWrapper } = require('../utils/email');
|
||||
const { shouldEmail, waText } = require('../utils/notify');
|
||||
const { buildEventNotifyRecipientList } = require('../utils/notifications');
|
||||
const given = request.status === 'given';
|
||||
const verb = given ? 'given' : 'declined';
|
||||
const who = request.attendee.name;
|
||||
const ev = request.event.title;
|
||||
const payer = request.attendee.registration.user;
|
||||
const line = given
|
||||
? `${request.signedName} gave parental consent for ${who} for ${ev}.`
|
||||
: `${request.signedName} declined parental consent for ${who} for ${ev}. ${who} can't be checked in — please contact the organisers about a refund.`;
|
||||
|
||||
const sends = [];
|
||||
const payerHasEmail = payer?.email && !payer.email.endsWith('@guest.local') && !payer.email.endsWith('@deleted.invalid');
|
||||
if (payerHasEmail && shouldEmail(payer) && payer.email !== request.parentEmail) {
|
||||
sends.push(sendMail({ to: payer.email, subject: `Consent ${verb} for ${who}`, html: emailWrapper(`<p>${line}</p>`), text: line }));
|
||||
}
|
||||
if (payer) sends.push(waText(payer, line));
|
||||
const staff = buildEventNotifyRecipientList(request.event);
|
||||
if (staff.length) {
|
||||
sends.push(sendMail({ to: staff.join(','), subject: `Consent ${verb}: ${who} — ${ev}`, html: emailWrapper(`<p>${line}</p>`), text: line }));
|
||||
}
|
||||
await Promise.allSettled(sends);
|
||||
}
|
||||
|
||||
/** One row per attendee on the event, with consent state — for the consent tracker. */
|
||||
async function consentRows(eventId) {
|
||||
const event = await prisma.event.findUnique({ where: { id: eventId } });
|
||||
if (!event) return { event: null, rows: [] };
|
||||
const attendees = await prisma.attendee.findMany({
|
||||
where: { registration: { eventId, status: { not: 'cancelled' } } },
|
||||
include: { registration: { select: { id: true, status: true, user: { select: { id: true, name: true, email: true } } } } },
|
||||
orderBy: [{ registrationId: 'asc' }, { order: 'asc' }],
|
||||
});
|
||||
const latest = await latestRequestsFor(attendees.map(a => a.id));
|
||||
const rows = attendees.map(a => {
|
||||
const r = latest.get(a.id) || null;
|
||||
return {
|
||||
attendeeId: a.id,
|
||||
name: a.name,
|
||||
dateOfBirth: a.dateOfBirth,
|
||||
minor: isMinor(a.dateOfBirth, event.startDate),
|
||||
parentName: a.parentName, parentEmail: a.parentEmail, parentPhone: a.parentPhone,
|
||||
registrationId: a.registrationId,
|
||||
registrationStatus: a.registration.status,
|
||||
bookedBy: a.registration.user,
|
||||
status: consentStatusFor(a, event, r),
|
||||
request: r && {
|
||||
id: r.id, status: r.status, textVersion: r.textVersion, signedName: r.signedName, respondedAt: r.respondedAt,
|
||||
lastSentAt: r.lastSentAt, remindersSent: r.remindersSent, photoConsent: r.photoConsent,
|
||||
outdated: r.status === 'given' && r.textVersion < (event.consentTextVersion || 1),
|
||||
},
|
||||
};
|
||||
});
|
||||
return { event, rows };
|
||||
}
|
||||
|
||||
// @desc Consent status for every attendee on an event
|
||||
// @route GET /api/consent/events/:eventId
|
||||
// @access Private/Staff
|
||||
const listEventConsent = async (req, res) => {
|
||||
try {
|
||||
const { event, rows } = await consentRows(req.params.eventId);
|
||||
if (!event) { res.status(404); throw new Error('Event not found'); }
|
||||
const counts = rows.reduce((c, r) => { c[r.status] = (c[r.status] || 0) + 1; return c; }, {});
|
||||
res.json({
|
||||
event: { id: event.id, title: event.title, startDate: event.startDate, requiresParentalConsent: event.requiresParentalConsent, consentTextVersion: event.consentTextVersion },
|
||||
counts,
|
||||
rows,
|
||||
});
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Re-send one consent request (with a fresh link)
|
||||
// @route POST /api/consent/requests/:id/resend
|
||||
// @access Private (the registrant, or staff)
|
||||
const resendRequest = async (req, res) => {
|
||||
try {
|
||||
const request = await prisma.consentRequest.findUnique({ where: { id: req.params.id }, include: { attendee: { include: { registration: true } } } });
|
||||
if (!request) { res.status(404); throw new Error('Consent request not found'); }
|
||||
if (request.attendee.registration.userId !== req.user.id && !STAFF.includes(req.user.role)) { res.status(403); throw new Error('Not authorized'); }
|
||||
const latest = (await latestRequestsFor([request.attendeeId])).get(request.attendeeId);
|
||||
if (latest?.id !== request.id) { res.status(400); throw new Error('A newer consent request has replaced this one.'); }
|
||||
const sent = await resendConsentRequest(request.id);
|
||||
res.json({ message: sent.length ? `Consent request re-sent by ${sent.join(' and ')}.` : 'The consent request could not be sent — check the parent\'s email and phone.' });
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Re-send every consent request still pending on an event
|
||||
// @route POST /api/consent/events/:eventId/resend-pending
|
||||
// @access Private/Supervisor
|
||||
const resendPending = async (req, res) => {
|
||||
try {
|
||||
const { rows } = await consentRows(req.params.eventId);
|
||||
const pending = rows.filter(r => r.status === 'pending' && r.request);
|
||||
let sent = 0;
|
||||
for (const r of pending) {
|
||||
try { if ((await resendConsentRequest(r.request.id)).length) sent += 1; } catch {}
|
||||
}
|
||||
res.json({ message: `Re-sent ${sent} of ${pending.length} pending request(s).`, sent, pending: pending.length });
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Ask again for consent where it was given on an older version of the text,
|
||||
// and for minors who never got a request (e.g. names added later)
|
||||
// @route POST /api/consent/events/:eventId/rerequest
|
||||
// @access Private/Supervisor
|
||||
const rerequestConsent = async (req, res) => {
|
||||
try {
|
||||
const { event, rows } = await consentRows(req.params.eventId);
|
||||
if (!event) { res.status(404); throw new Error('Event not found'); }
|
||||
if (!event.requiresParentalConsent) { res.status(400); throw new Error('This event does not need parental consent.'); }
|
||||
const targets = rows.filter(r => r.minor && r.registrationStatus !== 'cancelled' && (r.status === 'missing' || r.request?.outdated));
|
||||
let created = 0;
|
||||
for (const r of targets) {
|
||||
if (!r.parentEmail && !r.parentPhone) continue;
|
||||
const attendee = await prisma.attendee.findUnique({ where: { id: r.attendeeId } });
|
||||
await createConsentRequest(attendee, event);
|
||||
created += 1;
|
||||
}
|
||||
logAdminAction({
|
||||
actorId: req.user.id, actorRole: req.user.role, action: 'consent_rerequested',
|
||||
targetType: 'Event', targetId: event.id, metadata: { requests: created, textVersion: event.consentTextVersion },
|
||||
ip: getClientIp(req),
|
||||
});
|
||||
res.json({ message: `Sent ${created} new consent request(s).`, created });
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Medical & emergency list for an event (decrypted) — every view is audited
|
||||
// @route GET /api/consent/events/:eventId/medical
|
||||
// @access Private/Supervisor
|
||||
const medicalList = async (req, res) => {
|
||||
try {
|
||||
const event = await prisma.event.findUnique({ where: { id: req.params.eventId } });
|
||||
if (!event) { res.status(404); throw new Error('Event not found'); }
|
||||
const { rows } = await consentRows(event.id);
|
||||
const ids = rows.map(r => r.request?.id).filter(Boolean);
|
||||
const full = ids.length ? await prisma.consentRequest.findMany({ where: { id: { in: ids } } }) : [];
|
||||
const byId = new Map(full.map(r => [r.id, r]));
|
||||
const list = rows
|
||||
.filter(r => r.request && byId.get(r.request.id)?.status === 'given')
|
||||
.map(r => {
|
||||
const fr = byId.get(r.request.id);
|
||||
return {
|
||||
name: r.name, dateOfBirth: r.dateOfBirth,
|
||||
parentName: r.parentName, parentPhone: r.parentPhone, parentEmail: r.parentEmail,
|
||||
medical: openJson(fr.medical), emergency: openJson(fr.emergency), photoConsent: fr.photoConsent,
|
||||
purged: !!fr.medicalPurgedAt,
|
||||
};
|
||||
});
|
||||
logAdminAction({
|
||||
actorId: req.user.id, actorRole: req.user.role, action: 'medical_list_viewed',
|
||||
targetType: 'Event', targetId: event.id, metadata: { attendees: list.length },
|
||||
ip: getClientIp(req),
|
||||
});
|
||||
res.json({ event: { id: event.id, title: event.title, startDate: event.startDate }, rows: list });
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = { getConsent, answerConsent, listEventConsent, resendRequest, resendPending, rerequestConsent, medicalList };
|
||||
@@ -0,0 +1,331 @@
|
||||
const prisma = require('../config/db');
|
||||
const { safeErrorMessage } = require('../utils/errorUtils');
|
||||
const { logAdminAction } = require('../utils/adminAudit');
|
||||
const { getClientIp } = require('../utils/requestUtils');
|
||||
const { normalizeCode, previewCoupon, buildCouponLines, describeCoupon, countCouponUses } = require('../utils/coupons');
|
||||
const { resolveOptionPrice, resolveVariantTierPrice } = require('../utils/pricing');
|
||||
|
||||
const TYPES = ['percent', 'fixed', 'comp'];
|
||||
const ROLES = ['user', 'staff', 'supervisor', 'admin'];
|
||||
const STAFF_ROLES = ['staff', 'supervisor', 'admin'];
|
||||
|
||||
const COUPON_DETAIL_INCLUDE = {
|
||||
events: { include: { event: { select: { id: true, title: true, startDate: true } } } },
|
||||
options: { include: { eventOption: { select: { id: true, name: true, eventId: true } }, variant: { select: { id: true, name: true } } } },
|
||||
allowedUsers: { include: { user: { select: { id: true, name: true, email: true } } } },
|
||||
createdBy: { select: { id: true, name: true } },
|
||||
};
|
||||
|
||||
const optionalDate = (v, label) => {
|
||||
if (v === undefined) return undefined;
|
||||
if (v === null || v === '') return null;
|
||||
const d = new Date(v);
|
||||
if (isNaN(d.getTime())) throw new Error(`${label} is not a valid date`);
|
||||
return d;
|
||||
};
|
||||
|
||||
const optionalInt = (v, label, min = 0) => {
|
||||
if (v === undefined) return undefined;
|
||||
if (v === null || v === '') return null;
|
||||
const n = Number(v);
|
||||
if (!Number.isInteger(n) || n < min) throw new Error(`${label} must be a whole number of at least ${min}`);
|
||||
return n;
|
||||
};
|
||||
|
||||
/**
|
||||
* Turns a create/update body into Prisma data plus the relation lists. On update,
|
||||
* fields left out of the body are left alone.
|
||||
*/
|
||||
async function parseCouponBody(body, { isCreate }) {
|
||||
const data = {};
|
||||
if (isCreate || body.code !== undefined) {
|
||||
const code = normalizeCode(body.code);
|
||||
if (!/^[A-Z0-9_-]{3,32}$/.test(code)) {
|
||||
throw new Error('Codes are 3–32 letters, numbers, hyphens or underscores');
|
||||
}
|
||||
data.code = code;
|
||||
}
|
||||
if (isCreate || body.type !== undefined) {
|
||||
if (!TYPES.includes(body.type)) throw new Error("Discount type must be 'percent', 'fixed' or 'comp'");
|
||||
data.type = body.type;
|
||||
}
|
||||
if (isCreate || body.value !== undefined) {
|
||||
const type = data.type || body.type;
|
||||
const value = type === 'comp' ? 0 : Number(body.value);
|
||||
if (type !== 'comp' && !(value > 0)) throw new Error('Enter a discount value greater than zero');
|
||||
if (type === 'percent' && value > 100) throw new Error('A percentage discount can\'t be more than 100');
|
||||
data.value = value;
|
||||
}
|
||||
if (body.description !== undefined) data.description = body.description ? String(body.description).trim() : null;
|
||||
if (body.stackEarlyBird !== undefined) data.stackEarlyBird = !!body.stackEarlyBird;
|
||||
if (body.active !== undefined) data.active = !!body.active;
|
||||
const validFrom = optionalDate(body.validFrom, 'Valid from');
|
||||
const validUntil = optionalDate(body.validUntil, 'Valid until');
|
||||
if (validFrom !== undefined) data.validFrom = validFrom;
|
||||
if (validUntil !== undefined) data.validUntil = validUntil;
|
||||
if (validFrom && validUntil && validFrom > validUntil) throw new Error('"Valid from" must be before "Valid until"');
|
||||
const maxUses = optionalInt(body.maxUses, 'Max total uses', 1);
|
||||
const maxUsesPerUser = optionalInt(body.maxUsesPerUser, 'Max uses per person', 1);
|
||||
const minQuantity = optionalInt(body.minQuantity, 'Minimum quantity', 1);
|
||||
if (maxUses !== undefined) data.maxUses = maxUses;
|
||||
if (maxUsesPerUser !== undefined) data.maxUsesPerUser = maxUsesPerUser;
|
||||
if (minQuantity !== undefined) data.minQuantity = minQuantity;
|
||||
if (body.restrictRoles !== undefined) {
|
||||
const roles = Array.isArray(body.restrictRoles) ? body.restrictRoles : [];
|
||||
if (roles.some(r => !ROLES.includes(r))) throw new Error('Unknown role in restrictions');
|
||||
data.restrictRoles = roles;
|
||||
}
|
||||
if (body.restrictDomain !== undefined) {
|
||||
const domain = String(body.restrictDomain || '').trim().toLowerCase().replace(/^@/, '');
|
||||
if (domain && !/^[a-z0-9.-]+\.[a-z]{2,}$/.test(domain)) throw new Error('Enter an email domain like church.org');
|
||||
data.restrictDomain = domain || null;
|
||||
}
|
||||
|
||||
const eventIds = body.eventIds !== undefined ? [...new Set((body.eventIds || []).filter(Boolean))] : undefined;
|
||||
const scopes = body.options !== undefined
|
||||
? (body.options || []).filter(o => o && o.eventOptionId).map(o => ({ eventOptionId: o.eventOptionId, variantId: o.variantId || null }))
|
||||
: undefined;
|
||||
const userIds = body.userIds !== undefined ? [...new Set((body.userIds || []).filter(Boolean))] : undefined;
|
||||
|
||||
if (scopes && scopes.length > 0) {
|
||||
const optionRows = await prisma.eventOption.findMany({
|
||||
where: { id: { in: scopes.map(s => s.eventOptionId) } },
|
||||
select: { id: true, eventId: true, variants: { select: { id: true } } },
|
||||
});
|
||||
for (const s of scopes) {
|
||||
const row = optionRows.find(o => o.id === s.eventOptionId);
|
||||
if (!row) throw new Error('One of the selected ticket options no longer exists');
|
||||
if (s.variantId && !row.variants.some(v => v.id === s.variantId)) throw new Error('One of the selected variants no longer exists');
|
||||
// A scoped option implies its event, so the code can't be used elsewhere by mistake.
|
||||
if (eventIds && eventIds.length > 0 && !eventIds.includes(row.eventId)) {
|
||||
throw new Error('Selected ticket options must belong to the selected events');
|
||||
}
|
||||
}
|
||||
}
|
||||
return { data, eventIds, scopes, userIds };
|
||||
}
|
||||
|
||||
function relationWrites({ eventIds, scopes, userIds }, isCreate) {
|
||||
const out = {};
|
||||
if (eventIds !== undefined) out.events = { ...(isCreate ? {} : { deleteMany: {} }), create: eventIds.map(eventId => ({ eventId })) };
|
||||
if (scopes !== undefined) out.options = { ...(isCreate ? {} : { deleteMany: {} }), create: scopes };
|
||||
if (userIds !== undefined) out.allowedUsers = { ...(isCreate ? {} : { deleteMany: {} }), create: userIds.map(userId => ({ userId })) };
|
||||
return out;
|
||||
}
|
||||
|
||||
// @desc List coupons with their use counts
|
||||
// @route GET /api/coupons
|
||||
// @access Private/Supervisor
|
||||
const listCoupons = async (req, res) => {
|
||||
try {
|
||||
const coupons = await prisma.coupon.findMany({ include: COUPON_DETAIL_INCLUDE, orderBy: { createdAt: 'desc' } });
|
||||
const totals = await prisma.couponRedemption.groupBy({
|
||||
by: ['couponId'],
|
||||
where: { releasedAt: null, registration: { status: { not: 'cancelled' } } },
|
||||
_count: { _all: true },
|
||||
_sum: { discountAmount: true },
|
||||
});
|
||||
const byId = Object.fromEntries(totals.map(t => [t.couponId, t]));
|
||||
res.json(coupons.map(c => ({
|
||||
...c,
|
||||
summary: describeCoupon(c),
|
||||
uses: byId[c.id]?._count?._all || 0,
|
||||
totalDiscount: byId[c.id]?._sum?.discountAmount || 0,
|
||||
})));
|
||||
} catch (error) {
|
||||
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Get one coupon with its redemptions
|
||||
// @route GET /api/coupons/:id
|
||||
// @access Private/Supervisor
|
||||
const getCoupon = async (req, res) => {
|
||||
try {
|
||||
const coupon = await prisma.coupon.findUnique({
|
||||
where: { id: req.params.id },
|
||||
include: {
|
||||
...COUPON_DETAIL_INCLUDE,
|
||||
redemptions: {
|
||||
orderBy: { createdAt: 'desc' },
|
||||
include: {
|
||||
user: { select: { id: true, name: true, email: true } },
|
||||
appliedBy: { select: { id: true, name: true } },
|
||||
registration: { select: { id: true, status: true, event: { select: { id: true, title: true } } } },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
if (!coupon) { res.status(404); throw new Error('Coupon not found'); }
|
||||
res.json({ ...coupon, summary: describeCoupon(coupon) });
|
||||
} catch (error) {
|
||||
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Create a coupon
|
||||
// @route POST /api/coupons
|
||||
// @access Private/Supervisor
|
||||
const createCoupon = async (req, res) => {
|
||||
try {
|
||||
let parsed;
|
||||
try {
|
||||
parsed = await parseCouponBody(req.body || {}, { isCreate: true });
|
||||
} catch (e) { res.status(400); throw e; }
|
||||
if (await prisma.coupon.findUnique({ where: { code: parsed.data.code } })) {
|
||||
res.status(409); throw new Error(`The code ${parsed.data.code} already exists`);
|
||||
}
|
||||
const coupon = await prisma.coupon.create({
|
||||
data: { ...parsed.data, createdById: req.user.id, ...relationWrites(parsed, true) },
|
||||
include: COUPON_DETAIL_INCLUDE,
|
||||
});
|
||||
logAdminAction({
|
||||
actorId: req.user.id, actorRole: req.user.role, action: 'coupon_created',
|
||||
targetType: 'Coupon', targetId: coupon.id,
|
||||
metadata: { code: coupon.code, type: coupon.type, value: coupon.value },
|
||||
ip: getClientIp(req),
|
||||
});
|
||||
res.status(201).json({ ...coupon, summary: describeCoupon(coupon), uses: 0, totalDiscount: 0 });
|
||||
} catch (error) {
|
||||
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Update a coupon (including turning it off). Discounts already applied keep
|
||||
// their locked amount.
|
||||
// @route PUT /api/coupons/:id
|
||||
// @access Private/Supervisor
|
||||
const updateCoupon = async (req, res) => {
|
||||
try {
|
||||
const existing = await prisma.coupon.findUnique({ where: { id: req.params.id } });
|
||||
if (!existing) { res.status(404); throw new Error('Coupon not found'); }
|
||||
let parsed;
|
||||
try {
|
||||
parsed = await parseCouponBody({ type: existing.type, ...req.body }, { isCreate: false });
|
||||
} catch (e) { res.status(400); throw e; }
|
||||
if (parsed.data.code && parsed.data.code !== existing.code) {
|
||||
const used = await prisma.couponRedemption.count({ where: { couponId: existing.id } });
|
||||
if (used > 0) { res.status(400); throw new Error('This code has been used, so it can\'t be renamed. Turn it off and create a new one instead.'); }
|
||||
if (await prisma.coupon.findUnique({ where: { code: parsed.data.code } })) {
|
||||
res.status(409); throw new Error(`The code ${parsed.data.code} already exists`);
|
||||
}
|
||||
}
|
||||
const coupon = await prisma.coupon.update({
|
||||
where: { id: existing.id },
|
||||
data: { ...parsed.data, ...relationWrites(parsed, false) },
|
||||
include: COUPON_DETAIL_INCLUDE,
|
||||
});
|
||||
const disabled = existing.active && coupon.active === false;
|
||||
const changedKeys = Object.keys(req.body || {});
|
||||
logAdminAction({
|
||||
actorId: req.user.id, actorRole: req.user.role,
|
||||
action: disabled && changedKeys.length === 1 ? 'coupon_disabled' : 'coupon_updated',
|
||||
targetType: 'Coupon', targetId: coupon.id,
|
||||
metadata: { code: coupon.code, changedKeys },
|
||||
ip: getClientIp(req),
|
||||
});
|
||||
const { usesTotal } = await countCouponUses(coupon.id, null);
|
||||
res.json({ ...coupon, summary: describeCoupon(coupon), uses: usesTotal });
|
||||
} catch (error) {
|
||||
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Check a code against the items someone is about to register for
|
||||
// @route POST /api/coupons/validate
|
||||
// @access Public (login needed for restricted codes)
|
||||
// Body: { code, eventId, options: [{ eventOptionId, variantId?, quantity }], forUserId? }
|
||||
// Staff can pass forUserId to check on an attendee's behalf, or leave it out for a new
|
||||
// attendee — restricted codes are then checked when the registration is saved.
|
||||
const validateCode = async (req, res) => {
|
||||
try {
|
||||
const { code, eventId, options, forUserId } = req.body || {};
|
||||
if (!code || !eventId || !Array.isArray(options) || options.length === 0) {
|
||||
res.status(400); throw new Error('code, eventId and options are required');
|
||||
}
|
||||
const event = await prisma.event.findUnique({
|
||||
where: { id: eventId },
|
||||
include: { eventOptions: { include: { earlyBirdTiers: true, variants: true } } },
|
||||
});
|
||||
if (!event) { res.status(404); throw new Error('Event not found'); }
|
||||
|
||||
const isStaff = req.user && STAFF_ROLES.includes(req.user.role);
|
||||
let buyer = req.user ? { id: req.user.id, role: req.user.role, email: req.user.email } : null;
|
||||
if (isStaff && forUserId) {
|
||||
buyer = await prisma.user.findUnique({ where: { id: forUserId }, select: { id: true, role: true, email: true } });
|
||||
} else if (isStaff && forUserId === null) {
|
||||
buyer = null; // a walk-in without an account yet
|
||||
}
|
||||
|
||||
// Price the basket the same way registration will.
|
||||
const registrationOptions = [];
|
||||
for (const o of options) {
|
||||
const eo = event.eventOptions.find(x => x.id === o.eventOptionId);
|
||||
const qty = Number(o.quantity || 0);
|
||||
if (!eo || qty < 1) continue;
|
||||
const resolved = o.variantId
|
||||
? await resolveVariantTierPrice(eo, o.variantId, qty, buyer)
|
||||
: await resolveOptionPrice(eo, qty, buyer);
|
||||
registrationOptions.push({
|
||||
eventOptionId: eo.id, variantId: o.variantId || null, quantity: qty, eventOption: eo,
|
||||
tranches: [{ quantity: qty, priceSnapshot: resolved.price, appliedTierId: resolved.tierId }],
|
||||
});
|
||||
}
|
||||
const lines = buildCouponLines({ registrationOptions });
|
||||
const subtotal = lines.reduce((s, l) => s + l.unitPrice * l.quantity, 0);
|
||||
|
||||
try {
|
||||
const { coupon, discount } = await previewCoupon({
|
||||
code, eventId, user: buyer, lines,
|
||||
deferRestrictions: isStaff && !buyer,
|
||||
});
|
||||
res.json({
|
||||
valid: true,
|
||||
code: coupon.code,
|
||||
type: coupon.type,
|
||||
value: coupon.value,
|
||||
summary: describeCoupon(coupon),
|
||||
description: coupon.description,
|
||||
discount,
|
||||
subtotal,
|
||||
total: Math.max(0, subtotal - discount),
|
||||
});
|
||||
} catch (e) {
|
||||
if (!e.isCouponError) throw e;
|
||||
res.status(400).json({ valid: false, message: e.message });
|
||||
}
|
||||
} catch (error) {
|
||||
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Coupon uses for the Discounts report
|
||||
// @route GET /api/coupons/redemptions?eventIds=a,b&from=&to=
|
||||
// @access Private/Supervisor
|
||||
const listRedemptions = async (req, res) => {
|
||||
try {
|
||||
const eventIds = String(req.query.eventIds || '').split(',').map(s => s.trim()).filter(Boolean);
|
||||
const from = req.query.from ? new Date(req.query.from) : null;
|
||||
const to = req.query.to ? new Date(req.query.to) : null;
|
||||
const where = {
|
||||
releasedAt: null,
|
||||
registration: { status: { not: 'cancelled' }, ...(eventIds.length ? { eventId: { in: eventIds } } : {}) },
|
||||
...(from || to ? { createdAt: { ...(from && !isNaN(from) ? { gte: from } : {}), ...(to && !isNaN(to) ? { lte: to } : {}) } } : {}),
|
||||
};
|
||||
const rows = await prisma.couponRedemption.findMany({
|
||||
where,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
include: {
|
||||
coupon: { select: { id: true, code: true, type: true, value: true } },
|
||||
user: { select: { id: true, name: true, email: true } },
|
||||
appliedBy: { select: { id: true, name: true } },
|
||||
registration: { select: { id: true, status: true, event: { select: { id: true, title: true } } } },
|
||||
},
|
||||
});
|
||||
res.json(rows.map(r => ({ ...r, summary: describeCoupon(r.coupon) })));
|
||||
} catch (error) {
|
||||
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = { listCoupons, getCoupon, createCoupon, updateCoupon, validateCode, listRedemptions };
|
||||
@@ -3,7 +3,10 @@ const { v4: uuidv4 } = require('uuid');
|
||||
const multer = require('multer');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const crypto = require('crypto');
|
||||
const { assertEventOpen } = require('../utils/cashupUtils');
|
||||
const { logAdminAction } = require('../utils/adminAudit');
|
||||
const { getClientIp } = require('../utils/requestUtils');
|
||||
|
||||
// Helper to convert stored picture path/URL to an absolute, externally reachable URL based on the incoming request
|
||||
function toAbsoluteUrl(req, url) {
|
||||
@@ -41,7 +44,7 @@ function toAbsoluteUrl(req, url) {
|
||||
// @access Private/Admin
|
||||
const createEvent = async (req, res) => {
|
||||
try {
|
||||
const { title, description, startDate, endDate, registrationDeadline, goLiveAt, price, picture, redirectUrl, isHidden, requiresAuth } = req.body;
|
||||
const { title, description, startDate, endDate, registrationDeadline, goLiveAt, price, picture, redirectUrl, isHidden, requiresAuth, requiresRegistration, allowNamedAttendees, requiresParentalConsent, consentText, consentCollectMedical, consentCollectEmergency, consentCollectPhoto, waitlistEnabled, waitlistOfferHours, waitlistPartialOffers, contactName, contactPhone, contactEmail, location } = req.body;
|
||||
|
||||
const data = {
|
||||
id: uuidv4(),
|
||||
@@ -58,14 +61,41 @@ const createEvent = async (req, res) => {
|
||||
redirectUrl,
|
||||
isHidden: isHidden === true || isHidden === 'true',
|
||||
requiresAuth: requiresAuth === false || requiresAuth === 'false' ? false : true,
|
||||
requiresRegistration: requiresRegistration === false || requiresRegistration === 'false' ? false : true,
|
||||
// A youth event needs names (with dates of birth), so it always takes them.
|
||||
allowNamedAttendees: allowNamedAttendees === true || allowNamedAttendees === 'true' || requiresParentalConsent === true,
|
||||
requiresParentalConsent: requiresParentalConsent === true,
|
||||
consentText: consentText ? String(consentText) : null,
|
||||
consentCollectMedical: consentCollectMedical === true,
|
||||
consentCollectEmergency: consentCollectEmergency === true,
|
||||
consentCollectPhoto: consentCollectPhoto === true,
|
||||
waitlistEnabled: waitlistEnabled === true,
|
||||
waitlistOfferHours: Math.min(168, Math.max(1, parseInt(waitlistOfferHours, 10) || 24)),
|
||||
waitlistPartialOffers: waitlistPartialOffers !== false,
|
||||
contactName: contactName || null,
|
||||
contactPhone: contactPhone || null,
|
||||
contactEmail: contactEmail || null,
|
||||
location: location || null,
|
||||
};
|
||||
|
||||
const logEventCreate = (createdEvent) => logAdminAction({
|
||||
actorId: req.user?.id,
|
||||
actorRole: req.user?.role,
|
||||
action: 'event_created',
|
||||
targetType: 'Event',
|
||||
targetId: createdEvent.id,
|
||||
metadata: { title: createdEvent.title },
|
||||
ip: getClientIp(req),
|
||||
});
|
||||
|
||||
try {
|
||||
const event = await prisma.event.create({ data });
|
||||
logEventCreate(event);
|
||||
|
||||
// Automatically create a main ticket (event option) with the event price
|
||||
// Automatically create a main ticket (event option) with the event price — contact-only
|
||||
// events have no bookable options, so there's nothing to auto-create for them.
|
||||
try {
|
||||
if (prisma && prisma.eventOption && typeof prisma.eventOption.create === 'function') {
|
||||
if (data.requiresRegistration !== false && prisma && prisma.eventOption && typeof prisma.eventOption.create === 'function') {
|
||||
await prisma.eventOption.create({
|
||||
data: {
|
||||
id: uuidv4(),
|
||||
@@ -124,24 +154,28 @@ const createEvent = async (req, res) => {
|
||||
// @ts-ignore delete field and retry
|
||||
delete data.registrationDeadline;
|
||||
const event = await prisma.event.create({ data });
|
||||
logEventCreate(event);
|
||||
return res.status(201).json(event);
|
||||
}
|
||||
if (msg.includes('Unknown argument `goLiveAt`')) {
|
||||
// @ts-ignore delete field and retry
|
||||
delete data.goLiveAt;
|
||||
const event = await prisma.event.create({ data });
|
||||
logEventCreate(event);
|
||||
return res.status(201).json(event);
|
||||
}
|
||||
if (msg.includes('Unknown argument `createdById`')) {
|
||||
// @ts-ignore delete field and retry
|
||||
delete data.createdById;
|
||||
const event = await prisma.event.create({ data });
|
||||
logEventCreate(event);
|
||||
return res.status(201).json(event);
|
||||
}
|
||||
if (msg.includes('Unknown argument `redirectUrl`')) {
|
||||
// @ts-ignore delete field and retry
|
||||
delete data.redirectUrl;
|
||||
const event = await prisma.event.create({ data });
|
||||
logEventCreate(event);
|
||||
return res.status(201).json(event);
|
||||
}
|
||||
throw err;
|
||||
@@ -188,12 +222,8 @@ const getEvents = async (req, res) => {
|
||||
const limitedOpts = (ev.eventOptions || []).filter(o => (o.stockLimit || 0) > 0);
|
||||
let isSoldOut = false;
|
||||
if (limitedOpts.length > 0) {
|
||||
const soldCounts = await Promise.all(limitedOpts.map(opt =>
|
||||
prisma.registrationOption.aggregate({
|
||||
where: { eventOptionId: opt.id, registration: { status: { not: 'cancelled' } } },
|
||||
_sum: { quantity: true },
|
||||
}).then(r => r._sum?.quantity || 0)
|
||||
));
|
||||
const { takenCount } = require('../utils/stock');
|
||||
const soldCounts = await Promise.all(limitedOpts.map(opt => takenCount({ eventOptionId: opt.id })));
|
||||
isSoldOut = limitedOpts.every((opt, i) => soldCounts[i] >= opt.stockLimit);
|
||||
}
|
||||
return { ...ev, picture: toAbsoluteUrl(req, ev.picture), isSoldOut };
|
||||
@@ -394,22 +424,15 @@ const getEventById = async (req, res) => {
|
||||
try {
|
||||
if (canIncludeVariants) {
|
||||
eventOptionsWithStock = await Promise.all((event.eventOptions || []).map(async opt => {
|
||||
// Total sold quantity for this option (all non-cancelled)
|
||||
const soldAgg = await prisma.registrationOption.aggregate({
|
||||
where: { eventOptionId: opt.id, registration: { status: { not: 'cancelled' } } },
|
||||
_sum: { quantity: true }
|
||||
});
|
||||
const soldCount = soldAgg._sum?.quantity || 0;
|
||||
// Taken = non-cancelled registrations + places held for live waitlist offers
|
||||
const { takenCount } = require('../utils/stock');
|
||||
const soldCount = await takenCount({ eventOptionId: opt.id });
|
||||
const availableCount = opt.stockLimit > 0 ? Math.max(0, opt.stockLimit - soldCount) : null;
|
||||
|
||||
// Per-variant stock
|
||||
const variantsWithStock = await Promise.all((opt.variants || []).map(async v => {
|
||||
if (!v.stockLimit) return { ...v, soldCount: 0, availableCount: null };
|
||||
const vAgg = await prisma.registrationOption.aggregate({
|
||||
where: { variantId: v.id, registration: { status: { not: 'cancelled' } } },
|
||||
_sum: { quantity: true }
|
||||
});
|
||||
const vSold = vAgg._sum?.quantity || 0;
|
||||
const vSold = await takenCount({ eventOptionId: opt.id, variantId: v.id });
|
||||
return { ...v, soldCount: vSold, availableCount: Math.max(0, v.stockLimit - vSold) };
|
||||
}));
|
||||
|
||||
@@ -436,6 +459,55 @@ const getEventById = async (req, res) => {
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Download a .ics calendar file for an event
|
||||
// @route GET /api/events/:id/ics
|
||||
// @access Public (same visibility gating as getEventById)
|
||||
const getEventIcs = async (req, res) => {
|
||||
try {
|
||||
const eventId = req.params.id;
|
||||
const event = await prisma.event.findUnique({
|
||||
where: { id: eventId },
|
||||
select: { id: true, title: true, description: true, startDate: true, endDate: true, location: true, isActive: true, goLiveAt: true },
|
||||
});
|
||||
|
||||
if (!event) {
|
||||
res.status(404);
|
||||
throw new Error('Event not found');
|
||||
}
|
||||
|
||||
// Same public-visibility gate as getEventById — a hidden/not-yet-live event's
|
||||
// details shouldn't be enumerable via this route either.
|
||||
const isStaffOrHigher = !!(req.user && ['admin', 'supervisor', 'staff'].includes(req.user.role));
|
||||
if (!isStaffOrHigher) {
|
||||
if (event.isActive === false) {
|
||||
res.status(404);
|
||||
throw new Error('Event not found');
|
||||
}
|
||||
let goLiveAt = null;
|
||||
try { goLiveAt = event.goLiveAt ? new Date(event.goLiveAt) : null; } catch (e) {}
|
||||
if (goLiveAt && new Date() < goLiveAt) {
|
||||
res.status(404);
|
||||
throw new Error('Event not found');
|
||||
}
|
||||
}
|
||||
|
||||
const { buildEventIcs } = require('../utils/icsUtils');
|
||||
let frontendUrl = (process.env.FRONTEND_URL || process.env.APP_BASE_URL || 'http://localhost:3001').replace(/\/$/, '');
|
||||
// The ics library's url validator requires a scheme; guard against FRONTEND_URL/APP_BASE_URL
|
||||
// being configured without one (e.g. "example.com" instead of "https://example.com").
|
||||
if (!/^[a-z0-9+.-]+:\/\//i.test(frontendUrl)) {
|
||||
frontendUrl = `https://${frontendUrl}`;
|
||||
}
|
||||
const ics = buildEventIcs(event, `${frontendUrl}/events/${event.id}`);
|
||||
|
||||
res.setHeader('Content-Type', 'text/calendar; charset=utf-8');
|
||||
res.setHeader('Content-Disposition', `attachment; filename="${event.title.replace(/[^a-z0-9 -]/gi, '').slice(0, 60) || 'event'}.ics"`);
|
||||
res.send(ics);
|
||||
} catch (error) {
|
||||
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: error.message });
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Update event
|
||||
// @route PUT /api/events/:id
|
||||
// @access Private/Admin
|
||||
@@ -454,7 +526,17 @@ const updateEvent = async (req, res) => {
|
||||
// totals — same rule already enforced for payments/costs. Admin can reopen first.
|
||||
await assertEventOpen(req.params.id, res);
|
||||
|
||||
const { title, description, startDate, endDate, registrationDeadline, goLiveAt, price, picture, isActive, redirectUrl, isHidden, requiresAuth } = req.body;
|
||||
const logEventUpdate = () => logAdminAction({
|
||||
actorId: req.user.id,
|
||||
actorRole: req.user.role,
|
||||
action: 'event_updated',
|
||||
targetType: 'Event',
|
||||
targetId: req.params.id,
|
||||
metadata: { changedKeys: Object.keys(req.body || {}) },
|
||||
ip: getClientIp(req),
|
||||
});
|
||||
|
||||
const { title, description, startDate, endDate, registrationDeadline, goLiveAt, price, picture, isActive, redirectUrl, isHidden, requiresAuth, requiresRegistration, allowNamedAttendees, requiresParentalConsent, consentText, consentCollectMedical, consentCollectEmergency, consentCollectPhoto, waitlistEnabled, waitlistOfferHours, waitlistPartialOffers, contactName, contactPhone, contactEmail, location } = req.body;
|
||||
|
||||
const data = {
|
||||
title: title || event.title,
|
||||
@@ -468,6 +550,26 @@ const updateEvent = async (req, res) => {
|
||||
isActive: isActive !== undefined ? isActive : event.isActive,
|
||||
isHidden: isHidden !== undefined ? (isHidden === true || isHidden === 'true') : (event.isHidden ?? false),
|
||||
requiresAuth: requiresAuth !== undefined ? !(requiresAuth === false || requiresAuth === 'false') : (event.requiresAuth ?? true),
|
||||
requiresRegistration: requiresRegistration !== undefined ? !(requiresRegistration === false || requiresRegistration === 'false') : (event.requiresRegistration ?? true),
|
||||
allowNamedAttendees: (requiresParentalConsent ?? event.requiresParentalConsent) === true
|
||||
? true
|
||||
: (allowNamedAttendees !== undefined ? (allowNamedAttendees === true || allowNamedAttendees === 'true') : event.allowNamedAttendees),
|
||||
requiresParentalConsent: requiresParentalConsent !== undefined ? requiresParentalConsent === true : event.requiresParentalConsent,
|
||||
consentCollectMedical: consentCollectMedical !== undefined ? consentCollectMedical === true : event.consentCollectMedical,
|
||||
consentCollectEmergency: consentCollectEmergency !== undefined ? consentCollectEmergency === true : event.consentCollectEmergency,
|
||||
consentCollectPhoto: consentCollectPhoto !== undefined ? consentCollectPhoto === true : event.consentCollectPhoto,
|
||||
waitlistEnabled: waitlistEnabled !== undefined ? waitlistEnabled === true : event.waitlistEnabled,
|
||||
waitlistOfferHours: waitlistOfferHours !== undefined ? Math.min(168, Math.max(1, parseInt(waitlistOfferHours, 10) || 24)) : event.waitlistOfferHours,
|
||||
waitlistPartialOffers: waitlistPartialOffers !== undefined ? waitlistPartialOffers !== false : event.waitlistPartialOffers,
|
||||
// Editing the consent text starts a new version; parents who already agreed keep
|
||||
// their record of the old one (supervisors can re-request consent).
|
||||
...(consentText !== undefined && String(consentText || '') !== String(event.consentText || '')
|
||||
? { consentText: consentText ? String(consentText) : null, consentTextVersion: (event.consentTextVersion || 1) + 1 }
|
||||
: {}),
|
||||
contactName: contactName !== undefined ? (contactName || null) : event.contactName,
|
||||
contactPhone: contactPhone !== undefined ? (contactPhone || null) : event.contactPhone,
|
||||
contactEmail: contactEmail !== undefined ? (contactEmail || null) : event.contactEmail,
|
||||
location: location !== undefined ? (location || null) : event.location,
|
||||
updatedAt: new Date(),
|
||||
redirectUrl: redirectUrl !== undefined ? redirectUrl : event.redirectUrl,
|
||||
};
|
||||
@@ -515,6 +617,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 +625,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,12 +717,30 @@ const deleteEvent = async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
logAdminAction({
|
||||
actorId: req.user.id,
|
||||
actorRole: req.user.role,
|
||||
action: 'event_deleted',
|
||||
targetType: 'Event',
|
||||
targetId: req.params.id,
|
||||
ip: getClientIp(req),
|
||||
});
|
||||
|
||||
res.json({ message: 'Event deactivated' });
|
||||
} catch (error) {
|
||||
res.status(400).json({ message: error.message });
|
||||
}
|
||||
};
|
||||
|
||||
// Optional staff price from a request body: '' / null clears it, undefined leaves it.
|
||||
function parseStaffPrice(value, current = null) {
|
||||
if (value === undefined) return current;
|
||||
if (value === null || value === '') return null;
|
||||
const n = parseFloat(value);
|
||||
if (!(n >= 0)) throw new Error('Staff price must be zero or more');
|
||||
return n;
|
||||
}
|
||||
|
||||
// @desc Create event option
|
||||
// @route POST /api/events/:id/options
|
||||
// @access Private/Admin
|
||||
@@ -634,7 +757,7 @@ const createEventOption = async (req, res) => {
|
||||
|
||||
await assertEventOpen(req.params.id, res);
|
||||
|
||||
const { name, price, isMainTicket, stockLimit } = req.body;
|
||||
const { name, price, isMainTicket, stockLimit, staffPrice } = req.body;
|
||||
|
||||
const eventOption = await prisma.eventOption.create({
|
||||
data: {
|
||||
@@ -644,6 +767,7 @@ const createEventOption = async (req, res) => {
|
||||
price: parseFloat(price),
|
||||
isMainTicket: isMainTicket || false,
|
||||
stockLimit: stockLimit ? parseInt(stockLimit, 10) : 0,
|
||||
staffPrice: parseStaffPrice(staffPrice),
|
||||
}
|
||||
});
|
||||
|
||||
@@ -670,7 +794,7 @@ const updateEventOption = async (req, res) => {
|
||||
|
||||
await assertEventOpen(eventOption.eventId, res);
|
||||
|
||||
const { name, price, isMainTicket, stockLimit, earlyBirdTiers, variants } = req.body;
|
||||
const { name, price, isMainTicket, stockLimit, earlyBirdTiers, variants, staffPrice } = req.body;
|
||||
|
||||
const updatedEventOption = await prisma.eventOption.update({
|
||||
where: { id: req.params.id },
|
||||
@@ -679,29 +803,53 @@ const updateEventOption = async (req, res) => {
|
||||
price: price !== undefined ? parseFloat(price) : eventOption.price,
|
||||
isMainTicket: isMainTicket !== undefined ? isMainTicket : eventOption.isMainTicket,
|
||||
stockLimit: stockLimit !== undefined ? parseInt(stockLimit, 10) : eventOption.stockLimit,
|
||||
staffPrice: parseStaffPrice(staffPrice, eventOption.staffPrice),
|
||||
}
|
||||
});
|
||||
|
||||
// If earlyBirdTiers provided, replace all tiers for this option (including per-variant ones)
|
||||
// If earlyBirdTiers provided, upsert tiers for this option (including per-variant ones) by
|
||||
// id — mirrors the variants upsert just below. Editing tiers used to delete-and-recreate
|
||||
// every tier for the option on every save, which silently reassigned each one a brand-new
|
||||
// id; RegistrationOption/RegistrationOptionTranche.appliedTierId (onDelete: SetNull) then
|
||||
// pointed at nothing, so every past purchase under that tier lost its "early bird"
|
||||
// attribution — even for tiers the admin didn't touch — and any stock-limit count for the
|
||||
// tier reset to zero (allowing it to be oversold). Upserting by id keeps existing tiers'
|
||||
// ids stable across edits so that history stays linked.
|
||||
if (Array.isArray(earlyBirdTiers)) {
|
||||
try { await prisma.earlyBirdTier.deleteMany({ where: { eventOptionId: updatedEventOption.id } }); } catch {}
|
||||
const incomingIds = earlyBirdTiers.filter(t => t && t.id).map(t => t.id);
|
||||
const existingTiers = await prisma.earlyBirdTier.findMany({ where: { eventOptionId: updatedEventOption.id } });
|
||||
for (const et of existingTiers) {
|
||||
if (incomingIds.includes(et.id)) continue;
|
||||
// Never delete a tier that's still attributed on past purchases — just leave it
|
||||
// orphaned from the option's active tier list rather than nulling out history.
|
||||
const usageCount = await prisma.registrationOptionTranche.count({ where: { appliedTierId: et.id } });
|
||||
if (usageCount === 0) {
|
||||
try { await prisma.earlyBirdTier.delete({ where: { id: et.id } }); } catch {}
|
||||
}
|
||||
}
|
||||
for (let i = 0; i < earlyBirdTiers.length; i++) {
|
||||
const t = earlyBirdTiers[i];
|
||||
if (!t || !t.deadline || (t.price === undefined || t.price === null)) continue;
|
||||
const deadline = new Date(t.deadline);
|
||||
const p = parseFloat(t.price);
|
||||
if (!(deadline instanceof Date) || isNaN(deadline.getTime()) || !(p >= 0)) continue;
|
||||
await prisma.earlyBirdTier.create({
|
||||
data: {
|
||||
id: require('uuid').v4(),
|
||||
eventOptionId: updatedEventOption.id,
|
||||
variantId: t.variantId || null,
|
||||
deadline,
|
||||
price: p,
|
||||
order: typeof t.order === 'number' ? t.order : i,
|
||||
stockLimit: t.stockLimit ? parseInt(t.stockLimit, 10) : 0,
|
||||
}
|
||||
});
|
||||
const data = {
|
||||
eventOptionId: updatedEventOption.id,
|
||||
variantId: t.variantId || null,
|
||||
deadline,
|
||||
price: p,
|
||||
order: typeof t.order === 'number' ? t.order : i,
|
||||
stockLimit: t.stockLimit ? parseInt(t.stockLimit, 10) : 0,
|
||||
};
|
||||
if (t.id) {
|
||||
await prisma.earlyBirdTier.upsert({
|
||||
where: { id: t.id },
|
||||
update: data,
|
||||
create: { id: t.id, ...data },
|
||||
});
|
||||
} else {
|
||||
await prisma.earlyBirdTier.create({ data: { id: require('uuid').v4(), ...data } });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -724,10 +872,11 @@ const updateEventOption = async (req, res) => {
|
||||
if (!v.name) continue;
|
||||
const variantPrice = v.price !== undefined && v.price !== null && v.price !== '' ? parseFloat(v.price) : null;
|
||||
const variantStockLimit = v.stockLimit !== undefined ? parseInt(v.stockLimit, 10) : 0;
|
||||
const variantStaffPrice = parseStaffPrice(v.staffPrice);
|
||||
if (v.id) {
|
||||
await prisma.optionVariant.update({
|
||||
where: { id: v.id },
|
||||
data: { name: v.name, price: variantPrice, stockLimit: variantStockLimit, order: i }
|
||||
data: { name: v.name, price: variantPrice, stockLimit: variantStockLimit, staffPrice: variantStaffPrice, order: i }
|
||||
});
|
||||
} else {
|
||||
await prisma.optionVariant.create({
|
||||
@@ -737,6 +886,7 @@ const updateEventOption = async (req, res) => {
|
||||
name: v.name,
|
||||
price: variantPrice,
|
||||
stockLimit: variantStockLimit,
|
||||
staffPrice: variantStaffPrice,
|
||||
order: i,
|
||||
}
|
||||
});
|
||||
@@ -744,6 +894,10 @@ const updateEventOption = async (req, res) => {
|
||||
}
|
||||
}
|
||||
|
||||
if (stockLimit !== undefined || variants !== undefined) {
|
||||
require('../utils/waitlist').queueWaitlist(eventOption.eventId);
|
||||
}
|
||||
|
||||
const refreshed = await prisma.eventOption.findUnique({
|
||||
where: { id: updatedEventOption.id },
|
||||
include: { earlyBirdTiers: true, variants: { orderBy: { order: 'asc' } } }
|
||||
@@ -814,7 +968,7 @@ const createOptionVariant = async (req, res) => {
|
||||
const eventOption = await prisma.eventOption.findUnique({ where: { id: req.params.id } });
|
||||
if (!eventOption) { res.status(404); throw new Error('Event option not found'); }
|
||||
await assertEventOpen(eventOption.eventId, res);
|
||||
const { name, price, stockLimit, order } = req.body;
|
||||
const { name, price, stockLimit, order, staffPrice } = req.body;
|
||||
if (!name) { res.status(400); throw new Error('Variant name is required'); }
|
||||
const variant = await prisma.optionVariant.create({
|
||||
data: {
|
||||
@@ -823,6 +977,7 @@ const createOptionVariant = async (req, res) => {
|
||||
name,
|
||||
price: price !== undefined && price !== null && price !== '' ? parseFloat(price) : null,
|
||||
stockLimit: stockLimit ? parseInt(stockLimit, 10) : 0,
|
||||
staffPrice: parseStaffPrice(staffPrice),
|
||||
order: order !== undefined ? parseInt(order, 10) : 0,
|
||||
}
|
||||
});
|
||||
@@ -840,11 +995,12 @@ const updateOptionVariant = async (req, res) => {
|
||||
const variant = await prisma.optionVariant.findUnique({ where: { id: req.params.id }, include: { eventOption: { select: { eventId: true } } } });
|
||||
if (!variant) { res.status(404); throw new Error('Variant not found'); }
|
||||
await assertEventOpen(variant.eventOption.eventId, res);
|
||||
const { name, price, stockLimit, order } = req.body;
|
||||
const { name, price, stockLimit, order, staffPrice } = req.body;
|
||||
const updated = await prisma.optionVariant.update({
|
||||
where: { id: req.params.id },
|
||||
data: {
|
||||
name: name !== undefined ? name : variant.name,
|
||||
staffPrice: parseStaffPrice(staffPrice, variant.staffPrice),
|
||||
price: price !== undefined ? (price === null || price === '' ? null : parseFloat(price)) : variant.price,
|
||||
stockLimit: stockLimit !== undefined ? parseInt(stockLimit, 10) : variant.stockLimit,
|
||||
order: order !== undefined ? parseInt(order, 10) : variant.order,
|
||||
@@ -885,7 +1041,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 +1821,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 +1883,7 @@ module.exports = {
|
||||
getAllEvents,
|
||||
getEventsAll,
|
||||
getEventById,
|
||||
getEventIcs,
|
||||
updateEvent,
|
||||
getEventNotifyRecipients,
|
||||
updateEventNotifyRecipients,
|
||||
|
||||
@@ -2,10 +2,13 @@ const prisma = require('../config/db');
|
||||
const { v4: uuidv4 } = require('uuid');
|
||||
const { generateTicketsForRegistration } = require('../utils/ticketUtils');
|
||||
const { computeRegistrationTotalDue, refreshPricingForRegistration } = require('../utils/pricing');
|
||||
const { computeDonationRemaining } = require('../utils/donationUtils');
|
||||
const { logAdminAction } = require('../utils/adminAudit');
|
||||
const { getClientIp } = require('../utils/requestUtils');
|
||||
const axios = require('axios');
|
||||
const { emailTickets } = require('./ticketController');
|
||||
const { safeErrorMessage } = require('../utils/errorUtils');
|
||||
const { assertEventOpen } = require('../utils/cashupUtils');
|
||||
const { assertEventOpen, assertRegistrationEventOpen } = require('../utils/cashupUtils');
|
||||
|
||||
// @desc Create a new payment
|
||||
// @route POST /api/payments
|
||||
@@ -78,7 +81,7 @@ const createPayment = async (req, res) => {
|
||||
registration = await prisma.registration.findUnique({
|
||||
where: { id: registrationId },
|
||||
include: {
|
||||
registrationOptions: { include: { eventOption: { include: { earlyBirdTiers: true } } } },
|
||||
registrationOptions: { include: { eventOption: { include: { earlyBirdTiers: true } }, tranches: true } },
|
||||
payments: true,
|
||||
user: { select: { id: true } }
|
||||
}
|
||||
@@ -134,7 +137,7 @@ const createPayment = async (req, res) => {
|
||||
registration = await prisma.registration.findUnique({
|
||||
where: { id: registrationId },
|
||||
include: {
|
||||
registrationOptions: { include: { eventOption: { include: { earlyBirdTiers: true } } } },
|
||||
registrationOptions: { include: { eventOption: { include: { earlyBirdTiers: true } }, tranches: true } },
|
||||
payments: true,
|
||||
user: { select: { id: true } }
|
||||
}
|
||||
@@ -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,12 +764,174 @@ 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) });
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Reverse a previous donation assignment — hard-deletes the leg payment and reverts
|
||||
// the registration's status/tickets. The donation itself (never touched by assign)
|
||||
// is unaffected, so its remaining balance simply goes back up.
|
||||
// @route POST /api/payments/unassign-donation
|
||||
// @access Private/Supervisor
|
||||
const unassignDonationFromRegistration = async (req, res) => {
|
||||
try {
|
||||
const { legId } = req.body;
|
||||
if (!legId) {
|
||||
res.status(400);
|
||||
throw new Error('legId is required');
|
||||
}
|
||||
|
||||
const leg = await prisma.payment.findUnique({ where: { id: legId } });
|
||||
if (!leg) {
|
||||
res.status(404);
|
||||
throw new Error('Payment not found');
|
||||
}
|
||||
|
||||
// Mirror the frontend's isDonationLeg check: a real donation-assignment leg is a positive,
|
||||
// non-donation payment that references a donation via originalPaymentId. This also rejects
|
||||
// refund rows, which set originalPaymentId too but always with a negative amount.
|
||||
if (leg.isDonation || !leg.originalPaymentId || !(leg.amount > 0)) {
|
||||
res.status(400);
|
||||
throw new Error('This payment is not a donation-assignment leg');
|
||||
}
|
||||
if (!leg.registrationId) {
|
||||
res.status(400);
|
||||
throw new Error('This leg is not linked to a registration');
|
||||
}
|
||||
|
||||
const donation = await prisma.payment.findUnique({ where: { id: leg.originalPaymentId } });
|
||||
if (!donation || !donation.isDonation) {
|
||||
res.status(400);
|
||||
throw new Error('The original donation for this leg could not be found');
|
||||
}
|
||||
|
||||
await assertRegistrationEventOpen(leg.registrationId, res);
|
||||
|
||||
const registration = await prisma.registration.findUnique({
|
||||
where: { id: leg.registrationId },
|
||||
include: {
|
||||
registrationOptions: { include: { eventOption: { include: { earlyBirdTiers: true } }, tranches: true } },
|
||||
payments: true
|
||||
}
|
||||
});
|
||||
if (!registration) {
|
||||
res.status(404);
|
||||
throw new Error('Registration not found');
|
||||
}
|
||||
|
||||
const originalStatus = registration.status;
|
||||
const totalPaidExisting = registration.payments.reduce((sum, p) => sum + p.amount, 0);
|
||||
const totalDue = computeRegistrationTotalDue(registration, new Date());
|
||||
const totalPaidAfter = totalPaidExisting - leg.amount;
|
||||
const willDowngradeFromPaid = (originalStatus === 'paid') && (totalPaidAfter < totalDue);
|
||||
|
||||
if (willDowngradeFromPaid) {
|
||||
// Same guard createRefund uses: block if any ticket on the registration has been scanned.
|
||||
const regTickets = await prisma.ticket.findMany({
|
||||
where: { registrationOption: { registrationId: leg.registrationId } },
|
||||
include: { usages: true }
|
||||
});
|
||||
const hasUsed = regTickets.some(t => t.isUsed || (t.usages && t.usages.length > 0));
|
||||
if (hasUsed) {
|
||||
res.status(400);
|
||||
throw new Error('Cannot unassign this donation because a ticket has already been used');
|
||||
}
|
||||
}
|
||||
|
||||
await prisma.payment.delete({ where: { id: legId } });
|
||||
|
||||
// Recompute status the same way createRefund does after removing money from a registration.
|
||||
const updatedRegistration = await prisma.registration.findUnique({
|
||||
where: { id: leg.registrationId },
|
||||
include: {
|
||||
registrationOptions: { include: { eventOption: { include: { earlyBirdTiers: true } }, tranches: true } },
|
||||
payments: true
|
||||
}
|
||||
});
|
||||
let finalRegistration = updatedRegistration;
|
||||
if (updatedRegistration) {
|
||||
const totalPaid = updatedRegistration.payments.reduce((sum, p) => sum + p.amount, 0);
|
||||
const totalDueNow = computeRegistrationTotalDue(updatedRegistration, new Date());
|
||||
let newStatus;
|
||||
if (totalPaid >= totalDueNow) newStatus = 'paid';
|
||||
else if (totalPaid > 0) newStatus = 'partial_paid';
|
||||
else newStatus = 'pending';
|
||||
finalRegistration = await prisma.registration.update({
|
||||
where: { id: leg.registrationId },
|
||||
data: { status: newStatus, updatedAt: new Date() }
|
||||
});
|
||||
|
||||
// Same blunt scope createRefund uses — tickets aren't tagged per-leg, so a downgrade
|
||||
// clears every unused ticket on the registration, not just the ones this leg funded.
|
||||
if (originalStatus === 'paid' && newStatus !== 'paid') {
|
||||
await prisma.ticket.deleteMany({
|
||||
where: {
|
||||
registrationOption: { registrationId: leg.registrationId },
|
||||
isUsed: false
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Fire-and-forget notification
|
||||
const { sendDonationUnassignmentEmails } = require('../utils/notifications');
|
||||
const _udLeg = { id: leg.id, amount: leg.amount, createdAt: leg.createdAt, method: leg.method, externalId: leg.externalId, userId: leg.userId, registrationId: leg.registrationId };
|
||||
(async () => {
|
||||
try { await sendDonationUnassignmentEmails(_udLeg); }
|
||||
catch (e) { console.error('Failed to send emails after unassigning donation:', e); }
|
||||
})();
|
||||
|
||||
logAdminAction({
|
||||
actorId: req.user.id,
|
||||
actorRole: req.user.role,
|
||||
action: 'donation_unassigned',
|
||||
targetType: 'Registration',
|
||||
targetId: leg.registrationId,
|
||||
metadata: { legId: leg.id, donationId: donation.id, amount: leg.amount },
|
||||
ip: getClientIp(req),
|
||||
});
|
||||
|
||||
return res.status(200).json({
|
||||
message: 'Donation unassigned',
|
||||
updatedRegistration: finalRegistration,
|
||||
donationId: donation.id
|
||||
});
|
||||
} catch (error) {
|
||||
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Yoco line items for a registration: one per item at each price actually charged
|
||||
* (tranche-aware, so a line bought partly at early-bird shows both prices), plus the
|
||||
* coupon discount as a negative line. Previous/partial payment lines are added by callers.
|
||||
*/
|
||||
function checkoutLineItems(registration) {
|
||||
const { buildLineItems, couponDiscountLine } = require('../utils/pdfDocs');
|
||||
const items = buildLineItems(registration.registrationOptions).map(row => ({
|
||||
displayName: row.label + (row.isEarlyBird ? ' (early bird)' : ''),
|
||||
quantity: row.quantity,
|
||||
pricingDetails: { price: Math.round(row.unitPrice * 100) },
|
||||
}));
|
||||
const coupon = couponDiscountLine(registration);
|
||||
if (coupon) {
|
||||
items.push({ displayName: coupon.label, quantity: 1, pricingDetails: { price: -Math.round(coupon.amount * 100) } });
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
// Internal helper: create a Yoco checkout for a registration and return { checkoutId, redirectUrl, amount }
|
||||
// Does NOT check user authorization — callers are responsible for ensuring the user owns the registration.
|
||||
async function createRegistrationCheckoutInternal(registrationId, userId, { successUrl, cancelUrl, failureUrl } = {}) {
|
||||
@@ -780,9 +942,11 @@ async function createRegistrationCheckoutInternal(registrationId, userId, { succ
|
||||
include: {
|
||||
eventOption: { include: { earlyBirdTiers: true } },
|
||||
variant: true,
|
||||
tranches: true,
|
||||
}
|
||||
},
|
||||
payments: true,
|
||||
couponRedemptions: { where: { releasedAt: null }, include: { coupon: { select: { code: true } } } },
|
||||
user: { select: { id: true, name: true, email: true } },
|
||||
event: true
|
||||
}
|
||||
@@ -798,26 +962,7 @@ async function createRegistrationCheckoutInternal(registrationId, userId, { succ
|
||||
|
||||
const amountInCents = Math.round(remainingAmount * 100);
|
||||
const idempotencyKey = `reg_${registrationId}_${amountInCents}_${Date.now()}`;
|
||||
const now = new Date();
|
||||
|
||||
const lastPaymentAt = registration.payments.length > 0
|
||||
? new Date(Math.max(...registration.payments.map(p => new Date(p.createdAt).getTime())))
|
||||
: null;
|
||||
|
||||
const lineItems = registration.registrationOptions.map(option => {
|
||||
const variantLabel = option.variant?.name ? ` — ${option.variant.name}` : '';
|
||||
// Use priceSnapshot when available (variant-aware, authoritative); fall back to deadline-only check
|
||||
const unitPrice = (option.priceSnapshot !== null && option.priceSnapshot !== undefined)
|
||||
? option.priceSnapshot
|
||||
: require('../utils/pricing').getEffectiveUnitPrice(option.eventOption, lastPaymentAt, now);
|
||||
return {
|
||||
displayName: `${option.eventOption.name}${variantLabel}`,
|
||||
quantity: option.quantity,
|
||||
pricingDetails: {
|
||||
price: Math.round(unitPrice * 100)
|
||||
}
|
||||
};
|
||||
});
|
||||
const lineItems = checkoutLineItems(registration);
|
||||
|
||||
// Show prior payments as a deduction line
|
||||
if (totalPaid > 0) {
|
||||
@@ -894,7 +1039,7 @@ const createYocoCheckout = async (req, res) => {
|
||||
const freshReg = await prisma.registration.findUnique({
|
||||
where: { id: registrationId },
|
||||
include: {
|
||||
registrationOptions: { include: { eventOption: { include: { earlyBirdTiers: true } } } },
|
||||
registrationOptions: { include: { eventOption: { include: { earlyBirdTiers: true } }, tranches: true } },
|
||||
payments: true
|
||||
}
|
||||
});
|
||||
@@ -917,9 +1062,11 @@ const createYocoCheckout = async (req, res) => {
|
||||
include: {
|
||||
eventOption: { include: { earlyBirdTiers: true } },
|
||||
variant: true,
|
||||
tranches: true,
|
||||
}
|
||||
},
|
||||
payments: true,
|
||||
couponRedemptions: { where: { releasedAt: null }, include: { coupon: { select: { code: true } } } },
|
||||
user: { select: { id: true, name: true, email: true } },
|
||||
event: true
|
||||
}
|
||||
@@ -933,21 +1080,7 @@ const createYocoCheckout = async (req, res) => {
|
||||
if (remainingAmount >= 15 && requested < 15) { res.status(400); throw new Error('Minimum payment is R15'); }
|
||||
const charge = Math.min(requested, remainingAmount);
|
||||
const amountInCents = Math.round(charge * 100);
|
||||
const now = new Date();
|
||||
const lastPaymentAt = registration.payments.length > 0
|
||||
? new Date(Math.max(...registration.payments.map(p => new Date(p.createdAt).getTime())))
|
||||
: null;
|
||||
const lineItems = registration.registrationOptions.map(option => {
|
||||
const variantLabel = option.variant?.name ? ` — ${option.variant.name}` : '';
|
||||
const unitPrice = (option.priceSnapshot !== null && option.priceSnapshot !== undefined)
|
||||
? option.priceSnapshot
|
||||
: require('../utils/pricing').getEffectiveUnitPrice(option.eventOption, lastPaymentAt, now);
|
||||
return {
|
||||
displayName: `${option.eventOption.name}${variantLabel}`,
|
||||
quantity: option.quantity,
|
||||
pricingDetails: { price: Math.round(unitPrice * 100) }
|
||||
};
|
||||
});
|
||||
const lineItems = checkoutLineItems(registration);
|
||||
// Previous payments deduction
|
||||
if (totalPaid > 0) {
|
||||
lineItems.push({ displayName: 'Previous Payments', quantity: 1, pricingDetails: { price: -Math.round(totalPaid * 100) } });
|
||||
@@ -1057,20 +1190,22 @@ const sendPaymentLink = async (req, res) => {
|
||||
const { user, event } = registration;
|
||||
const message = `Hi ${user.name || ''}, here's your payment link for ${event?.title || 'your registration'}: ${redirectUrl}`;
|
||||
|
||||
// Validation is synchronous (fast, no network); the actual send is backgrounded since
|
||||
// SMTP/WAWP round trips shouldn't block this request.
|
||||
if (channel === 'email') {
|
||||
if (!user.email) { res.status(400); throw new Error('This user has no email address on file'); }
|
||||
const { sendMail } = require('../utils/email');
|
||||
await sendMail({
|
||||
sendMail({
|
||||
to: user.email,
|
||||
subject: `Payment link — ${event?.title || 'Registration'}`,
|
||||
text: message,
|
||||
html: `<p>Hi ${user.name || ''},</p><p>Here's your payment link for <strong>${event?.title || 'your registration'}</strong>:</p><p><a href="${redirectUrl}">${redirectUrl}</a></p>`
|
||||
});
|
||||
}).catch(e => console.error('Failed to send payment link email:', e));
|
||||
} else {
|
||||
const { isValidZAPhone } = require('../utils/whatsapp');
|
||||
if (!isValidZAPhone(user.phoneNumber)) { res.status(400); throw new Error('This user has no valid WhatsApp number on file'); }
|
||||
const { waTextAny } = require('../utils/notify');
|
||||
await waTextAny(user, message);
|
||||
waTextAny(user, message).catch(e => console.error('Failed to send payment link via WhatsApp:', e));
|
||||
}
|
||||
|
||||
return res.status(200).json({ sent: true, channel });
|
||||
@@ -1145,7 +1280,7 @@ const createRefund = async (req, res) => {
|
||||
const registration = await prisma.registration.findUnique({
|
||||
where: { id: linkRegistrationId },
|
||||
include: {
|
||||
registrationOptions: { include: { eventOption: { include: { earlyBirdTiers: true } } } },
|
||||
registrationOptions: { include: { eventOption: { include: { earlyBirdTiers: true } }, tranches: true } },
|
||||
payments: true,
|
||||
// Load tickets to check usage if needed
|
||||
_count: true
|
||||
@@ -1199,7 +1334,7 @@ const createRefund = async (req, res) => {
|
||||
const registration = await prisma.registration.findUnique({
|
||||
where: { id: linkRegistrationId },
|
||||
include: {
|
||||
registrationOptions: { include: { eventOption: { include: { earlyBirdTiers: true } } } },
|
||||
registrationOptions: { include: { eventOption: { include: { earlyBirdTiers: true } }, tranches: true } },
|
||||
payments: true
|
||||
}
|
||||
});
|
||||
@@ -1228,6 +1363,16 @@ const createRefund = async (req, res) => {
|
||||
const { sendRefundEmail } = require('../utils/notifications');
|
||||
sendRefundEmail(negativePayment.id).catch(e => console.error('Failed to send refund email:', e));
|
||||
|
||||
logAdminAction({
|
||||
actorId: req.user.id,
|
||||
actorRole: req.user.role,
|
||||
action: 'refund_created',
|
||||
targetType: 'Payment',
|
||||
targetId: negativePayment.id,
|
||||
metadata: { amount: amt, method: method || 'refund', reason: reason || null, registrationId: linkRegistrationId },
|
||||
ip: getClientIp(req),
|
||||
});
|
||||
|
||||
return res.status(201).json(negativePayment);
|
||||
} catch (error) {
|
||||
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
|
||||
@@ -1291,6 +1436,20 @@ const getPaymentStats = async (req, res) => {
|
||||
}
|
||||
}
|
||||
|
||||
// @desc Re-send the receipt PDF for one of the caller's own payments
|
||||
// @route POST /api/payments/:id/send-receipt
|
||||
// @access Private
|
||||
const sendReceipt = async (req, res) => {
|
||||
try {
|
||||
const { channel } = req.body || {};
|
||||
const { sendReceiptToUser } = require('../utils/notifications');
|
||||
await sendReceiptToUser(req.params.id, req.user.id, channel);
|
||||
res.json({ message: channel === 'whatsapp' ? 'Receipt sent to WhatsApp.' : 'Receipt emailed.' });
|
||||
} catch (error) {
|
||||
res.status(error.statusCode || 400).json({ message: error.message });
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
createPayment,
|
||||
getPayments,
|
||||
@@ -1299,9 +1458,11 @@ module.exports = {
|
||||
getPaymentsByRegistration,
|
||||
getPaymentsByEvent,
|
||||
assignDonationToRegistration,
|
||||
unassignDonationFromRegistration,
|
||||
createYocoCheckout,
|
||||
createRegistrationCheckoutInternal,
|
||||
sendPaymentLink,
|
||||
createRefund,
|
||||
getPaymentStats
|
||||
getPaymentStats,
|
||||
sendReceipt,
|
||||
};
|
||||
File diff suppressed because it is too large
Load Diff
@@ -250,7 +250,8 @@ async function renderReportPdfToFile(payload) {
|
||||
async function buildReportWorkbook(payload) {
|
||||
const { title, subtitle, kind, table, layered, stats, chart, note, extraTables } = payload || {};
|
||||
const wb = new ExcelJS.Workbook();
|
||||
wb.creator = 'Hope Family Church Events';
|
||||
const orgName = require('../utils/settingsCache').getSettingSync('org_name', process.env.ORG_NAME || 'Cross Code');
|
||||
wb.creator = `${orgName} Events`;
|
||||
wb.created = new Date();
|
||||
const sheetName = (title || 'Report').replace(/[\\/*?:[\]]/g, ' ').slice(0, 31) || 'Report';
|
||||
const ws = wb.addWorksheet(sheetName);
|
||||
|
||||
@@ -4,11 +4,19 @@ const { safeErrorMessage } = require('../utils/errorUtils');
|
||||
const { v4: uuidv4 } = require('uuid');
|
||||
const { invalidate: invalidateSettingsCache, warmCache, ENCRYPTED_KEYS } = require('../utils/settingsCache');
|
||||
const { encrypt, decrypt, isEncrypted } = require('../utils/encryption');
|
||||
const { logAdminAction } = require('../utils/adminAudit');
|
||||
const { getClientIp } = require('../utils/requestUtils');
|
||||
|
||||
// Keys safe to return without auth — includes legal keys needed by public legal pages
|
||||
const PUBLIC_KEYS = [
|
||||
'org_name', 'org_tagline', 'org_email', 'org_phone', 'org_address',
|
||||
'accent_color', 'logo_url', 'setup_complete', 'app_base_url',
|
||||
// Branding — primary_color/secondary_color/accent_color are the 3-color brand
|
||||
// system (accent_color's meaning was repurposed from "the one brand color" to
|
||||
// "tertiary accent color"; primary_color falls back to accent_color's legacy
|
||||
// value wherever it's consumed, so older data still resolves sensibly until
|
||||
// the admin re-saves the Branding tab).
|
||||
'primary_color', 'secondary_color', 'accent_color', 'logo_url', 'favicon_url',
|
||||
'setup_complete', 'app_base_url',
|
||||
// Legal pages
|
||||
'legal_operator_name', 'legal_io_name', 'legal_io_email',
|
||||
'legal_website_url', 'legal_effective_date',
|
||||
@@ -101,6 +109,20 @@ const updateSettings = async (req, res) => {
|
||||
if (ops.length) await prisma.$transaction(ops);
|
||||
invalidateSettingsCache();
|
||||
await warmCache(); // ensure in-memory cache reflects the new values before responding
|
||||
|
||||
// Log which keys changed, never the values — some settings are secrets (e.g. the
|
||||
// WAWP token) that aren't even encrypted at rest, let alone fit for an audit log.
|
||||
if (ops.length) {
|
||||
logAdminAction({
|
||||
actorId: req.user.id,
|
||||
actorRole: req.user.role,
|
||||
action: 'settings_updated',
|
||||
targetType: 'AppSetting',
|
||||
metadata: { changedKeys: Object.keys(updates).filter(k => updates[k] !== undefined && updates[k] !== null) },
|
||||
ip: getClientIp(req),
|
||||
});
|
||||
}
|
||||
|
||||
res.json({ message: 'Settings saved' });
|
||||
} catch (e) {
|
||||
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(e) });
|
||||
@@ -150,7 +172,9 @@ const setupRegister = async (req, res) => {
|
||||
},
|
||||
});
|
||||
|
||||
const token = generateToken(user.id, user.role, user.tokenVersion ?? 0);
|
||||
// Marked as a setup token so the 2FA requirement doesn't block the rest of the
|
||||
// wizard; the admin enrols at their first normal login.
|
||||
const token = generateToken(user.id, user.role, user.tokenVersion ?? 0, { setup: true });
|
||||
res.json({ token, name: user.name, email: user.email });
|
||||
} catch (e) {
|
||||
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(e) });
|
||||
@@ -293,12 +317,13 @@ const testSmtp = async (req, res) => {
|
||||
// Send a real test email to the authenticated user so there's visible proof
|
||||
const adminEmail = req.user?.email;
|
||||
if (adminEmail) {
|
||||
const orgName = getSettingSync('org_name', process.env.ORG_NAME || 'Cross Code');
|
||||
await transporter.sendMail({
|
||||
from: from || user || 'no-reply@hope-events.local',
|
||||
from: from || user || 'no-reply@crosscode.local',
|
||||
to: adminEmail,
|
||||
subject: 'SMTP test — Hope Events',
|
||||
text: `This is a test email sent from the Hope Events admin panel to confirm that your SMTP settings are working correctly.\n\nHost: ${host}:${port}\nFrom: ${from || user}`,
|
||||
html: `<p>This is a test email sent from the <strong>Hope Events</strong> admin panel to confirm that your SMTP settings are working correctly.</p><p><strong>Host:</strong> ${host}:${port}<br/><strong>From:</strong> ${from || user}</p>`,
|
||||
subject: `SMTP test — ${orgName}`,
|
||||
text: `This is a test email sent from the ${orgName} admin panel to confirm that your SMTP settings are working correctly.\n\nHost: ${host}:${port}\nFrom: ${from || user}`,
|
||||
html: `<p>This is a test email sent from the <strong>${orgName}</strong> admin panel to confirm that your SMTP settings are working correctly.</p><p><strong>Host:</strong> ${host}:${port}<br/><strong>From:</strong> ${from || user}</p>`,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -81,9 +81,10 @@ const getTickets = async (req, res) => {
|
||||
// @access Private
|
||||
const getUserTickets = async (req, res) => {
|
||||
try {
|
||||
// Tickets this user paid for, plus named tickets someone else bought for them.
|
||||
const tickets = await prisma.ticket.findMany({
|
||||
where: {
|
||||
userId: req.user.id,
|
||||
OR: [{ userId: req.user.id }, { holderUserId: req.user.id }],
|
||||
registrationOption: { registration: { status: { not: 'cancelled' } } }
|
||||
},
|
||||
include: {
|
||||
@@ -94,6 +95,8 @@ const getUserTickets = async (req, res) => {
|
||||
registration: true
|
||||
}
|
||||
},
|
||||
attendee: { select: { id: true, name: true } },
|
||||
user: { select: { id: true, name: true } },
|
||||
event: true,
|
||||
usages: {
|
||||
include: {
|
||||
@@ -261,6 +264,7 @@ const getScanPreview = async (req, res) => {
|
||||
e.title AS "eventTitle",
|
||||
u.id AS "userId",
|
||||
u.name AS "userName",
|
||||
a.name AS "attendeeName",
|
||||
COALESCE(
|
||||
json_agg(
|
||||
json_build_object(
|
||||
@@ -276,9 +280,10 @@ const getScanPreview = async (req, res) => {
|
||||
LEFT JOIN "OptionVariant" ov ON ov.id = ro."variantId"
|
||||
LEFT JOIN "Event" e ON e.id = t."eventId"
|
||||
LEFT JOIN "User" u ON u.id = t."userId"
|
||||
LEFT JOIN "Attendee" a ON a.id = t."attendeeId"
|
||||
LEFT JOIN "TicketUsage" tu ON tu."ticketId" = t.id
|
||||
WHERE t."qrCode" = ${req.params.qrCode}
|
||||
GROUP BY t.id, ro.id, eo.id, ov.id, e.id, u.id
|
||||
GROUP BY t.id, ro.id, eo.id, ov.id, e.id, u.id, a.id
|
||||
`;
|
||||
|
||||
if (!rows || rows.length === 0) {
|
||||
@@ -287,11 +292,14 @@ const getScanPreview = async (req, res) => {
|
||||
}
|
||||
|
||||
const r = rows[0];
|
||||
const { consentStatusForTickets } = require('../utils/consent');
|
||||
const consentStatus = (await consentStatusForTickets([r.id])).get(r.id) || 'not_required';
|
||||
res.json({
|
||||
id: r.id,
|
||||
quantity: Number(r.quantity),
|
||||
isUsed: r.isUsed,
|
||||
eventId: r.eventId,
|
||||
consentStatus,
|
||||
registrationOption: {
|
||||
id: r.registrationOptionId,
|
||||
eventOption: { id: r.eventOptionId, name: r.eventOptionName },
|
||||
@@ -299,6 +307,7 @@ const getScanPreview = async (req, res) => {
|
||||
},
|
||||
event: { id: r.evId, title: r.eventTitle },
|
||||
user: { id: r.userId, name: r.userName },
|
||||
attendee: r.attendeeName ? { name: r.attendeeName } : null,
|
||||
usages: r.usages || []
|
||||
});
|
||||
} catch (error) {
|
||||
@@ -375,6 +384,25 @@ const scanTicket = async (req, res) => {
|
||||
});
|
||||
}
|
||||
|
||||
// Youth events: no check-in without parental consent, unless a supervisor overrides.
|
||||
const { consentStatusForTickets } = require('../utils/consent');
|
||||
const consentStatus = (await consentStatusForTickets([ticket.id])).get(ticket.id) || 'not_required';
|
||||
if (!['not_required', 'given'].includes(consentStatus)) {
|
||||
const canOverride = ['admin', 'supervisor'].includes(req.user?.role);
|
||||
if (!(req.body?.overrideConsent === true && canOverride)) {
|
||||
const why = consentStatus === 'declined' ? 'Parental consent was declined' : consentStatus === 'pending' ? 'Parental consent is still pending' : 'Parental consent is missing';
|
||||
return res.status(403).json({ message: `${why} — this ticket can't be checked in.`, code: 'CONSENT_REQUIRED', consentStatus, canOverride, ticket });
|
||||
}
|
||||
const { logAdminAction } = require('../utils/adminAudit');
|
||||
const { getClientIp } = require('../utils/requestUtils');
|
||||
logAdminAction({
|
||||
actorId: req.user.id, actorRole: req.user.role, action: 'consent_override',
|
||||
targetType: 'Ticket', targetId: ticket.id,
|
||||
metadata: { consentStatus, eventId: ticket.eventId },
|
||||
ip: getClientIp(req),
|
||||
});
|
||||
}
|
||||
|
||||
// Determine how many to redeem this scan (defaults to all remaining)
|
||||
const requestedQty = parseInt(req.body?.qty || req.body?.quantity) || remaining;
|
||||
const qtyToRedeem = Math.min(Math.max(1, requestedQty), remaining);
|
||||
@@ -432,6 +460,7 @@ const getTicketsByEvent = async (req, res) => {
|
||||
include: {
|
||||
registrationOption: { include: { eventOption: true, variant: true, registration: { select: { id: true } } } },
|
||||
user: { select: { id: true, name: true, email: true, phoneNumber: true } },
|
||||
attendee: { select: { id: true, name: true, email: true, phone: true } },
|
||||
usages: { include: { scannedBy: { select: { id: true, name: true, email: true } } } }
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
@@ -660,6 +689,13 @@ async function emailTicketsInternal(req, res, userOverride) {
|
||||
return res.json({ message: 'No valid contact details on file — ticket delivery skipped.' });
|
||||
}
|
||||
|
||||
const ticketInclude = {
|
||||
event: true,
|
||||
registrationOption: { include: { eventOption: true, variant: true, registration: { select: { ticketDelivery: true } } } },
|
||||
attendee: true,
|
||||
user: { select: { name: true } },
|
||||
usages: true,
|
||||
};
|
||||
let tickets;
|
||||
if (registrationId) {
|
||||
const registration = await prisma.registration.findUnique({
|
||||
@@ -669,22 +705,53 @@ async function emailTicketsInternal(req, res, userOverride) {
|
||||
const registrationOptions = await prisma.registrationOption.findMany({ where: { registrationId } });
|
||||
tickets = await prisma.ticket.findMany({
|
||||
where: { registrationOptionId: { in: registrationOptions.map(o => o.id) }, userId },
|
||||
include: { event: true, registrationOption: { include: { eventOption: true, variant: true } }, usages: true },
|
||||
include: ticketInclude,
|
||||
orderBy: { createdAt: 'asc' }
|
||||
});
|
||||
} else {
|
||||
// The payer, or the named holder of a group ticket, can have it sent.
|
||||
tickets = await prisma.ticket.findMany({
|
||||
where: { id: { in: ticketIds }, userId },
|
||||
include: { event: true, registrationOption: { include: { eventOption: true, variant: true } }, usages: true },
|
||||
where: { id: { in: ticketIds }, OR: [{ userId }, { holderUserId: userId }] },
|
||||
include: ticketInclude,
|
||||
orderBy: { createdAt: 'asc' }
|
||||
});
|
||||
}
|
||||
|
||||
// Dedup by registrationOptionId
|
||||
// Group tickets set to "send each person their own": attendees with contact details
|
||||
// get theirs directly; the rest (and attendees without contact details) go to the
|
||||
// payer below. Not when the caller asked for a specific recipient or channel.
|
||||
if (!userOverride && !req._noSplit) {
|
||||
const own = tickets.filter(t => t.attendee && (t.attendee.email || t.attendee.phone)
|
||||
&& t.registrationOption?.registration?.ticketDelivery === 'each');
|
||||
for (const t of own) {
|
||||
const a = t.attendee;
|
||||
const hasEmail = !!(a.email && a.email.includes('@'));
|
||||
const recipient = {
|
||||
id: a.userId || null,
|
||||
name: a.name,
|
||||
email: hasEmail ? a.email : null,
|
||||
phoneNumber: a.phone || null,
|
||||
notificationPreference: hasEmail && a.phone ? 'both' : hasEmail ? 'email' : 'whatsapp',
|
||||
};
|
||||
const silent = { status: () => silent, json: () => silent };
|
||||
try {
|
||||
await emailTicketsInternal({ user: { id: userId }, body: { ticketIds: [t.id] }, _noSplit: true }, silent, recipient);
|
||||
} catch (e) {
|
||||
console.error(`[tickets] Failed to send ${a.name}'s ticket:`, e?.message || e);
|
||||
}
|
||||
}
|
||||
const sentIds = new Set(own.map(t => t.id));
|
||||
tickets = tickets.filter(t => !sentIds.has(t.id));
|
||||
if (tickets.length === 0 && own.length > 0) {
|
||||
return res.json({ message: `${own.length} ticket(s) sent to the people they're for`, ticketIds: own.map(t => t.id) });
|
||||
}
|
||||
}
|
||||
|
||||
// Dedup by registrationOptionId — except named tickets, which are one per person.
|
||||
{
|
||||
const seen = new Map(); const deduped = [];
|
||||
for (const t of tickets) {
|
||||
const key = t.registrationOptionId;
|
||||
const key = t.attendeeId ? `attendee:${t.attendeeId}` : t.registrationOptionId;
|
||||
if (!seen.has(key)) { seen.set(key, t); deduped.push(t); }
|
||||
else {
|
||||
const existing = seen.get(key);
|
||||
@@ -726,10 +793,10 @@ async function emailTicketsInternal(req, res, userOverride) {
|
||||
doc.font('Helvetica').fontSize(10);
|
||||
const variantSuffix = ticket.registrationOption.variant ? ` — ${ticket.registrationOption.variant.name}` : '';
|
||||
doc.text(`Ticket Type: ${ticket.registrationOption.eventOption.name}${variantSuffix}`, x + 10, y + 30, { width: ticketWidth - 20 });
|
||||
doc.text(`Qty: ${ticket.quantity || 1}`, x + 10, y + 45, { width: ticketWidth - 20 });
|
||||
doc.text(ticket.attendee ? `Name: ${ticket.attendee.name}` : `Qty: ${ticket.quantity || 1}`, x + 10, y + 45, { width: ticketWidth - 20 });
|
||||
const eventDate = new Date(ticket.event.startDate);
|
||||
doc.text(`Date: ${new Intl.DateTimeFormat('en-GB', { day: '2-digit', month: 'long', year: 'numeric' }).format(eventDate)}`, x + 10, y + 60, { width: ticketWidth - 20 });
|
||||
doc.text(`Purchased by: ${user.name}`, x + 10, y + 75, { width: ticketWidth - 20 });
|
||||
doc.text(`Purchased by: ${ticket.user?.name || user.name}`, x + 10, y + 75, { width: ticketWidth - 20 });
|
||||
const qrCodeSize = Math.min(ticketWidth, ticketHeight) * 0.45;
|
||||
const qrX = x + (ticketWidth - qrCodeSize) / 2; const qrY = y + 90;
|
||||
doc.image(qrCodeMap[ticket.id], qrX, qrY, { width: qrCodeSize, height: qrCodeSize });
|
||||
|
||||
@@ -1,7 +1,15 @@
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const crypto = require('crypto');
|
||||
const multer = require('multer');
|
||||
|
||||
// Builds a filename multer can never be tricked into escaping the upload
|
||||
// directory with — extension only, no attacker-controlled path segments.
|
||||
// (file.originalname is untrusted; joining it into a path allows `../` traversal.)
|
||||
function safeFilename(prefix, ext) {
|
||||
return `${prefix}-${Date.now()}-${crypto.randomBytes(8).toString('hex')}${ext}`;
|
||||
}
|
||||
|
||||
// Setup multer storage
|
||||
const storage = multer.diskStorage({
|
||||
destination: function (req, file, cb) {
|
||||
@@ -24,8 +32,7 @@ const storage = multer.diskStorage({
|
||||
}
|
||||
},
|
||||
filename: function (req, file, cb) {
|
||||
const uniqueName = `${Date.now()}-${file.originalname}`;
|
||||
cb(null, uniqueName);
|
||||
cb(null, safeFilename('event', path.extname(file.originalname).toLowerCase()));
|
||||
}
|
||||
});
|
||||
|
||||
@@ -54,7 +61,7 @@ const logoStorage = multer.diskStorage({
|
||||
}
|
||||
},
|
||||
filename: function (req, file, cb) {
|
||||
cb(null, `logo-${Date.now()}${path.extname(file.originalname).toLowerCase()}`);
|
||||
cb(null, safeFilename('logo', path.extname(file.originalname).toLowerCase()));
|
||||
}
|
||||
});
|
||||
|
||||
@@ -70,6 +77,34 @@ const uploadLogo = multer({
|
||||
}
|
||||
});
|
||||
|
||||
// Favicon storage (same subfolder as the logo — both are "branding" assets)
|
||||
const faviconStorage = multer.diskStorage({
|
||||
destination: function (req, file, cb) {
|
||||
const uploadPath = path.join(__dirname, '..', '..', 'public', 'uploads', 'branding');
|
||||
try {
|
||||
if (!fs.existsSync(uploadPath)) fs.mkdirSync(uploadPath, { recursive: true });
|
||||
cb(null, uploadPath);
|
||||
} catch (error) {
|
||||
cb(new Error(`Cannot access upload directory: ${error.message}`));
|
||||
}
|
||||
},
|
||||
filename: function (req, file, cb) {
|
||||
cb(null, safeFilename('favicon', path.extname(file.originalname).toLowerCase()));
|
||||
}
|
||||
});
|
||||
|
||||
const uploadFavicon = multer({
|
||||
storage: faviconStorage,
|
||||
limits: { fileSize: 2 * 1024 * 1024 }, // 2 MB
|
||||
fileFilter: function (req, file, cb) {
|
||||
const ext = path.extname(file.originalname).toLowerCase();
|
||||
if (!['.ico', '.png', '.svg'].includes(ext)) {
|
||||
return cb(new Error('Only .ico, .png, or .svg files are allowed for the favicon'), false);
|
||||
}
|
||||
cb(null, true);
|
||||
}
|
||||
});
|
||||
|
||||
// Controller function
|
||||
const uploadEventImage = (req, res) => {
|
||||
// Check for multer errors which would be passed in req.multerError
|
||||
@@ -95,9 +130,21 @@ const uploadLogoImage = (req, res) => {
|
||||
res.status(200).json({ url: `/uploads/branding/${req.file.filename}` });
|
||||
};
|
||||
|
||||
const uploadFaviconImage = (req, res) => {
|
||||
if (req.multerError) {
|
||||
return res.status(500).json({ message: `Upload failed: ${req.multerError.message}` });
|
||||
}
|
||||
if (!req.file) {
|
||||
return res.status(400).json({ message: 'No file uploaded' });
|
||||
}
|
||||
res.status(200).json({ url: `/uploads/branding/${req.file.filename}` });
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
upload,
|
||||
uploadEventImage,
|
||||
uploadLogo,
|
||||
uploadLogoImage,
|
||||
uploadFavicon,
|
||||
uploadFaviconImage,
|
||||
};
|
||||
|
||||
@@ -3,10 +3,29 @@ const { generateToken, hashPassword, comparePassword } = require('../config/auth
|
||||
const { v4: uuidv4 } = require('uuid');
|
||||
const { safeErrorMessage } = require('../utils/errorUtils');
|
||||
const { logSecurityEvent, getRecentSecurityEvents } = require('../utils/securityEvents');
|
||||
const { getSettingSync } = require('../utils/settingsCache');
|
||||
const {
|
||||
METHODS: TWO_FACTOR_METHODS,
|
||||
availableMethods,
|
||||
createChallenge,
|
||||
verifyChallenge,
|
||||
resendChallenge,
|
||||
issueTrustedDevice,
|
||||
isTrustedDevice,
|
||||
clearTrustedDevices,
|
||||
requiresTwoFactor,
|
||||
enforcementMethod,
|
||||
} = require('../utils/twoFactor');
|
||||
const axios = require('axios');
|
||||
|
||||
// ─── Helpers ────────────────────────────────────────────────────────────────
|
||||
|
||||
// Org name for email subjects — falls back to Cross Code (the platform vendor)
|
||||
// rather than any specific customer's name when unconfigured.
|
||||
function getOrgName() {
|
||||
return getSettingSync('org_name', process.env.ORG_NAME || 'Cross Code');
|
||||
}
|
||||
|
||||
// Resolve a client IP from the request (works behind proxies)
|
||||
function getClientIp(req) {
|
||||
const forwarded = req.headers['x-forwarded-for'];
|
||||
@@ -16,6 +35,53 @@ function getClientIp(req) {
|
||||
|
||||
const PRIVATE_IP_RE = /^(::1|::ffff:127\.|127\.|10\.|172\.(1[6-9]|2\d|3[01])\.|192\.168\.)/;
|
||||
|
||||
// Fire-and-forget: create a 24h activation token and deliver it to an inactive
|
||||
// account — via email if it has a real (non-guest) address, otherwise via
|
||||
// WhatsApp if it has a phone number. Used both when a login attempt hits an
|
||||
// inactive account, and immediately when an admin/supervisor creates an
|
||||
// account on someone's behalf (walk-in / manual registration).
|
||||
async function sendActivationLink(user) {
|
||||
const hasRealEmail = !!(user?.email && !user.email.endsWith('@guest.local'));
|
||||
if (!hasRealEmail && !user?.phoneNumber) return;
|
||||
try {
|
||||
const token = uuidv4();
|
||||
const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000); // 24h
|
||||
await prisma.passwordReset.updateMany({
|
||||
where: { userId: user.id, used: false },
|
||||
data: { used: true }
|
||||
});
|
||||
await prisma.passwordReset.create({
|
||||
data: { id: uuidv4(), userId: user.id, token, expiresAt, used: false }
|
||||
});
|
||||
const baseUrl = process.env.FRONTEND_URL || process.env.APP_BASE_URL || 'http://localhost:3001';
|
||||
const activationUrl = `${baseUrl.replace(/\/$/, '')}/activate-account?token=${encodeURIComponent(token)}`;
|
||||
|
||||
if (hasRealEmail) {
|
||||
const { sendMail, buildAccountActivationEmail } = require('../utils/email');
|
||||
const content = buildAccountActivationEmail({ name: user.name, activationUrl });
|
||||
sendMail({ to: user.email, subject: `Activate your ${getOrgName()} account`, ...content })
|
||||
.catch(e => console.warn('[activation email] Failed:', e?.message || e));
|
||||
} else {
|
||||
const orgName = getOrgName();
|
||||
const waMessage = [
|
||||
`🔓 *Activate your ${orgName} account*`,
|
||||
'',
|
||||
`Hi ${user.name || 'there'},`,
|
||||
'',
|
||||
`Your account needs to be activated before you can log in. Tap the link below to set a password and activate your account:`,
|
||||
'',
|
||||
activationUrl,
|
||||
'',
|
||||
`_This link expires in 24 hours._`,
|
||||
].join('\n');
|
||||
const { waTextAny } = require('../utils/notify');
|
||||
waTextAny(user, waMessage).catch(e => console.warn('[activation WA] Failed:', e?.message || e));
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[activation token] Failed to create activation token:', e?.message || e);
|
||||
}
|
||||
}
|
||||
|
||||
// Fire-and-forget: send a login notification email with approximate geo location
|
||||
async function sendLoginNotification(user, req) {
|
||||
try {
|
||||
@@ -41,7 +107,7 @@ async function sendLoginNotification(user, req) {
|
||||
const { buildWALogin } = require('../utils/waMessages');
|
||||
const content = buildLoginNotificationEmail({ name: user.name, when, location, userAgent });
|
||||
// Security: always email; also WhatsApp if preferred
|
||||
await sendMail({ to: user.email, subject: 'New login to your Hope Events account', ...content });
|
||||
await sendMail({ to: user.email, subject: `New login to your ${getOrgName()} account`, ...content });
|
||||
const { waText } = require('../utils/notify');
|
||||
await waText(user, buildWALogin({ name: user.name, when, location, userAgent })).catch(() => {});
|
||||
} catch (e) {
|
||||
@@ -63,13 +129,42 @@ async function sendWelcomeEmail(user) {
|
||||
const content = buildWelcomeEmail({ name: user.name, events });
|
||||
const { shouldEmail, waText } = require('../utils/notify');
|
||||
// Welcome is always sent via email; also via WhatsApp if preferred
|
||||
await sendMail({ to: user.email, subject: 'Welcome to Hope Events!', ...content });
|
||||
await sendMail({ to: user.email, subject: `Welcome to ${getOrgName()}!`, ...content });
|
||||
await waText(user, buildWAWelcome({ name: user.name, events })).catch(() => {});
|
||||
} catch (e) {
|
||||
console.warn('[welcome email] Failed:', e?.message || e);
|
||||
}
|
||||
}
|
||||
|
||||
// Fields the login flow needs once the password (and any 2FA code) has checked out.
|
||||
const LOGIN_USER_SELECT = {
|
||||
id: true, name: true, email: true, role: true, tokenVersion: true, isActive: true,
|
||||
phoneNumber: true, notificationPreference: true, twoFactorEnabled: true, twoFactorMethod: true,
|
||||
};
|
||||
|
||||
// Errors from utils/twoFactor carry their own HTTP status; everything else follows
|
||||
// the controller convention of res.status(...) before throwing.
|
||||
function errorStatus(error, res) {
|
||||
return error?.statusCode || (res.statusCode === 200 ? 400 : res.statusCode);
|
||||
}
|
||||
|
||||
// Final step of a successful login (after the password, and the 2FA code when
|
||||
// required): notify, record the activity, and issue the session token.
|
||||
function completeLogin(user, req, res, extra = {}) {
|
||||
// Send login notification in the background
|
||||
sendLoginNotification(user, req).catch(() => {});
|
||||
logSecurityEvent({ userId: user.id, type: 'login', ip: getClientIp(req), userAgent: req.headers['user-agent'] }).catch(() => {});
|
||||
|
||||
res.json({
|
||||
id: user.id,
|
||||
name: user.name,
|
||||
email: user.email,
|
||||
role: user.role,
|
||||
token: generateToken(user.id, user.role, user.tokenVersion),
|
||||
...extra
|
||||
});
|
||||
}
|
||||
|
||||
// @desc Register a new user
|
||||
// @route POST /api/users
|
||||
// @access Public
|
||||
@@ -144,7 +239,7 @@ const registerUser = async (req, res) => {
|
||||
// @access Public
|
||||
const loginUser = async (req, res) => {
|
||||
try {
|
||||
const {email, password} = req.body;
|
||||
const {email, password, deviceToken} = req.body;
|
||||
|
||||
const rawInput = email;
|
||||
|
||||
@@ -169,61 +264,16 @@ const loginUser = async (req, res) => {
|
||||
|
||||
// Check if user is active
|
||||
if (!user.isActive) {
|
||||
// If the account has a real email (not a guest placeholder), send an activation link via email
|
||||
// Resend the activation link on each failed login attempt against an inactive
|
||||
// account, in case the original one (sent at creation, or a prior attempt) expired.
|
||||
await sendActivationLink(user);
|
||||
// If the account has a real email (not a guest placeholder), it went out via email
|
||||
if (user.email && !user.email.endsWith('@guest.local')) {
|
||||
try {
|
||||
const token = uuidv4();
|
||||
const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000); // 24h
|
||||
await prisma.passwordReset.updateMany({
|
||||
where: { userId: user.id, used: false },
|
||||
data: { used: true }
|
||||
});
|
||||
await prisma.passwordReset.create({
|
||||
data: { id: uuidv4(), userId: user.id, token, expiresAt, used: false }
|
||||
});
|
||||
const baseUrl = process.env.FRONTEND_URL || process.env.APP_BASE_URL || 'http://localhost:3001';
|
||||
const activationUrl = `${baseUrl.replace(/\/$/, '')}/activate-account?token=${encodeURIComponent(token)}`;
|
||||
const { sendMail, buildAccountActivationEmail } = require('../utils/email');
|
||||
const content = buildAccountActivationEmail({ name: user.name, activationUrl });
|
||||
sendMail({ to: user.email, subject: 'Activate your Hope Events account', ...content })
|
||||
.catch(e => console.warn('[activation email] Failed:', e?.message || e));
|
||||
} catch (e) {
|
||||
console.warn('[activation token] Failed to create activation token:', e?.message || e);
|
||||
}
|
||||
res.status(401);
|
||||
throw new Error('Your account is not yet active. We\'ve sent you an email with a link to activate your account.');
|
||||
}
|
||||
// No real email — if they have a phone number, send the activation link via WhatsApp
|
||||
// No real email — if they have a phone number, it went out via WhatsApp
|
||||
if (user.phoneNumber) {
|
||||
try {
|
||||
const token = uuidv4();
|
||||
const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000); // 24h
|
||||
await prisma.passwordReset.updateMany({
|
||||
where: { userId: user.id, used: false },
|
||||
data: { used: true }
|
||||
});
|
||||
await prisma.passwordReset.create({
|
||||
data: { id: uuidv4(), userId: user.id, token, expiresAt, used: false }
|
||||
});
|
||||
const baseUrl = process.env.FRONTEND_URL || process.env.APP_BASE_URL || 'http://localhost:3001';
|
||||
const activationUrl = `${baseUrl.replace(/\/$/, '')}/activate-account?token=${encodeURIComponent(token)}`;
|
||||
const orgName = require('../utils/settingsCache').getSettingSync('org_name', process.env.ORG_NAME || 'Hope Events');
|
||||
const waMessage = [
|
||||
`🔓 *Activate your ${orgName} account*`,
|
||||
'',
|
||||
`Hi ${user.name || 'there'},`,
|
||||
'',
|
||||
`Your account needs to be activated before you can log in. Tap the link below to set a password and activate your account:`,
|
||||
'',
|
||||
activationUrl,
|
||||
'',
|
||||
`_This link expires in 24 hours._`,
|
||||
].join('\n');
|
||||
const { waTextAny } = require('../utils/notify');
|
||||
waTextAny(user, waMessage).catch(e => console.warn('[activation WA] Failed:', e?.message || e));
|
||||
} catch (e) {
|
||||
console.warn('[activation token WA] Failed to create activation token:', e?.message || e);
|
||||
}
|
||||
res.status(401);
|
||||
throw new Error('Your account is not yet active. We\'ve sent you a WhatsApp message with a link to activate your account.');
|
||||
}
|
||||
@@ -269,20 +319,179 @@ const loginUser = async (req, res) => {
|
||||
const updated = await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: { failedAttempts: 0, lockUntil: null },
|
||||
select: { id: true, name: true, email: true, role: true, tokenVersion: true, phoneNumber: true, notificationPreference: true },
|
||||
select: LOGIN_USER_SELECT,
|
||||
});
|
||||
|
||||
// Send login notification in the background
|
||||
sendLoginNotification(updated, req).catch(() => {});
|
||||
logSecurityEvent({ userId: updated.id, type: 'login', ip: getClientIp(req), userAgent: req.headers['user-agent'] }).catch(() => {});
|
||||
// Two-factor: the password alone isn't enough unless this browser was
|
||||
// remembered earlier. Send a code and hand back a challenge instead of a token.
|
||||
if (updated.twoFactorEnabled && !(await isTrustedDevice(updated.id, deviceToken))) {
|
||||
const challenge = await createChallenge(updated, 'login', updated.twoFactorMethod || 'email');
|
||||
return res.json({ twoFactorRequired: true, ...challenge });
|
||||
}
|
||||
|
||||
res.json({
|
||||
id: updated.id,
|
||||
name: updated.name,
|
||||
email: updated.email,
|
||||
role: updated.role,
|
||||
token: generateToken(updated.id, updated.role, updated.tokenVersion)
|
||||
// Admins and supervisors must use 2FA. Not set up yet: send a code to the
|
||||
// first channel that can deliver one, and verifying it turns 2FA on. A
|
||||
// trusted device can't skip this — nothing has been verified yet.
|
||||
if (!updated.twoFactorEnabled && requiresTwoFactor(updated)) {
|
||||
const method = await enforcementMethod(updated);
|
||||
if (method) {
|
||||
const challenge = await createChallenge(updated, 'login', method);
|
||||
return res.json({ twoFactorRequired: true, enrolling: true, ...challenge });
|
||||
}
|
||||
}
|
||||
|
||||
completeLogin(updated, req, res);
|
||||
} catch (error) {
|
||||
res.status(errorStatus(error, res)).json({ message: safeErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Second login step — exchange an emailed/WhatsApp code for a token
|
||||
// @route POST /api/users/login/2fa
|
||||
// @access Public
|
||||
const verifyLoginTwoFactor = async (req, res) => {
|
||||
try {
|
||||
const { challengeId, code, rememberDevice } = req.body || {};
|
||||
const challenge = await verifyChallenge(challengeId, code, 'login');
|
||||
|
||||
let user = await prisma.user.findUnique({ where: { id: challenge.userId }, select: LOGIN_USER_SELECT });
|
||||
if (!user || !user.isActive) {
|
||||
res.status(401);
|
||||
throw new Error('Your account has been deactivated');
|
||||
}
|
||||
|
||||
// First login since 2FA became required for this role: the code proves the
|
||||
// channel works, so turn 2FA on with it.
|
||||
if (!user.twoFactorEnabled && requiresTwoFactor(user)) {
|
||||
user = await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: { twoFactorEnabled: true, twoFactorMethod: challenge.method, updatedAt: new Date() },
|
||||
select: LOGIN_USER_SELECT,
|
||||
});
|
||||
logSecurityEvent({ userId: user.id, type: 'two_factor_enabled', ip: getClientIp(req), userAgent: req.headers['user-agent'] }).catch(() => {});
|
||||
}
|
||||
|
||||
const extra = {};
|
||||
if (rememberDevice === true) {
|
||||
extra.deviceToken = await issueTrustedDevice(user.id, req.headers['user-agent']);
|
||||
}
|
||||
completeLogin(user, req, res, extra);
|
||||
} catch (error) {
|
||||
res.status(errorStatus(error, res)).json({ message: safeErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Send a new code for a pending login challenge
|
||||
// @route POST /api/users/login/2fa/resend
|
||||
// @access Public
|
||||
const resendLoginTwoFactor = async (req, res) => {
|
||||
try {
|
||||
const { challengeId } = req.body || {};
|
||||
res.json(await resendChallenge(challengeId, 'login'));
|
||||
} catch (error) {
|
||||
res.status(errorStatus(error, res)).json({ message: safeErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Start turning on 2FA — sends a code to the chosen channel to prove it works
|
||||
// @route POST /api/users/2fa/enable
|
||||
// @access Private
|
||||
const startEnableTwoFactor = async (req, res) => {
|
||||
try {
|
||||
const { method, password } = req.body || {};
|
||||
const user = await prisma.user.findUnique({ where: { id: req.user.id } });
|
||||
if (!user) { res.status(404); throw new Error('User not found'); }
|
||||
|
||||
if (typeof password !== 'string' || !(await comparePassword(password, user.password))) {
|
||||
res.status(400);
|
||||
throw new Error('Incorrect password');
|
||||
}
|
||||
if (!TWO_FACTOR_METHODS.includes(method)) {
|
||||
res.status(400);
|
||||
throw new Error('Choose email or WhatsApp for your verification codes');
|
||||
}
|
||||
if (!(await availableMethods(user)).includes(method)) {
|
||||
res.status(400);
|
||||
throw new Error(method === 'whatsapp'
|
||||
? 'WhatsApp codes aren\'t available for your account. Add a valid phone number or choose email.'
|
||||
: 'Add a valid email address to your profile before turning on two-factor authentication.');
|
||||
}
|
||||
|
||||
// Unlike login, don't fall back to email here — the point of this step is to
|
||||
// prove the channel the user picked actually reaches them.
|
||||
res.json(await createChallenge(user, 'enable', method, { allowFallback: false }));
|
||||
} catch (error) {
|
||||
res.status(errorStatus(error, res)).json({ message: safeErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Finish turning on 2FA with the code that was sent
|
||||
// @route POST /api/users/2fa/confirm
|
||||
// @access Private
|
||||
const confirmEnableTwoFactor = async (req, res) => {
|
||||
try {
|
||||
const { challengeId, code } = req.body || {};
|
||||
const challenge = await verifyChallenge(challengeId, code, 'enable', req.user.id);
|
||||
|
||||
await prisma.user.update({
|
||||
where: { id: req.user.id },
|
||||
data: { twoFactorEnabled: true, twoFactorMethod: challenge.method, updatedAt: new Date() },
|
||||
});
|
||||
logSecurityEvent({ userId: req.user.id, type: 'two_factor_enabled', ip: getClientIp(req), userAgent: req.headers['user-agent'] }).catch(() => {});
|
||||
|
||||
res.json({ twoFactorEnabled: true, twoFactorMethod: challenge.method });
|
||||
} catch (error) {
|
||||
res.status(errorStatus(error, res)).json({ message: safeErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Turn off 2FA
|
||||
// @route POST /api/users/2fa/disable
|
||||
// @access Private
|
||||
const disableTwoFactor = async (req, res) => {
|
||||
try {
|
||||
const { password } = req.body || {};
|
||||
const user = await prisma.user.findUnique({ where: { id: req.user.id } });
|
||||
if (!user) { res.status(404); throw new Error('User not found'); }
|
||||
|
||||
if (requiresTwoFactor(user)) {
|
||||
res.status(403);
|
||||
throw new Error("Two-factor authentication is required for your role and can't be turned off.");
|
||||
}
|
||||
|
||||
if (typeof password !== 'string' || !(await comparePassword(password, user.password))) {
|
||||
res.status(400);
|
||||
throw new Error('Incorrect password');
|
||||
}
|
||||
|
||||
await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: { twoFactorEnabled: false, twoFactorMethod: null, updatedAt: new Date() },
|
||||
});
|
||||
await clearTrustedDevices(user.id);
|
||||
if (user.twoFactorEnabled) {
|
||||
logSecurityEvent({ userId: user.id, type: 'two_factor_disabled', ip: getClientIp(req), userAgent: req.headers['user-agent'] }).catch(() => {});
|
||||
}
|
||||
|
||||
res.json({ twoFactorEnabled: false, twoFactorMethod: null });
|
||||
} catch (error) {
|
||||
res.status(errorStatus(error, res)).json({ message: safeErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Re-confirm the logged-in user's password without starting a new session
|
||||
// (the self-service kiosk uses this before letting the event be changed)
|
||||
// @route POST /api/users/verify-password
|
||||
// @access Private
|
||||
const verifyPassword = async (req, res) => {
|
||||
try {
|
||||
const { password } = req.body || {};
|
||||
const user = await prisma.user.findUnique({ where: { id: req.user.id }, select: { password: true } });
|
||||
if (!user || typeof password !== 'string' || !(await comparePassword(password, user.password))) {
|
||||
res.status(401);
|
||||
throw new Error('Incorrect password');
|
||||
}
|
||||
res.json({ ok: true });
|
||||
} catch (error) {
|
||||
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
|
||||
}
|
||||
@@ -302,6 +511,8 @@ const getUserProfile = async (req, res) => {
|
||||
role: true,
|
||||
phoneNumber: true,
|
||||
notificationPreference: true,
|
||||
twoFactorEnabled: true,
|
||||
twoFactorMethod: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
isActive: true
|
||||
@@ -309,7 +520,7 @@ const getUserProfile = async (req, res) => {
|
||||
});
|
||||
|
||||
if (user) {
|
||||
res.json(user);
|
||||
res.json({ ...user, twoFactorRequired: requiresTwoFactor(user) });
|
||||
} else {
|
||||
res.status(404);
|
||||
throw new Error('User not found');
|
||||
@@ -366,6 +577,11 @@ const updateUserProfile = async (req, res) => {
|
||||
const { resolveNotificationPreference } = require('../utils/notificationPreference');
|
||||
const newPref = resolveNotificationPreference(notificationPreference, newPhone, user.notificationPreference);
|
||||
|
||||
// 2FA codes can't go to WhatsApp without a usable number — move them to email
|
||||
// rather than leave the account pointing at a channel that no longer exists.
|
||||
const { isValidZAPhone } = require('../utils/whatsapp');
|
||||
const newTwoFactorMethod = user.twoFactorMethod === 'whatsapp' && !isValidZAPhone(newPhone) ? 'email' : user.twoFactorMethod;
|
||||
|
||||
// Update user data
|
||||
const updatedUser = await prisma.user.update({
|
||||
where: { id: req.user.id },
|
||||
@@ -375,6 +591,7 @@ const updateUserProfile = async (req, res) => {
|
||||
password: newHashedPassword ? newHashedPassword : user.password,
|
||||
phoneNumber: newPhone,
|
||||
notificationPreference: newPref,
|
||||
twoFactorMethod: newTwoFactorMethod,
|
||||
updatedAt: new Date()
|
||||
},
|
||||
select: {
|
||||
@@ -384,6 +601,8 @@ const updateUserProfile = async (req, res) => {
|
||||
role: true,
|
||||
phoneNumber: true,
|
||||
notificationPreference: true,
|
||||
twoFactorEnabled: true,
|
||||
twoFactorMethod: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
isActive: true,
|
||||
@@ -393,11 +612,11 @@ const updateUserProfile = async (req, res) => {
|
||||
|
||||
// If the password was changed, send a security alert email (fire-and-forget)
|
||||
if (newHashedPassword) {
|
||||
await clearTrustedDevices(updatedUser.id);
|
||||
const { sendMail, buildPasswordChangedEmail } = require('../utils/email');
|
||||
const { getSettingSync } = require('../utils/settingsCache');
|
||||
const supportEmail = getSettingSync('org_email', process.env.EMAIL_FROM || '');
|
||||
const content = buildPasswordChangedEmail({ name: updatedUser.name, when: Date.now(), supportEmail });
|
||||
sendMail({ to: updatedUser.email, subject: 'Your Hope Events password was changed', ...content })
|
||||
sendMail({ to: updatedUser.email, subject: `Your ${getOrgName()} password was changed`, ...content })
|
||||
.catch(e => console.warn('[email] Failed to send password changed alert:', e?.message || e));
|
||||
const { waText } = require('../utils/notify');
|
||||
waText(updatedUser, content.text).catch(() => {});
|
||||
@@ -406,6 +625,7 @@ const updateUserProfile = async (req, res) => {
|
||||
|
||||
res.json({
|
||||
...updatedUser,
|
||||
twoFactorRequired: requiresTwoFactor(updatedUser),
|
||||
token: generateToken(updatedUser.id, updatedUser.role, updatedUser.tokenVersion)
|
||||
});
|
||||
} catch (error) {
|
||||
@@ -424,7 +644,7 @@ const getUsers = async (req, res) => {
|
||||
|
||||
const select = {
|
||||
id: true, name: true, email: true, role: true,
|
||||
phoneNumber: true, notificationPreference: true, createdAt: true, updatedAt: true, isActive: true
|
||||
phoneNumber: true, notificationPreference: true, twoFactorEnabled: true, createdAt: true, updatedAt: true, isActive: true
|
||||
};
|
||||
|
||||
// Build filters
|
||||
@@ -551,7 +771,7 @@ const updateUser = async (req, res) => {
|
||||
throw new Error('User not found');
|
||||
}
|
||||
|
||||
const { name, email, role, isActive, phoneNumber, password, notificationPreference } = req.body;
|
||||
const { name, email, role, isActive, phoneNumber, password, notificationPreference, twoFactorEnabled } = req.body;
|
||||
|
||||
const newPhone = phoneNumber !== undefined ? (phoneNumber || null) : user.phoneNumber;
|
||||
const { resolveNotificationPreference } = require('../utils/notificationPreference');
|
||||
@@ -572,6 +792,21 @@ const updateUser = async (req, res) => {
|
||||
data.password = await hashPassword(password.trim());
|
||||
}
|
||||
|
||||
// Admins can switch 2FA off for someone who has lost access to their codes,
|
||||
// but never on — enabling requires the user to prove the channel themselves.
|
||||
const resetTwoFactor = twoFactorEnabled === false && user.twoFactorEnabled;
|
||||
if (resetTwoFactor) {
|
||||
data.twoFactorEnabled = false;
|
||||
data.twoFactorMethod = null;
|
||||
}
|
||||
|
||||
// Promoted to a role that requires 2FA without having it: end their sessions
|
||||
// so the next login enrols them.
|
||||
const willHaveTwoFactor = resetTwoFactor ? false : user.twoFactorEnabled;
|
||||
if (!willHaveTwoFactor && data.role !== user.role && requiresTwoFactor({ role: data.role })) {
|
||||
data.tokenVersion = { increment: 1 };
|
||||
}
|
||||
|
||||
const updatedUser = await prisma.user.update({
|
||||
where: { id: req.params.id },
|
||||
data,
|
||||
@@ -582,12 +817,20 @@ const updateUser = async (req, res) => {
|
||||
role: true,
|
||||
phoneNumber: true,
|
||||
notificationPreference: true,
|
||||
twoFactorEnabled: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
isActive: true
|
||||
}
|
||||
});
|
||||
|
||||
if (resetTwoFactor || data.password) {
|
||||
await clearTrustedDevices(updatedUser.id);
|
||||
}
|
||||
if (resetTwoFactor) {
|
||||
logSecurityEvent({ userId: updatedUser.id, type: 'two_factor_disabled', ip: getClientIp(req), userAgent: req.headers['user-agent'] }).catch(() => {});
|
||||
}
|
||||
|
||||
res.json(updatedUser);
|
||||
} catch (error) {
|
||||
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
|
||||
@@ -638,10 +881,14 @@ const anonymizeUser = async (req, res) => {
|
||||
email: `deleted-${req.params.id}@deleted.local`,
|
||||
phoneNumber: null,
|
||||
isActive: false,
|
||||
twoFactorEnabled: false,
|
||||
twoFactorMethod: null,
|
||||
tokenVersion: { increment: 1 },
|
||||
updatedAt: new Date()
|
||||
}
|
||||
});
|
||||
await clearTrustedDevices(req.params.id);
|
||||
await require('../utils/consent').clearConsentDataForUser(req.params.id);
|
||||
|
||||
res.json({ message: 'User data deleted' });
|
||||
} catch (error) {
|
||||
@@ -746,6 +993,7 @@ const resetPassword = async (req, res) => {
|
||||
prisma.user.update({ where: { id: user.id }, data: { password: newHashed, updatedAt: new Date() } }),
|
||||
prisma.passwordReset.update({ where: { token }, data: { used: true } })
|
||||
]);
|
||||
await clearTrustedDevices(user.id);
|
||||
|
||||
logSecurityEvent({ userId: user.id, type: 'password_reset', ip: getClientIp(req), userAgent: req.headers['user-agent'] }).catch(() => {});
|
||||
|
||||
@@ -830,6 +1078,7 @@ const revokeMySession = async (req, res) => {
|
||||
data: { tokenVersion: { increment: 1 } },
|
||||
select: { id: true, role: true, tokenVersion: true },
|
||||
});
|
||||
await clearTrustedDevices(updated.id);
|
||||
// Bumping tokenVersion invalidates every existing token, including the one
|
||||
// this request just used. Issue a fresh token for the current device so it
|
||||
// isn't logged out too.
|
||||
@@ -856,6 +1105,7 @@ const adminRevokeUserSessions = async (req, res) => {
|
||||
where: { id: req.params.id },
|
||||
data: { tokenVersion: { increment: 1 } },
|
||||
});
|
||||
await clearTrustedDevices(req.params.id);
|
||||
res.json({ message: `Sessions revoked for ${target.name}.` });
|
||||
} catch (error) {
|
||||
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
|
||||
@@ -893,17 +1143,21 @@ const closeAccount = async (req, res) => {
|
||||
password: '',
|
||||
phoneNumber: null,
|
||||
isActive: false,
|
||||
twoFactorEnabled: false,
|
||||
twoFactorMethod: null,
|
||||
tokenVersion: { increment: 1 },
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
});
|
||||
await clearTrustedDevices(req.user.id);
|
||||
await require('../utils/consent').clearConsentDataForUser(req.user.id);
|
||||
// Send closure confirmation to the real address (before it was wiped)
|
||||
if (realEmail && !realEmail.endsWith('@deleted.invalid') && !realEmail.endsWith('@guest.local')) {
|
||||
const { sendMail, buildAccountClosedEmail } = require('../utils/email');
|
||||
const { waText } = require('../utils/notify');
|
||||
const { buildWAAccountClosed } = require('../utils/waMessages');
|
||||
const content = buildAccountClosedEmail({ name: realName, dataDeleted: true });
|
||||
sendMail({ to: realEmail, subject: 'Your Hope Events account has been closed', ...content }).catch(() => {});
|
||||
sendMail({ to: realEmail, subject: `Your ${getOrgName()} account has been closed`, ...content }).catch(() => {});
|
||||
// WhatsApp while we still have phone (send before data wipe completes in-flight)
|
||||
waText(user, buildWAAccountClosed({ name: realName, dataDeleted: true })).catch(() => {});
|
||||
}
|
||||
@@ -918,13 +1172,14 @@ const closeAccount = async (req, res) => {
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
});
|
||||
await clearTrustedDevices(req.user.id);
|
||||
// Send closure confirmation
|
||||
if (realEmail && !realEmail.endsWith('@deleted.invalid') && !realEmail.endsWith('@guest.local')) {
|
||||
const { sendMail, buildAccountClosedEmail } = require('../utils/email');
|
||||
const { waText } = require('../utils/notify');
|
||||
const { buildWAAccountClosed } = require('../utils/waMessages');
|
||||
const content = buildAccountClosedEmail({ name: realName, dataDeleted: false });
|
||||
sendMail({ to: realEmail, subject: 'Your Hope Events account has been closed', ...content }).catch(() => {});
|
||||
sendMail({ to: realEmail, subject: `Your ${getOrgName()} account has been closed`, ...content }).catch(() => {});
|
||||
waText(user, buildWAAccountClosed({ name: realName, dataDeleted: false })).catch(() => {});
|
||||
}
|
||||
return res.json({ message: 'Your account has been deactivated.' });
|
||||
@@ -949,6 +1204,12 @@ const getMyActivity = async (req, res) => {
|
||||
module.exports = {
|
||||
registerUser,
|
||||
loginUser,
|
||||
verifyLoginTwoFactor,
|
||||
resendLoginTwoFactor,
|
||||
startEnableTwoFactor,
|
||||
confirmEnableTwoFactor,
|
||||
disableTwoFactor,
|
||||
verifyPassword,
|
||||
getUserProfile,
|
||||
updateUserProfile,
|
||||
getUsers,
|
||||
@@ -964,4 +1225,5 @@ module.exports = {
|
||||
adminRevokeUserSessions,
|
||||
closeAccount,
|
||||
getMyActivity,
|
||||
sendActivationLink,
|
||||
};
|
||||
@@ -0,0 +1,195 @@
|
||||
const prisma = require('../config/db');
|
||||
const { v4: uuidv4 } = require('uuid');
|
||||
const { safeErrorMessage } = require('../utils/errorUtils');
|
||||
const { findAccountByContact } = require('../utils/accountMatch');
|
||||
const { remainingFor } = require('../utils/stock');
|
||||
const { ACTIVE, makeOffer, queueWaitlist, findOfferByToken } = require('../utils/waitlist');
|
||||
|
||||
const fail = (res, error) => res.status(error.statusCode || (res.statusCode === 200 ? 400 : res.statusCode)).json({ message: safeErrorMessage(error) });
|
||||
|
||||
const ENTRY_INCLUDE = {
|
||||
event: { select: { id: true, title: true, startDate: true, picture: true } },
|
||||
eventOption: { select: { id: true, name: true } },
|
||||
variant: { select: { id: true, name: true } },
|
||||
};
|
||||
|
||||
// @desc Join the waitlist for a sold-out option
|
||||
// @route POST /api/waitlist
|
||||
// @access Public (login needed when the event requires it)
|
||||
// Body: { eventId, eventOptionId, variantId?, quantity, guestName?, guestEmail?, guestPhone? }
|
||||
const joinWaitlist = async (req, res) => {
|
||||
try {
|
||||
const { eventId, eventOptionId, variantId = null, quantity, guestName, guestEmail, guestPhone } = req.body || {};
|
||||
const qty = parseInt(quantity, 10);
|
||||
if (!eventId || !eventOptionId || !(qty >= 1)) { res.status(400); throw new Error('eventId, eventOptionId and a quantity of at least 1 are required'); }
|
||||
|
||||
const event = await prisma.event.findUnique({ where: { id: eventId }, include: { eventOptions: { include: { variants: true } } } });
|
||||
if (!event || !event.isActive) { res.status(404); throw new Error('Event not found'); }
|
||||
if (!event.waitlistEnabled) { res.status(400); throw new Error('This event does not have a waitlist.'); }
|
||||
if (event.cashupStatus === 'closed' || new Date(event.endDate) < new Date()) { res.status(400); throw new Error('This event is no longer taking registrations.'); }
|
||||
const option = event.eventOptions.find(o => o.id === eventOptionId);
|
||||
if (!option) { res.status(400); throw new Error('Unknown ticket option for this event'); }
|
||||
const variant = variantId ? (option.variants || []).find(v => v.id === variantId) : null;
|
||||
if (variantId && !variant) { res.status(400); throw new Error('Unknown variant for this option'); }
|
||||
|
||||
const free = await remainingFor(option, variant);
|
||||
if (free === null || free >= qty) { res.status(400); throw new Error('There are places available — you can register now.'); }
|
||||
|
||||
// Who's joining
|
||||
let userId = req.user?.id;
|
||||
if (!userId) {
|
||||
if (event.requiresAuth !== false) { res.status(401); throw new Error('Log in to join the waitlist for this event.'); }
|
||||
if (!guestName || !guestEmail) { res.status(400); throw new Error('Name and email are required to join the waitlist'); }
|
||||
const match = await findAccountByContact({ email: guestEmail, phone: guestPhone });
|
||||
if (match.conflict) { res.status(409); throw new Error('This email and phone number belong to two different accounts. Please log in, or check your details.'); }
|
||||
userId = match.user?.id || (await prisma.user.create({
|
||||
data: { id: uuidv4(), name: String(guestName).trim(), email: String(guestEmail).trim(), password: '', phoneNumber: guestPhone || null, isActive: false, updatedAt: new Date() },
|
||||
})).id;
|
||||
}
|
||||
|
||||
const existing = await prisma.waitlistEntry.findFirst({ where: { userId, eventOptionId, variantId, status: { in: ACTIVE } } });
|
||||
if (existing) { res.status(409); throw new Error('You\'re already on the waitlist for this.'); }
|
||||
|
||||
const last = await prisma.waitlistEntry.aggregate({ where: { eventId }, _max: { position: true } });
|
||||
const entry = await prisma.waitlistEntry.create({
|
||||
data: { eventId, eventOptionId, variantId, userId, quantity: qty, position: (last._max.position || 0) + 1 },
|
||||
include: ENTRY_INCLUDE,
|
||||
});
|
||||
const ahead = await prisma.waitlistEntry.count({ where: { eventId, eventOptionId, variantId, status: 'waiting', position: { lt: entry.position } } });
|
||||
res.status(201).json({ ...entry, ahead, message: `You're on the waitlist${ahead ? ` — ${ahead} ahead of you` : ''}. We'll email or WhatsApp you if a place opens up.` });
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
// @desc The caller's waitlist entries
|
||||
// @route GET /api/waitlist/mine
|
||||
// @access Private
|
||||
const myWaitlist = async (req, res) => {
|
||||
try {
|
||||
const entries = await prisma.waitlistEntry.findMany({
|
||||
where: { userId: req.user.id, status: { in: ACTIVE }, event: { endDate: { gte: new Date() } } },
|
||||
include: ENTRY_INCLUDE,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
res.json(entries);
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Leave the waitlist (an open offer is passed on)
|
||||
// @route DELETE /api/waitlist/:id
|
||||
// @access Private (owner)
|
||||
const leaveWaitlist = async (req, res) => {
|
||||
try {
|
||||
const entry = await prisma.waitlistEntry.findUnique({ where: { id: req.params.id } });
|
||||
if (!entry || entry.userId !== req.user.id) { res.status(404); throw new Error('Waitlist entry not found'); }
|
||||
if (!ACTIVE.includes(entry.status)) { res.status(400); throw new Error('You\'re no longer on this waitlist.'); }
|
||||
await prisma.waitlistEntry.update({ where: { id: entry.id }, data: { status: 'left', offerTokenHash: null } });
|
||||
if (entry.status === 'offered') queueWaitlist(entry.eventId);
|
||||
res.json({ message: 'You\'ve left the waitlist.' });
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
// @desc What a claim link offers
|
||||
// @route GET /api/waitlist/claim/:token
|
||||
// @access Public (the token is the credential)
|
||||
const claimInfo = async (req, res) => {
|
||||
try {
|
||||
const entry = await findOfferByToken(req.params.token);
|
||||
if (!entry) { res.status(404); throw new Error('This waitlist link is not valid or has already been used.'); }
|
||||
const expired = entry.status !== 'offered' || new Date(entry.offerExpiresAt) < new Date();
|
||||
res.json({
|
||||
status: expired ? 'expired' : 'offered',
|
||||
eventId: entry.eventId,
|
||||
eventTitle: entry.event.title,
|
||||
eventStart: entry.event.startDate,
|
||||
eventOptionId: entry.eventOptionId,
|
||||
optionName: entry.eventOption.name,
|
||||
variantId: entry.variantId,
|
||||
variantName: entry.variant?.name || null,
|
||||
quantity: entry.offeredQuantity,
|
||||
requested: entry.quantity,
|
||||
expiresAt: entry.offerExpiresAt,
|
||||
name: entry.user?.name,
|
||||
});
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
// @desc The waitlist for an event
|
||||
// @route GET /api/waitlist/events/:eventId
|
||||
// @access Private/Staff
|
||||
const eventWaitlist = async (req, res) => {
|
||||
try {
|
||||
const entries = await prisma.waitlistEntry.findMany({
|
||||
where: { eventId: req.params.eventId, ...(req.query.all === 'true' ? {} : { status: { in: [...ACTIVE, 'claimed', 'expired'] } }) },
|
||||
include: { ...ENTRY_INCLUDE, user: { select: { id: true, name: true, email: true, phoneNumber: true } } },
|
||||
orderBy: { position: 'asc' },
|
||||
});
|
||||
res.json(entries);
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Offer a place to someone on the waitlist now (even without free stock)
|
||||
// @route POST /api/waitlist/:id/offer
|
||||
// @access Private/Supervisor
|
||||
const offerManually = async (req, res) => {
|
||||
try {
|
||||
const entry = await prisma.waitlistEntry.findUnique({ where: { id: req.params.id } });
|
||||
if (!entry) { res.status(404); throw new Error('Waitlist entry not found'); }
|
||||
if (!['waiting', 'expired'].includes(entry.status)) { res.status(400); throw new Error(`This person is ${entry.status === 'offered' ? 'already holding an offer' : `no longer waiting (${entry.status})`}.`); }
|
||||
const qty = Math.max(1, Math.min(entry.quantity, parseInt(req.body?.quantity, 10) || entry.quantity));
|
||||
const updated = await makeOffer(entry.id, qty);
|
||||
res.json({ ...updated, message: `Offered ${qty} place(s); the claim link has been sent.` });
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Move someone up or down the queue
|
||||
// @route POST /api/waitlist/:id/move { direction: 'up' | 'down' }
|
||||
// @access Private/Supervisor
|
||||
const moveEntry = async (req, res) => {
|
||||
try {
|
||||
const entry = await prisma.waitlistEntry.findUnique({ where: { id: req.params.id } });
|
||||
if (!entry) { res.status(404); throw new Error('Waitlist entry not found'); }
|
||||
const up = req.body?.direction === 'up';
|
||||
const neighbour = await prisma.waitlistEntry.findFirst({
|
||||
where: { eventId: entry.eventId, status: 'waiting', position: up ? { lt: entry.position } : { gt: entry.position } },
|
||||
orderBy: { position: up ? 'desc' : 'asc' },
|
||||
});
|
||||
if (!neighbour) return res.json({ message: 'Already at the ' + (up ? 'front.' : 'back.') });
|
||||
await prisma.$transaction([
|
||||
prisma.waitlistEntry.update({ where: { id: entry.id }, data: { position: neighbour.position } }),
|
||||
prisma.waitlistEntry.update({ where: { id: neighbour.id }, data: { position: entry.position } }),
|
||||
]);
|
||||
res.json({ message: 'Moved.' });
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Take someone off the waitlist (an open offer is passed on)
|
||||
// @route POST /api/waitlist/:id/remove
|
||||
// @access Private/Supervisor
|
||||
const removeEntry = async (req, res) => {
|
||||
try {
|
||||
const entry = await prisma.waitlistEntry.findUnique({ where: { id: req.params.id } });
|
||||
if (!entry) { res.status(404); throw new Error('Waitlist entry not found'); }
|
||||
if (!ACTIVE.includes(entry.status)) { res.status(400); throw new Error('This person is not on the waitlist any more.'); }
|
||||
await prisma.waitlistEntry.update({ where: { id: entry.id }, data: { status: 'removed', offerTokenHash: null } });
|
||||
if (entry.status === 'offered') queueWaitlist(entry.eventId);
|
||||
res.json({ message: 'Removed from the waitlist.' });
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = { joinWaitlist, myWaitlist, leaveWaitlist, claimInfo, eventWaitlist, offerManually, moveEntry, removeEntry };
|
||||
@@ -447,7 +447,8 @@ const updateRegistrationStatus = async (registrationId) => {
|
||||
include: {
|
||||
earlyBirdTiers: true
|
||||
}
|
||||
}
|
||||
},
|
||||
tranches: true
|
||||
}
|
||||
},
|
||||
payments: true
|
||||
|
||||
@@ -195,13 +195,14 @@ const handleWebhook = async (req, res) => {
|
||||
const { getSettingSync } = require('../utils/settingsCache');
|
||||
const adminEmail = getSettingSync('smtp_from', process.env.EMAIL_FROM || process.env.EMAIL_USER || '')
|
||||
|| getSettingSync('org_email', process.env.EMAIL_FROM || process.env.EMAIL_USER || '');
|
||||
const orgName = getSettingSync('org_name', process.env.ORG_NAME || 'Cross Code');
|
||||
const dashboardUrl = `${(process.env.FRONTEND_URL || 'http://localhost:3000').replace(/\/$/, '')}/dashboard/admin/settings?tab=whatsapp`;
|
||||
const when = new Date().toLocaleString('en-ZA', { timeZone: 'Africa/Johannesburg' });
|
||||
await sendMail({
|
||||
to: adminEmail,
|
||||
subject: 'WhatsApp session is down — action required',
|
||||
text: `The Hope Events WhatsApp session has failed and could not be automatically recovered.\n\nTime: ${when}\n\nPlease visit the admin dashboard to reconnect:\n${dashboardUrl}`,
|
||||
html: `<p>The Hope Events WhatsApp session has failed and could not be automatically recovered after ${MAX_ATTEMPTS} attempts.</p><p><strong>Time:</strong> ${when}</p><p>Please <a href="${dashboardUrl}">visit the admin dashboard</a> to re-scan the QR code and reconnect.</p>`,
|
||||
text: `The ${orgName} WhatsApp session has failed and could not be automatically recovered.\n\nTime: ${when}\n\nPlease visit the admin dashboard to reconnect:\n${dashboardUrl}`,
|
||||
html: `<p>The ${orgName} WhatsApp session has failed and could not be automatically recovered after ${MAX_ATTEMPTS} attempts.</p><p><strong>Time:</strong> ${when}</p><p>Please <a href="${dashboardUrl}">visit the admin dashboard</a> to re-scan the QR code and reconnect.</p>`,
|
||||
}).catch(() => {});
|
||||
}
|
||||
} catch (e) {
|
||||
|
||||
@@ -164,7 +164,10 @@ const reconcileYocoTransaction = async (req, res) => {
|
||||
const createdAt = ytx.createdDate || ytx.createdAt || new Date();
|
||||
const payment = await prisma.payment.create({ data: { ...paymentData, createdAt } });
|
||||
|
||||
// Optionally update registration status when applicable and generate/email tickets if paid
|
||||
// Optionally update registration status when applicable and generate tickets if paid.
|
||||
// Ticket generation stays synchronous so `generatedTickets` can be included in the response;
|
||||
// emailing/WhatsApp-ing the tickets and payment confirmation are backgrounded below since
|
||||
// they involve slow PDF rendering + SMTP/WAWP round trips that shouldn't block this request.
|
||||
let generatedTickets = [];
|
||||
if (registration) {
|
||||
try {
|
||||
@@ -172,15 +175,6 @@ const reconcileYocoTransaction = async (req, res) => {
|
||||
if (updatedReg && updatedReg.status === 'paid') {
|
||||
try {
|
||||
generatedTickets = await generateTicketsForRegistration(registration.id);
|
||||
if (Array.isArray(generatedTickets) && generatedTickets.length > 0) {
|
||||
try {
|
||||
const mockReq = { user: { id: registration.userId }, body: { registrationId: registration.id } };
|
||||
const mockRes = { status: () => mockRes, json: () => {} };
|
||||
await emailTickets(mockReq, mockRes);
|
||||
} catch (emailErr) {
|
||||
console.error('Error emailing tickets after Yoco reconciliation:', emailErr);
|
||||
}
|
||||
}
|
||||
} catch (genErr) {
|
||||
console.error('Error generating tickets after Yoco reconciliation:', genErr);
|
||||
}
|
||||
@@ -191,13 +185,20 @@ const reconcileYocoTransaction = async (req, res) => {
|
||||
}
|
||||
}
|
||||
|
||||
// Send emails for the reconciled payment
|
||||
try {
|
||||
const { sendPaymentEmails } = require('../utils/notifications');
|
||||
await sendPaymentEmails(payment.id);
|
||||
} catch (e) {
|
||||
console.error('Failed to send payment emails after reconciliation:', e);
|
||||
}
|
||||
// Fire-and-forget: payment confirmation email, then ticket email/WhatsApp (guarantees order)
|
||||
const { sendPaymentEmails } = require('../utils/notifications');
|
||||
const _rxPaymentId = payment.id;
|
||||
const _rxUserId = registration?.userId;
|
||||
const _rxRegId = registration?.id;
|
||||
const _rxShouldEmailTickets = Array.isArray(generatedTickets) && generatedTickets.length > 0;
|
||||
(async () => {
|
||||
try { await sendPaymentEmails(_rxPaymentId); } catch (e) { console.error('Failed to send payment emails after reconciliation:', e); }
|
||||
if (_rxShouldEmailTickets && _rxUserId) {
|
||||
const mockReq = { user: { id: _rxUserId }, body: { registrationId: _rxRegId } };
|
||||
const mockRes = { status: () => mockRes, json: () => {} };
|
||||
try { await emailTickets(mockReq, mockRes); } catch (e) { console.error('Error emailing tickets after Yoco reconciliation:', e); }
|
||||
}
|
||||
})();
|
||||
|
||||
// Update yoco transaction as reconciled
|
||||
const updatedTx = await Yoco.update({
|
||||
|
||||
+277
-30
@@ -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,25 @@ app.use('/api/setup', setupRoutes);
|
||||
app.use('/api/stats', statsRoutes);
|
||||
app.use('/api', costRoutes);
|
||||
app.use('/api/cashups', cashupRoutes);
|
||||
app.use('/api/admin', adminRoutes);
|
||||
app.use('/api/coupons', require('./routes/couponRoutes'));
|
||||
app.use('/api/consent', require('./routes/consentRoutes'));
|
||||
app.use('/api/waitlist', require('./routes/waitlistRoutes'));
|
||||
app.use('/api/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 +294,9 @@ app.get('/', async (req, res) => {
|
||||
? `<span class="badge badge-warn">testing</span>`
|
||||
: `<span class="badge badge-warn">development</span>`;
|
||||
|
||||
const html = pageShell('Hope Events API — Status', '#2563eb', `
|
||||
<h1>Hope Events API</h1>
|
||||
const orgName = getSettingSync('org_name', process.env.ORG_NAME || 'Cross Code');
|
||||
const html = pageShell(`${orgName} Events API — Status`, '#2563eb', `
|
||||
<h1>${orgName} Events API</h1>
|
||||
<p class="subtitle">v${API_VERSION} — ${now}</p>
|
||||
|
||||
<div class="stat-grid">
|
||||
@@ -311,7 +362,7 @@ app.get('/docs', async (req, res) => {
|
||||
|
||||
let user;
|
||||
try {
|
||||
const decoded = jwt.verify(token, process.env.JWT_SECRET);
|
||||
const decoded = jwt.verify(token, process.env.JWT_SECRET, { algorithms: ['HS256'] });
|
||||
user = await prisma.user.findUnique({
|
||||
where: { id: decoded.id },
|
||||
select: { id: true, name: true, email: true, role: true, isActive: true, tokenVersion: true },
|
||||
@@ -343,8 +394,24 @@ app.get('/docs', async (req, res) => {
|
||||
request:{ body:{ email:'jane@example.com', password:'secret123' }},
|
||||
responses:[
|
||||
{ status:200, desc:'Success', body:{ id:'a1b2c3d4-...', name:'Jane Doe', email:'jane@example.com', role:'user', token:'eyJhbGciOiJIUzI1NiJ9...' }},
|
||||
{ status:200, desc:'Two-factor code sent (account has 2FA on) — no token yet', body:{ twoFactorRequired:true, challengeId:'c0ffee00-...', method:'email', destination:'j•••@example.com' }},
|
||||
{ status:200, desc:'Admin/supervisor without 2FA — code sent; verifying it turns 2FA on', body:{ twoFactorRequired:true, enrolling:true, challengeId:'c0ffee00-...', method:'email', destination:'j•••@example.com' }},
|
||||
{ status:401, desc:'Wrong credentials', body:{ message:'Invalid email or password' }},
|
||||
]},
|
||||
{ method:'POST', path:'/api/users/login/2fa', auth:'public', desc:'Second login step — exchange the emailed/WhatsApp code for a JWT',
|
||||
notes:'Codes expire after 10 minutes and allow 5 attempts. With rememberDevice, the response includes a deviceToken; send it with later logins to skip the code for 30 days.',
|
||||
request:{ body:{ challengeId:'c0ffee00-...', code:'123456', rememberDevice:true }},
|
||||
responses:[
|
||||
{ status:200, desc:'Success', body:{ id:'a1b2c3d4-...', name:'Jane Doe', email:'jane@example.com', role:'user', token:'eyJhbGciOiJIUzI1NiJ9...', deviceToken:'9f2c...' }},
|
||||
{ status:400, desc:'Wrong or expired code', body:{ message:'Incorrect code. 4 attempts left.' }},
|
||||
]},
|
||||
{ method:'POST', path:'/api/users/login/2fa/resend', auth:'public', desc:'Send a new code for a pending login challenge',
|
||||
notes:'At most one send per 60 seconds.',
|
||||
request:{ body:{ challengeId:'c0ffee00-...' }},
|
||||
responses:[
|
||||
{ status:200, desc:'Sent', body:{ challengeId:'c0ffee00-...', method:'email', destination:'j•••@example.com' }},
|
||||
{ status:429, desc:'Too soon', body:{ message:'Please wait 42 seconds before requesting another code.' }},
|
||||
]},
|
||||
{ method:'POST', path:'/api/users/forgot', auth:'public', desc:'Request a password reset email',
|
||||
request:{ body:{ email:'jane@example.com' }},
|
||||
responses:[
|
||||
@@ -376,6 +443,29 @@ app.get('/docs', async (req, res) => {
|
||||
responses:[{ status:200, desc:'Success', body:{ message:'All sessions revoked.' }}]},
|
||||
{ method:'POST', path:'/api/users/close-account', auth:'user+', desc:'Deactivate own account',
|
||||
responses:[{ status:200, desc:'Success', body:{ message:'Account deactivated.' }}]},
|
||||
{ method:'POST', path:'/api/users/2fa/enable', auth:'user+', desc:'Start turning on two-factor authentication — sends a code to the chosen channel',
|
||||
notes:'WhatsApp needs a valid phone number on the account and a connected WhatsApp session.',
|
||||
request:{ body:{ method:'email', password:'secret123' }},
|
||||
responses:[
|
||||
{ status:200, desc:'Code sent', body:{ challengeId:'c0ffee00-...', method:'email', destination:'j•••@example.com' }},
|
||||
{ status:400, desc:'Wrong password or unavailable channel', body:{ message:'Incorrect password' }},
|
||||
]},
|
||||
{ method:'POST', path:'/api/users/2fa/confirm', auth:'user+', desc:'Finish turning on two-factor authentication with the code that was sent',
|
||||
request:{ body:{ challengeId:'c0ffee00-...', code:'123456' }},
|
||||
responses:[{ status:200, desc:'Enabled', body:{ twoFactorEnabled:true, twoFactorMethod:'email' }}]},
|
||||
{ method:'POST', path:'/api/users/2fa/disable', auth:'user+', desc:'Turn off two-factor authentication',
|
||||
request:{ body:{ password:'secret123' }},
|
||||
notes:'Admins and supervisors must keep two-factor on — 403 for them.',
|
||||
responses:[
|
||||
{ status:200, desc:'Disabled', body:{ twoFactorEnabled:false, twoFactorMethod:null }},
|
||||
{ status:403, desc:'Required for this role', body:{ message:"Two-factor authentication is required for your role and can't be turned off." }},
|
||||
]},
|
||||
{ method:'POST', path:'/api/users/verify-password', auth:'user+', desc:'Re-confirm own password without starting a new session',
|
||||
request:{ body:{ password:'secret123' }},
|
||||
responses:[
|
||||
{ status:200, desc:'Correct', body:{ ok:true }},
|
||||
{ status:401, desc:'Wrong password', body:{ message:'Incorrect password' }},
|
||||
]},
|
||||
{ method:'GET', path:'/api/users', auth:'supervisor+', desc:'List all users (paginated). All filters are applied server-side.',
|
||||
queryParams:{ page:'Page number (default 1)', limit:'Items per page (max 200, default 100)', search:'Search by name, email, or phone number (case-insensitive substring)', role:'Filter by role: admin|supervisor|staff|user', isActive:'Filter by active status: true|false (omit for both)' },
|
||||
responses:[
|
||||
@@ -522,6 +612,10 @@ app.get('/docs', async (req, res) => {
|
||||
{ method:'GET', path:'/api/registrations/event/:eventId', auth:'staff+', desc:'Get all registrations for a specific event',
|
||||
pathParams:{ ':eventId':'Event UUID' },
|
||||
responses:[{ status:200, desc:'Success', body:[{ id:'reg-uuid-...', user:{ id:'user-uuid-...', name:'Jane Doe', email:'jane@example.com', phoneNumber:'+27821234567' }, status:'paid' }]}]},
|
||||
{ method:'PUT', path:'/api/registrations/:id/attendees', auth:'user+', desc:'Replace the named attendees on a registration (group tickets) and choose ticket delivery (payer | each). Owner or staff. Removing an attendee whose ticket was scanned is refused.',
|
||||
request:{ body:{ attendees:[{ id:'att-uuid-...', eventOptionId:'opt-uuid-...', name:'Sam Smith', email:'sam@example.com' },{ eventOptionId:'opt-uuid-...', name:'Alex Smith' }], ticketDelivery:'each' }},
|
||||
responses:[{ status:200, desc:'Saved', body:{ id:'reg-uuid-...', ticketDelivery:'each', attendees:[{ id:'att-uuid-...', name:'Sam Smith', ticket:{ id:'tkt-uuid-...', isUsed:false }}] }}]},
|
||||
{ method:'POST', path:'/api/registrations/:id/attendees/:attendeeId/send-form-link', auth:'user+', desc:'Email/WhatsApp a named attendee the link to fill in their own attendee form' },
|
||||
{ method:'POST', path:'/api/registrations/manual', auth:'supervisor+', desc:'Manually create a registration (at-the-door / walk-in). Resolves early-bird tier pricing (including per-variant tiers) and stores priceSnapshot + appliedTierId on each option. Creates or finds a matching user by email/phone. If a matching user is found, updates their notificationPreference and backfills a missing phone number or guest placeholder email with the newly supplied value (without overwriting existing contact info). Generates a Yoco checkout link for unpaid registrations to include in the self-service notification email.',
|
||||
request:{ body:{ eventId:'ev-uuid-...', user:{ name:'Jane Doe', email:'jane@example.com', phoneNumber:'0821234567' }, options:[{ eventOptionId:'opt-uuid-...', quantity:1, variantId:'var-uuid-...' }], notificationPreference:'email' }},
|
||||
responses:[{ status:201, desc:'Created', body:{ id:'reg-uuid-new', status:'pending' }}]},
|
||||
@@ -529,11 +623,60 @@ 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: 'Waitlist', base: '/api/waitlist', endpoints: [
|
||||
{ method:'POST', path:'/api/waitlist', auth:'public', desc:'Join the waitlist for a sold-out line (login needed when the event requires it)',
|
||||
request:{ body:{ eventId:'ev-uuid-...', eventOptionId:'opt-uuid-...', quantity:2 }},
|
||||
responses:[{ status:201, desc:'Joined', body:{ id:'wl-uuid-...', status:'waiting', ahead:3 }}, { status:400, desc:'Places available', body:{ message:'There are places available — you can register now.' }}]},
|
||||
{ method:'GET', path:'/api/waitlist/mine', auth:'user+', desc:'My active waitlist entries' },
|
||||
{ method:'DELETE', path:'/api/waitlist/:id', auth:'user+', desc:'Leave the waitlist' },
|
||||
{ method:'GET', path:'/api/waitlist/claim/:token', auth:'public', desc:'What a claim link offers (rate-limited)' },
|
||||
{ method:'GET', path:'/api/waitlist/events/:eventId', auth:'staff+', desc:'The queue for an event' },
|
||||
{ method:'POST', path:'/api/waitlist/:id/offer', auth:'supervisor+', desc:'Offer places now, even over the stock limit' },
|
||||
{ method:'POST', path:'/api/waitlist/:id/move', auth:'supervisor+', desc:'Move up or down the queue', request:{ body:{ direction:'up' }} },
|
||||
{ method:'POST', path:'/api/waitlist/:id/remove', auth:'supervisor+', desc:'Take someone off the waitlist' },
|
||||
]},
|
||||
{ title: 'Parental consent', base: '/api/consent', endpoints: [
|
||||
{ method:'GET', path:'/api/consent/:token', auth:'public', desc:'Consent page data for a parent/guardian (the link token is the credential; rate-limited)',
|
||||
responses:[{ status:200, desc:'Success', body:{ status:'pending', attendeeName:'Sam Smith', consentText:'I give permission…', event:{ title:'Youth Camp' }, collect:{ medical:true, emergency:true, photo:true } }}, { status:404, desc:'Unknown or replaced link', body:{ message:'This consent link is not valid.' }}]},
|
||||
{ method:'POST', path:'/api/consent/:token', auth:'public', desc:'Give or decline consent. Records name, time, IP, device and the consent text version; medical/emergency details are stored encrypted.',
|
||||
request:{ body:{ decision:'give', signedName:'Pat Smith', isGuardian:true, medical:{ allergies:'Peanuts' }, emergency:{ name:'Gran', phone:'0821234567' }, photoConsent:true }},
|
||||
responses:[{ status:200, desc:'Answered', body:{ status:'given' }}, { status:409, desc:'Already answered', body:{ message:'Consent has already been given.' }}]},
|
||||
{ method:'GET', path:'/api/consent/events/:eventId', auth:'staff+', desc:'Consent status for every attendee on an event' },
|
||||
{ method:'POST', path:'/api/consent/requests/:id/resend', auth:'user+', desc:'Re-send a pending request with a fresh link (registrant or staff)' },
|
||||
{ method:'POST', path:'/api/consent/events/:eventId/resend-pending', auth:'supervisor+', desc:'Re-send every pending request on an event' },
|
||||
{ method:'POST', path:'/api/consent/events/:eventId/rerequest', auth:'supervisor+', desc:'Ask again where consent was given on an older version of the text, or never requested' },
|
||||
{ method:'GET', path:'/api/consent/events/:eventId/medical', auth:'supervisor+', desc:'Decrypted medical & emergency list; every view is written to the admin audit log' },
|
||||
]},
|
||||
{ title: 'Coupons', base: '/api/coupons', endpoints: [
|
||||
{ method:'POST', path:'/api/coupons/validate', auth:'public', desc:'Check a code against a basket before registering; returns the discount. Login needed for restricted codes; staff can pass forUserId (or null for a walk-in).',
|
||||
request:{ body:{ code:'STAFF50', eventId:'ev-uuid-...', options:[{ eventOptionId:'opt-uuid-...', quantity:2 }] }},
|
||||
responses:[
|
||||
{ status:200, desc:'Valid', body:{ valid:true, code:'STAFF50', type:'percent', value:50, summary:'50% off', discount:450, subtotal:900, total:450 }},
|
||||
{ status:400, desc:'Not usable', body:{ valid:false, message:'This code has expired.' }},
|
||||
]},
|
||||
{ method:'GET', path:'/api/coupons', auth:'supervisor+', desc:'List codes with use counts and total discount given',
|
||||
responses:[{ status:200, desc:'Success', body:[{ id:'cpn-uuid-...', code:'STAFF50', type:'percent', value:50, summary:'50% off', active:true, uses:3, totalDiscount:1350 }]}]},
|
||||
{ method:'POST', path:'/api/coupons', auth:'supervisor+', desc:'Create a code. type: percent | fixed | comp. eventIds/options/userIds/restrictRoles/restrictDomain are optional.',
|
||||
request:{ body:{ code:'STAFF50', type:'percent', value:50, eventIds:['ev-uuid-...'], restrictRoles:['staff','supervisor','admin'], maxUsesPerUser:1, stackEarlyBird:false }},
|
||||
responses:[{ status:201, desc:'Created', body:{ id:'cpn-uuid-...', code:'STAFF50' }}, { status:409, desc:'Code exists', body:{ message:'The code STAFF50 already exists' }}]},
|
||||
{ method:'GET', path:'/api/coupons/:id', auth:'supervisor+', desc:'One code with every use (redemption)' },
|
||||
{ method:'PUT', path:'/api/coupons/:id', auth:'supervisor+', desc:'Edit a code or turn it off ({ active:false }). Discounts already given keep their amount; a used code can\'t be renamed.' },
|
||||
{ method:'GET', path:'/api/coupons/redemptions', auth:'supervisor+', desc:'Active uses for the Discounts report. Query: eventIds (comma-separated), from, to' },
|
||||
]},
|
||||
{ title: 'Payments', base: '/api/payments', endpoints: [
|
||||
{ method:'POST', path:'/api/payments/yoco-checkout', auth:'user+', desc:'Initiate a Yoco checkout session. Before creating the checkout, re-evaluates early-bird tier eligibility (deadline + stock). If any price changed since registration, returns priceUpdated:true instead of creating a checkout — the client must inform the user and retry.',
|
||||
request:{ body:{ registrationId:'reg-uuid-...', amount:450, successUrl:'https://events.hopehenley.co.za/payment/success', cancelUrl:'https://events.hopehenley.co.za/payment/cancel', failureUrl:'https://events.hopehenley.co.za/payment/failure' }},
|
||||
request:{ body:{ registrationId:'reg-uuid-...', amount:450, successUrl:'https://events.example.com/payment/success', cancelUrl:'https://events.example.com/payment/cancel', failureUrl:'https://events.example.com/payment/failure' }},
|
||||
responses:[
|
||||
{ status:200, desc:'Checkout created — proceed to Yoco', body:{ redirectUrl:'https://pay.yoco.com/checkout/abc123', checkoutId:'yoco-checkout-id', amount:450 }},
|
||||
{ status:200, desc:'Early-bird price changed — checkout NOT created. Frontend must show warning and let user confirm before retrying.', body:{ priceUpdated:true, newTotal:500, message:'One or more early-bird prices have changed since your registration was created. Please review the updated total before proceeding.' }},
|
||||
@@ -546,6 +689,14 @@ app.get('/docs', async (req, res) => {
|
||||
{ method:'GET', path:'/api/payments/mypayments', auth:'user+', desc:'Get own payment history (paginated, excludes donations). Returned method is normalized to cash|card|eft|voucher|other — apple_pay/google_pay report as "card", any other gateway-reported value reports as "other"',
|
||||
queryParams:{ page:'Page (default 1)', limit:'Per page (default 25, max 25)', startDate:'ISO date, filters createdAt >=', endDate:'ISO date, filters createdAt <=', method:'Filter by normalized method: cash|card|eft|voucher|other', kind:'payment|refund — filters by amount sign' },
|
||||
responses:[{ status:200, desc:'Success', body:{ data:[{ id:'pay-uuid-...', amount:450, method:'card', status:'succeeded', createdAt:'2025-06-01T11:00:00.000Z' }], total:1, page:1, limit:25, pages:1 }}]},
|
||||
{ method:'POST', path:'/api/payments/:id/send-receipt', auth:'user+', desc:'Regenerate the receipt PDF for one of the caller\'s own payments and send it on the requested channel — email or WhatsApp. Fails if there is no valid email/phone on file for the chosen channel.',
|
||||
notes:'The PDF\'s receiptNo is derived, not sequential: RCPT-<year of payment.createdAt>-<last 6 hex chars of the payment id, uppercased>.',
|
||||
pathParams:{ ':id':'Payment UUID' },
|
||||
request:{ body:{ channel:'whatsapp' }},
|
||||
responses:[
|
||||
{ status:200, desc:'Sent', body:{ message:'Receipt sent to WhatsApp.' }},
|
||||
{ status:403, desc:'Not the owner', body:{ message:'Not authorized to access this payment' }},
|
||||
]},
|
||||
{ method:'GET', path:'/api/payments', auth:'supervisor+', desc:'List all payments',
|
||||
queryParams:{ page:'Page (default 1)', limit:'Per page (default 20)', eventId:'Filter by event', userId:'Filter by user', method:'Filter by method (cash|card|eft|donation)', startDate:'ISO date', endDate:'ISO date' },
|
||||
responses:[{ status:200, desc:'Success', body:{ data:[{ id:'pay-uuid-...', amount:450, method:'card', user:{ name:'Jane Doe' }, registration:{ event:{ title:'Camp 2025' }}}], total:1 }}]},
|
||||
@@ -750,7 +901,7 @@ app.get('/docs', async (req, res) => {
|
||||
responses:[{ status:200, desc:'Saved', body:{ message:'WhatsApp config saved.' }}]},
|
||||
{ method:'GET', path:'/api/whatsapp/status', auth:'admin', desc:'Get the current WAWP session status',
|
||||
responses:[
|
||||
{ status:200, desc:'Connected', body:{ status:'open', phoneNumber:'+27821234567', pushName:'Hope Events' }},
|
||||
{ status:200, desc:'Connected', body:{ status:'open', phoneNumber:'+27821234567', pushName:'Cross Code' }},
|
||||
{ status:200, desc:'Not connected', body:{ status:'close' }},
|
||||
]},
|
||||
{ method:'GET', path:'/api/whatsapp/qr', auth:'admin', desc:'Get a QR code image/string to link a WhatsApp account',
|
||||
@@ -773,14 +924,14 @@ app.get('/docs', async (req, res) => {
|
||||
]},
|
||||
|
||||
{ title: 'Settings', base: '/api/settings', endpoints: [
|
||||
{ method:'GET', path:'/api/settings', auth:'public', desc:'Public settings — org name, accent colour, logo URL, legal page slugs, registration notification email (no secrets)',
|
||||
responses:[{ status:200, desc:'Success', body:{ org_name:'Hope Family Church', org_tagline:'Where everyone belongs', accent_color:'#2563eb', logo_url:'/uploads/logo.png' }}]},
|
||||
{ method:'GET', path:'/api/settings', auth:'public', desc:'Public settings — org name, brand colours (primary/secondary/accent), logo URL, favicon URL, legal page slugs, registration notification email (no secrets)',
|
||||
responses:[{ status:200, desc:'Success', body:{ org_name:'Cross Code', org_tagline:'Event management, made simple', primary_color:'#4F46E5', secondary_color:'#8B5CF6', accent_color:'#EC4899', logo_url:'/uploads/branding/logo-123.png', favicon_url:'/uploads/branding/favicon-123.png' }}]},
|
||||
{ method:'GET', path:'/api/settings/all', auth:'admin', desc:'All settings including SMTP config. smtp_pass is returned masked (••••••••); smtp_user is returned decrypted.',
|
||||
responses:[{ status:200, desc:'Success', body:[{ key:'smtp_host', value:'smtp.example.com' },{ key:'smtp_user', value:'user@example.com' },{ key:'smtp_pass', value:'••••••••' }]}]},
|
||||
{ method:'GET', path:'/api/settings/needs-setup', auth:'public', desc:'Returns true until the setup wizard has been completed (setup_complete flag is set).',
|
||||
responses:[{ status:200, desc:'Success', body:{ needsSetup:true }}]},
|
||||
{ method:'PUT', path:'/api/settings', auth:'admin', desc:'Upsert one or more settings. smtp_user and smtp_pass are AES-256-GCM encrypted before storage. Sending •••••••• for smtp_pass is a no-op.',
|
||||
request:{ body:{ org_name:'Hope Family Church', smtp_host:'smtp.gmail.com', smtp_port:'587', smtp_user:'user@gmail.com', smtp_pass:'app-password' }},
|
||||
request:{ body:{ org_name:'Cross Code', smtp_host:'smtp.gmail.com', smtp_port:'587', smtp_user:'user@gmail.com', smtp_pass:'app-password' }},
|
||||
responses:[{ status:200, desc:'Saved', body:{ message:'Settings saved.' }}]},
|
||||
{ method:'POST', path:'/api/settings/test-smtp', auth:'admin (or setup token)', desc:'Test the SMTP connection with provided credentials. On success, sends a real test email to the authenticated admin and returns a friendly message. On failure, returns a human-readable message plus a raw field containing the original SMTP error for debugging. Error code 530 (Microsoft "Client not authenticated") maps to the authentication-failure message.',
|
||||
request:{ body:{ host:'smtp.gmail.com', port:587, secure:false, user:'me@gmail.com', pass:'app-password', from:'me@gmail.com' }},
|
||||
@@ -798,7 +949,7 @@ app.get('/docs', async (req, res) => {
|
||||
{ status:400, desc:'Already exists', body:{ message:'Setup already completed — users exist.' }},
|
||||
]},
|
||||
{ method:'POST', path:'/api/setup', auth:'setup token or admin', desc:'Final step of the setup wizard. Saves initial site settings. Requires the JWT returned by POST /api/setup/register.',
|
||||
request:{ body:{ settings:{ org_name:'Hope Family Church', smtp_host:'smtp.gmail.com', smtp_port:'587', smtp_user:'user@gmail.com', smtp_pass:'app-password', mail_from:'noreply@example.com' }}},
|
||||
request:{ body:{ settings:{ org_name:'Cross Code', smtp_host:'smtp.gmail.com', smtp_port:'587', smtp_user:'user@gmail.com', smtp_pass:'app-password', mail_from:'noreply@example.com' }}},
|
||||
responses:[{ status:200, desc:'Setup complete', body:{ message:'Setup complete.' }}]},
|
||||
]},
|
||||
];
|
||||
@@ -964,12 +1115,12 @@ app.get('/docs', async (req, res) => {
|
||||
{ trigger: 'Scheduled — every day at 07:00 local server time, once per active event that has gone live and has not yet started', channels: ['Email'], recipients: 'Admin — registrations inbox + event notify recipients (falls back to the event creator)', subject: 'Daily summary: {event} — {date}', content: 'Stat tiles (registrations, paid, awaiting payment, revenue), full registrations table with balances, full payments & donations table.' },
|
||||
]},
|
||||
{ category: 'Account & security', items: [
|
||||
{ trigger: 'New account registered', channels: ['Email (always)', 'WhatsApp (if preferred)'], recipients: 'New user', subject: 'Welcome to Hope Events!', content: 'Welcome message plus a list of upcoming events.' },
|
||||
{ trigger: 'Login attempt on an account that is not yet active', channels: ['Email (if a real address is on file)', 'WhatsApp (fallback when there is no usable email)'], recipients: 'User', subject: 'Activate your Hope Events account', content: 'One-time activation link; expires after 24 hours.' },
|
||||
{ trigger: 'Successful login', channels: ['Email (always)', 'WhatsApp (if preferred)'], recipients: 'User', subject: 'New login to your Hope Events account', content: 'Login time, approximate location, device/user agent. Security alert — always emailed regardless of the user\'s notification preference.' },
|
||||
{ trigger: 'Password changed via profile update', channels: ['Email (always)', 'WhatsApp (if preferred)'], recipients: 'User', subject: 'Your Hope Events password was changed', content: 'Confirms the change and gives a support contact to use if it wasn\'t them.' },
|
||||
{ trigger: 'New account registered', channels: ['Email (always)', 'WhatsApp (if preferred)'], recipients: 'New user', subject: 'Welcome to {org}!', content: 'Welcome message plus a list of upcoming events.' },
|
||||
{ trigger: 'Login attempt on an account that is not yet active', channels: ['Email (if a real address is on file)', 'WhatsApp (fallback when there is no usable email)'], recipients: 'User', subject: 'Activate your {org} account', content: 'One-time activation link; expires after 24 hours.' },
|
||||
{ trigger: 'Successful login', channels: ['Email (always)', 'WhatsApp (if preferred)'], recipients: 'User', subject: 'New login to your {org} account', content: 'Login time, approximate location, device/user agent. Security alert — always emailed regardless of the user\'s notification preference.' },
|
||||
{ trigger: 'Password changed via profile update', channels: ['Email (always)', 'WhatsApp (if preferred)'], recipients: 'User', subject: 'Your {org} password was changed', content: 'Confirms the change and gives a support contact to use if it wasn\'t them.' },
|
||||
{ trigger: 'Forgot-password request', channels: ['Email (always)', 'WhatsApp (if preferred)'], recipients: 'User', subject: 'Reset your password', content: 'Password reset link.' },
|
||||
{ trigger: 'Account closed (self-service — "Deactivate" or "Delete my data")', channels: ['Email', 'WhatsApp (if preferred)'], recipients: 'User (sent to their last-known address/number just before data is wiped)', subject: 'Your Hope Events account has been closed', content: 'Confirms closure; wording differs slightly when personal data was also erased.' },
|
||||
{ trigger: 'Account closed (self-service — "Deactivate" or "Delete my data")', channels: ['Email', 'WhatsApp (if preferred)'], recipients: 'User (sent to their last-known address/number just before data is wiped)', subject: 'Your {org} account has been closed', content: 'Confirms closure; wording differs slightly when personal data was also erased.' },
|
||||
]},
|
||||
{ category: 'Admin-triggered broadcasts', items: [
|
||||
{ trigger: 'Bulk email to event attendees, or an ad-hoc email broadcast — sent immediately or on a schedule', channels: ['Email'], recipients: 'Selected attendees / users / ad-hoc addresses chosen by the sender', subject: 'Admin-authored', content: 'Free-form subject & body written by the sender, supporting {{name}}, {{event.title}}, {{event.link}} placeholders. Used for reminders, thank-yous, promos, and multi-step automations.' },
|
||||
@@ -1013,13 +1164,14 @@ app.get('/docs', async (req, res) => {
|
||||
}
|
||||
|
||||
const notificationsHtml = NOTIFICATIONS.map(renderNotificationCategory).join('');
|
||||
const orgName = getSettingSync('org_name', process.env.ORG_NAME || 'Cross Code');
|
||||
|
||||
const html = `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Hope Events — API Docs</title>
|
||||
<title>${orgName} Events — API Docs</title>
|
||||
<style>
|
||||
*{box-sizing:border-box;margin:0;padding:0}
|
||||
body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;background:#f3f4f6;color:#1f2937;min-height:100vh;padding:24px 16px}
|
||||
@@ -1043,7 +1195,7 @@ app.get('/docs', async (req, res) => {
|
||||
<body>
|
||||
<div class="wrap">
|
||||
<div style="display:flex;align-items:baseline;justify-content:space-between;flex-wrap:wrap;gap:8px;margin-bottom:6px">
|
||||
<h1 style="font-size:1.4rem;font-weight:700;color:#111827">Hope Events — API Reference</h1>
|
||||
<h1 style="font-size:1.4rem;font-weight:700;color:#111827">${orgName} Events — API Reference</h1>
|
||||
<a href="/" style="font-size:.82rem;color:#6b7280">← Status page</a>
|
||||
</div>
|
||||
<p style="font-size:.82rem;color:#6b7280;margin-bottom:20px">
|
||||
@@ -1070,7 +1222,7 @@ app.get('/docs', async (req, res) => {
|
||||
${notificationsHtml}
|
||||
|
||||
<p style="font-size:.72rem;color:#9ca3af;margin-top:28px;text-align:center">
|
||||
Hope Events API v${API_VERSION} — ${new Date().toISOString()}
|
||||
${orgName} Events API v${API_VERSION} — ${new Date().toISOString()}
|
||||
</p>
|
||||
</div>
|
||||
<script>
|
||||
@@ -1098,6 +1250,14 @@ function toggle(id) {
|
||||
|
||||
// Error middleware
|
||||
app.use(notFound);
|
||||
|
||||
// Sentry captures the error here, then passes it through unchanged — errorHandler
|
||||
// below remains the sole source of what's actually sent back to the client.
|
||||
if (process.env.SENTRY_DSN) {
|
||||
const Sentry = require('@sentry/node');
|
||||
Sentry.setupExpressErrorHandler(app);
|
||||
}
|
||||
|
||||
app.use(errorHandler);
|
||||
|
||||
// Start server
|
||||
@@ -1161,6 +1321,59 @@ app.listen(PORT, () => {
|
||||
console.warn('[daily summaries] Not scheduled:', e?.message || e);
|
||||
}
|
||||
|
||||
// Waitlist job — expire unclaimed offers and offer any free places to the next people.
|
||||
try {
|
||||
const enabled = String(process.env.WAITLIST_JOB_ENABLED || 'true').toLowerCase() !== 'false';
|
||||
if (enabled) {
|
||||
const { runWaitlistJob } = require('./utils/waitlist');
|
||||
const everyMs = parseInt(process.env.WAITLIST_JOB_INTERVAL_MS || String(10 * 60 * 1000), 10);
|
||||
setInterval(async () => {
|
||||
try {
|
||||
const r = await runWaitlistJob();
|
||||
if (r.expired || r.offers) console.log(`[waitlist] ${r.expired} event(s) with expired offers, ${r.offers} new offer(s).`);
|
||||
} catch (e) {
|
||||
console.warn('[waitlist] Job failed:', e?.message || e);
|
||||
}
|
||||
}, everyMs);
|
||||
console.log(`[waitlist] Job every ${Math.round(everyMs / 60000)} min.`);
|
||||
} else {
|
||||
console.log('[waitlist] Job disabled by env WAITLIST_JOB_ENABLED=false');
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[waitlist] Not scheduled:', e?.message || e);
|
||||
}
|
||||
|
||||
// Daily parental-consent job — reminders for unanswered requests, and purging
|
||||
// medical/emergency details a set number of days after each event.
|
||||
try {
|
||||
const enabled = String(process.env.CONSENT_JOBS_ENABLED || 'true').toLowerCase() !== 'false';
|
||||
if (enabled) {
|
||||
const { sendConsentReminders, purgeConsentMedicalData } = require('./utils/consent');
|
||||
const runConsentJobs = async () => {
|
||||
try {
|
||||
const reminded = await sendConsentReminders(new Date());
|
||||
const purged = await purgeConsentMedicalData(new Date());
|
||||
if (reminded || purged) console.log(`[consent] Sent ${reminded} reminder(s), purged medical details on ${purged} request(s).`);
|
||||
} catch (e) {
|
||||
console.warn('[consent] Daily job failed:', e?.message || e);
|
||||
}
|
||||
};
|
||||
const scheduleConsent = () => {
|
||||
const now = new Date();
|
||||
const next = new Date(now);
|
||||
next.setHours(9, 0, 0, 0);
|
||||
if (next <= now) next.setDate(next.getDate() + 1);
|
||||
setTimeout(async () => { await runConsentJobs(); scheduleConsent(); }, next.getTime() - now.getTime());
|
||||
};
|
||||
scheduleConsent();
|
||||
console.log('[consent] Scheduler initialized (09:00 local time).');
|
||||
} else {
|
||||
console.log('[consent] Scheduler disabled by env CONSENT_JOBS_ENABLED=false');
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[consent] Not scheduled:', e?.message || e);
|
||||
}
|
||||
|
||||
// Daily temp-file cleanup — removes files older than 7 days from /temp
|
||||
try {
|
||||
const { cleanupTempFiles } = require('./utils/cleanupTemp');
|
||||
@@ -1188,6 +1401,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 +1488,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 };
|
||||
@@ -2,6 +2,10 @@ const rateLimit = require("express-rate-limit");
|
||||
|
||||
const jwt = require('jsonwebtoken');
|
||||
const prisma = require('../config/db');
|
||||
const { mustEnrolTwoFactor } = require('../utils/twoFactor');
|
||||
|
||||
// The setup wizard's token may finish setup without 2FA for this long.
|
||||
const SETUP_TOKEN_GRACE_SECONDS = 2 * 60 * 60;
|
||||
|
||||
// Protect routes - verify token
|
||||
const protect = async (req, res, next) => {
|
||||
@@ -13,8 +17,9 @@ const protect = async (req, res, next) => {
|
||||
// Get token from header
|
||||
token = req.headers.authorization.split(' ')[1];
|
||||
|
||||
// Verify token
|
||||
const decoded = jwt.verify(token, process.env.JWT_SECRET);
|
||||
// Verify token — pin the algorithm so a token signed with an
|
||||
// unexpected/attacker-chosen algorithm is never accepted.
|
||||
const decoded = jwt.verify(token, process.env.JWT_SECRET, { algorithms: ['HS256'] });
|
||||
|
||||
// Get user from the token (exclude password)
|
||||
req.user = await prisma.user.findUnique({
|
||||
@@ -28,7 +33,8 @@ const protect = async (req, res, next) => {
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
phoneNumber: true,
|
||||
tokenVersion: true
|
||||
tokenVersion: true,
|
||||
twoFactorEnabled: true
|
||||
}
|
||||
});
|
||||
|
||||
@@ -50,6 +56,17 @@ const protect = async (req, res, next) => {
|
||||
return next(new Error('Session has been revoked. Please log in again.'));
|
||||
}
|
||||
|
||||
// Admins and supervisors must use 2FA. A session that predates the
|
||||
// requirement (or the user's promotion) is sent back to log in, where they
|
||||
// enrol. The setup wizard's own token gets a short grace period.
|
||||
const inSetupGrace = decoded.setup === true && Date.now() / 1000 - (decoded.iat || 0) < SETUP_TOKEN_GRACE_SECONDS;
|
||||
if (!inSetupGrace && (await mustEnrolTwoFactor(req.user))) {
|
||||
return res.status(401).json({
|
||||
message: 'Two-factor authentication is required for your role. Please log in again to set it up.',
|
||||
code: 'TWO_FACTOR_REQUIRED',
|
||||
});
|
||||
}
|
||||
|
||||
next();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
@@ -105,7 +122,7 @@ const optionalAuth = async (req, res, next) => {
|
||||
}
|
||||
try {
|
||||
const token = req.headers.authorization.split(' ')[1];
|
||||
const decoded = jwt.verify(token, process.env.JWT_SECRET);
|
||||
const decoded = jwt.verify(token, process.env.JWT_SECRET, { algorithms: ['HS256'] });
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: decoded.id },
|
||||
select: { id: true, name: true, email: true, role: true, isActive: true, createdAt: true, updatedAt: true, phoneNumber: true, tokenVersion: true }
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { listAuditLog } = require('../controllers/adminAuditController');
|
||||
const { protect, admin } = require('../middleware/authMiddleware');
|
||||
|
||||
router.get('/audit-log', protect, admin, listAuditLog);
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,10 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { getBackups, triggerBackup, downloadBackup } = require('../controllers/backupController');
|
||||
const { protect, admin } = require('../middleware/authMiddleware');
|
||||
|
||||
router.get('/', protect, admin, getBackups);
|
||||
router.post('/run', protect, admin, triggerBackup);
|
||||
router.get('/:filename/download', protect, admin, downloadBackup);
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,20 @@
|
||||
const express = require('express');
|
||||
const rateLimit = require('express-rate-limit');
|
||||
const router = express.Router();
|
||||
const { getConsent, answerConsent, listEventConsent, resendRequest, resendPending, rerequestConsent, medicalList } = require('../controllers/consentController');
|
||||
const { protect, staff, supervisor } = require('../middleware/authMiddleware');
|
||||
|
||||
// The token in the link is the parent's only credential, so keep guessing slow.
|
||||
const consentLimiter = rateLimit({ windowMs: 60 * 1000, max: 30, message: { message: 'Too many attempts. Please try again in a minute.' } });
|
||||
|
||||
router.get('/events/:eventId', protect, staff, listEventConsent);
|
||||
router.post('/events/:eventId/resend-pending', protect, supervisor, resendPending);
|
||||
router.post('/events/:eventId/rerequest', protect, supervisor, rerequestConsent);
|
||||
router.get('/events/:eventId/medical', protect, supervisor, medicalList);
|
||||
router.post('/requests/:id/resend', protect, resendRequest);
|
||||
|
||||
// Public consent page
|
||||
router.get('/:token', consentLimiter, getConsent);
|
||||
router.post('/:token', consentLimiter, answerConsent);
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,15 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { listCoupons, getCoupon, createCoupon, updateCoupon, validateCode, listRedemptions } = require('../controllers/couponController');
|
||||
const { protect, supervisor, optionalAuth } = require('../middleware/authMiddleware');
|
||||
|
||||
// Check a code against a basket before registering (guests included)
|
||||
router.post('/validate', optionalAuth, validateCode);
|
||||
|
||||
router.get('/', protect, supervisor, listCoupons);
|
||||
router.post('/', protect, supervisor, createCoupon);
|
||||
router.get('/redemptions', protect, supervisor, listRedemptions);
|
||||
router.get('/:id', protect, supervisor, getCoupon);
|
||||
router.put('/:id', protect, supervisor, updateCoupon);
|
||||
|
||||
module.exports = router;
|
||||
@@ -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
|
||||
|
||||
@@ -8,10 +8,12 @@ const {
|
||||
getPaymentsByRegistration,
|
||||
getPaymentsByEvent,
|
||||
assignDonationToRegistration,
|
||||
unassignDonationFromRegistration,
|
||||
createYocoCheckout,
|
||||
sendPaymentLink,
|
||||
createRefund,
|
||||
getPaymentStats
|
||||
getPaymentStats,
|
||||
sendReceipt,
|
||||
} = require('../controllers/paymentController');
|
||||
const { protect, supervisor, staff, admin} = require('../middleware/authMiddleware');
|
||||
|
||||
@@ -20,6 +22,7 @@ router.post('/', protect, supervisor, createPayment);
|
||||
router.post('/yoco-checkout', protect, createYocoCheckout);
|
||||
router.post('/yoco-checkout/send', protect, supervisor, sendPaymentLink);
|
||||
router.get('/mypayments', protect, getUserPayments);
|
||||
router.post('/:id/send-receipt', protect, sendReceipt);
|
||||
router.get('/:id', protect, getPaymentById);
|
||||
router.get('/registration/:registrationId', protect, getPaymentsByRegistration);
|
||||
|
||||
@@ -27,6 +30,7 @@ router.get('/registration/:registrationId', protect, getPaymentsByRegistration);
|
||||
router.get('/', protect, supervisor, getPayments);
|
||||
router.get('/event/:eventId', protect, staff, getPaymentsByEvent);
|
||||
router.put('/assign-donation', protect, supervisor, assignDonationToRegistration);
|
||||
router.post('/unassign-donation', protect, supervisor, unassignDonationFromRegistration);
|
||||
router.post('/refund', protect, supervisor, createRefund);
|
||||
router.get('/admin/stats', protect, admin, getPaymentStats);
|
||||
|
||||
|
||||
@@ -12,6 +12,9 @@ const {
|
||||
updateRegistrationOptions,
|
||||
submitFormResponses, replaceFormResponses,
|
||||
getFormDraft, saveFormDraft,
|
||||
sendInvoice,
|
||||
applyCoupon, removeCoupon,
|
||||
updateAttendees, sendAttendeeFormLink,
|
||||
} = require('../controllers/registrationController');
|
||||
const { protect, supervisor, staff, optionalAuth } = require('../middleware/authMiddleware');
|
||||
|
||||
@@ -20,6 +23,11 @@ router.post('/', optionalAuth, createRegistration);
|
||||
router.get('/myregistrations', protect, getUserRegistrations);
|
||||
router.put('/:id/options', protect, updateRegistrationOptions);
|
||||
router.delete('/:id', protect, cancelRegistration);
|
||||
router.post('/:id/send-invoice', protect, sendInvoice);
|
||||
router.post('/:id/coupon', protect, applyCoupon);
|
||||
router.delete('/:id/coupon', protect, staff, removeCoupon);
|
||||
router.put('/:id/attendees', protect, updateAttendees);
|
||||
router.post('/:id/attendees/:attendeeId/send-form-link', protect, sendAttendeeFormLink);
|
||||
|
||||
// Registration detail + forms — optionalAuth so guests can access with just the registrationId
|
||||
router.get('/:id', optionalAuth, getRegistrationById);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
|
||||
const { upload, uploadEventImage, uploadLogo, uploadLogoImage } = require('../controllers/uploadController');
|
||||
const { upload, uploadEventImage, uploadLogo, uploadLogoImage, uploadFavicon, uploadFaviconImage } = require('../controllers/uploadController');
|
||||
const { protect, supervisor, admin } = require('../middleware/authMiddleware');
|
||||
const prisma = require('../config/db');
|
||||
|
||||
@@ -17,11 +17,9 @@ router.post('/event-image', protect, supervisor, (req, res, next) => {
|
||||
});
|
||||
}, uploadEventImage);
|
||||
|
||||
// @route POST /api/uploads/logo
|
||||
// @desc Upload site logo — admin, OR allowed during first-time setup (no users yet)
|
||||
// @access Admin or setup
|
||||
|
||||
async function logoAccess(req, res, next) {
|
||||
// Shared access rule for branding assets (logo, favicon): admin, OR allowed
|
||||
// during first-time setup (no users yet).
|
||||
async function brandingAssetAccess(req, res, next) {
|
||||
try {
|
||||
const count = await prisma.user.count();
|
||||
if (count === 0) return next(); // first-time setup
|
||||
@@ -31,11 +29,24 @@ async function logoAccess(req, res, next) {
|
||||
}
|
||||
}
|
||||
|
||||
router.post('/logo', logoAccess, (req, res, next) => {
|
||||
// @route POST /api/uploads/logo
|
||||
// @desc Upload site logo — admin, OR allowed during first-time setup (no users yet)
|
||||
// @access Admin or setup
|
||||
router.post('/logo', brandingAssetAccess, (req, res, next) => {
|
||||
uploadLogo.single('image')(req, res, (err) => {
|
||||
if (err) req.multerError = err;
|
||||
next();
|
||||
});
|
||||
}, uploadLogoImage);
|
||||
|
||||
// @route POST /api/uploads/favicon
|
||||
// @desc Upload site favicon — admin, OR allowed during first-time setup (no users yet)
|
||||
// @access Admin or setup
|
||||
router.post('/favicon', brandingAssetAccess, (req, res, next) => {
|
||||
uploadFavicon.single('image')(req, res, (err) => {
|
||||
if (err) req.multerError = err;
|
||||
next();
|
||||
});
|
||||
}, uploadFaviconImage);
|
||||
|
||||
module.exports = router;
|
||||
|
||||
@@ -3,6 +3,12 @@ const router = express.Router();
|
||||
const {
|
||||
registerUser,
|
||||
loginUser,
|
||||
verifyLoginTwoFactor,
|
||||
resendLoginTwoFactor,
|
||||
startEnableTwoFactor,
|
||||
confirmEnableTwoFactor,
|
||||
disableTwoFactor,
|
||||
verifyPassword,
|
||||
getUserProfile,
|
||||
updateUserProfile,
|
||||
getUsers,
|
||||
@@ -24,6 +30,8 @@ const { protect, admin, supervisor, loginLimiter} = require('../middleware/authM
|
||||
// Public routes
|
||||
router.post('/', registerUser);
|
||||
router.post('/login', loginLimiter, loginUser);
|
||||
router.post('/login/2fa', loginLimiter, verifyLoginTwoFactor);
|
||||
router.post('/login/2fa/resend', loginLimiter, resendLoginTwoFactor);
|
||||
router.post('/forgot', requestPasswordReset);
|
||||
router.post('/reset', resetPassword);
|
||||
router.post('/activate', activateAccount);
|
||||
@@ -36,6 +44,10 @@ router.route('/profile')
|
||||
router.post('/revoke-sessions', protect, revokeMySession);
|
||||
router.post('/close-account', protect, closeAccount);
|
||||
router.get('/activity', protect, getMyActivity);
|
||||
router.post('/verify-password', protect, loginLimiter, verifyPassword);
|
||||
router.post('/2fa/enable', protect, loginLimiter, startEnableTwoFactor);
|
||||
router.post('/2fa/confirm', protect, loginLimiter, confirmEnableTwoFactor);
|
||||
router.post('/2fa/disable', protect, loginLimiter, disableTwoFactor);
|
||||
|
||||
// Admin routes
|
||||
router.route('/')
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
const express = require('express');
|
||||
const rateLimit = require('express-rate-limit');
|
||||
const router = express.Router();
|
||||
const { joinWaitlist, myWaitlist, leaveWaitlist, claimInfo, eventWaitlist, offerManually, moveEntry, removeEntry } = require('../controllers/waitlistController');
|
||||
const { protect, staff, supervisor, optionalAuth } = require('../middleware/authMiddleware');
|
||||
|
||||
// Claim links are credentials, so keep guessing slow.
|
||||
const claimLimiter = rateLimit({ windowMs: 60 * 1000, max: 30, message: { message: 'Too many attempts. Please try again in a minute.' } });
|
||||
|
||||
router.post('/', optionalAuth, joinWaitlist);
|
||||
router.get('/mine', protect, myWaitlist);
|
||||
router.get('/claim/:token', claimLimiter, claimInfo);
|
||||
router.get('/events/:eventId', protect, staff, eventWaitlist);
|
||||
router.post('/:id/offer', protect, supervisor, offerManually);
|
||||
router.post('/:id/move', protect, supervisor, moveEntry);
|
||||
router.post('/:id/remove', protect, supervisor, removeEntry);
|
||||
router.delete('/:id', protect, leaveWaitlist);
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* Finding an existing account from an email and/or phone number.
|
||||
*
|
||||
* Email and phone are looked up separately rather than with one OR query, so an email
|
||||
* belonging to one account and a phone belonging to another can never be silently
|
||||
* collapsed into whichever matches first — that would let a registration hijack or
|
||||
* corrupt someone else's account. Phones are tried in both 27xxx and 0xxx forms.
|
||||
*/
|
||||
|
||||
const prisma = require('../config/db');
|
||||
|
||||
function hasValidEmail(email) {
|
||||
return !!(email && typeof email === 'string' && email.includes('@'));
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {Promise<{ phone: string, emailUser: object|null, phoneUser: object|null,
|
||||
* conflict: boolean, user: object|null }>}
|
||||
* `user` is the single matched account (null when none, or when email and phone point
|
||||
* at two different accounts — `conflict` is then true).
|
||||
*/
|
||||
async function findAccountByContact({ email, phone: rawPhone }, db = prisma) {
|
||||
const { normalizeZAPhone } = require('./whatsapp');
|
||||
const phone = normalizeZAPhone(rawPhone) || String(rawPhone || '').replace(/\D/g, '');
|
||||
const phoneAlt = phone && phone.startsWith('27') ? '0' + phone.slice(2) : (phone && phone.length === 9 ? '27' + phone : null);
|
||||
|
||||
const [emailUser, phoneUser] = await Promise.all([
|
||||
hasValidEmail(email) ? db.user.findUnique({ where: { email: String(email).trim() } }) : null,
|
||||
phone ? db.user.findFirst({ where: { OR: [{ phoneNumber: phone }, ...(phoneAlt ? [{ phoneNumber: phoneAlt }] : [])] } }) : null,
|
||||
]);
|
||||
|
||||
const conflict = !!(emailUser && phoneUser && emailUser.id !== phoneUser.id);
|
||||
return { phone, emailUser, phoneUser, conflict, user: conflict ? null : (emailUser || phoneUser || null) };
|
||||
}
|
||||
|
||||
module.exports = { findAccountByContact, hasValidEmail };
|
||||
@@ -0,0 +1,52 @@
|
||||
const prisma = require('../config/db');
|
||||
|
||||
// Fire-and-forget by design — a logging failure must never break the underlying admin
|
||||
// action, so this swallows its own errors rather than propagating them to the caller
|
||||
// (same posture as logSecurityEvent).
|
||||
async function logAdminAction({ actorId, actorRole, action, targetType, targetId, metadata, ip }) {
|
||||
try {
|
||||
await prisma.adminAuditLog.create({
|
||||
data: {
|
||||
actorId: actorId || null,
|
||||
actorRole,
|
||||
action,
|
||||
targetType,
|
||||
targetId: targetId || null,
|
||||
metadata: metadata || undefined,
|
||||
ip: ip || null,
|
||||
},
|
||||
});
|
||||
} catch (e) {
|
||||
console.error('Failed to log admin action:', e?.message);
|
||||
}
|
||||
}
|
||||
|
||||
// Paginated listing for the admin audit-log page, with optional actor/action/date filters.
|
||||
async function getAdminAuditLog({ page = 1, limit = 50, actorId, action, from, to } = {}) {
|
||||
const where = {};
|
||||
if (actorId) where.actorId = actorId;
|
||||
if (action) where.action = action;
|
||||
if (from || to) {
|
||||
where.createdAt = {};
|
||||
if (from) where.createdAt.gte = new Date(from);
|
||||
if (to) where.createdAt.lte = new Date(to);
|
||||
}
|
||||
|
||||
const take = Math.min(Math.max(Number(limit) || 50, 1), 200);
|
||||
const skip = (Math.max(Number(page) || 1, 1) - 1) * take;
|
||||
|
||||
const [rows, total] = await Promise.all([
|
||||
prisma.adminAuditLog.findMany({
|
||||
where,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take,
|
||||
skip,
|
||||
include: { actor: { select: { id: true, name: true, email: true } } },
|
||||
}),
|
||||
prisma.adminAuditLog.count({ where }),
|
||||
]);
|
||||
|
||||
return { rows, total, page: Math.max(Number(page) || 1, 1), limit: take };
|
||||
}
|
||||
|
||||
module.exports = { logAdminAction, getAdminAuditLog };
|
||||
@@ -0,0 +1,209 @@
|
||||
/**
|
||||
* Group tickets — named attendees on a registration.
|
||||
*
|
||||
* Rules:
|
||||
* - Only main-ticket places can be named, and only on events with
|
||||
* allowNamedAttendees. Each named attendee gets their own quantity-1 ticket; any
|
||||
* places left unnamed share one quantity ticket, as before.
|
||||
* - The payer stays the registration owner (billing, invoices, payments). An attendee
|
||||
* whose email/phone matches an existing account is linked to it, so the ticket also
|
||||
* shows in that person's dashboard — unless email and phone point at two different
|
||||
* accounts, in which case nothing is linked.
|
||||
* - Registration.ticketDelivery = 'each' sends each attendee their own ticket by
|
||||
* email/WhatsApp; attendees with no contact details fall back to the payer.
|
||||
* - Youth events (requiresParentalConsent): every main place must be named with a date
|
||||
* of birth, and anyone under 18 on the start date needs a parent/guardian name, email
|
||||
* and phone (see utils/consent.js).
|
||||
*/
|
||||
|
||||
const prisma = require('../config/db');
|
||||
const { findAccountByContact } = require('./accountMatch');
|
||||
const { isMinor } = require('./consent');
|
||||
|
||||
const EMAIL_RE = /^[^@\s]+@[^@\s]+\.[^@\s]+$/;
|
||||
|
||||
const lineKey = (eventOptionId, variantId) => `${eventOptionId}::${variantId || ''}`;
|
||||
|
||||
function attendeeError(message) {
|
||||
const err = new Error(message);
|
||||
err.statusCode = 400;
|
||||
return err;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks attendees against the event and the number of places on each line. Pure.
|
||||
* Returns the cleaned list (trimmed strings, nulls for blanks).
|
||||
*
|
||||
* @param {object} event - with allowNamedAttendees and eventOptions[{ id, isMainTicket, name }]
|
||||
* @param {object} placesByKey - total places each line will have, keyed by lineKey()
|
||||
* @param {Array} attendees - [{ eventOptionId, variantId?, name, email?, phone? }]
|
||||
* @param {object} [namedByKey] - places on those lines already named (and being kept)
|
||||
*/
|
||||
function validateAttendees(event, placesByKey, attendees, namedByKey = {}) {
|
||||
const consent = !!event.requiresParentalConsent;
|
||||
if ((!attendees || attendees.length === 0) && !consent) return [];
|
||||
if (!event.allowNamedAttendees && !consent) throw attendeeError('This event doesn\'t take attendee names.');
|
||||
if (!Array.isArray(attendees || [])) throw attendeeError('attendees must be a list');
|
||||
|
||||
const countByKey = {};
|
||||
const cleaned = (attendees || []).map((a, i) => {
|
||||
const option = (event.eventOptions || []).find(o => o.id === a?.eventOptionId);
|
||||
if (!option) throw attendeeError(`Attendee ${i + 1}: unknown ticket option.`);
|
||||
if (!option.isMainTicket) throw attendeeError(`Attendee ${i + 1}: names can only be given for "${option.name}" main tickets.`);
|
||||
const name = String(a.name || '').trim();
|
||||
if (!name) throw attendeeError(`Attendee ${i + 1}: enter a name.`);
|
||||
const email = String(a.email || '').trim() || null;
|
||||
if (email && !EMAIL_RE.test(email)) throw attendeeError(`Attendee ${i + 1}: "${email}" isn't a valid email address.`);
|
||||
const phone = String(a.phone || '').trim() || null;
|
||||
const key = lineKey(a.eventOptionId, a.variantId);
|
||||
countByKey[key] = (countByKey[key] || 0) + 1;
|
||||
if (countByKey[key] + (namedByKey[key] || 0) > (placesByKey[key] || 0)) {
|
||||
throw attendeeError(`More names than "${option.name}" tickets.`);
|
||||
}
|
||||
|
||||
// Date of birth (always kept if given) and, for youth events, parent details.
|
||||
let dateOfBirth = null;
|
||||
if (a.dateOfBirth) {
|
||||
dateOfBirth = new Date(a.dateOfBirth);
|
||||
if (isNaN(dateOfBirth.getTime()) || dateOfBirth > new Date()) throw attendeeError(`${name}: enter a valid date of birth.`);
|
||||
} else if (consent) {
|
||||
throw attendeeError(`${name}: enter a date of birth.`);
|
||||
}
|
||||
const parentName = String(a.parentName || '').trim() || null;
|
||||
const parentEmail = String(a.parentEmail || '').trim() || null;
|
||||
const parentPhone = String(a.parentPhone || '').trim() || null;
|
||||
if (consent && isMinor(dateOfBirth, event.startDate)) {
|
||||
if (!parentName || !parentEmail || !parentPhone) {
|
||||
throw attendeeError(`${name} is under 18, so a parent or guardian's name, email and phone are needed for consent.`);
|
||||
}
|
||||
if (!EMAIL_RE.test(parentEmail)) throw attendeeError(`${name}: "${parentEmail}" isn't a valid parent email address.`);
|
||||
}
|
||||
return { eventOptionId: a.eventOptionId, variantId: a.variantId || null, name, email, phone, dateOfBirth, parentName, parentEmail, parentPhone };
|
||||
});
|
||||
|
||||
// Youth events need every main place named (with a date of birth).
|
||||
if (consent) {
|
||||
for (const o of event.eventOptions || []) {
|
||||
if (!o.isMainTicket) continue;
|
||||
for (const [key, places] of Object.entries(placesByKey)) {
|
||||
if (!key.startsWith(`${o.id}::`)) continue;
|
||||
if ((countByKey[key] || 0) + (namedByKey[key] || 0) < places) {
|
||||
throw attendeeError(`This is a youth event — add a name and date of birth for every "${o.name}" ticket.`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return cleaned;
|
||||
}
|
||||
|
||||
/**
|
||||
* Works out the tickets one registration line should have, given its named attendees.
|
||||
* Pure — returns operations for the caller to run.
|
||||
*
|
||||
* - Every attendee gets a quantity-1 ticket (holderUserId follows the attendee's account).
|
||||
* - Unnamed places share one quantity ticket; a scanned one is never deleted or shrunk
|
||||
* below what's been redeemed.
|
||||
* - Tickets for attendees that no longer exist are removed unless already scanned.
|
||||
*
|
||||
* @param {object} line - { quantity }
|
||||
* @param {Array} attendees - [{ id, userId }]
|
||||
* @param {Array} tickets - existing [{ id, attendeeId, quantity, holderUserId, usages: [] }]
|
||||
* @returns {{ create: Array<{attendeeId, holderUserId, quantity}>, update: Array<{id, data}>, remove: string[] }}
|
||||
*/
|
||||
function planLineTickets(line, attendees, tickets) {
|
||||
const scanned = (t) => (t.usages || []).length > 0;
|
||||
const redeemed = (t) => (t.usages || []).reduce((s, u) => s + (u.quantityRedeemed || 0), 0);
|
||||
const ops = { create: [], update: [], remove: [] };
|
||||
const attendeeIds = new Set(attendees.map(a => a.id));
|
||||
|
||||
const byAttendee = new Map();
|
||||
const orphanNamed = [];
|
||||
const shared = [];
|
||||
for (const t of tickets) {
|
||||
if (t.attendeeId && attendeeIds.has(t.attendeeId)) byAttendee.set(t.attendeeId, t);
|
||||
else if (t.attendeeId) orphanNamed.push(t);
|
||||
else shared.push(t);
|
||||
}
|
||||
|
||||
// Tickets whose attendee was removed: drop unless already used (then keep, as shared).
|
||||
let keptScannedOrphans = 0;
|
||||
for (const t of orphanNamed) {
|
||||
if (scanned(t)) keptScannedOrphans += t.quantity || 1;
|
||||
else ops.remove.push(t.id);
|
||||
}
|
||||
|
||||
for (const a of attendees) {
|
||||
const t = byAttendee.get(a.id);
|
||||
if (!t) {
|
||||
ops.create.push({ attendeeId: a.id, holderUserId: a.userId || null, quantity: 1 });
|
||||
} else if ((t.holderUserId || null) !== (a.userId || null) || (t.quantity || 1) !== 1) {
|
||||
ops.update.push({ id: t.id, data: { holderUserId: a.userId || null, ...(scanned(t) ? {} : { quantity: 1 }) } });
|
||||
}
|
||||
}
|
||||
|
||||
// Unnamed places share one ticket.
|
||||
const remainder = Math.max(0, (line.quantity || 0) - attendees.length - keptScannedOrphans);
|
||||
const sorted = [...shared].sort((a, b) => (scanned(b) ? 1 : 0) - (scanned(a) ? 1 : 0));
|
||||
const [primary, ...extras] = sorted;
|
||||
if (primary) {
|
||||
const floor = scanned(primary) ? redeemed(primary) : 0;
|
||||
if (remainder === 0 && !scanned(primary)) ops.remove.push(primary.id);
|
||||
else {
|
||||
const qty = Math.max(remainder, floor);
|
||||
if (qty !== primary.quantity) ops.update.push({ id: primary.id, data: { quantity: qty } });
|
||||
}
|
||||
} else if (remainder > 0) {
|
||||
ops.create.push({ attendeeId: null, holderUserId: null, quantity: remainder });
|
||||
}
|
||||
for (const t of extras) if (!scanned(t)) ops.remove.push(t.id);
|
||||
|
||||
return ops;
|
||||
}
|
||||
|
||||
/**
|
||||
* Saves new attendees on a registration, linking each to its registration line and to
|
||||
* a matching account when there is exactly one.
|
||||
*/
|
||||
async function createAttendees(registrationId, attendees, db = prisma) {
|
||||
if (!attendees || attendees.length === 0) return [];
|
||||
const lines = await db.registrationOption.findMany({ where: { registrationId } });
|
||||
const existing = await db.attendee.findMany({ where: { registrationId }, select: { registrationOptionId: true } });
|
||||
const orderByLine = {};
|
||||
existing.forEach(a => { orderByLine[a.registrationOptionId] = (orderByLine[a.registrationOptionId] || 0) + 1; });
|
||||
|
||||
const created = [];
|
||||
for (const a of attendees) {
|
||||
const line = lines.find(l => l.eventOptionId === a.eventOptionId && (l.variantId || null) === (a.variantId || null));
|
||||
const match = (a.email || a.phone) ? await findAccountByContact({ email: a.email, phone: a.phone }, db) : { user: null };
|
||||
const order = orderByLine[line?.id] || 0;
|
||||
orderByLine[line?.id] = order + 1;
|
||||
created.push(await db.attendee.create({
|
||||
data: {
|
||||
registrationId,
|
||||
registrationOptionId: line?.id || null,
|
||||
eventOptionId: a.eventOptionId,
|
||||
variantId: a.variantId || null,
|
||||
name: a.name,
|
||||
email: a.email,
|
||||
phone: a.phone,
|
||||
dateOfBirth: a.dateOfBirth || null,
|
||||
parentName: a.parentName || null,
|
||||
parentEmail: a.parentEmail || null,
|
||||
parentPhone: a.parentPhone || null,
|
||||
userId: match.user?.id || null,
|
||||
order,
|
||||
},
|
||||
}));
|
||||
}
|
||||
return created;
|
||||
}
|
||||
|
||||
/** Named places already on a registration, per line key — for validating additions. */
|
||||
async function namedPlacesByKey(registrationId, db = prisma) {
|
||||
const rows = await db.attendee.findMany({ where: { registrationId }, select: { eventOptionId: true, variantId: true } });
|
||||
const out = {};
|
||||
rows.forEach(r => { const k = lineKey(r.eventOptionId, r.variantId); out[k] = (out[k] || 0) + 1; });
|
||||
return out;
|
||||
}
|
||||
|
||||
module.exports = { lineKey, validateAttendees, planLineTickets, createAttendees, namedPlacesByKey, attendeeError };
|
||||
@@ -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 };
|
||||
@@ -72,7 +72,7 @@ async function computeEventFinancials(eventId) {
|
||||
throw new Error('Event not found');
|
||||
}
|
||||
|
||||
const [payments, costs, tickets, salesRows, history] = await Promise.all([
|
||||
const [payments, costs, tickets, salesRows, history, discountAgg] = await Promise.all([
|
||||
prisma.payment.findMany({
|
||||
where: { OR: [{ eventId }, { registration: { eventId } }] }
|
||||
}),
|
||||
@@ -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 },
|
||||
@@ -92,7 +92,9 @@ async function computeEventFinancials(eventId) {
|
||||
performedBy: { select: { id: true, name: true, email: true } }
|
||||
},
|
||||
orderBy: { createdAt: 'desc' }
|
||||
})
|
||||
}),
|
||||
// Coupon discounts on the same paid registrations salesByOption counts.
|
||||
prisma.registration.aggregate({ where: { eventId, status: 'paid' }, _sum: { discountTotal: true } })
|
||||
]);
|
||||
|
||||
// Ticket quantity sold per EventOption (used to price per-item costs)
|
||||
@@ -191,17 +193,21 @@ async function computeEventFinancials(eventId) {
|
||||
const effectiveTotalRevenue = ALL_METHODS.reduce((s, m) => s + effectiveGrossIncomeByMethod[m], 0);
|
||||
const netProfit = effectiveTotalRevenue - totalCosts;
|
||||
|
||||
// What was actually sold, by ticket type — for the Finance report's income-stream breakdown
|
||||
// What was actually sold, by ticket type — for the Finance report's income-stream breakdown.
|
||||
// Revenue is tranche-aware: a line spanning two early-bird prices contributes each tranche
|
||||
// at the price it was actually bought at, not one blended/stale price for the whole line.
|
||||
const { computeOptionLineTotal } = require('./pricing');
|
||||
const salesByOptionMap = {};
|
||||
for (const ro of salesRows) {
|
||||
const opt = ro.eventOption;
|
||||
if (!opt) continue;
|
||||
if (!salesByOptionMap[opt.id]) salesByOptionMap[opt.id] = { eventOptionId: opt.id, name: opt.name, quantitySold: 0, revenue: 0 };
|
||||
const unitPrice = ro.priceSnapshot != null ? ro.priceSnapshot : opt.price;
|
||||
salesByOptionMap[opt.id].quantitySold += ro.quantity;
|
||||
salesByOptionMap[opt.id].revenue += unitPrice * ro.quantity;
|
||||
salesByOptionMap[opt.id].revenue += computeOptionLineTotal(ro, null, new Date());
|
||||
}
|
||||
const salesByOption = Object.values(salesByOptionMap);
|
||||
// salesByOption is at list (or early-bird) price; this is what coupon codes took off it.
|
||||
const discountsTotal = Number(discountAgg?._sum?.discountTotal || 0);
|
||||
|
||||
return {
|
||||
event,
|
||||
@@ -220,6 +226,7 @@ async function computeEventFinancials(eventId) {
|
||||
unallocatedDonationsTotal,
|
||||
totalDonations,
|
||||
salesByOption,
|
||||
discountsTotal,
|
||||
history
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,332 @@
|
||||
/**
|
||||
* Parental consent for youth events.
|
||||
*
|
||||
* Rules:
|
||||
* - On an event with requiresParentalConsent, every attendee gives a date of birth.
|
||||
* Anyone under 18 on the event's start date needs a parent/guardian (name, email,
|
||||
* phone), who is sent a consent request by email AND WhatsApp — regardless of anyone's
|
||||
* notification preference.
|
||||
* - A request's link token is random; only its HMAC is stored. Links expire when the
|
||||
* event ends and can be re-sent (which issues a new token).
|
||||
* - The parent's answer records the time, IP, device, typed name, and the exact consent
|
||||
* text and version they saw. Medical/emergency details are stored encrypted and purged
|
||||
* a set number of days after the event (setting consent_medical_retention_days).
|
||||
* - Tickets are issued as normal but can't be checked in until consent is given — the
|
||||
* latest request for the attendee decides. On a consent event, a ticket with no named
|
||||
* attendee is treated as missing consent. Supervisors can override (audited).
|
||||
* - Reminders go 2 days after the request, then every 3 days, until answered or the event.
|
||||
*/
|
||||
|
||||
const crypto = require('crypto');
|
||||
const prisma = require('../config/db');
|
||||
|
||||
const DAY = 24 * 60 * 60 * 1000;
|
||||
const FIRST_REMINDER_AFTER = 2 * DAY;
|
||||
const REMINDER_EVERY = 3 * DAY;
|
||||
const DEFAULT_RETENTION_DAYS = 30;
|
||||
|
||||
function consentError(message, statusCode = 400) {
|
||||
const err = new Error(message);
|
||||
err.statusCode = statusCode;
|
||||
return err;
|
||||
}
|
||||
|
||||
// ─── Pure helpers ─────────────────────────────────────────────────────────────
|
||||
|
||||
/** Whole years old on `onDate`. */
|
||||
function ageOn(dateOfBirth, onDate) {
|
||||
const dob = new Date(dateOfBirth);
|
||||
const on = new Date(onDate);
|
||||
let age = on.getUTCFullYear() - dob.getUTCFullYear();
|
||||
const beforeBirthday = on.getUTCMonth() < dob.getUTCMonth()
|
||||
|| (on.getUTCMonth() === dob.getUTCMonth() && on.getUTCDate() < dob.getUTCDate());
|
||||
if (beforeBirthday) age -= 1;
|
||||
return age;
|
||||
}
|
||||
|
||||
/** Under 18 on the event's start date. */
|
||||
function isMinor(dateOfBirth, eventStart) {
|
||||
if (!dateOfBirth) return false;
|
||||
return ageOn(dateOfBirth, eventStart) < 18;
|
||||
}
|
||||
|
||||
/**
|
||||
* Consent state for one attendee: 'not_required' | 'missing' | 'pending' | 'given' | 'declined'.
|
||||
* `latest` is the attendee's most recent ConsentRequest (or null).
|
||||
*/
|
||||
function consentStatusFor(attendee, event, latest) {
|
||||
if (!event?.requiresParentalConsent) return 'not_required';
|
||||
if (!attendee) return 'missing'; // unnamed place on a consent event
|
||||
if (!isMinor(attendee.dateOfBirth, event.startDate)) return 'not_required';
|
||||
if (!latest) return 'missing';
|
||||
return latest.status;
|
||||
}
|
||||
|
||||
/** Whether a pending request is due a reminder now. */
|
||||
function reminderDue(request, now = new Date()) {
|
||||
if (!request || request.status !== 'pending') return false;
|
||||
const t = new Date(now).getTime();
|
||||
if (t >= new Date(request.expiresAt).getTime()) return false;
|
||||
const gap = request.remindersSent === 0 ? FIRST_REMINDER_AFTER : REMINDER_EVERY;
|
||||
return t - new Date(request.lastSentAt).getTime() >= gap;
|
||||
}
|
||||
|
||||
const str = (v, max = 500) => String(v ?? '').trim().slice(0, max);
|
||||
|
||||
/**
|
||||
* Checks a parent's answer on the consent page. Pure. Returns the cleaned answer.
|
||||
* @param {object} body - { decision: 'give'|'decline', signedName, isGuardian, medical, emergency, photoConsent }
|
||||
* @param {object} event - with consentCollectMedical / Emergency / Photo
|
||||
*/
|
||||
function validateConsentAnswer(body, event) {
|
||||
const decision = body?.decision;
|
||||
if (decision !== 'give' && decision !== 'decline') throw consentError('Choose to give or decline consent.');
|
||||
const signedName = str(body.signedName, 200);
|
||||
if (signedName.length < 2) throw consentError('Type your full name to sign.');
|
||||
if (body.isGuardian !== true) throw consentError('Confirm that you are the parent or legal guardian.');
|
||||
|
||||
const out = { decision, signedName, medical: null, emergency: null, photoConsent: null };
|
||||
if (decision === 'decline') return out;
|
||||
|
||||
if (event.consentCollectMedical) {
|
||||
const m = body.medical || {};
|
||||
out.medical = {
|
||||
allergies: str(m.allergies), medication: str(m.medication),
|
||||
conditions: str(m.conditions), medicalAid: str(m.medicalAid, 200),
|
||||
};
|
||||
}
|
||||
if (event.consentCollectEmergency) {
|
||||
const em = body.emergency || {};
|
||||
out.emergency = { name: str(em.name, 200), phone: str(em.phone, 40), relationship: str(em.relationship, 100) };
|
||||
if (!out.emergency.name || !out.emergency.phone) throw consentError('Add an emergency contact name and phone number.');
|
||||
}
|
||||
if (event.consentCollectPhoto) {
|
||||
if (body.photoConsent !== true && body.photoConsent !== false) throw consentError('Answer the photo consent question.');
|
||||
out.photoConsent = body.photoConsent;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// ─── Tokens ───────────────────────────────────────────────────────────────────
|
||||
|
||||
const hashToken = (token) => crypto.createHmac('sha256', process.env.JWT_SECRET || '').update(`consent:${token}`).digest('hex');
|
||||
const newToken = () => crypto.randomBytes(24).toString('hex');
|
||||
|
||||
function consentLink(token) {
|
||||
const base = (process.env.FRONTEND_URL || process.env.APP_BASE_URL || 'http://localhost:3001').split(',')[0].trim().replace(/\/$/, '');
|
||||
return `${base}/consent/${token}`;
|
||||
}
|
||||
|
||||
// ─── Encryption of medical/emergency details ──────────────────────────────────
|
||||
|
||||
function sealJson(value) {
|
||||
if (!value) return null;
|
||||
const { encrypt } = require('./encryption');
|
||||
return encrypt(JSON.stringify(value));
|
||||
}
|
||||
|
||||
function openJson(value) {
|
||||
if (!value) return null;
|
||||
try {
|
||||
const { decrypt } = require('./encryption');
|
||||
return JSON.parse(decrypt(value));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Database helpers ─────────────────────────────────────────────────────────
|
||||
|
||||
/** Latest request per attendee id. */
|
||||
async function latestRequestsFor(attendeeIds, db = prisma) {
|
||||
if (!attendeeIds.length) return new Map();
|
||||
const rows = await db.consentRequest.findMany({ where: { attendeeId: { in: attendeeIds } }, orderBy: { createdAt: 'desc' } });
|
||||
const out = new Map();
|
||||
for (const r of rows) if (!out.has(r.attendeeId)) out.set(r.attendeeId, r);
|
||||
return out;
|
||||
}
|
||||
|
||||
async function sendConsentMessage(request, token, { reminder = false } = {}) {
|
||||
const { sendMail, emailWrapper, ctaButton, fallbackLink } = require('./email');
|
||||
const { waTextAny } = require('./notify');
|
||||
const { isValidZAPhone } = require('./whatsapp');
|
||||
const { getSettingSync } = require('./settingsCache');
|
||||
const orgName = getSettingSync('org_name', process.env.ORG_NAME || 'Cross Code');
|
||||
const ff = `-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif`;
|
||||
|
||||
const event = request.event || await prisma.event.findUnique({ where: { id: request.eventId } });
|
||||
const attendee = request.attendee || await prisma.attendee.findUnique({ where: { id: request.attendeeId } });
|
||||
const link = consentLink(token);
|
||||
const when = event?.startDate ? new Date(event.startDate).toLocaleDateString('en-GB', { day: 'numeric', month: 'long', year: 'numeric' }) : '';
|
||||
const subject = `${reminder ? 'Reminder: ' : ''}Consent needed for ${attendee.name} — ${event.title}`;
|
||||
const intro = `${attendee.name} has been registered for <strong>${event.title}</strong>${when ? ` on <strong>${when}</strong>` : ''}. As their parent or guardian, please review the details and give (or decline) consent.`;
|
||||
const sent = [];
|
||||
|
||||
if (request.parentEmail && request.parentEmail.includes('@')) {
|
||||
const html = emailWrapper(`
|
||||
<p style="font-size:22px;font-weight:800;color:#0f172a;margin:0 0 8px 0">${reminder ? 'A reminder: ' : ''}Parental consent needed</p>
|
||||
<p style="margin:0 0 8px 0;color:#374151;font-family:${ff}">Hi <strong>${request.parentName || 'there'}</strong>,</p>
|
||||
<p style="margin:0 0 24px 0;color:#374151;font-family:${ff}">${intro}</p>
|
||||
${ctaButton('Review and give consent', link)}
|
||||
${fallbackLink(link)}
|
||||
<p style="font-size:13px;color:#64748b;margin:16px 0 0 0;font-family:${ff}">${attendee.name} can't be checked in at the event until consent is given.</p>
|
||||
`, { preheader: `Consent needed for ${attendee.name}` });
|
||||
try {
|
||||
await sendMail({ to: request.parentEmail, subject, html, text: `Hi ${request.parentName || 'there'},\n\n${intro.replace(/<[^>]+>/g, '')}\n\nReview and give consent: ${link}\n\n${orgName}` });
|
||||
sent.push('email');
|
||||
} catch (e) {
|
||||
console.warn('[consent] email failed:', e?.message || e);
|
||||
}
|
||||
}
|
||||
if (request.parentPhone && isValidZAPhone(request.parentPhone)) {
|
||||
await waTextAny({ name: request.parentName, phoneNumber: request.parentPhone },
|
||||
`${reminder ? '⏰ Reminder: ' : ''}Hi ${request.parentName || 'there'}, ${attendee.name} has been registered for *${event.title}*${when ? ` on ${when}` : ''}. Please give (or decline) parental consent here: ${link}`);
|
||||
sent.push('WhatsApp');
|
||||
}
|
||||
return sent;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates (and sends) a consent request for an attendee. Earlier requests stay as the
|
||||
* record of what was agreed before; this one now decides check-in.
|
||||
*/
|
||||
async function createConsentRequest(attendee, event) {
|
||||
const token = newToken();
|
||||
const request = await prisma.consentRequest.create({
|
||||
data: {
|
||||
attendeeId: attendee.id,
|
||||
eventId: event.id,
|
||||
tokenHash: hashToken(token),
|
||||
textVersion: event.consentTextVersion || 1,
|
||||
consentText: event.consentText || '',
|
||||
parentName: attendee.parentName,
|
||||
parentEmail: attendee.parentEmail,
|
||||
parentPhone: attendee.parentPhone,
|
||||
expiresAt: event.endDate,
|
||||
},
|
||||
});
|
||||
const sent = await sendConsentMessage({ ...request, event, attendee }, token);
|
||||
return { request, sent };
|
||||
}
|
||||
|
||||
/**
|
||||
* Makes sure every under-18 attendee on a registration has a consent request — creates
|
||||
* and sends one for anyone who has none yet, or whose parent details have changed since.
|
||||
*/
|
||||
async function ensureConsentRequests(registrationId) {
|
||||
const reg = await prisma.registration.findUnique({ where: { id: registrationId }, include: { event: true, attendees: true } });
|
||||
if (!reg || !reg.event.requiresParentalConsent || reg.status === 'cancelled') return 0;
|
||||
const minors = reg.attendees.filter(a => isMinor(a.dateOfBirth, reg.event.startDate));
|
||||
const latest = await latestRequestsFor(minors.map(a => a.id));
|
||||
let created = 0;
|
||||
for (const a of minors) {
|
||||
const r = latest.get(a.id);
|
||||
const parentChanged = r && (r.parentEmail !== a.parentEmail || r.parentPhone !== a.parentPhone);
|
||||
if (!r || (parentChanged && r.status !== 'given')) {
|
||||
await createConsentRequest(a, reg.event);
|
||||
created += 1;
|
||||
}
|
||||
}
|
||||
return created;
|
||||
}
|
||||
|
||||
/** Re-sends a request with a fresh link (the old link stops working). */
|
||||
async function resendConsentRequest(requestId) {
|
||||
const request = await prisma.consentRequest.findUnique({ where: { id: requestId }, include: { event: true, attendee: true } });
|
||||
if (!request) throw consentError('Consent request not found', 404);
|
||||
if (request.status !== 'pending') throw consentError(`Consent has already been ${request.status}.`);
|
||||
if (new Date(request.expiresAt) < new Date()) throw consentError('This event has ended.');
|
||||
const token = newToken();
|
||||
await prisma.consentRequest.update({ where: { id: request.id }, data: { tokenHash: hashToken(token), lastSentAt: new Date() } });
|
||||
return sendConsentMessage(request, token, { reminder: true });
|
||||
}
|
||||
|
||||
async function findRequestByToken(token) {
|
||||
if (!token || typeof token !== 'string' || token.length > 100) return null;
|
||||
return prisma.consentRequest.findUnique({
|
||||
where: { tokenHash: hashToken(token) },
|
||||
include: { event: true, attendee: { include: { registration: { include: { user: { select: { id: true, name: true, email: true, phoneNumber: true, notificationPreference: true } } } } } } },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Consent state for each ticket id — for the scan gate and staff screens.
|
||||
* @returns {Promise<Map<string, string>>} ticketId → consent status
|
||||
*/
|
||||
async function consentStatusForTickets(ticketIds) {
|
||||
const tickets = await prisma.ticket.findMany({ where: { id: { in: ticketIds } }, include: { event: true, attendee: true } });
|
||||
const latest = await latestRequestsFor(tickets.map(t => t.attendeeId).filter(Boolean));
|
||||
const out = new Map();
|
||||
for (const t of tickets) {
|
||||
out.set(t.id, consentStatusFor(t.attendee, t.event, t.attendeeId ? latest.get(t.attendeeId) : null));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Daily job: reminders for unanswered requests. */
|
||||
async function sendConsentReminders(now = new Date()) {
|
||||
const pending = await prisma.consentRequest.findMany({
|
||||
where: { status: 'pending', expiresAt: { gt: now } },
|
||||
include: { event: true, attendee: true },
|
||||
});
|
||||
// Only the latest request per attendee is still live.
|
||||
const latest = await latestRequestsFor([...new Set(pending.map(r => r.attendeeId))]);
|
||||
let sent = 0;
|
||||
for (const r of pending) {
|
||||
if (latest.get(r.attendeeId)?.id !== r.id || !reminderDue(r, now)) continue;
|
||||
if (new Date(r.event.startDate) <= now) continue; // the event has started
|
||||
const token = newToken();
|
||||
await prisma.consentRequest.update({ where: { id: r.id }, data: { tokenHash: hashToken(token), lastSentAt: now, remindersSent: { increment: 1 } } });
|
||||
await sendConsentMessage(r, token, { reminder: true }).catch(e => console.warn('[consent] reminder failed:', e?.message || e));
|
||||
sent += 1;
|
||||
}
|
||||
return sent;
|
||||
}
|
||||
|
||||
/** Daily job: delete medical/emergency details a set number of days after the event. */
|
||||
async function purgeConsentMedicalData(now = new Date()) {
|
||||
const { getSettingSync } = require('./settingsCache');
|
||||
const days = Math.max(0, parseInt(getSettingSync('consent_medical_retention_days', String(DEFAULT_RETENTION_DAYS)), 10) || DEFAULT_RETENTION_DAYS);
|
||||
const cutoff = new Date(new Date(now).getTime() - days * DAY);
|
||||
const res = await prisma.consentRequest.updateMany({
|
||||
where: { medicalPurgedAt: null, event: { endDate: { lt: cutoff } }, OR: [{ medical: { not: null } }, { emergency: { not: null } }] },
|
||||
data: { medical: null, emergency: null, medicalPurgedAt: now },
|
||||
});
|
||||
return res.count;
|
||||
}
|
||||
|
||||
/** Clears parent, medical and emergency details for a person's registrations (anonymise). */
|
||||
async function clearConsentDataForUser(userId, db = prisma) {
|
||||
const attendees = await db.attendee.findMany({ where: { OR: [{ userId }, { registration: { userId } }] }, select: { id: true } });
|
||||
const ids = attendees.map(a => a.id);
|
||||
if (!ids.length) return 0;
|
||||
await db.attendee.updateMany({ where: { id: { in: ids } }, data: { parentName: null, parentEmail: null, parentPhone: null, dateOfBirth: null, email: null, phone: null } });
|
||||
const res = await db.consentRequest.updateMany({
|
||||
where: { attendeeId: { in: ids } },
|
||||
data: { medical: null, emergency: null, parentName: null, parentEmail: null, parentPhone: null, ip: null, device: null, medicalPurgedAt: new Date() },
|
||||
});
|
||||
return res.count;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
DEFAULT_RETENTION_DAYS,
|
||||
consentError,
|
||||
ageOn,
|
||||
isMinor,
|
||||
consentStatusFor,
|
||||
reminderDue,
|
||||
validateConsentAnswer,
|
||||
hashToken,
|
||||
consentLink,
|
||||
sealJson,
|
||||
openJson,
|
||||
latestRequestsFor,
|
||||
createConsentRequest,
|
||||
ensureConsentRequests,
|
||||
resendConsentRequest,
|
||||
findRequestByToken,
|
||||
consentStatusForTickets,
|
||||
sendConsentReminders,
|
||||
purgeConsentMedicalData,
|
||||
clearConsentDataForUser,
|
||||
};
|
||||
@@ -0,0 +1,298 @@
|
||||
/**
|
||||
* Coupon codes — percentage, fixed-amount and comp (100%) discounts on registrations.
|
||||
*
|
||||
* Rules:
|
||||
* - A registration can carry one active coupon. Its discount is stored as a
|
||||
* CouponRedemption (amount locked at apply time) and mirrored as the sum of active
|
||||
* redemptions on Registration.discountTotal, which computeRegistrationTotalDue
|
||||
* subtracts. Early-bird pricing refreshes never touch it.
|
||||
* - Editing a registration's items recalculates percentage and comp codes over the new
|
||||
* items; fixed-amount codes keep their amount (capped at what's in scope).
|
||||
* - Cancelling a registration releases its redemption, freeing the use.
|
||||
* - Scope: no CouponOption rows = the whole event; otherwise only matching options
|
||||
* (or one variant of an option).
|
||||
* - stackEarlyBird=false means "best price wins": the coupon is worked out against the
|
||||
* standard price, and only the part that beats the early-bird price is discounted.
|
||||
* - Restricted codes (users, roles, email domain) need a known account, so guests
|
||||
* can't use them; a match on any one restriction is enough.
|
||||
*/
|
||||
|
||||
const prisma = require('../config/db');
|
||||
|
||||
function normalizeCode(code) {
|
||||
return String(code || '').trim().toUpperCase();
|
||||
}
|
||||
|
||||
function couponError(message) {
|
||||
const err = new Error(message);
|
||||
err.isCouponError = true;
|
||||
err.statusCode = 400;
|
||||
return err;
|
||||
}
|
||||
|
||||
const round2 = (n) => Math.round(Number(n || 0) * 100) / 100;
|
||||
|
||||
/**
|
||||
* Discountable lines from a registration — one per price tranche, so tickets bought at
|
||||
* different prices are each discounted at what was actually charged.
|
||||
* `basePrice` is the standard (non-early-bird) price, used for "best price wins".
|
||||
*/
|
||||
function buildCouponLines(registration) {
|
||||
const lines = [];
|
||||
for (const ro of registration?.registrationOptions || []) {
|
||||
const eo = ro.eventOption || {};
|
||||
const variant = ro.variantId ? (eo.variants || []).find(v => v.id === ro.variantId) : null;
|
||||
const standard = variant && variant.price !== null && variant.price !== undefined ? Number(variant.price) : Number(eo.price || 0);
|
||||
const tranches = ro.tranches && ro.tranches.length > 0
|
||||
? ro.tranches
|
||||
: [{ quantity: ro.quantity, priceSnapshot: ro.priceSnapshot ?? standard, appliedTierId: ro.appliedTierId }];
|
||||
for (const t of tranches) {
|
||||
const unitPrice = Number(t.priceSnapshot ?? standard);
|
||||
lines.push({
|
||||
eventOptionId: ro.eventOptionId,
|
||||
variantId: ro.variantId || null,
|
||||
quantity: Number(t.quantity || 0),
|
||||
unitPrice,
|
||||
// Only an early-bird tranche has a "standard" price above what was charged; a
|
||||
// staff price or plain price is already the base the coupon works from.
|
||||
basePrice: t.appliedTierId ? Math.max(standard, unitPrice) : unitPrice,
|
||||
});
|
||||
}
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
|
||||
function lineInScope(line, scopes) {
|
||||
if (!scopes || scopes.length === 0) return true;
|
||||
return scopes.some(s => s.eventOptionId === line.eventOptionId && (!s.variantId || s.variantId === line.variantId));
|
||||
}
|
||||
|
||||
function scopedLines(coupon, lines) {
|
||||
return (lines || []).filter(l => lineInScope(l, coupon.options));
|
||||
}
|
||||
|
||||
/**
|
||||
* The discount a coupon gives on these lines, in rand, never more than what the in-scope
|
||||
* lines cost. Pure — used for previews, applying, and recalculating after edits.
|
||||
*/
|
||||
function computeCouponDiscount(coupon, lines) {
|
||||
const scoped = scopedLines(coupon, lines);
|
||||
const charged = scoped.reduce((s, l) => s + l.unitPrice * l.quantity, 0);
|
||||
if (charged <= 0) return 0;
|
||||
|
||||
let discount = 0;
|
||||
if (coupon.type === 'comp') {
|
||||
discount = charged;
|
||||
} else if (coupon.type === 'percent') {
|
||||
const pct = Math.min(100, Math.max(0, Number(coupon.value || 0))) / 100;
|
||||
if (coupon.stackEarlyBird) {
|
||||
discount = charged * pct;
|
||||
} else {
|
||||
discount = scoped.reduce((s, l) => s + (l.unitPrice - Math.min(l.unitPrice, l.basePrice * (1 - pct))) * l.quantity, 0);
|
||||
}
|
||||
} else if (coupon.type === 'fixed') {
|
||||
const amount = Math.max(0, Number(coupon.value || 0));
|
||||
if (coupon.stackEarlyBird) {
|
||||
discount = amount;
|
||||
} else {
|
||||
const base = scoped.reduce((s, l) => s + l.basePrice * l.quantity, 0);
|
||||
discount = charged - Math.max(0, base - amount);
|
||||
}
|
||||
}
|
||||
return round2(Math.min(charged, Math.max(0, discount)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks whether a coupon can be used. Pure — the caller supplies the use counts.
|
||||
*
|
||||
* @param {object} coupon - with events[], options[], allowedUsers[]
|
||||
* @param {object} ctx - { eventId, user: {id, role, email}|null, now, usesTotal, usesByUser, lines,
|
||||
* deferRestrictions } — deferRestrictions lets staff preview a
|
||||
* restricted code before the attendee's account exists; it's
|
||||
* checked against the attendee when the registration is saved.
|
||||
* @returns {{ ok: true } | { ok: false, message: string }}
|
||||
*/
|
||||
function validateCoupon(coupon, ctx) {
|
||||
const no = (message) => ({ ok: false, message });
|
||||
if (!coupon) return no('That code isn\'t valid.');
|
||||
if (!coupon.active) return no('This code is no longer active.');
|
||||
const now = ctx.now ? new Date(ctx.now) : new Date();
|
||||
if (coupon.validFrom && now < new Date(coupon.validFrom)) return no('This code isn\'t valid yet.');
|
||||
if (coupon.validUntil && now > new Date(coupon.validUntil)) return no('This code has expired.');
|
||||
if ((coupon.events || []).length > 0 && !coupon.events.some(e => e.eventId === ctx.eventId)) {
|
||||
return no('This code isn\'t valid for this event.');
|
||||
}
|
||||
|
||||
const restrictedUsers = (coupon.allowedUsers || []).map(u => u.userId);
|
||||
const restrictedRoles = coupon.restrictRoles || [];
|
||||
const domain = String(coupon.restrictDomain || '').trim().toLowerCase().replace(/^@/, '');
|
||||
if ((restrictedUsers.length || restrictedRoles.length || domain) && !(ctx.deferRestrictions && !ctx.user)) {
|
||||
const user = ctx.user;
|
||||
if (!user) return no('Log in to use this code.');
|
||||
const email = String(user.email || '').toLowerCase();
|
||||
const allowed = restrictedUsers.includes(user.id)
|
||||
|| restrictedRoles.includes(user.role)
|
||||
|| (domain && email.endsWith(`@${domain}`));
|
||||
if (!allowed) return no('This code isn\'t available for your account.');
|
||||
}
|
||||
|
||||
if (coupon.maxUses !== null && coupon.maxUses !== undefined && (ctx.usesTotal || 0) >= coupon.maxUses) {
|
||||
return no('This code has been fully used.');
|
||||
}
|
||||
if (coupon.maxUsesPerUser !== null && coupon.maxUsesPerUser !== undefined && ctx.user && (ctx.usesByUser || 0) >= coupon.maxUsesPerUser) {
|
||||
return no('You\'ve already used this code.');
|
||||
}
|
||||
|
||||
const scopedQty = scopedLines(coupon, ctx.lines).reduce((s, l) => s + l.quantity, 0);
|
||||
if (scopedQty === 0) return no('This code doesn\'t apply to the items you\'ve chosen.');
|
||||
if (coupon.minQuantity && scopedQty < coupon.minQuantity) {
|
||||
return no(`This code needs at least ${coupon.minQuantity} qualifying ticket${coupon.minQuantity === 1 ? '' : 's'}.`);
|
||||
}
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
/** Short human description, e.g. "20% off", "R50 off", "Free". */
|
||||
function describeCoupon(coupon) {
|
||||
if (!coupon) return '';
|
||||
if (coupon.type === 'comp') return 'Free';
|
||||
if (coupon.type === 'percent') return `${Number(coupon.value)}% off`;
|
||||
return `R${Number(coupon.value).toFixed(2)} off`;
|
||||
}
|
||||
|
||||
// ─── Database helpers ─────────────────────────────────────────────────────────
|
||||
|
||||
const COUPON_INCLUDE = { events: true, options: true, allowedUsers: true };
|
||||
|
||||
const REGISTRATION_INCLUDE = {
|
||||
registrationOptions: { include: { tranches: true, eventOption: { include: { variants: true } } } },
|
||||
couponRedemptions: { where: { releasedAt: null }, include: { coupon: { include: COUPON_INCLUDE } } },
|
||||
user: { select: { id: true, role: true, email: true } },
|
||||
};
|
||||
|
||||
async function findCouponByCode(code, db = prisma) {
|
||||
const normalized = normalizeCode(code);
|
||||
if (!normalized) return null;
|
||||
return db.coupon.findUnique({ where: { code: normalized }, include: COUPON_INCLUDE });
|
||||
}
|
||||
|
||||
/** Active uses: unreleased redemptions on registrations that aren't cancelled. */
|
||||
async function countCouponUses(couponId, userId, db = prisma) {
|
||||
const base = { couponId, releasedAt: null, registration: { status: { not: 'cancelled' } } };
|
||||
const [usesTotal, usesByUser] = await Promise.all([
|
||||
db.couponRedemption.count({ where: base }),
|
||||
userId ? db.couponRedemption.count({ where: { ...base, userId } }) : Promise.resolve(0),
|
||||
]);
|
||||
return { usesTotal, usesByUser };
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates `code` against hypothetical lines (nothing saved) and returns the discount it
|
||||
* would give. Throws a coupon error with a user-facing message when it can't be used.
|
||||
*/
|
||||
async function previewCoupon({ code, eventId, user, lines, deferRestrictions = false }) {
|
||||
const coupon = await findCouponByCode(code);
|
||||
const uses = coupon ? await countCouponUses(coupon.id, user?.id) : {};
|
||||
const check = validateCoupon(coupon, { eventId, user, lines, deferRestrictions, ...uses });
|
||||
if (!check.ok) throw couponError(check.message);
|
||||
return { coupon, discount: computeCouponDiscount(coupon, lines) };
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies `code` to a saved registration: validates it against the registration's owner
|
||||
* and items, locks in the discount, and updates Registration.discountTotal. Runs under a
|
||||
* per-coupon advisory lock so the last available use can't be claimed twice.
|
||||
*/
|
||||
async function applyCouponToRegistration({ registrationId, code, appliedById = null }) {
|
||||
const normalized = normalizeCode(code);
|
||||
if (!normalized) throw couponError('Enter a code.');
|
||||
|
||||
return prisma.$transaction(async (tx) => {
|
||||
const coupon = await findCouponByCode(normalized, tx);
|
||||
if (!coupon) throw couponError('That code isn\'t valid.');
|
||||
await tx.$executeRaw`SELECT pg_advisory_xact_lock(hashtext(${coupon.id}))`;
|
||||
|
||||
const registration = await tx.registration.findUnique({ where: { id: registrationId }, include: REGISTRATION_INCLUDE });
|
||||
if (!registration) throw couponError('Registration not found.');
|
||||
if (registration.couponRedemptions.length > 0) {
|
||||
throw couponError(registration.couponRedemptions[0].couponId === coupon.id
|
||||
? 'This code is already applied to this registration.'
|
||||
: 'A code has already been applied to this registration.');
|
||||
}
|
||||
|
||||
const lines = buildCouponLines(registration);
|
||||
const uses = await countCouponUses(coupon.id, registration.userId, tx);
|
||||
const check = validateCoupon(coupon, { eventId: registration.eventId, user: registration.user, lines, ...uses });
|
||||
if (!check.ok) throw couponError(check.message);
|
||||
|
||||
const discount = computeCouponDiscount(coupon, lines);
|
||||
const redemption = await tx.couponRedemption.create({
|
||||
data: { couponId: coupon.id, registrationId, userId: registration.userId, discountAmount: discount, appliedById },
|
||||
});
|
||||
await tx.registration.update({ where: { id: registrationId }, data: { discountTotal: discount, updatedAt: new Date() } });
|
||||
return { coupon, discount, redemption };
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-works active discounts after a registration's items change: percentage and comp
|
||||
* codes are recalculated over the new items, fixed codes keep their amount (capped at
|
||||
* what's still in scope). Pass `tx` to run inside the caller's transaction.
|
||||
*/
|
||||
async function recalculateRegistrationDiscount(registrationId, db = prisma) {
|
||||
const registration = await db.registration.findUnique({ where: { id: registrationId }, include: REGISTRATION_INCLUDE });
|
||||
if (!registration) return 0;
|
||||
if (registration.couponRedemptions.length === 0) {
|
||||
if (registration.discountTotal !== 0) {
|
||||
await db.registration.update({ where: { id: registrationId }, data: { discountTotal: 0 } });
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
const lines = buildCouponLines(registration);
|
||||
let total = 0;
|
||||
for (const r of registration.couponRedemptions) {
|
||||
let amount;
|
||||
if (r.coupon.type === 'fixed') {
|
||||
const inScope = scopedLines(r.coupon, lines).reduce((s, l) => s + l.unitPrice * l.quantity, 0);
|
||||
amount = round2(Math.min(r.discountAmount, inScope));
|
||||
} else {
|
||||
amount = computeCouponDiscount(r.coupon, lines);
|
||||
}
|
||||
if (Math.abs(amount - r.discountAmount) > 0.001) {
|
||||
await db.couponRedemption.update({ where: { id: r.id }, data: { discountAmount: amount } });
|
||||
}
|
||||
total += amount;
|
||||
}
|
||||
total = round2(total);
|
||||
if (Math.abs(total - registration.discountTotal) > 0.001) {
|
||||
await db.registration.update({ where: { id: registrationId }, data: { discountTotal: total } });
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
/** Releases a cancelled registration's coupon uses so they can be claimed again. */
|
||||
async function releaseRegistrationCoupons(registrationId, db = prisma) {
|
||||
const released = await db.couponRedemption.updateMany({
|
||||
where: { registrationId, releasedAt: null },
|
||||
data: { releasedAt: new Date() },
|
||||
});
|
||||
if (released.count > 0) {
|
||||
await db.registration.update({ where: { id: registrationId }, data: { discountTotal: 0 } });
|
||||
}
|
||||
return released.count;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
normalizeCode,
|
||||
couponError,
|
||||
buildCouponLines,
|
||||
computeCouponDiscount,
|
||||
validateCoupon,
|
||||
describeCoupon,
|
||||
findCouponByCode,
|
||||
countCouponUses,
|
||||
previewCoupon,
|
||||
applyCouponToRegistration,
|
||||
recalculateRegistrationDiscount,
|
||||
releaseRegistrationCoupons,
|
||||
};
|
||||
@@ -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 };
|
||||
@@ -49,6 +49,11 @@ function _getTransporter() {
|
||||
return { transporter: _cachedTransporter, cfg };
|
||||
}
|
||||
|
||||
/** True when a real SMTP server is configured (not the jsonTransport dev fallback). */
|
||||
function isSmtpConfigured() {
|
||||
return !!_smtpConfig().host;
|
||||
}
|
||||
|
||||
async function sendMail({ to, subject, html, text, attachments }) {
|
||||
// Never send to anonymised/deleted accounts
|
||||
if (!to || String(to).endsWith('@deleted.invalid')) {
|
||||
@@ -56,7 +61,7 @@ async function sendMail({ to, subject, html, text, attachments }) {
|
||||
return;
|
||||
}
|
||||
const { transporter, cfg } = _getTransporter();
|
||||
const from = cfg.from || 'no-reply@hope-events.local';
|
||||
const from = cfg.from || 'no-reply@crosscode.local';
|
||||
const info = await transporter.sendMail({ from, to, subject, html, text, ...(attachments ? { attachments } : {}) });
|
||||
|
||||
if (transporter.options && transporter.options.jsonTransport) {
|
||||
@@ -74,12 +79,17 @@ async function sendMail({ to, subject, html, text, attachments }) {
|
||||
|
||||
function getOrg() {
|
||||
const urlFallback = process.env.APP_BASE_URL || process.env.FRONTEND_URL || 'http://localhost:3001';
|
||||
// headerColor prefers the new primary_color setting; falls back to the
|
||||
// legacy accent_color value (which used to double as "the one brand color"
|
||||
// before the 3-color Primary/Secondary/Accent system existed) so emails
|
||||
// stay branded correctly until the admin re-saves the Branding tab.
|
||||
const primary = getSettingSync('primary_color', '') || getSettingSync('accent_color', '');
|
||||
return {
|
||||
name: getSettingSync('org_name', process.env.ORG_NAME || 'Hope Events'),
|
||||
name: getSettingSync('org_name', process.env.ORG_NAME || 'Cross Code'),
|
||||
tagline: getSettingSync('org_tagline', process.env.ORG_TAGLINE || 'Connecting community through events'),
|
||||
email: getSettingSync('smtp_from', process.env.EMAIL_FROM || process.env.EMAIL_USER || ''),
|
||||
url: getSettingSync('app_base_url', urlFallback).replace(/\/$/, ''),
|
||||
headerColor: getSettingSync('accent_color', process.env.EMAIL_HEADER_COLOR || '#1e3a5f'),
|
||||
headerColor: primary || process.env.EMAIL_HEADER_COLOR || '#1e3a5f',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -137,8 +147,9 @@ ${preheader ? `<div style="display:none;font-size:1px;line-height:1px;max-height
|
||||
</html>`;
|
||||
}
|
||||
|
||||
/** Renders a prominent CTA button. */
|
||||
function ctaButton(label, url, { bg = '#2563eb', fg = '#ffffff' } = {}) {
|
||||
/** Renders a prominent CTA button. Defaults to the org's brand color unless a semantic override (e.g. green for "activate", dark neutral for "log in") is passed explicitly. */
|
||||
function ctaButton(label, url, { bg, fg = '#ffffff' } = {}) {
|
||||
bg = bg || getOrg().headerColor;
|
||||
return `<table role="presentation" cellpadding="0" cellspacing="0" border="0" style="margin:28px auto 8px auto">
|
||||
<tr><td align="center" style="border-radius:8px;background-color:${bg};mso-padding-alt:0px">
|
||||
<a href="${url}" target="_blank"
|
||||
@@ -150,8 +161,17 @@ function ctaButton(label, url, { bg = '#2563eb', fg = '#ffffff' } = {}) {
|
||||
|
||||
/** Renders a fallback link below a CTA button. */
|
||||
function fallbackLink(url) {
|
||||
const color = getOrg().headerColor;
|
||||
return `<p style="text-align:center;margin:4px 0 0 0;font-size:12px;color:#94a3b8;word-break:break-all">
|
||||
Or copy this link: <a href="${url}" style="color:#2563eb">${url}</a>
|
||||
Or copy this link: <a href="${url}" style="color:${color}">${url}</a>
|
||||
</p>`;
|
||||
}
|
||||
|
||||
/** Renders a subtle, labeled secondary link — distinct from the primary CTA button. */
|
||||
function secondaryLink(label, url) {
|
||||
const color = getOrg().headerColor;
|
||||
return `<p style="text-align:center;margin:16px 0 0 0;font-size:13px;color:#64748b">
|
||||
<a href="${url}" style="color:${color}">${label}</a>
|
||||
</p>`;
|
||||
}
|
||||
|
||||
@@ -160,7 +180,12 @@ function divider() {
|
||||
return `<div style="border-top:1px solid #f1f5f9;margin:32px 0"></div>`;
|
||||
}
|
||||
|
||||
/** Coloured callout box. type: info | success | warning | danger | neutral */
|
||||
/**
|
||||
* Coloured callout box. type: info | success | warning | danger | neutral
|
||||
* These 4 semantic colors are deliberately NOT brand-driven — a "this wasn't
|
||||
* you" security warning must always read as urgent/red regardless of the
|
||||
* org's brand color, so don't wire these to getOrg().headerColor.
|
||||
*/
|
||||
function callout(content, type = 'info') {
|
||||
const map = {
|
||||
info: { bg: '#eff6ff', border: '#3b82f6', color: '#1e40af' },
|
||||
@@ -177,9 +202,10 @@ function callout(content, type = 'info') {
|
||||
|
||||
/** Numbered payment option row. */
|
||||
function paymentOption(num, title, detail) {
|
||||
const bg = getOrg().headerColor;
|
||||
return `<tr>
|
||||
<td style="padding:14px 16px 14px 0;vertical-align:top;width:28px">
|
||||
<div style="width:26px;height:26px;border-radius:50%;background:#2563eb;color:#fff;font-size:13px;font-weight:700;text-align:center;line-height:26px;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif">${num}</div>
|
||||
<div style="width:26px;height:26px;border-radius:50%;background:${bg};color:#fff;font-size:13px;font-weight:700;text-align:center;line-height:26px;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif">${num}</div>
|
||||
</td>
|
||||
<td style="padding:14px 0;border-bottom:1px solid #f1f5f9;vertical-align:top">
|
||||
<p style="margin:0 0 4px 0;font-size:14px;font-weight:700;color:#1e293b;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif">${title}</p>
|
||||
@@ -213,6 +239,28 @@ function buildPasswordResetEmail({ name, resetUrl }) {
|
||||
return { text, html: emailWrapper(body, { preheader }) };
|
||||
}
|
||||
|
||||
function buildTwoFactorCodeEmail({ name, code }) {
|
||||
const org = getOrg();
|
||||
const preheader = `Your ${org.name} verification code. It expires in 10 minutes.`;
|
||||
const body = `
|
||||
<p style="font-size:22px;font-weight:800;color:#0f172a;margin:0 0 8px 0;letter-spacing:-0.3px">Your verification code</p>
|
||||
<p style="color:#64748b;font-size:14px;margin:0 0 32px 0">Two-factor authentication for your account</p>
|
||||
|
||||
<p style="margin:0 0 8px 0;color:#374151">Hi <strong>${name || 'there'}</strong>,</p>
|
||||
<p style="margin:0 0 24px 0;color:#374151">Enter this code to continue. It will expire in <strong>10 minutes</strong>.</p>
|
||||
|
||||
<p style="margin:0 0 24px 0;font-size:34px;font-weight:800;letter-spacing:8px;color:#0f172a;text-align:center;font-family:'SFMono-Regular',Consolas,'Liberation Mono',Menlo,monospace">${code}</p>
|
||||
|
||||
${divider()}
|
||||
<p style="margin:0;font-size:13px;color:#94a3b8">
|
||||
Never share this code with anyone. If you did not try to log in, someone may know your password —
|
||||
change it and contact us at <a href="mailto:${org.email}" style="color:#2563eb">${org.email}</a>.
|
||||
</p>`;
|
||||
|
||||
const text = `Hi ${name || 'there'},\n\nYour ${org.name} verification code is: ${code}\n\nIt expires in 10 minutes. Never share this code with anyone.\n\nIf you did not try to log in, change your password and contact ${org.email}.\n\n${org.name} — ${org.email}`;
|
||||
return { text, html: emailWrapper(body, { preheader }) };
|
||||
}
|
||||
|
||||
function buildPasswordChangedEmail({ name, when, supportEmail }) {
|
||||
const org = getOrg();
|
||||
const contact = supportEmail || org.email;
|
||||
@@ -374,13 +422,16 @@ function buildAccountClosedEmail({ name, dataDeleted }) {
|
||||
|
||||
module.exports = {
|
||||
sendMail,
|
||||
isSmtpConfigured,
|
||||
emailWrapper,
|
||||
ctaButton,
|
||||
fallbackLink,
|
||||
secondaryLink,
|
||||
divider,
|
||||
callout,
|
||||
paymentOption,
|
||||
buildPasswordResetEmail,
|
||||
buildTwoFactorCodeEmail,
|
||||
buildPasswordChangedEmail,
|
||||
buildLoginNotificationEmail,
|
||||
buildWelcomeEmail,
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
const { createEvent } = require('ics');
|
||||
|
||||
// Event.startDate/endDate are stored as true UTC instants (the admin event form's
|
||||
// datetime-local input is parsed in the browser's local time before being sent as an
|
||||
// ISO string), so serializing them as UTC here requires no timezone math and lets every
|
||||
// viewer's calendar app localize correctly to *their own* timezone.
|
||||
function toUtcArray(date) {
|
||||
const d = new Date(date);
|
||||
return [d.getUTCFullYear(), d.getUTCMonth() + 1, d.getUTCDate(), d.getUTCHours(), d.getUTCMinutes()];
|
||||
}
|
||||
|
||||
/**
|
||||
* Build an RFC 5545 .ics file (as a string) for a single event.
|
||||
*
|
||||
* @param {object} event - Prisma Event row: title, description?, startDate, endDate, location?
|
||||
* @param {string} eventUrl - absolute URL to the event's public page
|
||||
* @returns {string}
|
||||
*/
|
||||
function buildEventIcs(event, eventUrl) {
|
||||
const { error, value } = createEvent({
|
||||
title: event.title,
|
||||
start: toUtcArray(event.startDate),
|
||||
end: toUtcArray(event.endDate),
|
||||
startInputType: 'utc',
|
||||
endInputType: 'utc',
|
||||
startOutputType: 'utc',
|
||||
endOutputType: 'utc',
|
||||
location: event.location || undefined,
|
||||
description: event.description || undefined,
|
||||
url: eventUrl,
|
||||
});
|
||||
if (error) throw error;
|
||||
return value;
|
||||
}
|
||||
|
||||
module.exports = { buildEventIcs };
|
||||
@@ -1,6 +1,8 @@
|
||||
const fs = require('fs');
|
||||
const prisma = require('../config/db');
|
||||
const { sendMail, emailWrapper, ctaButton, fallbackLink, divider, callout, paymentOption } = require('./email');
|
||||
const { computeRegistrationTotalDue } = require('./pricing');
|
||||
const { sendMail, emailWrapper, ctaButton, fallbackLink, secondaryLink, divider, callout, paymentOption } = require('./email');
|
||||
const { computeRegistrationTotalDue, computeOptionLineTotal } = require('./pricing');
|
||||
const { couponDiscountLine } = require('./pdfDocs');
|
||||
|
||||
// ─── Formatting helpers ───────────────────────────────────────────────────────
|
||||
|
||||
@@ -21,9 +23,23 @@ function fmtDateShort(d) {
|
||||
|
||||
const { getSettingSync } = require('./settingsCache');
|
||||
|
||||
// The .ics calendar-download link lives on the backend (not the frontend site), same
|
||||
// as the ticket-PDF URLs sent to WhatsApp — see whatsapp.js's BACKEND_URL usage.
|
||||
function getBackendUrl() {
|
||||
return (process.env.BACKEND_URL || '').replace(/\/$/, '');
|
||||
}
|
||||
|
||||
// Only rendered when BACKEND_URL is actually configured — the .ics endpoint lives on
|
||||
// the backend, and there's no reliable way to derive that URL otherwise.
|
||||
function calendarLinkRow(eventId) {
|
||||
const backendUrl = getBackendUrl();
|
||||
if (!backendUrl || !eventId) return '';
|
||||
return secondaryLink('Add to calendar', `${backendUrl}/api/events/${eventId}/ics`);
|
||||
}
|
||||
|
||||
function getOrg() {
|
||||
return {
|
||||
name: getSettingSync('org_name', process.env.ORG_NAME || 'Hope Events'),
|
||||
name: getSettingSync('org_name', process.env.ORG_NAME || 'Cross Code'),
|
||||
email: process.env.EMAIL_FROM || process.env.EMAIL_USER || '',
|
||||
url: (process.env.FRONTEND_URL || process.env.APP_BASE_URL || 'http://localhost:3001').replace(/\/$/, ''),
|
||||
};
|
||||
@@ -72,8 +88,9 @@ async function loadRegistrationFull(registrationId) {
|
||||
return prisma.registration.findUnique({
|
||||
where: { id: registrationId },
|
||||
include: {
|
||||
registrationOptions: { include: { eventOption: { include: { earlyBirdTiers: true } } } },
|
||||
registrationOptions: { include: { eventOption: { include: { earlyBirdTiers: true } }, variant: true, tranches: true } },
|
||||
payments: true,
|
||||
couponRedemptions: { where: { releasedAt: null }, include: { coupon: { select: { code: true } } } },
|
||||
user: { select: { id: true, name: true, email: true, phoneNumber: true, isActive: true, notificationPreference: true } },
|
||||
event: { include: { createdBy: { select: { id: true, name: true, email: true } }, notifyRecipients: { select: { id: true, name: true, email: true } } } },
|
||||
},
|
||||
@@ -86,11 +103,31 @@ async function loadTicketFull(ticketId) {
|
||||
include: {
|
||||
user: { select: { id: true, name: true, email: true, phoneNumber: true, notificationPreference: true } },
|
||||
event: true,
|
||||
registrationOption: { include: { eventOption: true, variant: true } },
|
||||
registrationOption: { include: { eventOption: true, variant: true, registration: { select: { ticketDelivery: true } } } },
|
||||
attendee: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Who a ticket's messages go to: a named attendee with contact details when the
|
||||
* registration sends each person their own tickets, otherwise the payer.
|
||||
*/
|
||||
function ticketRecipient(ticket) {
|
||||
const a = ticket.attendee;
|
||||
if (a && (a.email || a.phone) && ticket.registrationOption?.registration?.ticketDelivery === 'each') {
|
||||
const hasEmail = !!(a.email && a.email.includes('@'));
|
||||
return {
|
||||
id: a.userId || null,
|
||||
name: a.name,
|
||||
email: hasEmail ? a.email : null,
|
||||
phoneNumber: a.phone || null,
|
||||
notificationPreference: hasEmail && a.phone ? 'both' : hasEmail ? 'email' : 'whatsapp',
|
||||
};
|
||||
}
|
||||
return ticket.user;
|
||||
}
|
||||
|
||||
async function loadPaymentFull(paymentId) {
|
||||
return prisma.payment.findUnique({
|
||||
where: { id: paymentId },
|
||||
@@ -98,8 +135,9 @@ async function loadPaymentFull(paymentId) {
|
||||
user: { select: { id: true, name: true, email: true, phoneNumber: true, notificationPreference: true } },
|
||||
registration: {
|
||||
include: {
|
||||
registrationOptions: { include: { eventOption: { include: { earlyBirdTiers: true } } } },
|
||||
registrationOptions: { include: { eventOption: { include: { earlyBirdTiers: true } }, variant: true, tranches: true } },
|
||||
payments: true,
|
||||
couponRedemptions: { where: { releasedAt: null }, include: { coupon: { select: { code: true } } } },
|
||||
user: { select: { id: true, name: true, email: true, phoneNumber: true, isActive: true, notificationPreference: true } },
|
||||
event: { include: { createdBy: { select: { id: true, name: true, email: true } }, notifyRecipients: { select: { id: true, name: true, email: true } } } },
|
||||
},
|
||||
@@ -111,21 +149,28 @@ async function loadPaymentFull(paymentId) {
|
||||
|
||||
// ─── Shared building blocks ───────────────────────────────────────────────────
|
||||
|
||||
/** Renders the selections summary table. */
|
||||
function selectionsTable(registrationOptions) {
|
||||
/** Renders the selections summary table, with the coupon discount (if any) as its own row. */
|
||||
function selectionsTable(registrationOptions, couponLine = null) {
|
||||
const rows = (registrationOptions || []).map(ro => {
|
||||
const name = ro.eventOption?.name || 'Option';
|
||||
const qty = ro.quantity || 1;
|
||||
const price = ro.eventOption?.price || 0;
|
||||
const lineTotal = computeOptionLineTotal(ro, null, new Date());
|
||||
return `<tr>
|
||||
<td style="padding:10px 16px 10px 0;font-size:14px;color:#374151;font-family:${ff};border-bottom:1px solid #f1f5f9">${name}</td>
|
||||
<td style="padding:10px 0;font-size:14px;color:#374151;text-align:center;font-family:${ff};border-bottom:1px solid #f1f5f9">×${qty}</td>
|
||||
<td style="padding:10px 0 10px 16px;font-size:14px;color:#374151;text-align:right;font-weight:500;font-family:${ff};border-bottom:1px solid #f1f5f9">${fmtAmount(price * qty)}</td>
|
||||
<td style="padding:10px 0 10px 16px;font-size:14px;color:#374151;text-align:right;font-weight:500;font-family:${ff};border-bottom:1px solid #f1f5f9">${fmtAmount(lineTotal)}</td>
|
||||
</tr>`;
|
||||
});
|
||||
|
||||
if (!rows.length) return `<p style="color:#94a3b8;font-size:14px;margin:0">No items</p>`;
|
||||
|
||||
if (couponLine) {
|
||||
rows.push(`<tr>
|
||||
<td colspan="2" style="padding:10px 16px 10px 0;font-size:14px;color:#059669;font-family:${ff};border-bottom:1px solid #f1f5f9">${couponLine.label}</td>
|
||||
<td style="padding:10px 0 10px 16px;font-size:14px;color:#059669;text-align:right;font-weight:500;font-family:${ff};border-bottom:1px solid #f1f5f9">-${fmtAmount(couponLine.amount)}</td>
|
||||
</tr>`);
|
||||
}
|
||||
|
||||
return `<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="border:1px solid #e2e8f0;border-radius:8px;overflow:hidden;margin:0 0 4px 0">
|
||||
<tr style="background:#f8fafc">
|
||||
<th style="padding:10px 16px 10px 0;font-size:12px;font-weight:700;color:#64748b;text-align:left;text-transform:uppercase;letter-spacing:0.5px;font-family:${ff}">Item</th>
|
||||
@@ -136,6 +181,14 @@ function selectionsTable(registrationOptions) {
|
||||
</table>`;
|
||||
}
|
||||
|
||||
/** Plain-text selections, one bullet per item plus the coupon discount. */
|
||||
function selectionsText(reg) {
|
||||
const lines = (reg.registrationOptions || []).map(ro => ` • ${ro.eventOption?.name || 'Option'} ×${ro.quantity} — ${fmtAmount(computeOptionLineTotal(ro, null, new Date()))}`);
|
||||
const coupon = couponDiscountLine(reg);
|
||||
if (coupon) lines.push(` • ${coupon.label} — -${fmtAmount(coupon.amount)}`);
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
/** Renders a financial summary line. */
|
||||
function financialSummary(totalDue, totalPaid, balance) {
|
||||
const ff2 = ff;
|
||||
@@ -264,13 +317,14 @@ function buildRegistrationConfirmation(reg, { isNew = true } = {}) {
|
||||
<p style="margin:0 0 28px 0;color:#374151;font-family:${ff}">${subtext}${eventDate ? ` — <strong>${eventDate}</strong>` : ''}</p>
|
||||
|
||||
<p style="font-size:16px;font-weight:700;color:#0f172a;margin:0 0 12px 0;font-family:${ff}">Your registration</p>
|
||||
${selectionsTable(reg.registrationOptions)}
|
||||
${selectionsTable(reg.registrationOptions, couponDiscountLine(reg))}
|
||||
${financialSummary(totalDue, totalPaid, balance)}
|
||||
|
||||
${paymentSection({ balance, yocoLink: null, source: 'user', siteUrl: org.url, formRequired: false, isUserActive })}
|
||||
${accountCta(isUserActive, org.url)}`;
|
||||
${accountCta(isUserActive, org.url)}
|
||||
${calendarLinkRow(reg.eventId)}`;
|
||||
|
||||
const itemsText = (reg.registrationOptions || []).map(ro => ` • ${ro.eventOption?.name || 'Option'} ×${ro.quantity} — ${fmtAmount((ro.eventOption?.price || 0) * ro.quantity)}`).join('\n');
|
||||
const itemsText = selectionsText(reg);
|
||||
const text = `${heading}\n\nHi ${reg.user?.name || 'there'},\n\n${isNew ? `You are registered for ${eventTitle}` : `Your registration for ${eventTitle} has been updated`}${eventDate ? ' on ' + eventDate : ''}.\n\nYour selections:\n${itemsText || ' —'}\n\nTotal due: ${fmtAmount(totalDue)}\nAmount paid: ${fmtAmount(totalPaid)}\nBalance: ${fmtAmount(balance)}\n\n${balance > 0 ? `Payment options:\n 1. On our website: ${org.url}\n 2. At the door (cash or card)\n\nYour tickets will be sent once payment is confirmed.` : 'No payment required — your tickets have been sent separately.'}\n\n${org.name} — ${org.email}\n${org.url}`;
|
||||
|
||||
return { subject, text, html: emailWrapper(body, { preheader }) };
|
||||
@@ -304,13 +358,14 @@ function buildAdminInitiatedRegistrationConfirmation(reg, { yocoLink = null, for
|
||||
<p style="margin:0 0 28px 0;color:#374151;font-family:${ff}">${subtext}${eventDate ? ` The event takes place on <strong>${eventDate}</strong>.` : ''}</p>
|
||||
|
||||
<p style="font-size:16px;font-weight:700;color:#0f172a;margin:0 0 12px 0;font-family:${ff}">Your registration</p>
|
||||
${selectionsTable(reg.registrationOptions)}
|
||||
${selectionsTable(reg.registrationOptions, couponDiscountLine(reg))}
|
||||
${financialSummary(totalDue, totalPaid, balance)}
|
||||
|
||||
${paymentSection({ balance, yocoLink, source: 'admin', siteUrl: org.url, formRequired, isUserActive })}
|
||||
${accountCta(isUserActive, org.url)}`;
|
||||
${accountCta(isUserActive, org.url)}
|
||||
${calendarLinkRow(reg.eventId)}`;
|
||||
|
||||
const itemsText = (reg.registrationOptions || []).map(ro => ` • ${ro.eventOption?.name || 'Option'} ×${ro.quantity} — ${fmtAmount((ro.eventOption?.price || 0) * ro.quantity)}`).join('\n');
|
||||
const itemsText = selectionsText(reg);
|
||||
const payText = balance > 0
|
||||
? `Payment options:\n${yocoLink ? ` 1. Pay online (Yoco): ${yocoLink}\n (Already paid? Ignore this option)\n` : ''} ${yocoLink ? '2' : '1'}. On our website: ${org.url}\n ${yocoLink ? '3' : '2'}. At the door (cash or card)\n\nYour tickets will be sent once payment is confirmed.`
|
||||
: formRequired
|
||||
@@ -338,7 +393,7 @@ function buildRegistrationAdminNotice(reg, { isNew = true, isUpdated = false } =
|
||||
`<tr>
|
||||
<td style="padding:8px 12px;font-size:13px;color:#374151;font-family:${ff};border-bottom:1px solid #f1f5f9">${ro.eventOption?.name || 'Option'}</td>
|
||||
<td style="padding:8px 12px;font-size:13px;color:#374151;text-align:center;font-family:${ff};border-bottom:1px solid #f1f5f9">×${ro.quantity}</td>
|
||||
<td style="padding:8px 12px;font-size:13px;color:#374151;text-align:right;font-family:${ff};border-bottom:1px solid #f1f5f9">${fmtAmount((ro.eventOption?.price || 0) * ro.quantity)}</td>
|
||||
<td style="padding:8px 12px;font-size:13px;color:#374151;text-align:right;font-family:${ff};border-bottom:1px solid #f1f5f9">${fmtAmount(computeOptionLineTotal(ro, null, new Date()))}</td>
|
||||
</tr>`).join('');
|
||||
|
||||
const body = `
|
||||
@@ -639,6 +694,109 @@ function buildPaymentAdminNotice(payment) {
|
||||
return { to, subject, text, html: emailWrapper(body) };
|
||||
}
|
||||
|
||||
// ─── Donation unassignment ─────────────────────────────────────────────────────
|
||||
//
|
||||
// Sent when staff reverse a previous donation-assignment. Distinct from
|
||||
// buildDonationAppliedToRegistrant: the leg payment no longer exists by the time this runs
|
||||
// (it's hard-deleted before the notification fires), so callers pass a synthetic payment-shaped
|
||||
// object — { amount, createdAt, registration } — built from the leg's captured values plus a
|
||||
// freshly re-fetched registration so the balance table reflects the post-removal total.
|
||||
// Kept anonymous (no donor name), same reasoning as the "applied" email.
|
||||
|
||||
function buildDonationUnassignedFromRegistrant(payment) {
|
||||
const org = getOrg();
|
||||
const reg = payment.registration;
|
||||
const eventTitle = reg?.event?.title || 'the event';
|
||||
const totalDue = computeRegistrationTotalDue(reg, new Date());
|
||||
const totalPaid = (reg?.payments || []).reduce((s, p) => s + (p.amount || 0), 0);
|
||||
const balance = Math.max(totalDue - totalPaid, 0);
|
||||
const isUserActive = reg?.user?.isActive;
|
||||
const subject = `A donation was removed from your registration – ${eventTitle}`;
|
||||
const preheader = `A donation of ${fmtAmount(payment.amount)} was removed from your registration for ${eventTitle}.`;
|
||||
|
||||
const body = `
|
||||
<p style="font-size:22px;font-weight:800;color:#0f172a;margin:0 0 8px 0;letter-spacing:-0.3px">A donation was removed from your registration</p>
|
||||
<p style="font-size:14px;color:#64748b;margin:0 0 32px 0">Your balance has changed</p>
|
||||
|
||||
<p style="margin:0 0 4px 0;color:#374151;font-family:${ff}">Hi <strong>${reg?.user?.name || 'there'}</strong>,</p>
|
||||
<p style="margin:0 0 28px 0;color:#374151;font-family:${ff}">
|
||||
A donation of <strong>${fmtAmount(payment.amount)}</strong> previously applied to your registration for <strong>${eventTitle}</strong> has been removed by our team.
|
||||
</p>
|
||||
|
||||
${callout(`<strong style="font-size:15px">${fmtAmount(payment.amount)} removed</strong><br/>
|
||||
<span style="font-size:13px">Date: ${fmtDate(new Date())}</span>`,
|
||||
'warning')}
|
||||
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="margin:24px 0">
|
||||
<tr>
|
||||
<td style="font-size:13px;color:#64748b;padding:5px 0;font-family:${ff}">Total due</td>
|
||||
<td style="font-size:13px;text-align:right;font-weight:500;color:#374151;padding:5px 0;font-family:${ff}">${fmtAmount(totalDue)}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="font-size:13px;color:#64748b;padding:5px 0;font-family:${ff}">Total paid</td>
|
||||
<td style="font-size:13px;text-align:right;font-weight:500;color:#374151;padding:5px 0;font-family:${ff}">${fmtAmount(totalPaid)}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="font-size:14px;font-weight:700;color:#0f172a;padding:10px 0 5px 0;border-top:1px solid #e2e8f0;font-family:${ff}">${balance <= 0 ? 'Fully paid ✓' : 'Balance remaining'}</td>
|
||||
<td style="font-size:14px;font-weight:700;color:#${balance <= 0 ? '059669' : '0f172a'};text-align:right;padding:10px 0 5px 0;border-top:1px solid #e2e8f0;font-family:${ff}">${fmtAmount(balance)}</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
${balance > 0
|
||||
? callout(`<strong>A balance is now owing.</strong> Please arrange payment of ${fmtAmount(balance)} to secure your registration.`, 'warning')
|
||||
: ''}
|
||||
|
||||
${accountCta(isUserActive, org.url)}`;
|
||||
|
||||
const text = `A donation was removed from your registration\n\nHi ${reg?.user?.name || 'there'},\n\nA donation of ${fmtAmount(payment.amount)} previously applied to your registration for ${eventTitle} has been removed by our team.\n\nTotal due: ${fmtAmount(totalDue)}\nTotal paid: ${fmtAmount(totalPaid)}\nBalance: ${fmtAmount(balance)}\n\n${org.name} — ${org.email}`;
|
||||
return { subject, text, html: emailWrapper(body, { preheader }) };
|
||||
}
|
||||
|
||||
// Internal admin notice for a donation-assignment — same table layout as
|
||||
// buildPaymentAdminNotice (which reads payment.user as "Payer" — for a leg that's the donor,
|
||||
// since legs copy userId from the original donation, not the registrant), relabeled so it
|
||||
// doesn't read as a fresh incoming payment: no new money changed hands here, an
|
||||
// already-recorded donation was just reallocated to a registration.
|
||||
function buildDonationAssignmentAdminNotice(payment) {
|
||||
const notice = buildPaymentAdminNotice(payment);
|
||||
const eventTitle = payment.registration?.event?.title || 'Event';
|
||||
const payerName = payment.user?.name || '—';
|
||||
const subject = `Donation applied: ${fmtAmount(payment.amount)} — ${payerName} (${eventTitle})`;
|
||||
return {
|
||||
...notice,
|
||||
subject,
|
||||
html: notice.html
|
||||
.replace('Payment recorded', 'Donation applied')
|
||||
.replace('Internal notification', 'Internal notification — donation applied to a registration')
|
||||
.replace('>Registration payment<', '>Donation applied<'),
|
||||
text: notice.text
|
||||
.replace('Payment recorded', 'Donation applied')
|
||||
.replace('Type: Registration payment', 'Type: Donation applied'),
|
||||
};
|
||||
}
|
||||
|
||||
// Internal admin notice for a donation-unassignment — same table layout as
|
||||
// buildPaymentAdminNotice (which reads payment.user as "Payer" — for a leg that's the donor,
|
||||
// since legs copy userId from the original donation, not the registrant), with copy adjusted
|
||||
// for a removal rather than a new payment.
|
||||
function buildDonationUnassignmentAdminNotice(payment) {
|
||||
const notice = buildPaymentAdminNotice(payment);
|
||||
const eventTitle = payment.registration?.event?.title || 'Event';
|
||||
const payerName = payment.user?.name || '—';
|
||||
const subject = `Donation unassigned: ${fmtAmount(payment.amount)} — ${payerName} (${eventTitle})`;
|
||||
return {
|
||||
...notice,
|
||||
subject,
|
||||
html: notice.html
|
||||
.replace('Payment recorded', 'Donation unassigned')
|
||||
.replace('Internal notification', 'Internal notification — donation removed from registration')
|
||||
.replace('>Registration payment<', '>Donation unassigned<'),
|
||||
text: notice.text
|
||||
.replace('Payment recorded', 'Donation unassigned')
|
||||
.replace('Type: Registration payment', 'Type: Donation unassigned'),
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Refund email ─────────────────────────────────────────────────────────────
|
||||
|
||||
function buildRefundEmail(payment) {
|
||||
@@ -674,7 +832,7 @@ function buildRefundEmail(payment) {
|
||||
|
||||
// ─── Daily event summary ──────────────────────────────────────────────────────
|
||||
|
||||
function buildDailySummary(ev, registrations, payments, now) {
|
||||
function buildDailySummary(ev, registrations, payments, now, waitlistCount = 0) {
|
||||
const org = getOrg();
|
||||
const subject = `Daily summary: ${ev.title} — ${new Date(now).toLocaleDateString('en-GB', { day: 'numeric', month: 'long', year: 'numeric' })}`;
|
||||
|
||||
@@ -759,6 +917,7 @@ function buildDailySummary(ev, registrations, payments, now) {
|
||||
${statsRow('Total registrations', totalRegistrations)}
|
||||
${statsRow('Confirmed paid', paidCount, '#059669')}
|
||||
${statsRow('Awaiting payment', pendingCount, '#d97706')}
|
||||
${ev.waitlistEnabled ? statsRow('On the waitlist', waitlistCount, '#7c3aed') : ''}
|
||||
<td style="text-align:center;padding:16px">
|
||||
<div style="font-size:24px;font-weight:800;color:#2563eb;font-family:${ff}">${fmtAmount(totalRevenue)}</div>
|
||||
<div style="font-size:12px;color:#64748b;margin-top:4px;font-family:${ff}">Total collected</div>
|
||||
@@ -781,7 +940,7 @@ function buildDailySummary(ev, registrations, payments, now) {
|
||||
`Daily Summary — ${ev.title}`,
|
||||
new Date(now).toLocaleDateString(),
|
||||
'',
|
||||
`Registrations: ${totalRegistrations} | Paid: ${paidCount} | Pending: ${pendingCount} | Revenue: ${fmtAmount(totalRevenue)}`,
|
||||
`Registrations: ${totalRegistrations} | Paid: ${paidCount} | Pending: ${pendingCount}${ev.waitlistEnabled ? ` | Waitlist: ${waitlistCount}` : ''} | Revenue: ${fmtAmount(totalRevenue)}`,
|
||||
'',
|
||||
'Registrations:',
|
||||
...(regRows.length ? regRows.map(r => ` ${r.name} <${r.email}> — ${r.status} — due: ${fmtAmount(r.totalDue)} paid: ${fmtAmount(r.totalPaid)} balance: ${fmtAmount(r.balance)}`) : [' (none)']),
|
||||
@@ -795,25 +954,74 @@ function buildDailySummary(ev, registrations, payments, now) {
|
||||
|
||||
// ─── Send functions ───────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Generates an invoice PDF for a registration when it still has an outstanding balance,
|
||||
* reusing (or, if none was supplied, creating) a Yoco checkout link so the invoice can carry
|
||||
* a clickable "pay now" link and a scannable QR code. Returns null when nothing is owed or
|
||||
* PDF generation fails — callers fall back to the existing text-only notification either way.
|
||||
*/
|
||||
/**
|
||||
* Resolves a Yoco checkout link for an invoice's "pay now" link/QR code. Returns the given
|
||||
* hint as-is when supplied; otherwise creates a fresh checkout, or null if nothing is owed
|
||||
* (no point paying) or checkout creation fails (invoice still generates, just without a link).
|
||||
*/
|
||||
async function resolveInvoicePaymentUrl(reg, { totalDue, totalPaid, paymentUrlHint = null }) {
|
||||
if (paymentUrlHint) return paymentUrlHint;
|
||||
if (totalDue - totalPaid <= 0.01) return null;
|
||||
try {
|
||||
const { createRegistrationCheckoutInternal } = require('../controllers/paymentController');
|
||||
const baseUrl = process.env.FRONTEND_URL || process.env.APP_BASE_URL || 'http://localhost:3001';
|
||||
const checkout = await createRegistrationCheckoutInternal(reg.id, reg.userId, {
|
||||
successUrl: `${baseUrl}/payment/success`,
|
||||
cancelUrl: `${baseUrl}/payment/cancel`,
|
||||
failureUrl: `${baseUrl}/payment/failure`,
|
||||
});
|
||||
return checkout.redirectUrl || null;
|
||||
} catch (e) {
|
||||
console.warn('Could not create Yoco checkout for invoice PDF:', e?.message || e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Only used by the automatic registration-confirmation sends, which should stay silent (no
|
||||
// invoice attached) when nothing is owed — receipts already cover the fully-paid case.
|
||||
async function maybeGenerateInvoicePdf(reg, { totalDue, totalPaid, paymentUrlHint = null }) {
|
||||
if (totalDue - totalPaid <= 0.01) return null;
|
||||
const paymentUrl = await resolveInvoicePaymentUrl(reg, { totalDue, totalPaid, paymentUrlHint });
|
||||
try {
|
||||
const { generateInvoicePdf } = require('./pdfDocs');
|
||||
return await generateInvoicePdf(reg, { paymentUrl, totalDue, totalPaid });
|
||||
} catch (e) {
|
||||
console.error('Failed to generate invoice PDF:', e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function sendRegistrationEmails(registrationId) {
|
||||
let invoicePdf = null;
|
||||
try {
|
||||
const reg = await loadRegistrationFull(registrationId);
|
||||
if (!reg) return;
|
||||
const { shouldEmail, waText } = require('./notify');
|
||||
const { shouldEmail, waPdf, waText } = require('./notify');
|
||||
const { buildWARegistration } = require('./waMessages');
|
||||
const { computeRegistrationTotalDue } = require('./pricing');
|
||||
|
||||
const sends = [];
|
||||
const totalDue = computeRegistrationTotalDue(reg, new Date());
|
||||
const totalPaid = (reg.payments || []).reduce((s, p) => s + (p.amount || 0), 0);
|
||||
invoicePdf = await maybeGenerateInvoicePdf(reg, { totalDue, totalPaid });
|
||||
|
||||
// Email: only for real addresses (skip guest.local placeholders)
|
||||
if (reg.user?.email && !reg.user.email.endsWith('@guest.local')) {
|
||||
const msg = buildRegistrationConfirmation(reg, { isNew: true });
|
||||
if (shouldEmail(reg.user)) sends.push(sendMail({ to: reg.user.email, subject: msg.subject, html: msg.html, text: msg.text }));
|
||||
if (shouldEmail(reg.user)) sends.push(sendMail({
|
||||
to: reg.user.email, subject: msg.subject, html: msg.html, text: msg.text,
|
||||
...(invoicePdf ? { attachments: [{ filename: invoicePdf.filename, path: invoicePdf.filePath, contentType: 'application/pdf' }] } : {}),
|
||||
}));
|
||||
}
|
||||
// WhatsApp: always attempt — waText checks canWhatsApp (preference + valid phone) internally
|
||||
sends.push(waText(reg.user, buildWARegistration(reg, { isNew: true, totalDue, totalPaid, balance: Math.max(totalDue - totalPaid, 0) })));
|
||||
// WhatsApp: always attempt — waText/waPdf check canWhatsApp (preference + valid phone) internally
|
||||
const waCaption = buildWARegistration(reg, { isNew: true, totalDue, totalPaid, balance: Math.max(totalDue - totalPaid, 0) });
|
||||
sends.push(invoicePdf ? waPdf(reg.user, invoicePdf.filePath, invoicePdf.filename, waCaption) : waText(reg.user, waCaption));
|
||||
|
||||
const adminMsg = buildRegistrationAdminNotice(reg, { isNew: true });
|
||||
if (adminMsg.to && adminMsg.to.length) {
|
||||
@@ -822,25 +1030,34 @@ async function sendRegistrationEmails(registrationId) {
|
||||
await Promise.all(sends);
|
||||
} catch (e) {
|
||||
console.error('Failed to send registration emails:', e);
|
||||
} finally {
|
||||
if (invoicePdf) try { fs.unlinkSync(invoicePdf.filePath); } catch {}
|
||||
}
|
||||
}
|
||||
|
||||
async function sendRegistrationUpdatedEmails(registrationId) {
|
||||
let invoicePdf = null;
|
||||
try {
|
||||
const reg = await loadRegistrationFull(registrationId);
|
||||
if (!reg) return;
|
||||
const { shouldEmail, waText } = require('./notify');
|
||||
const { shouldEmail, waPdf, waText } = require('./notify');
|
||||
const { buildWARegistration } = require('./waMessages');
|
||||
const { computeRegistrationTotalDue } = require('./pricing');
|
||||
|
||||
const sends = [];
|
||||
const totalDue = computeRegistrationTotalDue(reg, new Date());
|
||||
const totalPaid = (reg.payments || []).reduce((s, p) => s + (p.amount || 0), 0);
|
||||
invoicePdf = await maybeGenerateInvoicePdf(reg, { totalDue, totalPaid });
|
||||
|
||||
if (reg.user?.email && !reg.user.email.endsWith('@guest.local')) {
|
||||
const msg = buildRegistrationConfirmation(reg, { isNew: false });
|
||||
if (shouldEmail(reg.user)) sends.push(sendMail({ to: reg.user.email, subject: msg.subject, html: msg.html, text: msg.text }));
|
||||
if (shouldEmail(reg.user)) sends.push(sendMail({
|
||||
to: reg.user.email, subject: msg.subject, html: msg.html, text: msg.text,
|
||||
...(invoicePdf ? { attachments: [{ filename: invoicePdf.filename, path: invoicePdf.filePath, contentType: 'application/pdf' }] } : {}),
|
||||
}));
|
||||
}
|
||||
sends.push(waText(reg.user, buildWARegistration(reg, { isNew: false, totalDue, totalPaid, balance: Math.max(totalDue - totalPaid, 0) })));
|
||||
const waCaption = buildWARegistration(reg, { isNew: false, totalDue, totalPaid, balance: Math.max(totalDue - totalPaid, 0) });
|
||||
sends.push(invoicePdf ? waPdf(reg.user, invoicePdf.filePath, invoicePdf.filename, waCaption) : waText(reg.user, waCaption));
|
||||
const adminMsg = buildRegistrationAdminNotice(reg, { isNew: false, isUpdated: true });
|
||||
if (adminMsg.to && adminMsg.to.length) {
|
||||
sends.push(sendMail({ to: adminMsg.to.join(','), subject: adminMsg.subject, html: adminMsg.html, text: adminMsg.text }));
|
||||
@@ -848,25 +1065,34 @@ async function sendRegistrationUpdatedEmails(registrationId) {
|
||||
await Promise.all(sends);
|
||||
} catch (e) {
|
||||
console.error('Failed to send registration updated emails:', e);
|
||||
} finally {
|
||||
if (invoicePdf) try { fs.unlinkSync(invoicePdf.filePath); } catch {}
|
||||
}
|
||||
}
|
||||
|
||||
async function sendSelfServiceRegistrationEmails(registrationId, { paymentUrl = null, formRequired = false, isNew = true } = {}) {
|
||||
let invoicePdf = null;
|
||||
try {
|
||||
const reg = await loadRegistrationFull(registrationId);
|
||||
if (!reg) return;
|
||||
const { shouldEmail, waText } = require('./notify');
|
||||
const { shouldEmail, waPdf, waText } = require('./notify');
|
||||
const { buildWARegistration } = require('./waMessages');
|
||||
const { computeRegistrationTotalDue } = require('./pricing');
|
||||
|
||||
const sends = [];
|
||||
const totalDue = computeRegistrationTotalDue(reg, new Date());
|
||||
const totalPaid = (reg.payments || []).reduce((s, p) => s + (p.amount || 0), 0);
|
||||
invoicePdf = await maybeGenerateInvoicePdf(reg, { totalDue, totalPaid, paymentUrlHint: paymentUrl });
|
||||
|
||||
if (reg.user?.email && !reg.user.email.endsWith('@guest.local')) {
|
||||
const msg = buildAdminInitiatedRegistrationConfirmation(reg, { yocoLink: paymentUrl, formRequired, isNew });
|
||||
if (shouldEmail(reg.user)) sends.push(sendMail({ to: reg.user.email, subject: msg.subject, html: msg.html, text: msg.text }));
|
||||
if (shouldEmail(reg.user)) sends.push(sendMail({
|
||||
to: reg.user.email, subject: msg.subject, html: msg.html, text: msg.text,
|
||||
...(invoicePdf ? { attachments: [{ filename: invoicePdf.filename, path: invoicePdf.filePath, contentType: 'application/pdf' }] } : {}),
|
||||
}));
|
||||
}
|
||||
sends.push(waText(reg.user, buildWARegistration(reg, { isNew, totalDue, totalPaid, balance: Math.max(totalDue - totalPaid, 0) })));
|
||||
const waCaption = buildWARegistration(reg, { isNew, totalDue, totalPaid, balance: Math.max(totalDue - totalPaid, 0) });
|
||||
sends.push(invoicePdf ? waPdf(reg.user, invoicePdf.filePath, invoicePdf.filename, waCaption) : waText(reg.user, waCaption));
|
||||
const adminMsg = buildRegistrationAdminNotice(reg, { isNew, isUpdated: !isNew });
|
||||
if (adminMsg.to && adminMsg.to.length) {
|
||||
sends.push(sendMail({ to: adminMsg.to.join(','), subject: adminMsg.subject, html: adminMsg.html, text: adminMsg.text }));
|
||||
@@ -874,28 +1100,40 @@ async function sendSelfServiceRegistrationEmails(registrationId, { paymentUrl =
|
||||
await Promise.all(sends);
|
||||
} catch (e) {
|
||||
console.error('Failed to send self-service registration emails:', e);
|
||||
} finally {
|
||||
if (invoicePdf) try { fs.unlinkSync(invoicePdf.filePath); } catch {}
|
||||
}
|
||||
}
|
||||
|
||||
async function sendPaymentEmails(paymentId) {
|
||||
let receiptPdf = null;
|
||||
try {
|
||||
const payment = await loadPaymentFull(paymentId);
|
||||
if (!payment) return;
|
||||
const user = payment.registration?.user || payment.user;
|
||||
const { shouldEmail, waText, waTextAny } = require('./notify');
|
||||
const { shouldEmail, waPdf, waPdfAny, waText, waTextAny } = require('./notify');
|
||||
const { buildWAPayment } = require('./waMessages');
|
||||
const { generateReceiptPdf } = require('./pdfDocs');
|
||||
|
||||
try { receiptPdf = await generateReceiptPdf(payment); } catch (e) { console.error('Failed to generate receipt PDF:', e); }
|
||||
|
||||
const sends = [];
|
||||
const hasValidEmail = user?.email && !user.email.endsWith('@guest.local') && !user.email.endsWith('@deleted.invalid');
|
||||
if (hasValidEmail) {
|
||||
const msg = buildPaymentReceipt(payment);
|
||||
if (shouldEmail(user)) sends.push(sendMail({ to: user.email, subject: msg.subject, html: msg.html, text: msg.text }));
|
||||
if (shouldEmail(user)) sends.push(sendMail({
|
||||
to: user.email, subject: msg.subject, html: msg.html, text: msg.text,
|
||||
...(receiptPdf ? { attachments: [{ filename: receiptPdf.filename, path: receiptPdf.filePath, contentType: 'application/pdf' }] } : {}),
|
||||
}));
|
||||
}
|
||||
// WhatsApp: respect preference when email is available; use as unconditional fallback when it isn't
|
||||
// WhatsApp: respect preference when email is available; use as unconditional fallback when it isn't.
|
||||
// Send the receipt PDF (with the usual text as its caption) when generation succeeded,
|
||||
// otherwise fall back to the plain text message so a PDF failure never blocks delivery.
|
||||
const waCaption = buildWAPayment(payment);
|
||||
if (hasValidEmail) {
|
||||
sends.push(waText(user, buildWAPayment(payment)));
|
||||
sends.push(receiptPdf ? waPdf(user, receiptPdf.filePath, receiptPdf.filename, waCaption) : waText(user, waCaption));
|
||||
} else {
|
||||
sends.push(waTextAny(user, buildWAPayment(payment)));
|
||||
sends.push(receiptPdf ? waPdfAny(user, receiptPdf.filePath, receiptPdf.filename, waCaption) : waTextAny(user, waCaption));
|
||||
}
|
||||
const hasEvent = !!(payment.registration?.eventId || payment.eventId);
|
||||
if (hasEvent) {
|
||||
@@ -907,6 +1145,8 @@ async function sendPaymentEmails(paymentId) {
|
||||
await Promise.all(sends);
|
||||
} catch (e) {
|
||||
console.error('Failed to send payment emails:', e);
|
||||
} finally {
|
||||
if (receiptPdf) try { fs.unlinkSync(receiptPdf.filePath); } catch {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -935,7 +1175,7 @@ async function sendCheckInEmails(ticketId, qtyRedeemed, totalRedeemed, remaining
|
||||
try {
|
||||
const ticket = await loadTicketFull(ticketId);
|
||||
if (!ticket) return;
|
||||
const user = ticket.user;
|
||||
const user = ticketRecipient(ticket);
|
||||
const { shouldEmail, waText, waTextAny } = require('./notify');
|
||||
const { buildWACheckIn } = require('./waMessages');
|
||||
|
||||
@@ -980,7 +1220,7 @@ async function sendDonationAssignmentEmails(paymentId) {
|
||||
} else {
|
||||
sends.push(waTextAny(user, buildWADonationAppliedToRegistrant(payment)));
|
||||
}
|
||||
const adminMsg = buildPaymentAdminNotice(payment);
|
||||
const adminMsg = buildDonationAssignmentAdminNotice(payment);
|
||||
if (adminMsg.to && adminMsg.to.length) {
|
||||
sends.push(sendMail({ to: adminMsg.to.join(','), subject: adminMsg.subject, html: adminMsg.html, text: adminMsg.text }));
|
||||
}
|
||||
@@ -990,19 +1230,209 @@ async function sendDonationAssignmentEmails(paymentId) {
|
||||
}
|
||||
}
|
||||
|
||||
// Sent when staff reverse a previous donation-assignment (the leg payment is hard-deleted
|
||||
// before this runs, so it can't be re-fetched by id like loadPaymentFull does elsewhere —
|
||||
// callers pass a snapshot of the leg's fields captured just before deletion instead).
|
||||
// Notifies the registrant (balance likely increased) and logs an internal admin notice showing
|
||||
// the donor as "Payer", same as the original assignment notice did.
|
||||
async function sendDonationUnassignmentEmails(leg) {
|
||||
try {
|
||||
const [registration, donor] = await Promise.all([
|
||||
prisma.registration.findUnique({
|
||||
where: { id: leg.registrationId },
|
||||
include: {
|
||||
registrationOptions: { include: { eventOption: { include: { earlyBirdTiers: true } }, tranches: true } },
|
||||
payments: true,
|
||||
user: { select: { id: true, name: true, email: true, phoneNumber: true, isActive: true, notificationPreference: true } },
|
||||
event: { include: { createdBy: { select: { id: true, name: true, email: true } }, notifyRecipients: { select: { id: true, name: true, email: true } } } },
|
||||
},
|
||||
}),
|
||||
leg.userId ? prisma.user.findUnique({ where: { id: leg.userId }, select: { id: true, name: true, email: true } }) : null,
|
||||
]);
|
||||
if (!registration) return;
|
||||
|
||||
// Synthetic payment-shaped object matching what buildDonationUnassignedFromRegistrant and
|
||||
// buildPaymentAdminNotice (via buildDonationUnassignmentAdminNotice) expect.
|
||||
const pseudoPayment = {
|
||||
id: leg.id,
|
||||
amount: leg.amount,
|
||||
createdAt: leg.createdAt,
|
||||
method: leg.method,
|
||||
externalId: leg.externalId,
|
||||
registrationId: leg.registrationId,
|
||||
registration,
|
||||
user: donor,
|
||||
};
|
||||
|
||||
const user = registration.user;
|
||||
const { shouldEmail, waText, waTextAny } = require('./notify');
|
||||
const { buildWADonationUnassignedFromRegistrant } = require('./waMessages');
|
||||
|
||||
const sends = [];
|
||||
const hasValidEmail = user?.email && !user.email.endsWith('@guest.local') && !user.email.endsWith('@deleted.invalid');
|
||||
if (hasValidEmail) {
|
||||
const msg = buildDonationUnassignedFromRegistrant(pseudoPayment);
|
||||
if (shouldEmail(user)) sends.push(sendMail({ to: user.email, subject: msg.subject, html: msg.html, text: msg.text }));
|
||||
}
|
||||
if (hasValidEmail) {
|
||||
sends.push(waText(user, buildWADonationUnassignedFromRegistrant(pseudoPayment)));
|
||||
} else {
|
||||
sends.push(waTextAny(user, buildWADonationUnassignedFromRegistrant(pseudoPayment)));
|
||||
}
|
||||
const adminMsg = buildDonationUnassignmentAdminNotice(pseudoPayment);
|
||||
if (adminMsg.to && adminMsg.to.length) {
|
||||
sends.push(sendMail({ to: adminMsg.to.join(','), subject: adminMsg.subject, html: adminMsg.html, text: adminMsg.text }));
|
||||
}
|
||||
await Promise.all(sends);
|
||||
} catch (e) {
|
||||
console.error('Failed to send donation-unassignment emails:', e);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Manual "send to me" requests ──────────────────────────────────────────────
|
||||
// Unlike the fire-and-forget sends above, these are awaited directly from a
|
||||
// request handler (dashboard "Email/WhatsApp invoice|receipt" buttons) — they
|
||||
// throw an Error with a `statusCode` on failure instead of swallowing it, so the
|
||||
// controller can surface a real error to the user rather than failing silently.
|
||||
|
||||
function userFacingError(message, statusCode) {
|
||||
const e = new Error(message);
|
||||
e.statusCode = statusCode;
|
||||
return e;
|
||||
}
|
||||
|
||||
/**
|
||||
* (re)generates and sends a registration's invoice PDF to the registrant on a single,
|
||||
* explicitly-chosen channel. Only the registrant themself may request their own invoice.
|
||||
* @param {string} registrationId
|
||||
* @param {string} requesterId - req.user.id of the caller
|
||||
* @param {'email'|'whatsapp'} channel
|
||||
*/
|
||||
async function sendInvoiceToUser(registrationId, requesterId, channel) {
|
||||
if (channel !== 'email' && channel !== 'whatsapp') throw userFacingError('Invalid channel', 400);
|
||||
|
||||
const reg = await loadRegistrationFull(registrationId);
|
||||
if (!reg) throw userFacingError('Registration not found', 404);
|
||||
if (reg.userId !== requesterId) throw userFacingError('Not authorized to access this registration', 403);
|
||||
|
||||
if (channel === 'email' && (!reg.user?.email || reg.user.email.endsWith('@guest.local'))) {
|
||||
throw userFacingError('No valid email address on file. Add one in your profile.', 400);
|
||||
}
|
||||
if (channel === 'whatsapp' && !reg.user?.phoneNumber) {
|
||||
throw userFacingError('No phone number on file. Add one in your profile.', 400);
|
||||
}
|
||||
|
||||
const totalDue = computeRegistrationTotalDue(reg, new Date());
|
||||
const totalPaid = (reg.payments || []).reduce((s, p) => s + (p.amount || 0), 0);
|
||||
|
||||
// Unlike the automatic post-registration send, a manually-requested invoice is generated
|
||||
// regardless of balance — someone may want it as a paid-in-full record too.
|
||||
const paymentUrl = await resolveInvoicePaymentUrl(reg, { totalDue, totalPaid });
|
||||
let invoicePdf;
|
||||
try {
|
||||
const { generateInvoicePdf } = require('./pdfDocs');
|
||||
invoicePdf = await generateInvoicePdf(reg, { paymentUrl, totalDue, totalPaid });
|
||||
} catch (e) {
|
||||
console.error('Failed to generate invoice PDF:', e);
|
||||
throw userFacingError('Could not generate the invoice right now. Please try again shortly.', 500);
|
||||
}
|
||||
|
||||
try {
|
||||
const eventTitle = reg.event?.title || 'your registration';
|
||||
if (channel === 'email') {
|
||||
await sendMail({
|
||||
to: reg.user.email,
|
||||
subject: `Your invoice for ${eventTitle}`,
|
||||
html: emailWrapper(
|
||||
`<p style="font-size:18px;font-weight:700;color:#0f172a;margin:0 0 12px 0">Your invoice is attached</p>
|
||||
<p style="margin:0;color:#374151;font-family:${ff}">Hi <strong>${reg.user?.name || 'there'}</strong>, here's the invoice you requested for <strong>${eventTitle}</strong>.</p>`
|
||||
),
|
||||
text: `Hi ${reg.user?.name || 'there'},\n\nHere's the invoice you requested for ${eventTitle} — see the attached PDF.`,
|
||||
attachments: [{ filename: invoicePdf.filename, path: invoicePdf.filePath, contentType: 'application/pdf' }],
|
||||
});
|
||||
} else {
|
||||
// Explicit user-requested channel — bypass the notification-preference gate (waPdfAny)
|
||||
// the same way ticket resends do, so "WhatsApp invoice" works even if the user's saved
|
||||
// preference is email-only.
|
||||
const { waPdfAny } = require('./notify');
|
||||
const { buildWARegistration } = require('./waMessages');
|
||||
const caption = buildWARegistration(reg, { isNew: false, totalDue, totalPaid, balance: Math.max(totalDue - totalPaid, 0) });
|
||||
await waPdfAny(reg.user, invoicePdf.filePath, invoicePdf.filename, caption);
|
||||
}
|
||||
} finally {
|
||||
try { fs.unlinkSync(invoicePdf.filePath); } catch {}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* (Re)generates and sends a payment's receipt PDF to the payer on a single, explicitly-chosen
|
||||
* channel. Only the person the payment belongs to (via the payment itself or its registration)
|
||||
* may request their own receipt.
|
||||
* @param {string} paymentId
|
||||
* @param {string} requesterId - req.user.id of the caller
|
||||
* @param {'email'|'whatsapp'} channel
|
||||
*/
|
||||
async function sendReceiptToUser(paymentId, requesterId, channel) {
|
||||
if (channel !== 'email' && channel !== 'whatsapp') throw userFacingError('Invalid channel', 400);
|
||||
|
||||
const payment = await loadPaymentFull(paymentId);
|
||||
if (!payment) throw userFacingError('Payment not found', 404);
|
||||
const owner = payment.registration?.user || payment.user;
|
||||
const ownerId = payment.registration?.userId || payment.userId;
|
||||
if (ownerId !== requesterId) throw userFacingError('Not authorized to access this payment', 403);
|
||||
|
||||
if (channel === 'email' && (!owner?.email || owner.email.endsWith('@guest.local'))) {
|
||||
throw userFacingError('No valid email address on file. Add one in your profile.', 400);
|
||||
}
|
||||
if (channel === 'whatsapp' && !owner?.phoneNumber) {
|
||||
throw userFacingError('No phone number on file. Add one in your profile.', 400);
|
||||
}
|
||||
|
||||
const { generateReceiptPdf } = require('./pdfDocs');
|
||||
let receiptPdf;
|
||||
try {
|
||||
receiptPdf = await generateReceiptPdf(payment);
|
||||
} catch (e) {
|
||||
console.error('Failed to generate receipt PDF:', e);
|
||||
throw userFacingError('Could not generate the receipt right now. Please try again shortly.', 500);
|
||||
}
|
||||
|
||||
try {
|
||||
const eventTitle = payment.registration?.event?.title || payment.event?.title || 'your registration';
|
||||
if (channel === 'email') {
|
||||
await sendMail({
|
||||
to: owner.email,
|
||||
subject: `Your payment receipt for ${eventTitle}`,
|
||||
html: emailWrapper(
|
||||
`<p style="font-size:18px;font-weight:700;color:#0f172a;margin:0 0 12px 0">Your receipt is attached</p>
|
||||
<p style="margin:0;color:#374151;font-family:${ff}">Hi <strong>${owner?.name || 'there'}</strong>, here's the receipt you requested for your payment of <strong>${fmtAmount(payment.amount)}</strong> towards <strong>${eventTitle}</strong>.</p>`
|
||||
),
|
||||
text: `Hi ${owner?.name || 'there'},\n\nHere's the receipt you requested for your payment of ${fmtAmount(payment.amount)} towards ${eventTitle} — see the attached PDF.`,
|
||||
attachments: [{ filename: receiptPdf.filename, path: receiptPdf.filePath, contentType: 'application/pdf' }],
|
||||
});
|
||||
} else {
|
||||
const { waPdfAny } = require('./notify');
|
||||
const { buildWAPayment } = require('./waMessages');
|
||||
await waPdfAny(owner, receiptPdf.filePath, receiptPdf.filename, buildWAPayment(payment));
|
||||
}
|
||||
} finally {
|
||||
try { fs.unlinkSync(receiptPdf.filePath); } catch {}
|
||||
}
|
||||
}
|
||||
|
||||
async function sendDailyEventSummaries(now = new Date()) {
|
||||
try {
|
||||
const today = new Date(now);
|
||||
const notifyInclude = { createdBy: { select: { id: true, name: true, email: true } }, notifyRecipients: { select: { id: true, name: true, email: true } } };
|
||||
let events = await prisma.event.findMany({
|
||||
where: { isActive: true, startDate: { gte: today } },
|
||||
where: { isActive: true, startDate: { gte: today }, requiresRegistration: true },
|
||||
include: notifyInclude,
|
||||
orderBy: { startDate: 'asc' },
|
||||
});
|
||||
|
||||
try {
|
||||
events = await prisma.event.findMany({
|
||||
where: { isActive: true, startDate: { gte: today }, goLiveAt: { lte: today } },
|
||||
where: { isActive: true, startDate: { gte: today }, goLiveAt: { lte: today }, requiresRegistration: true },
|
||||
include: notifyInclude,
|
||||
orderBy: { startDate: 'asc' },
|
||||
});
|
||||
@@ -1013,7 +1443,7 @@ async function sendDailyEventSummaries(now = new Date()) {
|
||||
where: { eventId: ev.id },
|
||||
include: {
|
||||
user: { select: { id: true, name: true, email: true, phoneNumber: true } },
|
||||
registrationOptions: { include: { eventOption: { include: { earlyBirdTiers: true } } } },
|
||||
registrationOptions: { include: { eventOption: { include: { earlyBirdTiers: true } }, tranches: true } },
|
||||
payments: true,
|
||||
},
|
||||
orderBy: { createdAt: 'asc' },
|
||||
@@ -1028,7 +1458,10 @@ async function sendDailyEventSummaries(now = new Date()) {
|
||||
orderBy: { createdAt: 'asc' },
|
||||
});
|
||||
|
||||
const { subject, text, html } = buildDailySummary(ev, registrations, payments, now);
|
||||
const waitlistCount = ev.waitlistEnabled
|
||||
? await prisma.waitlistEntry.count({ where: { eventId: ev.id, status: { in: ['waiting', 'offered'] } } })
|
||||
: 0;
|
||||
const { subject, text, html } = buildDailySummary(ev, registrations, payments, now, waitlistCount);
|
||||
const to = buildEventNotifyRecipientList(ev);
|
||||
if (to.length) await sendMail({ to: to.join(','), subject, html, text });
|
||||
}));
|
||||
@@ -1037,7 +1470,45 @@ async function sendDailyEventSummaries(now = new Date()) {
|
||||
}
|
||||
}
|
||||
|
||||
// Sent when a payer (or staff) asks for a named attendee to fill in their own form.
|
||||
// Always tries both channels the attendee has, regardless of anyone's preference.
|
||||
async function sendAttendeeFormLinkMessage(attendee) {
|
||||
const org = getOrg();
|
||||
const reg = attendee.registration;
|
||||
const eventTitle = reg?.event?.title || 'the event';
|
||||
const link = `${org.url}/forms?registrationId=${encodeURIComponent(attendee.registrationId)}&attendeeId=${encodeURIComponent(attendee.id)}`;
|
||||
const payer = reg?.user?.name || 'Someone';
|
||||
const sent = [];
|
||||
|
||||
if (attendee.email && attendee.email.includes('@')) {
|
||||
const html = emailWrapper(`
|
||||
<p style="font-size:22px;font-weight:800;color:#0f172a;margin:0 0 8px 0">Please fill in your form</p>
|
||||
<p style="margin:0 0 8px 0;color:#374151;font-family:${ff}">Hi <strong>${attendee.name}</strong>,</p>
|
||||
<p style="margin:0 0 24px 0;color:#374151;font-family:${ff}">${payer} registered you for <strong>${eventTitle}</strong>. Please fill in your attendee form.</p>
|
||||
${ctaButton('Fill in my form', link)}
|
||||
${fallbackLink(link)}
|
||||
`, { preheader: `Your attendee form for ${eventTitle}` });
|
||||
try {
|
||||
await sendMail({ to: attendee.email, subject: `Your attendee form for ${eventTitle}`, html, text: `Hi ${attendee.name},\n\n${payer} registered you for ${eventTitle}. Please fill in your attendee form:\n${link}\n\n${org.name}` });
|
||||
sent.push('email');
|
||||
} catch (e) {
|
||||
console.warn('[attendee form link] email failed:', e?.message || e);
|
||||
}
|
||||
}
|
||||
if (attendee.phone) {
|
||||
const { waTextAny } = require('./notify');
|
||||
const { isValidZAPhone } = require('./whatsapp');
|
||||
if (isValidZAPhone(attendee.phone)) {
|
||||
await waTextAny({ name: attendee.name, phoneNumber: attendee.phone }, `Hi ${attendee.name}, ${payer} registered you for *${eventTitle}*. Please fill in your attendee form: ${link}`);
|
||||
sent.push('WhatsApp');
|
||||
}
|
||||
}
|
||||
return sent;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
buildEventNotifyRecipientList,
|
||||
sendAttendeeFormLinkMessage,
|
||||
sendRegistrationEmails,
|
||||
sendRegistrationUpdatedEmails,
|
||||
sendPaymentEmails,
|
||||
@@ -1045,6 +1516,9 @@ module.exports = {
|
||||
sendRefundEmail,
|
||||
sendSelfServiceRegistrationEmails,
|
||||
sendDonationAssignmentEmails,
|
||||
sendDonationUnassignmentEmails,
|
||||
sendCheckInEmails,
|
||||
buildCheckInConfirmation,
|
||||
sendInvoiceToUser,
|
||||
sendReceiptToUser,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,372 @@
|
||||
/**
|
||||
* Branded PDF documents: payment receipts and registration invoices.
|
||||
*
|
||||
* Both are generated with pdfkit into backend/temp and returned as
|
||||
* { filePath, filename } for callers to attach to an email/WhatsApp send and
|
||||
* clean up afterwards (see notifications.js).
|
||||
*
|
||||
* Line items are built the same tranche-aware way the user dashboard renders
|
||||
* them (backend/src/utils/pricing.js) — merging tranches that share a
|
||||
* name/price/tier and separating early-bird lines from standard-price ones —
|
||||
* so the PDF total always matches computeRegistrationTotalDue().
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const PDFDocument = require('pdfkit');
|
||||
const QRCode = require('qrcode');
|
||||
const { getSetting } = require('./settingsCache');
|
||||
|
||||
function fmtAmount(amt) {
|
||||
return `R${Number(amt || 0).toFixed(2)}`;
|
||||
}
|
||||
|
||||
function fmtDate(d) {
|
||||
try { return new Date(d).toLocaleDateString('en-GB', { day: 'numeric', month: 'long', year: 'numeric' }); } catch { return ''; }
|
||||
}
|
||||
|
||||
function formatMethod(method) {
|
||||
const m = String(method || '').toLowerCase();
|
||||
if (!m) return '—';
|
||||
if (m === 'eft') return 'EFT';
|
||||
return m.replace(/^./, c => c.toUpperCase());
|
||||
}
|
||||
|
||||
function tempDir() {
|
||||
const dir = path.join(__dirname, '..', '..', 'temp');
|
||||
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
||||
return dir;
|
||||
}
|
||||
|
||||
function docNumber(prefix, id, date) {
|
||||
const year = new Date(date || Date.now()).getFullYear();
|
||||
const short = String(id || '').replace(/-/g, '').slice(-6).toUpperCase() || '000000';
|
||||
return `${prefix}-${year}-${short}`;
|
||||
}
|
||||
|
||||
async function getBranding() {
|
||||
const [name, address, email, phone, primary, accent, logoUrl] = await Promise.all([
|
||||
getSetting('org_name', process.env.ORG_NAME || 'Cross Code'),
|
||||
getSetting('org_address', ''),
|
||||
getSetting('org_email', process.env.EMAIL_FROM || process.env.EMAIL_USER || ''),
|
||||
getSetting('org_phone', ''),
|
||||
getSetting('primary_color', ''),
|
||||
getSetting('accent_color', ''),
|
||||
getSetting('logo_url', ''),
|
||||
]);
|
||||
const url = (process.env.FRONTEND_URL || process.env.APP_BASE_URL || 'http://localhost:3001').replace(/\/$/, '');
|
||||
let logoPath = null;
|
||||
if (logoUrl) {
|
||||
const p = path.join(__dirname, '..', '..', 'public', logoUrl.replace(/^\//, ''));
|
||||
if (fs.existsSync(p)) logoPath = p;
|
||||
}
|
||||
return { name, address, email, phone, brandColor: primary || accent || '#1e3a5f', url, logoPath };
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge a registration's tranches into display rows, same grouping logic as
|
||||
* the user dashboard: one row per (name, unit price, early-bird flag), with
|
||||
* `basePrice` carried along so callers can work out the early-bird discount.
|
||||
*/
|
||||
function buildLineItems(registrationOptions) {
|
||||
const rows = [];
|
||||
for (const ro of (registrationOptions || [])) {
|
||||
const variantLabel = ro.variant?.name ? ` (${ro.variant.name})` : '';
|
||||
const label = `${ro.eventOption?.name || 'Option'}${variantLabel}`;
|
||||
const basePrice = Number((ro.variant?.price ?? ro.eventOption?.price ?? 0));
|
||||
const tranches = Array.isArray(ro.tranches) && ro.tranches.length > 0
|
||||
? ro.tranches
|
||||
: [{
|
||||
quantity: ro.quantity,
|
||||
priceSnapshot: (ro.priceSnapshot !== null && ro.priceSnapshot !== undefined) ? Number(ro.priceSnapshot) : basePrice,
|
||||
appliedTierId: ro.appliedTierId,
|
||||
}];
|
||||
for (const t of tranches) {
|
||||
const unitPrice = Number(t.priceSnapshot || 0);
|
||||
const isEarlyBird = !!t.appliedTierId;
|
||||
const key = `${label}__${isEarlyBird}__${unitPrice}`;
|
||||
let row = rows.find(r => r.key === key);
|
||||
if (!row) { row = { key, label, quantity: 0, unitPrice, isEarlyBird, basePrice }; rows.push(row); }
|
||||
row.quantity += (t.quantity || 0);
|
||||
}
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* The registration's coupon discount and a label naming the code(s), e.g. "Code STAFF50".
|
||||
* Needs `couponRedemptions[].coupon.code` loaded for the label; the amount comes from
|
||||
* Registration.discountTotal, which is always present.
|
||||
*/
|
||||
function couponDiscountLine(registration) {
|
||||
const amount = Number(registration?.discountTotal || 0);
|
||||
if (!(amount > 0.001)) return null;
|
||||
const codes = (registration.couponRedemptions || []).map(r => r.coupon?.code).filter(Boolean);
|
||||
return { amount, label: codes.length ? `Code ${codes.join(', ')}` : 'Discount code' };
|
||||
}
|
||||
|
||||
/** Draws a two-column item table starting at `y`; returns the y position after the last row. */
|
||||
function drawItemsTable(doc, { x, width, y, rows, brandColor, headerLight = true }) {
|
||||
const colDesc = x;
|
||||
// Fixed-width columns anchored to the right edge so amounts never wrap,
|
||||
// regardless of the overall table width (receipt vs. narrower invoice table).
|
||||
const totalColW = 75, priceColW = 65, qtyColW = 35;
|
||||
const colTotal = x + width - totalColW;
|
||||
const colPrice = colTotal - priceColW;
|
||||
const colQty = colPrice - qtyColW;
|
||||
const rowH = 22;
|
||||
|
||||
if (headerLight) {
|
||||
doc.rect(x, y, width, rowH).fill('#f8fafc');
|
||||
doc.fillColor('#64748b').font('Helvetica-Bold').fontSize(9);
|
||||
} else {
|
||||
doc.rect(x, y, width, rowH).fill(brandColor);
|
||||
doc.fillColor('#ffffff').font('Helvetica-Bold').fontSize(9);
|
||||
}
|
||||
doc.text('DESCRIPTION', colDesc + 8, y + 7);
|
||||
doc.text('QTY', colQty, y + 7, { width: qtyColW - 8, align: 'right' });
|
||||
doc.text('PRICE', colPrice, y + 7, { width: priceColW - 8, align: 'right' });
|
||||
doc.text('TOTAL', colTotal, y + 7, { width: totalColW - 8, align: 'right' });
|
||||
y += rowH;
|
||||
|
||||
doc.font('Helvetica').fontSize(10).fillColor('#374151');
|
||||
for (const row of rows) {
|
||||
doc.text(row.label + (row.isEarlyBird ? ' (early bird)' : ''), colDesc + 8, y + 6, { width: colQty - colDesc - 12 });
|
||||
doc.text(String(row.quantity), colQty, y + 6, { width: qtyColW - 8, align: 'right' });
|
||||
doc.text(fmtAmount(row.unitPrice), colPrice, y + 6, { width: priceColW - 8, align: 'right' });
|
||||
doc.text(fmtAmount(row.unitPrice * row.quantity), colTotal, y + 6, { width: totalColW - 8, align: 'right' });
|
||||
doc.moveTo(x, y + rowH).lineTo(x + width, y + rowH).strokeColor('#e2e8f0').lineWidth(0.5).stroke();
|
||||
y += rowH;
|
||||
}
|
||||
return y;
|
||||
}
|
||||
|
||||
// ─── Payment receipt ──────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* @param {object} payment - from notifications.js loadPaymentFull: amount, method, externalId,
|
||||
* createdAt, user, registration { event, registrationOptions[{eventOption,variant,tranches}] }
|
||||
* @returns {Promise<{ filePath: string, filename: string }>}
|
||||
*/
|
||||
async function generateReceiptPdf(payment) {
|
||||
const org = await getBranding();
|
||||
const reg = payment.registration;
|
||||
const user = reg?.user || payment.user;
|
||||
const eventTitle = reg?.event?.title || payment.event?.title || 'Event';
|
||||
const eventStartDate = reg?.event?.startDate || payment.event?.startDate || null;
|
||||
const eventDate = eventStartDate ? fmtDate(eventStartDate) : '';
|
||||
const receiptNo = docNumber('RCPT', payment.id, payment.createdAt);
|
||||
const rows = reg ? buildLineItems(reg.registrationOptions) : [];
|
||||
|
||||
const filename = `receipt-${receiptNo}.pdf`;
|
||||
const filePath = path.join(tempDir(), `${Date.now()}-${filename}`);
|
||||
const doc = new PDFDocument({ size: 'A4', margin: 0 });
|
||||
const writeStream = fs.createWriteStream(filePath);
|
||||
doc.pipe(writeStream);
|
||||
|
||||
const pageWidth = doc.page.width;
|
||||
const pageHeight = doc.page.height;
|
||||
const marginX = 40;
|
||||
|
||||
// Header banner
|
||||
const bannerH = 150;
|
||||
doc.rect(0, 0, pageWidth, bannerH).fill(org.brandColor);
|
||||
if (org.logoPath) {
|
||||
try { doc.image(org.logoPath, marginX, 28, { fit: [36, 36] }); } catch {}
|
||||
}
|
||||
doc.fillColor('#ffffff').font('Helvetica-Bold').fontSize(13).text(org.name, marginX + (org.logoPath ? 46 : 0), 38, { width: 260 });
|
||||
doc.font('Helvetica-Bold').fontSize(26).text('Payment Receipt', marginX, 68, { width: 320 });
|
||||
doc.font('Helvetica').fontSize(11).text('Thank you for your payment.', marginX, 102, { width: 320 });
|
||||
if (eventStartDate) doc.font('Helvetica-Bold').fontSize(10).text('We look forward to seeing you at the event!', marginX, 122, { width: 320 });
|
||||
|
||||
const metaX = pageWidth - 250;
|
||||
const meta = [
|
||||
['Receipt No.', receiptNo],
|
||||
['Date', fmtDate(payment.createdAt)],
|
||||
['Payment Method', formatMethod(payment.method)],
|
||||
['Transaction ID', payment.externalId || payment.id.slice(0, 12)],
|
||||
];
|
||||
let metaY = 34;
|
||||
for (const [label, value] of meta) {
|
||||
doc.font('Helvetica').fontSize(9).fillColor('#ffffff').fillOpacity(0.75).text(label, metaX, metaY, { width: 90 });
|
||||
doc.font('Helvetica-Bold').fontSize(9).fillColor('#ffffff').fillOpacity(1).text(value, metaX + 90, metaY, { width: 120, align: 'right' });
|
||||
metaY += 24;
|
||||
}
|
||||
|
||||
// Body
|
||||
let y = bannerH + 30;
|
||||
doc.font('Helvetica-Bold').fontSize(10).fillColor(org.brandColor).text('PAYER DETAILS', marginX, y);
|
||||
doc.font('Helvetica-Bold').fontSize(10).fillColor(org.brandColor).text('EVENT DETAILS', marginX + 280, y);
|
||||
y += 16;
|
||||
doc.font('Helvetica-Bold').fontSize(11).fillColor('#0f172a').text(user?.name || 'Guest', marginX, y);
|
||||
doc.font('Helvetica-Bold').fontSize(11).fillColor('#0f172a').text(eventTitle, marginX + 280, y, { width: 240 });
|
||||
y += 16;
|
||||
doc.font('Helvetica').fontSize(9).fillColor('#374151').text(user?.email || '', marginX, y);
|
||||
if (eventDate) doc.font('Helvetica').fontSize(9).fillColor('#374151').text(eventDate, marginX + 280, y);
|
||||
y += 14;
|
||||
if (user?.phoneNumber) doc.font('Helvetica').fontSize(9).fillColor('#374151').text(user.phoneNumber, marginX, y);
|
||||
y += 30;
|
||||
|
||||
if (rows.length > 0) {
|
||||
y = drawItemsTable(doc, { x: marginX, width: pageWidth - marginX * 2, y, rows, brandColor: org.brandColor });
|
||||
}
|
||||
const coupon = couponDiscountLine(reg);
|
||||
if (coupon) {
|
||||
y += 8;
|
||||
doc.font('Helvetica').fontSize(10).fillColor('#059669')
|
||||
.text(`${coupon.label}: -${fmtAmount(coupon.amount)}`, marginX, y, { width: pageWidth - marginX * 2 - 8, align: 'right' });
|
||||
y += 14;
|
||||
}
|
||||
y += 20;
|
||||
|
||||
const boxW = 160, boxH = 32;
|
||||
doc.font('Helvetica-Bold').fontSize(10).fillColor(org.brandColor).text('TOTAL PAID', pageWidth - marginX - boxW - 110, y + 10, { width: 90, align: 'right' });
|
||||
doc.roundedRect(pageWidth - marginX - boxW, y, boxW, boxH, 4).fill(org.brandColor);
|
||||
doc.font('Helvetica-Bold').fontSize(14).fillColor('#ffffff').text(fmtAmount(payment.amount), pageWidth - marginX - boxW, y + 9, { width: boxW, align: 'center' });
|
||||
y += boxH + 30;
|
||||
|
||||
// Footer
|
||||
const footerH = 60;
|
||||
const footerY = Math.max(pageHeight - footerH, y + 20);
|
||||
doc.rect(0, footerY, pageWidth, footerH).fill('#0f172a');
|
||||
doc.font('Helvetica-BoldOblique').fontSize(16).fillColor('#ffffff').text('Thank you!', marginX, footerY + 20);
|
||||
doc.font('Helvetica').fontSize(9).fillColor('#94a3b8').text(org.url.replace(/^https?:\/\//, ''), 0, footerY + 24, { width: pageWidth - marginX, align: 'right' });
|
||||
|
||||
doc.end();
|
||||
await new Promise((resolve, reject) => { writeStream.on('finish', resolve); writeStream.on('error', reject); });
|
||||
return { filePath, filename };
|
||||
}
|
||||
|
||||
// ─── Invoice ───────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* @param {object} registration - from notifications.js loadRegistrationFull: id, createdAt,
|
||||
* user, event, registrationOptions[{eventOption,variant,tranches}], payments
|
||||
* @param {{ paymentUrl?: string|null, totalDue?: number, totalPaid?: number }} opts
|
||||
* @returns {Promise<{ filePath: string, filename: string }>}
|
||||
*/
|
||||
async function generateInvoicePdf(registration, { paymentUrl = null, totalDue = null, totalPaid = null } = {}) {
|
||||
const org = await getBranding();
|
||||
const { computeRegistrationTotalDue } = require('./pricing');
|
||||
const due = totalDue !== null ? totalDue : computeRegistrationTotalDue(registration, new Date());
|
||||
const paid = totalPaid !== null ? totalPaid : (registration.payments || []).reduce((s, p) => s + (p.amount || 0), 0);
|
||||
const balance = Math.max(due - paid, 0);
|
||||
const invoiceNo = docNumber('INV', registration.id, registration.createdAt);
|
||||
const rows = buildLineItems(registration.registrationOptions);
|
||||
|
||||
// Subtotal/discount: only early-bird rows are compared against today's base price, so an
|
||||
// ordinary base-price change over time never shows up as a false "discount" on standard rows.
|
||||
const fullPriceTotal = rows.reduce((s, r) => s + (r.isEarlyBird ? r.basePrice : r.unitPrice) * r.quantity, 0);
|
||||
const chargedTotal = rows.reduce((s, r) => s + r.unitPrice * r.quantity, 0);
|
||||
const discount = Math.max(0, fullPriceTotal - chargedTotal);
|
||||
const coupon = couponDiscountLine(registration);
|
||||
|
||||
const filename = `invoice-${invoiceNo}.pdf`;
|
||||
const filePath = path.join(tempDir(), `${Date.now()}-${filename}`);
|
||||
const doc = new PDFDocument({ size: 'A4', margin: 0 });
|
||||
const writeStream = fs.createWriteStream(filePath);
|
||||
doc.pipe(writeStream);
|
||||
|
||||
const pageWidth = doc.page.width;
|
||||
const pageHeight = doc.page.height;
|
||||
const sidebarW = 190;
|
||||
const mainX = sidebarW + 30;
|
||||
const mainW = pageWidth - mainX - 40;
|
||||
|
||||
// Sidebar
|
||||
doc.rect(0, 0, sidebarW, pageHeight).fill('#111827');
|
||||
let sy = 40;
|
||||
if (org.logoPath) {
|
||||
try { doc.image(org.logoPath, 28, sy, { fit: [32, 32] }); sy += 0; } catch {}
|
||||
}
|
||||
doc.font('Helvetica-Bold').fontSize(12).fillColor('#ffffff').text(org.name, org.logoPath ? 68 : 28, sy + 8, { width: sidebarW - (org.logoPath ? 96 : 56) });
|
||||
sy += 60;
|
||||
doc.font('Helvetica-Bold').fontSize(20).fillColor('#ffffff').text('INVOICE', 28, sy, { width: sidebarW - 56 });
|
||||
sy += 28;
|
||||
doc.font('Helvetica-Bold').fontSize(11).fillColor(org.brandColor).text(invoiceNo, 28, sy, { width: sidebarW - 56 });
|
||||
sy += 34;
|
||||
doc.font('Helvetica').fontSize(8).fillColor('#94a3b8').text('DATE ISSUED', 28, sy);
|
||||
doc.font('Helvetica-Bold').fontSize(9).fillColor('#ffffff').text(fmtDate(registration.createdAt), 28, sy + 11);
|
||||
sy += 40;
|
||||
doc.font('Helvetica').fontSize(8).fillColor(org.brandColor).text('BILL TO', 28, sy);
|
||||
sy += 13;
|
||||
doc.font('Helvetica-Bold').fontSize(10).fillColor('#ffffff').text(registration.user?.name || 'Guest', 28, sy, { width: sidebarW - 56 });
|
||||
sy += 15;
|
||||
doc.font('Helvetica').fontSize(8).fillColor('#cbd5e1').text(registration.user?.email || '', 28, sy, { width: sidebarW - 56 });
|
||||
sy += 12;
|
||||
if (registration.user?.phoneNumber) { doc.font('Helvetica').fontSize(8).fillColor('#cbd5e1').text(registration.user.phoneNumber, 28, sy, { width: sidebarW - 56 }); sy += 12; }
|
||||
|
||||
let by = pageHeight - 140;
|
||||
doc.font('Helvetica-Bold').fontSize(9).fillColor('#ffffff').text(org.name, 28, by, { width: sidebarW - 56 });
|
||||
by += 13;
|
||||
if (org.address) { doc.font('Helvetica').fontSize(8).fillColor('#94a3b8').text(org.address, 28, by, { width: sidebarW - 56 }); by += 12 * Math.ceil(org.address.length / 28); }
|
||||
if (org.email) { doc.font('Helvetica').fontSize(8).fillColor('#94a3b8').text(org.email, 28, by, { width: sidebarW - 56 }); by += 12; }
|
||||
doc.font('Helvetica').fontSize(8).fillColor('#94a3b8').text(org.url.replace(/^https?:\/\//, ''), 28, by, { width: sidebarW - 56 });
|
||||
|
||||
// Main content
|
||||
let y = 40;
|
||||
doc.font('Helvetica-Bold').fontSize(9).fillColor(org.brandColor).text('EVENT', mainX, y);
|
||||
y += 14;
|
||||
doc.font('Helvetica-Bold').fontSize(14).fillColor('#0f172a').text(registration.event?.title || 'Event', mainX, y, { width: mainW });
|
||||
y += 18;
|
||||
if (registration.event?.startDate) { doc.font('Helvetica').fontSize(9).fillColor('#64748b').text(fmtDate(registration.event.startDate), mainX, y); y += 14; }
|
||||
y += 16;
|
||||
|
||||
y = drawItemsTable(doc, { x: mainX, width: mainW, y, rows, brandColor: org.brandColor, headerLight: false });
|
||||
y += 16;
|
||||
|
||||
const totalsX = mainX + mainW - 220;
|
||||
const totalLine = (label, value, opts = {}) => {
|
||||
doc.font(opts.bold ? 'Helvetica-Bold' : 'Helvetica').fontSize(opts.size || 10).fillColor(opts.color || '#374151')
|
||||
.text(label, totalsX, y, { width: 120 });
|
||||
doc.font(opts.bold ? 'Helvetica-Bold' : 'Helvetica').fontSize(opts.size || 10).fillColor(opts.color || '#374151')
|
||||
.text(value, totalsX + 120, y, { width: 100, align: 'right' });
|
||||
y += (opts.size || 10) + 10;
|
||||
};
|
||||
if (discount > 0.01 || coupon) {
|
||||
totalLine('SUBTOTAL', fmtAmount(fullPriceTotal));
|
||||
if (discount > 0.01) totalLine(coupon ? 'EARLY BIRD' : 'DISCOUNT', `-${fmtAmount(discount)}`, { color: '#059669' });
|
||||
if (coupon) totalLine(coupon.label.toUpperCase(), `-${fmtAmount(coupon.amount)}`, { color: '#059669' });
|
||||
doc.moveTo(totalsX, y).lineTo(totalsX + 220, y).strokeColor('#e2e8f0').stroke();
|
||||
y += 8;
|
||||
}
|
||||
totalLine('TOTAL DUE', fmtAmount(due), { bold: true, size: 13, color: org.brandColor });
|
||||
if (paid > 0) totalLine('Already paid', `-${fmtAmount(paid)}`, { size: 9 });
|
||||
if (paid > 0) totalLine('BALANCE DUE', fmtAmount(balance), { bold: true, size: 12, color: balance > 0 ? org.brandColor : '#059669' });
|
||||
y += 20;
|
||||
|
||||
// Payment section
|
||||
if (balance > 0.01) {
|
||||
doc.font('Helvetica-Bold').fontSize(10).fillColor(org.brandColor).text('PAYMENT', mainX, y);
|
||||
y += 16;
|
||||
if (paymentUrl) {
|
||||
const qrSize = 100;
|
||||
try {
|
||||
const qrBuffer = await QRCode.toBuffer(paymentUrl, { width: qrSize, margin: 1 });
|
||||
doc.image(qrBuffer, mainX + mainW - qrSize, y, { width: qrSize, height: qrSize });
|
||||
doc.font('Helvetica').fontSize(8).fillColor('#94a3b8').text('Scan to pay', mainX + mainW - qrSize, y + qrSize + 4, { width: qrSize, align: 'center' });
|
||||
} catch {}
|
||||
doc.font('Helvetica').fontSize(9).fillColor('#374151').text('Pay online — tap the link or scan the QR code:', mainX, y, { width: mainW - 120 });
|
||||
doc.font('Helvetica-Bold').fontSize(10).fillColor('#2563eb').text(paymentUrl, mainX, y + 16, { width: mainW - 120, link: paymentUrl, underline: true });
|
||||
y += 60;
|
||||
} else {
|
||||
doc.font('Helvetica').fontSize(9).fillColor('#374151').text(`Pay online at ${org.url} or at the door (cash/card).`, mainX, y, { width: mainW });
|
||||
y += 24;
|
||||
}
|
||||
} else {
|
||||
doc.font('Helvetica-Bold').fontSize(11).fillColor('#059669').text('PAID IN FULL', mainX, y);
|
||||
y += 24;
|
||||
}
|
||||
|
||||
// Footer
|
||||
const footerH = 40;
|
||||
const footerY = Math.max(pageHeight - footerH, y + 20);
|
||||
doc.rect(sidebarW, footerY, pageWidth - sidebarW, footerH).fill(org.brandColor);
|
||||
doc.font('Helvetica-Bold').fontSize(11).fillColor('#ffffff').text('Thank you for your support!', sidebarW, footerY + 13, { width: pageWidth - sidebarW, align: 'center' });
|
||||
|
||||
doc.end();
|
||||
await new Promise((resolve, reject) => { writeStream.on('finish', resolve); writeStream.on('error', reject); });
|
||||
return { filePath, filename };
|
||||
}
|
||||
|
||||
module.exports = { generateReceiptPdf, generateInvoicePdf, buildLineItems, couponDiscountLine, getBranding, docNumber };
|
||||
+179
-46
@@ -7,15 +7,47 @@
|
||||
* Used at registration-creation time and again at payment-initiation time.
|
||||
* - getEffectiveUnitPrice: deadline-only check; used for line-item display in Yoco checkout and
|
||||
* as a fallback for legacy RegistrationOption rows that have no priceSnapshot.
|
||||
* - computeRegistrationTotalDue: uses priceSnapshot when present (authoritative after
|
||||
* refreshPricingForRegistration runs), otherwise falls back to getEffectiveUnitPrice.
|
||||
* - refreshPricingForRegistration: re-runs resolveOptionPrice for every RegistrationOption
|
||||
* that has an appliedTierId; updates priceSnapshot + appliedTierId in the DB if the tier
|
||||
* is now expired or its stock is exhausted.
|
||||
* - A RegistrationOption is an aggregate line (one per registration+option+variant); each
|
||||
* purchase-at-a-price is its own RegistrationOptionTranche row underneath it (quantity +
|
||||
* priceSnapshot + appliedTierId, never mutated after creation — mirrors the Payment model's
|
||||
* append-only pattern). This is what lets someone buy more of the same ticket type after an
|
||||
* early-bird tier expires without the new price bleeding onto tickets already bought.
|
||||
* RegistrationOption.quantity/priceSnapshot/appliedTierId are kept as a maintained mirror
|
||||
* (quantity = sum of tranche quantities; priceSnapshot/appliedTierId = most recent tranche)
|
||||
* for code that only needs "how many" or a single display price.
|
||||
* - computeRegistrationTotalDue: sums quantity*priceSnapshot across each RegistrationOption's
|
||||
* tranches (falling back to the legacy single-priceSnapshot/getEffectiveUnitPrice path for
|
||||
* rows created before tranches existed).
|
||||
* - Staff price: an option (or variant) can carry a staffPrice that Staff and above get
|
||||
* automatically when it's lower than the price they'd otherwise pay.
|
||||
* - Coupon discounts are stored on Registration.discountTotal (see utils/coupons.js) and
|
||||
* subtracted by computeRegistrationTotalDue, never below zero.
|
||||
* - refreshPricingForRegistration: re-evaluates only the newest, not-yet-paid-for tranche of
|
||||
* each RegistrationOption; if its tier has expired or lost stock, resolves the next
|
||||
* applicable tier/price for just that tranche. Older tranches — already priced-in — are
|
||||
* never touched.
|
||||
*/
|
||||
|
||||
const prisma = require('../config/db');
|
||||
|
||||
const STAFF_ROLES = ['staff', 'supervisor', 'admin'];
|
||||
|
||||
/** The staff price this user gets for an option/variant, or null if none applies. */
|
||||
function staffPriceFor(option, variantId, user) {
|
||||
if (!user || !STAFF_ROLES.includes(user.role)) return null;
|
||||
const variant = variantId ? (option?.variants || []).find(v => v.id === variantId) : null;
|
||||
const price = variant && variant.staffPrice !== null && variant.staffPrice !== undefined
|
||||
? variant.staffPrice
|
||||
: option?.staffPrice;
|
||||
return price === null || price === undefined ? null : Number(price);
|
||||
}
|
||||
|
||||
// Staff price wins only when it's cheaper than the resolved (possibly early-bird) price.
|
||||
function withStaffPrice(resolved, staffPrice) {
|
||||
if (staffPrice === null || !(staffPrice < resolved.price)) return resolved;
|
||||
return { price: staffPrice, tierId: null };
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute effective unit price for an event option at a given time considering early-bird tiers.
|
||||
* Only checks deadline (not stock). Used for display and as a legacy fallback.
|
||||
@@ -55,9 +87,14 @@ function getEffectiveUnitPrice(eventOption, referenceTime, atTime) {
|
||||
*
|
||||
* @param {object} option - EventOption with price, earlyBirdTiers[]
|
||||
* @param {number} requestedQty - quantity being purchased
|
||||
* @param {object|null} user - buyer ({ role }); Staff and above may get the staff price
|
||||
* @returns {Promise<{ price: number, tierId: string|null }>}
|
||||
*/
|
||||
async function resolveOptionPrice(option, requestedQty = 1) {
|
||||
async function resolveOptionPrice(option, requestedQty = 1, user = null) {
|
||||
return withStaffPrice(await resolveOptionTierPrice(option, requestedQty), staffPriceFor(option, null, user));
|
||||
}
|
||||
|
||||
async function resolveOptionTierPrice(option, requestedQty) {
|
||||
const now = new Date();
|
||||
// Only option-level tiers (no variant association)
|
||||
const tiers = (Array.isArray(option.earlyBirdTiers) ? option.earlyBirdTiers : [])
|
||||
@@ -74,12 +111,15 @@ async function resolveOptionPrice(option, requestedQty = 1) {
|
||||
// Skip expired tiers
|
||||
if (now >= new Date(tier.deadline)) continue;
|
||||
|
||||
// Check stock limit if one is set
|
||||
// Check stock limit if one is set. Sold-so-far is summed across tranches (not
|
||||
// RegistrationOption rows directly) because one RegistrationOption can now span
|
||||
// multiple tiers across its tranches — the row's own appliedTierId/quantity only
|
||||
// reflects its most recent tranche.
|
||||
if (tier.stockLimit > 0) {
|
||||
const soldAgg = await prisma.registrationOption.aggregate({
|
||||
const soldAgg = await prisma.registrationOptionTranche.aggregate({
|
||||
where: {
|
||||
appliedTierId: tier.id,
|
||||
registration: { status: { not: 'cancelled' } }
|
||||
registrationOption: { registration: { status: { not: 'cancelled' } } }
|
||||
},
|
||||
_sum: { quantity: true }
|
||||
});
|
||||
@@ -102,9 +142,14 @@ async function resolveOptionPrice(option, requestedQty = 1) {
|
||||
* @param {object} option - EventOption with price, earlyBirdTiers[], variants[]
|
||||
* @param {string} variantId
|
||||
* @param {number} requestedQty
|
||||
* @param {object|null} user - buyer ({ role }); Staff and above may get the staff price
|
||||
* @returns {Promise<{ price: number, tierId: string|null }>}
|
||||
*/
|
||||
async function resolveVariantTierPrice(option, variantId, requestedQty = 1) {
|
||||
async function resolveVariantTierPrice(option, variantId, requestedQty = 1, user = null) {
|
||||
return withStaffPrice(await resolveVariantOnlyTierPrice(option, variantId, requestedQty), staffPriceFor(option, variantId, user));
|
||||
}
|
||||
|
||||
async function resolveVariantOnlyTierPrice(option, variantId, requestedQty) {
|
||||
const now = new Date();
|
||||
const variant = (option.variants || []).find(v => v.id === variantId);
|
||||
const basePrice = (variant && variant.price !== null && variant.price !== undefined)
|
||||
@@ -125,8 +170,8 @@ async function resolveVariantTierPrice(option, variantId, requestedQty = 1) {
|
||||
for (const tier of tiers) {
|
||||
if (now >= new Date(tier.deadline)) continue;
|
||||
if (tier.stockLimit > 0) {
|
||||
const soldAgg = await prisma.registrationOption.aggregate({
|
||||
where: { appliedTierId: tier.id, registration: { status: { not: 'cancelled' } } },
|
||||
const soldAgg = await prisma.registrationOptionTranche.aggregate({
|
||||
where: { appliedTierId: tier.id, registrationOption: { registration: { status: { not: 'cancelled' } } } },
|
||||
_sum: { quantity: true }
|
||||
});
|
||||
const tierSold = soldAgg._sum?.quantity || 0;
|
||||
@@ -154,58 +199,117 @@ async function refreshPricingForRegistration(registrationId) {
|
||||
include: {
|
||||
registrationOptions: {
|
||||
include: {
|
||||
eventOption: { include: { earlyBirdTiers: true, variants: true } }
|
||||
eventOption: { include: { earlyBirdTiers: true, variants: true } },
|
||||
tranches: true,
|
||||
}
|
||||
}
|
||||
},
|
||||
payments: true,
|
||||
user: { select: { role: true } },
|
||||
}
|
||||
});
|
||||
if (!registration) return { changed: false };
|
||||
|
||||
let anyChanged = false;
|
||||
const totalPaid = (registration.payments || []).reduce((sum, p) => sum + (p.amount || 0), 0);
|
||||
|
||||
// For each option, only its single newest tranche is ever a repricing candidate — older
|
||||
// tranches were already locked in at purchase time and must never be touched. A newest
|
||||
// tranche is only touched once payments-so-far are established to not yet cover it (i.e.
|
||||
// it's the still-unpaid remainder) — legacy rows with no tranches are left to the
|
||||
// getEffectiveUnitPrice fallback in computeRegistrationTotalDue instead.
|
||||
let registrationOlderTotal = 0;
|
||||
const candidates = [];
|
||||
for (const ro of registration.registrationOptions) {
|
||||
// Only refresh options that were priced via a tier
|
||||
if (!ro.appliedTierId) continue;
|
||||
const tranches = ro.tranches || [];
|
||||
if (tranches.length === 0) continue;
|
||||
const sorted = [...tranches].sort((a, b) => new Date(a.createdAt) - new Date(b.createdAt));
|
||||
const newest = sorted[sorted.length - 1];
|
||||
const older = sorted.slice(0, -1);
|
||||
registrationOlderTotal += older.reduce((s, t) => s + Number(t.quantity || 0) * Number(t.priceSnapshot || 0), 0);
|
||||
candidates.push({ ro, newest });
|
||||
}
|
||||
|
||||
// Payments don't even cover the already-locked-in older tranches yet — leave everything
|
||||
// alone rather than guessing which portion is "paid for".
|
||||
if (totalPaid < registrationOlderTotal - 0.001) return { changed: false };
|
||||
|
||||
// Each candidate is independent, so resolve/update them concurrently instead of one at a
|
||||
// time — this loop sits directly in the payment-capture path.
|
||||
const results = await Promise.all(candidates.map(async ({ ro, newest }) => {
|
||||
// Only refresh tranches that were priced via a tier
|
||||
if (!newest.appliedTierId) return false;
|
||||
|
||||
// Find the currently applied tier
|
||||
const currentTier = (ro.eventOption.earlyBirdTiers || []).find(t => t.id === ro.appliedTierId);
|
||||
const currentTier = (ro.eventOption.earlyBirdTiers || []).find(t => t.id === newest.appliedTierId);
|
||||
|
||||
if (currentTier && new Date() < new Date(currentTier.deadline)) {
|
||||
// The tier's deadline is still in the future — honor the locked price.
|
||||
continue;
|
||||
return false;
|
||||
}
|
||||
|
||||
// Deadline has passed (or tier record missing) — resolve the next applicable tier
|
||||
const resolved = ro.variantId
|
||||
? await resolveVariantTierPrice(ro.eventOption, ro.variantId, ro.quantity)
|
||||
: await resolveOptionPrice(ro.eventOption, ro.quantity);
|
||||
? await resolveVariantTierPrice(ro.eventOption, ro.variantId, newest.quantity, registration.user)
|
||||
: await resolveOptionPrice(ro.eventOption, newest.quantity, registration.user);
|
||||
|
||||
const tierChanged = resolved.tierId !== ro.appliedTierId;
|
||||
const priceChanged = ro.priceSnapshot !== null && Math.abs(resolved.price - ro.priceSnapshot) > 0.001;
|
||||
const tierChanged = resolved.tierId !== newest.appliedTierId;
|
||||
const priceChanged = Math.abs(resolved.price - newest.priceSnapshot) > 0.001;
|
||||
|
||||
if (tierChanged || priceChanged) {
|
||||
await prisma.registrationOptionTranche.update({
|
||||
where: { id: newest.id },
|
||||
data: { priceSnapshot: resolved.price, appliedTierId: resolved.tierId }
|
||||
});
|
||||
// Mirror onto the aggregate row — it's this option's most recent tranche.
|
||||
await prisma.registrationOption.update({
|
||||
where: { id: ro.id },
|
||||
data: {
|
||||
priceSnapshot: resolved.price,
|
||||
appliedTierId: resolved.tierId
|
||||
}
|
||||
data: { priceSnapshot: resolved.price, appliedTierId: resolved.tierId }
|
||||
});
|
||||
anyChanged = true;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}));
|
||||
|
||||
return { changed: results.some(Boolean) };
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the total for a single RegistrationOption line — sums quantity*priceSnapshot
|
||||
* across its tranches (each priced at whatever was in effect when it was purchased).
|
||||
* Falls back to the legacy single priceSnapshot/getEffectiveUnitPrice path for rows
|
||||
* created before tranches existed.
|
||||
*
|
||||
* @param {object} ro - RegistrationOption, optionally with .tranches[] included
|
||||
* @param {Date|null} lastPaymentAt - used only for the legacy getEffectiveUnitPrice fallback
|
||||
* @param {Date} atTime - evaluation time (used only for the legacy fallback)
|
||||
* @returns {number}
|
||||
*/
|
||||
function computeOptionLineTotal(ro, lastPaymentAt, atTime) {
|
||||
if (Array.isArray(ro.tranches) && ro.tranches.length > 0) {
|
||||
return ro.tranches.reduce((s, t) => s + Number(t.quantity || 0) * Number(t.priceSnapshot || 0), 0);
|
||||
}
|
||||
|
||||
return { changed: anyChanged };
|
||||
const qty = Number(ro.quantity || 0);
|
||||
let unit;
|
||||
if (ro.priceSnapshot !== null && ro.priceSnapshot !== undefined) {
|
||||
// priceSnapshot is authoritative — set at registration creation and kept current
|
||||
// by refreshPricingForRegistration at payment initiation time.
|
||||
unit = ro.priceSnapshot;
|
||||
} else {
|
||||
// Fallback: legacy row without a snapshot — re-evaluate from tier deadlines
|
||||
const eo = ro.eventOption || {};
|
||||
unit = getEffectiveUnitPrice(eo, lastPaymentAt, atTime);
|
||||
}
|
||||
return qty * unit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute total due for a registration at a given time.
|
||||
*
|
||||
* Uses priceSnapshot when present (authoritative — set at registration time and refreshed
|
||||
* before payment via refreshPricingForRegistration). Falls back to getEffectiveUnitPrice
|
||||
* for legacy rows without a snapshot.
|
||||
* Sums computeOptionLineTotal() across each RegistrationOption (tranche-aware when
|
||||
* `.tranches` is included, legacy priceSnapshot/getEffectiveUnitPrice fallback otherwise),
|
||||
* less the registration's coupon discount (`discountTotal`), never below zero.
|
||||
*
|
||||
* @param {object} registration - includes registrationOptions[].{priceSnapshot, quantity, eventOption}
|
||||
* @param {object} registration - includes registrationOptions[].{priceSnapshot, quantity, eventOption, tranches?}
|
||||
* and optionally payments[]
|
||||
* @param {Date} atTime - evaluation time (used for legacy fallback only)
|
||||
* @returns {number}
|
||||
@@ -221,22 +325,46 @@ function computeRegistrationTotalDue(registration, atTime) {
|
||||
}
|
||||
} catch {}
|
||||
|
||||
return registration.registrationOptions.reduce((sum, ro) => {
|
||||
const qty = Number(ro.quantity || 0);
|
||||
let unit;
|
||||
const subtotal = computeRegistrationSubtotal(registration, lastPaymentAt, atTime);
|
||||
return Math.max(0, subtotal - Number(registration.discountTotal || 0));
|
||||
}
|
||||
|
||||
if (ro.priceSnapshot !== null && ro.priceSnapshot !== undefined) {
|
||||
// priceSnapshot is authoritative — set at registration creation and kept current
|
||||
// by refreshPricingForRegistration at payment initiation time.
|
||||
unit = ro.priceSnapshot;
|
||||
} else {
|
||||
// Fallback: legacy row without a snapshot — re-evaluate from tier deadlines
|
||||
const eo = ro.eventOption || {};
|
||||
unit = getEffectiveUnitPrice(eo, lastPaymentAt, atTime);
|
||||
/** Sum of the item lines before any coupon discount. */
|
||||
function computeRegistrationSubtotal(registration, lastPaymentAt, atTime) {
|
||||
if (!registration || !Array.isArray(registration.registrationOptions)) return 0;
|
||||
return registration.registrationOptions.reduce((sum, ro) => sum + computeOptionLineTotal(ro, lastPaymentAt, atTime), 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Attach computed, tranche-aware display totals to a registration in place: `lineTotal` on
|
||||
* each RegistrationOption and `totalDueComputed` on the registration itself. Lets API
|
||||
* responses hand the frontend an exact total instead of every page re-deriving
|
||||
* unitPrice*quantity client-side (which goes wrong once a line spans multiple tranches).
|
||||
*
|
||||
* @param {object} registration - requires registrationOptions[].tranches included
|
||||
* @returns {object} the same registration, mutated
|
||||
*/
|
||||
function attachComputedTotals(registration) {
|
||||
if (!registration) return registration;
|
||||
let lastPaymentAt = null;
|
||||
try {
|
||||
if (registration.payments && Array.isArray(registration.payments) && registration.payments.length > 0) {
|
||||
lastPaymentAt = new Date(Math.max(...registration.payments.map(p => new Date(p.createdAt).getTime())));
|
||||
}
|
||||
} catch {}
|
||||
const now = new Date();
|
||||
if (Array.isArray(registration.registrationOptions)) {
|
||||
for (const ro of registration.registrationOptions) {
|
||||
ro.lineTotal = computeOptionLineTotal(ro, lastPaymentAt, now);
|
||||
}
|
||||
}
|
||||
registration.subtotalComputed = computeRegistrationSubtotal(registration, lastPaymentAt, now);
|
||||
registration.totalDueComputed = computeRegistrationTotalDue(registration, now);
|
||||
return registration;
|
||||
}
|
||||
|
||||
return sum + qty * unit;
|
||||
}, 0);
|
||||
function attachComputedTotalsToList(registrations) {
|
||||
return (registrations || []).map(attachComputedTotals);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
@@ -244,5 +372,10 @@ module.exports = {
|
||||
resolveOptionPrice,
|
||||
resolveVariantTierPrice,
|
||||
refreshPricingForRegistration,
|
||||
computeOptionLineTotal,
|
||||
computeRegistrationTotalDue,
|
||||
computeRegistrationSubtotal,
|
||||
staffPriceFor,
|
||||
attachComputedTotals,
|
||||
attachComputedTotalsToList,
|
||||
};
|
||||
@@ -0,0 +1,9 @@
|
||||
// Resolve a client IP from the request (works behind proxies) — mirrors the equivalent
|
||||
// local helper in userController.js, shared here for the admin-audit call sites.
|
||||
function getClientIp(req) {
|
||||
const forwarded = req.headers['x-forwarded-for'];
|
||||
if (forwarded) return forwarded.split(',')[0].trim();
|
||||
return req.socket?.remoteAddress || 'unknown';
|
||||
}
|
||||
|
||||
module.exports = { getClientIp };
|
||||
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* Ticket stock — the one place that decides how many places are taken.
|
||||
*
|
||||
* Taken = places on registrations that aren't cancelled + places held for a live
|
||||
* waitlist offer (until it expires). An option's stock limit covers all its variants;
|
||||
* a variant can also have its own limit. 0 means unlimited.
|
||||
*/
|
||||
|
||||
const prisma = require('../config/db');
|
||||
|
||||
/**
|
||||
* @param {{ eventOptionId: string, variantId?: string|null }} line - variantId given → that variant only
|
||||
* @param {{ excludeEntryId?: string, db?: object }} [opts] - excludeEntryId: ignore this
|
||||
* waitlist offer's hold (the person claiming it)
|
||||
*/
|
||||
async function takenCount({ eventOptionId, variantId = null }, { excludeEntryId = null, db = prisma } = {}) {
|
||||
const scope = variantId ? { variantId } : { eventOptionId };
|
||||
const [registered, held] = await Promise.all([
|
||||
db.registrationOption.aggregate({
|
||||
where: { ...scope, registration: { status: { not: 'cancelled' } } },
|
||||
_sum: { quantity: true },
|
||||
}),
|
||||
db.waitlistEntry.aggregate({
|
||||
where: {
|
||||
...scope,
|
||||
status: 'offered',
|
||||
offerExpiresAt: { gt: new Date() },
|
||||
...(excludeEntryId ? { id: { not: excludeEntryId } } : {}),
|
||||
},
|
||||
_sum: { offeredQuantity: true },
|
||||
}),
|
||||
]);
|
||||
return (registered._sum?.quantity || 0) + (held._sum?.offeredQuantity || 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Places still free on a line: the tighter of the option and variant limits.
|
||||
* @returns {Promise<number|null>} null when unlimited
|
||||
*/
|
||||
async function remainingFor(option, variant, opts = {}) {
|
||||
const limits = [];
|
||||
if (option?.stockLimit > 0) {
|
||||
limits.push(option.stockLimit - await takenCount({ eventOptionId: option.id }, opts));
|
||||
}
|
||||
if (variant?.stockLimit > 0) {
|
||||
limits.push(variant.stockLimit - await takenCount({ eventOptionId: option.id, variantId: variant.id }, opts));
|
||||
}
|
||||
if (limits.length === 0) return null;
|
||||
return Math.max(0, Math.min(...limits));
|
||||
}
|
||||
|
||||
module.exports = { takenCount, remainingFor };
|
||||
@@ -1,11 +1,14 @@
|
||||
const prisma = require('../config/db');
|
||||
const { v4: uuidv4 } = require('uuid');
|
||||
const { planLineTickets } = require('./attendees');
|
||||
|
||||
/**
|
||||
* Generate tickets for a registration when payment status is "paid".
|
||||
*
|
||||
* Rules:
|
||||
* - Exactly ONE ticket per registrationOption (keyed by eventOptionId).
|
||||
* - Each named attendee (group tickets) gets their own quantity-1 ticket; the rest of a
|
||||
* line's places share ONE quantity ticket per registrationOption (see
|
||||
* utils/attendees.js planLineTickets).
|
||||
* - If duplicate registrationOptions exist for the same eventOptionId (old data),
|
||||
* consolidate them: merge quantities, keep the one with scan history, delete the rest.
|
||||
* - If duplicate ticket records exist for the same registrationOption (old data),
|
||||
@@ -54,88 +57,76 @@ const generateTicketsForRegistration = async (registrationId) => {
|
||||
byOption.get(key).push(ro);
|
||||
}
|
||||
|
||||
// For each group with duplicates, merge into the one that has tickets (or the first)
|
||||
for (const [, group] of byOption) {
|
||||
if (group.length <= 1) continue;
|
||||
// For each group with duplicates, merge into the one that has tickets (or the first).
|
||||
// Different (eventOptionId, variantId) groups touch disjoint rows, so process groups
|
||||
// concurrently instead of one at a time.
|
||||
await Promise.all(Array.from(byOption.values()).map(async (group) => {
|
||||
if (group.length <= 1) return;
|
||||
|
||||
// Prefer the option that already has tickets
|
||||
const withTickets = group.filter(ro => (ro.tickets || []).length > 0);
|
||||
const primary = withTickets.length > 0 ? withTickets[0] : group[0];
|
||||
const duplicates = group.filter(ro => ro.id !== primary.id);
|
||||
|
||||
// Move all tickets from duplicates to primary, then delete duplicate options
|
||||
for (const dup of duplicates) {
|
||||
for (const t of (dup.tickets || [])) {
|
||||
await prisma.ticket.update({ where: { id: t.id }, data: { registrationOptionId: primary.id, updatedAt: new Date() } });
|
||||
}
|
||||
const totalMergedQty = duplicates.reduce((s, d) => s + (d.quantity || 0), 0);
|
||||
await prisma.registrationOption.update({
|
||||
where: { id: primary.id },
|
||||
data: { quantity: (primary.quantity || 0) + totalMergedQty, }
|
||||
});
|
||||
await prisma.registrationOption.delete({ where: { id: dup.id } });
|
||||
}
|
||||
// Move all tickets from duplicates to primary (independent rows — safe to parallelize)
|
||||
await Promise.all(duplicates.map(dup => Promise.all(
|
||||
(dup.tickets || []).map(t =>
|
||||
prisma.ticket.update({ where: { id: t.id }, data: { registrationOptionId: primary.id, updatedAt: new Date() } })
|
||||
)
|
||||
)));
|
||||
|
||||
// Re-load the primary's current quantity after merge
|
||||
// Single write of the merged quantity, then delete the now-empty duplicate options
|
||||
const totalMergedQty = duplicates.reduce((s, d) => s + (d.quantity || 0), 0);
|
||||
await prisma.registrationOption.update({
|
||||
where: { id: primary.id },
|
||||
data: { quantity: (primary.quantity || 0) + totalMergedQty }
|
||||
});
|
||||
await Promise.all(duplicates.map(dup => prisma.registrationOption.delete({ where: { id: dup.id } })));
|
||||
|
||||
// Re-load the primary's current quantity + tickets after merge
|
||||
const updated = await prisma.registrationOption.findUnique({ where: { id: primary.id } });
|
||||
primary.quantity = updated?.quantity ?? primary.quantity;
|
||||
// Reload tickets
|
||||
primary.tickets = await prisma.ticket.findMany({
|
||||
where: { registrationOptionId: primary.id },
|
||||
include: { usages: true },
|
||||
orderBy: { createdAt: 'asc' }
|
||||
});
|
||||
}
|
||||
|
||||
// ── Step 2: For each unique option, ensure exactly one ticket with correct qty ──
|
||||
const generatedTickets = [];
|
||||
}));
|
||||
|
||||
// ── Step 2: For each unique option, sync its tickets: one per named attendee, plus
|
||||
// one shared quantity ticket for any unnamed places ──
|
||||
// Re-read fresh list (some options may have been deleted above)
|
||||
const freshOptions = await prisma.registrationOption.findMany({
|
||||
where: { registrationId },
|
||||
include: {
|
||||
tickets: { include: { usages: true }, orderBy: { createdAt: 'asc' } }
|
||||
tickets: { include: { usages: true }, orderBy: { createdAt: 'asc' } },
|
||||
attendees: { orderBy: { order: 'asc' } },
|
||||
}
|
||||
});
|
||||
|
||||
for (const option of freshOptions) {
|
||||
const targetQty = option.quantity || 1;
|
||||
const existingTickets = option.tickets || [];
|
||||
// Each option owns disjoint tickets, so resolve them concurrently instead of one at a time.
|
||||
const perOptionResults = await Promise.all(freshOptions.map(async (option) => {
|
||||
const plan = planLineTickets({ quantity: option.quantity || 1 }, option.attendees || [], option.tickets || []);
|
||||
await Promise.all([
|
||||
plan.remove.length ? prisma.ticket.deleteMany({ where: { id: { in: plan.remove } } }) : null,
|
||||
...plan.update.map(u => prisma.ticket.update({ where: { id: u.id }, data: { ...u.data, updatedAt: new Date() } })),
|
||||
]);
|
||||
return Promise.all(plan.create.map(c => prisma.ticket.create({
|
||||
data: {
|
||||
id: uuidv4(),
|
||||
qrCode: uuidv4(),
|
||||
registrationOptionId: option.id,
|
||||
userId: registration.userId,
|
||||
eventId: registration.eventId,
|
||||
quantity: c.quantity,
|
||||
attendeeId: c.attendeeId,
|
||||
holderUserId: c.holderUserId,
|
||||
updatedAt: new Date()
|
||||
}
|
||||
})));
|
||||
}));
|
||||
|
||||
if (existingTickets.length === 0) {
|
||||
// Create one ticket
|
||||
const ticket = await prisma.ticket.create({
|
||||
data: {
|
||||
id: uuidv4(),
|
||||
qrCode: uuidv4(),
|
||||
registrationOptionId: option.id,
|
||||
userId: registration.userId,
|
||||
eventId: registration.eventId,
|
||||
quantity: targetQty,
|
||||
updatedAt: new Date()
|
||||
}
|
||||
});
|
||||
generatedTickets.push(ticket);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Pick primary: prefer scanned, otherwise oldest
|
||||
const withUsages = existingTickets.filter(t => (t.usages || []).length > 0);
|
||||
const primary = withUsages.length > 0 ? withUsages[0] : existingTickets[0];
|
||||
|
||||
// Update quantity on primary if needed
|
||||
if (primary.quantity !== targetQty) {
|
||||
await prisma.ticket.update({ where: { id: primary.id }, data: { quantity: targetQty, updatedAt: new Date() } });
|
||||
}
|
||||
|
||||
// Delete unscanned duplicates
|
||||
const dups = existingTickets.filter(t => t.id !== primary.id && (t.usages || []).length === 0);
|
||||
if (dups.length > 0) {
|
||||
await prisma.ticket.deleteMany({ where: { id: { in: dups.map(t => t.id) } } });
|
||||
}
|
||||
}
|
||||
|
||||
return generatedTickets;
|
||||
return perOptionResults.flat();
|
||||
} catch (error) {
|
||||
console.error('Error generating tickets:', error);
|
||||
throw error;
|
||||
|
||||
@@ -0,0 +1,285 @@
|
||||
/**
|
||||
* Two-factor authentication helpers: 6-digit codes delivered by email or
|
||||
* WhatsApp, and "trust this device" tokens that let a browser skip the code.
|
||||
*
|
||||
* Neither the code nor the device token is stored in the clear — only an
|
||||
* HMAC keyed with JWT_SECRET, so a leaked database row can't be replayed.
|
||||
*/
|
||||
|
||||
const crypto = require('crypto');
|
||||
const prisma = require('../config/db');
|
||||
const { describeUserAgent } = require('./userAgent');
|
||||
|
||||
const CODE_TTL_MS = 10 * 60 * 1000; // a code is valid for 10 minutes
|
||||
const MAX_ATTEMPTS = 5; // wrong guesses allowed per code
|
||||
const RESEND_COOLDOWN_MS = 60 * 1000; // minimum gap between sends
|
||||
const CHALLENGE_MAX_AGE_MS = 30 * 60 * 1000; // after this, log in again rather than resend
|
||||
const TRUSTED_DEVICE_TTL_MS = 30 * 24 * 60 * 60 * 1000;
|
||||
|
||||
const METHODS = ['email', 'whatsapp'];
|
||||
|
||||
// Roles that must use two-factor authentication.
|
||||
const REQUIRED_ROLES = ['admin', 'supervisor'];
|
||||
|
||||
// Application error carrying the HTTP status the controller should respond with.
|
||||
function fail(message, statusCode = 400) {
|
||||
const err = new Error(message);
|
||||
err.statusCode = statusCode;
|
||||
return err;
|
||||
}
|
||||
|
||||
function generateCode() {
|
||||
return String(crypto.randomInt(0, 1000000)).padStart(6, '0');
|
||||
}
|
||||
|
||||
function hmac(value) {
|
||||
return crypto.createHmac('sha256', process.env.JWT_SECRET || '').update(String(value)).digest('hex');
|
||||
}
|
||||
|
||||
// Namespaced so a code hash can never collide with a device-token hash.
|
||||
const hashCode = (code) => hmac(`2fa-code:${code}`);
|
||||
const hashDeviceToken = (token) => hmac(`2fa-device:${token}`);
|
||||
|
||||
function safeEqual(a, b) {
|
||||
const ba = Buffer.from(String(a));
|
||||
const bb = Buffer.from(String(b));
|
||||
return ba.length === bb.length && crypto.timingSafeEqual(ba, bb);
|
||||
}
|
||||
|
||||
function hasRealEmail(user) {
|
||||
return !!(user?.email && !user.email.endsWith('@guest.local') && !user.email.endsWith('@deleted.invalid') && !user.email.endsWith('@deleted.local'));
|
||||
}
|
||||
|
||||
function maskEmail(email) {
|
||||
const [local, domain] = String(email || '').split('@');
|
||||
if (!local || !domain) return '•••';
|
||||
return `${local[0]}•••@${domain}`;
|
||||
}
|
||||
|
||||
function maskPhone(phone) {
|
||||
const digits = String(phone || '').replace(/\D/g, '');
|
||||
return `••• ••• ${digits.slice(-4)}`;
|
||||
}
|
||||
|
||||
/** Channels this user could receive a code on right now. */
|
||||
async function availableMethods(user) {
|
||||
const { isValidZAPhone, isConfigured } = require('./whatsapp');
|
||||
const methods = [];
|
||||
if (hasRealEmail(user)) methods.push('email');
|
||||
if (isValidZAPhone(user?.phoneNumber) && (await isConfigured())) methods.push('whatsapp');
|
||||
return methods;
|
||||
}
|
||||
|
||||
/**
|
||||
* True when this user's role must use two-factor authentication.
|
||||
* TWO_FACTOR_REQUIRED_ROLES=false turns the requirement off (break-glass).
|
||||
*/
|
||||
function requiresTwoFactor(user) {
|
||||
if (String(process.env.TWO_FACTOR_REQUIRED_ROLES || 'true').toLowerCase() === 'false') return false;
|
||||
return REQUIRED_ROLES.includes(user?.role);
|
||||
}
|
||||
|
||||
/**
|
||||
* The channel a required-2FA user should be enrolled on, or null when no channel
|
||||
* can actually deliver a code — SMTP not set up and no WhatsApp — in which case the
|
||||
* requirement is skipped rather than locking the account out.
|
||||
*/
|
||||
async function enforcementMethod(user) {
|
||||
const { isSmtpConfigured } = require('./email');
|
||||
if (hasRealEmail(user) && isSmtpConfigured()) return 'email';
|
||||
const { isValidZAPhone, isConfigured } = require('./whatsapp');
|
||||
if (isValidZAPhone(user?.phoneNumber) && (await isConfigured())) return 'whatsapp';
|
||||
return null;
|
||||
}
|
||||
|
||||
/** True when this user must enrol in 2FA before using their account. */
|
||||
async function mustEnrolTwoFactor(user) {
|
||||
if (!user || user.twoFactorEnabled || !requiresTwoFactor(user)) return false;
|
||||
return (await enforcementMethod(user)) !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends the code and reports where it actually went. A WhatsApp failure (session
|
||||
* disconnected, not configured) falls back to email so a user is never locked out
|
||||
* by the WhatsApp gateway being down. Throws when no channel could deliver.
|
||||
* Pass `allowFallback: false` when the point is to prove the chosen channel works.
|
||||
*/
|
||||
async function deliverCode(user, method, code, { allowFallback = true } = {}) {
|
||||
const { getSettingSync } = require('./settingsCache');
|
||||
const orgName = getSettingSync('org_name', process.env.ORG_NAME || 'Cross Code');
|
||||
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
console.info(`[2fa][dev] code for ${user.email}: ${code}`);
|
||||
}
|
||||
|
||||
if (method === 'whatsapp') {
|
||||
const { isValidZAPhone, isConfigured, sendText } = require('./whatsapp');
|
||||
const { buildWATwoFactorCode } = require('./waMessages');
|
||||
try {
|
||||
// sendText silently no-ops when WhatsApp isn't set up, so check first.
|
||||
if (!isValidZAPhone(user.phoneNumber) || !(await isConfigured())) throw new Error('WhatsApp unavailable');
|
||||
await sendText(user.phoneNumber, buildWATwoFactorCode({ name: user.name, code }));
|
||||
return { method: 'whatsapp', destination: maskPhone(user.phoneNumber) };
|
||||
} catch (e) {
|
||||
console.warn('[2fa] WhatsApp code failed:', e?.message || e);
|
||||
if (!allowFallback) throw fail('We couldn\'t send a WhatsApp message right now. Please try again later or choose email.', 503);
|
||||
if (!hasRealEmail(user)) throw fail('We couldn\'t send your verification code. Please try again later.', 503);
|
||||
}
|
||||
}
|
||||
|
||||
if (!hasRealEmail(user)) throw fail('We couldn\'t send your verification code. Please try again later.', 503);
|
||||
const { sendMail, buildTwoFactorCodeEmail } = require('./email');
|
||||
try {
|
||||
await sendMail({ to: user.email, subject: `Your ${orgName} verification code`, ...buildTwoFactorCodeEmail({ name: user.name, code }) });
|
||||
} catch (e) {
|
||||
console.warn('[2fa] Email code failed:', e?.message || e);
|
||||
throw fail('We couldn\'t send your verification code. Please try again later.', 503);
|
||||
}
|
||||
return { method: 'email', destination: maskEmail(user.email) };
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a fresh code and records the challenge. Any earlier challenge of the same
|
||||
* purpose for this user is discarded, so only the newest code works.
|
||||
*/
|
||||
async function createChallenge(user, purpose, method, options) {
|
||||
const code = generateCode();
|
||||
const sent = await deliverCode(user, method, code, options);
|
||||
|
||||
await prisma.twoFactorChallenge.deleteMany({ where: { userId: user.id, purpose } });
|
||||
const challenge = await prisma.twoFactorChallenge.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
purpose,
|
||||
method: sent.method,
|
||||
codeHash: hashCode(code),
|
||||
expiresAt: new Date(Date.now() + CODE_TTL_MS),
|
||||
},
|
||||
});
|
||||
|
||||
return { challengeId: challenge.id, method: sent.method, destination: sent.destination };
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks a submitted code. Returns the challenge (with its user) on success and
|
||||
* throws otherwise. Pass `userId` to also require the challenge belong to that user.
|
||||
*/
|
||||
async function verifyChallenge(challengeId, code, purpose, userId) {
|
||||
const invalid = () => fail('That code is invalid or has expired. Please request a new one.');
|
||||
if (!challengeId || typeof challengeId !== 'string') throw invalid();
|
||||
const submitted = String(code ?? '').replace(/\D/g, '');
|
||||
|
||||
// Count the attempt before comparing, atomically, so parallel guesses can't
|
||||
// slip past the attempt limit.
|
||||
const counted = await prisma.twoFactorChallenge.updateMany({
|
||||
where: {
|
||||
id: challengeId,
|
||||
purpose,
|
||||
...(userId ? { userId } : {}),
|
||||
usedAt: null,
|
||||
expiresAt: { gt: new Date() },
|
||||
attempts: { lt: MAX_ATTEMPTS },
|
||||
},
|
||||
data: { attempts: { increment: 1 } },
|
||||
});
|
||||
if (counted.count !== 1) throw invalid();
|
||||
|
||||
const challenge = await prisma.twoFactorChallenge.findUnique({ where: { id: challengeId }, include: { user: true } });
|
||||
if (!challenge || !safeEqual(challenge.codeHash, hashCode(submitted))) {
|
||||
const left = challenge ? MAX_ATTEMPTS - challenge.attempts : 0;
|
||||
throw fail(left > 0 ? `Incorrect code. ${left} attempt${left === 1 ? '' : 's'} left.` : 'Too many incorrect attempts. Please request a new code.');
|
||||
}
|
||||
|
||||
// Single use — if two requests race with the right code, only one wins.
|
||||
const used = await prisma.twoFactorChallenge.updateMany({
|
||||
where: { id: challengeId, usedAt: null },
|
||||
data: { usedAt: new Date() },
|
||||
});
|
||||
if (used.count !== 1) throw invalid();
|
||||
|
||||
return challenge;
|
||||
}
|
||||
|
||||
/** Replaces the code on an existing challenge and sends it again. */
|
||||
async function resendChallenge(challengeId, purpose, userId) {
|
||||
const expired = () => fail('This verification has expired. Please start again.');
|
||||
if (!challengeId || typeof challengeId !== 'string') throw expired();
|
||||
|
||||
const challenge = await prisma.twoFactorChallenge.findUnique({ where: { id: challengeId }, include: { user: true } });
|
||||
if (!challenge || challenge.purpose !== purpose || challenge.usedAt || (userId && challenge.userId !== userId)) throw expired();
|
||||
if (Date.now() - challenge.createdAt.getTime() > CHALLENGE_MAX_AGE_MS) throw expired();
|
||||
|
||||
const wait = RESEND_COOLDOWN_MS - (Date.now() - challenge.lastSentAt.getTime());
|
||||
if (wait > 0) throw fail(`Please wait ${Math.ceil(wait / 1000)} seconds before requesting another code.`, 429);
|
||||
|
||||
// Claim the send slot first so two simultaneous resends can't both go out.
|
||||
const claimed = await prisma.twoFactorChallenge.updateMany({
|
||||
where: { id: challengeId, lastSentAt: challenge.lastSentAt, usedAt: null },
|
||||
data: { lastSentAt: new Date() },
|
||||
});
|
||||
if (claimed.count !== 1) throw fail('Please wait before requesting another code.', 429);
|
||||
|
||||
const code = generateCode();
|
||||
const sent = await deliverCode(challenge.user, challenge.method, code);
|
||||
await prisma.twoFactorChallenge.update({
|
||||
where: { id: challengeId },
|
||||
data: { codeHash: hashCode(code), method: sent.method, attempts: 0, expiresAt: new Date(Date.now() + CODE_TTL_MS) },
|
||||
});
|
||||
|
||||
return { challengeId, method: sent.method, destination: sent.destination };
|
||||
}
|
||||
|
||||
/** Remembers this browser for 30 days. Returns the raw token to hand to the client. */
|
||||
async function issueTrustedDevice(userId, userAgent) {
|
||||
const token = crypto.randomBytes(32).toString('hex');
|
||||
await prisma.trustedDevice.deleteMany({ where: { userId, expiresAt: { lt: new Date() } } });
|
||||
await prisma.trustedDevice.create({
|
||||
data: {
|
||||
userId,
|
||||
tokenHash: hashDeviceToken(token),
|
||||
device: describeUserAgent(userAgent),
|
||||
expiresAt: new Date(Date.now() + TRUSTED_DEVICE_TTL_MS),
|
||||
},
|
||||
});
|
||||
return token;
|
||||
}
|
||||
|
||||
/** True when `token` is an unexpired trusted-device token belonging to this user. */
|
||||
async function isTrustedDevice(userId, token) {
|
||||
if (!token || typeof token !== 'string') return false;
|
||||
const row = await prisma.trustedDevice.findUnique({ where: { tokenHash: hashDeviceToken(token) } });
|
||||
if (!row || row.userId !== userId || row.expiresAt <= new Date()) return false;
|
||||
prisma.trustedDevice.update({ where: { id: row.id }, data: { lastUsedAt: new Date() } }).catch(() => {});
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Forgets every trusted device — call whenever credentials or sessions are reset. */
|
||||
async function clearTrustedDevices(userId) {
|
||||
try {
|
||||
await prisma.trustedDevice.deleteMany({ where: { userId } });
|
||||
} catch (e) {
|
||||
console.error('Failed to clear trusted devices:', e?.message);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
METHODS,
|
||||
REQUIRED_ROLES,
|
||||
requiresTwoFactor,
|
||||
enforcementMethod,
|
||||
mustEnrolTwoFactor,
|
||||
MAX_ATTEMPTS,
|
||||
generateCode,
|
||||
hashCode,
|
||||
hashDeviceToken,
|
||||
maskEmail,
|
||||
maskPhone,
|
||||
hasRealEmail,
|
||||
availableMethods,
|
||||
createChallenge,
|
||||
verifyChallenge,
|
||||
resendChallenge,
|
||||
issueTrustedDevice,
|
||||
isTrustedDevice,
|
||||
clearTrustedDevices,
|
||||
};
|
||||
@@ -23,7 +23,7 @@ const { getSettingSync } = require('./settingsCache');
|
||||
|
||||
function getOrg() {
|
||||
return {
|
||||
name: getSettingSync('org_name', process.env.ORG_NAME || 'Hope Events'),
|
||||
name: getSettingSync('org_name', process.env.ORG_NAME || 'Cross Code'),
|
||||
email: process.env.EMAIL_FROM || process.env.EMAIL_USER || '',
|
||||
url: (process.env.FRONTEND_URL || process.env.APP_BASE_URL || 'http://localhost:3001').replace(/\/$/, ''),
|
||||
};
|
||||
@@ -46,9 +46,13 @@ function buildWARegistration(reg, { isNew = true, balance, totalDue, totalPaid }
|
||||
? `You're registered for *${eventTitle}*${eventDate ? ` on ${eventDate}` : ''}.`
|
||||
: `Your registration for *${eventTitle}* has been updated.`;
|
||||
|
||||
const items = (reg.registrationOptions || [])
|
||||
.map(ro => `- ${ro.eventOption?.name || 'Option'} ×${ro.quantity} — ${fmtAmount((ro.eventOption?.price || 0) * ro.quantity)}`)
|
||||
.join('\n');
|
||||
const { computeOptionLineTotal } = require('./pricing');
|
||||
const { couponDiscountLine } = require('./pdfDocs');
|
||||
const coupon = couponDiscountLine(reg);
|
||||
const items = [
|
||||
...(reg.registrationOptions || []).map(ro => `- ${ro.eventOption?.name || 'Option'} ×${ro.quantity} — ${fmtAmount(computeOptionLineTotal(ro, null, new Date()))}`),
|
||||
...(coupon ? [`- ${coupon.label} — -${fmtAmount(coupon.amount)}`] : []),
|
||||
].join('\n');
|
||||
|
||||
const paid = totalPaid ?? (reg.payments || []).reduce((s, p) => s + (p.amount || 0), 0);
|
||||
const due = totalDue ?? 0;
|
||||
@@ -148,6 +152,25 @@ function buildWALogin({ name, when, location, userAgent }) {
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
// ─── Two-factor code ──────────────────────────────────────────────────────────
|
||||
|
||||
function buildWATwoFactorCode({ name, code }) {
|
||||
const org = getOrg();
|
||||
return [
|
||||
`🔑 *Your verification code*`,
|
||||
'',
|
||||
`Hi ${name || 'there'},`,
|
||||
'',
|
||||
`Your *${org.name}* verification code is:`,
|
||||
'',
|
||||
`*${code}*`,
|
||||
'',
|
||||
`_It expires in 10 minutes. Never share this code with anyone._`,
|
||||
'',
|
||||
`> _Not you?_ Change your password immediately at ${org.url} or contact ${org.email}.`,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
// ─── Welcome ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function buildWAWelcome({ name, events }) {
|
||||
@@ -266,12 +289,46 @@ function buildWADonationAppliedToRegistrant(payment) {
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
// Mirrors buildWADonationAppliedToRegistrant for the reverse action — payment.createdAt here is
|
||||
// the leg's original creation time, not "now"; use the current date for the balance display.
|
||||
function buildWADonationUnassignedFromRegistrant(payment) {
|
||||
const org = getOrg();
|
||||
const reg = payment.registration;
|
||||
const eventTitle = reg?.event?.title || 'the event';
|
||||
const name = reg?.user?.name || 'there';
|
||||
const amount = fmtAmount(payment.amount);
|
||||
|
||||
let balLine = '';
|
||||
if (reg) {
|
||||
const { computeRegistrationTotalDue } = require('./pricing');
|
||||
const totalDue = computeRegistrationTotalDue(reg, new Date());
|
||||
const totalPaid = (reg.payments || []).reduce((s, p) => s + (p.amount || 0), 0);
|
||||
const balance = Math.max(totalDue - totalPaid, 0);
|
||||
balLine = balance > 0
|
||||
? `\n*Balance now owing:* ${fmtAmount(balance)}\n_Please arrange payment at ${org.url} or at the door._`
|
||||
: '';
|
||||
}
|
||||
|
||||
return [
|
||||
`⚠️ *Donation Removed*`,
|
||||
'',
|
||||
`Hi ${name},`,
|
||||
'',
|
||||
`A donation of *${amount}* previously applied to your registration for *${eventTitle}* has been removed by our team.`,
|
||||
balLine,
|
||||
'',
|
||||
`_${org.name}_ | ${org.url}`,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
buildWARegistration,
|
||||
buildWAPayment,
|
||||
buildWARefund,
|
||||
buildWADonationAppliedToRegistrant,
|
||||
buildWADonationUnassignedFromRegistrant,
|
||||
buildWALogin,
|
||||
buildWATwoFactorCode,
|
||||
buildWAWelcome,
|
||||
buildWAAccountClosed,
|
||||
buildWATicketCaption,
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
/**
|
||||
* Waitlist for sold-out ticket options.
|
||||
*
|
||||
* Rules:
|
||||
* - Only on events with waitlistEnabled, and only for a line that's actually sold out.
|
||||
* - When places free up (a cancellation, a smaller booking, a raised stock limit, or an
|
||||
* offer expiring), processWaitlist offers them to the next people in line. An offer
|
||||
* holds the places for event.waitlistOfferHours — they count as taken (utils/stock.js)
|
||||
* so the public site can't sell them — and sends a claim link by email/WhatsApp.
|
||||
* - If fewer places are free than someone asked for: with waitlistPartialOffers they're
|
||||
* offered what's free; otherwise they're skipped for the next person who fits.
|
||||
* - Claiming = registering with the link (createRegistration's waitlistToken). Early-bird
|
||||
* and coupon rules apply as usual. An unclaimed offer expires and moves on.
|
||||
*/
|
||||
|
||||
const crypto = require('crypto');
|
||||
const prisma = require('../config/db');
|
||||
const { remainingFor } = require('./stock');
|
||||
|
||||
const ACTIVE = ['waiting', 'offered'];
|
||||
|
||||
function waitlistError(message, statusCode = 400) {
|
||||
const err = new Error(message);
|
||||
err.statusCode = statusCode;
|
||||
return err;
|
||||
}
|
||||
|
||||
const hashToken = (token) => crypto.createHmac('sha256', process.env.JWT_SECRET || '').update(`waitlist:${token}`).digest('hex');
|
||||
|
||||
function claimLink(token) {
|
||||
const base = (process.env.FRONTEND_URL || process.env.APP_BASE_URL || 'http://localhost:3001').split(',')[0].trim().replace(/\/$/, '');
|
||||
return `${base}/waitlist/claim/${token}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Who gets offered what, given the free places on a line. Pure.
|
||||
* @param {Array} waiting - entries in queue order [{ id, quantity }]
|
||||
* @param {number} free - places free
|
||||
* @param {boolean} partial - offer what's free when it's less than asked for
|
||||
* @returns {Array<{ entryId: string, quantity: number }>}
|
||||
*/
|
||||
function pickOffers(waiting, free, partial) {
|
||||
const offers = [];
|
||||
let left = free;
|
||||
for (const entry of waiting) {
|
||||
if (left <= 0) break;
|
||||
if (entry.quantity <= left) {
|
||||
offers.push({ entryId: entry.id, quantity: entry.quantity });
|
||||
left -= entry.quantity;
|
||||
} else if (partial) {
|
||||
// First in line gets what's free; nobody behind them jumps the queue.
|
||||
offers.push({ entryId: entry.id, quantity: left });
|
||||
left = 0;
|
||||
}
|
||||
// else: skip to the next person who fits
|
||||
}
|
||||
return offers;
|
||||
}
|
||||
|
||||
async function notifyOffer(entry, token) {
|
||||
const { sendMail, emailWrapper, ctaButton, fallbackLink } = require('./email');
|
||||
const { shouldEmail, waText, waTextAny } = require('./notify');
|
||||
const ff = `-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif`;
|
||||
const user = entry.user;
|
||||
const link = claimLink(token);
|
||||
const item = `${entry.eventOption.name}${entry.variant ? ` (${entry.variant.name})` : ''}`;
|
||||
const until = new Date(entry.offerExpiresAt).toLocaleString('en-GB', { day: 'numeric', month: 'long', hour: '2-digit', minute: '2-digit' });
|
||||
const partialNote = entry.offeredQuantity < entry.quantity
|
||||
? ` You asked for ${entry.quantity}; ${entry.offeredQuantity} came free, and you can take those now.` : '';
|
||||
const line = `${entry.offeredQuantity} × ${item} for ${entry.event.title} ${entry.offeredQuantity === 1 ? 'is' : 'are'} now available for you.${partialNote} We'll hold ${entry.offeredQuantity === 1 ? 'it' : 'them'} until ${until}.`;
|
||||
|
||||
const hasEmail = user?.email && !user.email.endsWith('@guest.local') && !user.email.endsWith('@deleted.invalid');
|
||||
const sends = [];
|
||||
if (hasEmail && shouldEmail(user)) {
|
||||
const html = emailWrapper(`
|
||||
<p style="font-size:22px;font-weight:800;color:#0f172a;margin:0 0 8px 0">A place has opened up!</p>
|
||||
<p style="margin:0 0 8px 0;color:#374151;font-family:${ff}">Hi <strong>${user.name}</strong>,</p>
|
||||
<p style="margin:0 0 24px 0;color:#374151;font-family:${ff}">${line}</p>
|
||||
${ctaButton('Claim my place', link)}
|
||||
${fallbackLink(link)}
|
||||
`, { preheader: `Your waitlist place for ${entry.event.title}` });
|
||||
sends.push(sendMail({ to: user.email, subject: `A place is available: ${entry.event.title}`, html, text: `Hi ${user.name},\n\n${line}\n\nClaim it here: ${link}` }));
|
||||
}
|
||||
const wa = `🎟️ Hi ${user?.name || 'there'}, ${line} Claim it here: ${link}`;
|
||||
sends.push(hasEmail ? waText(user, wa) : waTextAny(user, wa));
|
||||
await Promise.allSettled(sends);
|
||||
}
|
||||
|
||||
const OFFER_INCLUDE = { event: true, eventOption: true, variant: true, user: { select: { id: true, name: true, email: true, phoneNumber: true, notificationPreference: true } } };
|
||||
|
||||
/** Puts an entry on offer (inside the caller's transaction); returns the raw token. */
|
||||
async function writeOffer(db, entry, quantity, hours) {
|
||||
const token = crypto.randomBytes(24).toString('hex');
|
||||
await db.waitlistEntry.update({
|
||||
where: { id: entry.id },
|
||||
data: {
|
||||
status: 'offered',
|
||||
offeredQuantity: quantity,
|
||||
offerTokenHash: hashToken(token),
|
||||
offeredAt: new Date(),
|
||||
offerExpiresAt: new Date(Date.now() + (hours || 24) * 60 * 60 * 1000),
|
||||
},
|
||||
});
|
||||
return token;
|
||||
}
|
||||
|
||||
async function notifyOffers(offers) {
|
||||
for (const { entryId, token } of offers) {
|
||||
const entry = await prisma.waitlistEntry.findUnique({ where: { id: entryId }, include: OFFER_INCLUDE });
|
||||
if (entry) notifyOffer(entry, token).catch(e => console.warn('[waitlist] notify failed:', e?.message || e));
|
||||
}
|
||||
}
|
||||
|
||||
/** Offers `quantity` places to one entry (e.g. a supervisor's manual offer) and notifies. */
|
||||
async function makeOffer(entryId, quantity) {
|
||||
const entry = await prisma.waitlistEntry.findUnique({ where: { id: entryId }, include: { event: true } });
|
||||
const token = await writeOffer(prisma, entry, quantity, entry.event.waitlistOfferHours);
|
||||
await notifyOffers([{ entryId, token }]);
|
||||
return prisma.waitlistEntry.findUnique({ where: { id: entryId } });
|
||||
}
|
||||
|
||||
/**
|
||||
* Offers any free places on an event to the people waiting for them. Safe to call any
|
||||
* time places might have freed up; runs one event at a time under an advisory lock.
|
||||
* @returns {Promise<number>} offers made
|
||||
*/
|
||||
async function processWaitlist(eventId) {
|
||||
const event = await prisma.event.findUnique({
|
||||
where: { id: eventId },
|
||||
include: { eventOptions: { include: { variants: true } } },
|
||||
});
|
||||
if (!event || !event.waitlistEnabled || !event.isActive || event.cashupStatus === 'closed') return 0;
|
||||
if (new Date(event.endDate) < new Date()) return 0;
|
||||
|
||||
const made = [];
|
||||
await prisma.$transaction(async (tx) => {
|
||||
await tx.$executeRaw`SELECT pg_advisory_xact_lock(hashtext(${'waitlist:' + eventId}))`;
|
||||
const waiting = await tx.waitlistEntry.findMany({ where: { eventId, status: 'waiting' }, orderBy: { position: 'asc' } });
|
||||
const lines = [...new Map(waiting.map(w => [`${w.eventOptionId}::${w.variantId || ''}`, w])).values()];
|
||||
for (const sample of lines) {
|
||||
const option = event.eventOptions.find(o => o.id === sample.eventOptionId);
|
||||
const variant = sample.variantId ? (option?.variants || []).find(v => v.id === sample.variantId) : null;
|
||||
if (!option) continue;
|
||||
const free = await remainingFor(option, variant, { db: tx });
|
||||
if (free === null || free <= 0) continue;
|
||||
const queue = waiting.filter(w => w.eventOptionId === sample.eventOptionId && (w.variantId || null) === (sample.variantId || null));
|
||||
for (const offer of pickOffers(queue, free, event.waitlistPartialOffers)) {
|
||||
const token = await writeOffer(tx, { id: offer.entryId }, offer.quantity, event.waitlistOfferHours);
|
||||
made.push({ entryId: offer.entryId, token });
|
||||
}
|
||||
}
|
||||
}, { timeout: 20000 });
|
||||
await notifyOffers(made);
|
||||
return made.length;
|
||||
}
|
||||
|
||||
/** Marks lapsed offers as expired. @returns event ids that now have places to re-offer. */
|
||||
async function expireOffers(now = new Date()) {
|
||||
const lapsed = await prisma.waitlistEntry.findMany({ where: { status: 'offered', offerExpiresAt: { lte: now } }, select: { id: true, eventId: true } });
|
||||
if (lapsed.length === 0) return [];
|
||||
await prisma.waitlistEntry.updateMany({ where: { id: { in: lapsed.map(l => l.id) } }, data: { status: 'expired', offerTokenHash: null } });
|
||||
return [...new Set(lapsed.map(l => l.eventId))];
|
||||
}
|
||||
|
||||
/** Background job: expire lapsed offers, then re-offer free places everywhere waiting. */
|
||||
async function runWaitlistJob() {
|
||||
const expiredEvents = await expireOffers();
|
||||
const waitingEvents = await prisma.waitlistEntry.findMany({ where: { status: 'waiting' }, distinct: ['eventId'], select: { eventId: true } });
|
||||
const ids = new Set([...expiredEvents, ...waitingEvents.map(w => w.eventId)]);
|
||||
let offers = 0;
|
||||
for (const id of ids) offers += await processWaitlist(id).catch(() => 0);
|
||||
return { expired: expiredEvents.length, offers };
|
||||
}
|
||||
|
||||
/** Fire-and-forget re-offer after something freed places on an event. */
|
||||
function queueWaitlist(eventId) {
|
||||
if (!eventId) return;
|
||||
processWaitlist(eventId).catch(e => console.warn('[waitlist] process failed:', e?.message || e));
|
||||
}
|
||||
|
||||
/** A live offer from its claim token, or null. */
|
||||
async function findOfferByToken(token) {
|
||||
if (!token || typeof token !== 'string' || token.length > 100) return null;
|
||||
return prisma.waitlistEntry.findUnique({
|
||||
where: { offerTokenHash: hashToken(token) },
|
||||
include: { event: true, eventOption: true, variant: true, user: { select: { id: true, name: true } } },
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
ACTIVE,
|
||||
waitlistError,
|
||||
hashToken,
|
||||
pickOffers,
|
||||
makeOffer,
|
||||
processWaitlist,
|
||||
expireOffers,
|
||||
runWaitlistJob,
|
||||
queueWaitlist,
|
||||
findOfferByToken,
|
||||
};
|
||||
@@ -0,0 +1,104 @@
|
||||
const { validateAttendees, planLineTickets, lineKey } = require('../src/utils/attendees');
|
||||
|
||||
const EVENT = {
|
||||
allowNamedAttendees: true,
|
||||
eventOptions: [
|
||||
{ id: 'kid', name: 'Camper', isMainTicket: true },
|
||||
{ id: 'shirt', name: 'T-shirt', isMainTicket: false },
|
||||
],
|
||||
};
|
||||
const places = (n) => ({ [lineKey('kid', null)]: n });
|
||||
|
||||
describe('validateAttendees', () => {
|
||||
test('cleans up names and contact details', () => {
|
||||
const out = validateAttendees(EVENT, places(2), [
|
||||
{ eventOptionId: 'kid', name: ' Sam ', email: ' ', phone: '0821234567' },
|
||||
{ eventOptionId: 'kid', name: 'Alex', email: 'alex@example.com' },
|
||||
]);
|
||||
expect(out).toMatchObject([
|
||||
{ eventOptionId: 'kid', variantId: null, name: 'Sam', email: null, phone: '0821234567' },
|
||||
{ eventOptionId: 'kid', variantId: null, name: 'Alex', email: 'alex@example.com', phone: null },
|
||||
]);
|
||||
});
|
||||
|
||||
test('nothing to check when no attendees are given', () => {
|
||||
expect(validateAttendees({ ...EVENT, allowNamedAttendees: false }, places(1), [])).toEqual([]);
|
||||
});
|
||||
|
||||
test('refuses names on events that do not take them', () => {
|
||||
expect(() => validateAttendees({ ...EVENT, allowNamedAttendees: false }, places(1), [{ eventOptionId: 'kid', name: 'Sam' }])).toThrow(/doesn't take attendee names/);
|
||||
});
|
||||
|
||||
test('only main tickets can be named', () => {
|
||||
expect(() => validateAttendees(EVENT, { [lineKey('shirt', null)]: 1 }, [{ eventOptionId: 'shirt', name: 'Sam' }])).toThrow(/main tickets/);
|
||||
});
|
||||
|
||||
test('needs a name and a valid email', () => {
|
||||
expect(() => validateAttendees(EVENT, places(1), [{ eventOptionId: 'kid', name: ' ' }])).toThrow(/enter a name/);
|
||||
expect(() => validateAttendees(EVENT, places(1), [{ eventOptionId: 'kid', name: 'Sam', email: 'nope' }])).toThrow(/valid email/);
|
||||
});
|
||||
|
||||
test('no more names than places, counting names already given', () => {
|
||||
expect(() => validateAttendees(EVENT, places(1), [{ eventOptionId: 'kid', name: 'A' }, { eventOptionId: 'kid', name: 'B' }])).toThrow(/More names/);
|
||||
expect(() => validateAttendees(EVENT, places(3), [{ eventOptionId: 'kid', name: 'C' }], places(2))).not.toThrow();
|
||||
expect(() => validateAttendees(EVENT, places(2), [{ eventOptionId: 'kid', name: 'C' }], places(2))).toThrow(/More names/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('planLineTickets', () => {
|
||||
const scan = (q = 1) => [{ quantityRedeemed: q }];
|
||||
|
||||
test('no attendees: one shared ticket for the whole quantity', () => {
|
||||
expect(planLineTickets({ quantity: 3 }, [], [])).toEqual({ create: [{ attendeeId: null, holderUserId: null, quantity: 3 }], update: [], remove: [] });
|
||||
});
|
||||
|
||||
test('every attendee gets their own ticket; unnamed places share one', () => {
|
||||
const plan = planLineTickets({ quantity: 3 }, [{ id: 'a1', userId: 'u9' }, { id: 'a2', userId: null }], []);
|
||||
expect(plan.create).toEqual([
|
||||
{ attendeeId: 'a1', holderUserId: 'u9', quantity: 1 },
|
||||
{ attendeeId: 'a2', holderUserId: null, quantity: 1 },
|
||||
{ attendeeId: null, holderUserId: null, quantity: 1 },
|
||||
]);
|
||||
});
|
||||
|
||||
test('naming every place replaces the shared ticket', () => {
|
||||
const plan = planLineTickets({ quantity: 2 }, [{ id: 'a1' }, { id: 'a2' }], [{ id: 't0', attendeeId: null, quantity: 2, usages: [] }]);
|
||||
expect(plan.remove).toEqual(['t0']);
|
||||
expect(plan.create).toHaveLength(2);
|
||||
});
|
||||
|
||||
test('a scanned shared ticket is kept and never shrunk below what was used', () => {
|
||||
const plan = planLineTickets({ quantity: 2 }, [{ id: 'a1' }, { id: 'a2' }], [{ id: 't0', attendeeId: null, quantity: 2, usages: scan(1) }]);
|
||||
expect(plan.remove).toEqual([]);
|
||||
expect(plan.update).toEqual([{ id: 't0', data: { quantity: 1 } }]);
|
||||
});
|
||||
|
||||
test('removing an attendee removes their unscanned ticket', () => {
|
||||
const plan = planLineTickets({ quantity: 1 }, [{ id: 'a1' }], [
|
||||
{ id: 't1', attendeeId: 'a1', quantity: 1, usages: [] },
|
||||
{ id: 't2', attendeeId: 'gone', quantity: 1, usages: [] },
|
||||
]);
|
||||
expect(plan).toEqual({ create: [], update: [], remove: ['t2'] });
|
||||
});
|
||||
|
||||
test("keeps a scanned ticket whose attendee was removed, and counts it as a used place", () => {
|
||||
const plan = planLineTickets({ quantity: 2 }, [{ id: 'a1' }], [
|
||||
{ id: 't1', attendeeId: 'a1', quantity: 1, usages: [] },
|
||||
{ id: 't2', attendeeId: 'gone', quantity: 1, usages: scan() },
|
||||
]);
|
||||
expect(plan).toEqual({ create: [], update: [], remove: [] });
|
||||
});
|
||||
|
||||
test('follows the attendee to a newly linked account', () => {
|
||||
const plan = planLineTickets({ quantity: 1 }, [{ id: 'a1', userId: 'u2' }], [{ id: 't1', attendeeId: 'a1', quantity: 1, holderUserId: null, usages: [] }]);
|
||||
expect(plan.update).toEqual([{ id: 't1', data: { holderUserId: 'u2', quantity: 1 } }]);
|
||||
});
|
||||
|
||||
test('existing single shared ticket is resized, duplicates dropped', () => {
|
||||
const plan = planLineTickets({ quantity: 4 }, [], [
|
||||
{ id: 't1', attendeeId: null, quantity: 2, usages: [] },
|
||||
{ id: 't2', attendeeId: null, quantity: 2, usages: [] },
|
||||
]);
|
||||
expect(plan).toEqual({ create: [], update: [{ id: 't1', data: { quantity: 4 } }], remove: ['t2'] });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,135 @@
|
||||
process.env.JWT_SECRET = 'test-secret';
|
||||
|
||||
const { ageOn, isMinor, consentStatusFor, reminderDue, validateConsentAnswer, hashToken } = require('../src/utils/consent');
|
||||
const { validateAttendees, lineKey } = require('../src/utils/attendees');
|
||||
|
||||
const START = new Date('2026-12-10T08:00:00Z');
|
||||
const DAY = 24 * 60 * 60 * 1000;
|
||||
|
||||
describe('ageOn / isMinor', () => {
|
||||
test('counts whole years, turning a year older on the birthday', () => {
|
||||
expect(ageOn('2008-12-10', START)).toBe(18);
|
||||
expect(ageOn('2008-12-11', START)).toBe(17);
|
||||
expect(ageOn('2010-01-01', START)).toBe(16);
|
||||
});
|
||||
|
||||
test('under 18 on the event start date', () => {
|
||||
expect(isMinor('2008-12-11', START)).toBe(true);
|
||||
expect(isMinor('2008-12-10', START)).toBe(false);
|
||||
expect(isMinor(null, START)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('consentStatusFor', () => {
|
||||
const event = { requiresParentalConsent: true, startDate: START };
|
||||
const kid = { dateOfBirth: '2012-05-01' };
|
||||
|
||||
test('only needed on youth events, for minors', () => {
|
||||
expect(consentStatusFor(kid, { ...event, requiresParentalConsent: false }, null)).toBe('not_required');
|
||||
expect(consentStatusFor({ dateOfBirth: '1990-01-01' }, event, null)).toBe('not_required');
|
||||
});
|
||||
|
||||
test('a minor with no request, or an unnamed ticket, is missing consent', () => {
|
||||
expect(consentStatusFor(kid, event, null)).toBe('missing');
|
||||
expect(consentStatusFor(null, event, null)).toBe('missing');
|
||||
});
|
||||
|
||||
test('otherwise the latest request decides', () => {
|
||||
expect(consentStatusFor(kid, event, { status: 'pending' })).toBe('pending');
|
||||
expect(consentStatusFor(kid, event, { status: 'given' })).toBe('given');
|
||||
expect(consentStatusFor(kid, event, { status: 'declined' })).toBe('declined');
|
||||
});
|
||||
});
|
||||
|
||||
describe('reminderDue', () => {
|
||||
const now = new Date('2026-12-01T10:00:00Z');
|
||||
const req = (over) => ({ status: 'pending', remindersSent: 0, lastSentAt: new Date(now.getTime() - 1 * DAY), expiresAt: START, ...over });
|
||||
|
||||
test('first reminder 2 days after the request', () => {
|
||||
expect(reminderDue(req({}), now)).toBe(false);
|
||||
expect(reminderDue(req({ lastSentAt: new Date(now.getTime() - 2 * DAY) }), now)).toBe(true);
|
||||
});
|
||||
|
||||
test('then every 3 days', () => {
|
||||
expect(reminderDue(req({ remindersSent: 1, lastSentAt: new Date(now.getTime() - 2 * DAY) }), now)).toBe(false);
|
||||
expect(reminderDue(req({ remindersSent: 1, lastSentAt: new Date(now.getTime() - 3 * DAY) }), now)).toBe(true);
|
||||
});
|
||||
|
||||
test('never once answered or expired', () => {
|
||||
expect(reminderDue(req({ status: 'given', lastSentAt: new Date(0) }), now)).toBe(false);
|
||||
expect(reminderDue(req({ lastSentAt: new Date(0), expiresAt: new Date(now.getTime() - 1) }), now)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateConsentAnswer', () => {
|
||||
const event = { consentCollectMedical: true, consentCollectEmergency: true, consentCollectPhoto: true };
|
||||
const ok = { decision: 'give', signedName: 'Pat Parent', isGuardian: true, medical: { allergies: 'Peanuts' }, emergency: { name: 'Gran', phone: '0821234567' }, photoConsent: false };
|
||||
|
||||
test('accepts a complete answer and keeps only known fields', () => {
|
||||
const out = validateConsentAnswer({ ...ok, extra: 'x' }, event);
|
||||
expect(out).toEqual({
|
||||
decision: 'give', signedName: 'Pat Parent',
|
||||
medical: { allergies: 'Peanuts', medication: '', conditions: '', medicalAid: '' },
|
||||
emergency: { name: 'Gran', phone: '0821234567', relationship: '' },
|
||||
photoConsent: false,
|
||||
});
|
||||
});
|
||||
|
||||
test('needs a decision, a typed name and the guardian tick', () => {
|
||||
expect(() => validateConsentAnswer({ ...ok, decision: 'maybe' }, event)).toThrow(/give or decline/);
|
||||
expect(() => validateConsentAnswer({ ...ok, signedName: ' ' }, event)).toThrow(/full name/);
|
||||
expect(() => validateConsentAnswer({ ...ok, isGuardian: false }, event)).toThrow(/parent or legal guardian/);
|
||||
});
|
||||
|
||||
test('needs an emergency contact and a photo answer when asked for', () => {
|
||||
expect(() => validateConsentAnswer({ ...ok, emergency: {} }, event)).toThrow(/emergency contact/);
|
||||
expect(() => validateConsentAnswer({ ...ok, photoConsent: undefined }, event)).toThrow(/photo/);
|
||||
});
|
||||
|
||||
test('declining needs no extra details', () => {
|
||||
expect(validateConsentAnswer({ decision: 'decline', signedName: 'Pat', isGuardian: true }, event))
|
||||
.toEqual({ decision: 'decline', signedName: 'Pat', medical: null, emergency: null, photoConsent: null });
|
||||
});
|
||||
});
|
||||
|
||||
describe('hashToken', () => {
|
||||
test('is stable and never the token itself', () => {
|
||||
expect(hashToken('abc')).toBe(hashToken('abc'));
|
||||
expect(hashToken('abc')).not.toContain('abc');
|
||||
expect(hashToken('abc')).not.toBe(hashToken('abd'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateAttendees on a youth event', () => {
|
||||
const event = {
|
||||
requiresParentalConsent: true, allowNamedAttendees: true, startDate: START,
|
||||
eventOptions: [{ id: 'kid', name: 'Camper', isMainTicket: true }, { id: 'shirt', name: 'T-shirt', isMainTicket: false }],
|
||||
};
|
||||
const places = { [lineKey('kid', null)]: 2, [lineKey('shirt', null)]: 1 };
|
||||
const parent = { parentName: 'Pat', parentEmail: 'pat@example.com', parentPhone: '0821234567' };
|
||||
|
||||
test('every main place must be named', () => {
|
||||
expect(() => validateAttendees(event, places, [{ eventOptionId: 'kid', name: 'A', dateOfBirth: '1990-01-01' }])).toThrow(/every "Camper" ticket/);
|
||||
});
|
||||
|
||||
test('a date of birth is required', () => {
|
||||
expect(() => validateAttendees(event, places, [{ eventOptionId: 'kid', name: 'A' }, { eventOptionId: 'kid', name: 'B' }])).toThrow(/date of birth/);
|
||||
});
|
||||
|
||||
test('minors need full parent details; adults do not', () => {
|
||||
expect(() => validateAttendees(event, places, [
|
||||
{ eventOptionId: 'kid', name: 'Kid', dateOfBirth: '2014-03-03', parentName: 'Pat' },
|
||||
{ eventOptionId: 'kid', name: 'Leader', dateOfBirth: '1990-01-01' },
|
||||
])).toThrow(/under 18/);
|
||||
const out = validateAttendees(event, places, [
|
||||
{ eventOptionId: 'kid', name: 'Kid', dateOfBirth: '2014-03-03', ...parent },
|
||||
{ eventOptionId: 'kid', name: 'Leader', dateOfBirth: '1990-01-01' },
|
||||
]);
|
||||
expect(out[0]).toMatchObject({ name: 'Kid', parentEmail: 'pat@example.com' });
|
||||
expect(out[0].dateOfBirth).toBeInstanceOf(Date);
|
||||
});
|
||||
|
||||
test('a date of birth in the future is refused', () => {
|
||||
expect(() => validateAttendees(event, { [lineKey('kid', null)]: 1 }, [{ eventOptionId: 'kid', name: 'A', dateOfBirth: '2999-01-01' }])).toThrow(/valid date of birth/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,176 @@
|
||||
const {
|
||||
normalizeCode,
|
||||
buildCouponLines,
|
||||
computeCouponDiscount,
|
||||
validateCoupon,
|
||||
describeCoupon,
|
||||
} = require('../src/utils/coupons');
|
||||
const { computeRegistrationTotalDue, staffPriceFor } = require('../src/utils/pricing');
|
||||
|
||||
const NOW = new Date('2026-06-15T10:00:00Z');
|
||||
const PAST = new Date('2026-06-01T00:00:00Z');
|
||||
const FUTURE = new Date('2026-07-01T00:00:00Z');
|
||||
|
||||
const coupon = (over = {}) => ({
|
||||
id: 'c1', code: 'STAFF50', type: 'percent', value: 50, stackEarlyBird: false, active: true,
|
||||
validFrom: null, validUntil: null, maxUses: null, maxUsesPerUser: 1, minQuantity: null,
|
||||
restrictRoles: [], restrictDomain: null, events: [], options: [], allowedUsers: [],
|
||||
...over,
|
||||
});
|
||||
|
||||
// 2 adult tickets at R200 (standard) and 1 T-shirt at R100.
|
||||
const LINES = [
|
||||
{ eventOptionId: 'adult', variantId: null, quantity: 2, unitPrice: 200, basePrice: 200 },
|
||||
{ eventOptionId: 'shirt', variantId: 'L', quantity: 1, unitPrice: 100, basePrice: 100 },
|
||||
];
|
||||
|
||||
describe('normalizeCode', () => {
|
||||
test('trims and uppercases', () => {
|
||||
expect(normalizeCode(' staff50 ')).toBe('STAFF50');
|
||||
expect(normalizeCode(null)).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('computeCouponDiscount', () => {
|
||||
test('percentage over the whole event', () => {
|
||||
expect(computeCouponDiscount(coupon(), LINES)).toBe(250);
|
||||
});
|
||||
|
||||
test('fixed amount, capped at what is in scope', () => {
|
||||
expect(computeCouponDiscount(coupon({ type: 'fixed', value: 120 }), LINES)).toBe(120);
|
||||
expect(computeCouponDiscount(coupon({ type: 'fixed', value: 9999 }), LINES)).toBe(500);
|
||||
});
|
||||
|
||||
test('comp covers only in-scope options', () => {
|
||||
const c = coupon({ type: 'comp', options: [{ eventOptionId: 'adult', variantId: null }] });
|
||||
expect(computeCouponDiscount(c, LINES)).toBe(400);
|
||||
});
|
||||
|
||||
test('scope can target a single variant', () => {
|
||||
const c = coupon({ type: 'comp', options: [{ eventOptionId: 'shirt', variantId: 'M' }] });
|
||||
expect(computeCouponDiscount(c, LINES)).toBe(0);
|
||||
const c2 = coupon({ type: 'comp', options: [{ eventOptionId: 'shirt', variantId: 'L' }] });
|
||||
expect(computeCouponDiscount(c2, LINES)).toBe(100);
|
||||
});
|
||||
|
||||
test('best price wins against early-bird when not stacking', () => {
|
||||
// Early-bird R150 vs standard R200: 10% off standard = R180, early-bird already beats it.
|
||||
const eb = [{ eventOptionId: 'adult', variantId: null, quantity: 1, unitPrice: 150, basePrice: 200 }];
|
||||
expect(computeCouponDiscount(coupon({ value: 10 }), eb)).toBe(0);
|
||||
// 50% off standard = R100, beats R150 early-bird by R50.
|
||||
expect(computeCouponDiscount(coupon({ value: 50 }), eb)).toBe(50);
|
||||
// Fixed R80 off standard = R120, beats R150 by R30.
|
||||
expect(computeCouponDiscount(coupon({ type: 'fixed', value: 80 }), eb)).toBe(30);
|
||||
});
|
||||
|
||||
test('stacking applies on top of the early-bird price', () => {
|
||||
const eb = [{ eventOptionId: 'adult', variantId: null, quantity: 1, unitPrice: 150, basePrice: 200 }];
|
||||
expect(computeCouponDiscount(coupon({ value: 10, stackEarlyBird: true }), eb)).toBe(15);
|
||||
expect(computeCouponDiscount(coupon({ type: 'fixed', value: 80, stackEarlyBird: true }), eb)).toBe(80);
|
||||
});
|
||||
|
||||
test('never goes below zero or above the price', () => {
|
||||
expect(computeCouponDiscount(coupon({ value: 150 }), LINES)).toBe(500);
|
||||
expect(computeCouponDiscount(coupon({ type: 'fixed', value: -10 }), LINES)).toBe(0);
|
||||
expect(computeCouponDiscount(coupon(), [])).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateCoupon', () => {
|
||||
const ctx = (over = {}) => ({ eventId: 'e1', user: { id: 'u1', role: 'user', email: 'a@church.org' }, now: NOW, usesTotal: 0, usesByUser: 0, lines: LINES, ...over });
|
||||
|
||||
test('accepts an unrestricted active code', () => {
|
||||
expect(validateCoupon(coupon(), ctx())).toEqual({ ok: true });
|
||||
});
|
||||
|
||||
test('rejects inactive, not-yet-valid and expired codes', () => {
|
||||
expect(validateCoupon(coupon({ active: false }), ctx()).ok).toBe(false);
|
||||
expect(validateCoupon(coupon({ validFrom: FUTURE }), ctx()).message).toMatch(/isn't valid yet/);
|
||||
expect(validateCoupon(coupon({ validUntil: PAST }), ctx()).message).toMatch(/expired/);
|
||||
});
|
||||
|
||||
test('limits to the coupon events when any are set', () => {
|
||||
expect(validateCoupon(coupon({ events: [{ eventId: 'e2' }] }), ctx()).ok).toBe(false);
|
||||
expect(validateCoupon(coupon({ events: [{ eventId: 'e1' }, { eventId: 'e2' }] }), ctx()).ok).toBe(true);
|
||||
});
|
||||
|
||||
test('restricted codes need a known account matching any restriction', () => {
|
||||
const staffOnly = coupon({ restrictRoles: ['staff', 'supervisor', 'admin'] });
|
||||
expect(validateCoupon(staffOnly, ctx({ user: null })).message).toMatch(/Log in/);
|
||||
expect(validateCoupon(staffOnly, ctx()).ok).toBe(false);
|
||||
expect(validateCoupon(staffOnly, ctx({ user: { id: 'u1', role: 'staff', email: 'x@y.z' } })).ok).toBe(true);
|
||||
expect(validateCoupon(coupon({ restrictDomain: '@church.org' }), ctx()).ok).toBe(true);
|
||||
expect(validateCoupon(coupon({ restrictDomain: 'other.org' }), ctx()).ok).toBe(false);
|
||||
expect(validateCoupon(coupon({ allowedUsers: [{ userId: 'u1' }] }), ctx()).ok).toBe(true);
|
||||
});
|
||||
|
||||
test('enforces total and per-user use limits', () => {
|
||||
expect(validateCoupon(coupon({ maxUses: 5 }), ctx({ usesTotal: 5 })).message).toMatch(/fully used/);
|
||||
expect(validateCoupon(coupon({ maxUsesPerUser: 1 }), ctx({ usesByUser: 1 })).message).toMatch(/already used/);
|
||||
expect(validateCoupon(coupon({ maxUsesPerUser: null }), ctx({ usesByUser: 9 })).ok).toBe(true);
|
||||
});
|
||||
|
||||
test('needs qualifying items and the minimum quantity', () => {
|
||||
expect(validateCoupon(coupon({ options: [{ eventOptionId: 'other' }] }), ctx()).message).toMatch(/doesn't apply/);
|
||||
expect(validateCoupon(coupon({ minQuantity: 4 }), ctx()).message).toMatch(/at least 4/);
|
||||
expect(validateCoupon(coupon({ minQuantity: 3 }), ctx()).ok).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildCouponLines', () => {
|
||||
test('one line per tranche; early-bird tranches keep the standard price as base', () => {
|
||||
const reg = {
|
||||
registrationOptions: [{
|
||||
eventOptionId: 'adult', variantId: null, quantity: 3,
|
||||
eventOption: { price: 200, variants: [] },
|
||||
tranches: [
|
||||
{ quantity: 2, priceSnapshot: 150, appliedTierId: 'tier1' },
|
||||
{ quantity: 1, priceSnapshot: 200, appliedTierId: null },
|
||||
],
|
||||
}],
|
||||
};
|
||||
expect(buildCouponLines(reg)).toEqual([
|
||||
{ eventOptionId: 'adult', variantId: null, quantity: 2, unitPrice: 150, basePrice: 200 },
|
||||
{ eventOptionId: 'adult', variantId: null, quantity: 1, unitPrice: 200, basePrice: 200 },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('computeRegistrationTotalDue with a discount', () => {
|
||||
const reg = (discountTotal) => ({
|
||||
discountTotal,
|
||||
registrationOptions: [{ quantity: 2, tranches: [{ quantity: 2, priceSnapshot: 100 }] }],
|
||||
});
|
||||
|
||||
test('subtracts the coupon discount', () => {
|
||||
expect(computeRegistrationTotalDue(reg(50), NOW)).toBe(150);
|
||||
});
|
||||
|
||||
test('never goes below zero', () => {
|
||||
expect(computeRegistrationTotalDue(reg(500), NOW)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('staffPriceFor', () => {
|
||||
const option = { price: 200, staffPrice: 50, variants: [{ id: 'v1', staffPrice: 20 }, { id: 'v2', staffPrice: null }] };
|
||||
|
||||
test('applies to Staff and above only', () => {
|
||||
expect(staffPriceFor(option, null, { role: 'staff' })).toBe(50);
|
||||
expect(staffPriceFor(option, null, { role: 'admin' })).toBe(50);
|
||||
expect(staffPriceFor(option, null, { role: 'user' })).toBeNull();
|
||||
expect(staffPriceFor(option, null, null)).toBeNull();
|
||||
});
|
||||
|
||||
test('a variant staff price overrides the option one', () => {
|
||||
expect(staffPriceFor(option, 'v1', { role: 'staff' })).toBe(20);
|
||||
expect(staffPriceFor(option, 'v2', { role: 'staff' })).toBe(50);
|
||||
});
|
||||
});
|
||||
|
||||
describe('describeCoupon', () => {
|
||||
test('summarises each type', () => {
|
||||
expect(describeCoupon(coupon())).toBe('50% off');
|
||||
expect(describeCoupon(coupon({ type: 'fixed', value: 25 }))).toBe('R25.00 off');
|
||||
expect(describeCoupon(coupon({ type: 'comp' }))).toBe('Free');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,46 @@
|
||||
const { computeDonationRemaining } = require('../src/utils/donationUtils');
|
||||
|
||||
describe('computeDonationRemaining', () => {
|
||||
test('a donation with no legs has its full amount remaining', () => {
|
||||
expect(computeDonationRemaining(500, [])).toBe(500);
|
||||
});
|
||||
|
||||
test('an allocation leg reduces the remaining balance', () => {
|
||||
const legs = [{ amount: 200 }];
|
||||
expect(computeDonationRemaining(500, legs)).toBe(300);
|
||||
});
|
||||
|
||||
test('multiple allocation legs reduce the remaining balance cumulatively', () => {
|
||||
const legs = [{ amount: 200 }, { amount: 150 }];
|
||||
expect(computeDonationRemaining(500, legs)).toBe(150);
|
||||
});
|
||||
|
||||
test('the 1.4.2 regression: refunding the donation itself (a negative-amount leg) reduces remaining balance, not inflates it', () => {
|
||||
// 500 donation, never allocated, R200 of it refunded directly back to the donor
|
||||
// (a leg with amount: -200). That R200 is no longer available to allocate — remaining
|
||||
// must drop to 300. The pre-1.4.2 bug summed legs without Math.abs(), so
|
||||
// remaining = 500 - (-200) = 700 (inflated) instead of 500 - 200 = 300 (correct).
|
||||
const legs = [{ amount: -200 }];
|
||||
expect(computeDonationRemaining(500, legs)).toBe(300);
|
||||
});
|
||||
|
||||
test('an allocation and a separate direct refund both reduce the remaining balance', () => {
|
||||
// 500 donation: R200 allocated to a registration, R100 separately refunded to the donor.
|
||||
// Remaining = 500 - 200 - 100 = 200.
|
||||
const legs = [{ amount: 200 }, { amount: -100 }];
|
||||
expect(computeDonationRemaining(500, legs)).toBe(200);
|
||||
});
|
||||
|
||||
test('unassigning an allocation removes its leg entirely rather than adding an offsetting one', () => {
|
||||
// unassignDonationFromRegistration deletes the leg row outright (confirmed in
|
||||
// paymentController.js), so the "leg no longer exists" case — not a negative-amount
|
||||
// leg — is how an unassigned allocation becomes available again.
|
||||
const legsAfterUnassign = [];
|
||||
expect(computeDonationRemaining(500, legsAfterUnassign)).toBe(500);
|
||||
});
|
||||
|
||||
test('handles a null/undefined legs array', () => {
|
||||
expect(computeDonationRemaining(500, null)).toBe(500);
|
||||
expect(computeDonationRemaining(500, undefined)).toBe(500);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,146 @@
|
||||
const {
|
||||
getEffectiveUnitPrice,
|
||||
computeOptionLineTotal,
|
||||
computeRegistrationTotalDue,
|
||||
attachComputedTotals,
|
||||
} = require('../src/utils/pricing');
|
||||
|
||||
const DAY = 24 * 60 * 60 * 1000;
|
||||
const NOW = new Date('2026-06-01T00:00:00Z');
|
||||
const PAST = new Date(NOW.getTime() - DAY);
|
||||
const FUTURE = new Date(NOW.getTime() + DAY);
|
||||
|
||||
describe('getEffectiveUnitPrice', () => {
|
||||
test('returns base price when there are no early-bird tiers', () => {
|
||||
const option = { price: 100, earlyBirdTiers: [] };
|
||||
expect(getEffectiveUnitPrice(option, null, NOW)).toBe(100);
|
||||
});
|
||||
|
||||
test('returns tier price when the tier deadline is still in the future', () => {
|
||||
const option = {
|
||||
price: 100,
|
||||
earlyBirdTiers: [{ id: 't1', price: 50, deadline: FUTURE }],
|
||||
};
|
||||
expect(getEffectiveUnitPrice(option, null, NOW)).toBe(50);
|
||||
});
|
||||
|
||||
test('falls back to base price once the tier deadline has passed', () => {
|
||||
const option = {
|
||||
price: 100,
|
||||
earlyBirdTiers: [{ id: 't1', price: 50, deadline: PAST }],
|
||||
};
|
||||
expect(getEffectiveUnitPrice(option, null, NOW)).toBe(100);
|
||||
});
|
||||
|
||||
test('requires the deadline to be after both referenceTime and atTime', () => {
|
||||
const midDeadline = new Date(NOW.getTime() - DAY / 2);
|
||||
const option = {
|
||||
price: 100,
|
||||
earlyBirdTiers: [{ id: 't1', price: 50, deadline: midDeadline }],
|
||||
};
|
||||
// referenceTime (PAST) is before the deadline, but atTime (NOW) is after it — tier no longer applies
|
||||
expect(getEffectiveUnitPrice(option, PAST, NOW)).toBe(100);
|
||||
});
|
||||
|
||||
test('with multiple applicable tiers, picks the one with the earliest deadline', () => {
|
||||
const soonerDeadline = new Date(NOW.getTime() + DAY);
|
||||
const laterDeadline = new Date(NOW.getTime() + 2 * DAY);
|
||||
const option = {
|
||||
price: 100,
|
||||
earlyBirdTiers: [
|
||||
{ id: 'later', price: 80, deadline: laterDeadline },
|
||||
{ id: 'sooner', price: 60, deadline: soonerDeadline },
|
||||
],
|
||||
};
|
||||
expect(getEffectiveUnitPrice(option, null, NOW)).toBe(60);
|
||||
});
|
||||
|
||||
test('returns 0 for a missing eventOption', () => {
|
||||
expect(getEffectiveUnitPrice(null, null, NOW)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('computeOptionLineTotal', () => {
|
||||
test('the 1.8.0 regression: buying more after an early-bird tier expires only re-prices the new quantity', () => {
|
||||
// 5 tickets bought at R50 (early-bird), then 1 more bought after the price rose to R100.
|
||||
// Must total 5*50 + 1*100 = 350, not 6*100 = 600.
|
||||
const ro = {
|
||||
quantity: 6,
|
||||
priceSnapshot: 100,
|
||||
tranches: [
|
||||
{ quantity: 5, priceSnapshot: 50, createdAt: PAST },
|
||||
{ quantity: 1, priceSnapshot: 100, createdAt: NOW },
|
||||
],
|
||||
};
|
||||
expect(computeOptionLineTotal(ro, null, NOW)).toBe(350);
|
||||
});
|
||||
|
||||
test('sums a single tranche correctly', () => {
|
||||
const ro = { quantity: 3, priceSnapshot: 40, tranches: [{ quantity: 3, priceSnapshot: 40 }] };
|
||||
expect(computeOptionLineTotal(ro, null, NOW)).toBe(120);
|
||||
});
|
||||
|
||||
test('legacy fallback: no tranches, uses priceSnapshot directly', () => {
|
||||
const ro = { quantity: 4, priceSnapshot: 25, tranches: [] };
|
||||
expect(computeOptionLineTotal(ro, null, NOW)).toBe(100);
|
||||
});
|
||||
|
||||
test('legacy fallback: no tranches and no priceSnapshot, re-evaluates from tier deadlines', () => {
|
||||
const ro = {
|
||||
quantity: 2,
|
||||
priceSnapshot: null,
|
||||
tranches: [],
|
||||
eventOption: { price: 100, earlyBirdTiers: [{ id: 't1', price: 70, deadline: FUTURE }] },
|
||||
};
|
||||
expect(computeOptionLineTotal(ro, null, NOW)).toBe(140);
|
||||
});
|
||||
});
|
||||
|
||||
describe('computeRegistrationTotalDue', () => {
|
||||
test('sums tranche-aware totals across multiple RegistrationOptions', () => {
|
||||
const registration = {
|
||||
registrationOptions: [
|
||||
{
|
||||
quantity: 6,
|
||||
priceSnapshot: 100,
|
||||
tranches: [
|
||||
{ quantity: 5, priceSnapshot: 50, createdAt: PAST },
|
||||
{ quantity: 1, priceSnapshot: 100, createdAt: NOW },
|
||||
],
|
||||
},
|
||||
{
|
||||
quantity: 2,
|
||||
priceSnapshot: 20,
|
||||
tranches: [{ quantity: 2, priceSnapshot: 20, createdAt: PAST }],
|
||||
},
|
||||
],
|
||||
payments: [],
|
||||
};
|
||||
// 350 (first option, see 1.8.0 regression case) + 40 (second option)
|
||||
expect(computeRegistrationTotalDue(registration, NOW)).toBe(390);
|
||||
});
|
||||
|
||||
test('returns 0 for a registration with no options', () => {
|
||||
expect(computeRegistrationTotalDue({ registrationOptions: [] }, NOW)).toBe(0);
|
||||
expect(computeRegistrationTotalDue(null, NOW)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('attachComputedTotals', () => {
|
||||
test('mutates the registration with per-line and total computed amounts', () => {
|
||||
const registration = {
|
||||
registrationOptions: [
|
||||
{ quantity: 2, priceSnapshot: 50, tranches: [{ quantity: 2, priceSnapshot: 50 }] },
|
||||
],
|
||||
payments: [],
|
||||
};
|
||||
const result = attachComputedTotals(registration);
|
||||
expect(result).toBe(registration); // mutated in place
|
||||
expect(result.registrationOptions[0].lineTotal).toBe(100);
|
||||
expect(result.totalDueComputed).toBe(100);
|
||||
});
|
||||
|
||||
test('handles a null registration gracefully', () => {
|
||||
expect(attachComputedTotals(null)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,125 @@
|
||||
process.env.JWT_SECRET = 'test-secret';
|
||||
process.env.NODE_ENV = 'test';
|
||||
|
||||
// Two-factor is required for admins and supervisors — but only when some channel
|
||||
// can actually deliver a code, so a site with no SMTP/WhatsApp never locks them out.
|
||||
|
||||
const mockChannels = { smtp: true, whatsapp: true };
|
||||
const mockUsers = new Map();
|
||||
|
||||
jest.mock('../src/config/db', () => ({
|
||||
user: { findUnique: async ({ where }) => mockUsers.get(where.id) || null },
|
||||
}));
|
||||
jest.mock('../src/utils/settingsCache', () => ({ getSettingSync: (_key, fallback) => fallback }));
|
||||
jest.mock('../src/utils/email', () => ({ isSmtpConfigured: () => mockChannels.smtp }));
|
||||
jest.mock('../src/utils/whatsapp', () => ({
|
||||
isValidZAPhone: (p) => /^27\d{9}$/.test(String(p || '')),
|
||||
isConfigured: async () => mockChannels.whatsapp,
|
||||
}));
|
||||
|
||||
const jwt = require('jsonwebtoken');
|
||||
const { requiresTwoFactor, enforcementMethod, mustEnrolTwoFactor } = require('../src/utils/twoFactor');
|
||||
const { protect } = require('../src/middleware/authMiddleware');
|
||||
|
||||
const ADMIN = { id: 'a1', name: 'Ann', email: 'ann@example.com', phoneNumber: '27821234567', role: 'admin', isActive: true, tokenVersion: 0, twoFactorEnabled: false };
|
||||
|
||||
beforeEach(() => {
|
||||
mockChannels.smtp = true;
|
||||
mockChannels.whatsapp = true;
|
||||
mockUsers.clear();
|
||||
delete process.env.TWO_FACTOR_REQUIRED_ROLES;
|
||||
});
|
||||
|
||||
describe('requiresTwoFactor', () => {
|
||||
test('applies to admins and supervisors only', () => {
|
||||
expect(requiresTwoFactor({ role: 'admin' })).toBe(true);
|
||||
expect(requiresTwoFactor({ role: 'supervisor' })).toBe(true);
|
||||
expect(requiresTwoFactor({ role: 'staff' })).toBe(false);
|
||||
expect(requiresTwoFactor({ role: 'user' })).toBe(false);
|
||||
});
|
||||
|
||||
test('TWO_FACTOR_REQUIRED_ROLES=false turns the requirement off', () => {
|
||||
process.env.TWO_FACTOR_REQUIRED_ROLES = 'false';
|
||||
expect(requiresTwoFactor({ role: 'admin' })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('enforcementMethod', () => {
|
||||
test('prefers email when SMTP is configured', async () => {
|
||||
expect(await enforcementMethod(ADMIN)).toBe('email');
|
||||
});
|
||||
|
||||
test('falls back to WhatsApp without SMTP', async () => {
|
||||
mockChannels.smtp = false;
|
||||
expect(await enforcementMethod(ADMIN)).toBe('whatsapp');
|
||||
});
|
||||
|
||||
test('skips a guest placeholder email', async () => {
|
||||
expect(await enforcementMethod({ ...ADMIN, email: 'x@guest.local' })).toBe('whatsapp');
|
||||
});
|
||||
|
||||
test('is null when no channel can deliver a code', async () => {
|
||||
mockChannels.smtp = false;
|
||||
mockChannels.whatsapp = false;
|
||||
expect(await enforcementMethod(ADMIN)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('mustEnrolTwoFactor', () => {
|
||||
test('true for an admin without 2FA', async () => {
|
||||
expect(await mustEnrolTwoFactor(ADMIN)).toBe(true);
|
||||
});
|
||||
|
||||
test('false once enrolled, for other roles, or with no channel', async () => {
|
||||
expect(await mustEnrolTwoFactor({ ...ADMIN, twoFactorEnabled: true })).toBe(false);
|
||||
expect(await mustEnrolTwoFactor({ ...ADMIN, role: 'staff' })).toBe(false);
|
||||
mockChannels.smtp = false;
|
||||
mockChannels.whatsapp = false;
|
||||
expect(await mustEnrolTwoFactor(ADMIN)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('protect', () => {
|
||||
function run(user, claims = {}) {
|
||||
mockUsers.set(user.id, user);
|
||||
const token = jwt.sign({ id: user.id, role: user.role, tokenVersion: 0, ...claims }, process.env.JWT_SECRET);
|
||||
const req = { headers: { authorization: `Bearer ${token}` } };
|
||||
const res = { statusCode: 200, body: null };
|
||||
res.status = (c) => { res.statusCode = c; return res; };
|
||||
res.json = (b) => { res.body = b; return res; };
|
||||
return new Promise((resolve) => {
|
||||
protect(req, res, (err) => resolve({ req, res, err, passed: !err })).then(() => {
|
||||
if (res.body) resolve({ req, res, passed: false });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
test('rejects an un-enrolled admin session with TWO_FACTOR_REQUIRED', async () => {
|
||||
const { res, passed } = await run(ADMIN);
|
||||
expect(passed).toBe(false);
|
||||
expect(res.statusCode).toBe(401);
|
||||
expect(res.body.code).toBe('TWO_FACTOR_REQUIRED');
|
||||
});
|
||||
|
||||
test('lets an enrolled admin through', async () => {
|
||||
const { passed } = await run({ ...ADMIN, twoFactorEnabled: true });
|
||||
expect(passed).toBe(true);
|
||||
});
|
||||
|
||||
test('lets a regular user through without 2FA', async () => {
|
||||
const { passed } = await run({ ...ADMIN, id: 'u1', role: 'user' });
|
||||
expect(passed).toBe(true);
|
||||
});
|
||||
|
||||
test('gives the setup wizard token a grace period', async () => {
|
||||
const { passed } = await run(ADMIN, { setup: true });
|
||||
expect(passed).toBe(true);
|
||||
});
|
||||
|
||||
test('does not enforce when no channel can deliver a code', async () => {
|
||||
mockChannels.smtp = false;
|
||||
mockChannels.whatsapp = false;
|
||||
const { passed } = await run(ADMIN);
|
||||
expect(passed).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,74 @@
|
||||
process.env.JWT_SECRET = 'test-secret';
|
||||
|
||||
// The pure helpers under test never touch the database.
|
||||
jest.mock('../src/config/db', () => ({}));
|
||||
|
||||
const {
|
||||
generateCode,
|
||||
hashCode,
|
||||
hashDeviceToken,
|
||||
maskEmail,
|
||||
maskPhone,
|
||||
hasRealEmail,
|
||||
} = require('../src/utils/twoFactor');
|
||||
|
||||
describe('generateCode', () => {
|
||||
test('always returns exactly six digits', () => {
|
||||
for (let i = 0; i < 500; i++) {
|
||||
expect(generateCode()).toMatch(/^\d{6}$/);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('hashCode / hashDeviceToken', () => {
|
||||
test('is deterministic and never returns the input', () => {
|
||||
expect(hashCode('123456')).toBe(hashCode('123456'));
|
||||
expect(hashCode('123456')).not.toContain('123456');
|
||||
expect(hashCode('123456')).toMatch(/^[0-9a-f]{64}$/);
|
||||
});
|
||||
|
||||
test('different codes hash differently', () => {
|
||||
expect(hashCode('123456')).not.toBe(hashCode('123457'));
|
||||
});
|
||||
|
||||
test('code and device-token hashes are namespaced apart', () => {
|
||||
expect(hashCode('123456')).not.toBe(hashDeviceToken('123456'));
|
||||
});
|
||||
|
||||
test('depends on JWT_SECRET', () => {
|
||||
const before = hashCode('123456');
|
||||
process.env.JWT_SECRET = 'another-secret';
|
||||
expect(hashCode('123456')).not.toBe(before);
|
||||
process.env.JWT_SECRET = 'test-secret';
|
||||
});
|
||||
});
|
||||
|
||||
describe('masking', () => {
|
||||
test('maskEmail keeps the first letter and the domain', () => {
|
||||
expect(maskEmail('jane@example.com')).toBe('j•••@example.com');
|
||||
});
|
||||
|
||||
test('maskEmail copes with junk input', () => {
|
||||
expect(maskEmail('')).toBe('•••');
|
||||
expect(maskEmail(null)).toBe('•••');
|
||||
});
|
||||
|
||||
test('maskPhone keeps only the last four digits', () => {
|
||||
expect(maskPhone('27821234567')).toBe('••• ••• 4567');
|
||||
expect(maskPhone('+27 82 123 4567')).toBe('••• ••• 4567');
|
||||
});
|
||||
});
|
||||
|
||||
describe('hasRealEmail', () => {
|
||||
test('accepts a normal address', () => {
|
||||
expect(hasRealEmail({ email: 'jane@example.com' })).toBe(true);
|
||||
});
|
||||
|
||||
test('rejects guest and deleted placeholders and missing values', () => {
|
||||
expect(hasRealEmail({ email: 'abc@guest.local' })).toBe(false);
|
||||
expect(hasRealEmail({ email: 'deleted-1@deleted.invalid' })).toBe(false);
|
||||
expect(hasRealEmail({ email: 'deleted-1@deleted.local' })).toBe(false);
|
||||
expect(hasRealEmail({})).toBe(false);
|
||||
expect(hasRealEmail(null)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,282 @@
|
||||
process.env.JWT_SECRET = 'test-secret';
|
||||
process.env.NODE_ENV = 'test';
|
||||
|
||||
// ─── In-memory stand-in for the two Prisma models the 2FA helpers use ─────────
|
||||
// Supports just the `where` shapes those helpers issue: equality, null, { gt }, { lt }.
|
||||
function matches(row, where = {}) {
|
||||
return Object.entries(where).every(([key, cond]) => {
|
||||
const value = row[key];
|
||||
if (cond && typeof cond === 'object' && !(cond instanceof Date)) {
|
||||
if ('gt' in cond) return value > cond.gt;
|
||||
if ('lt' in cond) return value < cond.lt;
|
||||
}
|
||||
if (cond instanceof Date) return value instanceof Date && value.getTime() === cond.getTime();
|
||||
return value === cond;
|
||||
});
|
||||
}
|
||||
|
||||
function applyData(row, data) {
|
||||
for (const [key, val] of Object.entries(data)) {
|
||||
row[key] = val && typeof val === 'object' && 'increment' in val ? row[key] + val.increment : val;
|
||||
}
|
||||
}
|
||||
|
||||
function makeModel(defaults, relations = {}) {
|
||||
let rows = [];
|
||||
let seq = 0;
|
||||
const withRelations = (row, include) => {
|
||||
if (!row) return null;
|
||||
const out = { ...row };
|
||||
for (const key of Object.keys(include || {})) out[key] = relations[key](row);
|
||||
return out;
|
||||
};
|
||||
return {
|
||||
_rows: () => rows,
|
||||
_reset: () => { rows = []; },
|
||||
create: async ({ data }) => {
|
||||
const row = { id: `id-${++seq}`, ...defaults(), ...data };
|
||||
rows.push(row);
|
||||
return { ...row };
|
||||
},
|
||||
findUnique: async ({ where, include }) => withRelations(rows.find(r => matches(r, where)), include),
|
||||
update: async ({ where, data }) => {
|
||||
const row = rows.find(r => matches(r, where));
|
||||
applyData(row, data);
|
||||
return { ...row };
|
||||
},
|
||||
updateMany: async ({ where, data }) => {
|
||||
const hit = rows.filter(r => matches(r, where));
|
||||
hit.forEach(r => applyData(r, data));
|
||||
return { count: hit.length };
|
||||
},
|
||||
deleteMany: async ({ where }) => {
|
||||
const before = rows.length;
|
||||
rows = rows.filter(r => !matches(r, where));
|
||||
return { count: before - rows.length };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const USER = { id: 'user-1', name: 'Jane', email: 'jane@example.com', phoneNumber: '27821234567' };
|
||||
|
||||
const mockDb = {
|
||||
twoFactorChallenge: makeModel(
|
||||
() => ({ attempts: 0, usedAt: null, lastSentAt: new Date(), createdAt: new Date() }),
|
||||
{ user: () => USER },
|
||||
),
|
||||
trustedDevice: makeModel(() => ({ createdAt: new Date(), lastUsedAt: new Date() })),
|
||||
};
|
||||
|
||||
const mockSent = { emails: [], whatsapps: [] };
|
||||
const mockWhatsApp = { configured: true, fail: false };
|
||||
|
||||
jest.mock('../src/config/db', () => mockDb);
|
||||
jest.mock('../src/utils/settingsCache', () => ({ getSettingSync: (_key, fallback) => fallback }));
|
||||
jest.mock('../src/utils/email', () => ({
|
||||
sendMail: async (mail) => { mockSent.emails.push(mail); },
|
||||
isSmtpConfigured: () => true,
|
||||
buildTwoFactorCodeEmail: ({ code }) => ({ text: `code:${code}`, html: '' }),
|
||||
}));
|
||||
jest.mock('../src/utils/waMessages', () => ({ buildWATwoFactorCode: ({ code }) => `code:${code}` }));
|
||||
jest.mock('../src/utils/whatsapp', () => ({
|
||||
isValidZAPhone: (p) => /^27\d{9}$/.test(String(p || '')),
|
||||
isConfigured: async () => mockWhatsApp.configured,
|
||||
sendText: async (to, message) => {
|
||||
if (mockWhatsApp.fail) throw new Error('session disconnected');
|
||||
mockSent.whatsapps.push({ to, message });
|
||||
},
|
||||
}));
|
||||
|
||||
const {
|
||||
MAX_ATTEMPTS,
|
||||
availableMethods,
|
||||
createChallenge,
|
||||
verifyChallenge,
|
||||
resendChallenge,
|
||||
issueTrustedDevice,
|
||||
isTrustedDevice,
|
||||
clearTrustedDevices,
|
||||
} = require('../src/utils/twoFactor');
|
||||
|
||||
const lastEmailCode = () => mockSent.emails[mockSent.emails.length - 1].text.split(':')[1];
|
||||
const lastWhatsAppCode = () => mockSent.whatsapps[mockSent.whatsapps.length - 1].message.split(':')[1];
|
||||
const wrongCode = (code) => (code === '000000' ? '111111' : '000000');
|
||||
|
||||
beforeEach(() => {
|
||||
mockDb.twoFactorChallenge._reset();
|
||||
mockDb.trustedDevice._reset();
|
||||
mockSent.emails = [];
|
||||
mockSent.whatsapps = [];
|
||||
mockWhatsApp.configured = true;
|
||||
mockWhatsApp.fail = false;
|
||||
jest.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
jest.spyOn(console, 'info').mockImplementation(() => {});
|
||||
});
|
||||
|
||||
afterEach(() => jest.restoreAllMocks());
|
||||
|
||||
describe('availableMethods', () => {
|
||||
test('offers WhatsApp only with a valid phone and a configured gateway', async () => {
|
||||
expect(await availableMethods(USER)).toEqual(['email', 'whatsapp']);
|
||||
mockWhatsApp.configured = false;
|
||||
expect(await availableMethods(USER)).toEqual(['email']);
|
||||
mockWhatsApp.configured = true;
|
||||
expect(await availableMethods({ ...USER, phoneNumber: null })).toEqual(['email']);
|
||||
});
|
||||
|
||||
test('offers nothing by email for a guest placeholder address', async () => {
|
||||
expect(await availableMethods({ ...USER, email: 'x@guest.local' })).toEqual(['whatsapp']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createChallenge', () => {
|
||||
test('emails the code and stores only its hash', async () => {
|
||||
const res = await createChallenge(USER, 'login', 'email');
|
||||
expect(res).toMatchObject({ method: 'email', destination: 'j•••@example.com' });
|
||||
const [row] = mockDb.twoFactorChallenge._rows();
|
||||
expect(row.id).toBe(res.challengeId);
|
||||
expect(row.codeHash).not.toContain(lastEmailCode());
|
||||
expect(mockSent.emails[0].to).toBe(USER.email);
|
||||
});
|
||||
|
||||
test('sends by WhatsApp when asked', async () => {
|
||||
const res = await createChallenge(USER, 'login', 'whatsapp');
|
||||
expect(res).toMatchObject({ method: 'whatsapp', destination: '••• ••• 4567' });
|
||||
expect(mockSent.whatsapps).toHaveLength(1);
|
||||
expect(mockSent.emails).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('falls back to email when WhatsApp fails or is not configured', async () => {
|
||||
mockWhatsApp.fail = true;
|
||||
expect((await createChallenge(USER, 'login', 'whatsapp')).method).toBe('email');
|
||||
mockWhatsApp.fail = false;
|
||||
mockWhatsApp.configured = false;
|
||||
expect((await createChallenge(USER, 'login', 'whatsapp')).method).toBe('email');
|
||||
expect(mockSent.emails).toHaveLength(2);
|
||||
});
|
||||
|
||||
test('does not fall back when allowFallback is false, and records no challenge', async () => {
|
||||
mockWhatsApp.fail = true;
|
||||
await expect(createChallenge(USER, 'enable', 'whatsapp', { allowFallback: false })).rejects.toMatchObject({ statusCode: 503 });
|
||||
expect(mockSent.emails).toHaveLength(0);
|
||||
expect(mockDb.twoFactorChallenge._rows()).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('a new challenge replaces the previous one for the same purpose', async () => {
|
||||
const first = await createChallenge(USER, 'login', 'email');
|
||||
const firstCode = lastEmailCode();
|
||||
await createChallenge(USER, 'login', 'email');
|
||||
await expect(verifyChallenge(first.challengeId, firstCode, 'login')).rejects.toThrow(/invalid or has expired/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('verifyChallenge', () => {
|
||||
test('accepts the right code exactly once', async () => {
|
||||
const { challengeId } = await createChallenge(USER, 'login', 'email');
|
||||
const code = lastEmailCode();
|
||||
const challenge = await verifyChallenge(challengeId, code, 'login');
|
||||
expect(challenge.userId).toBe(USER.id);
|
||||
await expect(verifyChallenge(challengeId, code, 'login')).rejects.toThrow(/invalid or has expired/);
|
||||
});
|
||||
|
||||
test('tolerates spaces in the submitted code', async () => {
|
||||
const { challengeId } = await createChallenge(USER, 'login', 'email');
|
||||
const code = lastEmailCode();
|
||||
await expect(verifyChallenge(challengeId, ` ${code.slice(0, 3)} ${code.slice(3)} `, 'login')).resolves.toBeTruthy();
|
||||
});
|
||||
|
||||
test('locks the challenge after the maximum wrong attempts, even for the right code', async () => {
|
||||
const { challengeId } = await createChallenge(USER, 'login', 'email');
|
||||
const code = lastEmailCode();
|
||||
for (let i = 1; i <= MAX_ATTEMPTS; i++) {
|
||||
await expect(verifyChallenge(challengeId, wrongCode(code), 'login')).rejects.toThrow(
|
||||
i < MAX_ATTEMPTS ? /Incorrect code/ : /Too many incorrect attempts/
|
||||
);
|
||||
}
|
||||
await expect(verifyChallenge(challengeId, code, 'login')).rejects.toThrow(/invalid or has expired/);
|
||||
});
|
||||
|
||||
test('rejects an expired code', async () => {
|
||||
const { challengeId } = await createChallenge(USER, 'login', 'email');
|
||||
mockDb.twoFactorChallenge._rows()[0].expiresAt = new Date(Date.now() - 1000);
|
||||
await expect(verifyChallenge(challengeId, lastEmailCode(), 'login')).rejects.toThrow(/invalid or has expired/);
|
||||
});
|
||||
|
||||
test('rejects a code used for the wrong purpose or by another user', async () => {
|
||||
const { challengeId } = await createChallenge(USER, 'enable', 'email');
|
||||
const code = lastEmailCode();
|
||||
await expect(verifyChallenge(challengeId, code, 'login')).rejects.toThrow(/invalid or has expired/);
|
||||
await expect(verifyChallenge(challengeId, code, 'enable', 'someone-else')).rejects.toThrow(/invalid or has expired/);
|
||||
await expect(verifyChallenge(challengeId, code, 'enable', USER.id)).resolves.toBeTruthy();
|
||||
});
|
||||
|
||||
test('rejects missing or malformed input', async () => {
|
||||
await expect(verifyChallenge(undefined, '123456', 'login')).rejects.toThrow();
|
||||
await expect(verifyChallenge({ not: 'a string' }, '123456', 'login')).rejects.toThrow();
|
||||
const { challengeId } = await createChallenge(USER, 'login', 'email');
|
||||
await expect(verifyChallenge(challengeId, undefined, 'login')).rejects.toThrow(/Incorrect code/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resendChallenge', () => {
|
||||
test('refuses inside the cooldown', async () => {
|
||||
const { challengeId } = await createChallenge(USER, 'login', 'email');
|
||||
await expect(resendChallenge(challengeId, 'login')).rejects.toMatchObject({ statusCode: 429 });
|
||||
expect(mockSent.emails).toHaveLength(1);
|
||||
});
|
||||
|
||||
test('after the cooldown, sends a new code that replaces the old one and resets attempts', async () => {
|
||||
const { challengeId } = await createChallenge(USER, 'login', 'email');
|
||||
const oldCode = lastEmailCode();
|
||||
await expect(verifyChallenge(challengeId, wrongCode(oldCode), 'login')).rejects.toThrow();
|
||||
mockDb.twoFactorChallenge._rows()[0].lastSentAt = new Date(Date.now() - 61 * 1000);
|
||||
|
||||
await resendChallenge(challengeId, 'login');
|
||||
const newCode = lastEmailCode();
|
||||
expect(mockSent.emails).toHaveLength(2);
|
||||
expect(mockDb.twoFactorChallenge._rows()[0].attempts).toBe(0);
|
||||
if (newCode !== oldCode) {
|
||||
await expect(verifyChallenge(challengeId, oldCode, 'login')).rejects.toThrow(/Incorrect code/);
|
||||
}
|
||||
await expect(verifyChallenge(challengeId, newCode, 'login')).resolves.toBeTruthy();
|
||||
});
|
||||
|
||||
test('refuses once the challenge is too old or already used', async () => {
|
||||
const { challengeId } = await createChallenge(USER, 'login', 'email');
|
||||
const row = mockDb.twoFactorChallenge._rows()[0];
|
||||
row.lastSentAt = new Date(Date.now() - 61 * 1000);
|
||||
row.createdAt = new Date(Date.now() - 31 * 60 * 1000);
|
||||
await expect(resendChallenge(challengeId, 'login')).rejects.toThrow(/expired/);
|
||||
|
||||
row.createdAt = new Date();
|
||||
row.usedAt = new Date();
|
||||
await expect(resendChallenge(challengeId, 'login')).rejects.toThrow(/expired/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('trusted devices', () => {
|
||||
test('a freshly issued token is trusted for its own user only', async () => {
|
||||
const token = await issueTrustedDevice(USER.id, 'Mozilla/5.0 (Windows NT 10.0) Chrome/120.0 Safari/537.36');
|
||||
expect(token).toMatch(/^[0-9a-f]{64}$/);
|
||||
expect(mockDb.trustedDevice._rows()[0].tokenHash).not.toBe(token);
|
||||
expect(await isTrustedDevice(USER.id, token)).toBe(true);
|
||||
expect(await isTrustedDevice('someone-else', token)).toBe(false);
|
||||
});
|
||||
|
||||
test('unknown, malformed and expired tokens are not trusted', async () => {
|
||||
const token = await issueTrustedDevice(USER.id, '');
|
||||
expect(await isTrustedDevice(USER.id, 'nope')).toBe(false);
|
||||
expect(await isTrustedDevice(USER.id, undefined)).toBe(false);
|
||||
expect(await isTrustedDevice(USER.id, { tokenHash: 'x' })).toBe(false);
|
||||
mockDb.trustedDevice._rows()[0].expiresAt = new Date(Date.now() - 1000);
|
||||
expect(await isTrustedDevice(USER.id, token)).toBe(false);
|
||||
});
|
||||
|
||||
test('clearTrustedDevices forgets every device for the user', async () => {
|
||||
const a = await issueTrustedDevice(USER.id, '');
|
||||
const b = await issueTrustedDevice(USER.id, '');
|
||||
await clearTrustedDevices(USER.id);
|
||||
expect(await isTrustedDevice(USER.id, a)).toBe(false);
|
||||
expect(await isTrustedDevice(USER.id, b)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,31 @@
|
||||
const { pickOffers } = require('../src/utils/waitlist');
|
||||
|
||||
const q = (...qs) => qs.map((quantity, i) => ({ id: `e${i + 1}`, quantity }));
|
||||
|
||||
describe('pickOffers', () => {
|
||||
test('offers people in order while places last', () => {
|
||||
expect(pickOffers(q(1, 2, 1), 3, true)).toEqual([
|
||||
{ entryId: 'e1', quantity: 1 },
|
||||
{ entryId: 'e2', quantity: 2 },
|
||||
]);
|
||||
});
|
||||
|
||||
test('nothing free, no offers', () => {
|
||||
expect(pickOffers(q(1), 0, true)).toEqual([]);
|
||||
});
|
||||
|
||||
test('partial offers: the first in line gets what is free, nobody jumps ahead', () => {
|
||||
expect(pickOffers(q(3, 1), 2, true)).toEqual([{ entryId: 'e1', quantity: 2 }]);
|
||||
});
|
||||
|
||||
test('without partial offers, skips to the next person who fits', () => {
|
||||
expect(pickOffers(q(3, 1, 1), 2, false)).toEqual([
|
||||
{ entryId: 'e2', quantity: 1 },
|
||||
{ entryId: 'e3', quantity: 1 },
|
||||
]);
|
||||
});
|
||||
|
||||
test('without partial offers, nobody fits', () => {
|
||||
expect(pickOffers(q(3, 4), 2, false)).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,19 @@
|
||||
module.exports = {
|
||||
apps: [
|
||||
{
|
||||
name: 'hope-events-backend',
|
||||
cwd: __dirname + '/backend',
|
||||
script: 'src/index.js',
|
||||
env: { NODE_ENV: 'production' },
|
||||
max_memory_restart: '500M',
|
||||
},
|
||||
{
|
||||
name: 'hope-events-frontend',
|
||||
cwd: __dirname + '/frontend',
|
||||
script: 'npm',
|
||||
args: 'start -- -p 3000',
|
||||
env: { NODE_ENV: 'production' },
|
||||
max_memory_restart: '500M',
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { NextConfig } from "next";
|
||||
import { withSentryConfig } from "@sentry/nextjs";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
images: {
|
||||
@@ -12,4 +13,8 @@ const nextConfig: NextConfig = {
|
||||
},
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
// A no-op wrap when SENTRY_DSN isn't configured for this deployment — safe in
|
||||
// every environment (dev, or a fresh deploy that hasn't set up Sentry yet).
|
||||
export default process.env.NEXT_PUBLIC_SENTRY_DSN
|
||||
? withSentryConfig(nextConfig, { silent: true, disableLogger: true })
|
||||
: nextConfig;
|
||||
|
||||
Generated
+3956
-1735
File diff suppressed because it is too large
Load Diff
+13
-3
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "hope-events-frontend",
|
||||
"version": "1.5.2",
|
||||
"version": "1.16.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev --turbopack",
|
||||
@@ -27,12 +27,13 @@
|
||||
"@radix-ui/react-tabs": "^1.1.12",
|
||||
"@radix-ui/react-toast": "^1.2.14",
|
||||
"@radix-ui/react-tooltip": "^1.2.7",
|
||||
"@sentry/nextjs": "^10.71.0",
|
||||
"@zxing/browser": "^0.1.5",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"lucide-react": "^0.536.0",
|
||||
"next": "15.4.5",
|
||||
"next": "^15.5.24",
|
||||
"qrcode": "^1.5.4",
|
||||
"react": "19.1.0",
|
||||
"react-day-picker": "^9.8.1",
|
||||
@@ -51,9 +52,18 @@
|
||||
"@types/react-dom": "^19",
|
||||
"autoprefixer": "^10.4.21",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "15.4.5",
|
||||
"eslint-config-next": "^15.5.24",
|
||||
"postcss": "^8.5.6",
|
||||
"tailwindcss": "3.4",
|
||||
"typescript": "^5"
|
||||
},
|
||||
"overrides": {
|
||||
"next": {
|
||||
"postcss": "^8.5.23"
|
||||
}
|
||||
},
|
||||
"allowScripts": {
|
||||
"@sentry/cli@2.58.6": true,
|
||||
"unrs-resolver@1.12.2": true
|
||||
}
|
||||
}
|
||||
|
||||
|
Before Width: | Height: | Size: 4.2 KiB After Width: | Height: | Size: 4.2 KiB |
@@ -3,15 +3,25 @@ import { apiFetch } from "@/lib/api";
|
||||
|
||||
export const revalidate = 60;
|
||||
|
||||
// Event aliases are admin-set slugs (e.g. "camp-2025", "movie-night") — see the
|
||||
// "URL Alias" field in the event wizard. This catch-all route matches *any*
|
||||
// unmatched top-level path, so it's also what every bot/scanner probe hits
|
||||
// (/wp-login.php, /.env, /xmlrpc.php, etc.). Rejecting anything that isn't a
|
||||
// plausible slug here skips a live DB query for that background noise instead
|
||||
// of forwarding it straight to the backend.
|
||||
const VALID_ALIAS = /^[a-zA-Z0-9_-]{1,100}$/;
|
||||
|
||||
export default async function EventRedirectPage({ params }: { params: Promise<{ redirectUrl: string }> }) {
|
||||
const { redirectUrl } = await params;
|
||||
|
||||
let event: any = null;
|
||||
|
||||
try {
|
||||
event = await apiFetch<any>(`/api/events/by-alias/${redirectUrl}`);
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch event:", error);
|
||||
if (VALID_ALIAS.test(redirectUrl)) {
|
||||
try {
|
||||
event = await apiFetch<any>(`/api/events/by-alias/${redirectUrl}`);
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch event:", error);
|
||||
}
|
||||
}
|
||||
|
||||
if (!event || event.message?.toLowerCase().includes("not found")) {
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
"use client";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { useParams } from "next/navigation";
|
||||
import { Navbar } from "@/components/layout/Navbar";
|
||||
import { Footer } from "@/components/layout/Footer";
|
||||
import { apiFetch, ApiError } from "@/lib/api";
|
||||
import { formatDate } from "@/lib/date";
|
||||
import { ShieldCheck, CheckCircle2, XCircle } from "lucide-react";
|
||||
|
||||
type ConsentInfo = {
|
||||
status: "pending" | "given" | "declined";
|
||||
superseded: boolean;
|
||||
expired: boolean;
|
||||
respondedAt: string | null;
|
||||
attendeeName: string;
|
||||
parentName: string | null;
|
||||
consentText: string;
|
||||
event: { title: string; startDate: string; endDate: string; location: string | null };
|
||||
collect: { medical: boolean; emergency: boolean; photo: boolean };
|
||||
};
|
||||
|
||||
const inputCls = "w-full border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-brand-400";
|
||||
|
||||
/** Public page a parent/guardian opens from their consent email or WhatsApp. */
|
||||
export default function ConsentPage() {
|
||||
const params = useParams<{ token: string }>();
|
||||
const token = params?.token;
|
||||
const [info, setInfo] = useState<ConsentInfo | null>(null);
|
||||
const [loadError, setLoadError] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [done, setDone] = useState<"given" | "declined" | null>(null);
|
||||
|
||||
const [signedName, setSignedName] = useState("");
|
||||
const [isGuardian, setIsGuardian] = useState(false);
|
||||
const [medical, setMedical] = useState({ allergies: "", medication: "", conditions: "", medicalAid: "" });
|
||||
const [emergency, setEmergency] = useState({ name: "", phone: "", relationship: "" });
|
||||
const [photoConsent, setPhotoConsent] = useState<boolean | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!token) return;
|
||||
apiFetch<ConsentInfo>(`/api/consent/${encodeURIComponent(token)}`)
|
||||
.then(res => { setInfo(res); setSignedName(res.parentName || ""); })
|
||||
.catch(e => setLoadError(e instanceof ApiError ? e.message : "Couldn't load this consent request."));
|
||||
}, [token]);
|
||||
|
||||
const submit = async (decision: "give" | "decline") => {
|
||||
if (!token || !info) return;
|
||||
setError(null);
|
||||
if (decision === "give" && info.collect.photo && photoConsent === null) { setError("Please answer the photo consent question."); return; }
|
||||
setBusy(true);
|
||||
try {
|
||||
await apiFetch(`/api/consent/${encodeURIComponent(token)}`, {
|
||||
method: "POST",
|
||||
body: {
|
||||
decision, signedName, isGuardian,
|
||||
...(decision === "give" && info.collect.medical ? { medical } : {}),
|
||||
...(decision === "give" && info.collect.emergency ? { emergency } : {}),
|
||||
...(decision === "give" && info.collect.photo ? { photoConsent } : {}),
|
||||
},
|
||||
});
|
||||
setDone(decision === "give" ? "given" : "declined");
|
||||
} catch (e) {
|
||||
setError(e instanceof ApiError ? e.message : "Couldn't save your answer. Please try again.");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const open = info && info.status === "pending" && !info.superseded && !info.expired && !done;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col">
|
||||
<Navbar />
|
||||
<main className="flex-1 px-4 py-10 max-w-2xl mx-auto w-full">
|
||||
<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">
|
||||
<ShieldCheck className="w-5 h-5 text-brand-600" />
|
||||
</div>
|
||||
<h1 className="text-2xl font-semibold text-gray-900">Parental consent</h1>
|
||||
</div>
|
||||
|
||||
{loadError && <div className="border rounded-xl p-4 bg-red-50 text-red-800 text-sm">{loadError}</div>}
|
||||
{!info && !loadError && <div className="text-sm text-gray-600">Loading…</div>}
|
||||
|
||||
{info && (
|
||||
<div className="border rounded-xl p-5 bg-white shadow-sm space-y-4">
|
||||
<div>
|
||||
<div className="text-sm text-gray-500">Event</div>
|
||||
<div className="font-semibold text-gray-900">{info.event.title}</div>
|
||||
<div className="text-sm text-gray-600">
|
||||
{formatDate(new Date(info.event.startDate))}{info.event.endDate ? ` – ${formatDate(new Date(info.event.endDate))}` : ""}
|
||||
{info.event.location ? ` · ${info.event.location}` : ""}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm text-gray-500">Attendee</div>
|
||||
<div className="font-semibold text-gray-900">{info.attendeeName}</div>
|
||||
</div>
|
||||
|
||||
{(done || info.status !== "pending") && (
|
||||
<div className={`flex items-start gap-2 rounded-lg p-3 text-sm ${(done || info.status) === "given" ? "bg-green-50 text-green-800" : "bg-amber-50 text-amber-900"}`}>
|
||||
{(done || info.status) === "given" ? <CheckCircle2 className="w-5 h-5 shrink-0" /> : <XCircle className="w-5 h-5 shrink-0" />}
|
||||
<span>
|
||||
{(done || info.status) === "given" ? "Consent has been given. Thank you!" : "Consent has been declined. The organisers have been told."}
|
||||
{!done && info.respondedAt && ` (${formatDate(new Date(info.respondedAt))})`}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{!done && info.status === "pending" && info.superseded && (
|
||||
<div className="rounded-lg p-3 text-sm bg-amber-50 text-amber-900">A newer consent request has replaced this one. Please use the link in your latest message.</div>
|
||||
)}
|
||||
{!done && info.status === "pending" && info.expired && (
|
||||
<div className="rounded-lg p-3 text-sm bg-gray-100 text-gray-700">This consent link has expired because the event has ended.</div>
|
||||
)}
|
||||
|
||||
{open && (
|
||||
<>
|
||||
<div>
|
||||
<div className="text-sm font-medium text-gray-800 mb-1">Please read</div>
|
||||
<div className="text-sm text-gray-700 whitespace-pre-line border rounded-lg p-3 bg-gray-50 max-h-72 overflow-y-auto">{info.consentText || "The organisers haven't added any consent text."}</div>
|
||||
</div>
|
||||
|
||||
{info.collect.medical && (
|
||||
<fieldset className="space-y-2">
|
||||
<legend className="text-sm font-medium text-gray-800">Medical information <span className="font-normal text-gray-500">(kept confidential and deleted after the event)</span></legend>
|
||||
<textarea className={inputCls} rows={2} placeholder="Allergies" value={medical.allergies} onChange={e => setMedical({ ...medical, allergies: e.target.value })} aria-label="Allergies" />
|
||||
<textarea className={inputCls} rows={2} placeholder="Medication" value={medical.medication} onChange={e => setMedical({ ...medical, medication: e.target.value })} aria-label="Medication" />
|
||||
<textarea className={inputCls} rows={2} placeholder="Medical conditions" value={medical.conditions} onChange={e => setMedical({ ...medical, conditions: e.target.value })} aria-label="Medical conditions" />
|
||||
<input className={inputCls} placeholder="Medical aid (scheme and number)" value={medical.medicalAid} onChange={e => setMedical({ ...medical, medicalAid: e.target.value })} aria-label="Medical aid" />
|
||||
</fieldset>
|
||||
)}
|
||||
|
||||
{info.collect.emergency && (
|
||||
<fieldset className="space-y-2">
|
||||
<legend className="text-sm font-medium text-gray-800">Emergency contact</legend>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-2">
|
||||
<input className={inputCls} placeholder="Name" value={emergency.name} onChange={e => setEmergency({ ...emergency, name: e.target.value })} aria-label="Emergency contact name" />
|
||||
<input type="tel" className={inputCls} placeholder="Phone" value={emergency.phone} onChange={e => setEmergency({ ...emergency, phone: e.target.value })} aria-label="Emergency contact phone" />
|
||||
<input className={inputCls} placeholder="Relationship" value={emergency.relationship} onChange={e => setEmergency({ ...emergency, relationship: e.target.value })} aria-label="Relationship" />
|
||||
</div>
|
||||
</fieldset>
|
||||
)}
|
||||
|
||||
{info.collect.photo && (
|
||||
<fieldset>
|
||||
<legend className="text-sm font-medium text-gray-800 mb-1">May {info.attendeeName} appear in event photos and videos?</legend>
|
||||
<label className="inline-flex items-center gap-2 text-sm mr-4"><input type="radio" name="photo" checked={photoConsent === true} onChange={() => setPhotoConsent(true)} /> Yes</label>
|
||||
<label className="inline-flex items-center gap-2 text-sm"><input type="radio" name="photo" checked={photoConsent === false} onChange={() => setPhotoConsent(false)} /> No</label>
|
||||
</fieldset>
|
||||
)}
|
||||
|
||||
<div className="space-y-2 border-t pt-4">
|
||||
<label className="block text-sm font-medium text-gray-800">Type your full name to sign</label>
|
||||
<input className={inputCls} value={signedName} onChange={e => setSignedName(e.target.value)} aria-label="Your full name" />
|
||||
<label className="flex items-start gap-2 text-sm text-gray-700">
|
||||
<input type="checkbox" className="mt-0.5" checked={isGuardian} onChange={e => setIsGuardian(e.target.checked)} />
|
||||
<span>I am the parent or legal guardian of {info.attendeeName}.</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{error && <div className="p-3 border rounded bg-red-50 text-red-800 text-sm">{error}</div>}
|
||||
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<button onClick={() => submit("give")} disabled={busy || !isGuardian || signedName.trim().length < 2} className="px-4 py-2 rounded-lg bg-brand-600 text-white text-sm font-medium hover:bg-brand-700 disabled:opacity-50">
|
||||
{busy ? "Saving…" : "Give consent"}
|
||||
</button>
|
||||
<button onClick={() => submit("decline")} disabled={busy || !isGuardian || signedName.trim().length < 2} className="px-4 py-2 rounded-lg border border-gray-300 text-gray-700 text-sm font-medium hover:bg-gray-50 disabled:opacity-50">
|
||||
Decline
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500">We record your typed name, the time, and the device you used, together with the text above.</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
<Footer />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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,233 @@
|
||||
"use client";
|
||||
|
||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import { useDismissingState } from "@/hooks/useDismissingState";
|
||||
import { History } from "lucide-react";
|
||||
import { Table, TableHeader, TableBody, TableRow, TableHead, TableCell } from "@/components/ui/table";
|
||||
|
||||
const ACTIONS = [
|
||||
"refund_created",
|
||||
"donation_assigned",
|
||||
"donation_unassigned",
|
||||
"registration_created_manual",
|
||||
"registration_cancelled",
|
||||
"event_created",
|
||||
"event_updated",
|
||||
"event_deleted",
|
||||
"settings_updated",
|
||||
"coupon_created",
|
||||
"coupon_updated",
|
||||
"coupon_disabled",
|
||||
"coupon_applied_manual",
|
||||
"consent_override",
|
||||
"consent_rerequested",
|
||||
"medical_list_viewed",
|
||||
] as const;
|
||||
|
||||
const ACTION_LABELS: Record<string, string> = {
|
||||
refund_created: "Refund created",
|
||||
donation_assigned: "Donation assigned",
|
||||
donation_unassigned: "Donation unassigned",
|
||||
registration_created_manual: "Manual registration created",
|
||||
registration_cancelled: "Registration cancelled (staff)",
|
||||
event_created: "Event created",
|
||||
event_updated: "Event updated",
|
||||
event_deleted: "Event deactivated",
|
||||
settings_updated: "Settings updated",
|
||||
coupon_created: "Coupon created",
|
||||
coupon_updated: "Coupon edited",
|
||||
coupon_disabled: "Coupon turned off",
|
||||
coupon_applied_manual: "Coupon applied/removed by staff",
|
||||
consent_override: "Consent overridden at check-in",
|
||||
consent_rerequested: "Consent re-requested",
|
||||
medical_list_viewed: "Medical list viewed",
|
||||
};
|
||||
|
||||
interface AuditLogEntry {
|
||||
id: string;
|
||||
actorId: string | null;
|
||||
actorRole: string;
|
||||
action: string;
|
||||
targetType: string;
|
||||
targetId: string | null;
|
||||
metadata: Record<string, unknown> | null;
|
||||
ip: string | null;
|
||||
createdAt: string;
|
||||
actor: { id: string; name: string; email: string } | null;
|
||||
}
|
||||
|
||||
function formatMetadata(metadata: Record<string, unknown> | null): string {
|
||||
if (!metadata) return "";
|
||||
try {
|
||||
return Object.entries(metadata)
|
||||
.filter(([, v]) => v !== null && v !== undefined && v !== "")
|
||||
.map(([k, v]) => `${k}: ${Array.isArray(v) ? v.join(", ") : String(v)}`)
|
||||
.join(" · ");
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
export default function AdminAuditLogPage() {
|
||||
const { user, loading, token } = useAuth();
|
||||
const router = useRouter();
|
||||
|
||||
const isAdmin = useMemo(() => user?.role === "admin", [user]);
|
||||
|
||||
useEffect(() => {
|
||||
if (loading) return;
|
||||
if (!user) router.replace("/login");
|
||||
}, [user, loading, router]);
|
||||
|
||||
const [entries, setEntries] = useState<AuditLogEntry[]>([]);
|
||||
const [fetching, setFetching] = useState(false);
|
||||
const [error, setError] = useDismissingState<string | null>(null);
|
||||
const [page, setPage] = useState(1);
|
||||
const [total, setTotal] = useState(0);
|
||||
const pageSize = 50;
|
||||
const totalPages = Math.max(1, Math.ceil(total / pageSize));
|
||||
|
||||
const [actionFilter, setActionFilter] = useState<string>("");
|
||||
const [fromFilter, setFromFilter] = useState<string>("");
|
||||
const [toFilter, setToFilter] = useState<string>("");
|
||||
|
||||
const buildQuery = useCallback((p: number) => {
|
||||
const qs = new URLSearchParams({ page: String(p), limit: String(pageSize) });
|
||||
if (actionFilter) qs.set("action", actionFilter);
|
||||
if (fromFilter) qs.set("from", fromFilter);
|
||||
if (toFilter) qs.set("to", toFilter);
|
||||
return `/api/admin/audit-log?${qs.toString()}`;
|
||||
}, [actionFilter, fromFilter, toFilter]);
|
||||
|
||||
const load = useCallback(async (p = 1) => {
|
||||
if (!token) return;
|
||||
setError(null);
|
||||
setFetching(true);
|
||||
try {
|
||||
const res = await apiFetch<{ rows: AuditLogEntry[]; total: number; page: number }>(buildQuery(p), { authToken: token });
|
||||
setEntries(res?.rows || []);
|
||||
setTotal(res?.total ?? 0);
|
||||
setPage(p);
|
||||
} catch (e: any) {
|
||||
setError(e?.message || "Failed to load audit log");
|
||||
} finally {
|
||||
setFetching(false);
|
||||
}
|
||||
}, [token, buildQuery]);
|
||||
|
||||
useEffect(() => { if (token) load(1); }, [token, actionFilter, fromFilter, toFilter]);
|
||||
|
||||
return (
|
||||
<div className="max-w-6xl mx-auto w-full p-6">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="w-10 h-10 rounded-xl bg-brand-50 flex items-center justify-center shrink-0">
|
||||
<History className="w-5 h-5 text-brand-600" />
|
||||
</div>
|
||||
<div>
|
||||
<button className="text-xs text-brand-600 hover:underline" onClick={() => router.push("/dashboard/admin")}>← Back to dashboard</button>
|
||||
<h1 className="text-2xl font-semibold text-gray-900 mt-0.5">Admin Audit Log</h1>
|
||||
<p className="text-sm text-gray-500">{total} action{total !== 1 ? "s" : ""} recorded</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!isAdmin && (
|
||||
<div className="p-3 border rounded bg-yellow-50 text-yellow-800 text-sm mb-4">
|
||||
You need admin access to view the audit log.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && <div className="mb-3 p-3 border rounded bg-red-50 text-red-800 text-sm">{error}</div>}
|
||||
|
||||
<div className="border rounded-xl p-4 bg-white shadow-sm">
|
||||
<div className="flex flex-wrap items-end gap-3 mb-4">
|
||||
<div>
|
||||
<label className="block text-xs text-gray-600 mb-1">Action</label>
|
||||
<select className="border rounded px-2 py-1.5 text-sm" value={actionFilter} onChange={e => setActionFilter(e.target.value)}>
|
||||
<option value="">All actions</option>
|
||||
{ACTIONS.map(a => <option key={a} value={a}>{ACTION_LABELS[a]}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-gray-600 mb-1">From</label>
|
||||
<input type="date" className="border rounded px-2 py-1.5 text-sm" value={fromFilter} onChange={e => setFromFilter(e.target.value)} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-gray-600 mb-1">To</label>
|
||||
<input type="date" className="border rounded px-2 py-1.5 text-sm" value={toFilter} onChange={e => setToFilter(e.target.value)} />
|
||||
</div>
|
||||
<button className="text-sm px-2 py-1.5 rounded bg-gray-100 hover:bg-gray-200" onClick={() => load(page)} disabled={fetching}>
|
||||
{fetching ? "Refreshing…" : "Refresh"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>When</TableHead>
|
||||
<TableHead>Actor</TableHead>
|
||||
<TableHead>Action</TableHead>
|
||||
<TableHead>Target</TableHead>
|
||||
<TableHead>Details</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{entries.map(e => (
|
||||
<TableRow key={e.id}>
|
||||
<TableCell className="whitespace-nowrap text-gray-600">{new Date(e.createdAt).toLocaleString()}</TableCell>
|
||||
<TableCell>
|
||||
{e.actor ? (
|
||||
<>
|
||||
<div className="font-medium">{e.actor.name}</div>
|
||||
<div className="text-xs text-gray-500">{e.actor.email}</div>
|
||||
</>
|
||||
) : (
|
||||
<span className="text-gray-400 italic">Deleted user</span>
|
||||
)}
|
||||
<div className="text-xs text-gray-400 capitalize">{e.actorRole}</div>
|
||||
</TableCell>
|
||||
<TableCell>{ACTION_LABELS[e.action] || e.action}</TableCell>
|
||||
<TableCell className="text-xs text-gray-600">{e.targetType}{e.targetId ? ` #${e.targetId.slice(0, 8)}` : ""}</TableCell>
|
||||
<TableCell className="text-xs text-gray-500 max-w-[280px] truncate" title={formatMetadata(e.metadata)}>{formatMetadata(e.metadata)}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
{entries.length === 0 && !fetching && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={5} className="text-gray-500">No matching audit entries.</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
{fetching && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={5} className="text-gray-400">Loading…</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center justify-between mt-4 text-sm">
|
||||
<span className="text-gray-500">Page {page} of {totalPages}</span>
|
||||
<div className="flex gap-1">
|
||||
<button
|
||||
className="px-2 py-1 rounded bg-gray-100 hover:bg-gray-200 disabled:opacity-40"
|
||||
disabled={page <= 1 || fetching}
|
||||
onClick={() => load(page - 1)}
|
||||
>
|
||||
← Prev
|
||||
</button>
|
||||
<button
|
||||
className="px-2 py-1 rounded bg-gray-100 hover:bg-gray-200 disabled:opacity-40"
|
||||
disabled={page >= totalPages || fetching}
|
||||
onClick={() => load(page + 1)}
|
||||
>
|
||||
Next →
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -8,7 +8,7 @@ import { useStableState } from "@/hooks/useStableState";
|
||||
import { useVisiblePolling } from "@/hooks/useVisiblePolling";
|
||||
import {
|
||||
Calendar, Banknote, Gift, Users, Ticket, QrCode, ClipboardList,
|
||||
UserPlus, FileText, MessageCircle, BarChart2, Mail, Wallet, DoorOpen,
|
||||
UserPlus, FileText, MessageCircle, BarChart2, Mail, Wallet, DoorOpen, History, TicketPercent, ShieldCheck, ListChecks,
|
||||
} from "lucide-react";
|
||||
import { StatCard, StatCardRow } from "@/components/shared/StatCard";
|
||||
import { QuickActionTile, QuickActionGrid } from "@/components/shared/QuickActionTile";
|
||||
@@ -27,12 +27,16 @@ const QUICK_ACTIONS = [
|
||||
{ href: "/dashboard/supervisor/manual", label: "Manual registration", description: "Register a guest and issue tickets", icon: UserPlus },
|
||||
{ href: "/dashboard/supervisor/at-the-door", label: "At the door", description: "Walk-ins, payments, ticket printing", icon: DoorOpen },
|
||||
{ href: "/dashboard/supervisor/payments", label: "Payments & donations", description: "Manual payments and assignment", icon: Wallet },
|
||||
{ href: "/dashboard/supervisor/waitlist", label: "Waitlist", description: "People waiting for sold-out tickets", icon: ListChecks },
|
||||
{ href: "/dashboard/supervisor/consent", label: "Parental consent", description: "Youth events: consent status and medical list", icon: ShieldCheck },
|
||||
{ href: "/dashboard/supervisor/coupons", label: "Coupon codes", description: "Discounts for staff, leaders and promotions", icon: TicketPercent },
|
||||
{ href: "/dashboard/staff/ticket-scanning", label: "Open scanner", description: "Use your device camera to validate tickets", icon: QrCode },
|
||||
{ href: "/dashboard/supervisor/reports", label: "Reports", description: "View, export, and email reports", icon: BarChart2 },
|
||||
{ href: "/dashboard/admin/forms", label: "Attendee forms", description: "View submitted attendee forms", icon: FileText },
|
||||
{ href: "/dashboard/supervisor/email-attendees", label: "Email attendees", description: "Send a message to attendees of an event", icon: Mail },
|
||||
{ href: "/dashboard/supervisor/whatsapp-attendees", label: "WhatsApp attendees", description: "Send a WhatsApp message to event attendees", icon: MessageCircle },
|
||||
{ href: "/dashboard/admin/cashup", label: "Post-event Cashup", description: "Set costs, reconcile takings, and close out events", icon: Wallet },
|
||||
{ href: "/dashboard/admin/audit-log", label: "Audit log", description: "Review refunds, manual registrations, event and settings changes", icon: History },
|
||||
] as const;
|
||||
|
||||
type OverviewMetric = { thisMonth: number; lastMonth: number; pctChange: number | null };
|
||||
|
||||
@@ -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) => (
|
||||
<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)
|
||||
{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);
|
||||
return `Qty: ${opt.quantity} × R ${unit.toFixed(2)} = R ${(unit * (opt.quantity || 0)).toFixed(2)}`;
|
||||
})()}
|
||||
: (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>
|
||||
{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>
|
||||
{opt.appliedTierId && (
|
||||
<div className="text-green-700 text-[10px] mt-0.5">Early-bird price applied</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="text-xs text-gray-700 mt-1 font-medium">Total: R {totalDue.toFixed(2)}</div>
|
||||
</div>
|
||||
|
||||
@@ -6,9 +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 },
|
||||
@@ -17,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 =
|
||||
@@ -97,11 +103,18 @@ function SiteSettingsPageInner() {
|
||||
const [appBaseUrl, setAppBaseUrl] = useState("");
|
||||
|
||||
// ── Branding ──
|
||||
const [accentColor, setAccentColor] = useState("#2563eb");
|
||||
const [primaryColor, setPrimaryColor] = useState("#4F46E5");
|
||||
const [secondaryColor, setSecondaryColor] = useState("#8B5CF6");
|
||||
const [accentColor, setAccentColor] = useState("#EC4899");
|
||||
const [logoUrl, setLogoUrl] = useState("");
|
||||
const [logoFile, setLogoFile] = useState<File | null>(null);
|
||||
const [logoPreview, setLogoPreview] = useState<string | null>(null);
|
||||
const [suggestedColors, setSuggestedColors] = useState<string[]>([]);
|
||||
const fileRef = useRef<HTMLInputElement>(null);
|
||||
const [faviconUrl, setFaviconUrl] = useState("");
|
||||
const [faviconFile, setFaviconFile] = useState<File | null>(null);
|
||||
const [faviconPreview, setFaviconPreview] = useState<string | null>(null);
|
||||
const faviconRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// ── Notifications ──
|
||||
const [notifEmails, setNotifEmails] = useState("");
|
||||
@@ -123,6 +136,7 @@ function SiteSettingsPageInner() {
|
||||
const [legalIoEmail, setLegalIoEmail] = useState("");
|
||||
const [legalWebsiteUrl, setLegalWebsiteUrl] = useState("");
|
||||
const [legalEffectiveDate, setLegalEffectiveDate] = useState("");
|
||||
const [consentRetentionDays, setConsentRetentionDays] = useState("30");
|
||||
|
||||
// ── Load all settings once ────────────────────────────────────────────────
|
||||
useEffect(() => {
|
||||
@@ -135,8 +149,16 @@ function SiteSettingsPageInner() {
|
||||
setOrgPhone(s.org_phone || "");
|
||||
setOrgAddress(s.org_address || "");
|
||||
setAppBaseUrl(s.app_base_url || "");
|
||||
setAccentColor(s.accent_color || "#2563eb");
|
||||
// primary_color falls back to the legacy accent_color value (which used
|
||||
// to be "the one brand color" before this 3-color system existed).
|
||||
// accent_color is only trusted as the NEW tertiary color once
|
||||
// primary_color already exists — otherwise it's still doing legacy-
|
||||
// primary duty and must not also seed the Accent field.
|
||||
setPrimaryColor(s.primary_color || s.accent_color || "#4F46E5");
|
||||
setSecondaryColor(s.secondary_color || "#8B5CF6");
|
||||
setAccentColor(s.primary_color ? (s.accent_color || "#EC4899") : "#EC4899");
|
||||
setLogoUrl(s.logo_url || "");
|
||||
setFaviconUrl(s.favicon_url || "");
|
||||
setNotifEmails(s.reg_notification_emails || "");
|
||||
setSmtpHost(s.smtp_host || "");
|
||||
setSmtpPort(s.smtp_port || "587");
|
||||
@@ -150,6 +172,7 @@ function SiteSettingsPageInner() {
|
||||
setLegalIoEmail(s.legal_io_email || "");
|
||||
setLegalWebsiteUrl(s.legal_website_url || "");
|
||||
setLegalEffectiveDate(s.legal_effective_date || "");
|
||||
setConsentRetentionDays(s.consent_medical_retention_days || "30");
|
||||
})
|
||||
.catch((e: any) => setResult({ ok: false, message: e?.message || "Failed to load settings" }))
|
||||
.finally(() => setLoadingInitial(false));
|
||||
@@ -183,31 +206,47 @@ function SiteSettingsPageInner() {
|
||||
app_base_url: appBaseUrl.trim(),
|
||||
});
|
||||
|
||||
const uploadBrandingAsset = async (file: File, kind: "logo" | "favicon"): Promise<string> => {
|
||||
const fd = new FormData();
|
||||
fd.append("image", file);
|
||||
const res = await fetch(`${API_BASE}/api/uploads/${kind}`, {
|
||||
method: "POST",
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
body: fd,
|
||||
});
|
||||
if (!res.ok) throw new Error(`${kind === "logo" ? "Logo" : "Favicon"} upload failed`);
|
||||
const data = await res.json();
|
||||
return data.url || "";
|
||||
};
|
||||
|
||||
const saveBranding = async () => {
|
||||
if (!token) return;
|
||||
setSaving(true);
|
||||
setResult(null);
|
||||
try {
|
||||
let finalLogoUrl = logoUrl;
|
||||
if (logoFile) {
|
||||
const fd = new FormData();
|
||||
fd.append("image", logoFile);
|
||||
const res = await fetch(`${API_BASE}/api/uploads/logo`, {
|
||||
method: "POST",
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
body: fd,
|
||||
});
|
||||
if (!res.ok) throw new Error("Logo upload failed");
|
||||
const data = await res.json();
|
||||
finalLogoUrl = data.url || logoUrl;
|
||||
}
|
||||
if (logoFile) finalLogoUrl = await uploadBrandingAsset(logoFile, "logo") || logoUrl;
|
||||
|
||||
let finalFaviconUrl = faviconUrl;
|
||||
if (faviconFile) finalFaviconUrl = await uploadBrandingAsset(faviconFile, "favicon") || faviconUrl;
|
||||
|
||||
await apiFetch("/api/settings", {
|
||||
method: "PUT", authToken: token,
|
||||
body: { accent_color: accentColor, logo_url: finalLogoUrl },
|
||||
body: {
|
||||
primary_color: primaryColor,
|
||||
secondary_color: secondaryColor,
|
||||
accent_color: accentColor,
|
||||
logo_url: finalLogoUrl,
|
||||
favicon_url: finalFaviconUrl,
|
||||
},
|
||||
});
|
||||
setLogoUrl(finalLogoUrl);
|
||||
setLogoFile(null);
|
||||
setLogoPreview(null);
|
||||
setFaviconUrl(finalFaviconUrl);
|
||||
setFaviconFile(null);
|
||||
setFaviconPreview(null);
|
||||
setSuggestedColors([]);
|
||||
setResult({ ok: true, message: "Saved." });
|
||||
reloadSettings();
|
||||
} catch (e: any) {
|
||||
@@ -217,6 +256,39 @@ function SiteSettingsPageInner() {
|
||||
}
|
||||
};
|
||||
|
||||
const resetBranding = async () => {
|
||||
if (!token) return;
|
||||
if (!confirm("Reset branding to defaults? This clears your custom colors, logo, and favicon.")) return;
|
||||
setSaving(true);
|
||||
setResult(null);
|
||||
try {
|
||||
await apiFetch("/api/settings", {
|
||||
method: "PUT", authToken: token,
|
||||
body: { primary_color: "", secondary_color: "", accent_color: "", logo_url: "", favicon_url: "" },
|
||||
});
|
||||
setPrimaryColor("#4F46E5");
|
||||
setSecondaryColor("#8B5CF6");
|
||||
setAccentColor("#EC4899");
|
||||
setLogoUrl(""); setLogoFile(null); setLogoPreview(null);
|
||||
setFaviconUrl(""); setFaviconFile(null); setFaviconPreview(null);
|
||||
setSuggestedColors([]);
|
||||
setResult({ ok: true, message: "Reset to defaults." });
|
||||
reloadSettings();
|
||||
} catch (e: any) {
|
||||
setResult({ ok: false, message: e?.message || "Failed to reset" });
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleLogoFileChange = async (file: File | undefined) => {
|
||||
if (!file) return;
|
||||
setLogoFile(file);
|
||||
const preview = URL.createObjectURL(file);
|
||||
setLogoPreview(preview);
|
||||
setSuggestedColors(await extractDominantColors(preview));
|
||||
};
|
||||
|
||||
const saveNotifications = () => save({ reg_notification_emails: notifEmails.trim() });
|
||||
|
||||
const saveSmtp = async () => {
|
||||
@@ -249,6 +321,7 @@ function SiteSettingsPageInner() {
|
||||
legal_io_email: legalIoEmail.trim(),
|
||||
legal_website_url: legalWebsiteUrl.trim(),
|
||||
legal_effective_date: legalEffectiveDate.trim(),
|
||||
consent_medical_retention_days: String(Math.max(0, parseInt(consentRetentionDays, 10) || 0)),
|
||||
});
|
||||
|
||||
const handleTestSmtp = async () => {
|
||||
@@ -279,9 +352,10 @@ function SiteSettingsPageInner() {
|
||||
if (loadingInitial) return <div className="p-6 text-sm text-gray-500">Loading settings…</div>;
|
||||
|
||||
const currentLogoSrc = logoPreview || (logoUrl ? resolveToApiOrigin(logoUrl) : null);
|
||||
const currentFaviconSrc = faviconPreview || (faviconUrl ? resolveToApiOrigin(faviconUrl) : null);
|
||||
|
||||
return (
|
||||
<div className="max-w-3xl mx-auto w-full">
|
||||
<div className={activeTab === "branding" ? "max-w-6xl mx-auto w-full" : "max-w-3xl mx-auto w-full"}>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
@@ -318,13 +392,14 @@ function SiteSettingsPageInner() {
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="border rounded-xl p-5 bg-white shadow-sm">
|
||||
<div className={activeTab === "branding" ? "grid lg:grid-cols-5 gap-5 items-start" : ""}>
|
||||
<div className={`border rounded-xl p-5 bg-white shadow-sm ${activeTab === "branding" ? "lg:col-span-3" : ""}`}>
|
||||
{/* ── Organisation ─────────────────────────────────────────────────── */}
|
||||
{activeTab === "organisation" && (
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-lg font-semibold text-gray-900">Organisation details</h2>
|
||||
<Field label="Organisation name" required>
|
||||
<input className={inputCls} placeholder="Hope Family Church"
|
||||
<input className={inputCls} placeholder="Your Organisation"
|
||||
value={orgName} onChange={e => setOrgName(e.target.value)} />
|
||||
</Field>
|
||||
<Field label="Tagline">
|
||||
@@ -341,9 +416,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"
|
||||
@@ -357,15 +438,15 @@ function SiteSettingsPageInner() {
|
||||
{activeTab === "branding" && (
|
||||
<div className="space-y-5">
|
||||
<h2 className="text-lg font-semibold text-gray-900">Branding</h2>
|
||||
<Field label="Accent / brand colour" hint="Only recolors the organisation name next to the logo in the top navigation bar — every other button, link, and highlight across the site uses a fixed color scheme.">
|
||||
<div className="flex items-center gap-3">
|
||||
<input type="color" className="h-10 w-20 border rounded cursor-pointer"
|
||||
value={accentColor} onChange={e => setAccentColor(e.target.value)} />
|
||||
<input className={`${inputCls} font-mono`} placeholder="#2563eb"
|
||||
value={accentColor} onChange={e => setAccentColor(e.target.value)} />
|
||||
</div>
|
||||
<div className="mt-2 h-6 rounded-lg transition-colors" style={{ background: accentColor }} />
|
||||
</Field>
|
||||
|
||||
<div className="grid sm:grid-cols-3 gap-4">
|
||||
<ColorPickerField label="Primary" hint="Buttons, links, nav highlights, and the email header/CTA."
|
||||
value={primaryColor} onChange={setPrimaryColor} />
|
||||
<ColorPickerField label="Secondary" hint="Subtle backgrounds and outline-button borders/text."
|
||||
value={secondaryColor} onChange={setSecondaryColor} />
|
||||
<ColorPickerField label="Accent" hint="Hover states and highlight chips/badges."
|
||||
value={accentColor} onChange={setAccentColor} />
|
||||
</div>
|
||||
|
||||
<Field label="Site logo" hint="PNG, JPG, SVG or WebP, max 2 MB. Displayed in the navigation bar.">
|
||||
{currentLogoSrc && (
|
||||
@@ -375,21 +456,77 @@ function SiteSettingsPageInner() {
|
||||
<button
|
||||
type="button"
|
||||
className="text-xs text-red-500 hover:text-red-700"
|
||||
onClick={() => { setLogoUrl(""); setLogoFile(null); setLogoPreview(null); if (fileRef.current) fileRef.current.value = ""; }}
|
||||
onClick={() => { setLogoUrl(""); setLogoFile(null); setLogoPreview(null); setSuggestedColors([]); if (fileRef.current) fileRef.current.value = ""; }}
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<input ref={fileRef} type="file" accept="image/*" onChange={e => {
|
||||
<input ref={fileRef} type="file" accept="image/*" onChange={e => handleLogoFileChange(e.target.files?.[0])} className="text-sm" />
|
||||
{suggestedColors.length > 0 && (
|
||||
<div className="mt-3 border rounded-lg p-3 bg-gray-50">
|
||||
<p className="text-xs font-medium text-gray-600 mb-2">Suggested from your logo — click a swatch to apply it</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{suggestedColors.map((hex) => (
|
||||
<div key={hex} className="flex items-center gap-1 border rounded-lg bg-white p-1">
|
||||
<div className="w-6 h-6 rounded" style={{ background: hex }} title={hex} />
|
||||
<button type="button" className="text-[10px] font-semibold px-1.5 py-1 rounded hover:bg-gray-100" onClick={() => setPrimaryColor(hex)}>P</button>
|
||||
<button type="button" className="text-[10px] font-semibold px-1.5 py-1 rounded hover:bg-gray-100" onClick={() => setSecondaryColor(hex)}>S</button>
|
||||
<button type="button" className="text-[10px] font-semibold px-1.5 py-1 rounded hover:bg-gray-100" onClick={() => setAccentColor(hex)}>A</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Field>
|
||||
|
||||
<Field label="Favicon" hint="ICO, PNG, or SVG, max 2 MB, ideally square. Shown in the browser tab — falls back to the default if not set.">
|
||||
{currentFaviconSrc && (
|
||||
<div className="mb-3 flex items-center gap-3">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img src={currentFaviconSrc} alt="Current favicon" className="h-8 w-8 object-contain border rounded p-1 bg-gray-50" />
|
||||
<button
|
||||
type="button"
|
||||
className="text-xs text-red-500 hover:text-red-700"
|
||||
onClick={() => { setFaviconUrl(""); setFaviconFile(null); setFaviconPreview(null); if (faviconRef.current) faviconRef.current.value = ""; }}
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<input ref={faviconRef} type="file" accept=".ico,.png,.svg,image/x-icon,image/png,image/svg+xml" onChange={e => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
setLogoFile(file);
|
||||
setLogoPreview(URL.createObjectURL(file));
|
||||
setFaviconFile(file);
|
||||
setFaviconPreview(URL.createObjectURL(file));
|
||||
}} className="text-sm" />
|
||||
</Field>
|
||||
|
||||
<SaveBar saving={saving} onSave={saveBranding} result={result} />
|
||||
<div className="flex items-center justify-between pt-4 border-t mt-6 flex-wrap gap-3">
|
||||
<button
|
||||
type="button"
|
||||
disabled={saving}
|
||||
onClick={resetBranding}
|
||||
className="px-4 py-2 text-sm rounded-lg border border-gray-200 text-gray-600 hover:bg-gray-50 disabled:opacity-50"
|
||||
>
|
||||
Reset to defaults
|
||||
</button>
|
||||
<div className="flex items-center gap-3">
|
||||
{result && (
|
||||
<span className={`text-sm flex items-center gap-1.5 ${result.ok ? "text-green-600" : "text-red-600"}`}>
|
||||
{result.ok ? "✓" : "✗"} {result.message}
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
disabled={saving}
|
||||
onClick={saveBranding}
|
||||
className="px-6 py-2 bg-brand-600 hover:bg-brand-700 disabled:opacity-50 text-white rounded-lg text-sm font-medium"
|
||||
>
|
||||
{saving ? "Saving…" : "Save"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -524,12 +661,38 @@ function SiteSettingsPageInner() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t pt-4">
|
||||
<p className="text-sm font-medium text-gray-700 mb-3">Youth events</p>
|
||||
<Field label="Delete medical & emergency details this many days after an event">
|
||||
<input type="number" min="0" className={`${inputCls} sm:w-32`}
|
||||
value={consentRetentionDays} onChange={e => setConsentRetentionDays(e.target.value)} />
|
||||
</Field>
|
||||
<p className="text-xs text-gray-500 mt-1">Collected on parental consent forms. The consent record itself (who agreed, and when) is kept.</p>
|
||||
</div>
|
||||
|
||||
<SaveBar saving={saving} onSave={saveLegal} result={result} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── WhatsApp ──────────────────────────────────────────────────────── */}
|
||||
{activeTab === "whatsapp" && <WhatsAppTab active={activeTab === "whatsapp"} />}
|
||||
|
||||
{/* ── Backups ───────────────────────────────────────────────────────── */}
|
||||
{activeTab === "backups" && <BackupsTab active={activeTab === "backups"} />}
|
||||
</div>
|
||||
|
||||
{activeTab === "branding" && (
|
||||
<div className="lg:col-span-2 lg:sticky lg:top-4">
|
||||
<BrandingPreviewPanel
|
||||
primaryColor={primaryColor}
|
||||
secondaryColor={secondaryColor}
|
||||
accentColor={accentColor}
|
||||
logoSrc={currentLogoSrc}
|
||||
orgName={orgName}
|
||||
orgTagline={orgTagline}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -15,6 +15,7 @@ interface UserItem {
|
||||
role: string;
|
||||
phoneNumber?: string | null;
|
||||
notificationPreference?: string;
|
||||
twoFactorEnabled?: boolean;
|
||||
isActive: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
@@ -186,6 +187,21 @@ export default function AdminUsersPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const resetTwoFactor = async (id: string, name: string, role: string) => {
|
||||
// Admins and supervisors can't go without 2FA: a reset just makes them set it up
|
||||
// again (on a channel that works) at their next login.
|
||||
const message = role === "admin" || role === "supervisor"
|
||||
? `Reset two-factor authentication for ${name}? Their sessions end, and they'll set it up again with a new code at their next login.`
|
||||
: `Turn off two-factor authentication for ${name}? They will be able to log in with just their password until they turn it back on.`;
|
||||
if (!confirm(message)) return;
|
||||
try {
|
||||
await apiFetch(`/api/users/${encodeURIComponent(id)}`, { method: "PUT", authToken: token!, body: { twoFactorEnabled: false } });
|
||||
await loadUsers(page);
|
||||
} catch (e: any) {
|
||||
setError(e?.message || "Failed to reset two-factor authentication");
|
||||
}
|
||||
};
|
||||
|
||||
const deactivate = async (id: string) => {
|
||||
if (!confirm("Deactivate this user? Their account will be disabled.")) return;
|
||||
try {
|
||||
@@ -352,6 +368,9 @@ export default function AdminUsersPage() {
|
||||
<div className="flex gap-1 flex-wrap">
|
||||
<button className="px-2 py-1 text-xs rounded bg-gray-100 hover:bg-gray-200" onClick={() => startEdit(u)}>Edit</button>
|
||||
<button className="px-2 py-1 text-xs rounded bg-amber-500 text-white hover:bg-amber-600" onClick={() => revokeUserSessions(u.id, u.name)} title="Force this user to sign in again on every device where they're currently logged in">Sign out everywhere</button>
|
||||
{u.twoFactorEnabled && (
|
||||
<button className="px-2 py-1 text-xs rounded bg-amber-500 text-white hover:bg-amber-600" onClick={() => resetTwoFactor(u.id, u.name, u.role)} title={u.role === "admin" || u.role === "supervisor" ? "Reset two-factor authentication — they set it up again at their next login" : "Turn off two-factor authentication for a user who can no longer receive their codes"}>Reset 2FA</button>
|
||||
)}
|
||||
<button className="px-2 py-1 text-xs rounded bg-orange-500 text-white hover:bg-orange-600" onClick={() => deactivate(u.id)} title="Deactivate account">Deactivate</button>
|
||||
<button className="px-2 py-1 text-xs rounded bg-red-700 text-white hover:bg-red-800" onClick={() => deleteUserData(u.id, u.name)} title="Erase personal data">Delete data</button>
|
||||
</div>
|
||||
|
||||
@@ -145,7 +145,7 @@ function EventTicketsContent() {
|
||||
const optVariant = t.registrationOption?.variant?.name;
|
||||
const type = optVariant ? `${optBase} — ${optVariant}` : optBase;
|
||||
const qty = t.quantity || 1;
|
||||
const holder = t.user?.name || t.userId || "";
|
||||
const holder = t.attendee?.name ? `${t.attendee.name}${t.user?.name ? ` (booked by ${t.user.name})` : ""}` : (t.user?.name || t.userId || "");
|
||||
const qrSrc = `https://api.qrserver.com/v1/create-qr-code/?size=140x140&data=${encodeURIComponent(
|
||||
t.qrCode || t.id
|
||||
)}`;
|
||||
|
||||
@@ -36,6 +36,9 @@ export default function TicketScanningPage() {
|
||||
remaining: number;
|
||||
} | null>(null);
|
||||
const [confirmQty, setConfirmQty] = useState(1);
|
||||
// Youth events: the preview says whether parental consent allows check-in.
|
||||
const consentBlocked = !!confirmModal && !['not_required', 'given', undefined].includes(confirmModal.ticket?.consentStatus);
|
||||
const canOverrideConsent = user?.role === 'admin' || user?.role === 'supervisor';
|
||||
const [confirmQtyRaw, setConfirmQtyRaw] = useState("1");
|
||||
|
||||
// Event selection state
|
||||
@@ -165,7 +168,7 @@ export default function TicketScanningPage() {
|
||||
}
|
||||
}
|
||||
|
||||
async function commitScan() {
|
||||
async function commitScan(overrideConsent = false) {
|
||||
if (!confirmModal || !token) return;
|
||||
|
||||
// Capture everything before clearing modal state
|
||||
@@ -199,7 +202,7 @@ export default function TicketScanningPage() {
|
||||
await apiFetch<any>(url, {
|
||||
method: "POST",
|
||||
authToken: token,
|
||||
body: { qty },
|
||||
body: { qty, ...(overrideConsent ? { overrideConsent: true } : {}) },
|
||||
});
|
||||
refreshStatsAndRecent();
|
||||
} catch (e: any) {
|
||||
@@ -375,11 +378,25 @@ export default function TicketScanningPage() {
|
||||
<div className="bg-gray-50 border rounded-lg p-4 mb-4 space-y-1 text-sm">
|
||||
<div><span className="font-medium">Ticket type:</span> {confirmModal.ticket?.registrationOption?.eventOption?.name || 'Ticket'}{confirmModal.ticket?.registrationOption?.variant?.name ? ` — ${confirmModal.ticket.registrationOption.variant.name}` : ''}</div>
|
||||
<div><span className="font-medium">Event:</span> {confirmModal.ticket?.event?.title || '—'}</div>
|
||||
<div><span className="font-medium">Holder:</span> {confirmModal.ticket?.user?.name || confirmModal.ticket?.registrationOption?.registration?.user?.name || '—'}</div>
|
||||
<div><span className="font-medium">Holder:</span> {confirmModal.ticket?.attendee?.name || confirmModal.ticket?.user?.name || confirmModal.ticket?.registrationOption?.registration?.user?.name || '—'}</div>
|
||||
{confirmModal.ticket?.attendee?.name && confirmModal.ticket?.user?.name && (
|
||||
<div className="text-xs text-gray-500">Booked by {confirmModal.ticket.user.name}</div>
|
||||
)}
|
||||
<div><span className="font-medium">Qty on ticket:</span> {confirmModal.ticket?.quantity || 1}</div>
|
||||
<div><span className="font-medium">Remaining:</span> <span className={confirmModal.remaining < (confirmModal.ticket?.quantity || 1) ? 'text-amber-600 font-semibold' : 'text-green-700 font-semibold'}>{confirmModal.remaining}</span></div>
|
||||
</div>
|
||||
|
||||
{consentBlocked && (
|
||||
<div className="mb-4 rounded-lg border border-red-200 bg-red-50 p-3 text-sm text-red-800">
|
||||
<div className="font-semibold">
|
||||
{confirmModal.ticket?.consentStatus === 'declined' ? 'Parental consent was declined'
|
||||
: confirmModal.ticket?.consentStatus === 'pending' ? 'Parental consent is still pending'
|
||||
: 'Parental consent is missing'}
|
||||
</div>
|
||||
<div className="mt-1">This person can't be checked in until a parent or guardian gives consent.{canOverrideConsent ? ' As a supervisor you can override this — it is recorded in the audit log.' : ' Ask a supervisor.'}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(confirmModal.ticket?.quantity || 1) > 1 && (
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Qty to redeem (max {confirmModal.remaining})</label>
|
||||
@@ -408,12 +425,23 @@ export default function TicketScanningPage() {
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={commitScan}
|
||||
className="flex-1 px-4 py-2 rounded-lg bg-brand-600 text-white text-sm font-semibold hover:bg-brand-700"
|
||||
>
|
||||
Confirm Scan
|
||||
</button>
|
||||
{consentBlocked ? (
|
||||
canOverrideConsent && (
|
||||
<button
|
||||
onClick={() => commitScan(true)}
|
||||
className="flex-1 px-4 py-2 rounded-lg bg-red-600 text-white text-sm font-semibold hover:bg-red-700"
|
||||
>
|
||||
Override and check in
|
||||
</button>
|
||||
)
|
||||
) : (
|
||||
<button
|
||||
onClick={() => commitScan()}
|
||||
className="flex-1 px-4 py-2 rounded-lg bg-brand-600 text-white text-sm font-semibold hover:bg-brand-700"
|
||||
>
|
||||
Confirm Scan
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -6,6 +6,8 @@ import { useRouter } from "next/navigation";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import { scoreUser } from "@/lib/fuzzyMatch";
|
||||
import { useDismissingState } from "@/hooks/useDismissingState";
|
||||
import { CouponCodeField, AppliedCoupon } from "@/components/shared/CouponCodeField";
|
||||
import { AttendeeNamesEditor, AttendeeEntries, TicketDelivery, attendeesPayload, consentDetailsMissing } from "@/components/shared/AttendeeNamesEditor";
|
||||
import { DoorOpen, UserPlus, CreditCard, CheckSquare, Ticket, RotateCcw, type LucideIcon } from "lucide-react";
|
||||
|
||||
type Mode = "registration" | "payment" | "checkin" | "tickets" | "refund";
|
||||
@@ -100,6 +102,12 @@ export default function AtTheDoorPage() {
|
||||
const [eventOptions, setEventOptions] = useState<any[]>([]);
|
||||
const [showOptionsModal, setShowOptionsModal] = useState(false);
|
||||
const [pendingUser, setPendingUser] = useState<any | null>(null);
|
||||
const [doorCoupon, setDoorCoupon] = useState<AppliedCoupon | null>(null);
|
||||
const [doorAllowsNames, setDoorAllowsNames] = useState(false);
|
||||
const [doorYouthStart, setDoorYouthStart] = useState<string | null>(null);
|
||||
const [doorNaming, setDoorNaming] = useState(false);
|
||||
const [doorEntries, setDoorEntries] = useState<AttendeeEntries>({});
|
||||
const [doorDelivery, setDoorDelivery] = useState<TicketDelivery>("payer");
|
||||
const [pendingEditReg, setPendingEditReg] = useState<any | null>(null);
|
||||
const [confirming, setConfirming] = useState(false);
|
||||
const [quantities, setQuantities] = useState<Record<string, number>>({});
|
||||
@@ -153,6 +161,8 @@ export default function AtTheDoorPage() {
|
||||
});
|
||||
|
||||
setEventOptions(ev.options || ev.eventOptions || []);
|
||||
setDoorAllowsNames(!!ev.allowNamedAttendees || !!ev.requiresParentalConsent);
|
||||
setDoorYouthStart(ev.requiresParentalConsent ? ev.startDate : null);
|
||||
} catch {
|
||||
setEventOptions([]);
|
||||
}
|
||||
@@ -222,18 +232,37 @@ export default function AtTheDoorPage() {
|
||||
}
|
||||
}
|
||||
|
||||
if (!pendingEditReg && doorYouthStart) {
|
||||
const missing = consentDetailsMissing(eventOptions, (o, v) => quantities[v ? `${o}::${v}` : o] || 0, doorEntries, doorYouthStart);
|
||||
if (missing) { setError(missing); return; }
|
||||
}
|
||||
|
||||
setConfirming(true);
|
||||
try {
|
||||
if (pendingEditReg) {
|
||||
// Edit existing registration — replace options via PUT
|
||||
const res = await apiFetch(`/api/registrations/${pendingEditReg.id}/options`, {
|
||||
let res = await apiFetch(`/api/registrations/${pendingEditReg.id}/options`, {
|
||||
method: "PUT",
|
||||
authToken: token,
|
||||
body: { options: opts }
|
||||
});
|
||||
let message = "Registration updated";
|
||||
if (doorCoupon) {
|
||||
// Applied after the items are saved, so it's worked out on the final items.
|
||||
try {
|
||||
res = await apiFetch(`/api/registrations/${pendingEditReg.id}/coupon`, {
|
||||
method: "POST",
|
||||
authToken: token,
|
||||
body: { code: doorCoupon.code },
|
||||
});
|
||||
message = `Registration updated — code ${doorCoupon.code} applied`;
|
||||
} catch (ce: any) {
|
||||
message = `Registration updated, but the code wasn't applied: ${ce?.message || "unknown error"}`;
|
||||
}
|
||||
}
|
||||
setShowOptionsModal(false);
|
||||
setPendingEditReg(null);
|
||||
setInfo("Registration updated");
|
||||
setInfo(message);
|
||||
handleRegistrationSelected(res);
|
||||
} else if (pendingUser) {
|
||||
// New attendee
|
||||
@@ -242,7 +271,6 @@ export default function AtTheDoorPage() {
|
||||
authToken: token,
|
||||
body: {
|
||||
eventId,
|
||||
guestOnly: pendingUser.guestOnly,
|
||||
user: {
|
||||
name: pendingUser.name,
|
||||
...(pendingUser.email ? { email: pendingUser.email } : {}),
|
||||
@@ -250,9 +278,14 @@ export default function AtTheDoorPage() {
|
||||
},
|
||||
options: opts,
|
||||
notificationPreference: pendingUser.notifPref,
|
||||
...(doorCoupon ? { couponCode: doorCoupon.code } : {}),
|
||||
...(doorAllowsNames && (doorNaming || doorYouthStart) && attendeesPayload(eventOptions, (o, v) => quantities[v ? `${o}::${v}` : o] || 0, doorEntries).length > 0
|
||||
? { attendees: attendeesPayload(eventOptions, (o, v) => quantities[v ? `${o}::${v}` : o] || 0, doorEntries), ticketDelivery: doorDelivery }
|
||||
: {}),
|
||||
}
|
||||
});
|
||||
setShowOptionsModal(false);
|
||||
if (res?.couponError) setError(`Registered, but the code wasn't applied: ${res.couponError}`);
|
||||
handleRegistrationCreated(res);
|
||||
}
|
||||
} catch (e: any) {
|
||||
@@ -282,8 +315,12 @@ export default function AtTheDoorPage() {
|
||||
});
|
||||
setQuantities(qtyMap);
|
||||
setMinQuantities({});
|
||||
setPendingUser({ guestOnly: true, name, email: email || null, phone: phone || null, notifPref });
|
||||
setPendingUser({ name, email: email || null, phone: phone || null, notifPref });
|
||||
setPendingEditReg(null);
|
||||
setDoorCoupon(null);
|
||||
setDoorNaming(false);
|
||||
setDoorEntries({});
|
||||
setDoorDelivery("payer");
|
||||
setShowNewAttendeeModal(false);
|
||||
setShowOptionsModal(true);
|
||||
};
|
||||
@@ -320,6 +357,7 @@ export default function AtTheDoorPage() {
|
||||
setMinQuantities(minQtyMap);
|
||||
setPendingEditReg(freshReg);
|
||||
setPendingUser(null);
|
||||
setDoorCoupon(null);
|
||||
setShowOptionsModal(true);
|
||||
};
|
||||
|
||||
@@ -464,6 +502,18 @@ export default function AtTheDoorPage() {
|
||||
confirming={confirming}
|
||||
isEdit={!!pendingEditReg}
|
||||
totalPaid={(pendingEditReg?.payments || []).reduce((s: number, p: any) => s + (p.amount || 0), 0)}
|
||||
eventId={eventId}
|
||||
token={token}
|
||||
coupon={doorCoupon}
|
||||
setCoupon={setDoorCoupon}
|
||||
couponForUserId={pendingEditReg ? pendingEditReg.userId : null}
|
||||
existingCode={(pendingEditReg?.couponRedemptions || [])[0]?.coupon?.code || null}
|
||||
names={doorAllowsNames && !pendingEditReg ? {
|
||||
enabled: doorNaming, onEnabledChange: setDoorNaming,
|
||||
entries: doorEntries, onEntriesChange: setDoorEntries,
|
||||
delivery: doorDelivery, onDeliveryChange: setDoorDelivery,
|
||||
consentEventStart: doorYouthStart || undefined,
|
||||
} : null}
|
||||
/>
|
||||
<DonationModal
|
||||
open={showDonationModal}
|
||||
@@ -608,8 +658,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 +701,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);
|
||||
@@ -1147,9 +1198,16 @@ function DoorCheckInPanel({ token, eventId, registration, setError, setInfo }: a
|
||||
);
|
||||
}
|
||||
|
||||
function OptionsModal({ open, onClose, options, quantities, setQuantities, minQuantities = {}, onConfirm, confirming, isEdit, totalPaid = 0 }: any) {
|
||||
function OptionsModal({ open, onClose, options, quantities, setQuantities, minQuantities = {}, onConfirm, confirming, isEdit, totalPaid = 0, eventId, token, coupon, setCoupon, couponForUserId, existingCode, names }: any) {
|
||||
if (!open) return null;
|
||||
|
||||
const basket = Object.entries(quantities as Record<string, number>)
|
||||
.filter(([, qty]) => qty > 0)
|
||||
.map(([key, quantity]) => {
|
||||
const [eventOptionId, variantId] = key.split("::");
|
||||
return { eventOptionId, quantity, variantId: variantId || null };
|
||||
});
|
||||
|
||||
const safeOptions: any[] = options || [];
|
||||
|
||||
const total = safeOptions.reduce((sum: number, o: any) => {
|
||||
@@ -1254,8 +1312,31 @@ function OptionsModal({ open, onClose, options, quantities, setQuantities, minQu
|
||||
One or more items are below the quantity already issued as tickets. Please increase the selection.
|
||||
</div>
|
||||
)}
|
||||
{names && (
|
||||
<div className="mb-3 max-h-64 overflow-y-auto">
|
||||
<AttendeeNamesEditor
|
||||
options={safeOptions}
|
||||
qtyFor={(o: string, v: string | null) => quantities[v ? `${o}::${v}` : o] || 0}
|
||||
{...names}
|
||||
payerLabel="the person registering"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{existingCode ? (
|
||||
<div className="mb-3 text-sm text-green-700">Code <span className="font-mono font-semibold">{existingCode}</span> is applied to this registration.</div>
|
||||
) : (
|
||||
<div className="mb-3">
|
||||
<CouponCodeField eventId={eventId} basket={basket} token={token} forUserId={couponForUserId} applied={coupon} onChange={setCoupon} />
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-between items-center">
|
||||
<div className="text-lg font-semibold">Total: R {total.toFixed(2)}</div>
|
||||
<div>
|
||||
{coupon && coupon.discount > 0 && !isEdit && (
|
||||
<div className="text-xs text-gray-500"><span className="line-through">R {coupon.subtotal.toFixed(2)}</span> <span className="text-green-700">−R {coupon.discount.toFixed(2)}</span></div>
|
||||
)}
|
||||
<div className="text-lg font-semibold">Total: R {(coupon && !isEdit ? coupon.total : total).toFixed(2)}</div>
|
||||
{coupon && isEdit && <div className="text-xs text-green-700">Code {coupon.code} is applied when you save</div>}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button onClick={onClose} className="px-4 py-2 border rounded" disabled={confirming}>
|
||||
Cancel
|
||||
@@ -1737,12 +1818,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">
|
||||
|
||||
@@ -0,0 +1,251 @@
|
||||
"use client";
|
||||
|
||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import { formatDate } from "@/lib/date";
|
||||
import { useDismissingState } from "@/hooks/useDismissingState";
|
||||
import { ShieldCheck, Send, RefreshCw, HeartPulse, Printer } from "lucide-react";
|
||||
import { Table, TableHeader, TableBody, TableRow, TableHead, TableCell } from "@/components/ui/table";
|
||||
|
||||
type ConsentRow = {
|
||||
attendeeId: string;
|
||||
name: string;
|
||||
dateOfBirth: string | null;
|
||||
minor: boolean;
|
||||
parentName: string | null;
|
||||
parentEmail: string | null;
|
||||
parentPhone: string | null;
|
||||
registrationStatus: string;
|
||||
bookedBy: { name: string; email: string };
|
||||
status: "not_required" | "missing" | "pending" | "given" | "declined";
|
||||
request: { id: string; status: string; textVersion: number; signedName: string | null; respondedAt: string | null; lastSentAt: string; remindersSent: number; photoConsent: boolean | null; outdated: boolean } | null;
|
||||
};
|
||||
type MedicalRow = {
|
||||
name: string; dateOfBirth: string | null; parentName: string | null; parentPhone: string | null; parentEmail: string | null;
|
||||
medical: { allergies: string; medication: string; conditions: string; medicalAid: string } | null;
|
||||
emergency: { name: string; phone: string; relationship: string } | null;
|
||||
photoConsent: boolean | null; purged: boolean;
|
||||
};
|
||||
|
||||
const STATUS_STYLE: Record<ConsentRow["status"], { label: string; cls: string }> = {
|
||||
not_required: { label: "Not needed (18+)", cls: "bg-gray-100 text-gray-600" },
|
||||
missing: { label: "No request", cls: "bg-red-100 text-red-800" },
|
||||
pending: { label: "Pending", cls: "bg-amber-100 text-amber-800" },
|
||||
given: { label: "Given", cls: "bg-green-100 text-green-800" },
|
||||
declined: { label: "Declined", cls: "bg-red-100 text-red-800" },
|
||||
};
|
||||
|
||||
const esc = (s: unknown) => String(s ?? "").replace(/[&<>"]/g, c => ({ "&": "&", "<": "<", ">": ">", '"': """ }[c] as string));
|
||||
|
||||
export default function ConsentTrackerPage() {
|
||||
const { user, loading, token } = useAuth();
|
||||
const router = useRouter();
|
||||
const search = useSearchParams();
|
||||
const canView = useMemo(() => ["admin", "supervisor", "staff"].includes(user?.role || ""), [user]);
|
||||
const isSupervisor = user?.role === "admin" || user?.role === "supervisor";
|
||||
|
||||
useEffect(() => {
|
||||
if (loading) return;
|
||||
if (!user) router.replace("/login");
|
||||
}, [user, loading, router]);
|
||||
|
||||
const [events, setEvents] = useState<any[]>([]);
|
||||
const [eventId, setEventId] = useState<string>(search.get("eventId") || "");
|
||||
const [data, setData] = useState<{ event: any; counts: Record<string, number>; rows: ConsentRow[] } | null>(null);
|
||||
const [filter, setFilter] = useState<"minors" | "all" | "waiting">("minors");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useDismissingState<string | null>(null);
|
||||
const [info, setInfo] = useDismissingState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!token || !canView) return;
|
||||
apiFetch<any[]>("/api/events/all?includePast=true", { authToken: token })
|
||||
.then(evs => {
|
||||
const youth = (evs || []).filter(e => e.requiresParentalConsent);
|
||||
setEvents(youth);
|
||||
if (!eventId && youth.length) setEventId(youth[youth.length - 1].id);
|
||||
})
|
||||
.catch(e => setError(e?.message || "Failed to load events"));
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [token, canView]);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
if (!token || !eventId) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
setData(await apiFetch(`/api/consent/events/${encodeURIComponent(eventId)}`, { authToken: token }));
|
||||
} catch (e: any) {
|
||||
setError(e?.message || "Failed to load consent status");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}, [token, eventId, setError]);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const rows = useMemo(() => {
|
||||
const all = data?.rows || [];
|
||||
if (filter === "all") return all;
|
||||
if (filter === "waiting") return all.filter(r => ["missing", "pending", "declined"].includes(r.status) || r.request?.outdated);
|
||||
return all.filter(r => r.minor);
|
||||
}, [data, filter]);
|
||||
|
||||
const outdatedCount = (data?.rows || []).filter(r => r.request?.outdated || (r.minor && r.status === "missing")).length;
|
||||
|
||||
const act = async (fn: () => Promise<{ message: string }>) => {
|
||||
setBusy(true);
|
||||
try {
|
||||
const res = await fn();
|
||||
setInfo(res.message);
|
||||
await load();
|
||||
} catch (e: any) {
|
||||
setError(e?.message || "Something went wrong");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const resendOne = (r: ConsentRow) => act(() => apiFetch(`/api/consent/requests/${r.request!.id}/resend`, { method: "POST", authToken: token! }));
|
||||
const resendAll = () => act(() => apiFetch(`/api/consent/events/${eventId}/resend-pending`, { method: "POST", authToken: token! }));
|
||||
const rerequest = () => act(() => apiFetch(`/api/consent/events/${eventId}/rerequest`, { method: "POST", authToken: token! }));
|
||||
|
||||
// Printable list for camp leaders. Opening it is recorded in the audit log.
|
||||
const printMedical = async () => {
|
||||
try {
|
||||
const res = await apiFetch<{ event: any; rows: MedicalRow[] }>(`/api/consent/events/${eventId}/medical`, { authToken: token! });
|
||||
const w = window.open("", "_blank");
|
||||
if (!w) { setError("Allow pop-ups to print the medical list."); return; }
|
||||
const rowsHtml = res.rows.map(r => `
|
||||
<tr>
|
||||
<td><strong>${esc(r.name)}</strong><br/><small>${r.dateOfBirth ? esc(formatDate(new Date(r.dateOfBirth))) : ""}</small></td>
|
||||
<td>${r.purged ? "<em>Deleted after the event</em>" : `
|
||||
${r.medical?.allergies ? `<div><b>Allergies:</b> ${esc(r.medical.allergies)}</div>` : ""}
|
||||
${r.medical?.medication ? `<div><b>Medication:</b> ${esc(r.medical.medication)}</div>` : ""}
|
||||
${r.medical?.conditions ? `<div><b>Conditions:</b> ${esc(r.medical.conditions)}</div>` : ""}
|
||||
${r.medical?.medicalAid ? `<div><b>Medical aid:</b> ${esc(r.medical.medicalAid)}</div>` : ""}`}</td>
|
||||
<td>${r.emergency ? `${esc(r.emergency.name)}${r.emergency.relationship ? ` (${esc(r.emergency.relationship)})` : ""}<br/>${esc(r.emergency.phone)}` : ""}</td>
|
||||
<td>${esc(r.parentName)}<br/>${esc(r.parentPhone)}</td>
|
||||
<td>${r.photoConsent === null ? "" : r.photoConsent ? "Yes" : "No"}</td>
|
||||
</tr>`).join("");
|
||||
w.document.write(`<!doctype html><html><head><meta charset="utf-8"/><title>Medical & emergency list — ${esc(res.event.title)}</title>
|
||||
<style>body{font-family:system-ui,sans-serif;margin:24px;font-size:12px}h1{font-size:18px;margin:0 0 4px}p{color:#555;margin:0 0 12px}
|
||||
table{width:100%;border-collapse:collapse}th,td{border:1px solid #ccc;padding:6px;vertical-align:top;text-align:left}th{background:#f3f4f6}
|
||||
.conf{color:#b91c1c;font-weight:600}</style></head><body>
|
||||
<h1>Medical & emergency list — ${esc(res.event.title)}</h1>
|
||||
<p class="conf">Confidential — for event leaders only. Do not leave unattended; destroy after the event.</p>
|
||||
<table><thead><tr><th>Attendee</th><th>Medical</th><th>Emergency contact</th><th>Parent / guardian</th><th>Photos</th></tr></thead>
|
||||
<tbody>${rowsHtml || '<tr><td colspan="5">No consent given yet.</td></tr>'}</tbody></table>
|
||||
<script>window.onload=()=>window.print()</script></body></html>`);
|
||||
w.document.close();
|
||||
} catch (e: any) {
|
||||
setError(e?.message || "Failed to load the medical list");
|
||||
}
|
||||
};
|
||||
|
||||
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">
|
||||
<ShieldCheck className="w-5 h-5 text-brand-600" />
|
||||
</div>
|
||||
<div>
|
||||
<button className="text-xs text-brand-600 hover:underline" onClick={() => router.push("/dashboard")}>← Back to dashboard</button>
|
||||
<h1 className="text-2xl font-semibold text-gray-900 mt-0.5">Parental consent</h1>
|
||||
<p className="text-sm text-gray-500">Youth events: who still needs a parent's consent before check-in</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!canView && <div className="p-3 border rounded bg-yellow-50 text-yellow-800 text-sm mb-4">You need staff access to view consent status.</div>}
|
||||
{error && <div className="mb-3 p-3 border rounded bg-red-50 text-red-800 text-sm">{error}</div>}
|
||||
{info && <div className="mb-3 p-3 border rounded bg-green-50 text-green-800 text-sm">{info}</div>}
|
||||
|
||||
{canView && (
|
||||
<div className="border rounded-xl p-4 bg-white shadow-sm">
|
||||
<div className="flex flex-wrap items-end gap-3 mb-4">
|
||||
<div>
|
||||
<label className="block text-xs text-gray-600 mb-1">Youth event</label>
|
||||
<select className="border rounded-lg px-3 py-2 text-sm min-w-[16rem]" value={eventId} onChange={e => setEventId(e.target.value)}>
|
||||
{events.length === 0 && <option value="">No youth events</option>}
|
||||
{events.map(ev => <option key={ev.id} value={ev.id}>{ev.title} ({formatDate(new Date(ev.startDate))})</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-gray-600 mb-1">Show</label>
|
||||
<select className="border rounded-lg px-3 py-2 text-sm" value={filter} onChange={e => setFilter(e.target.value as typeof filter)}>
|
||||
<option value="minors">Under 18s</option>
|
||||
<option value="waiting">Still to sort out</option>
|
||||
<option value="all">Everyone</option>
|
||||
</select>
|
||||
</div>
|
||||
{isSupervisor && eventId && (
|
||||
<div className="flex flex-wrap gap-2 ml-auto">
|
||||
<button onClick={resendAll} disabled={busy || !(data?.counts?.pending)} className="inline-flex items-center gap-1.5 px-3 py-2 rounded-lg border text-sm hover:bg-gray-50 disabled:opacity-50"><Send className="w-4 h-4" /> Resend to all pending</button>
|
||||
<button onClick={rerequest} disabled={busy || outdatedCount === 0} title="For consent given on an older version of the text, or under 18s who never got a request" className="inline-flex items-center gap-1.5 px-3 py-2 rounded-lg border text-sm hover:bg-gray-50 disabled:opacity-50"><RefreshCw className="w-4 h-4" /> Re-request consent ({outdatedCount})</button>
|
||||
<button onClick={printMedical} disabled={busy} className="inline-flex items-center gap-1.5 px-3 py-2 rounded-lg bg-brand-600 text-white text-sm hover:bg-brand-700 disabled:opacity-50"><HeartPulse className="w-4 h-4" /> <Printer className="w-4 h-4" /> Medical & emergency list</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{data && (
|
||||
<div className="flex flex-wrap gap-2 mb-3 text-xs">
|
||||
{(["given", "pending", "declined", "missing"] as const).map(s => (
|
||||
<span key={s} className={`px-2 py-1 rounded-full font-medium ${STATUS_STYLE[s].cls}`}>{STATUS_STYLE[s].label}: {data.counts?.[s] || 0}</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Attendee</TableHead>
|
||||
<TableHead>Parent / guardian</TableHead>
|
||||
<TableHead>Consent</TableHead>
|
||||
<TableHead>Booked by</TableHead>
|
||||
<TableHead className="text-right">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{rows.map(r => {
|
||||
const st = STATUS_STYLE[r.status];
|
||||
return (
|
||||
<TableRow key={r.attendeeId}>
|
||||
<TableCell>
|
||||
<div className="font-medium">{r.name}</div>
|
||||
<div className="text-xs text-gray-500">{r.dateOfBirth ? formatDate(new Date(r.dateOfBirth)) : "No date of birth"}</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-sm">
|
||||
{r.parentName ? <>
|
||||
<div>{r.parentName}</div>
|
||||
<div className="text-xs text-gray-500">{[r.parentEmail, r.parentPhone].filter(Boolean).join(" · ")}</div>
|
||||
</> : <span className="text-gray-400">—</span>}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className={`text-xs px-2 py-0.5 rounded-full font-medium ${st.cls}`}>{st.label}</span>
|
||||
{r.request?.outdated && <div className="text-xs text-amber-700 mt-1">Given on an older version of the text</div>}
|
||||
{r.request?.respondedAt && <div className="text-xs text-gray-500 mt-1">{r.request.signedName} · {formatDate(new Date(r.request.respondedAt))}</div>}
|
||||
{r.status === "pending" && r.request && <div className="text-xs text-gray-500 mt-1">Sent {formatDate(new Date(r.request.lastSentAt))}{r.request.remindersSent ? ` · ${r.request.remindersSent} reminder(s)` : ""}</div>}
|
||||
</TableCell>
|
||||
<TableCell className="text-sm">{r.bookedBy?.name}<div className="text-xs text-gray-500">{r.registrationStatus}</div></TableCell>
|
||||
<TableCell className="text-right">
|
||||
{r.status === "pending" && r.request && (
|
||||
<button onClick={() => resendOne(r)} disabled={busy} className="px-2 py-1 text-xs rounded border hover:bg-gray-50">Resend</button>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
{!busy && rows.length === 0 && (
|
||||
<TableRow><TableCell colSpan={5} className="text-center text-sm text-gray-500 py-8">{eventId ? "No attendees to show." : "Pick a youth event."}</TableCell></TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 mt-3">Reminders go out automatically 2 days after a request, then every 3 days until the event. Medical details are deleted a set number of days after the event (Site Settings → Legal).</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,596 @@
|
||||
"use client";
|
||||
|
||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import { useDismissingState } from "@/hooks/useDismissingState";
|
||||
import { TicketPercent, Plus, X, Search } from "lucide-react";
|
||||
import { Table, TableHeader, TableBody, TableRow, TableHead, TableCell } from "@/components/ui/table";
|
||||
|
||||
type CouponType = "percent" | "fixed" | "comp";
|
||||
type Role = "user" | "staff" | "supervisor" | "admin";
|
||||
|
||||
type EventLite = {
|
||||
id: string;
|
||||
title: string;
|
||||
startDate: string;
|
||||
eventOptions: { id: string; name: string; variants?: { id: string; name: string }[] }[];
|
||||
};
|
||||
|
||||
type Coupon = {
|
||||
id: string;
|
||||
code: string;
|
||||
description: string | null;
|
||||
type: CouponType;
|
||||
value: number;
|
||||
stackEarlyBird: boolean;
|
||||
validFrom: string | null;
|
||||
validUntil: string | null;
|
||||
maxUses: number | null;
|
||||
maxUsesPerUser: number | null;
|
||||
minQuantity: number | null;
|
||||
restrictRoles: Role[];
|
||||
restrictDomain: string | null;
|
||||
active: boolean;
|
||||
summary: string;
|
||||
uses: number;
|
||||
totalDiscount: number;
|
||||
events: { eventId: string; event: { id: string; title: string } }[];
|
||||
options: { eventOptionId: string; variantId: string | null; eventOption: { name: string; eventId: string }; variant: { name: string } | null }[];
|
||||
allowedUsers: { userId: string; user: { id: string; name: string; email: string } }[];
|
||||
};
|
||||
|
||||
type Redemption = {
|
||||
id: string;
|
||||
discountAmount: number;
|
||||
createdAt: string;
|
||||
releasedAt: string | null;
|
||||
user: { name: string; email: string };
|
||||
appliedBy: { name: string } | null;
|
||||
registration: { status: string; event: { title: string } };
|
||||
};
|
||||
|
||||
type Draft = {
|
||||
code: string;
|
||||
description: string;
|
||||
type: CouponType;
|
||||
value: string;
|
||||
stackEarlyBird: boolean;
|
||||
validFrom: string;
|
||||
validUntil: string;
|
||||
maxUses: string;
|
||||
maxUsesPerUser: string;
|
||||
minQuantity: string;
|
||||
eventIds: string[];
|
||||
options: { eventOptionId: string; variantId: string | null }[];
|
||||
restrictTo: "anyone" | "roles" | "users" | "domain";
|
||||
restrictRoles: Role[];
|
||||
restrictDomain: string;
|
||||
users: { id: string; name: string; email: string }[];
|
||||
active: boolean;
|
||||
};
|
||||
|
||||
const blankDraft = (): Draft => ({
|
||||
code: "", description: "", type: "percent", value: "", stackEarlyBird: false,
|
||||
validFrom: "", validUntil: "", maxUses: "", maxUsesPerUser: "1", minQuantity: "",
|
||||
eventIds: [], options: [], restrictTo: "anyone", restrictRoles: ["staff", "supervisor", "admin"],
|
||||
restrictDomain: "", users: [], active: true,
|
||||
});
|
||||
|
||||
// <input type="datetime-local"> wants local "YYYY-MM-DDTHH:mm".
|
||||
const toLocalInput = (iso: string | null) => {
|
||||
if (!iso) return "";
|
||||
const d = new Date(iso);
|
||||
const pad = (n: number) => String(n).padStart(2, "0");
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
||||
};
|
||||
|
||||
function draftFromCoupon(c: Coupon): Draft {
|
||||
const restrictTo: Draft["restrictTo"] = c.allowedUsers.length ? "users" : c.restrictRoles.length ? "roles" : c.restrictDomain ? "domain" : "anyone";
|
||||
return {
|
||||
code: c.code, description: c.description || "", type: c.type, value: c.type === "comp" ? "" : String(c.value),
|
||||
stackEarlyBird: c.stackEarlyBird, validFrom: toLocalInput(c.validFrom), validUntil: toLocalInput(c.validUntil),
|
||||
maxUses: c.maxUses ? String(c.maxUses) : "", maxUsesPerUser: c.maxUsesPerUser ? String(c.maxUsesPerUser) : "",
|
||||
minQuantity: c.minQuantity ? String(c.minQuantity) : "", eventIds: c.events.map(e => e.eventId),
|
||||
options: c.options.map(o => ({ eventOptionId: o.eventOptionId, variantId: o.variantId })),
|
||||
restrictTo, restrictRoles: c.restrictRoles.length ? c.restrictRoles : ["staff", "supervisor", "admin"],
|
||||
restrictDomain: c.restrictDomain || "", users: c.allowedUsers.map(u => u.user), active: c.active,
|
||||
};
|
||||
}
|
||||
|
||||
function bodyFromDraft(d: Draft) {
|
||||
return {
|
||||
code: d.code.trim(),
|
||||
description: d.description.trim() || null,
|
||||
type: d.type,
|
||||
value: d.type === "comp" ? 0 : Number(d.value),
|
||||
stackEarlyBird: d.stackEarlyBird,
|
||||
validFrom: d.validFrom ? new Date(d.validFrom).toISOString() : null,
|
||||
validUntil: d.validUntil ? new Date(d.validUntil).toISOString() : null,
|
||||
maxUses: d.maxUses || null,
|
||||
maxUsesPerUser: d.maxUsesPerUser || null,
|
||||
minQuantity: d.minQuantity || null,
|
||||
eventIds: d.eventIds,
|
||||
options: d.options,
|
||||
restrictRoles: d.restrictTo === "roles" ? d.restrictRoles : [],
|
||||
restrictDomain: d.restrictTo === "domain" ? d.restrictDomain.trim() : null,
|
||||
userIds: d.restrictTo === "users" ? d.users.map(u => u.id) : [],
|
||||
active: d.active,
|
||||
};
|
||||
}
|
||||
|
||||
const fmtDate = (iso: string | null) => (iso ? new Date(iso).toLocaleDateString(undefined, { day: "numeric", month: "short", year: "numeric" }) : "");
|
||||
const fmtRand = (n: number) => `R ${(n || 0).toFixed(2)}`;
|
||||
const inputCls = "w-full border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-brand-400";
|
||||
|
||||
function couponStatus(c: Coupon): { label: string; cls: string } {
|
||||
const now = Date.now();
|
||||
if (!c.active) return { label: "Off", cls: "bg-gray-100 text-gray-600" };
|
||||
if (c.validUntil && new Date(c.validUntil).getTime() < now) return { label: "Expired", cls: "bg-gray-100 text-gray-600" };
|
||||
if (c.validFrom && new Date(c.validFrom).getTime() > now) return { label: "Scheduled", cls: "bg-amber-100 text-amber-800" };
|
||||
if (c.maxUses && c.uses >= c.maxUses) return { label: "Used up", cls: "bg-gray-100 text-gray-600" };
|
||||
return { label: "Active", cls: "bg-green-100 text-green-800" };
|
||||
}
|
||||
|
||||
export default function CouponsPage() {
|
||||
const { user, loading, token } = useAuth();
|
||||
const router = useRouter();
|
||||
const canView = useMemo(() => user?.role === "admin" || user?.role === "supervisor", [user]);
|
||||
|
||||
useEffect(() => {
|
||||
if (loading) return;
|
||||
if (!user) router.replace("/login");
|
||||
}, [user, loading, router]);
|
||||
|
||||
const [coupons, setCoupons] = useState<Coupon[]>([]);
|
||||
const [events, setEvents] = useState<EventLite[]>([]);
|
||||
const [fetching, setFetching] = useState(false);
|
||||
const [error, setError] = useDismissingState<string | null>(null);
|
||||
const [info, setInfo] = useDismissingState<string | null>(null);
|
||||
const [query, setQuery] = useState("");
|
||||
const [showOff, setShowOff] = useState(false);
|
||||
|
||||
const [editing, setEditing] = useState<{ id: string | null; draft: Draft } | null>(null);
|
||||
const [viewing, setViewing] = useState<{ coupon: Coupon; redemptions: Redemption[] } | null>(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
if (!token) return;
|
||||
setFetching(true);
|
||||
try {
|
||||
const [c, ev] = await Promise.all([
|
||||
apiFetch<Coupon[]>("/api/coupons", { authToken: token }),
|
||||
apiFetch<EventLite[]>("/api/events/all?includePast=true", { authToken: token }),
|
||||
]);
|
||||
setCoupons(c || []);
|
||||
setEvents(ev || []);
|
||||
} catch (e: any) {
|
||||
setError(e?.message || "Failed to load coupons");
|
||||
} finally {
|
||||
setFetching(false);
|
||||
}
|
||||
}, [token, setError]);
|
||||
|
||||
useEffect(() => { if (token && canView) load(); }, [token, canView, load]);
|
||||
|
||||
const visible = useMemo(() => {
|
||||
const q = query.trim().toLowerCase();
|
||||
return coupons.filter(c => (showOff || c.active) && (!q || c.code.toLowerCase().includes(q) || (c.description || "").toLowerCase().includes(q)));
|
||||
}, [coupons, query, showOff]);
|
||||
|
||||
const toggleActive = async (c: Coupon) => {
|
||||
try {
|
||||
await apiFetch(`/api/coupons/${c.id}`, { method: "PUT", authToken: token!, body: { active: !c.active } });
|
||||
setInfo(`${c.code} turned ${c.active ? "off" : "on"}.`);
|
||||
load();
|
||||
} catch (e: any) {
|
||||
setError(e?.message || "Failed to update coupon");
|
||||
}
|
||||
};
|
||||
|
||||
const openUses = async (c: Coupon) => {
|
||||
try {
|
||||
const full = await apiFetch<Coupon & { redemptions: Redemption[] }>(`/api/coupons/${c.id}`, { authToken: token! });
|
||||
setViewing({ coupon: c, redemptions: full.redemptions || [] });
|
||||
} catch (e: any) {
|
||||
setError(e?.message || "Failed to load uses");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-6xl mx-auto w-full p-6">
|
||||
<div className="flex items-center justify-between gap-3 mb-4 flex-wrap">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-xl bg-brand-50 flex items-center justify-center shrink-0">
|
||||
<TicketPercent className="w-5 h-5 text-brand-600" />
|
||||
</div>
|
||||
<div>
|
||||
<button className="text-xs text-brand-600 hover:underline" onClick={() => router.push("/dashboard")}>← Back to dashboard</button>
|
||||
<h1 className="text-2xl font-semibold text-gray-900 mt-0.5">Coupon codes</h1>
|
||||
<p className="text-sm text-gray-500">Discounts for staff, leaders, volunteers and promotions</p>
|
||||
</div>
|
||||
</div>
|
||||
{canView && (
|
||||
<button
|
||||
onClick={() => setEditing({ id: null, draft: blankDraft() })}
|
||||
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-brand-600 text-white text-sm font-medium hover:bg-brand-700"
|
||||
>
|
||||
<Plus className="w-4 h-4" /> New code
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!canView && (
|
||||
<div className="p-3 border rounded bg-yellow-50 text-yellow-800 text-sm mb-4">You need supervisor access to manage coupon codes.</div>
|
||||
)}
|
||||
{error && <div className="mb-3 p-3 border rounded bg-red-50 text-red-800 text-sm">{error}</div>}
|
||||
{info && <div className="mb-3 p-3 border rounded bg-green-50 text-green-800 text-sm">{info}</div>}
|
||||
|
||||
{canView && (
|
||||
<div className="border rounded-xl p-4 bg-white shadow-sm">
|
||||
<div className="flex flex-wrap items-center gap-3 mb-3">
|
||||
<div className="relative w-full sm:w-64">
|
||||
<Search className="w-4 h-4 text-gray-400 absolute left-2.5 top-2.5" />
|
||||
<input className={`${inputCls} pl-8`} placeholder="Search codes" value={query} onChange={e => setQuery(e.target.value)} />
|
||||
</div>
|
||||
<label className="flex items-center gap-2 text-sm text-gray-700">
|
||||
<input type="checkbox" checked={showOff} onChange={e => setShowOff(e.target.checked)} /> Show codes that are off
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Code</TableHead>
|
||||
<TableHead>Discount</TableHead>
|
||||
<TableHead>Events</TableHead>
|
||||
<TableHead>Uses</TableHead>
|
||||
<TableHead>Valid</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead className="text-right">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{visible.map(c => {
|
||||
const status = couponStatus(c);
|
||||
return (
|
||||
<TableRow key={c.id}>
|
||||
<TableCell>
|
||||
<div className="font-mono font-semibold text-gray-900">{c.code}</div>
|
||||
{c.description && <div className="text-xs text-gray-500">{c.description}</div>}
|
||||
{(c.restrictRoles.length > 0 || c.allowedUsers.length > 0 || c.restrictDomain) && (
|
||||
<div className="text-xs text-amber-700 mt-0.5">
|
||||
Restricted: {c.allowedUsers.length ? `${c.allowedUsers.length} person${c.allowedUsers.length === 1 ? "" : "s"}` : c.restrictRoles.length ? c.restrictRoles.join(", ") : `@${c.restrictDomain}`}
|
||||
</div>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div>{c.summary}</div>
|
||||
{c.options.length > 0 && <div className="text-xs text-gray-500">{c.options.map(o => o.eventOption.name + (o.variant ? ` (${o.variant.name})` : "")).join(", ")}</div>}
|
||||
</TableCell>
|
||||
<TableCell className="text-sm">{c.events.length ? c.events.map(e => e.event.title).join(", ") : <span className="text-gray-500">All events</span>}</TableCell>
|
||||
<TableCell className="text-sm">
|
||||
{c.uses}{c.maxUses ? ` / ${c.maxUses}` : ""}
|
||||
<div className="text-xs text-gray-500">{fmtRand(c.totalDiscount)} given</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-xs text-gray-600">
|
||||
{c.validFrom || c.validUntil ? `${c.validFrom ? fmtDate(c.validFrom) : "Now"} – ${c.validUntil ? fmtDate(c.validUntil) : "no end"}` : "Always"}
|
||||
</TableCell>
|
||||
<TableCell><span className={`text-xs px-2 py-0.5 rounded-full font-medium ${status.cls}`}>{status.label}</span></TableCell>
|
||||
<TableCell className="text-right whitespace-nowrap">
|
||||
<button className="px-2 py-1 text-xs rounded border hover:bg-gray-50 mr-1" onClick={() => openUses(c)}>Uses</button>
|
||||
<button className="px-2 py-1 text-xs rounded border hover:bg-gray-50 mr-1" onClick={() => setEditing({ id: c.id, draft: draftFromCoupon(c) })}>Edit</button>
|
||||
<button className={`px-2 py-1 text-xs rounded text-white ${c.active ? "bg-amber-500 hover:bg-amber-600" : "bg-green-600 hover:bg-green-700"}`} onClick={() => toggleActive(c)}>
|
||||
{c.active ? "Turn off" : "Turn on"}
|
||||
</button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
{!fetching && visible.length === 0 && (
|
||||
<TableRow><TableCell colSpan={7} className="text-center text-sm text-gray-500 py-8">No coupon codes yet.</TableCell></TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{editing && (
|
||||
<CouponModal
|
||||
token={token!}
|
||||
events={events}
|
||||
couponId={editing.id}
|
||||
initial={editing.draft}
|
||||
onClose={() => setEditing(null)}
|
||||
onSaved={(msg) => { setEditing(null); setInfo(msg); load(); }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{viewing && (
|
||||
<div className="fixed inset-0 z-50 bg-black/40 flex items-center justify-center p-4" onClick={() => setViewing(null)}>
|
||||
<div className="bg-white rounded-xl shadow-xl w-full max-w-2xl max-h-[85vh] flex flex-col" onClick={e => e.stopPropagation()}>
|
||||
<div className="flex items-center justify-between p-4 border-b">
|
||||
<h2 className="font-semibold">Uses of <span className="font-mono">{viewing.coupon.code}</span></h2>
|
||||
<button onClick={() => setViewing(null)} aria-label="Close"><X className="w-5 h-5 text-gray-500" /></button>
|
||||
</div>
|
||||
<div className="p-4 overflow-y-auto">
|
||||
{viewing.redemptions.length === 0 ? (
|
||||
<p className="text-sm text-gray-500">This code hasn't been used yet.</p>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow><TableHead>Person</TableHead><TableHead>Event</TableHead><TableHead>Discount</TableHead><TableHead>When</TableHead></TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{viewing.redemptions.map(r => (
|
||||
<TableRow key={r.id} className={r.releasedAt ? "opacity-60" : ""}>
|
||||
<TableCell>
|
||||
<div className="text-sm">{r.user.name}</div>
|
||||
<div className="text-xs text-gray-500">{r.user.email}</div>
|
||||
{r.appliedBy && <div className="text-xs text-gray-500">Applied by {r.appliedBy.name}</div>}
|
||||
</TableCell>
|
||||
<TableCell className="text-sm">{r.registration.event.title}</TableCell>
|
||||
<TableCell className="text-sm">
|
||||
{fmtRand(r.discountAmount)}
|
||||
{r.releasedAt && <div className="text-xs text-gray-500">Released (cancelled)</div>}
|
||||
</TableCell>
|
||||
<TableCell className="text-xs text-gray-600">{fmtDate(r.createdAt)}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CouponModal({ token, events, couponId, initial, onClose, onSaved }: {
|
||||
token: string;
|
||||
events: EventLite[];
|
||||
couponId: string | null;
|
||||
initial: Draft;
|
||||
onClose: () => void;
|
||||
onSaved: (message: string) => void;
|
||||
}) {
|
||||
const [d, setD] = useState<Draft>(initial);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [userQuery, setUserQuery] = useState("");
|
||||
const [userResults, setUserResults] = useState<{ id: string; name: string; email: string }[]>([]);
|
||||
const set = <K extends keyof Draft>(key: K, value: Draft[K]) => setD(prev => ({ ...prev, [key]: value }));
|
||||
|
||||
// Ticket options only make sense within the chosen events.
|
||||
const scopedEvents = useMemo(() => events.filter(e => d.eventIds.includes(e.id)), [events, d.eventIds]);
|
||||
|
||||
useEffect(() => {
|
||||
if (d.restrictTo !== "users" || userQuery.trim().length < 2) { setUserResults([]); return; }
|
||||
const t = setTimeout(async () => {
|
||||
try {
|
||||
const res = await apiFetch<{ data: { id: string; name: string; email: string }[] }>(`/api/users?search=${encodeURIComponent(userQuery.trim())}&limit=8`, { authToken: token });
|
||||
setUserResults(res?.data || []);
|
||||
} catch { setUserResults([]); }
|
||||
}, 300);
|
||||
return () => clearTimeout(t);
|
||||
}, [userQuery, d.restrictTo, token]);
|
||||
|
||||
const toggleEvent = (id: string) => {
|
||||
const eventIds = d.eventIds.includes(id) ? d.eventIds.filter(x => x !== id) : [...d.eventIds, id];
|
||||
// Drop scoped options from events that are no longer selected.
|
||||
const keepOptionIds = new Set(events.filter(e => eventIds.includes(e.id)).flatMap(e => e.eventOptions.map(o => o.id)));
|
||||
setD(prev => ({ ...prev, eventIds, options: prev.options.filter(o => keepOptionIds.has(o.eventOptionId)) }));
|
||||
};
|
||||
|
||||
const scopeKey = (o: { eventOptionId: string; variantId: string | null }) => `${o.eventOptionId}::${o.variantId || ""}`;
|
||||
const toggleScope = (eventOptionId: string, variantId: string | null) => {
|
||||
const key = scopeKey({ eventOptionId, variantId });
|
||||
set("options", d.options.some(o => scopeKey(o) === key) ? d.options.filter(o => scopeKey(o) !== key) : [...d.options, { eventOptionId, variantId }]);
|
||||
};
|
||||
|
||||
const save = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
if (d.restrictTo === "users" && d.users.length === 0) { setError("Add at least one person, or choose another restriction."); return; }
|
||||
setSaving(true);
|
||||
try {
|
||||
const body = bodyFromDraft(d);
|
||||
if (couponId) {
|
||||
await apiFetch(`/api/coupons/${couponId}`, { method: "PUT", authToken: token, body });
|
||||
onSaved(`${body.code.toUpperCase()} saved.`);
|
||||
} else {
|
||||
await apiFetch(`/api/coupons`, { method: "POST", authToken: token, body });
|
||||
onSaved(`${body.code.toUpperCase()} created.`);
|
||||
}
|
||||
} catch (err: any) {
|
||||
setError(err?.message || "Failed to save");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 bg-black/40 flex items-center justify-center p-4" onClick={onClose}>
|
||||
<form onSubmit={save} className="bg-white rounded-xl shadow-xl w-full max-w-2xl max-h-[90vh] flex flex-col" onClick={e => e.stopPropagation()}>
|
||||
<div className="flex items-center justify-between p-4 border-b">
|
||||
<h2 className="font-semibold">{couponId ? "Edit code" : "New coupon code"}</h2>
|
||||
<button type="button" onClick={onClose} aria-label="Close"><X className="w-5 h-5 text-gray-500" /></button>
|
||||
</div>
|
||||
|
||||
<div className="p-4 space-y-5 overflow-y-auto">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Code</label>
|
||||
<input className={`${inputCls} font-mono uppercase`} value={d.code} onChange={e => set("code", e.target.value.toUpperCase().replace(/[^A-Z0-9_-]/g, ""))} placeholder="STAFF50" required maxLength={32} />
|
||||
<p className="text-xs text-gray-500 mt-1">Letters, numbers, - and _. Not case-sensitive when entered.</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Description (internal)</label>
|
||||
<input className={inputCls} value={d.description} onChange={e => set("description", e.target.value)} placeholder="Staff discount for Camp 2026" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Discount type</label>
|
||||
<select className={inputCls} value={d.type} onChange={e => set("type", e.target.value as CouponType)}>
|
||||
<option value="percent">Percentage</option>
|
||||
<option value="fixed">Fixed amount (R)</option>
|
||||
<option value="comp">Free (100%)</option>
|
||||
</select>
|
||||
</div>
|
||||
{d.type !== "comp" && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">{d.type === "percent" ? "Percent off" : "Amount off (R)"}</label>
|
||||
<input type="number" min="0" step={d.type === "percent" ? "1" : "0.01"} max={d.type === "percent" ? 100 : undefined} className={inputCls} value={d.value} onChange={e => set("value", e.target.value)} required />
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-end">
|
||||
<label className="flex items-start gap-2 text-sm text-gray-700">
|
||||
<input type="checkbox" className="mt-0.5" checked={d.stackEarlyBird} onChange={e => set("stackEarlyBird", e.target.checked)} />
|
||||
<span>Also apply on top of early-bird prices <span className="block text-xs text-gray-500">Off: the better of the two prices wins.</span></span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Events</label>
|
||||
<p className="text-xs text-gray-500 mb-2">Leave all unticked for every event.</p>
|
||||
<div className="max-h-40 overflow-y-auto border rounded-lg divide-y">
|
||||
{events.map(ev => (
|
||||
<label key={ev.id} className="flex items-center gap-2 px-3 py-2 text-sm">
|
||||
<input type="checkbox" checked={d.eventIds.includes(ev.id)} onChange={() => toggleEvent(ev.id)} />
|
||||
<span>{ev.title}</span>
|
||||
<span className="text-xs text-gray-500 ml-auto">{fmtDate(ev.startDate)}</span>
|
||||
</label>
|
||||
))}
|
||||
{events.length === 0 && <p className="px-3 py-2 text-sm text-gray-500">No events.</p>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{scopedEvents.length > 0 && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Only these tickets / items</label>
|
||||
<p className="text-xs text-gray-500 mb-2">Leave all unticked to discount the whole registration.</p>
|
||||
<div className="space-y-2">
|
||||
{scopedEvents.map(ev => (
|
||||
<div key={ev.id} className="border rounded-lg p-3">
|
||||
<div className="text-xs font-semibold text-gray-600 mb-1">{ev.title}</div>
|
||||
{ev.eventOptions.map(o => (
|
||||
<div key={o.id}>
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input type="checkbox" checked={d.options.some(s => scopeKey(s) === scopeKey({ eventOptionId: o.id, variantId: null }))} onChange={() => toggleScope(o.id, null)} />
|
||||
{o.name}{(o.variants || []).length > 0 && <span className="text-xs text-gray-500">(all variants)</span>}
|
||||
</label>
|
||||
{(o.variants || []).map(v => (
|
||||
<label key={v.id} className="flex items-center gap-2 text-sm ml-6">
|
||||
<input type="checkbox" checked={d.options.some(s => scopeKey(s) === scopeKey({ eventOptionId: o.id, variantId: v.id }))} onChange={() => toggleScope(o.id, v.id)} />
|
||||
{v.name}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Valid from</label>
|
||||
<input type="datetime-local" className={inputCls} value={d.validFrom} onChange={e => set("validFrom", e.target.value)} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Valid until</label>
|
||||
<input type="datetime-local" className={inputCls} value={d.validUntil} onChange={e => set("validUntil", e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Max total uses</label>
|
||||
<input type="number" min="1" className={inputCls} value={d.maxUses} onChange={e => set("maxUses", e.target.value)} placeholder="Unlimited" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Max uses per person</label>
|
||||
<input type="number" min="1" className={inputCls} value={d.maxUsesPerUser} onChange={e => set("maxUsesPerUser", e.target.value)} placeholder="Unlimited" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Minimum tickets</label>
|
||||
<input type="number" min="1" className={inputCls} value={d.minQuantity} onChange={e => set("minQuantity", e.target.value)} placeholder="None" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Who can use it</label>
|
||||
<select className={inputCls} value={d.restrictTo} onChange={e => set("restrictTo", e.target.value as Draft["restrictTo"])}>
|
||||
<option value="anyone">Anyone with the code</option>
|
||||
<option value="roles">People with certain roles</option>
|
||||
<option value="users">Specific people</option>
|
||||
<option value="domain">An email domain</option>
|
||||
</select>
|
||||
{d.restrictTo !== "anyone" && (
|
||||
<p className="text-xs text-gray-500 mt-1">Restricted codes need the person to be logged in (or registered by staff), so they can't be shared in group chats.</p>
|
||||
)}
|
||||
|
||||
{d.restrictTo === "roles" && (
|
||||
<div className="flex flex-wrap gap-4 mt-2">
|
||||
{(["user", "staff", "supervisor", "admin"] as Role[]).map(r => (
|
||||
<label key={r} className="flex items-center gap-2 text-sm capitalize">
|
||||
<input type="checkbox" checked={d.restrictRoles.includes(r)} onChange={() => set("restrictRoles", d.restrictRoles.includes(r) ? d.restrictRoles.filter(x => x !== r) : [...d.restrictRoles, r])} />
|
||||
{r === "user" ? "Member" : r}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{d.restrictTo === "domain" && (
|
||||
<input className={`${inputCls} mt-2`} value={d.restrictDomain} onChange={e => set("restrictDomain", e.target.value)} placeholder="church.org" required />
|
||||
)}
|
||||
|
||||
{d.restrictTo === "users" && (
|
||||
<div className="mt-2">
|
||||
<div className="flex flex-wrap gap-2 mb-2">
|
||||
{d.users.map(u => (
|
||||
<span key={u.id} className="inline-flex items-center gap-1 text-xs bg-brand-50 text-brand-800 rounded-full px-2 py-1">
|
||||
{u.name}
|
||||
<button type="button" onClick={() => set("users", d.users.filter(x => x.id !== u.id))} aria-label={`Remove ${u.name}`}><X className="w-3 h-3" /></button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<input className={inputCls} value={userQuery} onChange={e => setUserQuery(e.target.value)} placeholder="Search by name, email or phone" />
|
||||
{userResults.length > 0 && (
|
||||
<div className="border rounded-lg mt-1 divide-y max-h-40 overflow-y-auto">
|
||||
{userResults.filter(r => !d.users.some(u => u.id === r.id)).map(r => (
|
||||
<button type="button" key={r.id} className="w-full text-left px-3 py-2 text-sm hover:bg-gray-50" onClick={() => { set("users", [...d.users, r]); setUserQuery(""); }}>
|
||||
{r.name} <span className="text-xs text-gray-500">{r.email}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<label className="flex items-center gap-2 text-sm text-gray-700">
|
||||
<input type="checkbox" checked={d.active} onChange={e => set("active", e.target.checked)} /> Active
|
||||
</label>
|
||||
|
||||
{error && <div className="p-3 border rounded bg-red-50 text-red-800 text-sm">{error}</div>}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-3 p-4 border-t">
|
||||
<button type="button" onClick={onClose} className="px-4 py-2 rounded-lg bg-gray-100 text-sm font-medium hover:bg-gray-200">Cancel</button>
|
||||
<button type="submit" disabled={saving} className="px-4 py-2 rounded-lg bg-brand-600 text-white text-sm font-medium hover:bg-brand-700 disabled:opacity-50">
|
||||
{saving ? "Saving…" : couponId ? "Save changes" : "Create code"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 ────────────────────────────────────────────────────────────────
|
||||
@@ -49,6 +50,7 @@ type Variant = {
|
||||
name: string;
|
||||
price: string; // empty → use option base price
|
||||
stockLimit: string;
|
||||
staffPrice?: string; // empty → use the option's staff price
|
||||
earlyBirdTiers: Tier[];
|
||||
};
|
||||
|
||||
@@ -58,6 +60,7 @@ type OptionDraft = {
|
||||
price: string;
|
||||
isMainTicket: boolean;
|
||||
stockLimit: string;
|
||||
staffPrice?: string; // empty → no staff price
|
||||
earlyBirdTiers: Tier[]; // option-level (used only when no variants)
|
||||
variants: Variant[];
|
||||
};
|
||||
@@ -192,6 +195,11 @@ function OptionsEditor({ options, onChange, required }: { options: OptionDraft[]
|
||||
<input type="number" step="1" min="0" className="border rounded px-2 py-1.5 text-sm w-24"
|
||||
value={opt.stockLimit} onChange={e => upd(i, { stockLimit: e.target.value })} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-[10px] text-gray-500 mb-0.5" title="Staff and above pay this automatically when it's lower than the price they'd otherwise pay">Staff price (R)</label>
|
||||
<input type="number" step="1" min="0" className="border rounded px-2 py-1.5 text-sm w-24" placeholder="(none)"
|
||||
value={opt.staffPrice ?? ""} onChange={e => upd(i, { staffPrice: e.target.value })} />
|
||||
</div>
|
||||
<div className="flex items-center gap-3 pb-1">
|
||||
<label className="text-xs flex items-center gap-1 cursor-pointer whitespace-nowrap">
|
||||
<input type="checkbox" checked={opt.isMainTicket} onChange={e => upd(i, { isMainTicket: e.target.checked })} /> Main ticket
|
||||
@@ -252,6 +260,11 @@ function VariantsEditor({ options, onChange }: { options: OptionDraft[]; onChang
|
||||
<input type="number" step="1" min="0" className="border rounded px-2 py-1 text-xs w-20"
|
||||
value={v.stockLimit} onChange={e => updVariant(vi, { stockLimit: e.target.value })} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-[10px] text-gray-500 mb-0.5">Staff price</label>
|
||||
<input type="number" step="1" min="0" className="border rounded px-2 py-1 text-xs w-20" placeholder="(option)"
|
||||
value={v.staffPrice ?? ""} onChange={e => updVariant(vi, { staffPrice: e.target.value })} />
|
||||
</div>
|
||||
<button type="button" className="text-xs px-2 py-1 rounded bg-red-50 text-red-700 border border-red-200 hover:bg-red-100 self-end" onClick={() => remVariant(vi)}>✕</button>
|
||||
</div>
|
||||
))}
|
||||
@@ -755,12 +768,22 @@ 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; allowNamedAttendees: boolean;
|
||||
requiresParentalConsent: boolean; consentText: string;
|
||||
consentCollectMedical: boolean; consentCollectEmergency: boolean; consentCollectPhoto: boolean;
|
||||
waitlistEnabled: boolean; waitlistOfferHours: string; waitlistPartialOffers: boolean;
|
||||
}
|
||||
|
||||
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, allowNamedAttendees: false,
|
||||
requiresParentalConsent: false, consentText: "",
|
||||
consentCollectMedical: true, consentCollectEmergency: true, consentCollectPhoto: true,
|
||||
waitlistEnabled: false, waitlistOfferHours: "24", waitlistPartialOffers: true,
|
||||
});
|
||||
|
||||
const blankOptions = (): OptionDraft[] => [
|
||||
@@ -779,19 +802,29 @@ 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,
|
||||
allowNamedAttendees: !!ev.allowNamedAttendees,
|
||||
requiresParentalConsent: !!ev.requiresParentalConsent, consentText: ev.consentText || "",
|
||||
consentCollectMedical: !!ev.consentCollectMedical, consentCollectEmergency: !!ev.consentCollectEmergency, consentCollectPhoto: !!ev.consentCollectPhoto,
|
||||
waitlistEnabled: !!ev.waitlistEnabled, waitlistOfferHours: String(ev.waitlistOfferHours ?? 24), waitlistPartialOffers: ev.waitlistPartialOffers !== 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[]>(() => {
|
||||
@@ -804,6 +837,7 @@ function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) {
|
||||
price: String(o.price ?? ""),
|
||||
isMainTicket: !!o.isMainTicket,
|
||||
stockLimit: String(o.stockLimit ?? "0"),
|
||||
staffPrice: o.staffPrice !== null && o.staffPrice !== undefined ? String(o.staffPrice) : "",
|
||||
// Option-level tiers only (no variantId)
|
||||
earlyBirdTiers: allTiers
|
||||
.filter((t: any) => !t.variantId)
|
||||
@@ -812,6 +846,7 @@ function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) {
|
||||
id: v.id, name: v.name,
|
||||
price: v.price !== null && v.price !== undefined ? String(v.price) : "",
|
||||
stockLimit: String(v.stockLimit ?? "0"),
|
||||
staffPrice: v.staffPrice !== null && v.staffPrice !== undefined ? String(v.staffPrice) : "",
|
||||
// Variant-level tiers — filter by variantId
|
||||
earlyBirdTiers: allTiers
|
||||
.filter((t: any) => t.variantId === v.id)
|
||||
@@ -873,6 +908,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
|
||||
@@ -886,13 +932,14 @@ function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) {
|
||||
id: v.id, name: v.name,
|
||||
price: v.price !== "" ? parseFloat(v.price) : null,
|
||||
stockLimit: parseInt(v.stockLimit || "0"), order: vi,
|
||||
staffPrice: v.staffPrice ? parseFloat(v.staffPrice) : null,
|
||||
}));
|
||||
|
||||
if (opt.id) {
|
||||
// Update option + variants first to get/confirm variant IDs
|
||||
const updated: any = await apiFetch(`/api/events/options/${opt.id}`, {
|
||||
method: "PUT", authToken: token || undefined,
|
||||
body: { name: opt.name, price: parseFloat(opt.price || "0"), isMainTicket: opt.isMainTicket, stockLimit: parseInt(opt.stockLimit || "0"), variants: variantsPayload }
|
||||
body: { name: opt.name, price: parseFloat(opt.price || "0"), isMainTicket: opt.isMainTicket, stockLimit: parseInt(opt.stockLimit || "0"), staffPrice: opt.staffPrice ? parseFloat(opt.staffPrice) : null, variants: variantsPayload }
|
||||
});
|
||||
|
||||
// Build variant tiers with confirmed IDs from response
|
||||
@@ -924,7 +971,7 @@ function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) {
|
||||
// New option added during edit
|
||||
const created: any = await apiFetch(`/api/events/${eventId}/options`, {
|
||||
method: "POST", authToken: token || undefined,
|
||||
body: { name: opt.name, price: parseFloat(opt.price || "0"), isMainTicket: opt.isMainTicket, stockLimit: parseInt(opt.stockLimit || "0") }
|
||||
body: { name: opt.name, price: parseFloat(opt.price || "0"), isMainTicket: opt.isMainTicket, stockLimit: parseInt(opt.stockLimit || "0"), staffPrice: opt.staffPrice ? parseFloat(opt.staffPrice) : null }
|
||||
});
|
||||
if (created?.id) {
|
||||
const updated: any = await apiFetch(`/api/events/options/${created.id}`, {
|
||||
@@ -962,6 +1009,7 @@ function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) {
|
||||
const variantsPayload = opt.variants.map((v, vi) => ({
|
||||
name: v.name, price: v.price !== "" ? parseFloat(v.price) : null,
|
||||
stockLimit: parseInt(v.stockLimit || "0"), order: vi,
|
||||
staffPrice: v.staffPrice ? parseFloat(v.staffPrice) : null,
|
||||
}));
|
||||
|
||||
let optId: string;
|
||||
@@ -970,13 +1018,13 @@ function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) {
|
||||
// Update the auto-created main ticket
|
||||
await apiFetch(`/api/events/options/${existingMain.id}`, {
|
||||
method: "PUT", authToken: token || undefined,
|
||||
body: { name: opt.name, price: parseFloat(opt.price || "0"), isMainTicket: true, stockLimit: parseInt(opt.stockLimit || "0") }
|
||||
body: { name: opt.name, price: parseFloat(opt.price || "0"), isMainTicket: true, stockLimit: parseInt(opt.stockLimit || "0"), staffPrice: opt.staffPrice ? parseFloat(opt.staffPrice) : null }
|
||||
});
|
||||
optId = existingMain.id;
|
||||
} else {
|
||||
const created: any = await apiFetch(`/api/events/${eventId}/options`, {
|
||||
method: "POST", authToken: token || undefined,
|
||||
body: { name: opt.name, price: parseFloat(opt.price || "0"), isMainTicket: opt.isMainTicket, stockLimit: parseInt(opt.stockLimit || "0") }
|
||||
body: { name: opt.name, price: parseFloat(opt.price || "0"), isMainTicket: opt.isMainTicket, stockLimit: parseInt(opt.stockLimit || "0"), staffPrice: opt.staffPrice ? parseFloat(opt.staffPrice) : null }
|
||||
});
|
||||
optId = created?.id;
|
||||
}
|
||||
@@ -1032,7 +1080,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 +1091,23 @@ 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,
|
||||
allowNamedAttendees: draft.allowNamedAttendees || draft.requiresParentalConsent,
|
||||
requiresParentalConsent: draft.requiresParentalConsent,
|
||||
consentText: draft.consentText,
|
||||
consentCollectMedical: draft.consentCollectMedical,
|
||||
consentCollectEmergency: draft.consentCollectEmergency,
|
||||
consentCollectPhoto: draft.consentCollectPhoto,
|
||||
waitlistEnabled: draft.waitlistEnabled,
|
||||
waitlistOfferHours: parseInt(draft.waitlistOfferHours, 10) || 24,
|
||||
waitlistPartialOffers: draft.waitlistPartialOffers,
|
||||
contactName: draft.contactName || undefined,
|
||||
contactPhone: draft.contactPhone || undefined,
|
||||
contactEmail: draft.contactEmail || undefined,
|
||||
location: draft.location.trim(),
|
||||
};
|
||||
|
||||
if (mode === "edit") {
|
||||
@@ -1115,10 +1177,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 ──
|
||||
@@ -1190,10 +1253,44 @@ function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) {
|
||||
<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">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>}
|
||||
<label className="block text-xs text-gray-600 mb-1">Location</label>
|
||||
<input className="w-full border rounded px-3 py-2 text-sm" value={draft.location} onChange={e => upd({ location: e.target.value })} placeholder="e.g. 123 Church St, City" />
|
||||
<p className="text-[10px] text-gray-400 mt-0.5">Defaults to your organisation's address — shown to attendees with a map link.</p>
|
||||
</div>
|
||||
<div className="flex items-start gap-2 p-3 border rounded bg-gray-50">
|
||||
<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" />}
|
||||
@@ -1261,6 +1358,10 @@ function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) {
|
||||
<input type="checkbox" className="mt-0.5" checked={!draft.requiresAuth} onChange={e => upd({ requiresAuth: !e.target.checked })} />
|
||||
<span><strong>Allow guest registration</strong> — attendees can register without creating an account</span>
|
||||
</label>
|
||||
<label className="flex items-start gap-2 text-sm cursor-pointer">
|
||||
<input type="checkbox" className="mt-0.5" checked={draft.allowNamedAttendees} onChange={e => upd({ allowNamedAttendees: e.target.checked })} />
|
||||
<span><strong>Named attendees (group tickets)</strong> — someone registering several people can give each a name, so each gets their own ticket and attendee form</span>
|
||||
</label>
|
||||
{mode === "edit" && (
|
||||
<label className="flex items-start gap-2 text-sm cursor-pointer">
|
||||
<input type="checkbox" className="mt-0.5" checked={draft.isActive} onChange={e => upd({ isActive: e.target.checked })} />
|
||||
@@ -1268,6 +1369,49 @@ function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) {
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="border rounded-lg p-4 bg-gray-50 space-y-3">
|
||||
<div className="text-xs font-semibold text-gray-500 uppercase tracking-wide mb-1">Waitlist</div>
|
||||
<label className="flex items-start gap-2 text-sm cursor-pointer">
|
||||
<input type="checkbox" className="mt-0.5" checked={draft.waitlistEnabled} onChange={e => upd({ waitlistEnabled: e.target.checked })} />
|
||||
<span><strong>Waitlist for sold-out tickets</strong> — people can join a queue; when places free up, the next person is offered them and has a set time to claim</span>
|
||||
</label>
|
||||
{draft.waitlistEnabled && (
|
||||
<>
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
Hold offered places for
|
||||
<input type="number" min="1" max="168" className="border rounded px-2 py-1 text-sm w-20" value={draft.waitlistOfferHours} onChange={e => upd({ waitlistOfferHours: e.target.value })} />
|
||||
hours
|
||||
</label>
|
||||
<label className="flex items-start gap-2 text-sm cursor-pointer">
|
||||
<input type="checkbox" className="mt-0.5" checked={draft.waitlistPartialOffers} onChange={e => upd({ waitlistPartialOffers: e.target.checked })} />
|
||||
<span>When fewer places free up than someone asked for, offer them what's free (otherwise skip to the next person who fits)</span>
|
||||
</label>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="border rounded-lg p-4 bg-gray-50 space-y-3">
|
||||
<div className="text-xs font-semibold text-gray-500 uppercase tracking-wide mb-1">Youth event</div>
|
||||
<label className="flex items-start gap-2 text-sm cursor-pointer">
|
||||
<input type="checkbox" className="mt-0.5" checked={draft.requiresParentalConsent} onChange={e => upd({ requiresParentalConsent: e.target.checked, ...(e.target.checked ? { allowNamedAttendees: true } : {}) })} />
|
||||
<span><strong>Requires parental consent</strong> — every attendee gives a date of birth; for anyone under 18, a parent or guardian is asked by email and WhatsApp to give consent before they can be checked in</span>
|
||||
</label>
|
||||
{draft.requiresParentalConsent && (
|
||||
<>
|
||||
<div>
|
||||
<label className="block text-xs text-gray-600 mb-1">Consent text (shown to the parent)</label>
|
||||
<textarea className="w-full border rounded px-2 py-1.5 text-sm" rows={6} value={draft.consentText} onChange={e => upd({ consentText: e.target.value })}
|
||||
placeholder="e.g. I give permission for my child to take part in all activities, including transport, and for leaders to arrange medical treatment in an emergency." />
|
||||
{mode === "edit" && <p className="text-[11px] text-gray-500 mt-1">Changing this starts a new version. Parents who already agreed keep their record; use "Re-request consent" on the Parental consent page to ask them again.</p>}
|
||||
</div>
|
||||
<div className="text-xs text-gray-600">Also ask the parent for:</div>
|
||||
<label className="flex items-center gap-2 text-sm"><input type="checkbox" checked={draft.consentCollectMedical} onChange={e => upd({ consentCollectMedical: e.target.checked })} /> Medical info (allergies, medication, conditions, medical aid)</label>
|
||||
<label className="flex items-center gap-2 text-sm"><input type="checkbox" checked={draft.consentCollectEmergency} onChange={e => upd({ consentCollectEmergency: e.target.checked })} /> An emergency contact</label>
|
||||
<label className="flex items-center gap-2 text-sm"><input type="checkbox" checked={draft.consentCollectPhoto} onChange={e => upd({ consentCollectPhoto: e.target.checked })} /> Photo consent (yes/no)</label>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user