Compare commits

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 13:59:22 +02:00
116 changed files with 12674 additions and 1286 deletions
+148
View File
@@ -7,6 +7,154 @@ and this project follows [Semantic Versioning](https://semver.org/).
## [Unreleased]
## [1.17.0] - 2026-10-19
### Added
- Offline ticket scanning. While online, staff pick an event and section on the scanner and tap "Enable offline mode". The section (or the whole event) is locked to their device and its tickets are downloaded to the device.
- With no connection, scans are checked against that list and queued on the device.
- The panel shows when the list was downloaded and how many scans are waiting.
- Queued scans sync automatically when the device is back online (or with "Sync now"), and check-in messages go out then.
- "Release section" ends offline mode and deletes the list from the device.
- Only one device can hold a section at a time (a whole-event lock clashes with every section). While a section is locked, other devices see who holds it and can't scan it, online or offline, so a ticket can never be redeemed twice by two offline devices.
- Syncing is idempotent and never redeems more than is left on a ticket. Anything that needs a person to look goes into a sync report: tickets not found, cancelled registrations, used-up tickets, scans outside the locked section, and check-ins without consent.
- Supervisor → Offline scanning page: active locks with force release (for a lost or flat phone; its scans still sync if it reconnects, and are flagged), and sync reports to review. Locks, releases, force-releases and syncs are recorded in the audit log.
- The scanner is installable as an app (web app manifest), and a service worker caches the scanner page and its scripts so it opens with no connection.
- The app now stays signed in with the last confirmed profile when it's reloaded without a connection, instead of sending the user to the login page.
- New `/api/scan-locks` endpoints.
- Requires the `add_offline_scanning` database migration.
### Changed
- `POST /api/tickets/scan/:qrCode` now accepts `sectionId` and checks it on the server; this was previously only checked in the browser.
- It now refuses (423) scans in a section that another device has locked for offline scanning.
## [1.16.0] - 2026-10-19
### Added
- Waitlist for sold-out tickets. A new event setting (event wizard → Visibility) turns it on, with how long offered places are held (24 hours by default) and whether someone can be offered fewer places than they asked for.
- Sold-out tickets show "Join waitlist" on the event page, the register page and the events list. Guests can join on events that allow guest registration.
- When places free up, the next person is emailed/WhatsApped a claim link, and the places are held for them so nobody else can buy them. Places free up through a cancellation (by the person or staff), a smaller booking, or a raised stock limit.
- The claim link opens the register page with the places pre-selected, and works without logging in.
- Unclaimed offers expire (checked every 10 minutes) and move on to the next person.
- People can leave a waitlist from My Events, which now lists their waitlists and any places held for them.
- Supervisor → Waitlist page: the queue per event, with offer now (even over the stock limit), move up/down and remove.
- The daily summary email shows each event's waitlist count.
- New `/api/waitlist` endpoints. `POST /api/registrations` accepts `waitlistToken`.
- Requires the `add_waitlist` database migration.
### Fixed
- Manual registrations (Manual registration, At the door, the kiosk) and adding places to an existing booking never checked stock, so either could take an option over its stock limit. Both are now checked like a normal registration.
- Stock is now counted in one place (`utils/stock.js`) for registration checks, `availableCount` and `isSoldOut`, including places held for waitlist offers.
## [1.15.0] - 2026-10-19
### Added
- Parental consent for youth events. A new "Requires parental consent" event setting (event wizard → Visibility) has its own consent text and optional medical info, emergency contact and photo consent questions. It turns on named attendees automatically.
- Registering then needs a name and date of birth for every main-ticket place. Anyone under 18 on the event's start date needs a parent or guardian's name, email and phone.
- This applies on the registration page, manual registration, at the door, the kiosk, and when editing names in My Events.
- The parent gets an email and a WhatsApp (always both) with a link to a new consent page (`/consent/<token>`). There they read the text, answer the optional questions, type their full name and confirm they're the parent or guardian, then give or decline.
- The system records the time, IP, device, signed name, and the exact consent text and version shown.
- Medical and emergency details are stored encrypted.
- The registrant and the event's notification recipients are told the outcome.
- Tickets are still issued, but the scanner blocks check-in until consent is given, showing why. Supervisors can override, and the override is recorded in the audit log. On a youth event, a ticket with no named attendee counts as missing consent.
- A daily 09:00 job reminds unanswered requests after 2 days, then every 3 days until the event. It also deletes medical and emergency details a set number of days after the event (Site Settings → Legal, default 30). `CONSENT_JOBS_ENABLED=false` turns it off.
- Supervisor → Parental consent page: consent status per attendee, resend one or all pending requests, re-request consent after the consent text changes, and a printable, confidential medical & emergency list. Every view of the list is recorded in the audit log.
- New reports: "Parental consent status" and "Medical & emergency list".
- My Events shows each attendee's consent status, with a "Resend consent" button.
- Anonymising or closing an account also clears that person's attendees' parent, date-of-birth, medical and emergency details.
- New endpoints under `/api/consent`. `GET /api/tickets/scan-preview/:qrCode` now returns `consentStatus`, and `POST /api/tickets/scan/:qrCode` accepts `overrideConsent`.
- Requires the `add_parental_consent` database migration.
## [1.14.0] - 2026-10-19
### Added
- Group tickets (named attendees). A new "Named attendees" event setting (event wizard → Visibility) lets someone registering several people give each main-ticket place a name, with an optional email and phone. It's offered on the registration page, Manual registration, At the door and the self-service kiosk.
- Each named person gets their own single-person ticket, with their name on the ticket PDF, the scanner's confirm screen and the staff ticket list. Places left unnamed share one quantity ticket as before.
- The payer chooses whether every ticket comes to them or each person gets their own by email/WhatsApp; anyone without contact details falls back to the payer.
- The payer stays the account owner for billing, invoices and payments.
- An attendee whose email or phone matches an existing account is linked to it (never when the email and phone belong to two different accounts). Their ticket then appears in their own My Events, marked "Booked by …", and they can open and submit their own attendee form.
- My Events → registration popup has a new "People" section for adding or changing names and contact details, switching ticket delivery, and sending an attendee a link to fill in their own form (`/forms?registrationId=…&attendeeId=…`). An attendee whose ticket has been scanned can't be removed.
- Attendee forms now show one block per named person, labelled with their name, and each response is saved against that attendee.
- New endpoints `PUT /api/registrations/:id/attendees` and `POST /api/registrations/:id/attendees/:attendeeId/send-form-link`. `POST /api/registrations` and `/manual` accept `attendees` and `ticketDelivery`.
- Requires the `add_named_attendees` database migration.
### Changed
- Reducing the quantity of a main ticket removes the newest names beyond the new quantity.
- Check-in confirmations for a named ticket go to that person when the registration sends each person their own tickets.
- Unauthenticated guest registration now matches an existing account by phone as well as email, and is refused (409) when the email and phone belong to two different accounts, instead of silently reusing whichever account the email matched.
- Free registrations now issue tickets through the same generator as paid ones, instead of a separate copy of the logic.
### Security
- Registration details (`GET /api/registrations/:id`) hide attendees' email addresses and phone numbers from anyone other than the payer and staff, such as an attendee opening their form link.
## [1.13.0] - 2026-10-19
### Added
- Coupon codes. Supervisors create percentage, fixed-amount or free ("comp") codes under Supervisor → Coupon codes.
- Codes can be limited to particular events, ticket options or variants, a date window, total and per-person uses (one per person by default), a minimum number of tickets, and specific people, roles (e.g. Staff and above) or an email domain. Restricted codes need a logged-in account, so they can't be shared around.
- By default the better of the code and the early-bird price wins; a code can instead stack on top of early-bird pricing.
- People enter codes under "Have a code?" on the registration page and in My Events (for registrations not yet paid in full). Staff can apply them in Manual registration, At the door and the self-service kiosk.
- The discount is locked in when it's applied, the same way early-bird prices are. It appears as its own line on invoices, receipts, registration and payment emails, WhatsApp messages and the Yoco checkout.
- Editing a registration's items recalculates percentage and free codes; fixed-amount codes keep their amount. Cancelling a registration frees the use. A free code that covers everything marks the registration paid and sends the tickets.
- Use limits are enforced under a database lock, so the last available use can't be claimed twice.
- Staff price: a ticket option or variant can carry a staff price (event wizard → Items & pricing) that Staff and above pay automatically when it's lower than the normal price.
- New "Discounts (coupon codes)" report (Reports → Financial), showing uses and value given away per code and event. The Finance report's "What was sold" is now shown after discounts.
- Coupon activity in the admin audit log: codes created, edited and turned off, and codes applied or removed by staff.
- New endpoints: `POST /api/coupons/validate`, `GET|POST /api/coupons`, `GET|PUT /api/coupons/:id`, `GET /api/coupons/redemptions`, `POST|DELETE /api/registrations/:id/coupon`. `POST /api/registrations` and `/manual` accept `couponCode`.
- Registration responses now include `discountTotal` and `subtotalComputed`.
- Requires the `add_coupon_codes` database migration (`npm run prisma:deploy` in `backend`).
### Fixed
- Yoco checkout line items priced each item at its most recent price times the whole quantity. When a line spanned several price tranches (some tickets at early-bird, some at full price), the item lines didn't match the amount charged. They now list each price actually charged.
- The free-registration check on `POST /api/registrations` and `/manual` had the same fault: it used the latest price for the whole quantity instead of the real total.
## [1.12.0] - 2026-10-19
### Added
- Two-factor authentication is now required for Admin and Supervisor accounts. An admin or supervisor without it is sent a code at their next login (by email, or WhatsApp when SMTP isn't set up), and entering it turns two-factor on — the same code step as before, with a note explaining why. Trusted devices don't skip this first code. They can change whether codes come by email or WhatsApp from Profile & Security, but can no longer turn two-factor off (`POST /api/users/2fa/disable` returns 403 for them).
- Existing admin and supervisor sessions without two-factor are ended on their next request (`401` with `code: "TWO_FACTOR_REQUIRED"`), and the login page explains why. Promoting a user to supervisor or admin also ends their sessions if they don't have two-factor yet, so they enrol at their next login. An admin "Reset 2FA" for a supervisor/admin now means they set it up again at their next login.
- The requirement is skipped when no channel can deliver a code — no SMTP configured and no WhatsApp — so a fresh install (or the setup wizard, which also gets a short grace period) never locks the first admin out. `TWO_FACTOR_REQUIRED_ROLES=false` turns the requirement off as an emergency switch.
- `FEATURES.md`, a repo-root overview of everything the platform does — roles, public site, events, registration, payments, tickets, messaging, dashboards, cashup, reports, settings, security, background jobs, and a page map.
### Changed
- `info.md` (the overview for prospective churches) brought up to date with features added since it was written — donations, invoices and receipts, cashup and costs, profit reporting, audit trail, scheduled messaging, self-service kiosk, scan points, backups, branding, and privacy controls.
## [1.11.0] - 2026-10-08
### Added
- Optional two-factor authentication. Any user can turn it on from Profile & Security and choose to receive a 6-digit code by email or WhatsApp; the chosen channel is confirmed with a code before it takes effect. At login the code is asked for after the password, expires after 10 minutes and allows 5 attempts, with a "Resend code" option. If a WhatsApp code can't be sent, it falls back to the account's email address.
- "Trust this device for 30 days" on the code step, so a remembered device skips the code. Trusted devices are forgotten when the password is changed or reset, when sessions are signed out everywhere, and when two-factor is turned off.
- Admins can reset two-factor authentication for a user who can no longer receive codes ("Reset 2FA" on the Users page). Turning two-factor on or off shows in Account activity.
- The self-service kiosk asks supervisors with two-factor on for their code at setup. Changing the event now re-confirms the password against the existing kiosk session (`POST /api/users/verify-password`) instead of logging in again.
### Changed
- `POST /api/users/login` returns `{ twoFactorRequired, challengeId, method, destination }` instead of a token for accounts with two-factor on; the token is issued by the new `POST /api/users/login/2fa`. Accounts without two-factor are unaffected. Requires the `add_two_factor` database migration (`npm run prisma:deploy` in `backend`).
## [1.10.6] - 2026-10-08
### Fixed
- The frontend production build (`next build`) no longer hangs or fails when the backend isn't reachable. With no `NEXT_PUBLIC_API_URL`/`API_URL` set, the root layout fetched a relative `/api/settings` URL that never settled during prerender, so every page timed out after 60 seconds; server-side fetches now fail fast when no API base is configured. The home page and the Events page also no longer fail the build when `/api/events` can't be reached — they build with an empty list and fill in on the first revalidation. At runtime a failed fetch still errors, so the last good page keeps being served.
### Security
- Updated dependencies to clear `npm audit` advisories: `express`, `multer`, `proxy-addr`, `qs` and others in the backend, `next` (15.5.27) and `sharp` (0.35.5) in the frontend, and `nodemailer` from 9 to 10 (requires Node.js 20 or newer). The root package now overrides `shell-quote` to a patched release, since every `concurrently` version pins a vulnerable one. Backend production dependencies now report no known vulnerabilities; the remaining advisories are in development tooling (`nodemon`, `jest`, `tailwindcss` 3) and have no non-breaking fix.
- Dependency install scripts are now explicitly allow-listed via `allowScripts` in the backend and frontend `package.json` (Prisma, `@sentry/cli`, `unrs-resolver`), so npm no longer blocks them.
## [1.10.5] - 2026-10-01
### Fixed
+482
View File
@@ -0,0 +1,482 @@
# Hope Events — Feature Overview
A complete map of what the platform does, grouped by who uses it. For setup and
deployment see [README.md](./README.md); for the API see
[backend/API_DOCUMENTATION.md](./backend/API_DOCUMENTATION.md); for setting
defaults see [SETTINGS.md](./SETTINGS.md); for history see
[CHANGELOG.md](./CHANGELOG.md).
**Stack:** Next.js (React, Tailwind, shadcn/ui) frontend · Express + Prisma +
PostgreSQL backend · Yoco for card payments · SMTP for email · WAWP for
WhatsApp · PM2 for process management · optional Sentry for error monitoring.
---
## Contents
1. [Roles](#roles)
2. [Public site](#public-site)
3. [Events](#events)
4. [Registration](#registration)
5. [Payments, donations & refunds](#payments-donations--refunds)
6. [Tickets & check-in](#tickets--check-in)
7. [Attendee forms](#attendee-forms)
8. [Messaging & notifications](#messaging--notifications)
9. [User dashboard (My Events)](#user-dashboard-my-events)
10. [Staff tools](#staff-tools)
11. [Supervisor tools](#supervisor-tools)
12. [Admin tools](#admin-tools)
13. [Cashup & event costs](#cashup--event-costs)
14. [Reports](#reports)
15. [Site settings & setup wizard](#site-settings--setup-wizard)
16. [Accounts & security](#accounts--security)
17. [Help system](#help-system)
18. [Background jobs](#background-jobs)
19. [Operations](#operations)
20. [Page map](#page-map)
---
## Roles
Four roles, each including everything the roles below it can do:
| Role | Can do |
|------|--------|
| **User** | Browse events, register, pay, donate, view tickets, fill in forms, manage their own profile |
| **Staff** | Scan tickets, view/print event tickets, view registrations and form responses |
| **Supervisor** | Create and manage events, record payments/refunds, assign donations, at-the-door desk, manual registration, bulk email/WhatsApp, reports, site banner, self-service kiosk |
| **Admin** | Manage users and roles, all registrations, event costs and cashup (close/reopen events), audit log, site settings, WhatsApp connection, backups |
---
## Public site
- **Home page** — hero section with the organisation's name and branding, upcoming
and in-progress events, and a "Join Us" button (becomes "My Dashboard" when
logged in).
- **Events list** (`/events`) — event cards with picture, dates, location,
price, and a Register / Sold Out / Contact us button.
- **Event detail** (`/events/[id]`) — description, ticket options with
variant and early-bird pricing, low-stock and sold-out badges, downloadable
attachments, embedded Google Map with "Directions" link, and an
**Add to calendar** (`.ics`) button.
- **Short links** — events can have an alias (e.g. `/camp-2025`) that redirects
to the event. Only plausible aliases hit the database, so bot probes are
rejected cheaply.
- **Contact page** (`/contact`) — organisation email, phone, address and map.
- **Legal pages** — Terms of Use and Privacy Policy, filled from Site Settings.
- **Site banner** — a site-wide announcement bar set by supervisors.
- **Lockdown rules** (`/lockdown-rules`) — static rules page for the youth
lockdown event.
- **SEO** — `sitemap.xml` (every public page and event), `robots.txt`, and
Open Graph/Twitter link-preview metadata.
- **Theming** — brand colours, logo and favicon come from Site Settings.
---
## Events
Created and edited by supervisors through a 6-step wizard: **Basic Details →
Items & Pricing → Sections → Form → Visibility → Attachments**.
- **Basic details** — title, description, start/end dates, registration
deadline, picture (upload or URL), location (defaults to the org's address),
contact person.
- **Contact-only events** — for things like baptisms: shown publicly with a
"Contact us" popup instead of registration, with no tickets.
- **Ticket options** — multiple options per event (e.g. Adult, Child, Dinner,
T-shirt), each with a price and optional **stock limit**. One option is marked
as the main ticket, which is what gets checked in at the door.
- **Variants** — sub-types of an option (sizes, colours, Adult/Child/VIP), each
with an optional price override and its own stock limit.
- **Early-bird tiers** — deadline- and/or stock-limited cheaper prices per
option or variant. Prices are locked in per purchase ("tranches"), so buying
more later doesn't re-price what was already bought.
- **Sections** — named scan points (e.g. "Main hall", "Dinner") limited to
certain ticket options, so a scanner at that point only accepts those tickets.
- **Visibility** — active/inactive, hidden (reachable only by link), scheduled
go-live date, require login vs. allow guest registration, or no registration
at all.
- **Attachments** — downloadable files (maps, schedules, waivers) shown on the
event page.
- **Notify recipients** — choose which staff get registration/payment alerts and
the daily summary for the event.
- **Sold-out handling** — an event is sold out when every stock-limited option
is exhausted. "X remaining" badges appear at a threshold that scales with
capacity.
---
## Registration
- **Online registration** (`/register/[eventId]`) — pick options, variants and
quantities; see early-bird prices and stock warnings; fill in the event form.
- **Guest registration** — when an event doesn't require login, an inactive
account is created and an activation link is sent by email or WhatsApp.
- **Statuses** — Pending, Confirmed, Partially Paid, Paid, Cancelled.
- **Edit options** — users can add or change items on their registration
(not for past or closed events).
- **Self-cancellation** — allowed only before any payment; admins can cancel
any time.
- **Pricing refresh** — when an unpaid registration is viewed or paid, expired
early-bird prices are re-evaluated, and the user is warned if the total
changed.
- **Manual registration** (supervisor) — register someone on their behalf,
creating an account if needed. Includes a "Record Payment" tab.
- **Duplicate-account protection** — if an email and phone belong to two
different accounts, the registration is blocked instead of merging them.
- **Group tickets** — on events with "Named attendees" turned on, someone booking
several places can name each person (with optional email/phone). Each named person
gets their own ticket and their own attendee form; unnamed places share one ticket.
Tickets go all to the payer, or each to the person (falling back to the payer).
Attendees matching an existing account see their ticket in My Events. Names can be
added or changed later from My Events, and attendees can be sent a link to fill in
their own form. Available on the registration page, manual registration, At the
door and the kiosk.
- **Waitlist** — on events with a waitlist, sold-out tickets show "Join waitlist"
(on the event page, the register page and the event list). When places free up — a
cancellation, a smaller booking or a higher stock limit — the next person is
emailed/WhatsApped a claim link and the places are held for them (24 hours by
default) so nobody else can buy them. Unclaimed offers expire and move on; people
who asked for more than came free can be offered what's free or skipped. Claiming
works without logging in. People can leave from My Events.
- **Parental consent (youth events)** — on events that require it, every attendee
gives a date of birth; for anyone under 18, a parent or guardian gets an email and
a WhatsApp with a consent link. They read the event's consent text, optionally give
medical info, an emergency contact and photo consent, type their name to sign, and
give or decline. Tickets are issued but can't be checked in until consent is given
(supervisors can override, audited). Reminders go after 2 days, then every 3 days.
Medical details are encrypted and deleted a set number of days after the event.
- **Coupon codes** — percentage, fixed-amount or free (comp) codes, entered under
"Have a code?" on the registration page, in My Events (for unpaid registrations),
manual registration, At the door and the kiosk. Codes can be limited to events,
ticket options or variants, dates, total and per-person uses, a minimum quantity,
and specific people, roles or an email domain (restricted codes need a login).
Early-bird prices either stack or the better price wins. The discount is locked in
when applied and shows on invoices, receipts, emails and the Yoco checkout;
cancelling frees the use. Managed from Supervisor → Coupon codes.
- **Staff price** — a ticket option or variant can have a staff price that Staff and
above get automatically, without a code.
---
## Payments, donations & refunds
- **Online card payments via Yoco** — checkout for the full outstanding balance,
confirmed by a signed webhook.
- **Payment links** — supervisors can generate and send a Yoco link for any
amount (including partial).
- **Manual payments** — cash, card, EFT, voucher or other, recorded by staff.
Each payment records **who recorded it** as well as who it's for.
- **Refunds** — negative payments against an original payment, netted against
the original method.
- **Donations** — users can donate from their dashboard (`/dashboard/user/donate`).
Supervisors can **assign** a donation (fully or partly) to someone's
registration and **unassign** it later. Assignments are kept as separate
records, so the original donation never changes.
- **Yoco reconciliation** — imported Yoco transactions can be matched to a
registration, recorded as a donation (optionally choosing the donor), or
ignored.
- **Invoices & receipts** — branded PDFs: an invoice (itemised, early-bird
discount, balance due, pay-now link and QR code) and a payment receipt. Sent
automatically and re-sendable on demand by email or WhatsApp.
- **Method normalisation** — Apple Pay / Google Pay / checkout payments count as
"Card" in reports and user views.
---
## Tickets & check-in
- **Automatic tickets** — QR-coded tickets are generated when a registration is
fully paid (or free), and sent as a PDF by email and/or WhatsApp.
- **Quantity tickets** — one ticket can cover several people and be redeemed
partially (e.g. check in 2 of 3).
- **Named tickets** — group bookings can give each person their own ticket, shown
with their name on the PDF, the scanner and the staff ticket list.
- **QR scanner** — works in any mobile browser. A preview checks the ticket is
for the selected event/section, then a confirmation screen shows holder, type
and remaining quantity.
- **Check-in confirmation** — holders get an email/WhatsApp saying how many were
checked in and how many remain.
- **Scan stats** — recent scans and per-event scan counts.
- **Offline scanning** — while online, a staff member locks an event section (or the
whole event) to their device and downloads its tickets. They can then keep scanning
with no connection; scans are queued on the device and sync automatically when it's
back online (never redeeming more than is left). Only one device can hold a section,
and while it's locked nobody else can scan it, online or offline. Problems found
while syncing go into a report for a supervisor. Supervisors can force-release a
lost device. The scanner can be added to the home screen (PWA) and opens offline.
- **Printing** — A4 sheets (8 tickets per page) and A6 single tickets.
- **Resend** — send tickets to a different email/phone at the door.
---
## Attendee forms
- **Form builder** per event — field types: Yes/No, Text, Paragraph, Number,
Date, plus Statement and Heading blocks; required/optional, help text,
reordering. The whole form can be marked required.
- **Drafts** — partially filled forms are auto-saved.
- **Responses** — view, filter (event, user, past/inactive) and print one
response per A4 page. Supervisors can edit responses.
---
## Messaging & notifications
Every user picks a **notification preference**: Email, WhatsApp, or Both.
**Automatic notifications**
| Trigger | Goes to |
|---------|---------|
| Registration created / updated | Registrant (with invoice if unpaid) + event notify recipients |
| Payment recorded / donation applied / unassigned | Registrant (with receipt) + admins |
| Refund processed | Registrant + admins |
| Tickets generated | Registrant (ticket PDF) |
| Ticket checked in | Ticket holder |
| Daily summary, 07:00, for each live event that hasn't started yet | Registrations inbox + event notify recipients |
| Account registered, activation, login, password changed/reset, account closed | The account holder |
**Bulk messaging** (supervisor)
- **Email attendees** and **WhatsApp attendees** — filter by status or pick
specific attendees, preview, send now or schedule for later.
- **Placeholders** — `{{name}}`, `{{event.title}}`, `{{event.link}}`,
`{{event.start}}`, `{{balance}}`, and `{{payment.link}}` (a live Yoco link for each recipient's
outstanding balance).
- **Broadcasts** — ad-hoc email/WhatsApp to any users or addresses, not tied to
one event.
- **Automations** — schedule a sequence of lifecycle emails for an event
(pre-event, final reminder, thank-you, promo for another event).
- **Manage scheduled** — view, edit or delete queued jobs per channel. Sends
run in the background and are purged 24 h after sending.
**WhatsApp** is connected through the WAWP API: an admin links a number in
Site Settings → WhatsApp (QR code or pairing code), with status, restart and
logout controls. Disconnected sessions are recovered automatically.
---
## User dashboard (My Events)
- Registrations with status badges, event thumbnails, and Closed/Past/Inactive
tags. Past and closed events are hidden by default.
- Registration detail: itemised bill (early-bird lines grouped), payments,
**Invoice** and **Receipt** buttons, edit, cancel, **Make payment**.
- Upcoming events and tickets side by side, with bulk ticket email/WhatsApp.
- **Payment history** (`/dashboard/user/payments`) — paginated, with filters by
date, method and payment/refund.
- **Donate** page.
- **Forms** — complete outstanding attendee forms.
- **Profile & Security** — edit details and notification preference, change
password, see recent account activity (logins, password changes, device),
sign out everywhere, deactivate or delete my data.
---
## Staff tools
- **Staff dashboard** — scan stats and quick actions.
- **Ticket scanning** (`/dashboard/staff/ticket-scanning`) — camera QR scanner
with event and section filters.
- **Event tickets & printing** (`/dashboard/staff/event-tickets`) — list, search
and print an event's tickets.
---
## Supervisor tools
- **Dashboard** — KPIs (active events, revenue, donations, registrations,
tickets sold, each vs. the previous month), revenue trend chart, top events,
and recent scans. Tiles link to the matching report.
- **Manage events** — the 6-step event wizard, ticket options, variants,
sections, form and attachments.
- **At the door** — search by name/phone/ticket ID, walk-in registration,
payments, a **Check-In** tab for redeeming main tickets by quantity, and
ticket printing/resending.
- **Self-service kiosk** (`/self-service`) — a touchscreen page a supervisor
unlocks for an event, where visitors register themselves. Existing accounts
are matched by email/phone, and any change to their details needs
confirmation.
- **Manual registration** — register someone on their behalf and record their
payment.
- **Payments** — tabs for Payment, Refund, Donations (assign/unassign),
Reconcile (Yoco transactions), and Payment Link.
- **Coupon codes** (`/dashboard/supervisor/coupons`) — create, edit and turn off
discount codes, and see who used each one.
- **Offline scanning** (`/dashboard/supervisor/offline-scans`) — devices holding
offline locks (with force release) and sync reports to review.
- **Waitlist** (`/dashboard/supervisor/waitlist`) — the queue per event: offer a
place now, move people up or down, or remove them.
- **Parental consent** (`/dashboard/supervisor/consent`) — consent status per
attendee on youth events, resend (one or all pending), re-request after the
consent text changes, and a printable medical & emergency list (audited).
- **Email attendees / WhatsApp attendees** — bulk and scheduled messaging.
- **Forms** — view, filter, edit and print form responses.
- **Reports** — see [Reports](#reports).
- **Site banner** (`/set-banner`) — set the announcement bar.
---
## Admin tools
- **Admin dashboard** — same KPIs as supervisor, plus admin quick actions.
- **Manage users** — server-side search and filters (role, active), paging,
edit role and notification preference, revoke sessions, delete or
**anonymise** personal data.
- **Registrations** — all registrations, with filters (event, status, text),
status counts, revenue and outstanding totals, and per-registration options,
form answers and payments (with who recorded each one).
- **Cashup** — see below.
- **Audit log** (`/dashboard/admin/audit-log`) — who did what and when for
refunds, donation assign/unassign, manual registrations, staff
cancellations, event create/update/delete, settings changes, and coupon codes
(created, edited, turned off, applied or removed by staff).
- **Site settings** — see below.
---
## Cashup & event costs
- **Event costs** — once-off or per-item costs (optionally linked to a ticket
option), with a "paid from takings" method so cash on hand is correct.
- **Expected vs. actual** — expected takings per method (cash/card/EFT/other)
compared with actual amounts, with variance shown.
- **Cash denomination counts** — entered per staff member, at any time. The
event's actual cash is the sum of these counts.
- **Payment accountability by staff member** — what each person recorded, by
method, with live cash variance.
- **Close / quick close / reopen** an event — closing locks it from further
edits and hides it from the kiosk and user actions. Drafts are saved as you
go.
- **Report tab** — a read-only cashup summary, shown automatically once the
event is closed.
- **Cashup audit trail** — every close/reopen with totals and who did it.
---
## Reports
A full-width workspace with shared filters (events, date range with presets,
include past/inactive/closed) and a searchable grid of reports, each opening in
a viewer with its own filters, stat tiles and bar charts.
| Category | Reports |
|----------|---------|
| Financial | Payments between dates · Revenue summary (by method) · Revenue detailed · Finance report (revenue & costs, after discounts) · Profit report · Discounts (coupon codes) |
| Registration | Attendees per event (grouped by option) · Registration type counts · Master orders breakdown · Registration status breakdown (count by registration or ticket quantity) · Parental consent status · Medical & emergency list |
| Ticketing | Ticket usage summary |
| Donations | Donations breakdown (used / unused per event) |
| Cashup | Cashup reconciliation · Cashup audit trail |
**Export:** Print, branded PDF download, styled Excel (`.xlsx`) workbook,
Email (to yourself), or WhatsApp (the PDF to your own number). Dashboard tiles
deep-link into reports with `?report=&range=`.
---
## Site settings & setup wizard
**Setup wizard** (`/setup`) — on a fresh database, walks through organisation
details, the first admin account and branding. No manual seeding needed.
**Site Settings** (`/dashboard/admin/settings`) — stored in the database and
applied without a restart. Secrets are encrypted with AES-256-GCM.
| Tab | Contains |
|-----|----------|
| Organisation | Name, tagline, contact email/phone, address (with map link) |
| Branding | Primary/secondary/accent colours, logo, favicon, colours suggested from the logo, live website/email preview on desktop and mobile |
| Notifications | Registrations inbox and admin notification emails |
| Email | SMTP host/port/TLS/user/password/from, with a "Test connection" button |
| Legal | Operator, Information Officer, website, effective date for the Terms/Privacy pages |
| WhatsApp | WAWP credentials, connect a number, status/restart/logout |
| Backups | List, run now, download database backups |
See [SETTINGS.md](./SETTINGS.md) for the default of every setting.
---
## Accounts & security
- Register, log in, forgot/reset password, activate account (for accounts
created on someone's behalf).
- JWT authentication (HS256 pinned) with **session revocation** (token
versioning) — users can sign out everywhere, and admins can do it for any user.
- **Two-factor authentication** — a 6-digit login code by email or WhatsApp
(10-minute expiry, 5 attempts, resend), with "Trust this device for 30 days".
**Required for Admins and Supervisors**: their next login sends a code that turns
it on, and they can change the channel but not turn it off (skipped only when no
email or WhatsApp channel can deliver a code; `TWO_FACTOR_REQUIRED_ROLES=false`
is the emergency switch). Optional for everyone else from Profile & Security.
Admins can reset it for a locked-out user, and the kiosk asks for the code at
setup.
- **Login lockout** after repeated failed attempts, plus login rate limiting
(`TRUST_PROXY` to read real IPs behind a proxy).
- **Security event log** — logins, password changes and resets, with device
and IP.
- **Account closure** — deactivate, or anonymise personal data while keeping
financial records.
- **Upload hardening** — server-generated filenames, and uploads served with a
sandbox CSP and `nosniff`.
---
## Help system
A floating **"Need help?"** button on every page opens a page-specific guide
(tabs, quick links). Every page has dedicated content (`frontend/src/content/help/`),
falling back to a general guide.
---
## Background jobs
| Job | Schedule |
|-----|----------|
| Scheduled email/WhatsApp worker | Polls every 30 s (`SCHEDULED_EMAILS_INTERVAL_MS`) and purges sent jobs after 24 h |
| Daily event summary email | 07:00 daily |
| Waitlist: expire unclaimed offers, offer freed places | Every 10 minutes (`WAITLIST_JOB_ENABLED=false` to disable) |
| Parental consent reminders, and deleting medical details after events | 09:00 daily (`CONSENT_JOBS_ENABLED=false` to disable) |
| Database backup (`pg_dump`, gzipped, 14-day retention) | 02:00 daily (`BACKUP_ENABLED=false` to disable) |
| Temp file cleanup (files older than 7 days in `/temp`) | Daily |
---
## Operations
- **PM2** (`ecosystem.config.js`) runs backend and frontend, restarting either
one above 500 MB of memory.
- **Sentry** error monitoring when `SENTRY_DSN` / `NEXT_PUBLIC_SENTRY_DSN` are
set.
- Unhandled promise rejections are logged and don't crash the server.
- Backend status page (`/`) and built-in API docs page (`/docs`).
- Jest tests for early-bird tranche pricing and donation balances.
- Versioning via git tags and [CHANGELOG.md](./CHANGELOG.md).
---
## Page map
| Path | Who | What |
|------|-----|------|
| `/` | Public | Home |
| `/events`, `/events/[id]` | Public | Event list and detail |
| `/[alias]` | Public | Event short link |
| `/register/[eventId]` | Public / User | Registration |
| `/registration/success` | Public / User | Confirmation, form and pay |
| `/payment/success`, `/payment/failure`, `/payment/cancel` | Public | Yoco return pages |
| `/forms` | Public / User | Attendee forms |
| `/contact`, `/legal/terms`, `/legal/privacy`, `/lockdown-rules` | Public | Info pages |
| `/login`, `/register`, `/forgot-password`, `/reset-password`, `/activate-account` | Public | Accounts |
| `/setup` | First run | Setup wizard |
| `/consent/[token]` | Parent/guardian | Give or decline parental consent |
| `/waitlist/claim/[token]` | Public | Claim a waitlist place |
| `/self-service` | Supervisor-unlocked | Kiosk |
| `/set-banner` | Supervisor | Site banner |
| `/dashboard/user` (+ `/payments`, `/donate`, `/forms`, `/profile`, `/reset-password`) | User | My Events |
| `/dashboard/staff` (+ `/ticket-scanning`, `/event-tickets`) | Staff | Staff tools |
| `/dashboard/supervisor` (+ `/events`, `/event-options`, `/at-the-door`, `/manual`, `/manual-registration`, `/payments`, `/coupons`, `/waitlist`, `/consent`, `/offline-scans`, `/email-attendees`, `/whatsapp-attendees`, `/forms`, `/reports`) | Supervisor | Supervisor tools |
| `/dashboard/admin` (+ `/users`, `/registrations`, `/forms`, `/cashup`, `/cashup/[id]`, `/audit-log`, `/settings`) | Admin | Admin tools |
+1
View File
@@ -112,6 +112,7 @@ Documentation: see Frontend README for environment and scripts.
- Backend README: ./backend/README.md
- Frontend README: ./frontend/README.md
- Site Settings — defaults reference: ./SETTINGS.md
- Feature overview: ./FEATURES.md
## Recent Changes
+1
View File
@@ -66,6 +66,7 @@ or Branding tab has been saved even once — the wizard always writes
| `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
+10
View File
@@ -4,6 +4,11 @@ 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
@@ -47,6 +52,11 @@ 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
+140 -4
View File
@@ -18,6 +18,10 @@ Tokens are issued by `POST /api/users/login`.
- [Event Options & Variants](#event-options--variants)
- [Event Attachments](#event-attachments)
- [Registrations](#registrations)
- [Coupons](#coupons)
- [Parental consent](#parental-consent)
- [Waitlist](#waitlist)
- [Offline scanning](#offline-scanning)
- [Payments](#payments)
- [Tickets](#tickets)
- [Broadcasts (Email)](#broadcasts-email)
@@ -43,7 +47,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 +57,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 +81,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 +231,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 +247,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 +279,96 @@ For unauthenticated (guest) registration on events with `requiresAuth: false`:
---
## Offline scanning
| Method | Path | Auth | Description |
|--------|------|------|-------------|
| GET | `/api/scan-locks?eventId=` | staff+ | Active offline locks for an event |
| POST | `/api/scan-locks` | staff+ | Lock `{ eventId, sectionId?, deviceId, deviceName? }` to this device (409 if the scope is held elsewhere) |
| GET | `/api/scan-locks/:id/snapshot?deviceId=` | staff+ | Tickets valid in the lock's scope (lock holder only) |
| POST | `/api/scan-locks/:id/sync` | staff+ | `{ deviceId, scans: [{ clientScanId, qrCode, qty, scannedAt }] }` — idempotent; returns accepted, duplicates, conflicts |
| DELETE | `/api/scan-locks/:id` | staff+ | Release (`{ deviceId }`, lock holder only) |
| POST | `/api/scan-locks/:id/force-release` | supervisor+ | Release a lost device's lock |
| GET | `/api/scan-locks/reports?eventId=&unreviewed=` | supervisor+ | Sync reports |
| POST | `/api/scan-locks/reports/:id/review` | supervisor+ | Mark a report reviewed |
How locks work:
- One device per scope. A whole-event lock (`sectionId` null) clashes with every section lock on that event.
- `POST /api/tickets/scan/:qrCode` now accepts `sectionId` and `deviceId`. It enforces the section on the server, and returns `423 { code: "SCAN_LOCKED" }` when another device holds a lock covering the ticket.
How syncing works:
- Synced scans never redeem more than is left on a ticket.
- Conflicts are recorded in the sync report: `not_found`, `cancelled`, `wrong_scope`, `already_used`, `partly_used` and `consent`.
- Check-in messages are sent after the sync.
- Locks, releases, force-releases and syncs are written to the admin audit log.
## Waitlist
Events with `waitlistEnabled` (plus `waitlistOfferHours`, default 24, and `waitlistPartialOffers`, default true) take a queue for sold-out lines.
| Method | Path | Auth | Description |
|--------|------|------|-------------|
| POST | `/api/waitlist` | optional | Join: `{ eventId, eventOptionId, variantId?, quantity }` (+ `guestName`, `guestEmail`, `guestPhone` when not logged in on a guest event). Only when the line can't cover the quantity |
| GET | `/api/waitlist/mine` | user+ | My active entries |
| DELETE | `/api/waitlist/:id` | user+ | Leave (an open offer moves on) |
| GET | `/api/waitlist/claim/:token` | public | What a claim link offers |
| GET | `/api/waitlist/events/:eventId` | staff+ | The queue for an event |
| POST | `/api/waitlist/:id/offer` | supervisor+ | Offer now (`{ quantity? }`), even over the stock limit |
| POST | `/api/waitlist/:id/move` | supervisor+ | `{ direction: "up" \| "down" }` |
| POST | `/api/waitlist/:id/remove` | supervisor+ | Take someone off |
How offers work:
- Places held for a live offer count as taken everywhere stock is counted: `availableCount`, `isSoldOut` and registration stock checks.
- Offers are made automatically when places free up: a cancellation, a smaller booking, or a raised stock limit.
- A job every 10 minutes (`WAITLIST_JOB_ENABLED`, `WAITLIST_JOB_INTERVAL_MS`) expires unclaimed offers and re-offers.
- Claiming means registering with `POST /api/registrations` plus `waitlistToken`. The token stands in for logging in, and the held places are released to the claimer.
- Manual registration and adding places to an existing booking are now checked against stock too.
## Parental consent
Youth events (`requiresParentalConsent`, plus `consentText`, `consentCollectMedical`, `consentCollectEmergency` and `consentCollectPhoto` on the event) need every main place named with a `dateOfBirth`. Attendees under 18 on the start date also need `parentName`, `parentEmail` and `parentPhone`. Changing `consentText` bumps `consentTextVersion`.
| Method | Path | Auth | Description |
|--------|------|------|-------------|
| GET | `/api/consent/:token` | public | What the parent sees: attendee, event, consent text, fields to fill in |
| POST | `/api/consent/:token` | public | `{ decision: "give"\|"decline", signedName, isGuardian: true, medical?, emergency?, photoConsent? }` |
| GET | `/api/consent/events/:eventId` | staff+ | Consent status per attendee, with counts |
| POST | `/api/consent/requests/:id/resend` | user+ | Re-send one request with a new link (registrant or staff) |
| POST | `/api/consent/events/:eventId/resend-pending` | supervisor+ | Re-send every pending request |
| POST | `/api/consent/events/:eventId/rerequest` | supervisor+ | New requests for consent given on older text, and minors with none |
| GET | `/api/consent/events/:eventId/medical` | supervisor+ | Decrypted medical & emergency list (audited as `medical_list_viewed`) |
How requests work:
- Requests go to the parent by both email and WhatsApp, whatever anyone's notification preference.
- Links are random tokens; only an HMAC is stored. They expire when the event ends.
- The answer stores the signed name, time, IP, device, and the exact text and version shown.
- Medical and emergency details are AES-256-GCM encrypted. A daily job (09:00, `CONSENT_JOBS_ENABLED=false` to disable) sends reminders (day 2, then every 3 days) and deletes them `consent_medical_retention_days` (default 30) after the event.
Check-in rules:
- `GET /api/tickets/scan-preview/:qrCode` returns `consentStatus` (`not_required`, `missing`, `pending`, `given` or `declined`).
- `POST /api/tickets/scan/:qrCode` returns `403 { code: "CONSENT_REQUIRED" }` unless consent is given.
- Supervisors can pass `overrideConsent: true`, which is audited as `consent_override`.
## Coupons
| Method | Path | Auth | Description |
|--------|------|------|-------------|
| POST | `/api/coupons/validate` | optional | Check a code against a basket (`{ code, eventId, options, forUserId? }`) |
| GET | `/api/coupons` | supervisor+ | List codes with use counts and total discount |
| POST | `/api/coupons` | supervisor+ | Create a code |
| GET | `/api/coupons/:id` | supervisor+ | One code with its uses |
| PUT | `/api/coupons/:id` | supervisor+ | Edit or turn off a code |
| GET | `/api/coupons/redemptions` | supervisor+ | Uses for the Discounts report (`eventIds`, `from`, `to`) |
- **Types:** `percent` (value 1–100), `fixed` (rand amount), `comp` (free).
- **Scope:** `eventIds` (none = every event) and `options: [{ eventOptionId, variantId? }]` (none = the whole registration).
- **Restrictions:** `userIds`, `restrictRoles` and `restrictDomain`. A match on any one is enough, and restricted codes need a known account.
- **Limits:** `validFrom`, `validUntil`, `maxUses`, `maxUsesPerUser` (default 1) and `minQuantity`.
- **Early-bird:** `stackEarlyBird: false` means the better price wins; `true` discounts the early-bird price.
- **Locked amount:** the discount is locked when applied. Editing items recalculates percentage and comp codes, but fixed codes keep their amount. Cancelling releases the use.
Options and variants also accept an optional `staffPrice`, charged automatically to Staff and above when it's lower than the normal price.
## Payments
| Method | Path | Auth | Description |
+1105 -615
View File
File diff suppressed because it is too large Load Diff
+9 -2
View File
@@ -1,6 +1,6 @@
{
"name": "event-management-backend",
"version": "1.10.5",
"version": "1.17.0",
"description": "Event Management System Backend",
"main": "src/index.js",
"scripts": {
@@ -29,7 +29,7 @@
"jsonwebtoken": "^9.0.3",
"multer": "^2.2.0",
"node-fetch": "^2.7.0",
"nodemailer": "^9.0.6",
"nodemailer": "^10.0.16",
"pdfkit": "^0.17.1",
"qrcode": "^1.5.4",
"raw-body": "^3.0.0",
@@ -42,5 +42,12 @@
},
"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,62 @@
-- CreateEnum
CREATE TYPE "TwoFactorMethod" AS ENUM ('email', 'whatsapp');
-- AlterEnum
-- This migration adds more than one value to an enum.
-- With PostgreSQL versions 11 and earlier, this is not possible
-- in a single migration. This can be worked around by creating
-- multiple migrations, each migration adding only one value to
-- the enum.
ALTER TYPE "SecurityEventType" ADD VALUE 'two_factor_enabled';
ALTER TYPE "SecurityEventType" ADD VALUE 'two_factor_disabled';
-- AlterTable
ALTER TABLE "User" ADD COLUMN "twoFactorEnabled" BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN "twoFactorMethod" "TwoFactorMethod";
-- CreateTable
CREATE TABLE "TwoFactorChallenge" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"purpose" TEXT NOT NULL,
"method" "TwoFactorMethod" NOT NULL,
"codeHash" TEXT NOT NULL,
"attempts" INTEGER NOT NULL DEFAULT 0,
"lastSentAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"expiresAt" TIMESTAMP(3) NOT NULL,
"usedAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "TwoFactorChallenge_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "TrustedDevice" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"tokenHash" TEXT NOT NULL,
"device" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"lastUsedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"expiresAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "TrustedDevice_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "TwoFactorChallenge_userId_idx" ON "TwoFactorChallenge"("userId");
-- CreateIndex
CREATE UNIQUE INDEX "TrustedDevice_tokenHash_key" ON "TrustedDevice"("tokenHash");
-- CreateIndex
CREATE INDEX "TrustedDevice_userId_idx" ON "TrustedDevice"("userId");
-- AddForeignKey
ALTER TABLE "TwoFactorChallenge" ADD CONSTRAINT "TwoFactorChallenge_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "TrustedDevice" ADD CONSTRAINT "TrustedDevice_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,164 @@
-- CreateEnum
CREATE TYPE "CouponType" AS ENUM ('percent', 'fixed', 'comp');
-- AlterEnum
-- This migration adds more than one value to an enum.
-- With PostgreSQL versions 11 and earlier, this is not possible
-- in a single migration. This can be worked around by creating
-- multiple migrations, each migration adding only one value to
-- the enum.
ALTER TYPE "AdminAuditAction" ADD VALUE 'coupon_created';
ALTER TYPE "AdminAuditAction" ADD VALUE 'coupon_updated';
ALTER TYPE "AdminAuditAction" ADD VALUE 'coupon_disabled';
ALTER TYPE "AdminAuditAction" ADD VALUE 'coupon_applied_manual';
-- AlterTable
ALTER TABLE "EventOption" ADD COLUMN "staffPrice" DOUBLE PRECISION;
-- AlterTable
ALTER TABLE "OptionVariant" ADD COLUMN "staffPrice" DOUBLE PRECISION;
-- AlterTable
ALTER TABLE "Registration" ADD COLUMN "discountTotal" DOUBLE PRECISION NOT NULL DEFAULT 0;
-- CreateTable
CREATE TABLE "Coupon" (
"id" TEXT NOT NULL,
"code" TEXT NOT NULL,
"description" TEXT,
"type" "CouponType" NOT NULL,
"value" DOUBLE PRECISION NOT NULL DEFAULT 0,
"stackEarlyBird" BOOLEAN NOT NULL DEFAULT false,
"validFrom" TIMESTAMP(3),
"validUntil" TIMESTAMP(3),
"maxUses" INTEGER,
"maxUsesPerUser" INTEGER DEFAULT 1,
"minQuantity" INTEGER,
"restrictRoles" "UserRole"[],
"restrictDomain" TEXT,
"active" BOOLEAN NOT NULL DEFAULT true,
"createdById" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Coupon_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "CouponEvent" (
"id" TEXT NOT NULL,
"couponId" TEXT NOT NULL,
"eventId" TEXT NOT NULL,
CONSTRAINT "CouponEvent_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "CouponOption" (
"id" TEXT NOT NULL,
"couponId" TEXT NOT NULL,
"eventOptionId" TEXT NOT NULL,
"variantId" TEXT,
CONSTRAINT "CouponOption_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "CouponUser" (
"id" TEXT NOT NULL,
"couponId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
CONSTRAINT "CouponUser_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "CouponRedemption" (
"id" TEXT NOT NULL,
"couponId" TEXT NOT NULL,
"registrationId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"discountAmount" DOUBLE PRECISION NOT NULL,
"appliedById" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"releasedAt" TIMESTAMP(3),
CONSTRAINT "CouponRedemption_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "Coupon_code_key" ON "Coupon"("code");
-- CreateIndex
CREATE INDEX "Coupon_createdById_idx" ON "Coupon"("createdById");
-- CreateIndex
CREATE INDEX "CouponEvent_eventId_idx" ON "CouponEvent"("eventId");
-- CreateIndex
CREATE UNIQUE INDEX "CouponEvent_couponId_eventId_key" ON "CouponEvent"("couponId", "eventId");
-- CreateIndex
CREATE INDEX "CouponOption_couponId_idx" ON "CouponOption"("couponId");
-- CreateIndex
CREATE INDEX "CouponOption_eventOptionId_idx" ON "CouponOption"("eventOptionId");
-- CreateIndex
CREATE INDEX "CouponOption_variantId_idx" ON "CouponOption"("variantId");
-- CreateIndex
CREATE INDEX "CouponUser_userId_idx" ON "CouponUser"("userId");
-- CreateIndex
CREATE UNIQUE INDEX "CouponUser_couponId_userId_key" ON "CouponUser"("couponId", "userId");
-- CreateIndex
CREATE INDEX "CouponRedemption_couponId_releasedAt_idx" ON "CouponRedemption"("couponId", "releasedAt");
-- CreateIndex
CREATE INDEX "CouponRedemption_registrationId_idx" ON "CouponRedemption"("registrationId");
-- CreateIndex
CREATE INDEX "CouponRedemption_userId_idx" ON "CouponRedemption"("userId");
-- CreateIndex
CREATE INDEX "CouponRedemption_appliedById_idx" ON "CouponRedemption"("appliedById");
-- AddForeignKey
ALTER TABLE "Coupon" ADD CONSTRAINT "Coupon_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "CouponEvent" ADD CONSTRAINT "CouponEvent_couponId_fkey" FOREIGN KEY ("couponId") REFERENCES "Coupon"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "CouponEvent" ADD CONSTRAINT "CouponEvent_eventId_fkey" FOREIGN KEY ("eventId") REFERENCES "Event"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "CouponOption" ADD CONSTRAINT "CouponOption_couponId_fkey" FOREIGN KEY ("couponId") REFERENCES "Coupon"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "CouponOption" ADD CONSTRAINT "CouponOption_eventOptionId_fkey" FOREIGN KEY ("eventOptionId") REFERENCES "EventOption"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "CouponOption" ADD CONSTRAINT "CouponOption_variantId_fkey" FOREIGN KEY ("variantId") REFERENCES "OptionVariant"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "CouponUser" ADD CONSTRAINT "CouponUser_couponId_fkey" FOREIGN KEY ("couponId") REFERENCES "Coupon"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "CouponUser" ADD CONSTRAINT "CouponUser_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "CouponRedemption" ADD CONSTRAINT "CouponRedemption_couponId_fkey" FOREIGN KEY ("couponId") REFERENCES "Coupon"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "CouponRedemption" ADD CONSTRAINT "CouponRedemption_registrationId_fkey" FOREIGN KEY ("registrationId") REFERENCES "Registration"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "CouponRedemption" ADD CONSTRAINT "CouponRedemption_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "CouponRedemption" ADD CONSTRAINT "CouponRedemption_appliedById_fkey" FOREIGN KEY ("appliedById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
@@ -0,0 +1,69 @@
-- CreateEnum
CREATE TYPE "TicketDelivery" AS ENUM ('payer', 'each');
-- AlterTable
ALTER TABLE "Event" ADD COLUMN "allowNamedAttendees" BOOLEAN NOT NULL DEFAULT false;
-- AlterTable
ALTER TABLE "FormResponse" ADD COLUMN "attendeeId" TEXT;
-- AlterTable
ALTER TABLE "Registration" ADD COLUMN "ticketDelivery" "TicketDelivery" NOT NULL DEFAULT 'payer';
-- AlterTable
ALTER TABLE "Ticket" ADD COLUMN "attendeeId" TEXT,
ADD COLUMN "holderUserId" TEXT;
-- CreateTable
CREATE TABLE "Attendee" (
"id" TEXT NOT NULL,
"registrationId" TEXT NOT NULL,
"registrationOptionId" TEXT,
"eventOptionId" TEXT NOT NULL,
"variantId" TEXT,
"name" TEXT NOT NULL,
"email" TEXT,
"phone" TEXT,
"userId" TEXT,
"dateOfBirth" TIMESTAMP(3),
"gender" TEXT,
"order" INTEGER NOT NULL DEFAULT 0,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Attendee_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "Attendee_registrationId_idx" ON "Attendee"("registrationId");
-- CreateIndex
CREATE INDEX "Attendee_registrationOptionId_idx" ON "Attendee"("registrationOptionId");
-- CreateIndex
CREATE INDEX "Attendee_userId_idx" ON "Attendee"("userId");
-- CreateIndex
CREATE UNIQUE INDEX "Ticket_attendeeId_key" ON "Ticket"("attendeeId");
-- CreateIndex
CREATE INDEX "Ticket_holderUserId_idx" ON "Ticket"("holderUserId");
-- AddForeignKey
ALTER TABLE "Ticket" ADD CONSTRAINT "Ticket_attendeeId_fkey" FOREIGN KEY ("attendeeId") REFERENCES "Attendee"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Ticket" ADD CONSTRAINT "Ticket_holderUserId_fkey" FOREIGN KEY ("holderUserId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "FormResponse" ADD CONSTRAINT "FormResponse_attendeeId_fkey" FOREIGN KEY ("attendeeId") REFERENCES "Attendee"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Attendee" ADD CONSTRAINT "Attendee_registrationId_fkey" FOREIGN KEY ("registrationId") REFERENCES "Registration"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Attendee" ADD CONSTRAINT "Attendee_registrationOptionId_fkey" FOREIGN KEY ("registrationOptionId") REFERENCES "RegistrationOption"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Attendee" ADD CONSTRAINT "Attendee_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
@@ -0,0 +1,71 @@
-- CreateEnum
CREATE TYPE "ConsentStatus" AS ENUM ('pending', 'given', 'declined');
-- AlterEnum
-- This migration adds more than one value to an enum.
-- With PostgreSQL versions 11 and earlier, this is not possible
-- in a single migration. This can be worked around by creating
-- multiple migrations, each migration adding only one value to
-- the enum.
ALTER TYPE "AdminAuditAction" ADD VALUE 'consent_override';
ALTER TYPE "AdminAuditAction" ADD VALUE 'consent_rerequested';
ALTER TYPE "AdminAuditAction" ADD VALUE 'medical_list_viewed';
-- AlterTable
ALTER TABLE "Attendee" ADD COLUMN "parentEmail" TEXT,
ADD COLUMN "parentName" TEXT,
ADD COLUMN "parentPhone" TEXT;
-- AlterTable
ALTER TABLE "Event" ADD COLUMN "consentCollectEmergency" BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN "consentCollectMedical" BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN "consentCollectPhoto" BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN "consentText" TEXT,
ADD COLUMN "consentTextVersion" INTEGER NOT NULL DEFAULT 1,
ADD COLUMN "requiresParentalConsent" BOOLEAN NOT NULL DEFAULT false;
-- CreateTable
CREATE TABLE "ConsentRequest" (
"id" TEXT NOT NULL,
"attendeeId" TEXT NOT NULL,
"eventId" TEXT NOT NULL,
"tokenHash" TEXT NOT NULL,
"status" "ConsentStatus" NOT NULL DEFAULT 'pending',
"textVersion" INTEGER NOT NULL,
"consentText" TEXT NOT NULL,
"parentName" TEXT,
"parentEmail" TEXT,
"parentPhone" TEXT,
"signedName" TEXT,
"ip" TEXT,
"device" TEXT,
"medical" TEXT,
"emergency" TEXT,
"photoConsent" BOOLEAN,
"respondedAt" TIMESTAMP(3),
"lastSentAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"remindersSent" INTEGER NOT NULL DEFAULT 0,
"expiresAt" TIMESTAMP(3) NOT NULL,
"medicalPurgedAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "ConsentRequest_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "ConsentRequest_tokenHash_key" ON "ConsentRequest"("tokenHash");
-- CreateIndex
CREATE INDEX "ConsentRequest_attendeeId_createdAt_idx" ON "ConsentRequest"("attendeeId", "createdAt");
-- CreateIndex
CREATE INDEX "ConsentRequest_eventId_status_idx" ON "ConsentRequest"("eventId", "status");
-- AddForeignKey
ALTER TABLE "ConsentRequest" ADD CONSTRAINT "ConsentRequest_attendeeId_fkey" FOREIGN KEY ("attendeeId") REFERENCES "Attendee"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ConsentRequest" ADD CONSTRAINT "ConsentRequest_eventId_fkey" FOREIGN KEY ("eventId") REFERENCES "Event"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,63 @@
-- CreateEnum
CREATE TYPE "WaitlistStatus" AS ENUM ('waiting', 'offered', 'claimed', 'expired', 'removed', 'left');
-- AlterTable
ALTER TABLE "Event" ADD COLUMN "waitlistEnabled" BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN "waitlistOfferHours" INTEGER NOT NULL DEFAULT 24,
ADD COLUMN "waitlistPartialOffers" BOOLEAN NOT NULL DEFAULT true;
-- CreateTable
CREATE TABLE "WaitlistEntry" (
"id" TEXT NOT NULL,
"eventId" TEXT NOT NULL,
"eventOptionId" TEXT NOT NULL,
"variantId" TEXT,
"userId" TEXT NOT NULL,
"quantity" INTEGER NOT NULL,
"position" INTEGER NOT NULL,
"status" "WaitlistStatus" NOT NULL DEFAULT 'waiting',
"offeredQuantity" INTEGER,
"offerTokenHash" TEXT,
"offeredAt" TIMESTAMP(3),
"offerExpiresAt" TIMESTAMP(3),
"claimedAt" TIMESTAMP(3),
"registrationId" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "WaitlistEntry_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "WaitlistEntry_offerTokenHash_key" ON "WaitlistEntry"("offerTokenHash");
-- CreateIndex
CREATE INDEX "WaitlistEntry_eventId_status_position_idx" ON "WaitlistEntry"("eventId", "status", "position");
-- CreateIndex
CREATE INDEX "WaitlistEntry_eventOptionId_status_idx" ON "WaitlistEntry"("eventOptionId", "status");
-- CreateIndex
CREATE INDEX "WaitlistEntry_variantId_idx" ON "WaitlistEntry"("variantId");
-- CreateIndex
CREATE INDEX "WaitlistEntry_userId_idx" ON "WaitlistEntry"("userId");
-- CreateIndex
CREATE INDEX "WaitlistEntry_registrationId_idx" ON "WaitlistEntry"("registrationId");
-- AddForeignKey
ALTER TABLE "WaitlistEntry" ADD CONSTRAINT "WaitlistEntry_eventId_fkey" FOREIGN KEY ("eventId") REFERENCES "Event"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "WaitlistEntry" ADD CONSTRAINT "WaitlistEntry_eventOptionId_fkey" FOREIGN KEY ("eventOptionId") REFERENCES "EventOption"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "WaitlistEntry" ADD CONSTRAINT "WaitlistEntry_variantId_fkey" FOREIGN KEY ("variantId") REFERENCES "OptionVariant"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "WaitlistEntry" ADD CONSTRAINT "WaitlistEntry_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "WaitlistEntry" ADD CONSTRAINT "WaitlistEntry_registrationId_fkey" FOREIGN KEY ("registrationId") REFERENCES "Registration"("id") ON DELETE SET NULL ON UPDATE CASCADE;
@@ -0,0 +1,93 @@
-- AlterEnum
-- This migration adds more than one value to an enum.
-- With PostgreSQL versions 11 and earlier, this is not possible
-- in a single migration. This can be worked around by creating
-- multiple migrations, each migration adding only one value to
-- the enum.
ALTER TYPE "AdminAuditAction" ADD VALUE 'scan_lock_acquired';
ALTER TYPE "AdminAuditAction" ADD VALUE 'scan_lock_released';
ALTER TYPE "AdminAuditAction" ADD VALUE 'scan_lock_force_released';
ALTER TYPE "AdminAuditAction" ADD VALUE 'offline_sync';
-- AlterTable
ALTER TABLE "TicketUsage" ADD COLUMN "clientScanId" TEXT,
ADD COLUMN "offline" BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN "scanLockId" TEXT,
ADD COLUMN "syncedAt" TIMESTAMP(3);
-- CreateTable
CREATE TABLE "ScanLock" (
"id" TEXT NOT NULL,
"eventId" TEXT NOT NULL,
"sectionId" TEXT,
"deviceId" TEXT NOT NULL,
"deviceName" TEXT,
"userId" TEXT NOT NULL,
"lockedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"snapshotAt" TIMESTAMP(3),
"lastSyncAt" TIMESTAMP(3),
"releasedAt" TIMESTAMP(3),
"forceReleasedById" TEXT,
CONSTRAINT "ScanLock_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "OfflineSyncReport" (
"id" TEXT NOT NULL,
"scanLockId" TEXT NOT NULL,
"accepted" INTEGER NOT NULL DEFAULT 0,
"duplicates" INTEGER NOT NULL DEFAULT 0,
"conflicts" JSONB NOT NULL DEFAULT '[]',
"afterForceRelease" BOOLEAN NOT NULL DEFAULT false,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"reviewedAt" TIMESTAMP(3),
"reviewedById" TEXT,
CONSTRAINT "OfflineSyncReport_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "ScanLock_eventId_releasedAt_idx" ON "ScanLock"("eventId", "releasedAt");
-- CreateIndex
CREATE INDEX "ScanLock_userId_idx" ON "ScanLock"("userId");
-- CreateIndex
CREATE INDEX "ScanLock_sectionId_idx" ON "ScanLock"("sectionId");
-- CreateIndex
CREATE INDEX "ScanLock_forceReleasedById_idx" ON "ScanLock"("forceReleasedById");
-- CreateIndex
CREATE INDEX "OfflineSyncReport_scanLockId_idx" ON "OfflineSyncReport"("scanLockId");
-- CreateIndex
CREATE INDEX "OfflineSyncReport_reviewedById_idx" ON "OfflineSyncReport"("reviewedById");
-- CreateIndex
CREATE UNIQUE INDEX "TicketUsage_clientScanId_key" ON "TicketUsage"("clientScanId");
-- AddForeignKey
ALTER TABLE "TicketUsage" ADD CONSTRAINT "TicketUsage_scanLockId_fkey" FOREIGN KEY ("scanLockId") REFERENCES "ScanLock"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ScanLock" ADD CONSTRAINT "ScanLock_eventId_fkey" FOREIGN KEY ("eventId") REFERENCES "Event"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ScanLock" ADD CONSTRAINT "ScanLock_sectionId_fkey" FOREIGN KEY ("sectionId") REFERENCES "Section"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ScanLock" ADD CONSTRAINT "ScanLock_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ScanLock" ADD CONSTRAINT "ScanLock_forceReleasedById_fkey" FOREIGN KEY ("forceReleasedById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "OfflineSyncReport" ADD CONSTRAINT "OfflineSyncReport_scanLockId_fkey" FOREIGN KEY ("scanLockId") REFERENCES "ScanLock"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "OfflineSyncReport" ADD CONSTRAINT "OfflineSyncReport_reviewedById_fkey" FOREIGN KEY ("reviewedById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
+460 -71
View File
@@ -51,6 +51,13 @@ enum SecurityEventType {
login
password_changed
password_reset
two_factor_enabled
two_factor_disabled
}
enum TwoFactorMethod {
email
whatsapp
}
model User {
@@ -67,6 +74,8 @@ model User {
lockUntil DateTime?
tokenVersion Int @default(0)
notificationPreference NotificationPreference @default(email)
twoFactorEnabled Boolean @default(false)
twoFactorMethod TwoFactorMethod?
registrations Registration[]
payments Payment[]
paymentsRecorded Payment[] @relation("PaymentRecordedBy")
@@ -82,59 +91,94 @@ 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[]
scanLocks ScanLock[] @relation("ScanLockOwner")
scanLocksForced ScanLock[] @relation("ScanLockForcedBy")
syncReportsDone OfflineSyncReport[] @relation("SyncReportReviewer")
twoFactorChallenges TwoFactorChallenge[]
trustedDevices TrustedDevice[]
}
model Event {
id String @id @default(uuid())
title String
description String?
startDate DateTime
endDate DateTime
registrationDeadline DateTime?
goLiveAt DateTime @default(now())
price Float
picture String?
isActive Boolean @default(true)
isHidden Boolean @default(false)
requiresAuth Boolean @default(true)
requiresRegistration 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[]
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[]
scanLocks ScanLock[]
@@index([createdById])
@@index([closedById])
@@ -148,26 +192,30 @@ 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[]
@@ -181,10 +229,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])
}
@@ -195,6 +247,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)
@@ -203,6 +261,9 @@ model Registration {
payments Payment[]
formResponses FormResponse[]
formDrafts FormDraft[]
couponRedemptions CouponRedemption[]
attendees Attendee[]
waitlistEntries WaitlistEntry[]
@@index([userId])
@@index([eventId])
@@ -212,19 +273,20 @@ model Registration {
}
model RegistrationOption {
id String @id @default(uuid())
id String @id @default(uuid())
registrationId String
eventOptionId String
quantity Int @default(1)
quantity Int @default(1)
variantId String?
appliedTierId String?
priceSnapshot Float?
registration Registration @relation(fields: [registrationId], references: [id], onDelete: Restrict)
eventOption EventOption @relation(fields: [eventOptionId], references: [id], onDelete: Restrict)
variant OptionVariant? @relation(fields: [variantId], references: [id], onDelete: SetNull)
appliedTier EarlyBirdTier? @relation(fields: [appliedTierId], references: [id], onDelete: SetNull)
registration Registration @relation(fields: [registrationId], references: [id], onDelete: Restrict)
eventOption EventOption @relation(fields: [eventOptionId], references: [id], onDelete: Restrict)
variant OptionVariant? @relation(fields: [variantId], references: [id], onDelete: SetNull)
appliedTier EarlyBirdTier? @relation(fields: [appliedTierId], references: [id], onDelete: SetNull)
tickets Ticket[]
tranches RegistrationOptionTranche[]
attendees Attendee[]
@@index([registrationId])
@@index([eventOptionId])
@@ -240,14 +302,14 @@ model RegistrationOption {
// mirror the most recently added tranche) for the many call sites that only need "how many"
// or a single display price.
model RegistrationOptionTranche {
id String @id @default(uuid())
registrationOptionId String
quantity Int
priceSnapshot Float
appliedTierId String?
createdAt DateTime @default(now())
registrationOption RegistrationOption @relation(fields: [registrationOptionId], references: [id], onDelete: Cascade)
appliedTier EarlyBirdTier? @relation(fields: [appliedTierId], references: [id], onDelete: SetNull)
id String @id @default(uuid())
registrationOptionId String
quantity Int
priceSnapshot Float
appliedTierId String?
createdAt DateTime @default(now())
registrationOption RegistrationOption @relation(fields: [registrationOptionId], references: [id], onDelete: Cascade)
appliedTier EarlyBirdTier? @relation(fields: [appliedTierId], references: [id], onDelete: SetNull)
@@index([registrationOptionId])
@@index([appliedTierId])
@@ -291,6 +353,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)
@@ -298,6 +366,7 @@ model Ticket {
event Event @relation(fields: [eventId], references: [id], onDelete: Restrict)
usages TicketUsage[]
@@index([holderUserId])
@@index([eventId])
@@index([userId])
@@index([registrationOptionId])
@@ -305,13 +374,20 @@ model Ticket {
}
model TicketUsage {
id String @id @default(uuid())
id String @id @default(uuid())
ticketId String
scannedById String
scannedAt DateTime @default(now())
quantityRedeemed Int @default(1)
ticket Ticket @relation(fields: [ticketId], references: [id], onDelete: Restrict)
scannedBy User @relation(fields: [scannedById], references: [id], onDelete: Restrict)
scannedAt DateTime @default(now())
quantityRedeemed Int @default(1)
// Offline scanning: the device's id for the scan (makes syncing idempotent), and
// the offline lock it was made under.
clientScanId String? @unique
offline Boolean @default(false)
scanLockId String?
syncedAt DateTime?
ticket Ticket @relation(fields: [ticketId], references: [id], onDelete: Restrict)
scannedBy User @relation(fields: [scannedById], references: [id], onDelete: Restrict)
scanLock ScanLock? @relation(fields: [scanLockId], references: [id], onDelete: SetNull)
@@index([ticketId])
@@index([scannedAt])
@@ -330,6 +406,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.
@@ -355,6 +465,17 @@ enum AdminAuditAction {
event_updated
event_deleted
settings_updated
coupon_created
coupon_updated
coupon_disabled
coupon_applied_manual
consent_override
consent_rerequested
medical_list_viewed
scan_lock_acquired
scan_lock_released
scan_lock_force_released
offline_sync
}
// Append-only audit trail for admin/supervisor-initiated actions with money or
@@ -362,16 +483,16 @@ enum AdminAuditAction {
// fixed enum). Nullable FK with SetNull mirrors SecurityEvent's pattern so entries
// survive account close/anonymization.
model AdminAuditLog {
id String @id @default(uuid())
id String @id @default(uuid())
actorId String?
actor User? @relation("AdminAuditActor", fields: [actorId], references: [id], onDelete: SetNull)
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())
createdAt DateTime @default(now())
@@index([actorId, createdAt])
@@index([action, createdAt])
@@ -432,6 +553,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])
@@ -493,6 +616,7 @@ model Section {
event Event @relation(fields: [eventId], references: [id], onDelete: Cascade)
allowedOptions SectionOption[]
scanLocks ScanLock[]
@@index([eventId])
}
@@ -596,11 +720,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])
}
@@ -760,3 +884,268 @@ 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])
}
// ─── Offline scanning ─────────────────────────────────────────────────────────
// Locks a section (or the whole event, sectionId null) to one device for offline
// scanning, so two devices can never redeem the same ticket while offline.
// One active (releasedAt null) lock per scope — enforced in utils/scanLocks.js.
model ScanLock {
id String @id @default(uuid())
eventId String
sectionId String?
deviceId String
deviceName String?
userId String
lockedAt DateTime @default(now())
snapshotAt DateTime?
lastSyncAt DateTime?
releasedAt DateTime?
forceReleasedById String?
event Event @relation(fields: [eventId], references: [id], onDelete: Cascade)
section Section? @relation(fields: [sectionId], references: [id], onDelete: Cascade)
user User @relation("ScanLockOwner", fields: [userId], references: [id], onDelete: Cascade)
forceReleasedBy User? @relation("ScanLockForcedBy", fields: [forceReleasedById], references: [id], onDelete: SetNull)
usages TicketUsage[]
syncReports OfflineSyncReport[]
@@index([eventId, releasedAt])
@@index([userId])
@@index([sectionId])
@@index([forceReleasedById])
}
// One sync of queued offline scans; `conflicts` lists scans that needed a person to look.
model OfflineSyncReport {
id String @id @default(uuid())
scanLockId String
accepted Int @default(0)
duplicates Int @default(0)
conflicts Json @default("[]")
afterForceRelease Boolean @default(false)
createdAt DateTime @default(now())
reviewedAt DateTime?
reviewedById String?
scanLock ScanLock @relation(fields: [scanLockId], references: [id], onDelete: Cascade)
reviewedBy User? @relation("SyncReportReviewer", fields: [reviewedById], references: [id], onDelete: SetNull)
@@index([scanLockId])
@@index([reviewedById])
}
+3 -2
View File
@@ -1,9 +1,10 @@
const jwt = require('jsonwebtoken');
const bcrypt = require('bcryptjs');
const generateToken = (userId, role, tokenVersion = 0) => {
// `extra` adds claims, e.g. { setup: true } for the first-run setup wizard's token.
const generateToken = (userId, role, tokenVersion = 0, extra = {}) => {
return jwt.sign(
{ id: userId, role, tokenVersion },
{ ...extra, id: userId, role, tokenVersion },
process.env.JWT_SECRET,
{ expiresIn: '30d' }
);
@@ -0,0 +1,264 @@
const prisma = require('../config/db');
const { safeErrorMessage } = require('../utils/errorUtils');
const { logAdminAction } = require('../utils/adminAudit');
const { getClientIp } = require('../utils/requestUtils');
const { describeUserAgent } = require('../utils/userAgent');
const {
isMinor, consentStatusFor, validateConsentAnswer, sealJson, openJson, latestRequestsFor,
findRequestByToken, resendConsentRequest, createConsentRequest,
} = require('../utils/consent');
const STAFF = ['admin', 'supervisor', 'staff'];
const fail = (res, error) => res.status(error.statusCode || (res.statusCode === 200 ? 400 : res.statusCode)).json({ message: safeErrorMessage(error) });
// What the parent sees — never the registrant's contact details or other attendees.
function publicView(request) {
const ev = request.event;
const latestForAttendee = request._isLatest !== false;
return {
status: request.status,
superseded: !latestForAttendee,
expired: new Date(request.expiresAt) < new Date(),
respondedAt: request.respondedAt,
attendeeName: request.attendee.name,
parentName: request.parentName,
consentText: request.consentText,
textVersion: request.textVersion,
event: { title: ev.title, startDate: ev.startDate, endDate: ev.endDate, location: ev.location },
collect: { medical: ev.consentCollectMedical, emergency: ev.consentCollectEmergency, photo: ev.consentCollectPhoto },
};
}
// @desc Consent page data for a parent/guardian
// @route GET /api/consent/:token
// @access Public (the token is the credential)
const getConsent = async (req, res) => {
try {
const request = await findRequestByToken(req.params.token);
if (!request) { res.status(404); throw new Error('This consent link is not valid. It may have been replaced by a newer one — check your latest message.'); }
const latest = (await latestRequestsFor([request.attendeeId])).get(request.attendeeId);
res.json(publicView({ ...request, _isLatest: latest?.id === request.id }));
} catch (error) {
fail(res, error);
}
};
// @desc Give or decline consent
// @route POST /api/consent/:token
// @access Public (the token is the credential)
const answerConsent = async (req, res) => {
try {
const request = await findRequestByToken(req.params.token);
if (!request) { res.status(404); throw new Error('This consent link is not valid.'); }
if (request.status !== 'pending') { res.status(409); throw new Error(`Consent has already been ${request.status}.`); }
if (new Date(request.expiresAt) < new Date()) { res.status(410); throw new Error('This consent link has expired because the event has ended.'); }
const latest = (await latestRequestsFor([request.attendeeId])).get(request.attendeeId);
if (latest?.id !== request.id) { res.status(409); throw new Error('A newer consent request replaced this one — please use the link in your latest message.'); }
let answer;
try { answer = validateConsentAnswer(req.body || {}, request.event); } catch (e) { res.status(400); throw e; }
// Only flips pending → answered once, even if the form is submitted twice at once.
const updated = await prisma.consentRequest.updateMany({
where: { id: request.id, status: 'pending' },
data: {
status: answer.decision === 'give' ? 'given' : 'declined',
signedName: answer.signedName,
ip: getClientIp(req),
device: describeUserAgent(req.headers['user-agent']),
medical: sealJson(answer.medical),
emergency: sealJson(answer.emergency),
photoConsent: answer.photoConsent,
respondedAt: new Date(),
},
});
if (updated.count !== 1) { res.status(409); throw new Error('Consent has already been answered.'); }
notifyConsentAnswered(request.id).catch(e => console.error('[consent] notify failed:', e?.message || e));
res.json({ message: answer.decision === 'give' ? 'Thank you — consent has been given.' : 'Thank you — consent has been declined.', status: answer.decision === 'give' ? 'given' : 'declined' });
} catch (error) {
fail(res, error);
}
};
// Tells the registrant and the event's notify recipients how a parent answered.
async function notifyConsentAnswered(requestId) {
const request = await prisma.consentRequest.findUnique({
where: { id: requestId },
include: {
attendee: { include: { registration: { include: { user: { select: { id: true, name: true, email: true, phoneNumber: true, notificationPreference: true } } } } } },
event: { include: { createdBy: { select: { id: true, name: true, email: true } }, notifyRecipients: { select: { id: true, name: true, email: true } } } },
},
});
if (!request) return;
const { sendMail, emailWrapper } = require('../utils/email');
const { shouldEmail, waText } = require('../utils/notify');
const { buildEventNotifyRecipientList } = require('../utils/notifications');
const given = request.status === 'given';
const verb = given ? 'given' : 'declined';
const who = request.attendee.name;
const ev = request.event.title;
const payer = request.attendee.registration.user;
const line = given
? `${request.signedName} gave parental consent for ${who} for ${ev}.`
: `${request.signedName} declined parental consent for ${who} for ${ev}. ${who} can't be checked in — please contact the organisers about a refund.`;
const sends = [];
const payerHasEmail = payer?.email && !payer.email.endsWith('@guest.local') && !payer.email.endsWith('@deleted.invalid');
if (payerHasEmail && shouldEmail(payer) && payer.email !== request.parentEmail) {
sends.push(sendMail({ to: payer.email, subject: `Consent ${verb} for ${who}`, html: emailWrapper(`<p>${line}</p>`), text: line }));
}
if (payer) sends.push(waText(payer, line));
const staff = buildEventNotifyRecipientList(request.event);
if (staff.length) {
sends.push(sendMail({ to: staff.join(','), subject: `Consent ${verb}: ${who} — ${ev}`, html: emailWrapper(`<p>${line}</p>`), text: line }));
}
await Promise.allSettled(sends);
}
/** One row per attendee on the event, with consent state — for the consent tracker. */
async function consentRows(eventId) {
const event = await prisma.event.findUnique({ where: { id: eventId } });
if (!event) return { event: null, rows: [] };
const attendees = await prisma.attendee.findMany({
where: { registration: { eventId, status: { not: 'cancelled' } } },
include: { registration: { select: { id: true, status: true, user: { select: { id: true, name: true, email: true } } } } },
orderBy: [{ registrationId: 'asc' }, { order: 'asc' }],
});
const latest = await latestRequestsFor(attendees.map(a => a.id));
const rows = attendees.map(a => {
const r = latest.get(a.id) || null;
return {
attendeeId: a.id,
name: a.name,
dateOfBirth: a.dateOfBirth,
minor: isMinor(a.dateOfBirth, event.startDate),
parentName: a.parentName, parentEmail: a.parentEmail, parentPhone: a.parentPhone,
registrationId: a.registrationId,
registrationStatus: a.registration.status,
bookedBy: a.registration.user,
status: consentStatusFor(a, event, r),
request: r && {
id: r.id, status: r.status, textVersion: r.textVersion, signedName: r.signedName, respondedAt: r.respondedAt,
lastSentAt: r.lastSentAt, remindersSent: r.remindersSent, photoConsent: r.photoConsent,
outdated: r.status === 'given' && r.textVersion < (event.consentTextVersion || 1),
},
};
});
return { event, rows };
}
// @desc Consent status for every attendee on an event
// @route GET /api/consent/events/:eventId
// @access Private/Staff
const listEventConsent = async (req, res) => {
try {
const { event, rows } = await consentRows(req.params.eventId);
if (!event) { res.status(404); throw new Error('Event not found'); }
const counts = rows.reduce((c, r) => { c[r.status] = (c[r.status] || 0) + 1; return c; }, {});
res.json({
event: { id: event.id, title: event.title, startDate: event.startDate, requiresParentalConsent: event.requiresParentalConsent, consentTextVersion: event.consentTextVersion },
counts,
rows,
});
} catch (error) {
fail(res, error);
}
};
// @desc Re-send one consent request (with a fresh link)
// @route POST /api/consent/requests/:id/resend
// @access Private (the registrant, or staff)
const resendRequest = async (req, res) => {
try {
const request = await prisma.consentRequest.findUnique({ where: { id: req.params.id }, include: { attendee: { include: { registration: true } } } });
if (!request) { res.status(404); throw new Error('Consent request not found'); }
if (request.attendee.registration.userId !== req.user.id && !STAFF.includes(req.user.role)) { res.status(403); throw new Error('Not authorized'); }
const latest = (await latestRequestsFor([request.attendeeId])).get(request.attendeeId);
if (latest?.id !== request.id) { res.status(400); throw new Error('A newer consent request has replaced this one.'); }
const sent = await resendConsentRequest(request.id);
res.json({ message: sent.length ? `Consent request re-sent by ${sent.join(' and ')}.` : 'The consent request could not be sent — check the parent\'s email and phone.' });
} catch (error) {
fail(res, error);
}
};
// @desc Re-send every consent request still pending on an event
// @route POST /api/consent/events/:eventId/resend-pending
// @access Private/Supervisor
const resendPending = async (req, res) => {
try {
const { rows } = await consentRows(req.params.eventId);
const pending = rows.filter(r => r.status === 'pending' && r.request);
let sent = 0;
for (const r of pending) {
try { if ((await resendConsentRequest(r.request.id)).length) sent += 1; } catch {}
}
res.json({ message: `Re-sent ${sent} of ${pending.length} pending request(s).`, sent, pending: pending.length });
} catch (error) {
fail(res, error);
}
};
// @desc Ask again for consent where it was given on an older version of the text,
// and for minors who never got a request (e.g. names added later)
// @route POST /api/consent/events/:eventId/rerequest
// @access Private/Supervisor
const rerequestConsent = async (req, res) => {
try {
const { event, rows } = await consentRows(req.params.eventId);
if (!event) { res.status(404); throw new Error('Event not found'); }
if (!event.requiresParentalConsent) { res.status(400); throw new Error('This event does not need parental consent.'); }
const targets = rows.filter(r => r.minor && r.registrationStatus !== 'cancelled' && (r.status === 'missing' || r.request?.outdated));
let created = 0;
for (const r of targets) {
if (!r.parentEmail && !r.parentPhone) continue;
const attendee = await prisma.attendee.findUnique({ where: { id: r.attendeeId } });
await createConsentRequest(attendee, event);
created += 1;
}
logAdminAction({
actorId: req.user.id, actorRole: req.user.role, action: 'consent_rerequested',
targetType: 'Event', targetId: event.id, metadata: { requests: created, textVersion: event.consentTextVersion },
ip: getClientIp(req),
});
res.json({ message: `Sent ${created} new consent request(s).`, created });
} catch (error) {
fail(res, error);
}
};
// @desc Medical & emergency list for an event (decrypted) — every view is audited
// @route GET /api/consent/events/:eventId/medical
// @access Private/Supervisor
const medicalList = async (req, res) => {
try {
const event = await prisma.event.findUnique({ where: { id: req.params.eventId } });
if (!event) { res.status(404); throw new Error('Event not found'); }
const { rows } = await consentRows(event.id);
const ids = rows.map(r => r.request?.id).filter(Boolean);
const full = ids.length ? await prisma.consentRequest.findMany({ where: { id: { in: ids } } }) : [];
const byId = new Map(full.map(r => [r.id, r]));
const list = rows
.filter(r => r.request && byId.get(r.request.id)?.status === 'given')
.map(r => {
const fr = byId.get(r.request.id);
return {
name: r.name, dateOfBirth: r.dateOfBirth,
parentName: r.parentName, parentPhone: r.parentPhone, parentEmail: r.parentEmail,
medical: openJson(fr.medical), emergency: openJson(fr.emergency), photoConsent: fr.photoConsent,
purged: !!fr.medicalPurgedAt,
};
});
logAdminAction({
actorId: req.user.id, actorRole: req.user.role, action: 'medical_list_viewed',
targetType: 'Event', targetId: event.id, metadata: { attendees: list.length },
ip: getClientIp(req),
});
res.json({ event: { id: event.id, title: event.title, startDate: event.startDate }, rows: list });
} catch (error) {
fail(res, error);
}
};
module.exports = { getConsent, answerConsent, listEventConsent, resendRequest, resendPending, rerequestConsent, medicalList };
+331
View File
@@ -0,0 +1,331 @@
const prisma = require('../config/db');
const { safeErrorMessage } = require('../utils/errorUtils');
const { logAdminAction } = require('../utils/adminAudit');
const { getClientIp } = require('../utils/requestUtils');
const { normalizeCode, previewCoupon, buildCouponLines, describeCoupon, countCouponUses } = require('../utils/coupons');
const { resolveOptionPrice, resolveVariantTierPrice } = require('../utils/pricing');
const TYPES = ['percent', 'fixed', 'comp'];
const ROLES = ['user', 'staff', 'supervisor', 'admin'];
const STAFF_ROLES = ['staff', 'supervisor', 'admin'];
const COUPON_DETAIL_INCLUDE = {
events: { include: { event: { select: { id: true, title: true, startDate: true } } } },
options: { include: { eventOption: { select: { id: true, name: true, eventId: true } }, variant: { select: { id: true, name: true } } } },
allowedUsers: { include: { user: { select: { id: true, name: true, email: true } } } },
createdBy: { select: { id: true, name: true } },
};
const optionalDate = (v, label) => {
if (v === undefined) return undefined;
if (v === null || v === '') return null;
const d = new Date(v);
if (isNaN(d.getTime())) throw new Error(`${label} is not a valid date`);
return d;
};
const optionalInt = (v, label, min = 0) => {
if (v === undefined) return undefined;
if (v === null || v === '') return null;
const n = Number(v);
if (!Number.isInteger(n) || n < min) throw new Error(`${label} must be a whole number of at least ${min}`);
return n;
};
/**
* Turns a create/update body into Prisma data plus the relation lists. On update,
* fields left out of the body are left alone.
*/
async function parseCouponBody(body, { isCreate }) {
const data = {};
if (isCreate || body.code !== undefined) {
const code = normalizeCode(body.code);
if (!/^[A-Z0-9_-]{3,32}$/.test(code)) {
throw new Error('Codes are 3–32 letters, numbers, hyphens or underscores');
}
data.code = code;
}
if (isCreate || body.type !== undefined) {
if (!TYPES.includes(body.type)) throw new Error("Discount type must be 'percent', 'fixed' or 'comp'");
data.type = body.type;
}
if (isCreate || body.value !== undefined) {
const type = data.type || body.type;
const value = type === 'comp' ? 0 : Number(body.value);
if (type !== 'comp' && !(value > 0)) throw new Error('Enter a discount value greater than zero');
if (type === 'percent' && value > 100) throw new Error('A percentage discount can\'t be more than 100');
data.value = value;
}
if (body.description !== undefined) data.description = body.description ? String(body.description).trim() : null;
if (body.stackEarlyBird !== undefined) data.stackEarlyBird = !!body.stackEarlyBird;
if (body.active !== undefined) data.active = !!body.active;
const validFrom = optionalDate(body.validFrom, 'Valid from');
const validUntil = optionalDate(body.validUntil, 'Valid until');
if (validFrom !== undefined) data.validFrom = validFrom;
if (validUntil !== undefined) data.validUntil = validUntil;
if (validFrom && validUntil && validFrom > validUntil) throw new Error('"Valid from" must be before "Valid until"');
const maxUses = optionalInt(body.maxUses, 'Max total uses', 1);
const maxUsesPerUser = optionalInt(body.maxUsesPerUser, 'Max uses per person', 1);
const minQuantity = optionalInt(body.minQuantity, 'Minimum quantity', 1);
if (maxUses !== undefined) data.maxUses = maxUses;
if (maxUsesPerUser !== undefined) data.maxUsesPerUser = maxUsesPerUser;
if (minQuantity !== undefined) data.minQuantity = minQuantity;
if (body.restrictRoles !== undefined) {
const roles = Array.isArray(body.restrictRoles) ? body.restrictRoles : [];
if (roles.some(r => !ROLES.includes(r))) throw new Error('Unknown role in restrictions');
data.restrictRoles = roles;
}
if (body.restrictDomain !== undefined) {
const domain = String(body.restrictDomain || '').trim().toLowerCase().replace(/^@/, '');
if (domain && !/^[a-z0-9.-]+\.[a-z]{2,}$/.test(domain)) throw new Error('Enter an email domain like church.org');
data.restrictDomain = domain || null;
}
const eventIds = body.eventIds !== undefined ? [...new Set((body.eventIds || []).filter(Boolean))] : undefined;
const scopes = body.options !== undefined
? (body.options || []).filter(o => o && o.eventOptionId).map(o => ({ eventOptionId: o.eventOptionId, variantId: o.variantId || null }))
: undefined;
const userIds = body.userIds !== undefined ? [...new Set((body.userIds || []).filter(Boolean))] : undefined;
if (scopes && scopes.length > 0) {
const optionRows = await prisma.eventOption.findMany({
where: { id: { in: scopes.map(s => s.eventOptionId) } },
select: { id: true, eventId: true, variants: { select: { id: true } } },
});
for (const s of scopes) {
const row = optionRows.find(o => o.id === s.eventOptionId);
if (!row) throw new Error('One of the selected ticket options no longer exists');
if (s.variantId && !row.variants.some(v => v.id === s.variantId)) throw new Error('One of the selected variants no longer exists');
// A scoped option implies its event, so the code can't be used elsewhere by mistake.
if (eventIds && eventIds.length > 0 && !eventIds.includes(row.eventId)) {
throw new Error('Selected ticket options must belong to the selected events');
}
}
}
return { data, eventIds, scopes, userIds };
}
function relationWrites({ eventIds, scopes, userIds }, isCreate) {
const out = {};
if (eventIds !== undefined) out.events = { ...(isCreate ? {} : { deleteMany: {} }), create: eventIds.map(eventId => ({ eventId })) };
if (scopes !== undefined) out.options = { ...(isCreate ? {} : { deleteMany: {} }), create: scopes };
if (userIds !== undefined) out.allowedUsers = { ...(isCreate ? {} : { deleteMany: {} }), create: userIds.map(userId => ({ userId })) };
return out;
}
// @desc List coupons with their use counts
// @route GET /api/coupons
// @access Private/Supervisor
const listCoupons = async (req, res) => {
try {
const coupons = await prisma.coupon.findMany({ include: COUPON_DETAIL_INCLUDE, orderBy: { createdAt: 'desc' } });
const totals = await prisma.couponRedemption.groupBy({
by: ['couponId'],
where: { releasedAt: null, registration: { status: { not: 'cancelled' } } },
_count: { _all: true },
_sum: { discountAmount: true },
});
const byId = Object.fromEntries(totals.map(t => [t.couponId, t]));
res.json(coupons.map(c => ({
...c,
summary: describeCoupon(c),
uses: byId[c.id]?._count?._all || 0,
totalDiscount: byId[c.id]?._sum?.discountAmount || 0,
})));
} catch (error) {
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
}
};
// @desc Get one coupon with its redemptions
// @route GET /api/coupons/:id
// @access Private/Supervisor
const getCoupon = async (req, res) => {
try {
const coupon = await prisma.coupon.findUnique({
where: { id: req.params.id },
include: {
...COUPON_DETAIL_INCLUDE,
redemptions: {
orderBy: { createdAt: 'desc' },
include: {
user: { select: { id: true, name: true, email: true } },
appliedBy: { select: { id: true, name: true } },
registration: { select: { id: true, status: true, event: { select: { id: true, title: true } } } },
},
},
},
});
if (!coupon) { res.status(404); throw new Error('Coupon not found'); }
res.json({ ...coupon, summary: describeCoupon(coupon) });
} catch (error) {
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
}
};
// @desc Create a coupon
// @route POST /api/coupons
// @access Private/Supervisor
const createCoupon = async (req, res) => {
try {
let parsed;
try {
parsed = await parseCouponBody(req.body || {}, { isCreate: true });
} catch (e) { res.status(400); throw e; }
if (await prisma.coupon.findUnique({ where: { code: parsed.data.code } })) {
res.status(409); throw new Error(`The code ${parsed.data.code} already exists`);
}
const coupon = await prisma.coupon.create({
data: { ...parsed.data, createdById: req.user.id, ...relationWrites(parsed, true) },
include: COUPON_DETAIL_INCLUDE,
});
logAdminAction({
actorId: req.user.id, actorRole: req.user.role, action: 'coupon_created',
targetType: 'Coupon', targetId: coupon.id,
metadata: { code: coupon.code, type: coupon.type, value: coupon.value },
ip: getClientIp(req),
});
res.status(201).json({ ...coupon, summary: describeCoupon(coupon), uses: 0, totalDiscount: 0 });
} catch (error) {
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
}
};
// @desc Update a coupon (including turning it off). Discounts already applied keep
// their locked amount.
// @route PUT /api/coupons/:id
// @access Private/Supervisor
const updateCoupon = async (req, res) => {
try {
const existing = await prisma.coupon.findUnique({ where: { id: req.params.id } });
if (!existing) { res.status(404); throw new Error('Coupon not found'); }
let parsed;
try {
parsed = await parseCouponBody({ type: existing.type, ...req.body }, { isCreate: false });
} catch (e) { res.status(400); throw e; }
if (parsed.data.code && parsed.data.code !== existing.code) {
const used = await prisma.couponRedemption.count({ where: { couponId: existing.id } });
if (used > 0) { res.status(400); throw new Error('This code has been used, so it can\'t be renamed. Turn it off and create a new one instead.'); }
if (await prisma.coupon.findUnique({ where: { code: parsed.data.code } })) {
res.status(409); throw new Error(`The code ${parsed.data.code} already exists`);
}
}
const coupon = await prisma.coupon.update({
where: { id: existing.id },
data: { ...parsed.data, ...relationWrites(parsed, false) },
include: COUPON_DETAIL_INCLUDE,
});
const disabled = existing.active && coupon.active === false;
const changedKeys = Object.keys(req.body || {});
logAdminAction({
actorId: req.user.id, actorRole: req.user.role,
action: disabled && changedKeys.length === 1 ? 'coupon_disabled' : 'coupon_updated',
targetType: 'Coupon', targetId: coupon.id,
metadata: { code: coupon.code, changedKeys },
ip: getClientIp(req),
});
const { usesTotal } = await countCouponUses(coupon.id, null);
res.json({ ...coupon, summary: describeCoupon(coupon), uses: usesTotal });
} catch (error) {
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
}
};
// @desc Check a code against the items someone is about to register for
// @route POST /api/coupons/validate
// @access Public (login needed for restricted codes)
// Body: { code, eventId, options: [{ eventOptionId, variantId?, quantity }], forUserId? }
// Staff can pass forUserId to check on an attendee's behalf, or leave it out for a new
// attendee — restricted codes are then checked when the registration is saved.
const validateCode = async (req, res) => {
try {
const { code, eventId, options, forUserId } = req.body || {};
if (!code || !eventId || !Array.isArray(options) || options.length === 0) {
res.status(400); throw new Error('code, eventId and options are required');
}
const event = await prisma.event.findUnique({
where: { id: eventId },
include: { eventOptions: { include: { earlyBirdTiers: true, variants: true } } },
});
if (!event) { res.status(404); throw new Error('Event not found'); }
const isStaff = req.user && STAFF_ROLES.includes(req.user.role);
let buyer = req.user ? { id: req.user.id, role: req.user.role, email: req.user.email } : null;
if (isStaff && forUserId) {
buyer = await prisma.user.findUnique({ where: { id: forUserId }, select: { id: true, role: true, email: true } });
} else if (isStaff && forUserId === null) {
buyer = null; // a walk-in without an account yet
}
// Price the basket the same way registration will.
const registrationOptions = [];
for (const o of options) {
const eo = event.eventOptions.find(x => x.id === o.eventOptionId);
const qty = Number(o.quantity || 0);
if (!eo || qty < 1) continue;
const resolved = o.variantId
? await resolveVariantTierPrice(eo, o.variantId, qty, buyer)
: await resolveOptionPrice(eo, qty, buyer);
registrationOptions.push({
eventOptionId: eo.id, variantId: o.variantId || null, quantity: qty, eventOption: eo,
tranches: [{ quantity: qty, priceSnapshot: resolved.price, appliedTierId: resolved.tierId }],
});
}
const lines = buildCouponLines({ registrationOptions });
const subtotal = lines.reduce((s, l) => s + l.unitPrice * l.quantity, 0);
try {
const { coupon, discount } = await previewCoupon({
code, eventId, user: buyer, lines,
deferRestrictions: isStaff && !buyer,
});
res.json({
valid: true,
code: coupon.code,
type: coupon.type,
value: coupon.value,
summary: describeCoupon(coupon),
description: coupon.description,
discount,
subtotal,
total: Math.max(0, subtotal - discount),
});
} catch (e) {
if (!e.isCouponError) throw e;
res.status(400).json({ valid: false, message: e.message });
}
} catch (error) {
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
}
};
// @desc Coupon uses for the Discounts report
// @route GET /api/coupons/redemptions?eventIds=a,b&from=&to=
// @access Private/Supervisor
const listRedemptions = async (req, res) => {
try {
const eventIds = String(req.query.eventIds || '').split(',').map(s => s.trim()).filter(Boolean);
const from = req.query.from ? new Date(req.query.from) : null;
const to = req.query.to ? new Date(req.query.to) : null;
const where = {
releasedAt: null,
registration: { status: { not: 'cancelled' }, ...(eventIds.length ? { eventId: { in: eventIds } } : {}) },
...(from || to ? { createdAt: { ...(from && !isNaN(from) ? { gte: from } : {}), ...(to && !isNaN(to) ? { lte: to } : {}) } } : {}),
};
const rows = await prisma.couponRedemption.findMany({
where,
orderBy: { createdAt: 'desc' },
include: {
coupon: { select: { id: true, code: true, type: true, value: true } },
user: { select: { id: true, name: true, email: true } },
appliedBy: { select: { id: true, name: true } },
registration: { select: { id: true, status: true, event: { select: { id: true, title: true } } } },
},
});
res.json(rows.map(r => ({ ...r, summary: describeCoupon(r.coupon) })));
} catch (error) {
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
}
};
module.exports = { listCoupons, getCoupon, createCoupon, updateCoupon, validateCode, listRedemptions };
+57 -24
View File
@@ -44,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, requiresRegistration, contactName, contactPhone, contactEmail, location } = 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(),
@@ -62,6 +62,16 @@ const createEvent = async (req, res) => {
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,
@@ -212,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 };
@@ -418,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) };
}));
@@ -537,7 +536,7 @@ const updateEvent = async (req, res) => {
ip: getClientIp(req),
});
const { title, description, startDate, endDate, registrationDeadline, goLiveAt, price, picture, isActive, redirectUrl, isHidden, requiresAuth, requiresRegistration, contactName, contactPhone, contactEmail, location } = req.body;
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,
@@ -552,6 +551,21 @@ const updateEvent = async (req, res) => {
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,
@@ -718,6 +732,15 @@ const deleteEvent = async (req, res) => {
}
};
// 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
@@ -734,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: {
@@ -744,6 +767,7 @@ const createEventOption = async (req, res) => {
price: parseFloat(price),
isMainTicket: isMainTicket || false,
stockLimit: stockLimit ? parseInt(stockLimit, 10) : 0,
staffPrice: parseStaffPrice(staffPrice),
}
});
@@ -770,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 },
@@ -779,6 +803,7 @@ const updateEventOption = async (req, res) => {
price: price !== undefined ? parseFloat(price) : eventOption.price,
isMainTicket: isMainTicket !== undefined ? isMainTicket : eventOption.isMainTicket,
stockLimit: stockLimit !== undefined ? parseInt(stockLimit, 10) : eventOption.stockLimit,
staffPrice: parseStaffPrice(staffPrice, eventOption.staffPrice),
}
});
@@ -847,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({
@@ -860,6 +886,7 @@ const updateEventOption = async (req, res) => {
name: v.name,
price: variantPrice,
stockLimit: variantStockLimit,
staffPrice: variantStaffPrice,
order: i,
}
});
@@ -867,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' } } }
@@ -937,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: {
@@ -946,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,
}
});
@@ -963,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,
+23 -35
View File
@@ -913,6 +913,25 @@ const unassignDonationFromRegistration = async (req, res) => {
}
};
/**
* Yoco line items for a registration: one per item at each price actually charged
* (tranche-aware, so a line bought partly at early-bird shows both prices), plus the
* coupon discount as a negative line. Previous/partial payment lines are added by callers.
*/
function checkoutLineItems(registration) {
const { buildLineItems, couponDiscountLine } = require('../utils/pdfDocs');
const items = buildLineItems(registration.registrationOptions).map(row => ({
displayName: row.label + (row.isEarlyBird ? ' (early bird)' : ''),
quantity: row.quantity,
pricingDetails: { price: Math.round(row.unitPrice * 100) },
}));
const coupon = couponDiscountLine(registration);
if (coupon) {
items.push({ displayName: coupon.label, quantity: 1, pricingDetails: { price: -Math.round(coupon.amount * 100) } });
}
return items;
}
// Internal helper: create a Yoco checkout for a registration and return { checkoutId, redirectUrl, amount }
// Does NOT check user authorization — callers are responsible for ensuring the user owns the registration.
async function createRegistrationCheckoutInternal(registrationId, userId, { successUrl, cancelUrl, failureUrl } = {}) {
@@ -927,6 +946,7 @@ async function createRegistrationCheckoutInternal(registrationId, userId, { succ
}
},
payments: true,
couponRedemptions: { where: { releasedAt: null }, include: { coupon: { select: { code: true } } } },
user: { select: { id: true, name: true, email: true } },
event: true
}
@@ -942,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) {
@@ -1065,6 +1066,7 @@ const createYocoCheckout = async (req, res) => {
}
},
payments: true,
couponRedemptions: { where: { releasedAt: null }, include: { coupon: { select: { code: true } } } },
user: { select: { id: true, name: true, email: true } },
event: true
}
@@ -1078,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) } });
+583 -122
View File
@@ -7,35 +7,101 @@ const { resolveOptionPrice, resolveVariantTierPrice, computeRegistrationTotalDue
const { assertEventOpen } = require('../utils/cashupUtils');
const { logAdminAction } = require('../utils/adminAudit');
const { getClientIp } = require('../utils/requestUtils');
const { buildCouponLines, previewCoupon, applyCouponToRegistration, recalculateRegistrationDiscount, releaseRegistrationCoupons, describeCoupon } = require('../utils/coupons');
const { lineKey, validateAttendees, createAttendees, namedPlacesByKey } = require('../utils/attendees');
const { findAccountByContact } = require('../utils/accountMatch');
const { ensureConsentRequests } = require('../utils/consent');
const { takenCount } = require('../utils/stock');
const { findOfferByToken, queueWaitlist } = require('../utils/waitlist');
// Youth events: send parental consent requests for any new under-18 attendees. Runs in
// the background so a slow mail/WhatsApp send never holds up the registration.
function queueConsentRequests(registrationId) {
ensureConsentRequests(registrationId).catch(e => console.error('[consent] Failed to send requests:', e?.message || e));
}
// Named attendees for API responses, with whether their ticket has been issued/used.
const ATTENDEES_INCLUDE = {
orderBy: { order: 'asc' },
include: {
ticket: { select: { id: true, isUsed: true } },
// Latest consent request (youth events) — it decides check-in.
consentRequests: { orderBy: { createdAt: 'desc' }, take: 1, select: { id: true, status: true, respondedAt: true, lastSentAt: true, textVersion: true } },
},
};
const TICKET_DELIVERY = ['payer', 'each'];
/**
* Check overall stock availability for an EventOption.
* Places each line will have once `requested` is added to `existingLines` — used to
* check attendee names against what's actually being bought.
*/
function placesAfter(existingLines, requested) {
const out = {};
for (const l of [...(existingLines || []), ...(requested || [])]) {
const k = lineKey(l.eventOptionId, l.variantId);
out[k] = (out[k] || 0) + Number(l.quantity || 0);
}
return out;
}
// Active coupon on a registration, for API responses (the discount itself is
// Registration.discountTotal).
const ACTIVE_COUPONS_INCLUDE = {
where: { releasedAt: null },
select: { id: true, discountAmount: true, createdAt: true, coupon: { select: { id: true, code: true, type: true, value: true, description: true } } },
};
/**
* Coupon lines for options that haven't been saved yet, so a code can be checked
* before anything is created. Mirrors buildCouponLines for one tranche per option.
*/
function pendingCouponLines(event, resolvedOptions) {
return buildCouponLines({
registrationOptions: resolvedOptions.map(o => ({
eventOptionId: o.eventOptionId,
variantId: o.variantId || null,
quantity: o.quantity,
eventOption: event.eventOptions.find(eo => eo.id === o.eventOptionId),
tranches: [{ quantity: o.quantity, priceSnapshot: o.priceSnapshot, appliedTierId: o.appliedTierId }],
})),
});
}
/**
* Price one requested option for this buyer: early-bird tiers, then variant/option price,
* then the staff price for Staff and above. Returns { priceSnapshot, appliedTierId }.
*/
async function priceRequestedOption(eventOption, variantId, qty, buyer) {
try {
const resolved = variantId
? await resolveVariantTierPrice(eventOption, variantId, qty, buyer)
: await resolveOptionPrice(eventOption, qty, buyer);
return { priceSnapshot: resolved.price, appliedTierId: resolved.tierId };
} catch (e) {
const variant = variantId ? (eventOption.variants || []).find(v => v.id === variantId) : null;
const price = variant && variant.price !== null && variant.price !== undefined ? variant.price : eventOption.price;
return { priceSnapshot: Number(price || 0), appliedTierId: null };
}
}
/**
* Check overall stock availability for an EventOption. Places held for live waitlist
* offers count as taken, except the offer being claimed (`excludeEntryId`).
* Returns { available: boolean, remaining: number|null }
*/
async function checkOptionStock(option, requestedQty) {
async function checkOptionStock(option, requestedQty, excludeEntryId = null) {
if (!option.stockLimit || option.stockLimit === 0) return { available: true, remaining: null };
const soldAgg = await prisma.registrationOption.aggregate({
where: { eventOptionId: option.id, registration: { status: { not: 'cancelled' } } },
_sum: { quantity: true }
});
const sold = soldAgg._sum?.quantity || 0;
const remaining = option.stockLimit - sold;
const remaining = option.stockLimit - await takenCount({ eventOptionId: option.id }, { excludeEntryId });
return { available: remaining >= requestedQty, remaining };
}
/**
* Check stock availability for a specific variant.
* Check stock availability for a specific variant (same rules as checkOptionStock).
*/
async function checkVariantStock(variant, requestedQty) {
async function checkVariantStock(variant, requestedQty, excludeEntryId = null) {
if (!variant.stockLimit || variant.stockLimit === 0) return { available: true, remaining: null };
const soldAgg = await prisma.registrationOption.aggregate({
where: { variantId: variant.id, registration: { status: { not: 'cancelled' } } },
_sum: { quantity: true }
});
const sold = soldAgg._sum?.quantity || 0;
const remaining = variant.stockLimit - sold;
const remaining = variant.stockLimit - await takenCount({ eventOptionId: variant.eventOptionId, variantId: variant.id }, { excludeEntryId });
return { available: remaining >= requestedQty, remaining };
}
@@ -44,7 +110,7 @@ async function checkVariantStock(variant, requestedQty) {
// @access Private
const createRegistration = async (req, res) => {
try {
const { eventId, options, guestName, guestEmail, guestPhone } = req.body;
const { eventId, options, guestName, guestEmail, guestPhone, couponCode, attendees, ticketDelivery, waitlistToken } = req.body;
// Check if event exists
const canIncludeVariants = !!(prisma && prisma.optionVariant && typeof prisma.optionVariant.findMany === 'function');
@@ -72,15 +138,26 @@ const createRegistration = async (req, res) => {
throw new Error('This event is closed and no longer accepting registrations.');
}
// A waitlist claim link: its token identifies the person and releases the places
// held for them. It works without logging in, the same way a consent link does.
let waitlistOffer = null;
if (waitlistToken) {
waitlistOffer = await findOfferByToken(waitlistToken);
if (!waitlistOffer || waitlistOffer.eventId !== eventId) { res.status(400); throw new Error('This waitlist link is not valid.'); }
if (waitlistOffer.status !== 'offered') { res.status(400); throw new Error(`This waitlist offer has ${waitlistOffer.status === 'claimed' ? 'already been used' : 'expired'}.`); }
if (new Date(waitlistOffer.offerExpiresAt) < new Date()) { res.status(400); throw new Error('This waitlist offer has expired.'); }
if (req.user && req.user.id !== waitlistOffer.userId) { res.status(403); throw new Error('This waitlist offer belongs to someone else.'); }
}
// Enforce auth unless event explicitly opts out
const eventRequiresAuth = event.requiresAuth !== false;
if (eventRequiresAuth && !req.user) {
if (eventRequiresAuth && !req.user && !waitlistOffer) {
res.status(401);
throw new Error('Authentication required for this event');
}
// Unauthenticated guest registration is only permitted for free events
if (!req.user) {
if (!req.user && !waitlistOffer) {
const totalDueCheck = (event.eventOptions || []).reduce((sum, eo) => sum + (eo.price || 0), 0);
// Also check against the requested options specifically
const requestedTotal = (options || []).reduce((sum, opt) => {
@@ -97,13 +174,20 @@ const createRegistration = async (req, res) => {
let userId;
if (req.user) {
userId = req.user.id;
} else if (waitlistOffer) {
userId = waitlistOffer.userId;
} else {
// Guest registration: find existing user by email or create inactive placeholder
if (!guestEmail || !guestName) {
res.status(400);
throw new Error('Name and email are required for unauthenticated registration');
}
const existingGuest = await prisma.user.findUnique({ where: { email: guestEmail } });
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.');
}
const existingGuest = match.user;
if (existingGuest) {
userId = existingGuest.id;
} else {
@@ -154,7 +238,7 @@ const createRegistration = async (req, res) => {
// Check overall option stock
try {
const stockCheck = await checkOptionStock(eventOption, qty);
const stockCheck = await checkOptionStock(eventOption, qty, waitlistOffer?.id);
if (!stockCheck.available) {
const err = new Error(`"${eventOption.name}" is sold out or does not have enough stock (${stockCheck.remaining ?? 0} remaining).`);
err.isStockError = true;
@@ -174,7 +258,7 @@ const createRegistration = async (req, res) => {
throw new Error(`Variant not found for option "${eventOption.name}"`);
}
try {
const vStock = await checkVariantStock(variant, qty);
const vStock = await checkVariantStock(variant, qty, waitlistOffer?.id);
if (!vStock.available) {
const err = new Error(`Variant "${variant.name}" is sold out (${vStock.remaining ?? 0} remaining).`);
err.isStockError = true;
@@ -186,30 +270,13 @@ const createRegistration = async (req, res) => {
variantPrice = variant.price; // null = use option price
}
// Resolve price: variant-level tiers take priority, then option-level tiers, then base prices
// Resolve price: variant-level tiers take priority, then option-level tiers, then
// base prices, then the staff price for Staff and above.
let priceSnapshot = null;
let appliedTierId = null;
if (canIncludeTiers) {
try {
if (variantId) {
// Try variant-specific early-bird tier first
const variantResolved = await resolveVariantTierPrice(eventOption, variantId, qty);
if (variantResolved.tierId) {
priceSnapshot = variantResolved.price;
appliedTierId = variantResolved.tierId;
} else {
// No variant tier — use the variant's own price (may be null → falls back to option price below)
priceSnapshot = variantPrice !== null && variantPrice !== undefined ? variantPrice : null;
}
} else {
// No variant — use option-level early-bird tier
const resolved = await resolveOptionPrice(eventOption, qty);
priceSnapshot = resolved.price;
appliedTierId = resolved.tierId;
}
} catch (e) {
priceSnapshot = variantPrice !== null && variantPrice !== undefined ? variantPrice : eventOption.price;
}
const buyer = req.user || (waitlistOffer ? await prisma.user.findUnique({ where: { id: waitlistOffer.userId }, select: { id: true, role: true } }) : null);
({ priceSnapshot, appliedTierId } = await priceRequestedOption(eventOption, variantId, qty, buyer));
} else {
priceSnapshot = variantPrice !== null && variantPrice !== undefined ? variantPrice : null;
}
@@ -218,12 +285,32 @@ const createRegistration = async (req, res) => {
return { ...option, variantId, appliedTierId, priceSnapshot };
}));
// Check a coupon code before anything is saved, so a bad code doesn't leave a
// half-made registration behind. It's applied (and re-checked under a lock) below.
if (couponCode) {
const buyer = req.user ? { id: req.user.id, role: req.user.role, email: req.user.email } : null;
await previewCoupon({ code: couponCode, eventId, user: buyer, lines: pendingCouponLines(event, resolvedOptions) });
}
// Check for existing non-cancelled registration for this user+event → merge instead
const existingReg = await prisma.registration.findFirst({
where: { userId, eventId, status: { not: 'cancelled' } },
include: { registrationOptions: true }
});
// Attendee names are checked against the places the registration will have after
// this purchase, counting names already given on an existing registration.
const cleanAttendees = validateAttendees(
event,
placesAfter(existingReg?.registrationOptions, resolvedOptions),
attendees,
existingReg ? await namedPlacesByKey(existingReg.id) : {}
);
if (ticketDelivery !== undefined && !TICKET_DELIVERY.includes(ticketDelivery)) {
res.status(400);
throw new Error("ticketDelivery must be 'payer' or 'each'");
}
let registration;
let isNewRegistration = false;
if (existingReg) {
@@ -328,10 +415,49 @@ const createRegistration = async (req, res) => {
});
}
if (cleanAttendees.length > 0) {
await createAttendees(registration.id, cleanAttendees);
queueConsentRequests(registration.id);
}
if (waitlistOffer) {
await prisma.waitlistEntry.update({
where: { id: waitlistOffer.id },
data: { status: 'claimed', claimedAt: new Date(), registrationId: registration.id, offerTokenHash: null },
});
}
if (ticketDelivery) {
await prisma.registration.update({ where: { id: registration.id }, data: { ticketDelivery } });
}
// Apply the coupon (or re-work an existing one over the merged items).
let couponError = null;
if (!isNewRegistration) await recalculateRegistrationDiscount(registration.id);
if (couponCode) {
try {
await applyCouponToRegistration({ registrationId: registration.id, code: couponCode });
} catch (e) {
// Lost a race for the last use since the check above — keep the registration.
if (!e.isCouponError) throw e;
couponError = e.message;
}
}
if (couponCode || !isNewRegistration || cleanAttendees.length > 0 || ticketDelivery) {
registration = await prisma.registration.findUnique({
where: { id: registration.id },
include: {
registrationOptions: { include: { eventOption: true, tranches: true } },
event: true,
user: { select: { id: true, name: true, email: true, phoneNumber: true } },
couponRedemptions: ACTIVE_COUPONS_INCLUDE,
attendees: ATTENDEES_INCLUDE,
}
});
}
// If total due is zero, mark as paid and generate/update tickets if no required form
let freeTicketMockReq = null, freeTicketMockRes = null;
try {
const totalDue = (registration.registrationOptions || []).reduce((sum, ro) => sum + ((ro.priceSnapshot ?? ro.eventOption?.price ?? 0) * (ro.quantity || 0)), 0);
const totalDue = computeRegistrationTotalDue(registration, new Date());
if (totalDue === 0) {
// Mark paid (no-op if already paid)
if (registration.status !== 'paid') {
@@ -344,31 +470,9 @@ const createRegistration = async (req, res) => {
hasRequiredForm = !!(form && form.isRequired);
} catch {}
if (!hasRequiredForm) {
// Batch-fetch existing tickets then create/update in parallel (1 read + N parallel writes vs N×2 sequential)
const optionIds = registration.registrationOptions.map(o => o.id);
const existingTickets = await prisma.ticket.findMany({ where: { registrationOptionId: { in: optionIds } } });
const ticketByOptionId = Object.fromEntries(existingTickets.map(t => [t.registrationOptionId, t]));
await Promise.all(registration.registrationOptions.map(option => {
const exists = ticketByOptionId[option.id];
if (exists) {
const newQty = option.quantity || 1;
if (newQty > (exists.quantity || 1)) {
return prisma.ticket.update({ where: { id: exists.id }, data: { quantity: newQty, updatedAt: new Date() } });
}
return Promise.resolve();
}
return prisma.ticket.create({
data: {
id: uuidv4(),
qrCode: uuidv4(),
registrationOptionId: option.id,
userId: registration.userId,
eventId: registration.eventId,
quantity: option.quantity || 1,
updatedAt: new Date(),
}
});
}));
// Same generator as paid registrations: a ticket per named attendee, and one
// shared quantity ticket for the rest of each line.
await generateTicketsForRegistration(registration.id);
// Capture for chained email — tickets sent after registration confirmation
freeTicketMockReq = { user: { id: registration.userId }, body: { registrationId: registration.id } };
freeTicketMockRes = { status: () => freeTicketMockRes, json: () => {} };
@@ -399,7 +503,7 @@ const createRegistration = async (req, res) => {
}
})();
res.status(201).json(attachComputedTotals(registration));
res.status(201).json({ ...attachComputedTotals(registration), ...(couponError ? { couponError } : {}) });
} catch (error) {
res.status(400).json({ message: error.message });
}
@@ -411,6 +515,8 @@ const createRegistration = async (req, res) => {
const getRegistrations = async (req, res) => {
try {
const include = {
couponRedemptions: ACTIVE_COUPONS_INCLUDE,
attendees: ATTENDEES_INCLUDE,
payments: { include: { recordedBy: { select: { id: true, name: true, email: true } } } },
registrationOptions: {
include: {
@@ -488,6 +594,9 @@ const getUserRegistrations = async (req, res) => {
tranches: true,
}
},
couponRedemptions: ACTIVE_COUPONS_INCLUDE,
attendees: ATTENDEES_INCLUDE,
formResponses: { select: { id: true, attendeeId: true } },
// Nest the event's form so the frontend can tell whether attendee forms are
// required without a separate GET /api/events/:id per registration.
event: { include: { form: { include: { fields: true } } } },
@@ -534,7 +643,9 @@ const getRegistrationById = async (req, res) => {
}
},
payments: { include: { recordedBy: { select: { id: true, name: true, email: true } } } },
formResponses: { include: { answers: true } }
formResponses: { include: { answers: true } },
couponRedemptions: ACTIVE_COUPONS_INCLUDE,
attendees: ATTENDEES_INCLUDE,
};
let registration = await prisma.registration.findUnique({ where: { id: req.params.id }, include });
@@ -552,11 +663,17 @@ const getRegistrationById = async (req, res) => {
}
// Guests (no auth) can view by knowing the registrationId (UUID = unguessable)
// Authenticated users must be the owner or staff+
if (req.user && registration.userId !== req.user.id && req.user.role !== 'admin' && req.user.role !== 'supervisor' && req.user.role !== 'staff') {
// Authenticated users must be the owner, staff+, or a named attendee on it
const isOwnerOrStaffViewer = req.user && (registration.userId === req.user.id || ['admin', 'supervisor', 'staff'].includes(req.user.role));
const isAttendeeViewer = req.user && (registration.attendees || []).some(a => a.userId === req.user.id);
if (req.user && !isOwnerOrStaffViewer && !isAttendeeViewer) {
res.status(403);
throw new Error('Not authorized to view this registration');
}
// Only the payer and staff see attendees' contact details.
if (!isOwnerOrStaffViewer) {
registration.attendees = (registration.attendees || []).map(a => ({ ...a, email: null, phone: null }));
}
res.json(attachComputedTotals(registration));
} catch (error) {
@@ -631,6 +748,8 @@ const updateRegistrationStatus = async (req, res) => {
// staff-initiated cancellation — log it the same way DELETE /:id does, so both
// paths land under the one 'registration_cancelled' filter in the audit log.
if (status === 'cancelled' && registration.status !== 'cancelled') {
await releaseRegistrationCoupons(req.params.id);
queueWaitlist(registration.eventId);
logAdminAction({
actorId: req.user.id,
actorRole: req.user.role,
@@ -715,6 +834,8 @@ const cancelRegistration = async (req, res) => {
updatedAt: new Date()
}
});
await releaseRegistrationCoupons(req.params.id);
queueWaitlist(registration.eventId);
// Only log when staff cancels on someone else's behalf — a routine self-service
// cancellation isn't an admin action worth cluttering the audit trail with.
@@ -765,7 +886,9 @@ const getRegistrationsByEvent = async (req, res) => {
}
},
payments: { include: { recordedBy: { select: { id: true, name: true, email: true } } } },
user: { select: { id: true, name: true, email: true, phoneNumber: true, notificationPreference: true, isActive: true } }
user: { select: { id: true, name: true, email: true, phoneNumber: true, notificationPreference: true, isActive: true } },
couponRedemptions: ACTIVE_COUPONS_INCLUDE,
attendees: ATTENDEES_INCLUDE,
},
orderBy: { createdAt: 'asc' }
});
@@ -791,7 +914,7 @@ const getRegistrationsByEvent = async (req, res) => {
const createManualRegistration = async (req, res) => {
let userRecord;
try {
const { eventId, options, user, notificationPreference: prefFromBody, skipActivationNotice } = req.body;
const { eventId, options, user, notificationPreference: prefFromBody, skipActivationNotice, couponCode, attendees, ticketDelivery } = req.body;
if (!eventId || !options || !user || !user.name || (!user.email && !user.phoneNumber)) {
res.status(400);
@@ -822,8 +945,8 @@ const createManualRegistration = async (req, res) => {
throw new Error('At least one option must be selected');
}
// Resolve pricing for each option (appliedTierId + priceSnapshot)
const resolvedManualOptions = [];
// Validate each option up front, before any account is created or changed —
// including stock, which staff registrations previously skipped.
for (const option of options) {
const eventOption = event.eventOptions.find(eo => eo.id === option.eventOptionId);
if (!eventOption) {
@@ -834,34 +957,25 @@ const createManualRegistration = async (req, res) => {
res.status(400);
throw new Error('Quantity must be at least 1');
}
const variantId = option.variantId || null;
let priceSnapshot = null;
let appliedTierId = null;
try {
if (variantId) {
const variantResolved = await resolveVariantTierPrice(eventOption, variantId, option.quantity);
if (variantResolved.tierId) {
priceSnapshot = variantResolved.price;
appliedTierId = variantResolved.tierId;
} else {
const variant = (eventOption.variants || []).find(v => v.id === variantId);
priceSnapshot = (variant && variant.price !== null && variant.price !== undefined)
? Number(variant.price)
: Number(eventOption.price || 0);
}
} else {
const resolved = await resolveOptionPrice(eventOption, option.quantity);
priceSnapshot = resolved.price;
appliedTierId = resolved.tierId;
}
} catch (e) {
priceSnapshot = Number(eventOption.price || 0);
const variant = option.variantId ? (eventOption.variants || []).find(v => v.id === option.variantId) : null;
const oStock = await checkOptionStock(eventOption, option.quantity);
const vStock = variant ? await checkVariantStock(variant, option.quantity) : { available: true };
if (!oStock.available || !vStock.available) {
res.status(400);
const left = Math.max(0, Math.min(oStock.remaining ?? Infinity, vStock.remaining ?? Infinity));
throw new Error(`"${eventOption.name}${variant ? ` (${variant.name})` : ''}" is sold out or does not have enough stock (${left} remaining). Raise the stock limit to add more.`);
}
resolvedManualOptions.push({ ...option, variantId, appliedTierId, priceSnapshot });
}
// Pricing (appliedTierId + priceSnapshot) depends on who the attendee is — Staff and
// above get the staff price — so it runs once the account is resolved below.
const priceManualOptions = (attendee) => Promise.all(options.map(async (option) => {
const eventOption = event.eventOptions.find(eo => eo.id === option.eventOptionId);
const variantId = option.variantId || null;
const priced = await priceRequestedOption(eventOption, variantId, option.quantity, attendee);
return { ...option, variantId, ...priced };
}));
// Resolve or create a user
let userId;
@@ -992,12 +1106,32 @@ const createManualRegistration = async (req, res) => {
}
}
let couponError = null;
const attendee = await prisma.user.findUnique({ where: { id: userId }, select: { id: true, role: true, email: true } });
const resolvedManualOptions = await priceManualOptions(attendee);
// Check the coupon against this attendee before saving anything.
if (couponCode) {
await previewCoupon({ code: couponCode, eventId, user: attendee, lines: pendingCouponLines(event, resolvedManualOptions) });
}
// Merge into existing non-cancelled registration if one exists, otherwise create new
const existingReg = await prisma.registration.findFirst({
where: { userId, eventId, status: { not: 'cancelled' } },
include: { registrationOptions: true }
});
const cleanAttendees = validateAttendees(
event,
placesAfter(existingReg?.registrationOptions, resolvedManualOptions),
attendees,
existingReg ? await namedPlacesByKey(existingReg.id) : {}
);
if (ticketDelivery !== undefined && !TICKET_DELIVERY.includes(ticketDelivery)) {
res.status(400);
throw new Error("ticketDelivery must be 'payer' or 'each'");
}
let registration;
let isNewRegistration = false;
@@ -1050,6 +1184,18 @@ const createManualRegistration = async (req, res) => {
}
});
}));
// Re-work any coupon over the merged items, and apply a new one, before the status
// is worked out from the totals.
await recalculateRegistrationDiscount(existingReg.id);
if (couponCode) {
try {
await applyCouponToRegistration({ registrationId: existingReg.id, code: couponCode, appliedById: req.user.id });
} catch (e) {
if (!e.isCouponError) throw e;
couponError = e.message;
}
}
// Recompute status based on totals across ALL current options and payments
const freshForStatus = await prisma.registration.findUnique({
where: { id: existingReg.id },
@@ -1072,6 +1218,7 @@ const createManualRegistration = async (req, res) => {
registrationOptions: { include: { eventOption: true, tranches: true } },
event: true,
user: { select: { id: true, name: true, email: true, phoneNumber: true } },
couponRedemptions: ACTIVE_COUPONS_INCLUDE,
},
});
registration.status = newExistingStatus;
@@ -1109,10 +1256,45 @@ const createManualRegistration = async (req, res) => {
user: { select: { id: true, name: true, email: true, phoneNumber: true } },
},
});
if (couponCode) {
try {
await applyCouponToRegistration({ registrationId: registration.id, code: couponCode, appliedById: req.user.id });
} catch (e) {
if (!e.isCouponError) throw e;
couponError = e.message;
}
registration = await prisma.registration.findUnique({
where: { id: registration.id },
include: {
registrationOptions: { include: { eventOption: true, tranches: true } },
event: true,
user: { select: { id: true, name: true, email: true, phoneNumber: true } },
couponRedemptions: ACTIVE_COUPONS_INCLUDE,
},
});
}
}
const registrationId = registration.id;
if (cleanAttendees.length > 0) {
await createAttendees(registrationId, cleanAttendees);
queueConsentRequests(registrationId);
}
if (ticketDelivery) await prisma.registration.update({ where: { id: registrationId }, data: { ticketDelivery } });
if (couponCode && !couponError) {
logAdminAction({
actorId: req.user.id,
actorRole: req.user.role,
action: 'coupon_applied_manual',
targetType: 'Registration',
targetId: registrationId,
metadata: { code: String(couponCode).trim().toUpperCase(), discount: registration.discountTotal, forUserId: userId },
ip: getClientIp(req),
});
}
// Auto mark paid and generate tickets if free and no required form (new registrations only)
let hasRequiredForm = false;
try {
@@ -1122,7 +1304,7 @@ const createManualRegistration = async (req, res) => {
// Auto-mark paid and generate tickets for free registrations (new OR existing that became free)
if (registration.status !== 'paid') try {
const totalDue = (registration.registrationOptions || []).reduce((sum, ro) => sum + ((ro.priceSnapshot ?? ro.eventOption?.price ?? 0) * (ro.quantity || 0)), 0);
const totalDue = computeRegistrationTotalDue(registration, new Date());
if (totalDue === 0) {
await prisma.registration.update({ where: { id: registration.id }, data: { status: 'paid', updatedAt: new Date() } });
registration.status = 'paid';
@@ -1185,7 +1367,7 @@ const createManualRegistration = async (req, res) => {
ip: getClientIp(req),
});
return res.status(201).json(attachComputedTotals(registration));
return res.status(201).json({ ...attachComputedTotals(registration), ...(couponError ? { couponError } : {}) });
} catch (error) {
console.error(error);
return res.status(400).json({message: error.message});
@@ -1219,6 +1401,7 @@ const updateRegistrationOptions = async (req, res) => {
},
event: { include: { eventOptions: { include: { earlyBirdTiers: true, variants: true } } } },
payments: true,
user: { select: { id: true, role: true, email: true } },
}
});
@@ -1347,19 +1530,16 @@ const updateRegistrationOptions = async (req, res) => {
let removalPlan = null;
if (delta > 0) {
try {
if (variantId) {
const variantResolved = await resolveVariantTierPrice(eventOption, variantId, delta);
priceSnapshot = variantResolved.price;
appliedTierId = variantResolved.tierId;
} else {
const resolved = await resolveOptionPrice(eventOption, delta);
priceSnapshot = resolved.price;
appliedTierId = resolved.tierId;
}
} catch (e) {
priceSnapshot = Number(eventOption?.price || 0);
// Adding places needs stock, like a new registration does.
const oStock = await checkOptionStock(eventOption, delta);
const variant = variantId ? (eventOption.variants || []).find(v => v.id === variantId) : null;
const vStock = variant ? await checkVariantStock(variant, delta) : { available: true };
if (!oStock.available || !vStock.available) {
res.status(400);
const left = Math.max(0, Math.min(oStock.remaining ?? Infinity, vStock.remaining ?? Infinity));
throw new Error(`Only ${left} more "${eventOption.name}${variant ? ` (${variant.name})` : ''}" available.`);
}
({ priceSnapshot, appliedTierId } = await priceRequestedOption(eventOption, variantId, delta, registration.user));
newTotalDue += existingTotal + delta * (priceSnapshot || 0);
} else if (delta < 0) {
const existingTranches = existingRows.flatMap(ro => ro.tranches || []);
@@ -1491,9 +1671,28 @@ const updateRegistrationOptions = async (req, res) => {
}
}
// Names on places that no longer exist go too (newest first). Tickets for them
// are tidied up by generateTicketsForRegistration; the floor check above already
// stops anything going below tickets issued.
await tx.attendee.deleteMany({ where: { registrationId, registrationOptionId: null } });
const lines = await tx.registrationOption.findMany({ where: { registrationId }, include: { attendees: { orderBy: { order: 'desc' } } } });
for (const line of lines) {
const extra = (line.attendees || []).length - (line.quantity || 0);
if (extra > 0) {
await tx.attendee.deleteMany({ where: { id: { in: line.attendees.slice(0, extra).map(a => a.id) } } });
}
}
// Coupon: percentage/comp codes follow the new items, fixed codes keep their amount.
const discount = await recalculateRegistrationDiscount(registrationId, tx);
const dueAfterDiscount = Math.max(0, newTotalDue - discount);
if (dueAfterDiscount < totalPaid - 0.001) {
throw new Error('Cannot reduce items below the amount already paid');
}
// Update updatedAt and potentially status based on payments vs due
let newStatus = registration.status;
if (totalPaid >= newTotalDue) newStatus = 'paid';
if (totalPaid >= dueAfterDiscount) newStatus = 'paid';
else if (totalPaid > 0) newStatus = 'partial_paid';
else newStatus = 'pending';
@@ -1511,11 +1710,14 @@ const updateRegistrationOptions = async (req, res) => {
event: true,
user: { select: { id: true, name: true, email: true, phoneNumber: true } },
payments: true,
couponRedemptions: ACTIVE_COUPONS_INCLUDE,
attendees: ATTENDEES_INCLUDE,
}
});
const { sendRegistrationUpdatedEmails } = require('../utils/notifications');
sendRegistrationUpdatedEmails(registrationId).catch(e => console.error('Failed to send registration updated emails:', e));
if (resolvedUpdateOptions.some(o => o.delta < 0) || oldByKey.size > newKeys.size) queueWaitlist(registration.eventId);
// If registration is now paid, regenerate tickets and send them
if (updated.status === 'paid') {
@@ -1555,8 +1757,11 @@ const submitFormResponses = async (req, res) => {
}
});
if (!registration) { res.status(404); throw new Error('Registration not found'); }
// Guests (no auth) may submit by knowing the registrationId; authenticated users must be owner or staff+
if (req.user && registration.userId !== userId && !['admin','supervisor','staff'].includes(req.user.role)) {
// Guests (no auth) may submit by knowing the registrationId; authenticated users must be
// owner, staff+, or submitting their own form as a named attendee
const ownAttendeeOnly = Array.isArray(responses) && responses.length > 0 && req.user
&& (await prisma.attendee.count({ where: { registrationId, userId, id: { in: responses.map(r => r?.attendeeId).filter(Boolean) } } })) === responses.length;
if (req.user && registration.userId !== userId && !['admin','supervisor','staff'].includes(req.user.role) && !ownAttendeeOnly) {
res.status(403); throw new Error('Not authorized');
}
@@ -1578,11 +1783,20 @@ const submitFormResponses = async (req, res) => {
if (toSubmit.length === 0) { res.status(400); throw new Error('No responses provided'); }
if (toSubmit.length > mainTickets) { res.status(400); throw new Error(`Too many forms submitted. Expected at most ${mainTickets}`); }
// A response can be for a named attendee (group tickets); each attendee answers once.
const attendeeIds = toSubmit.map(r => r?.attendeeId).filter(Boolean);
if (attendeeIds.length > 0) {
const known = await prisma.attendee.findMany({ where: { id: { in: attendeeIds }, registrationId }, include: { formResponses: { select: { id: true } } } });
if (known.length !== new Set(attendeeIds).size) { res.status(400); throw new Error('Unknown attendee for this registration'); }
const answered = known.find(a => a.formResponses.length > 0);
if (answered) { res.status(400); throw new Error(`${answered.name}'s form has already been submitted`); }
}
// Create responses and answers
const created = [];
for (const resp of toSubmit) {
const respId = uuidv4();
const fr = await prisma.formResponse.create({ data: { id: respId, registrationId } });
const fr = await prisma.formResponse.create({ data: { id: respId, registrationId, attendeeId: resp?.attendeeId || null } });
const ansMap = resp?.answers || {};
for (const f of answerableFields) {
const raw = ansMap[f.id];
@@ -1818,7 +2032,254 @@ const sendInvoice = async (req, res) => {
}
};
/**
* After a discount changes, bring the status in line with the new total. A registration
* that a coupon has made fully paid gets its tickets, as any paid registration does.
*/
async function syncStatusAfterDiscount(registrationId) {
const reg = await prisma.registration.findUnique({
where: { id: registrationId },
include: { registrationOptions: { include: { eventOption: true, tranches: true } }, payments: true },
});
const due = computeRegistrationTotalDue(reg, new Date());
const paid = (reg.payments || []).reduce((s, p) => s + (p.amount || 0), 0);
const status = paid >= due - 0.001 ? 'paid' : paid > 0 ? 'partial_paid' : 'pending';
if (status !== reg.status) {
await prisma.registration.update({ where: { id: registrationId }, data: { status, updatedAt: new Date() } });
}
if (status === 'paid' && reg.status !== 'paid') {
(async () => {
try {
let hasRequiredForm = false;
try {
const form = await prisma.eventForm.findUnique({ where: { eventId: reg.eventId } });
hasRequiredForm = !!(form && form.isRequired);
} catch {}
if (hasRequiredForm) return;
await generateTicketsForRegistration(registrationId);
const mockReq = { user: { id: reg.userId }, body: { registrationId } };
const mockRes = { status: () => mockRes, json: () => {} };
await emailTickets(mockReq, mockRes);
} catch (e) {
console.error('[coupon] Failed to issue tickets after discount:', e?.message || e);
}
})();
}
return status;
}
const isStaffRole = (role) => ['admin', 'supervisor', 'staff'].includes(role);
// @desc Apply a coupon code to an existing registration
// @route POST /api/registrations/:id/coupon
// @access Private (owner, or staff applying it on someone's behalf)
const applyCoupon = async (req, res) => {
try {
const { code } = req.body || {};
const registration = await prisma.registration.findUnique({
where: { id: req.params.id },
include: { event: { select: { endDate: true, cashupStatus: true } } },
});
if (!registration) { res.status(404); throw new Error('Registration not found'); }
const byStaff = registration.userId !== req.user.id;
if (byStaff && !isStaffRole(req.user.role)) { res.status(403); throw new Error('Not authorized to change this registration'); }
if (registration.status === 'cancelled') { res.status(400); throw new Error('This registration is cancelled.'); }
if (registration.event?.cashupStatus === 'closed') { res.status(400); throw new Error('This event is closed.'); }
if (registration.event?.endDate && new Date(registration.event.endDate) < new Date()) {
res.status(400); throw new Error('This event has already ended.');
}
const { coupon, discount } = await applyCouponToRegistration({
registrationId: registration.id,
code,
appliedById: byStaff ? req.user.id : null,
});
await syncStatusAfterDiscount(registration.id);
if (byStaff) {
logAdminAction({
actorId: req.user.id,
actorRole: req.user.role,
action: 'coupon_applied_manual',
targetType: 'Registration',
targetId: registration.id,
metadata: { code: coupon.code, discount, forUserId: registration.userId },
ip: getClientIp(req),
});
}
const { sendRegistrationUpdatedEmails } = require('../utils/notifications');
sendRegistrationUpdatedEmails(registration.id).catch(e => console.error('Failed to send registration updated emails:', e));
const updated = await prisma.registration.findUnique({
where: { id: registration.id },
include: {
registrationOptions: { include: { eventOption: true, variant: { select: { id: true, name: true, price: true } }, tranches: true } },
payments: true,
couponRedemptions: ACTIVE_COUPONS_INCLUDE,
},
});
res.json({ ...attachComputedTotals(updated), message: `Code ${coupon.code} applied — ${describeCoupon(coupon)}.` });
} catch (error) {
res.status(error.statusCode || (res.statusCode === 200 ? 400 : res.statusCode)).json({ message: error.message });
}
};
// @desc Remove the coupon from a registration (the use is freed)
// @route DELETE /api/registrations/:id/coupon
// @access Private/Staff
const removeCoupon = async (req, res) => {
try {
const registration = await prisma.registration.findUnique({ where: { id: req.params.id } });
if (!registration) { res.status(404); throw new Error('Registration not found'); }
await assertEventOpen(registration.eventId, res);
// Removing a discount raises the total, which can take a paid registration back to
// unpaid. Same rule as a staff status change: refuse once a ticket has been scanned,
// otherwise withdraw the unused tickets until it's paid again.
const tickets = await prisma.ticket.findMany({
where: { registrationOption: { registrationId: registration.id } },
include: { usages: true },
});
if (registration.status === 'paid' && tickets.some(t => t.isUsed || t.usages.length > 0)) {
res.status(400);
throw new Error('A ticket on this registration has already been scanned, so its code can\'t be removed.');
}
const released = await releaseRegistrationCoupons(registration.id);
if (!released) { res.status(400); throw new Error('No code is applied to this registration.'); }
const status = await syncStatusAfterDiscount(registration.id);
if (registration.status === 'paid' && status !== 'paid') {
await prisma.ticket.deleteMany({ where: { registrationOption: { registrationId: registration.id }, isUsed: false } });
}
logAdminAction({
actorId: req.user.id,
actorRole: req.user.role,
action: 'coupon_applied_manual',
targetType: 'Registration',
targetId: registration.id,
metadata: { removed: true, forUserId: registration.userId },
ip: getClientIp(req),
});
res.json({ message: 'Code removed.' });
} catch (error) {
res.status(error.statusCode || (res.statusCode === 200 ? 400 : res.statusCode)).json({ message: error.message });
}
};
const isOwnerOrStaff = (registration, user) =>
registration.userId === user.id || ['admin', 'supervisor', 'staff'].includes(user.role);
// @desc Replace the named attendees on a registration (and who gets the tickets)
// @route PUT /api/registrations/:id/attendees
// @access Private (owner or staff)
// Body: { attendees: [{ id?, eventOptionId, variantId?, name, email?, phone? }], ticketDelivery? }
// Attendees left out are removed — refused if their ticket has been scanned.
const updateAttendees = async (req, res) => {
try {
const { attendees = [], ticketDelivery } = req.body || {};
const registration = await prisma.registration.findUnique({
where: { id: req.params.id },
include: {
registrationOptions: true,
attendees: { include: { ticket: { include: { usages: true } } } },
event: { include: { eventOptions: true } },
},
});
if (!registration) { res.status(404); throw new Error('Registration not found'); }
if (!isOwnerOrStaff(registration, req.user)) { res.status(403); throw new Error('Not authorized to edit this registration'); }
if (registration.status === 'cancelled') { res.status(400); throw new Error('This registration is cancelled.'); }
if (registration.event?.cashupStatus === 'closed') { res.status(400); throw new Error('This event is closed.'); }
if (registration.event?.endDate && new Date(registration.event.endDate) < new Date()) {
res.status(400); throw new Error('This event has already ended.');
}
if (ticketDelivery !== undefined && !TICKET_DELIVERY.includes(ticketDelivery)) {
res.status(400); throw new Error("ticketDelivery must be 'payer' or 'each'");
}
const clean = validateAttendees(registration.event, placesAfter(registration.registrationOptions, []), attendees);
const keepIds = new Set(attendees.map(a => a?.id).filter(Boolean));
const existingById = new Map(registration.attendees.map(a => [a.id, a]));
for (const id of keepIds) if (!existingById.has(id)) { res.status(400); throw new Error('Unknown attendee for this registration'); }
const removed = registration.attendees.filter(a => !keepIds.has(a.id));
const usedRemoved = removed.find(a => a.ticket && (a.ticket.isUsed || a.ticket.usages.length > 0));
if (usedRemoved) { res.status(400); throw new Error(`${usedRemoved.name}'s ticket has already been scanned, so they can't be removed.`); }
const toCreate = [];
await prisma.$transaction(async (tx) => {
if (removed.length) await tx.attendee.deleteMany({ where: { id: { in: removed.map(a => a.id) } } });
for (let i = 0; i < attendees.length; i++) {
const a = attendees[i];
const c = clean[i];
if (a.id) {
const prev = existingById.get(a.id);
const contactChanged = prev.email !== c.email || prev.phone !== c.phone;
const match = contactChanged && (c.email || c.phone) ? await findAccountByContact({ email: c.email, phone: c.phone }, tx) : null;
await tx.attendee.update({
where: { id: a.id },
data: {
name: c.name, email: c.email, phone: c.phone,
dateOfBirth: c.dateOfBirth, parentName: c.parentName, parentEmail: c.parentEmail, parentPhone: c.parentPhone,
...(contactChanged ? { userId: match?.user?.id || null } : {}),
},
});
} else {
toCreate.push(c);
}
}
if (toCreate.length) await createAttendees(registration.id, toCreate, tx);
if (ticketDelivery) await tx.registration.update({ where: { id: registration.id }, data: { ticketDelivery } });
});
queueConsentRequests(registration.id);
// Paid registrations: re-sync tickets and send any new ones.
if (registration.status === 'paid') {
const created = await generateTicketsForRegistration(registration.id);
if (created.length > 0) {
const mockReq = { user: { id: registration.userId }, body: { ticketIds: created.map(t => t.id) } };
const mockRes = { status: () => mockRes, json: () => {} };
emailTickets(mockReq, mockRes).catch(e => console.error('[attendees] Failed to send new tickets:', e?.message || e));
}
}
const updated = await prisma.registration.findUnique({
where: { id: registration.id },
select: { id: true, ticketDelivery: true, attendees: ATTENDEES_INCLUDE },
});
res.json(updated);
} catch (error) {
res.status(error.statusCode || (res.statusCode === 200 ? 400 : res.statusCode)).json({ message: error.message });
}
};
// @desc Send an attendee a link to fill in their own attendee form
// @route POST /api/registrations/:id/attendees/:attendeeId/send-form-link
// @access Private (owner or staff)
const sendAttendeeFormLink = async (req, res) => {
try {
const attendee = await prisma.attendee.findFirst({
where: { id: req.params.attendeeId, registrationId: req.params.id },
include: { registration: { include: { event: true, user: { select: { id: true, name: true } } } } },
});
if (!attendee) { res.status(404); throw new Error('Attendee not found'); }
if (!isOwnerOrStaff(attendee.registration, req.user)) { res.status(403); throw new Error('Not authorized'); }
if (!attendee.email && !attendee.phone) { res.status(400); throw new Error(`Add an email or phone number for ${attendee.name} first.`); }
const { sendAttendeeFormLinkMessage } = require('../utils/notifications');
const sent = await sendAttendeeFormLinkMessage(attendee);
res.json({ message: sent.length ? `Form link sent to ${attendee.name} by ${sent.join(' and ')}.` : 'The form link could not be sent.' });
} catch (error) {
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: error.message });
}
};
module.exports = {
updateAttendees,
sendAttendeeFormLink,
applyCoupon,
removeCoupon,
createRegistration,
getRegistrations,
getUserRegistrations,
@@ -0,0 +1,151 @@
const prisma = require('../config/db');
const { safeErrorMessage } = require('../utils/errorUtils');
const { logAdminAction } = require('../utils/adminAudit');
const { getClientIp } = require('../utils/requestUtils');
const { LOCK_INCLUDE, activeLocks, acquireLock, snapshotFor, syncScans } = require('../utils/scanLocks');
const SUPERVISORS = ['admin', 'supervisor'];
const fail = (res, error) => res.status(error.statusCode || (res.statusCode === 200 ? 400 : res.statusCode)).json({ message: safeErrorMessage(error) });
const audit = (req, action, lock, metadata = {}) => logAdminAction({
actorId: req.user.id, actorRole: req.user.role, action, targetType: 'ScanLock', targetId: lock.id,
metadata: { eventId: lock.eventId, sectionId: lock.sectionId, deviceName: lock.deviceName, ...metadata },
ip: getClientIp(req),
});
const lockView = (l) => ({
id: l.id, eventId: l.eventId, sectionId: l.sectionId, sectionName: l.section?.name || null,
deviceId: l.deviceId, deviceName: l.deviceName, user: l.user, lockedAt: l.lockedAt,
snapshotAt: l.snapshotAt, lastSyncAt: l.lastSyncAt, releasedAt: l.releasedAt,
});
/** The lock, if the caller is the person holding it on this device. */
async function ownLock(req, res) {
const lock = await prisma.scanLock.findUnique({ where: { id: req.params.id }, include: LOCK_INCLUDE });
if (!lock) { res.status(404); throw new Error('Offline lock not found'); }
const deviceId = req.body?.deviceId || req.query?.deviceId;
if (lock.userId !== req.user.id || lock.deviceId !== deviceId) { res.status(403); throw new Error('This offline lock belongs to another device.'); }
return lock;
}
// @desc Active offline locks for an event
// @route GET /api/scan-locks?eventId=
// @access Private/Staff
const listLocks = async (req, res) => {
try {
if (!req.query.eventId) { res.status(400); throw new Error('eventId is required'); }
res.json((await activeLocks(String(req.query.eventId))).map(lockView));
} catch (error) {
fail(res, error);
}
};
// @desc Lock a section (or the whole event) to this device for offline scanning
// @route POST /api/scan-locks { eventId, sectionId?, deviceId, deviceName? }
// @access Private/Staff
const createLock = async (req, res) => {
try {
const { eventId, sectionId, deviceId, deviceName } = req.body || {};
if (!eventId) { res.status(400); throw new Error('eventId is required'); }
const lock = await acquireLock({ eventId, sectionId: sectionId || null, deviceId, deviceName, userId: req.user.id });
audit(req, 'scan_lock_acquired', lock);
res.status(201).json(lockView(lock));
} catch (error) {
fail(res, error);
}
};
// @desc Download the tickets valid for this lock, for offline scanning
// @route GET /api/scan-locks/:id/snapshot?deviceId=
// @access Private/Staff (lock holder)
const getSnapshot = async (req, res) => {
try {
const lock = await ownLock(req, res);
if (lock.releasedAt) { res.status(410); throw new Error('This offline lock has been released.'); }
res.json({ lock: lockView(lock), ...(await snapshotFor(lock)) });
} catch (error) {
fail(res, error);
}
};
// @desc Upload scans made offline
// @route POST /api/scan-locks/:id/sync { deviceId, scans: [{ clientScanId, qrCode, qty, scannedAt }] }
// @access Private/Staff (lock holder — also after a release, so nothing made offline is lost)
const syncLock = async (req, res) => {
try {
const lock = await ownLock(req, res);
const result = await syncScans(lock, req.body?.scans);
if (result.accepted || result.conflicts.length) {
audit(req, 'offline_sync', lock, { accepted: result.accepted, conflicts: result.conflicts.length, duplicates: result.duplicates, afterForceRelease: !!lock.forceReleasedById });
}
res.json({ accepted: result.accepted, duplicates: result.duplicates, conflicts: result.conflicts, reportId: result.report.id, released: !!lock.releasedAt });
} catch (error) {
fail(res, error);
}
};
// @desc End offline mode (the device should sync first)
// @route DELETE /api/scan-locks/:id { deviceId }
// @access Private/Staff (lock holder)
const releaseLock = async (req, res) => {
try {
const lock = await ownLock(req, res);
if (!lock.releasedAt) {
await prisma.scanLock.update({ where: { id: lock.id }, data: { releasedAt: new Date() } });
audit(req, 'scan_lock_released', lock);
}
res.json({ message: 'Offline mode ended.' });
} catch (error) {
fail(res, error);
}
};
// @desc Release someone else's lock (e.g. a lost or flat phone)
// @route POST /api/scan-locks/:id/force-release
// @access Private/Supervisor
const forceRelease = async (req, res) => {
try {
const lock = await prisma.scanLock.findUnique({ where: { id: req.params.id }, include: LOCK_INCLUDE });
if (!lock) { res.status(404); throw new Error('Offline lock not found'); }
if (lock.releasedAt) return res.json({ message: 'Already released.' });
await prisma.scanLock.update({ where: { id: lock.id }, data: { releasedAt: new Date(), forceReleasedById: req.user.id } });
audit(req, 'scan_lock_force_released', lock, { heldBy: lock.user?.name });
res.json({ message: `Released. If ${lock.user?.name || 'that device'} reconnects, its offline scans will still sync and be flagged for review.` });
} catch (error) {
fail(res, error);
}
};
// @desc Offline sync reports for an event (with anything that needs review)
// @route GET /api/scan-locks/reports?eventId=&unreviewed=true
// @access Private/Supervisor
const listReports = async (req, res) => {
try {
const where = {
scanLock: { eventId: String(req.query.eventId || '') },
...(req.query.unreviewed === 'true' ? { reviewedAt: null } : {}),
};
const reports = await prisma.offlineSyncReport.findMany({
where,
include: { scanLock: { include: LOCK_INCLUDE }, reviewedBy: { select: { name: true } } },
orderBy: { createdAt: 'desc' },
take: 200,
});
res.json(reports.map(r => ({ ...r, scanLock: lockView(r.scanLock) })));
} catch (error) {
fail(res, error);
}
};
// @desc Mark a sync report as reviewed
// @route POST /api/scan-locks/reports/:id/review
// @access Private/Supervisor
const reviewReport = async (req, res) => {
try {
const report = await prisma.offlineSyncReport.update({ where: { id: req.params.id }, data: { reviewedAt: new Date(), reviewedById: req.user.id } });
res.json(report);
} catch (error) {
fail(res, error);
}
};
module.exports = { SUPERVISORS, listLocks, createLock, getSnapshot, syncLock, releaseLock, forceRelease, listReports, reviewReport };
@@ -172,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) });
+97 -9
View File
@@ -81,9 +81,10 @@ const getTickets = async (req, res) => {
// @access Private
const getUserTickets = async (req, res) => {
try {
// Tickets this user paid for, plus named tickets someone else bought for them.
const tickets = await prisma.ticket.findMany({
where: {
userId: req.user.id,
OR: [{ userId: req.user.id }, { holderUserId: req.user.id }],
registrationOption: { registration: { status: { not: 'cancelled' } } }
},
include: {
@@ -94,6 +95,8 @@ const getUserTickets = async (req, res) => {
registration: true
}
},
attendee: { select: { id: true, name: true } },
user: { select: { id: true, name: true } },
event: true,
usages: {
include: {
@@ -261,6 +264,7 @@ const getScanPreview = async (req, res) => {
e.title AS "eventTitle",
u.id AS "userId",
u.name AS "userName",
a.name AS "attendeeName",
COALESCE(
json_agg(
json_build_object(
@@ -276,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) {
@@ -363,6 +372,27 @@ const scanTicket = async (req, res) => {
return res.status(400).json({ message: 'Ticket belongs to a different event', ticket });
}
// Section checks used to happen only in the browser; enforce them here too.
const sectionId = String((req.query?.sectionId || req.body?.sectionId) || '').trim() || null;
if (sectionId) {
const section = await prisma.section.findUnique({ where: { id: sectionId }, include: { allowedOptions: true } });
if (!section || section.eventId !== ticket.eventId) {
return res.status(400).json({ message: 'Section not found for this event', ticket });
}
if (!section.allowedOptions.some(o => o.eventOptionId === r.eventOptionId)) {
return res.status(400).json({ message: 'This ticket is not valid for this section', ticket });
}
}
// A section (or the whole event) locked to another device for offline scanning
// can't be scanned online — those scans would be invisible to the offline device.
const { blockingLock } = require('../utils/scanLocks');
const lock = await blockingLock({ eventId: ticket.eventId, optionId: r.eventOptionId }, { sectionId, deviceId: req.body?.deviceId || null });
if (lock) {
const scope = lock.section ? `"${lock.section.name}"` : 'This event';
return res.status(423).json({ message: `${scope} is offline-locked by ${lock.user?.name || 'another device'}${lock.deviceName ? ` on ${lock.deviceName}` : ''}. Scan it on that device, or ask a supervisor to release the lock.`, code: 'SCAN_LOCKED' });
}
// Compute how many have already been redeemed
const totalRedeemed = (ticket.usages || []).reduce((s, u) => s + (u.quantityRedeemed || 1), 0);
const remaining = (ticket.quantity || 1) - totalRedeemed;
@@ -375,6 +405,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 +481,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 +710,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 +726,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 +814,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 });
+267 -14
View File
@@ -4,6 +4,18 @@ const { v4: uuidv4 } = require('uuid');
const { safeErrorMessage } = require('../utils/errorUtils');
const { logSecurityEvent, getRecentSecurityEvents } = require('../utils/securityEvents');
const { getSettingSync } = require('../utils/settingsCache');
const {
METHODS: TWO_FACTOR_METHODS,
availableMethods,
createChallenge,
verifyChallenge,
resendChallenge,
issueTrustedDevice,
isTrustedDevice,
clearTrustedDevices,
requiresTwoFactor,
enforcementMethod,
} = require('../utils/twoFactor');
const axios = require('axios');
// ─── Helpers ────────────────────────────────────────────────────────────────
@@ -124,6 +136,35 @@ async function sendWelcomeEmail(user) {
}
}
// Fields the login flow needs once the password (and any 2FA code) has checked out.
const LOGIN_USER_SELECT = {
id: true, name: true, email: true, role: true, tokenVersion: true, isActive: true,
phoneNumber: true, notificationPreference: true, twoFactorEnabled: true, twoFactorMethod: true,
};
// Errors from utils/twoFactor carry their own HTTP status; everything else follows
// the controller convention of res.status(...) before throwing.
function errorStatus(error, res) {
return error?.statusCode || (res.statusCode === 200 ? 400 : res.statusCode);
}
// Final step of a successful login (after the password, and the 2FA code when
// required): notify, record the activity, and issue the session token.
function completeLogin(user, req, res, extra = {}) {
// Send login notification in the background
sendLoginNotification(user, req).catch(() => {});
logSecurityEvent({ userId: user.id, type: 'login', ip: getClientIp(req), userAgent: req.headers['user-agent'] }).catch(() => {});
res.json({
id: user.id,
name: user.name,
email: user.email,
role: user.role,
token: generateToken(user.id, user.role, user.tokenVersion),
...extra
});
}
// @desc Register a new user
// @route POST /api/users
// @access Public
@@ -198,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;
@@ -278,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) });
}
@@ -311,6 +511,8 @@ const getUserProfile = async (req, res) => {
role: true,
phoneNumber: true,
notificationPreference: true,
twoFactorEnabled: true,
twoFactorMethod: true,
createdAt: true,
updatedAt: true,
isActive: true
@@ -318,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');
@@ -375,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 },
@@ -384,6 +591,7 @@ const updateUserProfile = async (req, res) => {
password: newHashedPassword ? newHashedPassword : user.password,
phoneNumber: newPhone,
notificationPreference: newPref,
twoFactorMethod: newTwoFactorMethod,
updatedAt: new Date()
},
select: {
@@ -393,6 +601,8 @@ const updateUserProfile = async (req, res) => {
role: true,
phoneNumber: true,
notificationPreference: true,
twoFactorEnabled: true,
twoFactorMethod: true,
createdAt: true,
updatedAt: true,
isActive: true,
@@ -402,6 +612,7 @@ const updateUserProfile = async (req, res) => {
// If the password was changed, send a security alert email (fire-and-forget)
if (newHashedPassword) {
await clearTrustedDevices(updatedUser.id);
const { sendMail, buildPasswordChangedEmail } = require('../utils/email');
const supportEmail = getSettingSync('org_email', process.env.EMAIL_FROM || '');
const content = buildPasswordChangedEmail({ name: updatedUser.name, when: Date.now(), supportEmail });
@@ -414,6 +625,7 @@ const updateUserProfile = async (req, res) => {
res.json({
...updatedUser,
twoFactorRequired: requiresTwoFactor(updatedUser),
token: generateToken(updatedUser.id, updatedUser.role, updatedUser.tokenVersion)
});
} catch (error) {
@@ -432,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
@@ -559,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');
@@ -580,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,
@@ -590,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) });
@@ -646,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) {
@@ -754,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(() => {});
@@ -838,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.
@@ -864,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) });
@@ -901,10 +1143,14 @@ 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');
@@ -926,6 +1172,7 @@ const closeAccount = async (req, res) => {
updatedAt: new Date(),
},
});
await clearTrustedDevices(req.user.id);
// Send closure confirmation
if (realEmail && !realEmail.endsWith('@deleted.invalid') && !realEmail.endsWith('@guest.local')) {
const { sendMail, buildAccountClosedEmail } = require('../utils/email');
@@ -957,6 +1204,12 @@ const getMyActivity = async (req, res) => {
module.exports = {
registerUser,
loginUser,
verifyLoginTwoFactor,
resendLoginTwoFactor,
startEnableTwoFactor,
confirmEnableTwoFactor,
disableTwoFactor,
verifyPassword,
getUserProfile,
updateUserProfile,
getUsers,
@@ -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 };
+154
View File
@@ -175,6 +175,10 @@ app.use('/api/stats', statsRoutes);
app.use('/api', costRoutes);
app.use('/api/cashups', cashupRoutes);
app.use('/api/admin', adminRoutes);
app.use('/api/coupons', require('./routes/couponRoutes'));
app.use('/api/consent', require('./routes/consentRoutes'));
app.use('/api/waitlist', require('./routes/waitlistRoutes'));
app.use('/api/scan-locks', require('./routes/scanLockRoutes'));
app.use('/api/backups', backupRoutes);
// Pre-warm the settings cache so synchronous helpers have DB values from startup
@@ -391,8 +395,24 @@ app.get('/docs', async (req, res) => {
request:{ body:{ email:'jane@example.com', password:'secret123' }},
responses:[
{ status:200, desc:'Success', body:{ id:'a1b2c3d4-...', name:'Jane Doe', email:'jane@example.com', role:'user', token:'eyJhbGciOiJIUzI1NiJ9...' }},
{ status:200, desc:'Two-factor code sent (account has 2FA on) — no token yet', body:{ twoFactorRequired:true, challengeId:'c0ffee00-...', method:'email', destination:'j•••@example.com' }},
{ status:200, desc:'Admin/supervisor without 2FA — code sent; verifying it turns 2FA on', body:{ twoFactorRequired:true, enrolling:true, challengeId:'c0ffee00-...', method:'email', destination:'j•••@example.com' }},
{ status:401, desc:'Wrong credentials', body:{ message:'Invalid email or password' }},
]},
{ method:'POST', path:'/api/users/login/2fa', auth:'public', desc:'Second login step — exchange the emailed/WhatsApp code for a JWT',
notes:'Codes expire after 10 minutes and allow 5 attempts. With rememberDevice, the response includes a deviceToken; send it with later logins to skip the code for 30 days.',
request:{ body:{ challengeId:'c0ffee00-...', code:'123456', rememberDevice:true }},
responses:[
{ status:200, desc:'Success', body:{ id:'a1b2c3d4-...', name:'Jane Doe', email:'jane@example.com', role:'user', token:'eyJhbGciOiJIUzI1NiJ9...', deviceToken:'9f2c...' }},
{ status:400, desc:'Wrong or expired code', body:{ message:'Incorrect code. 4 attempts left.' }},
]},
{ method:'POST', path:'/api/users/login/2fa/resend', auth:'public', desc:'Send a new code for a pending login challenge',
notes:'At most one send per 60 seconds.',
request:{ body:{ challengeId:'c0ffee00-...' }},
responses:[
{ status:200, desc:'Sent', body:{ challengeId:'c0ffee00-...', method:'email', destination:'j•••@example.com' }},
{ status:429, desc:'Too soon', body:{ message:'Please wait 42 seconds before requesting another code.' }},
]},
{ method:'POST', path:'/api/users/forgot', auth:'public', desc:'Request a password reset email',
request:{ body:{ email:'jane@example.com' }},
responses:[
@@ -424,6 +444,29 @@ app.get('/docs', async (req, res) => {
responses:[{ status:200, desc:'Success', body:{ message:'All sessions revoked.' }}]},
{ method:'POST', path:'/api/users/close-account', auth:'user+', desc:'Deactivate own account',
responses:[{ status:200, desc:'Success', body:{ message:'Account deactivated.' }}]},
{ method:'POST', path:'/api/users/2fa/enable', auth:'user+', desc:'Start turning on two-factor authentication — sends a code to the chosen channel',
notes:'WhatsApp needs a valid phone number on the account and a connected WhatsApp session.',
request:{ body:{ method:'email', password:'secret123' }},
responses:[
{ status:200, desc:'Code sent', body:{ challengeId:'c0ffee00-...', method:'email', destination:'j•••@example.com' }},
{ status:400, desc:'Wrong password or unavailable channel', body:{ message:'Incorrect password' }},
]},
{ method:'POST', path:'/api/users/2fa/confirm', auth:'user+', desc:'Finish turning on two-factor authentication with the code that was sent',
request:{ body:{ challengeId:'c0ffee00-...', code:'123456' }},
responses:[{ status:200, desc:'Enabled', body:{ twoFactorEnabled:true, twoFactorMethod:'email' }}]},
{ method:'POST', path:'/api/users/2fa/disable', auth:'user+', desc:'Turn off two-factor authentication',
request:{ body:{ password:'secret123' }},
notes:'Admins and supervisors must keep two-factor on — 403 for them.',
responses:[
{ status:200, desc:'Disabled', body:{ twoFactorEnabled:false, twoFactorMethod:null }},
{ status:403, desc:'Required for this role', body:{ message:"Two-factor authentication is required for your role and can't be turned off." }},
]},
{ method:'POST', path:'/api/users/verify-password', auth:'user+', desc:'Re-confirm own password without starting a new session',
request:{ body:{ password:'secret123' }},
responses:[
{ status:200, desc:'Correct', body:{ ok:true }},
{ status:401, desc:'Wrong password', body:{ message:'Incorrect password' }},
]},
{ method:'GET', path:'/api/users', auth:'supervisor+', desc:'List all users (paginated). All filters are applied server-side.',
queryParams:{ page:'Page number (default 1)', limit:'Items per page (max 200, default 100)', search:'Search by name, email, or phone number (case-insensitive substring)', role:'Filter by role: admin|supervisor|staff|user', isActive:'Filter by active status: true|false (omit for both)' },
responses:[
@@ -570,6 +613,10 @@ app.get('/docs', async (req, res) => {
{ method:'GET', path:'/api/registrations/event/:eventId', auth:'staff+', desc:'Get all registrations for a specific event',
pathParams:{ ':eventId':'Event UUID' },
responses:[{ status:200, desc:'Success', body:[{ id:'reg-uuid-...', user:{ id:'user-uuid-...', name:'Jane Doe', email:'jane@example.com', phoneNumber:'+27821234567' }, status:'paid' }]}]},
{ method:'PUT', path:'/api/registrations/:id/attendees', auth:'user+', desc:'Replace the named attendees on a registration (group tickets) and choose ticket delivery (payer | each). Owner or staff. Removing an attendee whose ticket was scanned is refused.',
request:{ body:{ attendees:[{ id:'att-uuid-...', eventOptionId:'opt-uuid-...', name:'Sam Smith', email:'sam@example.com' },{ eventOptionId:'opt-uuid-...', name:'Alex Smith' }], ticketDelivery:'each' }},
responses:[{ status:200, desc:'Saved', body:{ id:'reg-uuid-...', ticketDelivery:'each', attendees:[{ id:'att-uuid-...', name:'Sam Smith', ticket:{ id:'tkt-uuid-...', isUsed:false }}] }}]},
{ method:'POST', path:'/api/registrations/:id/attendees/:attendeeId/send-form-link', auth:'user+', desc:'Email/WhatsApp a named attendee the link to fill in their own attendee form' },
{ method:'POST', path:'/api/registrations/manual', auth:'supervisor+', desc:'Manually create a registration (at-the-door / walk-in). Resolves early-bird tier pricing (including per-variant tiers) and stores priceSnapshot + appliedTierId on each option. Creates or finds a matching user by email/phone. If a matching user is found, updates their notificationPreference and backfills a missing phone number or guest placeholder email with the newly supplied value (without overwriting existing contact info). Generates a Yoco checkout link for unpaid registrations to include in the self-service notification email.',
request:{ body:{ eventId:'ev-uuid-...', user:{ name:'Jane Doe', email:'jane@example.com', phoneNumber:'0821234567' }, options:[{ eventOptionId:'opt-uuid-...', quantity:1, variantId:'var-uuid-...' }], notificationPreference:'email' }},
responses:[{ status:201, desc:'Created', body:{ id:'reg-uuid-new', status:'pending' }}]},
@@ -588,6 +635,60 @@ app.get('/docs', async (req, res) => {
]},
]},
{ title: 'Offline scanning', base: '/api/scan-locks', endpoints: [
{ method:'GET', path:'/api/scan-locks', auth:'staff+', desc:'Active offline locks for an event (?eventId=)' },
{ method:'POST', path:'/api/scan-locks', auth:'staff+', desc:'Lock a section (or the whole event) to this device for offline scanning',
request:{ body:{ eventId:'ev-uuid-...', sectionId:'sec-uuid-...', deviceId:'device-uuid', deviceName:'Chrome on Android' }},
responses:[{ status:201, desc:'Locked', body:{ id:'lock-uuid-...', sectionName:'Main hall' }}, { status:409, desc:'Held elsewhere', body:{ message:'"Main hall" is offline-locked by Ann on Chrome on Android.' }}]},
{ method:'GET', path:'/api/scan-locks/:id/snapshot', auth:'staff+', desc:'Tickets valid in the lock scope, for the device to keep offline (lock holder, ?deviceId=)' },
{ method:'POST', path:'/api/scan-locks/:id/sync', auth:'staff+', desc:'Upload offline scans (idempotent by clientScanId; capped at what is left on each ticket)',
request:{ body:{ deviceId:'device-uuid', scans:[{ clientScanId:'scan-uuid', qrCode:'qr-uuid', qty:1, scannedAt:'2025-07-10T08:15:00.000Z' }] }},
responses:[{ status:200, desc:'Synced', body:{ accepted:1, duplicates:0, conflicts:[] }}]},
{ method:'DELETE', path:'/api/scan-locks/:id', auth:'staff+', desc:'End offline mode (lock holder)' },
{ method:'POST', path:'/api/scan-locks/:id/force-release', auth:'supervisor+', desc:'Release a lost device\'s lock' },
{ method:'GET', path:'/api/scan-locks/reports', auth:'supervisor+', desc:'Offline sync reports for an event' },
{ method:'POST', path:'/api/scan-locks/reports/:id/review', auth:'supervisor+', desc:'Mark a sync report reviewed' },
]},
{ title: 'Waitlist', base: '/api/waitlist', endpoints: [
{ method:'POST', path:'/api/waitlist', auth:'public', desc:'Join the waitlist for a sold-out line (login needed when the event requires it)',
request:{ body:{ eventId:'ev-uuid-...', eventOptionId:'opt-uuid-...', quantity:2 }},
responses:[{ status:201, desc:'Joined', body:{ id:'wl-uuid-...', status:'waiting', ahead:3 }}, { status:400, desc:'Places available', body:{ message:'There are places available — you can register now.' }}]},
{ method:'GET', path:'/api/waitlist/mine', auth:'user+', desc:'My active waitlist entries' },
{ method:'DELETE', path:'/api/waitlist/:id', auth:'user+', desc:'Leave the waitlist' },
{ method:'GET', path:'/api/waitlist/claim/:token', auth:'public', desc:'What a claim link offers (rate-limited)' },
{ method:'GET', path:'/api/waitlist/events/:eventId', auth:'staff+', desc:'The queue for an event' },
{ method:'POST', path:'/api/waitlist/:id/offer', auth:'supervisor+', desc:'Offer places now, even over the stock limit' },
{ method:'POST', path:'/api/waitlist/:id/move', auth:'supervisor+', desc:'Move up or down the queue', request:{ body:{ direction:'up' }} },
{ method:'POST', path:'/api/waitlist/:id/remove', auth:'supervisor+', desc:'Take someone off the waitlist' },
]},
{ title: 'Parental consent', base: '/api/consent', endpoints: [
{ method:'GET', path:'/api/consent/:token', auth:'public', desc:'Consent page data for a parent/guardian (the link token is the credential; rate-limited)',
responses:[{ status:200, desc:'Success', body:{ status:'pending', attendeeName:'Sam Smith', consentText:'I give permission…', event:{ title:'Youth Camp' }, collect:{ medical:true, emergency:true, photo:true } }}, { status:404, desc:'Unknown or replaced link', body:{ message:'This consent link is not valid.' }}]},
{ method:'POST', path:'/api/consent/:token', auth:'public', desc:'Give or decline consent. Records name, time, IP, device and the consent text version; medical/emergency details are stored encrypted.',
request:{ body:{ decision:'give', signedName:'Pat Smith', isGuardian:true, medical:{ allergies:'Peanuts' }, emergency:{ name:'Gran', phone:'0821234567' }, photoConsent:true }},
responses:[{ status:200, desc:'Answered', body:{ status:'given' }}, { status:409, desc:'Already answered', body:{ message:'Consent has already been given.' }}]},
{ method:'GET', path:'/api/consent/events/:eventId', auth:'staff+', desc:'Consent status for every attendee on an event' },
{ method:'POST', path:'/api/consent/requests/:id/resend', auth:'user+', desc:'Re-send a pending request with a fresh link (registrant or staff)' },
{ method:'POST', path:'/api/consent/events/:eventId/resend-pending', auth:'supervisor+', desc:'Re-send every pending request on an event' },
{ method:'POST', path:'/api/consent/events/:eventId/rerequest', auth:'supervisor+', desc:'Ask again where consent was given on an older version of the text, or never requested' },
{ method:'GET', path:'/api/consent/events/:eventId/medical', auth:'supervisor+', desc:'Decrypted medical & emergency list; every view is written to the admin audit log' },
]},
{ title: 'Coupons', base: '/api/coupons', endpoints: [
{ method:'POST', path:'/api/coupons/validate', auth:'public', desc:'Check a code against a basket before registering; returns the discount. Login needed for restricted codes; staff can pass forUserId (or null for a walk-in).',
request:{ body:{ code:'STAFF50', eventId:'ev-uuid-...', options:[{ eventOptionId:'opt-uuid-...', quantity:2 }] }},
responses:[
{ status:200, desc:'Valid', body:{ valid:true, code:'STAFF50', type:'percent', value:50, summary:'50% off', discount:450, subtotal:900, total:450 }},
{ status:400, desc:'Not usable', body:{ valid:false, message:'This code has expired.' }},
]},
{ method:'GET', path:'/api/coupons', auth:'supervisor+', desc:'List codes with use counts and total discount given',
responses:[{ status:200, desc:'Success', body:[{ id:'cpn-uuid-...', code:'STAFF50', type:'percent', value:50, summary:'50% off', active:true, uses:3, totalDiscount:1350 }]}]},
{ method:'POST', path:'/api/coupons', auth:'supervisor+', desc:'Create a code. type: percent | fixed | comp. eventIds/options/userIds/restrictRoles/restrictDomain are optional.',
request:{ body:{ code:'STAFF50', type:'percent', value:50, eventIds:['ev-uuid-...'], restrictRoles:['staff','supervisor','admin'], maxUsesPerUser:1, stackEarlyBird:false }},
responses:[{ status:201, desc:'Created', body:{ id:'cpn-uuid-...', code:'STAFF50' }}, { status:409, desc:'Code exists', body:{ message:'The code STAFF50 already exists' }}]},
{ method:'GET', path:'/api/coupons/:id', auth:'supervisor+', desc:'One code with every use (redemption)' },
{ method:'PUT', path:'/api/coupons/:id', auth:'supervisor+', desc:'Edit a code or turn it off ({ active:false }). Discounts already given keep their amount; a used code can\'t be renamed.' },
{ method:'GET', path:'/api/coupons/redemptions', auth:'supervisor+', desc:'Active uses for the Discounts report. Query: eventIds (comma-separated), from, to' },
]},
{ title: 'Payments', base: '/api/payments', endpoints: [
{ method:'POST', path:'/api/payments/yoco-checkout', auth:'user+', desc:'Initiate a Yoco checkout session. Before creating the checkout, re-evaluates early-bird tier eligibility (deadline + stock). If any price changed since registration, returns priceUpdated:true instead of creating a checkout — the client must inform the user and retry.',
request:{ body:{ registrationId:'reg-uuid-...', amount:450, successUrl:'https://events.example.com/payment/success', cancelUrl:'https://events.example.com/payment/cancel', failureUrl:'https://events.example.com/payment/failure' }},
@@ -1235,6 +1336,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');
+17 -1
View File
@@ -2,6 +2,10 @@ const rateLimit = require("express-rate-limit");
const jwt = require('jsonwebtoken');
const prisma = require('../config/db');
const { mustEnrolTwoFactor } = require('../utils/twoFactor');
// The setup wizard's token may finish setup without 2FA for this long.
const SETUP_TOKEN_GRACE_SECONDS = 2 * 60 * 60;
// Protect routes - verify token
const protect = async (req, res, next) => {
@@ -29,7 +33,8 @@ const protect = async (req, res, next) => {
createdAt: true,
updatedAt: true,
phoneNumber: true,
tokenVersion: true
tokenVersion: true,
twoFactorEnabled: true
}
});
@@ -51,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);
+20
View File
@@ -0,0 +1,20 @@
const express = require('express');
const rateLimit = require('express-rate-limit');
const router = express.Router();
const { getConsent, answerConsent, listEventConsent, resendRequest, resendPending, rerequestConsent, medicalList } = require('../controllers/consentController');
const { protect, staff, supervisor } = require('../middleware/authMiddleware');
// The token in the link is the parent's only credential, so keep guessing slow.
const consentLimiter = rateLimit({ windowMs: 60 * 1000, max: 30, message: { message: 'Too many attempts. Please try again in a minute.' } });
router.get('/events/:eventId', protect, staff, listEventConsent);
router.post('/events/:eventId/resend-pending', protect, supervisor, resendPending);
router.post('/events/:eventId/rerequest', protect, supervisor, rerequestConsent);
router.get('/events/:eventId/medical', protect, supervisor, medicalList);
router.post('/requests/:id/resend', protect, resendRequest);
// Public consent page
router.get('/:token', consentLimiter, getConsent);
router.post('/:token', consentLimiter, answerConsent);
module.exports = router;
+15
View File
@@ -0,0 +1,15 @@
const express = require('express');
const router = express.Router();
const { listCoupons, getCoupon, createCoupon, updateCoupon, validateCode, listRedemptions } = require('../controllers/couponController');
const { protect, supervisor, optionalAuth } = require('../middleware/authMiddleware');
// Check a code against a basket before registering (guests included)
router.post('/validate', optionalAuth, validateCode);
router.get('/', protect, supervisor, listCoupons);
router.post('/', protect, supervisor, createCoupon);
router.get('/redemptions', protect, supervisor, listRedemptions);
router.get('/:id', protect, supervisor, getCoupon);
router.put('/:id', protect, supervisor, updateCoupon);
module.exports = router;
+6
View File
@@ -13,6 +13,8 @@ const {
submitFormResponses, replaceFormResponses,
getFormDraft, saveFormDraft,
sendInvoice,
applyCoupon, removeCoupon,
updateAttendees, sendAttendeeFormLink,
} = require('../controllers/registrationController');
const { protect, supervisor, staff, optionalAuth } = require('../middleware/authMiddleware');
@@ -22,6 +24,10 @@ 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);
+15
View File
@@ -0,0 +1,15 @@
const express = require('express');
const router = express.Router();
const { listLocks, createLock, getSnapshot, syncLock, releaseLock, forceRelease, listReports, reviewReport } = require('../controllers/scanLockController');
const { protect, staff, supervisor } = require('../middleware/authMiddleware');
router.get('/', protect, staff, listLocks);
router.post('/', protect, staff, createLock);
router.get('/reports', protect, supervisor, listReports);
router.post('/reports/:id/review', protect, supervisor, reviewReport);
router.get('/:id/snapshot', protect, staff, getSnapshot);
router.post('/:id/sync', protect, staff, syncLock);
router.post('/:id/force-release', protect, supervisor, forceRelease);
router.delete('/:id', protect, staff, releaseLock);
module.exports = router;
+12
View File
@@ -3,6 +3,12 @@ const router = express.Router();
const {
registerUser,
loginUser,
verifyLoginTwoFactor,
resendLoginTwoFactor,
startEnableTwoFactor,
confirmEnableTwoFactor,
disableTwoFactor,
verifyPassword,
getUserProfile,
updateUserProfile,
getUsers,
@@ -24,6 +30,8 @@ const { protect, admin, supervisor, loginLimiter} = require('../middleware/authM
// Public routes
router.post('/', registerUser);
router.post('/login', loginLimiter, loginUser);
router.post('/login/2fa', loginLimiter, verifyLoginTwoFactor);
router.post('/login/2fa/resend', loginLimiter, resendLoginTwoFactor);
router.post('/forgot', requestPasswordReset);
router.post('/reset', resetPassword);
router.post('/activate', activateAccount);
@@ -36,6 +44,10 @@ router.route('/profile')
router.post('/revoke-sessions', protect, revokeMySession);
router.post('/close-account', protect, closeAccount);
router.get('/activity', protect, getMyActivity);
router.post('/verify-password', protect, loginLimiter, verifyPassword);
router.post('/2fa/enable', protect, loginLimiter, startEnableTwoFactor);
router.post('/2fa/confirm', protect, loginLimiter, confirmEnableTwoFactor);
router.post('/2fa/disable', protect, loginLimiter, disableTwoFactor);
// Admin routes
router.route('/')
+19
View File
@@ -0,0 +1,19 @@
const express = require('express');
const rateLimit = require('express-rate-limit');
const router = express.Router();
const { joinWaitlist, myWaitlist, leaveWaitlist, claimInfo, eventWaitlist, offerManually, moveEntry, removeEntry } = require('../controllers/waitlistController');
const { protect, staff, supervisor, optionalAuth } = require('../middleware/authMiddleware');
// Claim links are credentials, so keep guessing slow.
const claimLimiter = rateLimit({ windowMs: 60 * 1000, max: 30, message: { message: 'Too many attempts. Please try again in a minute.' } });
router.post('/', optionalAuth, joinWaitlist);
router.get('/mine', protect, myWaitlist);
router.get('/claim/:token', claimLimiter, claimInfo);
router.get('/events/:eventId', protect, staff, eventWaitlist);
router.post('/:id/offer', protect, supervisor, offerManually);
router.post('/:id/move', protect, supervisor, moveEntry);
router.post('/:id/remove', protect, supervisor, removeEntry);
router.delete('/:id', protect, leaveWaitlist);
module.exports = router;
+36
View File
@@ -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 };
+209
View File
@@ -0,0 +1,209 @@
/**
* Group tickets — named attendees on a registration.
*
* Rules:
* - Only main-ticket places can be named, and only on events with
* allowNamedAttendees. Each named attendee gets their own quantity-1 ticket; any
* places left unnamed share one quantity ticket, as before.
* - The payer stays the registration owner (billing, invoices, payments). An attendee
* whose email/phone matches an existing account is linked to it, so the ticket also
* shows in that person's dashboard — unless email and phone point at two different
* accounts, in which case nothing is linked.
* - Registration.ticketDelivery = 'each' sends each attendee their own ticket by
* email/WhatsApp; attendees with no contact details fall back to the payer.
* - Youth events (requiresParentalConsent): every main place must be named with a date
* of birth, and anyone under 18 on the start date needs a parent/guardian name, email
* and phone (see utils/consent.js).
*/
const prisma = require('../config/db');
const { findAccountByContact } = require('./accountMatch');
const { isMinor } = require('./consent');
const EMAIL_RE = /^[^@\s]+@[^@\s]+\.[^@\s]+$/;
const lineKey = (eventOptionId, variantId) => `${eventOptionId}::${variantId || ''}`;
function attendeeError(message) {
const err = new Error(message);
err.statusCode = 400;
return err;
}
/**
* Checks attendees against the event and the number of places on each line. Pure.
* Returns the cleaned list (trimmed strings, nulls for blanks).
*
* @param {object} event - with allowNamedAttendees and eventOptions[{ id, isMainTicket, name }]
* @param {object} placesByKey - total places each line will have, keyed by lineKey()
* @param {Array} attendees - [{ eventOptionId, variantId?, name, email?, phone? }]
* @param {object} [namedByKey] - places on those lines already named (and being kept)
*/
function validateAttendees(event, placesByKey, attendees, namedByKey = {}) {
const consent = !!event.requiresParentalConsent;
if ((!attendees || attendees.length === 0) && !consent) return [];
if (!event.allowNamedAttendees && !consent) throw attendeeError('This event doesn\'t take attendee names.');
if (!Array.isArray(attendees || [])) throw attendeeError('attendees must be a list');
const countByKey = {};
const cleaned = (attendees || []).map((a, i) => {
const option = (event.eventOptions || []).find(o => o.id === a?.eventOptionId);
if (!option) throw attendeeError(`Attendee ${i + 1}: unknown ticket option.`);
if (!option.isMainTicket) throw attendeeError(`Attendee ${i + 1}: names can only be given for "${option.name}" main tickets.`);
const name = String(a.name || '').trim();
if (!name) throw attendeeError(`Attendee ${i + 1}: enter a name.`);
const email = String(a.email || '').trim() || null;
if (email && !EMAIL_RE.test(email)) throw attendeeError(`Attendee ${i + 1}: "${email}" isn't a valid email address.`);
const phone = String(a.phone || '').trim() || null;
const key = lineKey(a.eventOptionId, a.variantId);
countByKey[key] = (countByKey[key] || 0) + 1;
if (countByKey[key] + (namedByKey[key] || 0) > (placesByKey[key] || 0)) {
throw attendeeError(`More names than "${option.name}" tickets.`);
}
// Date of birth (always kept if given) and, for youth events, parent details.
let dateOfBirth = null;
if (a.dateOfBirth) {
dateOfBirth = new Date(a.dateOfBirth);
if (isNaN(dateOfBirth.getTime()) || dateOfBirth > new Date()) throw attendeeError(`${name}: enter a valid date of birth.`);
} else if (consent) {
throw attendeeError(`${name}: enter a date of birth.`);
}
const parentName = String(a.parentName || '').trim() || null;
const parentEmail = String(a.parentEmail || '').trim() || null;
const parentPhone = String(a.parentPhone || '').trim() || null;
if (consent && isMinor(dateOfBirth, event.startDate)) {
if (!parentName || !parentEmail || !parentPhone) {
throw attendeeError(`${name} is under 18, so a parent or guardian's name, email and phone are needed for consent.`);
}
if (!EMAIL_RE.test(parentEmail)) throw attendeeError(`${name}: "${parentEmail}" isn't a valid parent email address.`);
}
return { eventOptionId: a.eventOptionId, variantId: a.variantId || null, name, email, phone, dateOfBirth, parentName, parentEmail, parentPhone };
});
// Youth events need every main place named (with a date of birth).
if (consent) {
for (const o of event.eventOptions || []) {
if (!o.isMainTicket) continue;
for (const [key, places] of Object.entries(placesByKey)) {
if (!key.startsWith(`${o.id}::`)) continue;
if ((countByKey[key] || 0) + (namedByKey[key] || 0) < places) {
throw attendeeError(`This is a youth event — add a name and date of birth for every "${o.name}" ticket.`);
}
}
}
}
return cleaned;
}
/**
* Works out the tickets one registration line should have, given its named attendees.
* Pure — returns operations for the caller to run.
*
* - Every attendee gets a quantity-1 ticket (holderUserId follows the attendee's account).
* - Unnamed places share one quantity ticket; a scanned one is never deleted or shrunk
* below what's been redeemed.
* - Tickets for attendees that no longer exist are removed unless already scanned.
*
* @param {object} line - { quantity }
* @param {Array} attendees - [{ id, userId }]
* @param {Array} tickets - existing [{ id, attendeeId, quantity, holderUserId, usages: [] }]
* @returns {{ create: Array<{attendeeId, holderUserId, quantity}>, update: Array<{id, data}>, remove: string[] }}
*/
function planLineTickets(line, attendees, tickets) {
const scanned = (t) => (t.usages || []).length > 0;
const redeemed = (t) => (t.usages || []).reduce((s, u) => s + (u.quantityRedeemed || 0), 0);
const ops = { create: [], update: [], remove: [] };
const attendeeIds = new Set(attendees.map(a => a.id));
const byAttendee = new Map();
const orphanNamed = [];
const shared = [];
for (const t of tickets) {
if (t.attendeeId && attendeeIds.has(t.attendeeId)) byAttendee.set(t.attendeeId, t);
else if (t.attendeeId) orphanNamed.push(t);
else shared.push(t);
}
// Tickets whose attendee was removed: drop unless already used (then keep, as shared).
let keptScannedOrphans = 0;
for (const t of orphanNamed) {
if (scanned(t)) keptScannedOrphans += t.quantity || 1;
else ops.remove.push(t.id);
}
for (const a of attendees) {
const t = byAttendee.get(a.id);
if (!t) {
ops.create.push({ attendeeId: a.id, holderUserId: a.userId || null, quantity: 1 });
} else if ((t.holderUserId || null) !== (a.userId || null) || (t.quantity || 1) !== 1) {
ops.update.push({ id: t.id, data: { holderUserId: a.userId || null, ...(scanned(t) ? {} : { quantity: 1 }) } });
}
}
// Unnamed places share one ticket.
const remainder = Math.max(0, (line.quantity || 0) - attendees.length - keptScannedOrphans);
const sorted = [...shared].sort((a, b) => (scanned(b) ? 1 : 0) - (scanned(a) ? 1 : 0));
const [primary, ...extras] = sorted;
if (primary) {
const floor = scanned(primary) ? redeemed(primary) : 0;
if (remainder === 0 && !scanned(primary)) ops.remove.push(primary.id);
else {
const qty = Math.max(remainder, floor);
if (qty !== primary.quantity) ops.update.push({ id: primary.id, data: { quantity: qty } });
}
} else if (remainder > 0) {
ops.create.push({ attendeeId: null, holderUserId: null, quantity: remainder });
}
for (const t of extras) if (!scanned(t)) ops.remove.push(t.id);
return ops;
}
/**
* Saves new attendees on a registration, linking each to its registration line and to
* a matching account when there is exactly one.
*/
async function createAttendees(registrationId, attendees, db = prisma) {
if (!attendees || attendees.length === 0) return [];
const lines = await db.registrationOption.findMany({ where: { registrationId } });
const existing = await db.attendee.findMany({ where: { registrationId }, select: { registrationOptionId: true } });
const orderByLine = {};
existing.forEach(a => { orderByLine[a.registrationOptionId] = (orderByLine[a.registrationOptionId] || 0) + 1; });
const created = [];
for (const a of attendees) {
const line = lines.find(l => l.eventOptionId === a.eventOptionId && (l.variantId || null) === (a.variantId || null));
const match = (a.email || a.phone) ? await findAccountByContact({ email: a.email, phone: a.phone }, db) : { user: null };
const order = orderByLine[line?.id] || 0;
orderByLine[line?.id] = order + 1;
created.push(await db.attendee.create({
data: {
registrationId,
registrationOptionId: line?.id || null,
eventOptionId: a.eventOptionId,
variantId: a.variantId || null,
name: a.name,
email: a.email,
phone: a.phone,
dateOfBirth: a.dateOfBirth || null,
parentName: a.parentName || null,
parentEmail: a.parentEmail || null,
parentPhone: a.parentPhone || null,
userId: match.user?.id || null,
order,
},
}));
}
return created;
}
/** Named places already on a registration, per line key — for validating additions. */
async function namedPlacesByKey(registrationId, db = prisma) {
const rows = await db.attendee.findMany({ where: { registrationId }, select: { eventOptionId: true, variantId: true } });
const out = {};
rows.forEach(r => { const k = lineKey(r.eventOptionId, r.variantId); out[k] = (out[k] || 0) + 1; });
return out;
}
module.exports = { lineKey, validateAttendees, planLineTickets, createAttendees, namedPlacesByKey, attendeeError };
+7 -2
View File
@@ -72,7 +72,7 @@ async function computeEventFinancials(eventId) {
throw new Error('Event not found');
}
const [payments, costs, tickets, salesRows, history] = await Promise.all([
const [payments, costs, tickets, salesRows, history, discountAgg] = await Promise.all([
prisma.payment.findMany({
where: { OR: [{ eventId }, { registration: { eventId } }] }
}),
@@ -92,7 +92,9 @@ async function computeEventFinancials(eventId) {
performedBy: { select: { id: true, name: true, email: true } }
},
orderBy: { createdAt: 'desc' }
})
}),
// Coupon discounts on the same paid registrations salesByOption counts.
prisma.registration.aggregate({ where: { eventId, status: 'paid' }, _sum: { discountTotal: true } })
]);
// Ticket quantity sold per EventOption (used to price per-item costs)
@@ -204,6 +206,8 @@ async function computeEventFinancials(eventId) {
salesByOptionMap[opt.id].revenue += computeOptionLineTotal(ro, null, new Date());
}
const salesByOption = Object.values(salesByOptionMap);
// salesByOption is at list (or early-bird) price; this is what coupon codes took off it.
const discountsTotal = Number(discountAgg?._sum?.discountTotal || 0);
return {
event,
@@ -222,6 +226,7 @@ async function computeEventFinancials(eventId) {
unallocatedDonationsTotal,
totalDonations,
salesByOption,
discountsTotal,
history
};
}
+332
View File
@@ -0,0 +1,332 @@
/**
* Parental consent for youth events.
*
* Rules:
* - On an event with requiresParentalConsent, every attendee gives a date of birth.
* Anyone under 18 on the event's start date needs a parent/guardian (name, email,
* phone), who is sent a consent request by email AND WhatsApp — regardless of anyone's
* notification preference.
* - A request's link token is random; only its HMAC is stored. Links expire when the
* event ends and can be re-sent (which issues a new token).
* - The parent's answer records the time, IP, device, typed name, and the exact consent
* text and version they saw. Medical/emergency details are stored encrypted and purged
* a set number of days after the event (setting consent_medical_retention_days).
* - Tickets are issued as normal but can't be checked in until consent is given — the
* latest request for the attendee decides. On a consent event, a ticket with no named
* attendee is treated as missing consent. Supervisors can override (audited).
* - Reminders go 2 days after the request, then every 3 days, until answered or the event.
*/
const crypto = require('crypto');
const prisma = require('../config/db');
const DAY = 24 * 60 * 60 * 1000;
const FIRST_REMINDER_AFTER = 2 * DAY;
const REMINDER_EVERY = 3 * DAY;
const DEFAULT_RETENTION_DAYS = 30;
function consentError(message, statusCode = 400) {
const err = new Error(message);
err.statusCode = statusCode;
return err;
}
// ─── Pure helpers ─────────────────────────────────────────────────────────────
/** Whole years old on `onDate`. */
function ageOn(dateOfBirth, onDate) {
const dob = new Date(dateOfBirth);
const on = new Date(onDate);
let age = on.getUTCFullYear() - dob.getUTCFullYear();
const beforeBirthday = on.getUTCMonth() < dob.getUTCMonth()
|| (on.getUTCMonth() === dob.getUTCMonth() && on.getUTCDate() < dob.getUTCDate());
if (beforeBirthday) age -= 1;
return age;
}
/** Under 18 on the event's start date. */
function isMinor(dateOfBirth, eventStart) {
if (!dateOfBirth) return false;
return ageOn(dateOfBirth, eventStart) < 18;
}
/**
* Consent state for one attendee: 'not_required' | 'missing' | 'pending' | 'given' | 'declined'.
* `latest` is the attendee's most recent ConsentRequest (or null).
*/
function consentStatusFor(attendee, event, latest) {
if (!event?.requiresParentalConsent) return 'not_required';
if (!attendee) return 'missing'; // unnamed place on a consent event
if (!isMinor(attendee.dateOfBirth, event.startDate)) return 'not_required';
if (!latest) return 'missing';
return latest.status;
}
/** Whether a pending request is due a reminder now. */
function reminderDue(request, now = new Date()) {
if (!request || request.status !== 'pending') return false;
const t = new Date(now).getTime();
if (t >= new Date(request.expiresAt).getTime()) return false;
const gap = request.remindersSent === 0 ? FIRST_REMINDER_AFTER : REMINDER_EVERY;
return t - new Date(request.lastSentAt).getTime() >= gap;
}
const str = (v, max = 500) => String(v ?? '').trim().slice(0, max);
/**
* Checks a parent's answer on the consent page. Pure. Returns the cleaned answer.
* @param {object} body - { decision: 'give'|'decline', signedName, isGuardian, medical, emergency, photoConsent }
* @param {object} event - with consentCollectMedical / Emergency / Photo
*/
function validateConsentAnswer(body, event) {
const decision = body?.decision;
if (decision !== 'give' && decision !== 'decline') throw consentError('Choose to give or decline consent.');
const signedName = str(body.signedName, 200);
if (signedName.length < 2) throw consentError('Type your full name to sign.');
if (body.isGuardian !== true) throw consentError('Confirm that you are the parent or legal guardian.');
const out = { decision, signedName, medical: null, emergency: null, photoConsent: null };
if (decision === 'decline') return out;
if (event.consentCollectMedical) {
const m = body.medical || {};
out.medical = {
allergies: str(m.allergies), medication: str(m.medication),
conditions: str(m.conditions), medicalAid: str(m.medicalAid, 200),
};
}
if (event.consentCollectEmergency) {
const em = body.emergency || {};
out.emergency = { name: str(em.name, 200), phone: str(em.phone, 40), relationship: str(em.relationship, 100) };
if (!out.emergency.name || !out.emergency.phone) throw consentError('Add an emergency contact name and phone number.');
}
if (event.consentCollectPhoto) {
if (body.photoConsent !== true && body.photoConsent !== false) throw consentError('Answer the photo consent question.');
out.photoConsent = body.photoConsent;
}
return out;
}
// ─── Tokens ───────────────────────────────────────────────────────────────────
const hashToken = (token) => crypto.createHmac('sha256', process.env.JWT_SECRET || '').update(`consent:${token}`).digest('hex');
const newToken = () => crypto.randomBytes(24).toString('hex');
function consentLink(token) {
const base = (process.env.FRONTEND_URL || process.env.APP_BASE_URL || 'http://localhost:3001').split(',')[0].trim().replace(/\/$/, '');
return `${base}/consent/${token}`;
}
// ─── Encryption of medical/emergency details ──────────────────────────────────
function sealJson(value) {
if (!value) return null;
const { encrypt } = require('./encryption');
return encrypt(JSON.stringify(value));
}
function openJson(value) {
if (!value) return null;
try {
const { decrypt } = require('./encryption');
return JSON.parse(decrypt(value));
} catch {
return null;
}
}
// ─── Database helpers ─────────────────────────────────────────────────────────
/** Latest request per attendee id. */
async function latestRequestsFor(attendeeIds, db = prisma) {
if (!attendeeIds.length) return new Map();
const rows = await db.consentRequest.findMany({ where: { attendeeId: { in: attendeeIds } }, orderBy: { createdAt: 'desc' } });
const out = new Map();
for (const r of rows) if (!out.has(r.attendeeId)) out.set(r.attendeeId, r);
return out;
}
async function sendConsentMessage(request, token, { reminder = false } = {}) {
const { sendMail, emailWrapper, ctaButton, fallbackLink } = require('./email');
const { waTextAny } = require('./notify');
const { isValidZAPhone } = require('./whatsapp');
const { getSettingSync } = require('./settingsCache');
const orgName = getSettingSync('org_name', process.env.ORG_NAME || 'Cross Code');
const ff = `-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif`;
const event = request.event || await prisma.event.findUnique({ where: { id: request.eventId } });
const attendee = request.attendee || await prisma.attendee.findUnique({ where: { id: request.attendeeId } });
const link = consentLink(token);
const when = event?.startDate ? new Date(event.startDate).toLocaleDateString('en-GB', { day: 'numeric', month: 'long', year: 'numeric' }) : '';
const subject = `${reminder ? 'Reminder: ' : ''}Consent needed for ${attendee.name} — ${event.title}`;
const intro = `${attendee.name} has been registered for <strong>${event.title}</strong>${when ? ` on <strong>${when}</strong>` : ''}. As their parent or guardian, please review the details and give (or decline) consent.`;
const sent = [];
if (request.parentEmail && request.parentEmail.includes('@')) {
const html = emailWrapper(`
<p style="font-size:22px;font-weight:800;color:#0f172a;margin:0 0 8px 0">${reminder ? 'A reminder: ' : ''}Parental consent needed</p>
<p style="margin:0 0 8px 0;color:#374151;font-family:${ff}">Hi <strong>${request.parentName || 'there'}</strong>,</p>
<p style="margin:0 0 24px 0;color:#374151;font-family:${ff}">${intro}</p>
${ctaButton('Review and give consent', link)}
${fallbackLink(link)}
<p style="font-size:13px;color:#64748b;margin:16px 0 0 0;font-family:${ff}">${attendee.name} can't be checked in at the event until consent is given.</p>
`, { preheader: `Consent needed for ${attendee.name}` });
try {
await sendMail({ to: request.parentEmail, subject, html, text: `Hi ${request.parentName || 'there'},\n\n${intro.replace(/<[^>]+>/g, '')}\n\nReview and give consent: ${link}\n\n${orgName}` });
sent.push('email');
} catch (e) {
console.warn('[consent] email failed:', e?.message || e);
}
}
if (request.parentPhone && isValidZAPhone(request.parentPhone)) {
await waTextAny({ name: request.parentName, phoneNumber: request.parentPhone },
`${reminder ? '⏰ Reminder: ' : ''}Hi ${request.parentName || 'there'}, ${attendee.name} has been registered for *${event.title}*${when ? ` on ${when}` : ''}. Please give (or decline) parental consent here: ${link}`);
sent.push('WhatsApp');
}
return sent;
}
/**
* Creates (and sends) a consent request for an attendee. Earlier requests stay as the
* record of what was agreed before; this one now decides check-in.
*/
async function createConsentRequest(attendee, event) {
const token = newToken();
const request = await prisma.consentRequest.create({
data: {
attendeeId: attendee.id,
eventId: event.id,
tokenHash: hashToken(token),
textVersion: event.consentTextVersion || 1,
consentText: event.consentText || '',
parentName: attendee.parentName,
parentEmail: attendee.parentEmail,
parentPhone: attendee.parentPhone,
expiresAt: event.endDate,
},
});
const sent = await sendConsentMessage({ ...request, event, attendee }, token);
return { request, sent };
}
/**
* Makes sure every under-18 attendee on a registration has a consent request — creates
* and sends one for anyone who has none yet, or whose parent details have changed since.
*/
async function ensureConsentRequests(registrationId) {
const reg = await prisma.registration.findUnique({ where: { id: registrationId }, include: { event: true, attendees: true } });
if (!reg || !reg.event.requiresParentalConsent || reg.status === 'cancelled') return 0;
const minors = reg.attendees.filter(a => isMinor(a.dateOfBirth, reg.event.startDate));
const latest = await latestRequestsFor(minors.map(a => a.id));
let created = 0;
for (const a of minors) {
const r = latest.get(a.id);
const parentChanged = r && (r.parentEmail !== a.parentEmail || r.parentPhone !== a.parentPhone);
if (!r || (parentChanged && r.status !== 'given')) {
await createConsentRequest(a, reg.event);
created += 1;
}
}
return created;
}
/** Re-sends a request with a fresh link (the old link stops working). */
async function resendConsentRequest(requestId) {
const request = await prisma.consentRequest.findUnique({ where: { id: requestId }, include: { event: true, attendee: true } });
if (!request) throw consentError('Consent request not found', 404);
if (request.status !== 'pending') throw consentError(`Consent has already been ${request.status}.`);
if (new Date(request.expiresAt) < new Date()) throw consentError('This event has ended.');
const token = newToken();
await prisma.consentRequest.update({ where: { id: request.id }, data: { tokenHash: hashToken(token), lastSentAt: new Date() } });
return sendConsentMessage(request, token, { reminder: true });
}
async function findRequestByToken(token) {
if (!token || typeof token !== 'string' || token.length > 100) return null;
return prisma.consentRequest.findUnique({
where: { tokenHash: hashToken(token) },
include: { event: true, attendee: { include: { registration: { include: { user: { select: { id: true, name: true, email: true, phoneNumber: true, notificationPreference: true } } } } } } },
});
}
/**
* Consent state for each ticket id — for the scan gate and staff screens.
* @returns {Promise<Map<string, string>>} ticketId → consent status
*/
async function consentStatusForTickets(ticketIds) {
const tickets = await prisma.ticket.findMany({ where: { id: { in: ticketIds } }, include: { event: true, attendee: true } });
const latest = await latestRequestsFor(tickets.map(t => t.attendeeId).filter(Boolean));
const out = new Map();
for (const t of tickets) {
out.set(t.id, consentStatusFor(t.attendee, t.event, t.attendeeId ? latest.get(t.attendeeId) : null));
}
return out;
}
/** Daily job: reminders for unanswered requests. */
async function sendConsentReminders(now = new Date()) {
const pending = await prisma.consentRequest.findMany({
where: { status: 'pending', expiresAt: { gt: now } },
include: { event: true, attendee: true },
});
// Only the latest request per attendee is still live.
const latest = await latestRequestsFor([...new Set(pending.map(r => r.attendeeId))]);
let sent = 0;
for (const r of pending) {
if (latest.get(r.attendeeId)?.id !== r.id || !reminderDue(r, now)) continue;
if (new Date(r.event.startDate) <= now) continue; // the event has started
const token = newToken();
await prisma.consentRequest.update({ where: { id: r.id }, data: { tokenHash: hashToken(token), lastSentAt: now, remindersSent: { increment: 1 } } });
await sendConsentMessage(r, token, { reminder: true }).catch(e => console.warn('[consent] reminder failed:', e?.message || e));
sent += 1;
}
return sent;
}
/** Daily job: delete medical/emergency details a set number of days after the event. */
async function purgeConsentMedicalData(now = new Date()) {
const { getSettingSync } = require('./settingsCache');
const days = Math.max(0, parseInt(getSettingSync('consent_medical_retention_days', String(DEFAULT_RETENTION_DAYS)), 10) || DEFAULT_RETENTION_DAYS);
const cutoff = new Date(new Date(now).getTime() - days * DAY);
const res = await prisma.consentRequest.updateMany({
where: { medicalPurgedAt: null, event: { endDate: { lt: cutoff } }, OR: [{ medical: { not: null } }, { emergency: { not: null } }] },
data: { medical: null, emergency: null, medicalPurgedAt: now },
});
return res.count;
}
/** Clears parent, medical and emergency details for a person's registrations (anonymise). */
async function clearConsentDataForUser(userId, db = prisma) {
const attendees = await db.attendee.findMany({ where: { OR: [{ userId }, { registration: { userId } }] }, select: { id: true } });
const ids = attendees.map(a => a.id);
if (!ids.length) return 0;
await db.attendee.updateMany({ where: { id: { in: ids } }, data: { parentName: null, parentEmail: null, parentPhone: null, dateOfBirth: null, email: null, phone: null } });
const res = await db.consentRequest.updateMany({
where: { attendeeId: { in: ids } },
data: { medical: null, emergency: null, parentName: null, parentEmail: null, parentPhone: null, ip: null, device: null, medicalPurgedAt: new Date() },
});
return res.count;
}
module.exports = {
DEFAULT_RETENTION_DAYS,
consentError,
ageOn,
isMinor,
consentStatusFor,
reminderDue,
validateConsentAnswer,
hashToken,
consentLink,
sealJson,
openJson,
latestRequestsFor,
createConsentRequest,
ensureConsentRequests,
resendConsentRequest,
findRequestByToken,
consentStatusForTickets,
sendConsentReminders,
purgeConsentMedicalData,
clearConsentDataForUser,
};
+298
View File
@@ -0,0 +1,298 @@
/**
* Coupon codes — percentage, fixed-amount and comp (100%) discounts on registrations.
*
* Rules:
* - A registration can carry one active coupon. Its discount is stored as a
* CouponRedemption (amount locked at apply time) and mirrored as the sum of active
* redemptions on Registration.discountTotal, which computeRegistrationTotalDue
* subtracts. Early-bird pricing refreshes never touch it.
* - Editing a registration's items recalculates percentage and comp codes over the new
* items; fixed-amount codes keep their amount (capped at what's in scope).
* - Cancelling a registration releases its redemption, freeing the use.
* - Scope: no CouponOption rows = the whole event; otherwise only matching options
* (or one variant of an option).
* - stackEarlyBird=false means "best price wins": the coupon is worked out against the
* standard price, and only the part that beats the early-bird price is discounted.
* - Restricted codes (users, roles, email domain) need a known account, so guests
* can't use them; a match on any one restriction is enough.
*/
const prisma = require('../config/db');
function normalizeCode(code) {
return String(code || '').trim().toUpperCase();
}
function couponError(message) {
const err = new Error(message);
err.isCouponError = true;
err.statusCode = 400;
return err;
}
const round2 = (n) => Math.round(Number(n || 0) * 100) / 100;
/**
* Discountable lines from a registration — one per price tranche, so tickets bought at
* different prices are each discounted at what was actually charged.
* `basePrice` is the standard (non-early-bird) price, used for "best price wins".
*/
function buildCouponLines(registration) {
const lines = [];
for (const ro of registration?.registrationOptions || []) {
const eo = ro.eventOption || {};
const variant = ro.variantId ? (eo.variants || []).find(v => v.id === ro.variantId) : null;
const standard = variant && variant.price !== null && variant.price !== undefined ? Number(variant.price) : Number(eo.price || 0);
const tranches = ro.tranches && ro.tranches.length > 0
? ro.tranches
: [{ quantity: ro.quantity, priceSnapshot: ro.priceSnapshot ?? standard, appliedTierId: ro.appliedTierId }];
for (const t of tranches) {
const unitPrice = Number(t.priceSnapshot ?? standard);
lines.push({
eventOptionId: ro.eventOptionId,
variantId: ro.variantId || null,
quantity: Number(t.quantity || 0),
unitPrice,
// Only an early-bird tranche has a "standard" price above what was charged; a
// staff price or plain price is already the base the coupon works from.
basePrice: t.appliedTierId ? Math.max(standard, unitPrice) : unitPrice,
});
}
}
return lines;
}
function lineInScope(line, scopes) {
if (!scopes || scopes.length === 0) return true;
return scopes.some(s => s.eventOptionId === line.eventOptionId && (!s.variantId || s.variantId === line.variantId));
}
function scopedLines(coupon, lines) {
return (lines || []).filter(l => lineInScope(l, coupon.options));
}
/**
* The discount a coupon gives on these lines, in rand, never more than what the in-scope
* lines cost. Pure — used for previews, applying, and recalculating after edits.
*/
function computeCouponDiscount(coupon, lines) {
const scoped = scopedLines(coupon, lines);
const charged = scoped.reduce((s, l) => s + l.unitPrice * l.quantity, 0);
if (charged <= 0) return 0;
let discount = 0;
if (coupon.type === 'comp') {
discount = charged;
} else if (coupon.type === 'percent') {
const pct = Math.min(100, Math.max(0, Number(coupon.value || 0))) / 100;
if (coupon.stackEarlyBird) {
discount = charged * pct;
} else {
discount = scoped.reduce((s, l) => s + (l.unitPrice - Math.min(l.unitPrice, l.basePrice * (1 - pct))) * l.quantity, 0);
}
} else if (coupon.type === 'fixed') {
const amount = Math.max(0, Number(coupon.value || 0));
if (coupon.stackEarlyBird) {
discount = amount;
} else {
const base = scoped.reduce((s, l) => s + l.basePrice * l.quantity, 0);
discount = charged - Math.max(0, base - amount);
}
}
return round2(Math.min(charged, Math.max(0, discount)));
}
/**
* Checks whether a coupon can be used. Pure — the caller supplies the use counts.
*
* @param {object} coupon - with events[], options[], allowedUsers[]
* @param {object} ctx - { eventId, user: {id, role, email}|null, now, usesTotal, usesByUser, lines,
* deferRestrictions } — deferRestrictions lets staff preview a
* restricted code before the attendee's account exists; it's
* checked against the attendee when the registration is saved.
* @returns {{ ok: true } | { ok: false, message: string }}
*/
function validateCoupon(coupon, ctx) {
const no = (message) => ({ ok: false, message });
if (!coupon) return no('That code isn\'t valid.');
if (!coupon.active) return no('This code is no longer active.');
const now = ctx.now ? new Date(ctx.now) : new Date();
if (coupon.validFrom && now < new Date(coupon.validFrom)) return no('This code isn\'t valid yet.');
if (coupon.validUntil && now > new Date(coupon.validUntil)) return no('This code has expired.');
if ((coupon.events || []).length > 0 && !coupon.events.some(e => e.eventId === ctx.eventId)) {
return no('This code isn\'t valid for this event.');
}
const restrictedUsers = (coupon.allowedUsers || []).map(u => u.userId);
const restrictedRoles = coupon.restrictRoles || [];
const domain = String(coupon.restrictDomain || '').trim().toLowerCase().replace(/^@/, '');
if ((restrictedUsers.length || restrictedRoles.length || domain) && !(ctx.deferRestrictions && !ctx.user)) {
const user = ctx.user;
if (!user) return no('Log in to use this code.');
const email = String(user.email || '').toLowerCase();
const allowed = restrictedUsers.includes(user.id)
|| restrictedRoles.includes(user.role)
|| (domain && email.endsWith(`@${domain}`));
if (!allowed) return no('This code isn\'t available for your account.');
}
if (coupon.maxUses !== null && coupon.maxUses !== undefined && (ctx.usesTotal || 0) >= coupon.maxUses) {
return no('This code has been fully used.');
}
if (coupon.maxUsesPerUser !== null && coupon.maxUsesPerUser !== undefined && ctx.user && (ctx.usesByUser || 0) >= coupon.maxUsesPerUser) {
return no('You\'ve already used this code.');
}
const scopedQty = scopedLines(coupon, ctx.lines).reduce((s, l) => s + l.quantity, 0);
if (scopedQty === 0) return no('This code doesn\'t apply to the items you\'ve chosen.');
if (coupon.minQuantity && scopedQty < coupon.minQuantity) {
return no(`This code needs at least ${coupon.minQuantity} qualifying ticket${coupon.minQuantity === 1 ? '' : 's'}.`);
}
return { ok: true };
}
/** Short human description, e.g. "20% off", "R50 off", "Free". */
function describeCoupon(coupon) {
if (!coupon) return '';
if (coupon.type === 'comp') return 'Free';
if (coupon.type === 'percent') return `${Number(coupon.value)}% off`;
return `R${Number(coupon.value).toFixed(2)} off`;
}
// ─── Database helpers ─────────────────────────────────────────────────────────
const COUPON_INCLUDE = { events: true, options: true, allowedUsers: true };
const REGISTRATION_INCLUDE = {
registrationOptions: { include: { tranches: true, eventOption: { include: { variants: true } } } },
couponRedemptions: { where: { releasedAt: null }, include: { coupon: { include: COUPON_INCLUDE } } },
user: { select: { id: true, role: true, email: true } },
};
async function findCouponByCode(code, db = prisma) {
const normalized = normalizeCode(code);
if (!normalized) return null;
return db.coupon.findUnique({ where: { code: normalized }, include: COUPON_INCLUDE });
}
/** Active uses: unreleased redemptions on registrations that aren't cancelled. */
async function countCouponUses(couponId, userId, db = prisma) {
const base = { couponId, releasedAt: null, registration: { status: { not: 'cancelled' } } };
const [usesTotal, usesByUser] = await Promise.all([
db.couponRedemption.count({ where: base }),
userId ? db.couponRedemption.count({ where: { ...base, userId } }) : Promise.resolve(0),
]);
return { usesTotal, usesByUser };
}
/**
* Validates `code` against hypothetical lines (nothing saved) and returns the discount it
* would give. Throws a coupon error with a user-facing message when it can't be used.
*/
async function previewCoupon({ code, eventId, user, lines, deferRestrictions = false }) {
const coupon = await findCouponByCode(code);
const uses = coupon ? await countCouponUses(coupon.id, user?.id) : {};
const check = validateCoupon(coupon, { eventId, user, lines, deferRestrictions, ...uses });
if (!check.ok) throw couponError(check.message);
return { coupon, discount: computeCouponDiscount(coupon, lines) };
}
/**
* Applies `code` to a saved registration: validates it against the registration's owner
* and items, locks in the discount, and updates Registration.discountTotal. Runs under a
* per-coupon advisory lock so the last available use can't be claimed twice.
*/
async function applyCouponToRegistration({ registrationId, code, appliedById = null }) {
const normalized = normalizeCode(code);
if (!normalized) throw couponError('Enter a code.');
return prisma.$transaction(async (tx) => {
const coupon = await findCouponByCode(normalized, tx);
if (!coupon) throw couponError('That code isn\'t valid.');
await tx.$executeRaw`SELECT pg_advisory_xact_lock(hashtext(${coupon.id}))`;
const registration = await tx.registration.findUnique({ where: { id: registrationId }, include: REGISTRATION_INCLUDE });
if (!registration) throw couponError('Registration not found.');
if (registration.couponRedemptions.length > 0) {
throw couponError(registration.couponRedemptions[0].couponId === coupon.id
? 'This code is already applied to this registration.'
: 'A code has already been applied to this registration.');
}
const lines = buildCouponLines(registration);
const uses = await countCouponUses(coupon.id, registration.userId, tx);
const check = validateCoupon(coupon, { eventId: registration.eventId, user: registration.user, lines, ...uses });
if (!check.ok) throw couponError(check.message);
const discount = computeCouponDiscount(coupon, lines);
const redemption = await tx.couponRedemption.create({
data: { couponId: coupon.id, registrationId, userId: registration.userId, discountAmount: discount, appliedById },
});
await tx.registration.update({ where: { id: registrationId }, data: { discountTotal: discount, updatedAt: new Date() } });
return { coupon, discount, redemption };
});
}
/**
* Re-works active discounts after a registration's items change: percentage and comp
* codes are recalculated over the new items, fixed codes keep their amount (capped at
* what's still in scope). Pass `tx` to run inside the caller's transaction.
*/
async function recalculateRegistrationDiscount(registrationId, db = prisma) {
const registration = await db.registration.findUnique({ where: { id: registrationId }, include: REGISTRATION_INCLUDE });
if (!registration) return 0;
if (registration.couponRedemptions.length === 0) {
if (registration.discountTotal !== 0) {
await db.registration.update({ where: { id: registrationId }, data: { discountTotal: 0 } });
}
return 0;
}
const lines = buildCouponLines(registration);
let total = 0;
for (const r of registration.couponRedemptions) {
let amount;
if (r.coupon.type === 'fixed') {
const inScope = scopedLines(r.coupon, lines).reduce((s, l) => s + l.unitPrice * l.quantity, 0);
amount = round2(Math.min(r.discountAmount, inScope));
} else {
amount = computeCouponDiscount(r.coupon, lines);
}
if (Math.abs(amount - r.discountAmount) > 0.001) {
await db.couponRedemption.update({ where: { id: r.id }, data: { discountAmount: amount } });
}
total += amount;
}
total = round2(total);
if (Math.abs(total - registration.discountTotal) > 0.001) {
await db.registration.update({ where: { id: registrationId }, data: { discountTotal: total } });
}
return total;
}
/** Releases a cancelled registration's coupon uses so they can be claimed again. */
async function releaseRegistrationCoupons(registrationId, db = prisma) {
const released = await db.couponRedemption.updateMany({
where: { registrationId, releasedAt: null },
data: { releasedAt: new Date() },
});
if (released.count > 0) {
await db.registration.update({ where: { id: registrationId }, data: { discountTotal: 0 } });
}
return released.count;
}
module.exports = {
normalizeCode,
couponError,
buildCouponLines,
computeCouponDiscount,
validateCoupon,
describeCoupon,
findCouponByCode,
countCouponUses,
previewCoupon,
applyCouponToRegistration,
recalculateRegistrationDiscount,
releaseRegistrationCoupons,
};
+29
View File
@@ -49,6 +49,11 @@ function _getTransporter() {
return { transporter: _cachedTransporter, cfg };
}
/** True when a real SMTP server is configured (not the jsonTransport dev fallback). */
function isSmtpConfigured() {
return !!_smtpConfig().host;
}
async function sendMail({ to, subject, html, text, attachments }) {
// Never send to anonymised/deleted accounts
if (!to || String(to).endsWith('@deleted.invalid')) {
@@ -234,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;
@@ -395,6 +422,7 @@ function buildAccountClosedEmail({ name, dataDeleted }) {
module.exports = {
sendMail,
isSmtpConfigured,
emailWrapper,
ctaButton,
fallbackLink,
@@ -403,6 +431,7 @@ module.exports = {
callout,
paymentOption,
buildPasswordResetEmail,
buildTwoFactorCodeEmail,
buildPasswordChangedEmail,
buildLoginNotificationEmail,
buildWelcomeEmail,
+91 -11
View File
@@ -2,6 +2,7 @@ const fs = require('fs');
const prisma = require('../config/db');
const { sendMail, emailWrapper, ctaButton, fallbackLink, secondaryLink, divider, callout, paymentOption } = require('./email');
const { computeRegistrationTotalDue, computeOptionLineTotal } = require('./pricing');
const { couponDiscountLine } = require('./pdfDocs');
// ─── Formatting helpers ───────────────────────────────────────────────────────
@@ -89,6 +90,7 @@ async function loadRegistrationFull(registrationId) {
include: {
registrationOptions: { include: { eventOption: { include: { earlyBirdTiers: true } }, variant: true, tranches: true } },
payments: true,
couponRedemptions: { where: { releasedAt: null }, include: { coupon: { select: { code: true } } } },
user: { select: { id: true, name: true, email: true, phoneNumber: true, isActive: true, notificationPreference: true } },
event: { include: { createdBy: { select: { id: true, name: true, email: true } }, notifyRecipients: { select: { id: true, name: true, email: true } } } },
},
@@ -101,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 },
@@ -115,6 +137,7 @@ async function loadPaymentFull(paymentId) {
include: {
registrationOptions: { include: { eventOption: { include: { earlyBirdTiers: true } }, variant: true, tranches: true } },
payments: true,
couponRedemptions: { where: { releasedAt: null }, include: { coupon: { select: { code: true } } } },
user: { select: { id: true, name: true, email: true, phoneNumber: true, isActive: true, notificationPreference: true } },
event: { include: { createdBy: { select: { id: true, name: true, email: true } }, notifyRecipients: { select: { id: true, name: true, email: true } } } },
},
@@ -126,8 +149,8 @@ async function loadPaymentFull(paymentId) {
// ─── Shared building blocks ───────────────────────────────────────────────────
/** Renders the selections summary table. */
function selectionsTable(registrationOptions) {
/** Renders the selections summary table, with the coupon discount (if any) as its own row. */
function selectionsTable(registrationOptions, couponLine = null) {
const rows = (registrationOptions || []).map(ro => {
const name = ro.eventOption?.name || 'Option';
const qty = ro.quantity || 1;
@@ -141,6 +164,13 @@ function selectionsTable(registrationOptions) {
if (!rows.length) return `<p style="color:#94a3b8;font-size:14px;margin:0">No items</p>`;
if (couponLine) {
rows.push(`<tr>
<td colspan="2" style="padding:10px 16px 10px 0;font-size:14px;color:#059669;font-family:${ff};border-bottom:1px solid #f1f5f9">${couponLine.label}</td>
<td style="padding:10px 0 10px 16px;font-size:14px;color:#059669;text-align:right;font-weight:500;font-family:${ff};border-bottom:1px solid #f1f5f9">-${fmtAmount(couponLine.amount)}</td>
</tr>`);
}
return `<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="border:1px solid #e2e8f0;border-radius:8px;overflow:hidden;margin:0 0 4px 0">
<tr style="background:#f8fafc">
<th style="padding:10px 16px 10px 0;font-size:12px;font-weight:700;color:#64748b;text-align:left;text-transform:uppercase;letter-spacing:0.5px;font-family:${ff}">Item</th>
@@ -151,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;
@@ -279,14 +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)}
${calendarLinkRow(reg.eventId)}`;
const itemsText = (reg.registrationOptions || []).map(ro => ` • ${ro.eventOption?.name || 'Option'} ×${ro.quantity} — ${fmtAmount(computeOptionLineTotal(ro, null, new Date()))}`).join('\n');
const itemsText = selectionsText(reg);
const text = `${heading}\n\nHi ${reg.user?.name || 'there'},\n\n${isNew ? `You are registered for ${eventTitle}` : `Your registration for ${eventTitle} has been updated`}${eventDate ? ' on ' + eventDate : ''}.\n\nYour selections:\n${itemsText || ' —'}\n\nTotal due: ${fmtAmount(totalDue)}\nAmount paid: ${fmtAmount(totalPaid)}\nBalance: ${fmtAmount(balance)}\n\n${balance > 0 ? `Payment options:\n 1. On our website: ${org.url}\n 2. At the door (cash or card)\n\nYour tickets will be sent once payment is confirmed.` : 'No payment required — your tickets have been sent separately.'}\n\n${org.name} — ${org.email}\n${org.url}`;
return { subject, text, html: emailWrapper(body, { preheader }) };
@@ -320,14 +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)}
${calendarLinkRow(reg.eventId)}`;
const itemsText = (reg.registrationOptions || []).map(ro => ` • ${ro.eventOption?.name || 'Option'} ×${ro.quantity} — ${fmtAmount(computeOptionLineTotal(ro, null, new Date()))}`).join('\n');
const itemsText = selectionsText(reg);
const payText = balance > 0
? `Payment options:\n${yocoLink ? ` 1. Pay online (Yoco): ${yocoLink}\n (Already paid? Ignore this option)\n` : ''} ${yocoLink ? '2' : '1'}. On our website: ${org.url}\n ${yocoLink ? '3' : '2'}. At the door (cash or card)\n\nYour tickets will be sent once payment is confirmed.`
: formRequired
@@ -794,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' })}`;
@@ -879,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>
@@ -901,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)']),
@@ -1136,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');
@@ -1419,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 });
}));
@@ -1428,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,
+24 -3
View File
@@ -93,6 +93,18 @@ function buildLineItems(registrationOptions) {
return rows;
}
/**
* The registration's coupon discount and a label naming the code(s), e.g. "Code STAFF50".
* Needs `couponRedemptions[].coupon.code` loaded for the label; the amount comes from
* Registration.discountTotal, which is always present.
*/
function couponDiscountLine(registration) {
const amount = Number(registration?.discountTotal || 0);
if (!(amount > 0.001)) return null;
const codes = (registration.couponRedemptions || []).map(r => r.coupon?.code).filter(Boolean);
return { amount, label: codes.length ? `Code ${codes.join(', ')}` : 'Discount code' };
}
/** Draws a two-column item table starting at `y`; returns the y position after the last row. */
function drawItemsTable(doc, { x, width, y, rows, brandColor, headerLight = true }) {
const colDesc = x;
@@ -198,6 +210,13 @@ async function generateReceiptPdf(payment) {
if (rows.length > 0) {
y = drawItemsTable(doc, { x: marginX, width: pageWidth - marginX * 2, y, rows, brandColor: org.brandColor });
}
const coupon = couponDiscountLine(reg);
if (coupon) {
y += 8;
doc.font('Helvetica').fontSize(10).fillColor('#059669')
.text(`${coupon.label}: -${fmtAmount(coupon.amount)}`, marginX, y, { width: pageWidth - marginX * 2 - 8, align: 'right' });
y += 14;
}
y += 20;
const boxW = 160, boxH = 32;
@@ -240,6 +259,7 @@ async function generateInvoicePdf(registration, { paymentUrl = null, totalDue =
const fullPriceTotal = rows.reduce((s, r) => s + (r.isEarlyBird ? r.basePrice : r.unitPrice) * r.quantity, 0);
const chargedTotal = rows.reduce((s, r) => s + r.unitPrice * r.quantity, 0);
const discount = Math.max(0, fullPriceTotal - chargedTotal);
const coupon = couponDiscountLine(registration);
const filename = `invoice-${invoiceNo}.pdf`;
const filePath = path.join(tempDir(), `${Date.now()}-${filename}`);
@@ -303,9 +323,10 @@ async function generateInvoicePdf(registration, { paymentUrl = null, totalDue =
.text(value, totalsX + 120, y, { width: 100, align: 'right' });
y += (opts.size || 10) + 10;
};
if (discount > 0.01) {
if (discount > 0.01 || coupon) {
totalLine('SUBTOTAL', fmtAmount(fullPriceTotal));
totalLine('DISCOUNT', `-${fmtAmount(discount)}`, { color: '#059669' });
if (discount > 0.01) totalLine(coupon ? 'EARLY BIRD' : 'DISCOUNT', `-${fmtAmount(discount)}`, { color: '#059669' });
if (coupon) totalLine(coupon.label.toUpperCase(), `-${fmtAmount(coupon.amount)}`, { color: '#059669' });
doc.moveTo(totalsX, y).lineTo(totalsX + 220, y).strokeColor('#e2e8f0').stroke();
y += 8;
}
@@ -348,4 +369,4 @@ async function generateInvoicePdf(registration, { paymentUrl = null, totalDue =
return { filePath, filename };
}
module.exports = { generateReceiptPdf, generateInvoicePdf, buildLineItems, getBranding, docNumber };
module.exports = { generateReceiptPdf, generateInvoicePdf, buildLineItems, couponDiscountLine, getBranding, docNumber };
+49 -5
View File
@@ -18,6 +18,10 @@
* - computeRegistrationTotalDue: sums quantity*priceSnapshot across each RegistrationOption's
* tranches (falling back to the legacy single-priceSnapshot/getEffectiveUnitPrice path for
* rows created before tranches existed).
* - Staff price: an option (or variant) can carry a staffPrice that Staff and above get
* automatically when it's lower than the price they'd otherwise pay.
* - Coupon discounts are stored on Registration.discountTotal (see utils/coupons.js) and
* subtracted by computeRegistrationTotalDue, never below zero.
* - refreshPricingForRegistration: re-evaluates only the newest, not-yet-paid-for tranche of
* each RegistrationOption; if its tier has expired or lost stock, resolves the next
* applicable tier/price for just that tranche. Older tranches — already priced-in — are
@@ -26,6 +30,24 @@
const prisma = require('../config/db');
const STAFF_ROLES = ['staff', 'supervisor', 'admin'];
/** The staff price this user gets for an option/variant, or null if none applies. */
function staffPriceFor(option, variantId, user) {
if (!user || !STAFF_ROLES.includes(user.role)) return null;
const variant = variantId ? (option?.variants || []).find(v => v.id === variantId) : null;
const price = variant && variant.staffPrice !== null && variant.staffPrice !== undefined
? variant.staffPrice
: option?.staffPrice;
return price === null || price === undefined ? null : Number(price);
}
// Staff price wins only when it's cheaper than the resolved (possibly early-bird) price.
function withStaffPrice(resolved, staffPrice) {
if (staffPrice === null || !(staffPrice < resolved.price)) return resolved;
return { price: staffPrice, tierId: null };
}
/**
* Compute effective unit price for an event option at a given time considering early-bird tiers.
* Only checks deadline (not stock). Used for display and as a legacy fallback.
@@ -65,9 +87,14 @@ function getEffectiveUnitPrice(eventOption, referenceTime, atTime) {
*
* @param {object} option - EventOption with price, earlyBirdTiers[]
* @param {number} requestedQty - quantity being purchased
* @param {object|null} user - buyer ({ role }); Staff and above may get the staff price
* @returns {Promise<{ price: number, tierId: string|null }>}
*/
async function resolveOptionPrice(option, requestedQty = 1) {
async function resolveOptionPrice(option, requestedQty = 1, user = null) {
return withStaffPrice(await resolveOptionTierPrice(option, requestedQty), staffPriceFor(option, null, user));
}
async function resolveOptionTierPrice(option, requestedQty) {
const now = new Date();
// Only option-level tiers (no variant association)
const tiers = (Array.isArray(option.earlyBirdTiers) ? option.earlyBirdTiers : [])
@@ -115,9 +142,14 @@ async function resolveOptionPrice(option, requestedQty = 1) {
* @param {object} option - EventOption with price, earlyBirdTiers[], variants[]
* @param {string} variantId
* @param {number} requestedQty
* @param {object|null} user - buyer ({ role }); Staff and above may get the staff price
* @returns {Promise<{ price: number, tierId: string|null }>}
*/
async function resolveVariantTierPrice(option, variantId, requestedQty = 1) {
async function resolveVariantTierPrice(option, variantId, requestedQty = 1, user = null) {
return withStaffPrice(await resolveVariantOnlyTierPrice(option, variantId, requestedQty), staffPriceFor(option, variantId, user));
}
async function resolveVariantOnlyTierPrice(option, variantId, requestedQty) {
const now = new Date();
const variant = (option.variants || []).find(v => v.id === variantId);
const basePrice = (variant && variant.price !== null && variant.price !== undefined)
@@ -172,6 +204,7 @@ async function refreshPricingForRegistration(registrationId) {
}
},
payments: true,
user: { select: { role: true } },
}
});
if (!registration) return { changed: false };
@@ -215,8 +248,8 @@ async function refreshPricingForRegistration(registrationId) {
// Deadline has passed (or tier record missing) — resolve the next applicable tier
const resolved = ro.variantId
? await resolveVariantTierPrice(ro.eventOption, ro.variantId, newest.quantity)
: await resolveOptionPrice(ro.eventOption, newest.quantity);
? await resolveVariantTierPrice(ro.eventOption, ro.variantId, newest.quantity, registration.user)
: await resolveOptionPrice(ro.eventOption, newest.quantity, registration.user);
const tierChanged = resolved.tierId !== newest.appliedTierId;
const priceChanged = Math.abs(resolved.price - newest.priceSnapshot) > 0.001;
@@ -273,7 +306,8 @@ function computeOptionLineTotal(ro, lastPaymentAt, atTime) {
* Compute total due for a registration at a given time.
*
* Sums computeOptionLineTotal() across each RegistrationOption (tranche-aware when
* `.tranches` is included, legacy priceSnapshot/getEffectiveUnitPrice fallback otherwise).
* `.tranches` is included, legacy priceSnapshot/getEffectiveUnitPrice fallback otherwise),
* less the registration's coupon discount (`discountTotal`), never below zero.
*
* @param {object} registration - includes registrationOptions[].{priceSnapshot, quantity, eventOption, tranches?}
* and optionally payments[]
@@ -291,6 +325,13 @@ function computeRegistrationTotalDue(registration, atTime) {
}
} catch {}
const subtotal = computeRegistrationSubtotal(registration, lastPaymentAt, atTime);
return Math.max(0, subtotal - Number(registration.discountTotal || 0));
}
/** Sum of the item lines before any coupon discount. */
function computeRegistrationSubtotal(registration, lastPaymentAt, atTime) {
if (!registration || !Array.isArray(registration.registrationOptions)) return 0;
return registration.registrationOptions.reduce((sum, ro) => sum + computeOptionLineTotal(ro, lastPaymentAt, atTime), 0);
}
@@ -317,6 +358,7 @@ function attachComputedTotals(registration) {
ro.lineTotal = computeOptionLineTotal(ro, lastPaymentAt, now);
}
}
registration.subtotalComputed = computeRegistrationSubtotal(registration, lastPaymentAt, now);
registration.totalDueComputed = computeRegistrationTotalDue(registration, now);
return registration;
}
@@ -332,6 +374,8 @@ module.exports = {
refreshPricingForRegistration,
computeOptionLineTotal,
computeRegistrationTotalDue,
computeRegistrationSubtotal,
staffPriceFor,
attachComputedTotals,
attachComputedTotalsToList,
};
+213
View File
@@ -0,0 +1,213 @@
/**
* Offline scanning — locks, snapshots and syncing.
*
* Rules:
* - Offline mode is per section (or the whole event), and only one device may hold a
* scope at a time: while offline, devices can't see each other's scans, so two
* devices on the same scope could redeem one ticket twice. A whole-event lock clashes
* with every section of that event, and a section lock with the whole event.
* - While a scope is locked, other devices can't scan it online either.
* - The lock holder downloads a snapshot of valid tickets; scans made offline are
* queued on the device and synced later. Syncing is idempotent (each scan carries a
* clientScanId) and never redeems more than is left on a ticket.
* - Anything that needs a person to look — unknown ticket, cancelled registration, used
* up, outside the locked scope, consent not given — goes into an OfflineSyncReport.
* - A supervisor can force-release a lock (lost phone). If that device syncs later its
* scans are still recorded, and the report is flagged.
*/
const prisma = require('../config/db');
function lockError(message, statusCode = 400) {
const err = new Error(message);
err.statusCode = statusCode;
return err;
}
// ─── Pure helpers ─────────────────────────────────────────────────────────────
/** Whether a new lock on `sectionId` (null = whole event) clashes with `existing`. */
function locksClash(existing, sectionId) {
if (!existing.sectionId || !sectionId) return true;
return existing.sectionId === sectionId;
}
/**
* Whether `lock` covers a scan of a ticket with option `optionId`. When the scanner has
* picked a section, only a lock on that section (or the whole event) covers it; with no
* section picked, any lock on a section that admits the ticket does.
* @param {object} lock - { sectionId, section?: { allowedOptions: [{ eventOptionId }] } }
*/
function lockCovers(lock, { sectionId = null, optionId }) {
if (!lock.sectionId) return true;
if (sectionId) return sectionId === lock.sectionId;
return (lock.section?.allowedOptions || []).some(o => o.eventOptionId === optionId);
}
/**
* What to do with one synced scan, given the ticket as the server sees it now. Pure.
* @param {object|null} ticket - { remaining, cancelled, inScope, consentOk }
* @param {number} qty - quantity the device redeemed
* @returns {{ redeem: number, conflict: string|null }}
*/
function judgeSyncedScan(ticket, qty) {
if (!ticket) return { redeem: 0, conflict: 'not_found' };
if (ticket.cancelled) return { redeem: 0, conflict: 'cancelled' };
if (!ticket.inScope) return { redeem: 0, conflict: 'wrong_scope' };
if (ticket.remaining <= 0) return { redeem: 0, conflict: 'already_used' };
const redeem = Math.min(Math.max(1, qty || 1), ticket.remaining);
if (!ticket.consentOk) return { redeem, conflict: 'consent' };
return { redeem, conflict: redeem < (qty || 1) ? 'partly_used' : null };
}
const CONFLICT_TEXT = {
not_found: 'Ticket not found (possibly transferred or reissued)',
cancelled: 'Registration was cancelled',
wrong_scope: 'Ticket is not valid for this section',
already_used: 'Ticket had already been fully used',
partly_used: 'Fewer places were left than were checked in offline',
consent: 'Checked in without parental consent',
};
// ─── Database helpers ─────────────────────────────────────────────────────────
const LOCK_INCLUDE = {
section: { include: { allowedOptions: true } },
user: { select: { id: true, name: true } },
};
async function activeLocks(eventId, db = prisma) {
return db.scanLock.findMany({ where: { eventId, releasedAt: null }, include: LOCK_INCLUDE, orderBy: { lockedAt: 'asc' } });
}
/** Locks a scope to this device. Re-locking by the same device and person returns the existing lock. */
async function acquireLock({ eventId, sectionId = null, deviceId, deviceName, userId }) {
if (!deviceId || typeof deviceId !== 'string' || deviceId.length > 100) throw lockError('A device id is required');
return prisma.$transaction(async (tx) => {
await tx.$executeRaw`SELECT pg_advisory_xact_lock(hashtext(${'scanlock:' + eventId}))`;
const event = await tx.event.findUnique({ where: { id: eventId } });
if (!event) throw lockError('Event not found', 404);
if (sectionId) {
const section = await tx.section.findUnique({ where: { id: sectionId } });
if (!section || section.eventId !== eventId) throw lockError('Section not found for this event', 404);
}
const existing = await activeLocks(eventId, tx);
const same = existing.find(l => (l.sectionId || null) === (sectionId || null) && l.deviceId === deviceId && l.userId === userId);
if (same) return same;
const clash = existing.find(l => locksClash(l, sectionId));
if (clash) {
const scope = clash.section ? `"${clash.section.name}"` : 'the whole event';
throw lockError(`${scope} is offline-locked by ${clash.user.name}${clash.deviceName ? ` on ${clash.deviceName}` : ''}.`, 409);
}
return tx.scanLock.create({ data: { eventId, sectionId: sectionId || null, deviceId, deviceName: deviceName ? String(deviceName).slice(0, 100) : null, userId }, include: LOCK_INCLUDE });
});
}
/**
* The lock (other than the scanner's own device) that stops this online scan, or null.
* @param {object} ticket - { eventId, optionId }
*/
async function blockingLock(ticket, { sectionId = null, deviceId = null } = {}) {
const locks = await activeLocks(ticket.eventId);
return locks.find(l => l.deviceId !== deviceId && lockCovers(l, { sectionId, optionId: ticket.optionId })) || null;
}
/** Tickets valid in a lock's scope, for the device to keep offline. */
async function snapshotFor(lock) {
const optionIds = lock.section ? lock.section.allowedOptions.map(o => o.eventOptionId) : null;
const tickets = await prisma.ticket.findMany({
where: {
eventId: lock.eventId,
registrationOption: { registration: { status: { not: 'cancelled' } }, ...(optionIds ? { eventOptionId: { in: optionIds } } : {}) },
},
include: {
usages: { select: { quantityRedeemed: true } },
attendee: { select: { name: true } },
user: { select: { name: true } },
registrationOption: { include: { eventOption: { select: { id: true, name: true, isMainTicket: true } }, variant: { select: { name: true } } } },
},
});
const { consentStatusForTickets } = require('./consent');
const consent = await consentStatusForTickets(tickets.map(t => t.id));
const snapshotAt = new Date();
await prisma.scanLock.update({ where: { id: lock.id }, data: { snapshotAt } });
return {
snapshotAt,
tickets: tickets.map(t => {
const redeemed = t.usages.reduce((s, u) => s + (u.quantityRedeemed || 1), 0);
return {
id: t.id,
qrCode: t.qrCode,
holder: t.attendee?.name || t.user?.name || '',
bookedBy: t.attendee ? t.user?.name || '' : '',
option: t.registrationOption.eventOption.name,
variant: t.registrationOption.variant?.name || null,
quantity: t.quantity || 1,
remaining: Math.max(0, (t.quantity || 1) - redeemed),
consentStatus: consent.get(t.id) || 'not_required',
};
}),
};
}
/**
* Records queued offline scans. Each scan: { clientScanId, qrCode, qty, scannedAt }.
* @returns {Promise<{ accepted, duplicates, conflicts, report }>}
*/
async function syncScans(lock, scans) {
const results = { accepted: 0, duplicates: 0, conflicts: [] };
const optionIds = lock.section ? new Set(lock.section.allowedOptions.map(o => o.eventOptionId)) : null;
const { consentStatusForTickets } = require('./consent');
const checkIns = [];
for (const scan of (scans || []).slice(0, 2000)) {
const clientScanId = String(scan?.clientScanId || '').slice(0, 100);
if (!clientScanId) continue;
if (await prisma.ticketUsage.findUnique({ where: { clientScanId } })) { results.duplicates += 1; continue; }
const qty = Math.max(1, parseInt(scan.qty, 10) || 1);
const scannedAt = scan.scannedAt && !isNaN(new Date(scan.scannedAt)) ? new Date(scan.scannedAt) : new Date();
const ticket = await prisma.ticket.findUnique({
where: { qrCode: String(scan.qrCode || '') },
include: { usages: true, registrationOption: { include: { registration: { select: { status: true } }, eventOption: { select: { isMainTicket: true } } } } },
});
let state = null;
if (ticket && ticket.eventId === lock.eventId) {
const redeemed = ticket.usages.reduce((s, u) => s + (u.quantityRedeemed || 1), 0);
const consent = (await consentStatusForTickets([ticket.id])).get(ticket.id);
state = {
remaining: (ticket.quantity || 1) - redeemed,
cancelled: ticket.registrationOption.registration.status === 'cancelled',
inScope: !optionIds || optionIds.has(ticket.registrationOption.eventOptionId),
consentOk: ['not_required', 'given'].includes(consent),
};
}
const verdict = judgeSyncedScan(state, qty);
if (verdict.redeem > 0) {
await prisma.ticketUsage.create({
data: { ticketId: ticket.id, scannedById: lock.userId, scannedAt, quantityRedeemed: verdict.redeem, clientScanId, offline: true, scanLockId: lock.id, syncedAt: new Date() },
});
await prisma.ticket.update({ where: { id: ticket.id }, data: { isUsed: state.remaining - verdict.redeem <= 0, updatedAt: new Date() } });
results.accepted += 1;
if (ticket.registrationOption.eventOption.isMainTicket) {
checkIns.push({ ticketId: ticket.id, qty: verdict.redeem, total: (ticket.quantity || 1) - state.remaining + verdict.redeem, left: state.remaining - verdict.redeem });
}
}
if (verdict.conflict) {
results.conflicts.push({ clientScanId, qrCode: scan.qrCode, ticketId: ticket?.id || null, qty, redeemed: verdict.redeem, scannedAt, reason: verdict.conflict, message: CONFLICT_TEXT[verdict.conflict] });
}
}
await prisma.scanLock.update({ where: { id: lock.id }, data: { lastSyncAt: new Date() } });
const report = await prisma.offlineSyncReport.create({
data: { scanLockId: lock.id, accepted: results.accepted, duplicates: results.duplicates, conflicts: results.conflicts, afterForceRelease: !!lock.forceReleasedById },
});
// Check-in confirmations go out after sync, not at the moment of the offline scan.
const { sendCheckInEmails } = require('./notifications');
for (const c of checkIns) sendCheckInEmails(c.ticketId, c.qty, c.total, c.left).catch(() => {});
return { ...results, report };
}
module.exports = { lockError, locksClash, lockCovers, judgeSyncedScan, CONFLICT_TEXT, LOCK_INCLUDE, activeLocks, acquireLock, blockingLock, snapshotFor, syncScans };
+52
View File
@@ -0,0 +1,52 @@
/**
* Ticket stock — the one place that decides how many places are taken.
*
* Taken = places on registrations that aren't cancelled + places held for a live
* waitlist offer (until it expires). An option's stock limit covers all its variants;
* a variant can also have its own limit. 0 means unlimited.
*/
const prisma = require('../config/db');
/**
* @param {{ eventOptionId: string, variantId?: string|null }} line - variantId given → that variant only
* @param {{ excludeEntryId?: string, db?: object }} [opts] - excludeEntryId: ignore this
* waitlist offer's hold (the person claiming it)
*/
async function takenCount({ eventOptionId, variantId = null }, { excludeEntryId = null, db = prisma } = {}) {
const scope = variantId ? { variantId } : { eventOptionId };
const [registered, held] = await Promise.all([
db.registrationOption.aggregate({
where: { ...scope, registration: { status: { not: 'cancelled' } } },
_sum: { quantity: true },
}),
db.waitlistEntry.aggregate({
where: {
...scope,
status: 'offered',
offerExpiresAt: { gt: new Date() },
...(excludeEntryId ? { id: { not: excludeEntryId } } : {}),
},
_sum: { offeredQuantity: true },
}),
]);
return (registered._sum?.quantity || 0) + (held._sum?.offeredQuantity || 0);
}
/**
* Places still free on a line: the tighter of the option and variant limits.
* @returns {Promise<number|null>} null when unlimited
*/
async function remainingFor(option, variant, opts = {}) {
const limits = [];
if (option?.stockLimit > 0) {
limits.push(option.stockLimit - await takenCount({ eventOptionId: option.id }, opts));
}
if (variant?.stockLimit > 0) {
limits.push(variant.stockLimit - await takenCount({ eventOptionId: option.id, variantId: variant.id }, opts));
}
if (limits.length === 0) return null;
return Math.max(0, Math.min(...limits));
}
module.exports = { takenCount, remainingFor };
+27 -39
View File
@@ -1,11 +1,14 @@
const prisma = require('../config/db');
const { v4: uuidv4 } = require('uuid');
const { planLineTickets } = require('./attendees');
/**
* Generate tickets for a registration when payment status is "paid".
*
* Rules:
* - Exactly ONE ticket per registrationOption (keyed by eventOptionId).
* - Each named attendee (group tickets) gets their own quantity-1 ticket; the rest of a
* line's places share ONE quantity ticket per registrationOption (see
* utils/attendees.js planLineTickets).
* - If duplicate registrationOptions exist for the same eventOptionId (old data),
* consolidate them: merge quantities, keep the one with scan history, delete the rest.
* - If duplicate ticket records exist for the same registrationOption (old data),
@@ -90,55 +93,40 @@ const generateTicketsForRegistration = async (registrationId) => {
});
}));
// ── Step 2: For each unique option, ensure exactly one ticket with correct qty ──
// ── 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' } },
}
});
// 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 targetQty = option.quantity || 1;
const existingTickets = option.tickets || [];
if (existingTickets.length === 0) {
// Create one ticket
return prisma.ticket.create({
data: {
id: uuidv4(),
qrCode: uuidv4(),
registrationOptionId: option.id,
userId: registration.userId,
eventId: registration.eventId,
quantity: targetQty,
updatedAt: new Date()
}
});
}
// Pick primary: prefer scanned, otherwise oldest
const withUsages = existingTickets.filter(t => (t.usages || []).length > 0);
const primary = withUsages.length > 0 ? withUsages[0] : existingTickets[0];
const updates = [];
// Update quantity on primary if needed
if (primary.quantity !== targetQty) {
updates.push(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) {
updates.push(prisma.ticket.deleteMany({ where: { id: { in: dups.map(t => t.id) } } }));
}
if (updates.length > 0) await Promise.all(updates);
return null;
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()
}
})));
}));
return perOptionResults.filter(Boolean);
return perOptionResults.flat();
} catch (error) {
console.error('Error generating tickets:', error);
throw error;
+285
View File
@@ -0,0 +1,285 @@
/**
* Two-factor authentication helpers: 6-digit codes delivered by email or
* WhatsApp, and "trust this device" tokens that let a browser skip the code.
*
* Neither the code nor the device token is stored in the clear — only an
* HMAC keyed with JWT_SECRET, so a leaked database row can't be replayed.
*/
const crypto = require('crypto');
const prisma = require('../config/db');
const { describeUserAgent } = require('./userAgent');
const CODE_TTL_MS = 10 * 60 * 1000; // a code is valid for 10 minutes
const MAX_ATTEMPTS = 5; // wrong guesses allowed per code
const RESEND_COOLDOWN_MS = 60 * 1000; // minimum gap between sends
const CHALLENGE_MAX_AGE_MS = 30 * 60 * 1000; // after this, log in again rather than resend
const TRUSTED_DEVICE_TTL_MS = 30 * 24 * 60 * 60 * 1000;
const METHODS = ['email', 'whatsapp'];
// Roles that must use two-factor authentication.
const REQUIRED_ROLES = ['admin', 'supervisor'];
// Application error carrying the HTTP status the controller should respond with.
function fail(message, statusCode = 400) {
const err = new Error(message);
err.statusCode = statusCode;
return err;
}
function generateCode() {
return String(crypto.randomInt(0, 1000000)).padStart(6, '0');
}
function hmac(value) {
return crypto.createHmac('sha256', process.env.JWT_SECRET || '').update(String(value)).digest('hex');
}
// Namespaced so a code hash can never collide with a device-token hash.
const hashCode = (code) => hmac(`2fa-code:${code}`);
const hashDeviceToken = (token) => hmac(`2fa-device:${token}`);
function safeEqual(a, b) {
const ba = Buffer.from(String(a));
const bb = Buffer.from(String(b));
return ba.length === bb.length && crypto.timingSafeEqual(ba, bb);
}
function hasRealEmail(user) {
return !!(user?.email && !user.email.endsWith('@guest.local') && !user.email.endsWith('@deleted.invalid') && !user.email.endsWith('@deleted.local'));
}
function maskEmail(email) {
const [local, domain] = String(email || '').split('@');
if (!local || !domain) return '•••';
return `${local[0]}•••@${domain}`;
}
function maskPhone(phone) {
const digits = String(phone || '').replace(/\D/g, '');
return `••• ••• ${digits.slice(-4)}`;
}
/** Channels this user could receive a code on right now. */
async function availableMethods(user) {
const { isValidZAPhone, isConfigured } = require('./whatsapp');
const methods = [];
if (hasRealEmail(user)) methods.push('email');
if (isValidZAPhone(user?.phoneNumber) && (await isConfigured())) methods.push('whatsapp');
return methods;
}
/**
* True when this user's role must use two-factor authentication.
* TWO_FACTOR_REQUIRED_ROLES=false turns the requirement off (break-glass).
*/
function requiresTwoFactor(user) {
if (String(process.env.TWO_FACTOR_REQUIRED_ROLES || 'true').toLowerCase() === 'false') return false;
return REQUIRED_ROLES.includes(user?.role);
}
/**
* The channel a required-2FA user should be enrolled on, or null when no channel
* can actually deliver a code — SMTP not set up and no WhatsApp — in which case the
* requirement is skipped rather than locking the account out.
*/
async function enforcementMethod(user) {
const { isSmtpConfigured } = require('./email');
if (hasRealEmail(user) && isSmtpConfigured()) return 'email';
const { isValidZAPhone, isConfigured } = require('./whatsapp');
if (isValidZAPhone(user?.phoneNumber) && (await isConfigured())) return 'whatsapp';
return null;
}
/** True when this user must enrol in 2FA before using their account. */
async function mustEnrolTwoFactor(user) {
if (!user || user.twoFactorEnabled || !requiresTwoFactor(user)) return false;
return (await enforcementMethod(user)) !== null;
}
/**
* Sends the code and reports where it actually went. A WhatsApp failure (session
* disconnected, not configured) falls back to email so a user is never locked out
* by the WhatsApp gateway being down. Throws when no channel could deliver.
* Pass `allowFallback: false` when the point is to prove the chosen channel works.
*/
async function deliverCode(user, method, code, { allowFallback = true } = {}) {
const { getSettingSync } = require('./settingsCache');
const orgName = getSettingSync('org_name', process.env.ORG_NAME || 'Cross Code');
if (process.env.NODE_ENV !== 'production') {
console.info(`[2fa][dev] code for ${user.email}: ${code}`);
}
if (method === 'whatsapp') {
const { isValidZAPhone, isConfigured, sendText } = require('./whatsapp');
const { buildWATwoFactorCode } = require('./waMessages');
try {
// sendText silently no-ops when WhatsApp isn't set up, so check first.
if (!isValidZAPhone(user.phoneNumber) || !(await isConfigured())) throw new Error('WhatsApp unavailable');
await sendText(user.phoneNumber, buildWATwoFactorCode({ name: user.name, code }));
return { method: 'whatsapp', destination: maskPhone(user.phoneNumber) };
} catch (e) {
console.warn('[2fa] WhatsApp code failed:', e?.message || e);
if (!allowFallback) throw fail('We couldn\'t send a WhatsApp message right now. Please try again later or choose email.', 503);
if (!hasRealEmail(user)) throw fail('We couldn\'t send your verification code. Please try again later.', 503);
}
}
if (!hasRealEmail(user)) throw fail('We couldn\'t send your verification code. Please try again later.', 503);
const { sendMail, buildTwoFactorCodeEmail } = require('./email');
try {
await sendMail({ to: user.email, subject: `Your ${orgName} verification code`, ...buildTwoFactorCodeEmail({ name: user.name, code }) });
} catch (e) {
console.warn('[2fa] Email code failed:', e?.message || e);
throw fail('We couldn\'t send your verification code. Please try again later.', 503);
}
return { method: 'email', destination: maskEmail(user.email) };
}
/**
* Sends a fresh code and records the challenge. Any earlier challenge of the same
* purpose for this user is discarded, so only the newest code works.
*/
async function createChallenge(user, purpose, method, options) {
const code = generateCode();
const sent = await deliverCode(user, method, code, options);
await prisma.twoFactorChallenge.deleteMany({ where: { userId: user.id, purpose } });
const challenge = await prisma.twoFactorChallenge.create({
data: {
userId: user.id,
purpose,
method: sent.method,
codeHash: hashCode(code),
expiresAt: new Date(Date.now() + CODE_TTL_MS),
},
});
return { challengeId: challenge.id, method: sent.method, destination: sent.destination };
}
/**
* Checks a submitted code. Returns the challenge (with its user) on success and
* throws otherwise. Pass `userId` to also require the challenge belong to that user.
*/
async function verifyChallenge(challengeId, code, purpose, userId) {
const invalid = () => fail('That code is invalid or has expired. Please request a new one.');
if (!challengeId || typeof challengeId !== 'string') throw invalid();
const submitted = String(code ?? '').replace(/\D/g, '');
// Count the attempt before comparing, atomically, so parallel guesses can't
// slip past the attempt limit.
const counted = await prisma.twoFactorChallenge.updateMany({
where: {
id: challengeId,
purpose,
...(userId ? { userId } : {}),
usedAt: null,
expiresAt: { gt: new Date() },
attempts: { lt: MAX_ATTEMPTS },
},
data: { attempts: { increment: 1 } },
});
if (counted.count !== 1) throw invalid();
const challenge = await prisma.twoFactorChallenge.findUnique({ where: { id: challengeId }, include: { user: true } });
if (!challenge || !safeEqual(challenge.codeHash, hashCode(submitted))) {
const left = challenge ? MAX_ATTEMPTS - challenge.attempts : 0;
throw fail(left > 0 ? `Incorrect code. ${left} attempt${left === 1 ? '' : 's'} left.` : 'Too many incorrect attempts. Please request a new code.');
}
// Single use — if two requests race with the right code, only one wins.
const used = await prisma.twoFactorChallenge.updateMany({
where: { id: challengeId, usedAt: null },
data: { usedAt: new Date() },
});
if (used.count !== 1) throw invalid();
return challenge;
}
/** Replaces the code on an existing challenge and sends it again. */
async function resendChallenge(challengeId, purpose, userId) {
const expired = () => fail('This verification has expired. Please start again.');
if (!challengeId || typeof challengeId !== 'string') throw expired();
const challenge = await prisma.twoFactorChallenge.findUnique({ where: { id: challengeId }, include: { user: true } });
if (!challenge || challenge.purpose !== purpose || challenge.usedAt || (userId && challenge.userId !== userId)) throw expired();
if (Date.now() - challenge.createdAt.getTime() > CHALLENGE_MAX_AGE_MS) throw expired();
const wait = RESEND_COOLDOWN_MS - (Date.now() - challenge.lastSentAt.getTime());
if (wait > 0) throw fail(`Please wait ${Math.ceil(wait / 1000)} seconds before requesting another code.`, 429);
// Claim the send slot first so two simultaneous resends can't both go out.
const claimed = await prisma.twoFactorChallenge.updateMany({
where: { id: challengeId, lastSentAt: challenge.lastSentAt, usedAt: null },
data: { lastSentAt: new Date() },
});
if (claimed.count !== 1) throw fail('Please wait before requesting another code.', 429);
const code = generateCode();
const sent = await deliverCode(challenge.user, challenge.method, code);
await prisma.twoFactorChallenge.update({
where: { id: challengeId },
data: { codeHash: hashCode(code), method: sent.method, attempts: 0, expiresAt: new Date(Date.now() + CODE_TTL_MS) },
});
return { challengeId, method: sent.method, destination: sent.destination };
}
/** Remembers this browser for 30 days. Returns the raw token to hand to the client. */
async function issueTrustedDevice(userId, userAgent) {
const token = crypto.randomBytes(32).toString('hex');
await prisma.trustedDevice.deleteMany({ where: { userId, expiresAt: { lt: new Date() } } });
await prisma.trustedDevice.create({
data: {
userId,
tokenHash: hashDeviceToken(token),
device: describeUserAgent(userAgent),
expiresAt: new Date(Date.now() + TRUSTED_DEVICE_TTL_MS),
},
});
return token;
}
/** True when `token` is an unexpired trusted-device token belonging to this user. */
async function isTrustedDevice(userId, token) {
if (!token || typeof token !== 'string') return false;
const row = await prisma.trustedDevice.findUnique({ where: { tokenHash: hashDeviceToken(token) } });
if (!row || row.userId !== userId || row.expiresAt <= new Date()) return false;
prisma.trustedDevice.update({ where: { id: row.id }, data: { lastUsedAt: new Date() } }).catch(() => {});
return true;
}
/** Forgets every trusted device — call whenever credentials or sessions are reset. */
async function clearTrustedDevices(userId) {
try {
await prisma.trustedDevice.deleteMany({ where: { userId } });
} catch (e) {
console.error('Failed to clear trusted devices:', e?.message);
}
}
module.exports = {
METHODS,
REQUIRED_ROLES,
requiresTwoFactor,
enforcementMethod,
mustEnrolTwoFactor,
MAX_ATTEMPTS,
generateCode,
hashCode,
hashDeviceToken,
maskEmail,
maskPhone,
hasRealEmail,
availableMethods,
createChallenge,
verifyChallenge,
resendChallenge,
issueTrustedDevice,
isTrustedDevice,
clearTrustedDevices,
};
+26 -3
View File
@@ -47,9 +47,12 @@ function buildWARegistration(reg, { isNew = true, balance, totalDue, totalPaid }
: `Your registration for *${eventTitle}* has been updated.`;
const { computeOptionLineTotal } = require('./pricing');
const items = (reg.registrationOptions || [])
.map(ro => `- ${ro.eventOption?.name || 'Option'} ×${ro.quantity} — ${fmtAmount(computeOptionLineTotal(ro, null, new Date()))}`)
.join('\n');
const { couponDiscountLine } = require('./pdfDocs');
const coupon = couponDiscountLine(reg);
const items = [
...(reg.registrationOptions || []).map(ro => `- ${ro.eventOption?.name || 'Option'} ×${ro.quantity} — ${fmtAmount(computeOptionLineTotal(ro, null, new Date()))}`),
...(coupon ? [`- ${coupon.label} — -${fmtAmount(coupon.amount)}`] : []),
].join('\n');
const paid = totalPaid ?? (reg.payments || []).reduce((s, p) => s + (p.amount || 0), 0);
const due = totalDue ?? 0;
@@ -149,6 +152,25 @@ function buildWALogin({ name, when, location, userAgent }) {
].join('\n');
}
// ─── Two-factor code ──────────────────────────────────────────────────────────
function buildWATwoFactorCode({ name, code }) {
const org = getOrg();
return [
`🔑 *Your verification code*`,
'',
`Hi ${name || 'there'},`,
'',
`Your *${org.name}* verification code is:`,
'',
`*${code}*`,
'',
`_It expires in 10 minutes. Never share this code with anyone._`,
'',
`> _Not you?_ Change your password immediately at ${org.url} or contact ${org.email}.`,
].join('\n');
}
// ─── Welcome ──────────────────────────────────────────────────────────────────
function buildWAWelcome({ name, events }) {
@@ -306,6 +328,7 @@ module.exports = {
buildWADonationAppliedToRegistrant,
buildWADonationUnassignedFromRegistrant,
buildWALogin,
buildWATwoFactorCode,
buildWAWelcome,
buildWAAccountClosed,
buildWATicketCaption,
+201
View File
@@ -0,0 +1,201 @@
/**
* Waitlist for sold-out ticket options.
*
* Rules:
* - Only on events with waitlistEnabled, and only for a line that's actually sold out.
* - When places free up (a cancellation, a smaller booking, a raised stock limit, or an
* offer expiring), processWaitlist offers them to the next people in line. An offer
* holds the places for event.waitlistOfferHours — they count as taken (utils/stock.js)
* so the public site can't sell them — and sends a claim link by email/WhatsApp.
* - If fewer places are free than someone asked for: with waitlistPartialOffers they're
* offered what's free; otherwise they're skipped for the next person who fits.
* - Claiming = registering with the link (createRegistration's waitlistToken). Early-bird
* and coupon rules apply as usual. An unclaimed offer expires and moves on.
*/
const crypto = require('crypto');
const prisma = require('../config/db');
const { remainingFor } = require('./stock');
const ACTIVE = ['waiting', 'offered'];
function waitlistError(message, statusCode = 400) {
const err = new Error(message);
err.statusCode = statusCode;
return err;
}
const hashToken = (token) => crypto.createHmac('sha256', process.env.JWT_SECRET || '').update(`waitlist:${token}`).digest('hex');
function claimLink(token) {
const base = (process.env.FRONTEND_URL || process.env.APP_BASE_URL || 'http://localhost:3001').split(',')[0].trim().replace(/\/$/, '');
return `${base}/waitlist/claim/${token}`;
}
/**
* Who gets offered what, given the free places on a line. Pure.
* @param {Array} waiting - entries in queue order [{ id, quantity }]
* @param {number} free - places free
* @param {boolean} partial - offer what's free when it's less than asked for
* @returns {Array<{ entryId: string, quantity: number }>}
*/
function pickOffers(waiting, free, partial) {
const offers = [];
let left = free;
for (const entry of waiting) {
if (left <= 0) break;
if (entry.quantity <= left) {
offers.push({ entryId: entry.id, quantity: entry.quantity });
left -= entry.quantity;
} else if (partial) {
// First in line gets what's free; nobody behind them jumps the queue.
offers.push({ entryId: entry.id, quantity: left });
left = 0;
}
// else: skip to the next person who fits
}
return offers;
}
async function notifyOffer(entry, token) {
const { sendMail, emailWrapper, ctaButton, fallbackLink } = require('./email');
const { shouldEmail, waText, waTextAny } = require('./notify');
const ff = `-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif`;
const user = entry.user;
const link = claimLink(token);
const item = `${entry.eventOption.name}${entry.variant ? ` (${entry.variant.name})` : ''}`;
const until = new Date(entry.offerExpiresAt).toLocaleString('en-GB', { day: 'numeric', month: 'long', hour: '2-digit', minute: '2-digit' });
const partialNote = entry.offeredQuantity < entry.quantity
? ` You asked for ${entry.quantity}; ${entry.offeredQuantity} came free, and you can take those now.` : '';
const line = `${entry.offeredQuantity} × ${item} for ${entry.event.title} ${entry.offeredQuantity === 1 ? 'is' : 'are'} now available for you.${partialNote} We'll hold ${entry.offeredQuantity === 1 ? 'it' : 'them'} until ${until}.`;
const hasEmail = user?.email && !user.email.endsWith('@guest.local') && !user.email.endsWith('@deleted.invalid');
const sends = [];
if (hasEmail && shouldEmail(user)) {
const html = emailWrapper(`
<p style="font-size:22px;font-weight:800;color:#0f172a;margin:0 0 8px 0">A place has opened up!</p>
<p style="margin:0 0 8px 0;color:#374151;font-family:${ff}">Hi <strong>${user.name}</strong>,</p>
<p style="margin:0 0 24px 0;color:#374151;font-family:${ff}">${line}</p>
${ctaButton('Claim my place', link)}
${fallbackLink(link)}
`, { preheader: `Your waitlist place for ${entry.event.title}` });
sends.push(sendMail({ to: user.email, subject: `A place is available: ${entry.event.title}`, html, text: `Hi ${user.name},\n\n${line}\n\nClaim it here: ${link}` }));
}
const wa = `🎟️ Hi ${user?.name || 'there'}, ${line} Claim it here: ${link}`;
sends.push(hasEmail ? waText(user, wa) : waTextAny(user, wa));
await Promise.allSettled(sends);
}
const OFFER_INCLUDE = { event: true, eventOption: true, variant: true, user: { select: { id: true, name: true, email: true, phoneNumber: true, notificationPreference: true } } };
/** Puts an entry on offer (inside the caller's transaction); returns the raw token. */
async function writeOffer(db, entry, quantity, hours) {
const token = crypto.randomBytes(24).toString('hex');
await db.waitlistEntry.update({
where: { id: entry.id },
data: {
status: 'offered',
offeredQuantity: quantity,
offerTokenHash: hashToken(token),
offeredAt: new Date(),
offerExpiresAt: new Date(Date.now() + (hours || 24) * 60 * 60 * 1000),
},
});
return token;
}
async function notifyOffers(offers) {
for (const { entryId, token } of offers) {
const entry = await prisma.waitlistEntry.findUnique({ where: { id: entryId }, include: OFFER_INCLUDE });
if (entry) notifyOffer(entry, token).catch(e => console.warn('[waitlist] notify failed:', e?.message || e));
}
}
/** Offers `quantity` places to one entry (e.g. a supervisor's manual offer) and notifies. */
async function makeOffer(entryId, quantity) {
const entry = await prisma.waitlistEntry.findUnique({ where: { id: entryId }, include: { event: true } });
const token = await writeOffer(prisma, entry, quantity, entry.event.waitlistOfferHours);
await notifyOffers([{ entryId, token }]);
return prisma.waitlistEntry.findUnique({ where: { id: entryId } });
}
/**
* Offers any free places on an event to the people waiting for them. Safe to call any
* time places might have freed up; runs one event at a time under an advisory lock.
* @returns {Promise<number>} offers made
*/
async function processWaitlist(eventId) {
const event = await prisma.event.findUnique({
where: { id: eventId },
include: { eventOptions: { include: { variants: true } } },
});
if (!event || !event.waitlistEnabled || !event.isActive || event.cashupStatus === 'closed') return 0;
if (new Date(event.endDate) < new Date()) return 0;
const made = [];
await prisma.$transaction(async (tx) => {
await tx.$executeRaw`SELECT pg_advisory_xact_lock(hashtext(${'waitlist:' + eventId}))`;
const waiting = await tx.waitlistEntry.findMany({ where: { eventId, status: 'waiting' }, orderBy: { position: 'asc' } });
const lines = [...new Map(waiting.map(w => [`${w.eventOptionId}::${w.variantId || ''}`, w])).values()];
for (const sample of lines) {
const option = event.eventOptions.find(o => o.id === sample.eventOptionId);
const variant = sample.variantId ? (option?.variants || []).find(v => v.id === sample.variantId) : null;
if (!option) continue;
const free = await remainingFor(option, variant, { db: tx });
if (free === null || free <= 0) continue;
const queue = waiting.filter(w => w.eventOptionId === sample.eventOptionId && (w.variantId || null) === (sample.variantId || null));
for (const offer of pickOffers(queue, free, event.waitlistPartialOffers)) {
const token = await writeOffer(tx, { id: offer.entryId }, offer.quantity, event.waitlistOfferHours);
made.push({ entryId: offer.entryId, token });
}
}
}, { timeout: 20000 });
await notifyOffers(made);
return made.length;
}
/** Marks lapsed offers as expired. @returns event ids that now have places to re-offer. */
async function expireOffers(now = new Date()) {
const lapsed = await prisma.waitlistEntry.findMany({ where: { status: 'offered', offerExpiresAt: { lte: now } }, select: { id: true, eventId: true } });
if (lapsed.length === 0) return [];
await prisma.waitlistEntry.updateMany({ where: { id: { in: lapsed.map(l => l.id) } }, data: { status: 'expired', offerTokenHash: null } });
return [...new Set(lapsed.map(l => l.eventId))];
}
/** Background job: expire lapsed offers, then re-offer free places everywhere waiting. */
async function runWaitlistJob() {
const expiredEvents = await expireOffers();
const waitingEvents = await prisma.waitlistEntry.findMany({ where: { status: 'waiting' }, distinct: ['eventId'], select: { eventId: true } });
const ids = new Set([...expiredEvents, ...waitingEvents.map(w => w.eventId)]);
let offers = 0;
for (const id of ids) offers += await processWaitlist(id).catch(() => 0);
return { expired: expiredEvents.length, offers };
}
/** Fire-and-forget re-offer after something freed places on an event. */
function queueWaitlist(eventId) {
if (!eventId) return;
processWaitlist(eventId).catch(e => console.warn('[waitlist] process failed:', e?.message || e));
}
/** A live offer from its claim token, or null. */
async function findOfferByToken(token) {
if (!token || typeof token !== 'string' || token.length > 100) return null;
return prisma.waitlistEntry.findUnique({
where: { offerTokenHash: hashToken(token) },
include: { event: true, eventOption: true, variant: true, user: { select: { id: true, name: true } } },
});
}
module.exports = {
ACTIVE,
waitlistError,
hashToken,
pickOffers,
makeOffer,
processWaitlist,
expireOffers,
runWaitlistJob,
queueWaitlist,
findOfferByToken,
};
+104
View File
@@ -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'] });
});
});
+135
View File
@@ -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/);
});
});
+176
View File
@@ -0,0 +1,176 @@
const {
normalizeCode,
buildCouponLines,
computeCouponDiscount,
validateCoupon,
describeCoupon,
} = require('../src/utils/coupons');
const { computeRegistrationTotalDue, staffPriceFor } = require('../src/utils/pricing');
const NOW = new Date('2026-06-15T10:00:00Z');
const PAST = new Date('2026-06-01T00:00:00Z');
const FUTURE = new Date('2026-07-01T00:00:00Z');
const coupon = (over = {}) => ({
id: 'c1', code: 'STAFF50', type: 'percent', value: 50, stackEarlyBird: false, active: true,
validFrom: null, validUntil: null, maxUses: null, maxUsesPerUser: 1, minQuantity: null,
restrictRoles: [], restrictDomain: null, events: [], options: [], allowedUsers: [],
...over,
});
// 2 adult tickets at R200 (standard) and 1 T-shirt at R100.
const LINES = [
{ eventOptionId: 'adult', variantId: null, quantity: 2, unitPrice: 200, basePrice: 200 },
{ eventOptionId: 'shirt', variantId: 'L', quantity: 1, unitPrice: 100, basePrice: 100 },
];
describe('normalizeCode', () => {
test('trims and uppercases', () => {
expect(normalizeCode(' staff50 ')).toBe('STAFF50');
expect(normalizeCode(null)).toBe('');
});
});
describe('computeCouponDiscount', () => {
test('percentage over the whole event', () => {
expect(computeCouponDiscount(coupon(), LINES)).toBe(250);
});
test('fixed amount, capped at what is in scope', () => {
expect(computeCouponDiscount(coupon({ type: 'fixed', value: 120 }), LINES)).toBe(120);
expect(computeCouponDiscount(coupon({ type: 'fixed', value: 9999 }), LINES)).toBe(500);
});
test('comp covers only in-scope options', () => {
const c = coupon({ type: 'comp', options: [{ eventOptionId: 'adult', variantId: null }] });
expect(computeCouponDiscount(c, LINES)).toBe(400);
});
test('scope can target a single variant', () => {
const c = coupon({ type: 'comp', options: [{ eventOptionId: 'shirt', variantId: 'M' }] });
expect(computeCouponDiscount(c, LINES)).toBe(0);
const c2 = coupon({ type: 'comp', options: [{ eventOptionId: 'shirt', variantId: 'L' }] });
expect(computeCouponDiscount(c2, LINES)).toBe(100);
});
test('best price wins against early-bird when not stacking', () => {
// Early-bird R150 vs standard R200: 10% off standard = R180, early-bird already beats it.
const eb = [{ eventOptionId: 'adult', variantId: null, quantity: 1, unitPrice: 150, basePrice: 200 }];
expect(computeCouponDiscount(coupon({ value: 10 }), eb)).toBe(0);
// 50% off standard = R100, beats R150 early-bird by R50.
expect(computeCouponDiscount(coupon({ value: 50 }), eb)).toBe(50);
// Fixed R80 off standard = R120, beats R150 by R30.
expect(computeCouponDiscount(coupon({ type: 'fixed', value: 80 }), eb)).toBe(30);
});
test('stacking applies on top of the early-bird price', () => {
const eb = [{ eventOptionId: 'adult', variantId: null, quantity: 1, unitPrice: 150, basePrice: 200 }];
expect(computeCouponDiscount(coupon({ value: 10, stackEarlyBird: true }), eb)).toBe(15);
expect(computeCouponDiscount(coupon({ type: 'fixed', value: 80, stackEarlyBird: true }), eb)).toBe(80);
});
test('never goes below zero or above the price', () => {
expect(computeCouponDiscount(coupon({ value: 150 }), LINES)).toBe(500);
expect(computeCouponDiscount(coupon({ type: 'fixed', value: -10 }), LINES)).toBe(0);
expect(computeCouponDiscount(coupon(), [])).toBe(0);
});
});
describe('validateCoupon', () => {
const ctx = (over = {}) => ({ eventId: 'e1', user: { id: 'u1', role: 'user', email: 'a@church.org' }, now: NOW, usesTotal: 0, usesByUser: 0, lines: LINES, ...over });
test('accepts an unrestricted active code', () => {
expect(validateCoupon(coupon(), ctx())).toEqual({ ok: true });
});
test('rejects inactive, not-yet-valid and expired codes', () => {
expect(validateCoupon(coupon({ active: false }), ctx()).ok).toBe(false);
expect(validateCoupon(coupon({ validFrom: FUTURE }), ctx()).message).toMatch(/isn't valid yet/);
expect(validateCoupon(coupon({ validUntil: PAST }), ctx()).message).toMatch(/expired/);
});
test('limits to the coupon events when any are set', () => {
expect(validateCoupon(coupon({ events: [{ eventId: 'e2' }] }), ctx()).ok).toBe(false);
expect(validateCoupon(coupon({ events: [{ eventId: 'e1' }, { eventId: 'e2' }] }), ctx()).ok).toBe(true);
});
test('restricted codes need a known account matching any restriction', () => {
const staffOnly = coupon({ restrictRoles: ['staff', 'supervisor', 'admin'] });
expect(validateCoupon(staffOnly, ctx({ user: null })).message).toMatch(/Log in/);
expect(validateCoupon(staffOnly, ctx()).ok).toBe(false);
expect(validateCoupon(staffOnly, ctx({ user: { id: 'u1', role: 'staff', email: 'x@y.z' } })).ok).toBe(true);
expect(validateCoupon(coupon({ restrictDomain: '@church.org' }), ctx()).ok).toBe(true);
expect(validateCoupon(coupon({ restrictDomain: 'other.org' }), ctx()).ok).toBe(false);
expect(validateCoupon(coupon({ allowedUsers: [{ userId: 'u1' }] }), ctx()).ok).toBe(true);
});
test('enforces total and per-user use limits', () => {
expect(validateCoupon(coupon({ maxUses: 5 }), ctx({ usesTotal: 5 })).message).toMatch(/fully used/);
expect(validateCoupon(coupon({ maxUsesPerUser: 1 }), ctx({ usesByUser: 1 })).message).toMatch(/already used/);
expect(validateCoupon(coupon({ maxUsesPerUser: null }), ctx({ usesByUser: 9 })).ok).toBe(true);
});
test('needs qualifying items and the minimum quantity', () => {
expect(validateCoupon(coupon({ options: [{ eventOptionId: 'other' }] }), ctx()).message).toMatch(/doesn't apply/);
expect(validateCoupon(coupon({ minQuantity: 4 }), ctx()).message).toMatch(/at least 4/);
expect(validateCoupon(coupon({ minQuantity: 3 }), ctx()).ok).toBe(true);
});
});
describe('buildCouponLines', () => {
test('one line per tranche; early-bird tranches keep the standard price as base', () => {
const reg = {
registrationOptions: [{
eventOptionId: 'adult', variantId: null, quantity: 3,
eventOption: { price: 200, variants: [] },
tranches: [
{ quantity: 2, priceSnapshot: 150, appliedTierId: 'tier1' },
{ quantity: 1, priceSnapshot: 200, appliedTierId: null },
],
}],
};
expect(buildCouponLines(reg)).toEqual([
{ eventOptionId: 'adult', variantId: null, quantity: 2, unitPrice: 150, basePrice: 200 },
{ eventOptionId: 'adult', variantId: null, quantity: 1, unitPrice: 200, basePrice: 200 },
]);
});
});
describe('computeRegistrationTotalDue with a discount', () => {
const reg = (discountTotal) => ({
discountTotal,
registrationOptions: [{ quantity: 2, tranches: [{ quantity: 2, priceSnapshot: 100 }] }],
});
test('subtracts the coupon discount', () => {
expect(computeRegistrationTotalDue(reg(50), NOW)).toBe(150);
});
test('never goes below zero', () => {
expect(computeRegistrationTotalDue(reg(500), NOW)).toBe(0);
});
});
describe('staffPriceFor', () => {
const option = { price: 200, staffPrice: 50, variants: [{ id: 'v1', staffPrice: 20 }, { id: 'v2', staffPrice: null }] };
test('applies to Staff and above only', () => {
expect(staffPriceFor(option, null, { role: 'staff' })).toBe(50);
expect(staffPriceFor(option, null, { role: 'admin' })).toBe(50);
expect(staffPriceFor(option, null, { role: 'user' })).toBeNull();
expect(staffPriceFor(option, null, null)).toBeNull();
});
test('a variant staff price overrides the option one', () => {
expect(staffPriceFor(option, 'v1', { role: 'staff' })).toBe(20);
expect(staffPriceFor(option, 'v2', { role: 'staff' })).toBe(50);
});
});
describe('describeCoupon', () => {
test('summarises each type', () => {
expect(describeCoupon(coupon())).toBe('50% off');
expect(describeCoupon(coupon({ type: 'fixed', value: 25 }))).toBe('R25.00 off');
expect(describeCoupon(coupon({ type: 'comp' }))).toBe('Free');
});
});
+125
View File
@@ -0,0 +1,125 @@
process.env.JWT_SECRET = 'test-secret';
process.env.NODE_ENV = 'test';
// Two-factor is required for admins and supervisors — but only when some channel
// can actually deliver a code, so a site with no SMTP/WhatsApp never locks them out.
const mockChannels = { smtp: true, whatsapp: true };
const mockUsers = new Map();
jest.mock('../src/config/db', () => ({
user: { findUnique: async ({ where }) => mockUsers.get(where.id) || null },
}));
jest.mock('../src/utils/settingsCache', () => ({ getSettingSync: (_key, fallback) => fallback }));
jest.mock('../src/utils/email', () => ({ isSmtpConfigured: () => mockChannels.smtp }));
jest.mock('../src/utils/whatsapp', () => ({
isValidZAPhone: (p) => /^27\d{9}$/.test(String(p || '')),
isConfigured: async () => mockChannels.whatsapp,
}));
const jwt = require('jsonwebtoken');
const { requiresTwoFactor, enforcementMethod, mustEnrolTwoFactor } = require('../src/utils/twoFactor');
const { protect } = require('../src/middleware/authMiddleware');
const ADMIN = { id: 'a1', name: 'Ann', email: 'ann@example.com', phoneNumber: '27821234567', role: 'admin', isActive: true, tokenVersion: 0, twoFactorEnabled: false };
beforeEach(() => {
mockChannels.smtp = true;
mockChannels.whatsapp = true;
mockUsers.clear();
delete process.env.TWO_FACTOR_REQUIRED_ROLES;
});
describe('requiresTwoFactor', () => {
test('applies to admins and supervisors only', () => {
expect(requiresTwoFactor({ role: 'admin' })).toBe(true);
expect(requiresTwoFactor({ role: 'supervisor' })).toBe(true);
expect(requiresTwoFactor({ role: 'staff' })).toBe(false);
expect(requiresTwoFactor({ role: 'user' })).toBe(false);
});
test('TWO_FACTOR_REQUIRED_ROLES=false turns the requirement off', () => {
process.env.TWO_FACTOR_REQUIRED_ROLES = 'false';
expect(requiresTwoFactor({ role: 'admin' })).toBe(false);
});
});
describe('enforcementMethod', () => {
test('prefers email when SMTP is configured', async () => {
expect(await enforcementMethod(ADMIN)).toBe('email');
});
test('falls back to WhatsApp without SMTP', async () => {
mockChannels.smtp = false;
expect(await enforcementMethod(ADMIN)).toBe('whatsapp');
});
test('skips a guest placeholder email', async () => {
expect(await enforcementMethod({ ...ADMIN, email: 'x@guest.local' })).toBe('whatsapp');
});
test('is null when no channel can deliver a code', async () => {
mockChannels.smtp = false;
mockChannels.whatsapp = false;
expect(await enforcementMethod(ADMIN)).toBeNull();
});
});
describe('mustEnrolTwoFactor', () => {
test('true for an admin without 2FA', async () => {
expect(await mustEnrolTwoFactor(ADMIN)).toBe(true);
});
test('false once enrolled, for other roles, or with no channel', async () => {
expect(await mustEnrolTwoFactor({ ...ADMIN, twoFactorEnabled: true })).toBe(false);
expect(await mustEnrolTwoFactor({ ...ADMIN, role: 'staff' })).toBe(false);
mockChannels.smtp = false;
mockChannels.whatsapp = false;
expect(await mustEnrolTwoFactor(ADMIN)).toBe(false);
});
});
describe('protect', () => {
function run(user, claims = {}) {
mockUsers.set(user.id, user);
const token = jwt.sign({ id: user.id, role: user.role, tokenVersion: 0, ...claims }, process.env.JWT_SECRET);
const req = { headers: { authorization: `Bearer ${token}` } };
const res = { statusCode: 200, body: null };
res.status = (c) => { res.statusCode = c; return res; };
res.json = (b) => { res.body = b; return res; };
return new Promise((resolve) => {
protect(req, res, (err) => resolve({ req, res, err, passed: !err })).then(() => {
if (res.body) resolve({ req, res, passed: false });
});
});
}
test('rejects an un-enrolled admin session with TWO_FACTOR_REQUIRED', async () => {
const { res, passed } = await run(ADMIN);
expect(passed).toBe(false);
expect(res.statusCode).toBe(401);
expect(res.body.code).toBe('TWO_FACTOR_REQUIRED');
});
test('lets an enrolled admin through', async () => {
const { passed } = await run({ ...ADMIN, twoFactorEnabled: true });
expect(passed).toBe(true);
});
test('lets a regular user through without 2FA', async () => {
const { passed } = await run({ ...ADMIN, id: 'u1', role: 'user' });
expect(passed).toBe(true);
});
test('gives the setup wizard token a grace period', async () => {
const { passed } = await run(ADMIN, { setup: true });
expect(passed).toBe(true);
});
test('does not enforce when no channel can deliver a code', async () => {
mockChannels.smtp = false;
mockChannels.whatsapp = false;
const { passed } = await run(ADMIN);
expect(passed).toBe(true);
});
});
+54
View File
@@ -0,0 +1,54 @@
const { locksClash, lockCovers, judgeSyncedScan } = require('../src/utils/scanLocks');
describe('locksClash', () => {
test('a whole-event lock clashes with everything', () => {
expect(locksClash({ sectionId: null }, 'hall')).toBe(true);
expect(locksClash({ sectionId: 'hall' }, null)).toBe(true);
});
test('section locks only clash with the same section', () => {
expect(locksClash({ sectionId: 'hall' }, 'hall')).toBe(true);
expect(locksClash({ sectionId: 'hall' }, 'dinner')).toBe(false);
});
});
describe('lockCovers', () => {
const dinnerLock = { sectionId: 'dinner', section: { allowedOptions: [{ eventOptionId: 'meal' }] } };
test('a whole-event lock covers every scan', () => {
expect(lockCovers({ sectionId: null }, { sectionId: 'x', optionId: 'y' })).toBe(true);
});
test('with a section picked, only that section is covered', () => {
expect(lockCovers(dinnerLock, { sectionId: 'dinner', optionId: 'meal' })).toBe(true);
expect(lockCovers(dinnerLock, { sectionId: 'hall', optionId: 'meal' })).toBe(false);
});
test('with no section picked, tickets the locked section admits are covered', () => {
expect(lockCovers(dinnerLock, { optionId: 'meal' })).toBe(true);
expect(lockCovers(dinnerLock, { optionId: 'entry' })).toBe(false);
});
});
describe('judgeSyncedScan', () => {
const ok = { remaining: 2, cancelled: false, inScope: true, consentOk: true };
test('records a normal scan', () => {
expect(judgeSyncedScan(ok, 1)).toEqual({ redeem: 1, conflict: null });
});
test('never redeems more than is left', () => {
expect(judgeSyncedScan(ok, 3)).toEqual({ redeem: 2, conflict: 'partly_used' });
expect(judgeSyncedScan({ ...ok, remaining: 0 }, 1)).toEqual({ redeem: 0, conflict: 'already_used' });
});
test('flags unknown, cancelled and out-of-scope tickets without recording them', () => {
expect(judgeSyncedScan(null, 1)).toEqual({ redeem: 0, conflict: 'not_found' });
expect(judgeSyncedScan({ ...ok, cancelled: true }, 1)).toEqual({ redeem: 0, conflict: 'cancelled' });
expect(judgeSyncedScan({ ...ok, inScope: false }, 1)).toEqual({ redeem: 0, conflict: 'wrong_scope' });
});
test('records but flags a check-in without consent', () => {
expect(judgeSyncedScan({ ...ok, consentOk: false }, 1)).toEqual({ redeem: 1, conflict: 'consent' });
});
});
+74
View File
@@ -0,0 +1,74 @@
process.env.JWT_SECRET = 'test-secret';
// The pure helpers under test never touch the database.
jest.mock('../src/config/db', () => ({}));
const {
generateCode,
hashCode,
hashDeviceToken,
maskEmail,
maskPhone,
hasRealEmail,
} = require('../src/utils/twoFactor');
describe('generateCode', () => {
test('always returns exactly six digits', () => {
for (let i = 0; i < 500; i++) {
expect(generateCode()).toMatch(/^\d{6}$/);
}
});
});
describe('hashCode / hashDeviceToken', () => {
test('is deterministic and never returns the input', () => {
expect(hashCode('123456')).toBe(hashCode('123456'));
expect(hashCode('123456')).not.toContain('123456');
expect(hashCode('123456')).toMatch(/^[0-9a-f]{64}$/);
});
test('different codes hash differently', () => {
expect(hashCode('123456')).not.toBe(hashCode('123457'));
});
test('code and device-token hashes are namespaced apart', () => {
expect(hashCode('123456')).not.toBe(hashDeviceToken('123456'));
});
test('depends on JWT_SECRET', () => {
const before = hashCode('123456');
process.env.JWT_SECRET = 'another-secret';
expect(hashCode('123456')).not.toBe(before);
process.env.JWT_SECRET = 'test-secret';
});
});
describe('masking', () => {
test('maskEmail keeps the first letter and the domain', () => {
expect(maskEmail('jane@example.com')).toBe('j•••@example.com');
});
test('maskEmail copes with junk input', () => {
expect(maskEmail('')).toBe('•••');
expect(maskEmail(null)).toBe('•••');
});
test('maskPhone keeps only the last four digits', () => {
expect(maskPhone('27821234567')).toBe('••• ••• 4567');
expect(maskPhone('+27 82 123 4567')).toBe('••• ••• 4567');
});
});
describe('hasRealEmail', () => {
test('accepts a normal address', () => {
expect(hasRealEmail({ email: 'jane@example.com' })).toBe(true);
});
test('rejects guest and deleted placeholders and missing values', () => {
expect(hasRealEmail({ email: 'abc@guest.local' })).toBe(false);
expect(hasRealEmail({ email: 'deleted-1@deleted.invalid' })).toBe(false);
expect(hasRealEmail({ email: 'deleted-1@deleted.local' })).toBe(false);
expect(hasRealEmail({})).toBe(false);
expect(hasRealEmail(null)).toBe(false);
});
});
+282
View File
@@ -0,0 +1,282 @@
process.env.JWT_SECRET = 'test-secret';
process.env.NODE_ENV = 'test';
// ─── In-memory stand-in for the two Prisma models the 2FA helpers use ─────────
// Supports just the `where` shapes those helpers issue: equality, null, { gt }, { lt }.
function matches(row, where = {}) {
return Object.entries(where).every(([key, cond]) => {
const value = row[key];
if (cond && typeof cond === 'object' && !(cond instanceof Date)) {
if ('gt' in cond) return value > cond.gt;
if ('lt' in cond) return value < cond.lt;
}
if (cond instanceof Date) return value instanceof Date && value.getTime() === cond.getTime();
return value === cond;
});
}
function applyData(row, data) {
for (const [key, val] of Object.entries(data)) {
row[key] = val && typeof val === 'object' && 'increment' in val ? row[key] + val.increment : val;
}
}
function makeModel(defaults, relations = {}) {
let rows = [];
let seq = 0;
const withRelations = (row, include) => {
if (!row) return null;
const out = { ...row };
for (const key of Object.keys(include || {})) out[key] = relations[key](row);
return out;
};
return {
_rows: () => rows,
_reset: () => { rows = []; },
create: async ({ data }) => {
const row = { id: `id-${++seq}`, ...defaults(), ...data };
rows.push(row);
return { ...row };
},
findUnique: async ({ where, include }) => withRelations(rows.find(r => matches(r, where)), include),
update: async ({ where, data }) => {
const row = rows.find(r => matches(r, where));
applyData(row, data);
return { ...row };
},
updateMany: async ({ where, data }) => {
const hit = rows.filter(r => matches(r, where));
hit.forEach(r => applyData(r, data));
return { count: hit.length };
},
deleteMany: async ({ where }) => {
const before = rows.length;
rows = rows.filter(r => !matches(r, where));
return { count: before - rows.length };
},
};
}
const USER = { id: 'user-1', name: 'Jane', email: 'jane@example.com', phoneNumber: '27821234567' };
const mockDb = {
twoFactorChallenge: makeModel(
() => ({ attempts: 0, usedAt: null, lastSentAt: new Date(), createdAt: new Date() }),
{ user: () => USER },
),
trustedDevice: makeModel(() => ({ createdAt: new Date(), lastUsedAt: new Date() })),
};
const mockSent = { emails: [], whatsapps: [] };
const mockWhatsApp = { configured: true, fail: false };
jest.mock('../src/config/db', () => mockDb);
jest.mock('../src/utils/settingsCache', () => ({ getSettingSync: (_key, fallback) => fallback }));
jest.mock('../src/utils/email', () => ({
sendMail: async (mail) => { mockSent.emails.push(mail); },
isSmtpConfigured: () => true,
buildTwoFactorCodeEmail: ({ code }) => ({ text: `code:${code}`, html: '' }),
}));
jest.mock('../src/utils/waMessages', () => ({ buildWATwoFactorCode: ({ code }) => `code:${code}` }));
jest.mock('../src/utils/whatsapp', () => ({
isValidZAPhone: (p) => /^27\d{9}$/.test(String(p || '')),
isConfigured: async () => mockWhatsApp.configured,
sendText: async (to, message) => {
if (mockWhatsApp.fail) throw new Error('session disconnected');
mockSent.whatsapps.push({ to, message });
},
}));
const {
MAX_ATTEMPTS,
availableMethods,
createChallenge,
verifyChallenge,
resendChallenge,
issueTrustedDevice,
isTrustedDevice,
clearTrustedDevices,
} = require('../src/utils/twoFactor');
const lastEmailCode = () => mockSent.emails[mockSent.emails.length - 1].text.split(':')[1];
const lastWhatsAppCode = () => mockSent.whatsapps[mockSent.whatsapps.length - 1].message.split(':')[1];
const wrongCode = (code) => (code === '000000' ? '111111' : '000000');
beforeEach(() => {
mockDb.twoFactorChallenge._reset();
mockDb.trustedDevice._reset();
mockSent.emails = [];
mockSent.whatsapps = [];
mockWhatsApp.configured = true;
mockWhatsApp.fail = false;
jest.spyOn(console, 'warn').mockImplementation(() => {});
jest.spyOn(console, 'info').mockImplementation(() => {});
});
afterEach(() => jest.restoreAllMocks());
describe('availableMethods', () => {
test('offers WhatsApp only with a valid phone and a configured gateway', async () => {
expect(await availableMethods(USER)).toEqual(['email', 'whatsapp']);
mockWhatsApp.configured = false;
expect(await availableMethods(USER)).toEqual(['email']);
mockWhatsApp.configured = true;
expect(await availableMethods({ ...USER, phoneNumber: null })).toEqual(['email']);
});
test('offers nothing by email for a guest placeholder address', async () => {
expect(await availableMethods({ ...USER, email: 'x@guest.local' })).toEqual(['whatsapp']);
});
});
describe('createChallenge', () => {
test('emails the code and stores only its hash', async () => {
const res = await createChallenge(USER, 'login', 'email');
expect(res).toMatchObject({ method: 'email', destination: 'j•••@example.com' });
const [row] = mockDb.twoFactorChallenge._rows();
expect(row.id).toBe(res.challengeId);
expect(row.codeHash).not.toContain(lastEmailCode());
expect(mockSent.emails[0].to).toBe(USER.email);
});
test('sends by WhatsApp when asked', async () => {
const res = await createChallenge(USER, 'login', 'whatsapp');
expect(res).toMatchObject({ method: 'whatsapp', destination: '••• ••• 4567' });
expect(mockSent.whatsapps).toHaveLength(1);
expect(mockSent.emails).toHaveLength(0);
});
test('falls back to email when WhatsApp fails or is not configured', async () => {
mockWhatsApp.fail = true;
expect((await createChallenge(USER, 'login', 'whatsapp')).method).toBe('email');
mockWhatsApp.fail = false;
mockWhatsApp.configured = false;
expect((await createChallenge(USER, 'login', 'whatsapp')).method).toBe('email');
expect(mockSent.emails).toHaveLength(2);
});
test('does not fall back when allowFallback is false, and records no challenge', async () => {
mockWhatsApp.fail = true;
await expect(createChallenge(USER, 'enable', 'whatsapp', { allowFallback: false })).rejects.toMatchObject({ statusCode: 503 });
expect(mockSent.emails).toHaveLength(0);
expect(mockDb.twoFactorChallenge._rows()).toHaveLength(0);
});
test('a new challenge replaces the previous one for the same purpose', async () => {
const first = await createChallenge(USER, 'login', 'email');
const firstCode = lastEmailCode();
await createChallenge(USER, 'login', 'email');
await expect(verifyChallenge(first.challengeId, firstCode, 'login')).rejects.toThrow(/invalid or has expired/);
});
});
describe('verifyChallenge', () => {
test('accepts the right code exactly once', async () => {
const { challengeId } = await createChallenge(USER, 'login', 'email');
const code = lastEmailCode();
const challenge = await verifyChallenge(challengeId, code, 'login');
expect(challenge.userId).toBe(USER.id);
await expect(verifyChallenge(challengeId, code, 'login')).rejects.toThrow(/invalid or has expired/);
});
test('tolerates spaces in the submitted code', async () => {
const { challengeId } = await createChallenge(USER, 'login', 'email');
const code = lastEmailCode();
await expect(verifyChallenge(challengeId, ` ${code.slice(0, 3)} ${code.slice(3)} `, 'login')).resolves.toBeTruthy();
});
test('locks the challenge after the maximum wrong attempts, even for the right code', async () => {
const { challengeId } = await createChallenge(USER, 'login', 'email');
const code = lastEmailCode();
for (let i = 1; i <= MAX_ATTEMPTS; i++) {
await expect(verifyChallenge(challengeId, wrongCode(code), 'login')).rejects.toThrow(
i < MAX_ATTEMPTS ? /Incorrect code/ : /Too many incorrect attempts/
);
}
await expect(verifyChallenge(challengeId, code, 'login')).rejects.toThrow(/invalid or has expired/);
});
test('rejects an expired code', async () => {
const { challengeId } = await createChallenge(USER, 'login', 'email');
mockDb.twoFactorChallenge._rows()[0].expiresAt = new Date(Date.now() - 1000);
await expect(verifyChallenge(challengeId, lastEmailCode(), 'login')).rejects.toThrow(/invalid or has expired/);
});
test('rejects a code used for the wrong purpose or by another user', async () => {
const { challengeId } = await createChallenge(USER, 'enable', 'email');
const code = lastEmailCode();
await expect(verifyChallenge(challengeId, code, 'login')).rejects.toThrow(/invalid or has expired/);
await expect(verifyChallenge(challengeId, code, 'enable', 'someone-else')).rejects.toThrow(/invalid or has expired/);
await expect(verifyChallenge(challengeId, code, 'enable', USER.id)).resolves.toBeTruthy();
});
test('rejects missing or malformed input', async () => {
await expect(verifyChallenge(undefined, '123456', 'login')).rejects.toThrow();
await expect(verifyChallenge({ not: 'a string' }, '123456', 'login')).rejects.toThrow();
const { challengeId } = await createChallenge(USER, 'login', 'email');
await expect(verifyChallenge(challengeId, undefined, 'login')).rejects.toThrow(/Incorrect code/);
});
});
describe('resendChallenge', () => {
test('refuses inside the cooldown', async () => {
const { challengeId } = await createChallenge(USER, 'login', 'email');
await expect(resendChallenge(challengeId, 'login')).rejects.toMatchObject({ statusCode: 429 });
expect(mockSent.emails).toHaveLength(1);
});
test('after the cooldown, sends a new code that replaces the old one and resets attempts', async () => {
const { challengeId } = await createChallenge(USER, 'login', 'email');
const oldCode = lastEmailCode();
await expect(verifyChallenge(challengeId, wrongCode(oldCode), 'login')).rejects.toThrow();
mockDb.twoFactorChallenge._rows()[0].lastSentAt = new Date(Date.now() - 61 * 1000);
await resendChallenge(challengeId, 'login');
const newCode = lastEmailCode();
expect(mockSent.emails).toHaveLength(2);
expect(mockDb.twoFactorChallenge._rows()[0].attempts).toBe(0);
if (newCode !== oldCode) {
await expect(verifyChallenge(challengeId, oldCode, 'login')).rejects.toThrow(/Incorrect code/);
}
await expect(verifyChallenge(challengeId, newCode, 'login')).resolves.toBeTruthy();
});
test('refuses once the challenge is too old or already used', async () => {
const { challengeId } = await createChallenge(USER, 'login', 'email');
const row = mockDb.twoFactorChallenge._rows()[0];
row.lastSentAt = new Date(Date.now() - 61 * 1000);
row.createdAt = new Date(Date.now() - 31 * 60 * 1000);
await expect(resendChallenge(challengeId, 'login')).rejects.toThrow(/expired/);
row.createdAt = new Date();
row.usedAt = new Date();
await expect(resendChallenge(challengeId, 'login')).rejects.toThrow(/expired/);
});
});
describe('trusted devices', () => {
test('a freshly issued token is trusted for its own user only', async () => {
const token = await issueTrustedDevice(USER.id, 'Mozilla/5.0 (Windows NT 10.0) Chrome/120.0 Safari/537.36');
expect(token).toMatch(/^[0-9a-f]{64}$/);
expect(mockDb.trustedDevice._rows()[0].tokenHash).not.toBe(token);
expect(await isTrustedDevice(USER.id, token)).toBe(true);
expect(await isTrustedDevice('someone-else', token)).toBe(false);
});
test('unknown, malformed and expired tokens are not trusted', async () => {
const token = await issueTrustedDevice(USER.id, '');
expect(await isTrustedDevice(USER.id, 'nope')).toBe(false);
expect(await isTrustedDevice(USER.id, undefined)).toBe(false);
expect(await isTrustedDevice(USER.id, { tokenHash: 'x' })).toBe(false);
mockDb.trustedDevice._rows()[0].expiresAt = new Date(Date.now() - 1000);
expect(await isTrustedDevice(USER.id, token)).toBe(false);
});
test('clearTrustedDevices forgets every device for the user', async () => {
const a = await issueTrustedDevice(USER.id, '');
const b = await issueTrustedDevice(USER.id, '');
await clearTrustedDevices(USER.id);
expect(await isTrustedDevice(USER.id, a)).toBe(false);
expect(await isTrustedDevice(USER.id, b)).toBe(false);
});
});
+31
View File
@@ -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([]);
});
});
+189 -182
View File
@@ -1,12 +1,12 @@
{
"name": "hope-events-frontend",
"version": "1.10.5",
"version": "1.17.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "hope-events-frontend",
"version": "1.10.5",
"version": "1.17.0",
"dependencies": {
"@hookform/resolvers": "^5.2.1",
"@radix-ui/react-accordion": "^1.2.11",
@@ -31,6 +31,7 @@
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"date-fns": "^4.1.0",
"idb": "^8.0.4",
"lucide-react": "^0.536.0",
"next": "^15.5.24",
"qrcode": "^1.5.4",
@@ -768,9 +769,9 @@
}
},
"node_modules/@img/sharp-darwin-arm64": {
"version": "0.35.4",
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.4.tgz",
"integrity": "sha512-Uhfl4V4lhP2nbUVF9+hyH1+luj86f1gUFeo8ALYxFoULoU+G87D43BfeMP8XHsk9boxAnCY/bf2EHwhA7MuGsA==",
"version": "0.35.5",
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.5.tgz",
"integrity": "sha512-QRUlFQ0WxvdWyqqG/WtI3iupfD5rBzmCHXSdPsY91sAtVtTo7Q4cb6zOccZ3gqEqkr0f1As1ehLqmEpDsRf+lg==",
"cpu": [
"arm64"
],
@@ -786,13 +787,13 @@
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-darwin-arm64": "1.3.3"
"@img/sharp-libvips-darwin-arm64": "1.3.4"
}
},
"node_modules/@img/sharp-darwin-x64": {
"version": "0.35.4",
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.4.tgz",
"integrity": "sha512-hWniXY3bG5qKpkKrAwPe4y+VTPmf086YQAnkxWh7uA1YrlRouWGa0M0Mxj3ZjnXFkv7/TD1bTy9lGUK26vRvWw==",
"version": "0.35.5",
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.5.tgz",
"integrity": "sha512-+BR255RhDlpygUpOc/Jdt1nT6DQ3XG/ERo5wbcdOf5Q320dKtPCKPLR1LJs9VGXRaMa8l1uUa0tkCNOXiAxZUw==",
"cpu": [
"x64"
],
@@ -808,20 +809,20 @@
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-darwin-x64": "1.3.3"
"@img/sharp-libvips-darwin-x64": "1.3.4"
}
},
"node_modules/@img/sharp-freebsd-wasm32": {
"version": "0.35.4",
"resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.4.tgz",
"integrity": "sha512-lIsKw/BU+kjB4eZjxrYrZmwOJYi3Ajrv66iAlBmUPyKc3HpnloevB1g3wxGD9P/5BbQ1brBGl65VRRrCvQDEqA==",
"version": "0.35.5",
"resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.5.tgz",
"integrity": "sha512-Y/z91nEZ4uIBX5X3nfTovjU9lHNKFYbL2lpHCLVNmXQK03VIZvXBBt0KxbPGp2SdGSF+2mQU4e+hQaWOt86iAw==",
"license": "Apache-2.0",
"optional": true,
"os": [
"freebsd"
],
"dependencies": {
"@img/sharp-wasm32": "0.35.4"
"@img/sharp-wasm32": "0.35.5"
},
"engines": {
"node": ">=20.9.0"
@@ -831,9 +832,9 @@
}
},
"node_modules/@img/sharp-libvips-darwin-arm64": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.3.tgz",
"integrity": "sha512-suTBPTDGrI9WodccaDdwZItTSaBYASlBk1NSfElSHrUfzu3szG6lvIF58+WiFvnfzuK8ZBFS5zE00PxqxnRiPg==",
"version": "1.3.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.4.tgz",
"integrity": "sha512-5R89nBYiRdUlSWJxPhO+GVtaXzXSxKnRu/xqMn3KTA3L9EB9Oy/P+Nn2f2vlhPuUdy/Zusb2DarbyTpGCfEDuw==",
"cpu": [
"arm64"
],
@@ -847,9 +848,9 @@
}
},
"node_modules/@img/sharp-libvips-darwin-x64": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.3.tgz",
"integrity": "sha512-FVJZ5mITMobmXIz/hPDTw0EintTW5H3WfrxwLqEqjiIihlu+hVRyGrFQ60xl0Lxn7Bt3zdpevPaQi0HEzqz9fw==",
"version": "1.3.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.4.tgz",
"integrity": "sha512-iR2OKH80yi0U+dUplyh3/xdpFvps6YkCwsXenIJxqxR1v9o+xtKTGbS9H7cps+2Vxjc8B1j96p75NmTGjIhtpQ==",
"cpu": [
"x64"
],
@@ -863,9 +864,9 @@
}
},
"node_modules/@img/sharp-libvips-linux-arm": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.3.tgz",
"integrity": "sha512-3rbU4vqXXc3hY/OiXdl52xZvT0F1yEngWfvqudtPJg/KkyiaQw2DRsFrNzpmLvfavbwOq3qXn36GP8obHRULQA==",
"version": "1.3.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.4.tgz",
"integrity": "sha512-LmRtTsOHuvM2+wlO2Db37dx5MiZhB0FvSunciw48YjdOkZz9KAiRbm8ujeMOA1INqmei5NapFxYEK1D1ZSidmw==",
"cpu": [
"arm"
],
@@ -879,9 +880,9 @@
}
},
"node_modules/@img/sharp-libvips-linux-arm64": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.3.tgz",
"integrity": "sha512-0DaL0A6Xu6sQSQFwe4iVCrKWU2cCTItnRsYsCdxAMm9NF6twAA9BKnoqy4hqz4+azQ0JHuA26qiUKsf1XJ/v5A==",
"version": "1.3.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.4.tgz",
"integrity": "sha512-Y3dgX/6lE2QhQb+Gxy0WZxfg9MEm/JBjamZpS2IklP7xIQoKN4hzAm7KcMVGtaVDt3neE9OKBC7vAfonA/Lr1A==",
"cpu": [
"arm64"
],
@@ -895,9 +896,9 @@
}
},
"node_modules/@img/sharp-libvips-linux-ppc64": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.3.tgz",
"integrity": "sha512-cdn1OvUBwsXhbC0zSzJnNzf5MZ/mTrobawDvNXBTxe8VtqKAm0sRuEY2Evzovb/w9JMk4TvRxqt1mekSuJz64w==",
"version": "1.3.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.4.tgz",
"integrity": "sha512-Le6boB8Tai0Nis+gIxIpKx68UDVVIqdR8Tin5Yf1z2LJJQLDJvCDRqRu+jC2qCoD+eIomonmOwB4smBRxfVpYQ==",
"cpu": [
"ppc64"
],
@@ -911,9 +912,9 @@
}
},
"node_modules/@img/sharp-libvips-linux-riscv64": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.3.tgz",
"integrity": "sha512-HjPVx7yKz+0lqdhDlTw1tt90wamBoxhiXpvl1XZpJLiHH4RCJ5yDTqH+VlYPv2fwFs89JFw4c1IexYOcQUi4IQ==",
"version": "1.3.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.4.tgz",
"integrity": "sha512-aHkkIEHPRdQEegJN20MLmGtxYD9R2wQr3Cwpddnu5+YKMt6Uzax7S9h5gpZTo8wyrGuZSlfQ63OevL5mTyOC7Q==",
"cpu": [
"riscv64"
],
@@ -927,9 +928,9 @@
}
},
"node_modules/@img/sharp-libvips-linux-s390x": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.3.tgz",
"integrity": "sha512-neWLh+3yCNThxnfy3c4BbVBeGgt9aftno+XbT56iK28RgeDs3UOFWviLWlUu0bArYVYJaFDK+RRohbicUNCm8Q==",
"version": "1.3.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.4.tgz",
"integrity": "sha512-ra/mB6MikESDUO7Yg+Mi95bFBb9GsObURuhnOv3OqknjGe9sZrG8tCe9q0xSIGrtLgvgw0gKnFWcK4blSgQOuQ==",
"cpu": [
"s390x"
],
@@ -943,9 +944,9 @@
}
},
"node_modules/@img/sharp-libvips-linux-x64": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.3.tgz",
"integrity": "sha512-4vKmvAst9nrowcqquKFAyZJUDolUaIp8uRiN0mWFguJ1IplC9/pitXtlnnlU4aa/eJw3J7i67V+pwUL+wZGdsA==",
"version": "1.3.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.4.tgz",
"integrity": "sha512-GJ//SSXbnwSDes02umB3nDJLFcQzw8a18V8fyhqr6tV515tOEMdImjjxj1AoafMRz56F3PHgftnj1QEKSU1zkw==",
"cpu": [
"x64"
],
@@ -959,9 +960,9 @@
}
},
"node_modules/@img/sharp-libvips-linuxmusl-arm64": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.3.tgz",
"integrity": "sha512-Y9kQaLMuNoB0bPYOOdcZMaseNrFpPodIWWMrx+CZyydf2xn68j9WYc6sWWRrDwNkzCQjKYfc68L7jKjGlHMibw==",
"version": "1.3.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.4.tgz",
"integrity": "sha512-hvulFwtjUcagsis6BBxHwGFwWoNZjgYmULGVrZcyfNbjA8hKILbRxGg15/7w5HDyXHXUos/j6baAWqnCyQ2DWA==",
"cpu": [
"arm64"
],
@@ -975,9 +976,9 @@
}
},
"node_modules/@img/sharp-libvips-linuxmusl-x64": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.3.tgz",
"integrity": "sha512-fj8Mv0HHfD1Rr+4I68+3agJynxDWtBFgicTbSOb9Bke6pIwzGcJ+RX/yHjmiEGFMCavY/dxvem7MyNaJF+wDiw==",
"version": "1.3.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.4.tgz",
"integrity": "sha512-6zXKeE/p39I1AmA3cJG35eyBGNqNddLnUXjhwBnsGjFPWqf5VKkDBEqaEkPDoTEtkxwi2vv8Tcr2mDyP4So7Fg==",
"cpu": [
"x64"
],
@@ -991,9 +992,9 @@
}
},
"node_modules/@img/sharp-linux-arm": {
"version": "0.35.4",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.4.tgz",
"integrity": "sha512-7OAS8gI0EReKGVN2HssHlM6umJgxF5VI3xN0p9FA91p/YO+ou5hiNghLdZ5BEHztwaaK5+bLKRf8x/o2L2nk9A==",
"version": "0.35.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.5.tgz",
"integrity": "sha512-LEaXK2WdXVK5ykcw0buWyPMsmLLL2vpHLD6yrNSW+JGEL3BZPA4tpKN6iaMc4AxTTAoaX/sU1rOL51lcIz48ZQ==",
"cpu": [
"arm"
],
@@ -1009,13 +1010,13 @@
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-arm": "1.3.3"
"@img/sharp-libvips-linux-arm": "1.3.4"
}
},
"node_modules/@img/sharp-linux-arm64": {
"version": "0.35.4",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.4.tgz",
"integrity": "sha512-De4jpEnAU8Hd5oT0j1G3uL4ZvTuipVMn7YC6vPaJhy6/7EwEae0SVAoBrUMYQbkLGDm85taVWwuPc1a44LTzCQ==",
"version": "0.35.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.5.tgz",
"integrity": "sha512-LYVx5JTsOM2CBzmxreh+nl64/3H6Xb09iSLknqH47z2T2DFFxDeFLP5y4dJwe6H7uGQlHPyEEtIqyo3DYsRwdQ==",
"cpu": [
"arm64"
],
@@ -1031,13 +1032,13 @@
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-arm64": "1.3.3"
"@img/sharp-libvips-linux-arm64": "1.3.4"
}
},
"node_modules/@img/sharp-linux-ppc64": {
"version": "0.35.4",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.4.tgz",
"integrity": "sha512-2oYZJeIl4kCcMGk4ouZVjnkCtFrpQFlNEtJ6GbxzhHQchwH0NH/qEb9ykmOl29dqwMq+JhFdZn+1ak2FKhI9fQ==",
"version": "0.35.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.5.tgz",
"integrity": "sha512-QVxAAq8evVRI9ia2vqgwrmWucn5Dfv+JdWzj75pD8omHLPSP7f8p20O8jxzjCcuCEQEOtYOZUmX1hkiZ0kdevA==",
"cpu": [
"ppc64"
],
@@ -1053,13 +1054,13 @@
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-ppc64": "1.3.3"
"@img/sharp-libvips-linux-ppc64": "1.3.4"
}
},
"node_modules/@img/sharp-linux-riscv64": {
"version": "0.35.4",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.4.tgz",
"integrity": "sha512-cPbNChoRURAWdebDIHSenxRpgEdy7JkPydSnUxRm9VvKD7m0/xVaR/8Fzlu81pk5nHEvHH87UZUA7cTtwnbJSA==",
"version": "0.35.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.5.tgz",
"integrity": "sha512-LtdreXguaavKODPIfzJ4kffx7UNt1omwtK0rch4EBbbSTXPnxWmYSayXdLJw0fJzQ97kHt1gL/yh4tvU+nCyRQ==",
"cpu": [
"riscv64"
],
@@ -1075,13 +1076,13 @@
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-riscv64": "1.3.3"
"@img/sharp-libvips-linux-riscv64": "1.3.4"
}
},
"node_modules/@img/sharp-linux-s390x": {
"version": "0.35.4",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.4.tgz",
"integrity": "sha512-RY0JFY8Fd6RonCBtHz+DvadaPkXDSI1AUn6yWL9TipqkZ1vY8w8evqdgyDFnkm4/K1ve1TvZiaePP5oSd4+WVQ==",
"version": "0.35.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.5.tgz",
"integrity": "sha512-UZasTOFiYzotTsGOCu42BfUzP6Tu6Do/947iRm1RsLKvlllxwGcn4RN27LibGWceix4Y+Pmw3jsnTcCQIgWjqA==",
"cpu": [
"s390x"
],
@@ -1097,13 +1098,13 @@
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-s390x": "1.3.3"
"@img/sharp-libvips-linux-s390x": "1.3.4"
}
},
"node_modules/@img/sharp-linux-x64": {
"version": "0.35.4",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.4.tgz",
"integrity": "sha512-9qvvEAuk8k89TfWUoX2htWjbAMX8p+NxCppjpcg5k6xMsjhBQPTsoIh36h9Qde4WRuGpJeYnOjdosDn/cnv+OA==",
"version": "0.35.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.5.tgz",
"integrity": "sha512-SxFtLTeJInhAA9Q836kux2vZNeOBQEx658qvbboZScr0wIARym3IcGmW7KpVD5sbVg0Ojy+udFQdayYIZyoNog==",
"cpu": [
"x64"
],
@@ -1119,13 +1120,13 @@
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-x64": "1.3.3"
"@img/sharp-libvips-linux-x64": "1.3.4"
}
},
"node_modules/@img/sharp-linuxmusl-arm64": {
"version": "0.35.4",
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.4.tgz",
"integrity": "sha512-KB5jxpfWQTr0nc3xdHtWChdbifHrBGsd2SM62Eyxrl8afikm+f5qGBU75SJIZBT/S1MC8XyacdlXBMSWq6OURA==",
"version": "0.35.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.5.tgz",
"integrity": "sha512-9HbMclmI1zlNkFRs3z9/eBtDjfD0sGlrX1z6b1qwmiFY5ElDLh4BC0LPBdVp7z1DXFiKlIcznf+ZlsuZzLxQqg==",
"cpu": [
"arm64"
],
@@ -1141,13 +1142,13 @@
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linuxmusl-arm64": "1.3.3"
"@img/sharp-libvips-linuxmusl-arm64": "1.3.4"
}
},
"node_modules/@img/sharp-linuxmusl-x64": {
"version": "0.35.4",
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.4.tgz",
"integrity": "sha512-f+eZJZIQNEEd26RPSW+76chwOf1XtA2Y/O+5ocVyLliHkeih3e+jhLVBdNTd2rS3IbNXK8+ug93Vf5ZXtF5Lxg==",
"version": "0.35.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.5.tgz",
"integrity": "sha512-4KOphqB035HrVdqLZfCgMzzERrQkkzOwRhl4OAkRO1YCldbaFjySXMaK534Mo0V+LndnlJk+sbUyLeU0ULyD1A==",
"cpu": [
"x64"
],
@@ -1163,13 +1164,13 @@
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linuxmusl-x64": "1.3.3"
"@img/sharp-libvips-linuxmusl-x64": "1.3.4"
}
},
"node_modules/@img/sharp-wasm32": {
"version": "0.35.4",
"resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.4.tgz",
"integrity": "sha512-zQnl4Kwp7Q6NHsENtU2T/00Zi+w3AQNwz3+UaTyVBy2FpXrzXzGjndpK61onhZjRtRpQXxCTeqw19bVyXOh7jA==",
"version": "0.35.5",
"resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.5.tgz",
"integrity": "sha512-Ptsga1su4tQx+LLF1ECS9U6nz5kmrXKo6XVbtR48Ke3ZRxxgaWBu7IDtEe1quo8hiupwm6WFqxVlXaSf7IINGQ==",
"license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
"optional": true,
"dependencies": {
@@ -1183,16 +1184,16 @@
}
},
"node_modules/@img/sharp-webcontainers-wasm32": {
"version": "0.35.4",
"resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.4.tgz",
"integrity": "sha512-ESfNkywmCfPNyaZjxooddJQiQ+l/nTpGEOGthxiLnIHXC/CmcBixnfwUleX9mCz9ovrUUvKMap/pm8RYbzfwaA==",
"version": "0.35.5",
"resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.5.tgz",
"integrity": "sha512-hfhF/FmoQyTUkA0bIKFOtw536BQSeBMe6BF6QyWlrPxT754+TFLaZ7sKKTfvvM0yJgKgaYTwnFCIZ/GuDw5SUA==",
"cpu": [
"wasm32"
],
"license": "Apache-2.0",
"optional": true,
"dependencies": {
"@img/sharp-wasm32": "0.35.4"
"@img/sharp-wasm32": "0.35.5"
},
"engines": {
"node": ">=20.9.0"
@@ -1202,9 +1203,9 @@
}
},
"node_modules/@img/sharp-win32-arm64": {
"version": "0.35.4",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.4.tgz",
"integrity": "sha512-iNdlBX9gLVvqe2I3uIJSIKTq6wckP/DYxZtcqxm09x5Gi24DnFBmPAWZmr60ZyYMG0xlzo6goG3670ar+RXvRw==",
"version": "0.35.5",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.5.tgz",
"integrity": "sha512-X4t7g+7ZA5DKblCBEXGjUqqemj4vczING/5viFwAL8h4N3qYeyjwdCvRLHi4EdOUI+2Z7UFlp1VM+p/AuEtm6Q==",
"cpu": [
"arm64"
],
@@ -1221,9 +1222,9 @@
}
},
"node_modules/@img/sharp-win32-ia32": {
"version": "0.35.4",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.4.tgz",
"integrity": "sha512-kqRsbaa5CS6KHlpxnN7WhE6vAAugXyZButpRdvDWetlv6Qv4N9WTcrWzF7tXfB9T7MsoadqdI8hmwLq6UlLvtw==",
"version": "0.35.5",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.5.tgz",
"integrity": "sha512-5Zm82LoBc43nhwNybZlG7Y1KO//Zhsn306fQl29ZOuStHLGTo3BWL83q3cznX0poxSAMuYL1On/BHBxkBeKr6A==",
"cpu": [
"ia32"
],
@@ -1240,9 +1241,9 @@
}
},
"node_modules/@img/sharp-win32-x64": {
"version": "0.35.4",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.4.tgz",
"integrity": "sha512-XtmnYhBcrORsJ4XJngyzr/EWP0hRZLAZRFaApdKuviyqF78+ylxh2y06ZmtULAMOnObJ3ucpN0AcwSWnMowTRg==",
"version": "0.35.5",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.5.tgz",
"integrity": "sha512-x76eH0vEiHlcMQu8Y8IenntaACtddpT6W0wmXtWrnKcnKI7ME5DdgqhAD6SEWOEl1v2zDvkZDhFA9KnURwpfqg==",
"cpu": [
"x64"
],
@@ -1353,15 +1354,15 @@
}
},
"node_modules/@next/env": {
"version": "15.5.24",
"resolved": "https://registry.npmjs.org/@next/env/-/env-15.5.24.tgz",
"integrity": "sha512-mBDF7T0XKZjs9SpUAl0buizVO+O02ULjOvWX8o/AZo/5AGw/UAS1Zzcylmd4pqbftzmKQi+L/nB4jgBYKEAl5Q==",
"version": "15.5.27",
"resolved": "https://registry.npmjs.org/@next/env/-/env-15.5.27.tgz",
"integrity": "sha512-WC5hvVqiuFOcRyz6gKPeJkiVvSMf3scNeAV49CdWtVqxmUlzh8UKR6YHt9yiNkh8LngtzqSlVz07/Ma3ZAzchA==",
"license": "MIT"
},
"node_modules/@next/eslint-plugin-next": {
"version": "15.5.24",
"resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-15.5.24.tgz",
"integrity": "sha512-K6jcYhwMY73qpeJ5SP+rrXs7Fuvv94HEvOCEen/zjwG7aq83utZY8n+8YdKHZR1lv0n5cB4z9C6QqwpE9acN+A==",
"version": "15.5.27",
"resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-15.5.27.tgz",
"integrity": "sha512-a94xlzvOLaULh4Fzcvj9B2hwIOtPHJW75gghkXiJ8s1Jk0eo00UknrDVhx04Y+8ARu9ds13vy/jHg5u/PdVGqw==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -1369,9 +1370,9 @@
}
},
"node_modules/@next/swc-darwin-arm64": {
"version": "15.5.24",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-15.5.24.tgz",
"integrity": "sha512-AGdNLvxZNY6eR2iSnV+6wUa8CiHTMr4F7g3uHH7fT4ICIJBE00R9u4tzN/Vuwsw0cOi8MTD2HJcTCb6siMH88Q==",
"version": "15.5.27",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-15.5.27.tgz",
"integrity": "sha512-fBA9QurmK4+uj4wNhmRTuO5zMVRaBHCvp6H93zTG/5i6Yi6TewbvbesXB8YQNj8T99Cwrow87hHiFV6wL/hyMA==",
"cpu": [
"arm64"
],
@@ -1385,9 +1386,9 @@
}
},
"node_modules/@next/swc-darwin-x64": {
"version": "15.5.24",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-15.5.24.tgz",
"integrity": "sha512-9HrQajBMmGcrrrvDfRimiCrbAPh3E6uHJmwBovYr6Yrmi9p9PZqI876BrXX280wICh3o2XwUlp4blkB0NNBqFg==",
"version": "15.5.27",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-15.5.27.tgz",
"integrity": "sha512-FGZwJwry8qZNHxD/nfwrCwCHHrqx6epqItkkxbYcBQEQ2uw5S43bY83fQB5zbAsZE7TELMjoYkbhsQ4oKM7wwg==",
"cpu": [
"x64"
],
@@ -1401,9 +1402,9 @@
}
},
"node_modules/@next/swc-linux-arm64-gnu": {
"version": "15.5.24",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.5.24.tgz",
"integrity": "sha512-rl9LSfE75si0WT3cDgdUC1XYCKS+TgxC+/IjitmeycrAG18X/plIP1/vy8dd/HPycYcIvE688PD7FuvEAiEAew==",
"version": "15.5.27",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.5.27.tgz",
"integrity": "sha512-GQulAaJYNF1RYuatlsw9/uzbKz4if2V7Ic9nVO+P0ljYPm4yBRTFIl6g5YkKTw40lMLbpy9edLoH44g3jSHMYA==",
"cpu": [
"arm64"
],
@@ -1417,9 +1418,9 @@
}
},
"node_modules/@next/swc-linux-arm64-musl": {
"version": "15.5.24",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.5.24.tgz",
"integrity": "sha512-TlNAnpsjxSF3aAUtqnfmtXXf8m9sIDBlmF3c7bTAlnshUYu2U0OxN2uf5d0gcFwqHVEdivJNBcCaqNOwPGNimw==",
"version": "15.5.27",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.5.27.tgz",
"integrity": "sha512-URFFsUmu8Pi0/1qdnUI7tx7keZw1Mt5zHfrIX1RfDG9Nih8DYxK54WZsDJUX2ZTlii1jgotL7qcbwKnSAES6xQ==",
"cpu": [
"arm64"
],
@@ -1433,9 +1434,9 @@
}
},
"node_modules/@next/swc-linux-x64-gnu": {
"version": "15.5.24",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-15.5.24.tgz",
"integrity": "sha512-7dwtlhr0SLndqTG1z9ncRkbJswDZiKWlxzFyXDvJ2RDZRDRHp8zyMJ4D9UH/FgnQeXxxB6gZy2pMcIUoNKQ4pA==",
"version": "15.5.27",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-15.5.27.tgz",
"integrity": "sha512-aJp74z7uvU5jQhyH7lyk+4qSRcTdKbKT9NQE3jiO9kWYkoHZjKi0zzHDbCoZ+v2sJDEiJ/z58R0UlWMGWNF3HA==",
"cpu": [
"x64"
],
@@ -1449,9 +1450,9 @@
}
},
"node_modules/@next/swc-linux-x64-musl": {
"version": "15.5.24",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-15.5.24.tgz",
"integrity": "sha512-kGZxM+WhkYs0276lFrMkj7PRtXT3Btp6cwvfSO/cCVLxJJttB5Ccnl2niaCgUja8HgSbEVnMHpg3FJWoOJ9e/g==",
"version": "15.5.27",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-15.5.27.tgz",
"integrity": "sha512-ILl2bQ8MbpvyMpY8AXljb3BXs0MJSC8HXZ6QV5AWpVS7ZELqNPtrHvhUk+SNarvjvICXX8bCS287TCi+gUFVYg==",
"cpu": [
"x64"
],
@@ -1465,9 +1466,9 @@
}
},
"node_modules/@next/swc-win32-arm64-msvc": {
"version": "15.5.24",
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.5.24.tgz",
"integrity": "sha512-jBDDkZ/qKAqkWivWDMkJSXUzbzV0QKRBKJjEHUAvSB97Hzw7NLzJ6yV56Lts/wjir7s4P31GYgpbS6ZL+hasAA==",
"version": "15.5.27",
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.5.27.tgz",
"integrity": "sha512-1MB9Rh5y8TOPN2x7naNTJNkCZQav9c70AI7EBYXxsEewwOffC4DjjwBwKf20NBvcA+u+5jDv7nVmHxunlP2d7Q==",
"cpu": [
"arm64"
],
@@ -1481,9 +1482,9 @@
}
},
"node_modules/@next/swc-win32-x64-msvc": {
"version": "15.5.24",
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.5.24.tgz",
"integrity": "sha512-JqtwjvvorjacQ0spgjmUJoxySoYgPwdT1sFdQ0/zmW4iMlP2hjYlCoJIyS7o6Epb4Fug8eco3HXFoBDUCDeH7Q==",
"version": "15.5.27",
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.5.27.tgz",
"integrity": "sha512-XtYgpGOswgnyBWXzOEG6q9RhvnSw0CEZ0MbWqCsl5PISgt9EzvJvNW5mU5n1PsphX/lnamzxjgQXH2GUJL8nqg==",
"cpu": [
"x64"
],
@@ -3897,9 +3898,9 @@
}
},
"node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": {
"version": "5.0.9",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz",
"integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==",
"version": "5.0.12",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz",
"integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -4990,9 +4991,9 @@
}
},
"node_modules/brace-expansion": {
"version": "1.1.18",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz",
"integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==",
"version": "1.1.21",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.21.tgz",
"integrity": "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -5872,13 +5873,13 @@
}
},
"node_modules/eslint-config-next": {
"version": "15.5.24",
"resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-15.5.24.tgz",
"integrity": "sha512-RXne8azyj9DKV9N15elyRsnh98BtKINYHCG7bmVhpIhaOtaYvrNJ37XwWpjY9Z09f5RHYOf9vn5uU0MgV1iLrw==",
"version": "15.5.27",
"resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-15.5.27.tgz",
"integrity": "sha512-SlpZ5Ch1bM+GNHxahIoCy5OVgbpIHzHoGQDjHo6vEhxvYMukWYVo++AEh9RWYaIxH1i38PO7ArzSdmTruswOew==",
"dev": true,
"license": "MIT",
"dependencies": {
"@next/eslint-plugin-next": "15.5.24",
"@next/eslint-plugin-next": "15.5.27",
"@rushstack/eslint-patch": "^1.10.3",
"@typescript-eslint/eslint-plugin": "^5.4.2 || ^6.0.0 || ^7.0.0 || ^8.0.0",
"@typescript-eslint/parser": "^5.4.2 || ^6.0.0 || ^7.0.0 || ^8.0.0",
@@ -6287,9 +6288,9 @@
"license": "MIT"
},
"node_modules/fast-uri": {
"version": "3.1.6",
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.6.tgz",
"integrity": "sha512-7Ical1vFEMr0onbVzEDIreM22I4khW+fzyQPwvAFWBp1iwdshSZRsL4jjRvPG9JP1uiqMHRto+YU6R2/CzDz5Q==",
"version": "3.1.8",
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.8.tgz",
"integrity": "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==",
"funding": [
{
"type": "github",
@@ -6624,9 +6625,9 @@
}
},
"node_modules/glob/node_modules/brace-expansion": {
"version": "5.0.9",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz",
"integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==",
"version": "5.0.12",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz",
"integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==",
"license": "MIT",
"dependencies": {
"balanced-match": "^4.0.2"
@@ -6805,6 +6806,12 @@
"node": ">= 6"
}
},
"node_modules/idb": {
"version": "8.0.4",
"resolved": "https://registry.npmjs.org/idb/-/idb-8.0.4.tgz",
"integrity": "sha512-jd0ITCrN+Zah4J7R/Z2jnR3W7A4BXDAjmo62MwWuSjxCiueUAf7X5rbodEdidWzeknwXHmLpdZkoiiCppNi30Q==",
"license": "ISC"
},
"node_modules/ignore": {
"version": "5.3.2",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
@@ -7848,12 +7855,12 @@
"peer": true
},
"node_modules/next": {
"version": "15.5.24",
"resolved": "https://registry.npmjs.org/next/-/next-15.5.24.tgz",
"integrity": "sha512-Y+xn8EQCoC3ZbsFPyzE+tE8XOdrWeUdUF7NeXbmg9DsgAxl5UYxlsrvgVESHTyTGigoTa1bCUrxn70F5bqt0Gw==",
"version": "15.5.27",
"resolved": "https://registry.npmjs.org/next/-/next-15.5.27.tgz",
"integrity": "sha512-F82CrlPZ8GRxBH9RIIoR3qU1t5RT3dv1K8moe8SjlDEquiFipvEI13R1Om90TaUOEwShkWCMlg8+gvTNzfMhvg==",
"license": "MIT",
"dependencies": {
"@next/env": "15.5.24",
"@next/env": "15.5.27",
"@swc/helpers": "0.5.15",
"caniuse-lite": "^1.0.30001579",
"postcss": "8.4.31",
@@ -7866,15 +7873,15 @@
"node": "^18.18.0 || ^19.8.0 || >= 20.0.0"
},
"optionalDependencies": {
"@next/swc-darwin-arm64": "15.5.24",
"@next/swc-darwin-x64": "15.5.24",
"@next/swc-linux-arm64-gnu": "15.5.24",
"@next/swc-linux-arm64-musl": "15.5.24",
"@next/swc-linux-x64-gnu": "15.5.24",
"@next/swc-linux-x64-musl": "15.5.24",
"@next/swc-win32-arm64-msvc": "15.5.24",
"@next/swc-win32-x64-msvc": "15.5.24",
"sharp": "^0.34.3 || ^0.35.3"
"@next/swc-darwin-arm64": "15.5.27",
"@next/swc-darwin-x64": "15.5.27",
"@next/swc-linux-arm64-gnu": "15.5.27",
"@next/swc-linux-arm64-musl": "15.5.27",
"@next/swc-linux-x64-gnu": "15.5.27",
"@next/swc-linux-x64-musl": "15.5.27",
"@next/swc-win32-arm64-msvc": "15.5.27",
"@next/swc-win32-x64-msvc": "15.5.27",
"sharp": "^0.34.3 || ^0.35.4"
},
"peerDependencies": {
"@opentelemetry/api": "^1.1.0",
@@ -9064,9 +9071,9 @@
}
},
"node_modules/sharp": {
"version": "0.35.4",
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.4.tgz",
"integrity": "sha512-n++8XWcj+jCOr2IOl7h8LbKnGBDY4aPbmprMONBNFdn0ImXqpGVv5zliDs0V9HbmbCQLpbuo2ej9rAoOQTvMDA==",
"version": "0.35.5",
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.5.tgz",
"integrity": "sha512-Ywn4OnzGukp7CDMrp08RQ50YKmuwG47brZgIVPTvBaaAfQlRlygrRqSrxdCiL9M+LlzLBiJ68IR1QqvzHyjC7g==",
"license": "Apache-2.0",
"optional": true,
"dependencies": {
@@ -9081,31 +9088,31 @@
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-darwin-arm64": "0.35.4",
"@img/sharp-darwin-x64": "0.35.4",
"@img/sharp-freebsd-wasm32": "0.35.4",
"@img/sharp-libvips-darwin-arm64": "1.3.3",
"@img/sharp-libvips-darwin-x64": "1.3.3",
"@img/sharp-libvips-linux-arm": "1.3.3",
"@img/sharp-libvips-linux-arm64": "1.3.3",
"@img/sharp-libvips-linux-ppc64": "1.3.3",
"@img/sharp-libvips-linux-riscv64": "1.3.3",
"@img/sharp-libvips-linux-s390x": "1.3.3",
"@img/sharp-libvips-linux-x64": "1.3.3",
"@img/sharp-libvips-linuxmusl-arm64": "1.3.3",
"@img/sharp-libvips-linuxmusl-x64": "1.3.3",
"@img/sharp-linux-arm": "0.35.4",
"@img/sharp-linux-arm64": "0.35.4",
"@img/sharp-linux-ppc64": "0.35.4",
"@img/sharp-linux-riscv64": "0.35.4",
"@img/sharp-linux-s390x": "0.35.4",
"@img/sharp-linux-x64": "0.35.4",
"@img/sharp-linuxmusl-arm64": "0.35.4",
"@img/sharp-linuxmusl-x64": "0.35.4",
"@img/sharp-webcontainers-wasm32": "0.35.4",
"@img/sharp-win32-arm64": "0.35.4",
"@img/sharp-win32-ia32": "0.35.4",
"@img/sharp-win32-x64": "0.35.4"
"@img/sharp-darwin-arm64": "0.35.5",
"@img/sharp-darwin-x64": "0.35.5",
"@img/sharp-freebsd-wasm32": "0.35.5",
"@img/sharp-libvips-darwin-arm64": "1.3.4",
"@img/sharp-libvips-darwin-x64": "1.3.4",
"@img/sharp-libvips-linux-arm": "1.3.4",
"@img/sharp-libvips-linux-arm64": "1.3.4",
"@img/sharp-libvips-linux-ppc64": "1.3.4",
"@img/sharp-libvips-linux-riscv64": "1.3.4",
"@img/sharp-libvips-linux-s390x": "1.3.4",
"@img/sharp-libvips-linux-x64": "1.3.4",
"@img/sharp-libvips-linuxmusl-arm64": "1.3.4",
"@img/sharp-libvips-linuxmusl-x64": "1.3.4",
"@img/sharp-linux-arm": "0.35.5",
"@img/sharp-linux-arm64": "0.35.5",
"@img/sharp-linux-ppc64": "0.35.5",
"@img/sharp-linux-riscv64": "0.35.5",
"@img/sharp-linux-s390x": "0.35.5",
"@img/sharp-linux-x64": "0.35.5",
"@img/sharp-linuxmusl-arm64": "0.35.5",
"@img/sharp-linuxmusl-x64": "0.35.5",
"@img/sharp-webcontainers-wasm32": "0.35.5",
"@img/sharp-win32-arm64": "0.35.5",
"@img/sharp-win32-ia32": "0.35.5",
"@img/sharp-win32-x64": "0.35.5"
},
"peerDependenciesMeta": {
"@types/node": {
@@ -9235,9 +9242,9 @@
}
},
"node_modules/source-map-js": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz",
"integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==",
"license": "BSD-3-Clause",
"engines": {
"node": ">=0.10.0"
+6 -1
View File
@@ -1,6 +1,6 @@
{
"name": "hope-events-frontend",
"version": "1.10.5",
"version": "1.17.0",
"private": true,
"scripts": {
"dev": "next dev --turbopack",
@@ -32,6 +32,7 @@
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"date-fns": "^4.1.0",
"idb": "^8.0.4",
"lucide-react": "^0.536.0",
"next": "^15.5.24",
"qrcode": "^1.5.4",
@@ -61,5 +62,9 @@
"next": {
"postcss": "^8.5.23"
}
},
"allowScripts": {
"@sentry/cli@2.58.6": true,
"unrs-resolver@1.12.2": true
}
}
+67
View File
@@ -0,0 +1,67 @@
/*
* Service worker for offline ticket scanning.
*
* Only the scanner page and the static files it needs are cached:
* - /_next/static/* (hashed, never change): cache-first.
* - The scanner page itself: network-first, falling back to the cached copy offline.
* - The page can ask (postMessage { type: 'cache-urls', urls }) for the files it has
* loaded to be cached up front, so it works offline straight after "Enable offline mode".
* API calls are never cached — offline scans go through IndexedDB instead.
*/
const CACHE = 'hope-scanner-v1';
const SCANNER_PATH = '/dashboard/staff/ticket-scanning';
self.addEventListener('install', () => self.skipWaiting());
self.addEventListener('activate', (event) => {
event.waitUntil((async () => {
const keys = await caches.keys();
await Promise.all(keys.filter(k => k.startsWith('hope-scanner-') && k !== CACHE).map(k => caches.delete(k)));
await self.clients.claim();
})());
});
self.addEventListener('message', (event) => {
const data = event.data || {};
if (data.type !== 'cache-urls' || !Array.isArray(data.urls)) return;
event.waitUntil((async () => {
const cache = await caches.open(CACHE);
const sameOrigin = data.urls.filter(u => {
try { return new URL(u, self.location.origin).origin === self.location.origin; } catch { return false; }
});
await Promise.all(sameOrigin.map(u => cache.add(u).catch(() => {})));
})());
});
self.addEventListener('fetch', (event) => {
const req = event.request;
if (req.method !== 'GET') return;
const url = new URL(req.url);
if (url.origin !== self.location.origin) return;
if (url.pathname.startsWith('/_next/static/')) {
event.respondWith((async () => {
const cache = await caches.open(CACHE);
const hit = await cache.match(req);
if (hit) return hit;
const res = await fetch(req);
if (res.ok) cache.put(req, res.clone());
return res;
})());
return;
}
if (url.pathname === SCANNER_PATH) {
event.respondWith((async () => {
const cache = await caches.open(CACHE);
try {
const res = await fetch(req);
if (res.ok) cache.put(SCANNER_PATH, res.clone());
return res;
} catch {
const hit = (await cache.match(req, { ignoreSearch: true })) || (await cache.match(SCANNER_PATH));
if (hit) return hit;
throw new Error('offline and not cached');
}
})());
}
});
+181
View File
@@ -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>
);
}
@@ -18,6 +18,17 @@ const ACTIONS = [
"event_updated",
"event_deleted",
"settings_updated",
"coupon_created",
"coupon_updated",
"coupon_disabled",
"coupon_applied_manual",
"consent_override",
"consent_rerequested",
"medical_list_viewed",
"scan_lock_acquired",
"scan_lock_released",
"scan_lock_force_released",
"offline_sync",
] as const;
const ACTION_LABELS: Record<string, string> = {
@@ -30,6 +41,17 @@ const ACTION_LABELS: Record<string, string> = {
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",
scan_lock_acquired: "Offline scanning started",
scan_lock_released: "Offline scanning ended",
scan_lock_force_released: "Offline lock force-released",
offline_sync: "Offline scans synced",
};
interface AuditLogEntry {
+5 -1
View File
@@ -8,7 +8,7 @@ import { useStableState } from "@/hooks/useStableState";
import { useVisiblePolling } from "@/hooks/useVisiblePolling";
import {
Calendar, Banknote, Gift, Users, Ticket, QrCode, ClipboardList,
UserPlus, FileText, MessageCircle, BarChart2, Mail, Wallet, DoorOpen, History,
UserPlus, FileText, MessageCircle, BarChart2, Mail, Wallet, DoorOpen, History, TicketPercent, ShieldCheck, ListChecks, WifiOff,
} from "lucide-react";
import { StatCard, StatCardRow } from "@/components/shared/StatCard";
import { QuickActionTile, QuickActionGrid } from "@/components/shared/QuickActionTile";
@@ -27,6 +27,10 @@ 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/offline-scans", label: "Offline scanning", description: "Devices scanning offline and their synced scans", icon: WifiOff },
{ 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 },
@@ -136,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(() => {
@@ -171,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));
@@ -319,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 () => {
@@ -658,6 +661,15 @@ 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>
)}
@@ -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
)}`;
@@ -7,6 +7,9 @@ import { apiFetch } from "@/lib/api";
import { useDismissingState } from "@/hooks/useDismissingState";
import { useRouter } from "next/navigation";
import { QrCode } from "lucide-react";
import { useOfflineScanning } from "@/components/scanner/useOfflineScanning";
import { OfflineScanPanel } from "@/components/scanner/OfflineScanPanel";
import type { SnapshotTicket } from "@/lib/offlineScan";
export default function TicketScanningPage() {
const router = useRouter();
@@ -17,6 +20,7 @@ export default function TicketScanningPage() {
const [alreadyUsedModal, setAlreadyUsedModal] = useState<{ message: string; ticket?: any } | null>(null);
const [errorModal, setErrorModal] = useState<string | null>(null);
const { token, user } = useAuth();
const offline = useOfflineScanning(token);
const [recentScans, setRecentScans] = useState<any[]>([]);
const [stats, setStats] = useState<any | null>(null);
const [loadingStats, setLoadingStats] = useState<boolean>(false);
@@ -34,8 +38,12 @@ export default function TicketScanningPage() {
qrCode: string;
ticket: any;
remaining: number;
offlineTicket?: SnapshotTicket; // scanned against the downloaded list (offline mode)
} | 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
@@ -93,6 +101,16 @@ export default function TicketScanningPage() {
})();
}, [selectedEventId, token]);
// In offline mode the event and section are fixed to the locked scope.
useEffect(() => {
if (!offline.lock) return;
setSelectedEventId(offline.lock.eventId);
}, [offline.lock]);
useEffect(() => {
if (!offline.lock) return;
setSelectedSectionId(offline.lock.sectionId || "all");
}, [offline.lock, sections]);
// Bug fix: stop the scanner when the event or section filter changes so stale scan
// context is never used. The user must press "Scan Ticket" again to resume.
const isFirstFilterRender = useRef(true);
@@ -111,6 +129,32 @@ export default function TicketScanningPage() {
return;
}
// Offline mode: check the ticket against the list downloaded to this device.
if (offline.lock) {
const t = await offline.findTicket(text);
if (!t) { setErrorModal("Not found in the offline ticket list. Check at the door desk."); return; }
if (t.remaining <= 0) { setAlreadyUsedModal({ message: "Ticket has already been fully used", ticket: { id: t.id } }); return; }
if (!["not_required", "given"].includes(t.consentStatus)) {
setErrorModal(t.consentStatus === "declined" ? "Parental consent was declined — this ticket can't be checked in." : "Parental consent is missing or pending — this ticket can't be checked in offline. Check at the door desk.");
return;
}
setConfirmQty(t.remaining);
setConfirmQtyRaw(String(t.remaining));
setConfirmModal({
qrCode: text,
remaining: t.remaining,
offlineTicket: t,
ticket: {
id: t.id, quantity: t.quantity, consentStatus: t.consentStatus,
event: { title: offline.lock.eventTitle },
registrationOption: { eventOption: { name: t.option }, variant: t.variant ? { name: t.variant } : null },
attendee: t.bookedBy ? { name: t.holder } : null,
user: { name: t.bookedBy || t.holder },
},
});
return;
}
// Always preview first to validate the ticket before showing confirm step
// Uses the lightweight scan-preview endpoint (only fetches fields needed for the confirm modal)
let preview: any;
@@ -165,11 +209,11 @@ export default function TicketScanningPage() {
}
}
async function commitScan() {
async function commitScan(overrideConsent = false) {
if (!confirmModal || !token) return;
// Capture everything before clearing modal state
const { qrCode, ticket, remaining } = confirmModal;
const { qrCode, ticket, remaining, offlineTicket } = confirmModal;
const qty = confirmQty;
const baseName = ticket?.registrationOption?.eventOption?.name || "Ticket";
const variantName = ticket?.registrationOption?.variant?.name;
@@ -191,6 +235,17 @@ export default function TicketScanningPage() {
total: ticket?.quantity || 1,
});
// Offline mode: saved on this device, synced when there's a connection.
if (offlineTicket) {
try {
await offline.scanLocally(offlineTicket, qty);
} catch (e: any) {
setSuccessModal(null);
setErrorModal(e?.message || "Couldn't save the scan on this device");
}
return;
}
// Background write — reverse the optimistic update on failure
try {
const url = `/api/tickets/scan/${encodeURIComponent(qrCode)}${
@@ -199,7 +254,12 @@ export default function TicketScanningPage() {
await apiFetch<any>(url, {
method: "POST",
authToken: token,
body: { qty },
body: {
qty,
deviceId: offline.deviceId(),
...(selectedSectionId && selectedSectionId !== "all" ? { sectionId: selectedSectionId } : {}),
...(overrideConsent ? { overrideConsent: true } : {}),
},
});
refreshStatsAndRecent();
} catch (e: any) {
@@ -267,8 +327,10 @@ export default function TicketScanningPage() {
className="border rounded px-3 py-2 w-full sm:w-56 max-w-full"
value={selectedEventId}
onChange={(e) => setSelectedEventId(e.target.value)}
disabled={!!offline.lock}
>
<option value="all">All events</option>
{offline.lock && !events.some(ev => ev.id === offline.lock!.eventId) && <option value={offline.lock.eventId}>{offline.lock.eventTitle}</option>}
{events.map((ev) => (
<option key={ev.id} value={ev.id}>{ev.title}</option>
))}
@@ -286,6 +348,7 @@ export default function TicketScanningPage() {
className="border rounded px-3 py-2 w-full sm:w-56 max-w-full"
value={selectedSectionId}
onChange={(e) => setSelectedSectionId(e.target.value)}
disabled={!!offline.lock}
>
<option value="all">All sections</option>
{sections.map(section => (
@@ -296,6 +359,16 @@ export default function TicketScanningPage() {
</div>
)}
<OfflineScanPanel
offline={offline}
token={token}
eventId={selectedEventId}
eventTitle={events.find(ev => ev.id === selectedEventId)?.title || offline.lock?.eventTitle || ""}
sectionId={selectedSectionId !== "all" ? selectedSectionId : null}
sectionName={sections.find(s => s.id === selectedSectionId)?.name || null}
onError={setError}
onInfo={setScanInfo}
/>
<QRScanner
ref={scannerRef}
onResult={handleResult}
@@ -375,11 +448,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&apos;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 +495,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
@@ -249,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) {
@@ -283,6 +317,10 @@ export default function AtTheDoorPage() {
setMinQuantities({});
setPendingUser({ name, email: email || null, phone: phone || null, notifPref });
setPendingEditReg(null);
setDoorCoupon(null);
setDoorNaming(false);
setDoorEntries({});
setDoorDelivery("payer");
setShowNewAttendeeModal(false);
setShowOptionsModal(true);
};
@@ -319,6 +357,7 @@ export default function AtTheDoorPage() {
setMinQuantities(minQtyMap);
setPendingEditReg(freshReg);
setPendingUser(null);
setDoorCoupon(null);
setShowOptionsModal(true);
};
@@ -463,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}
@@ -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
@@ -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 => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" }[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 &amp; 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 &amp; 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&apos;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 &amp; 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&apos;t been used yet.</p>
) : (
<Table>
<TableHeader>
<TableRow><TableHead>Person</TableHead><TableHead>Event</TableHead><TableHead>Discount</TableHead><TableHead>When</TableHead></TableRow>
</TableHeader>
<TableBody>
{viewing.redemptions.map(r => (
<TableRow key={r.id} className={r.releasedAt ? "opacity-60" : ""}>
<TableCell>
<div className="text-sm">{r.user.name}</div>
<div className="text-xs text-gray-500">{r.user.email}</div>
{r.appliedBy && <div className="text-xs text-gray-500">Applied by {r.appliedBy.name}</div>}
</TableCell>
<TableCell className="text-sm">{r.registration.event.title}</TableCell>
<TableCell className="text-sm">
{fmtRand(r.discountAmount)}
{r.releasedAt && <div className="text-xs text-gray-500">Released (cancelled)</div>}
</TableCell>
<TableCell className="text-xs text-gray-600">{fmtDate(r.createdAt)}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</div>
</div>
</div>
)}
</div>
);
}
function CouponModal({ token, events, couponId, initial, onClose, onSaved }: {
token: string;
events: EventLite[];
couponId: string | null;
initial: Draft;
onClose: () => void;
onSaved: (message: string) => void;
}) {
const [d, setD] = useState<Draft>(initial);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const [userQuery, setUserQuery] = useState("");
const [userResults, setUserResults] = useState<{ id: string; name: string; email: string }[]>([]);
const set = <K extends keyof Draft>(key: K, value: Draft[K]) => setD(prev => ({ ...prev, [key]: value }));
// Ticket options only make sense within the chosen events.
const scopedEvents = useMemo(() => events.filter(e => d.eventIds.includes(e.id)), [events, d.eventIds]);
useEffect(() => {
if (d.restrictTo !== "users" || userQuery.trim().length < 2) { setUserResults([]); return; }
const t = setTimeout(async () => {
try {
const res = await apiFetch<{ data: { id: string; name: string; email: string }[] }>(`/api/users?search=${encodeURIComponent(userQuery.trim())}&limit=8`, { authToken: token });
setUserResults(res?.data || []);
} catch { setUserResults([]); }
}, 300);
return () => clearTimeout(t);
}, [userQuery, d.restrictTo, token]);
const toggleEvent = (id: string) => {
const eventIds = d.eventIds.includes(id) ? d.eventIds.filter(x => x !== id) : [...d.eventIds, id];
// Drop scoped options from events that are no longer selected.
const keepOptionIds = new Set(events.filter(e => eventIds.includes(e.id)).flatMap(e => e.eventOptions.map(o => o.id)));
setD(prev => ({ ...prev, eventIds, options: prev.options.filter(o => keepOptionIds.has(o.eventOptionId)) }));
};
const scopeKey = (o: { eventOptionId: string; variantId: string | null }) => `${o.eventOptionId}::${o.variantId || ""}`;
const toggleScope = (eventOptionId: string, variantId: string | null) => {
const key = scopeKey({ eventOptionId, variantId });
set("options", d.options.some(o => scopeKey(o) === key) ? d.options.filter(o => scopeKey(o) !== key) : [...d.options, { eventOptionId, variantId }]);
};
const save = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
if (d.restrictTo === "users" && d.users.length === 0) { setError("Add at least one person, or choose another restriction."); return; }
setSaving(true);
try {
const body = bodyFromDraft(d);
if (couponId) {
await apiFetch(`/api/coupons/${couponId}`, { method: "PUT", authToken: token, body });
onSaved(`${body.code.toUpperCase()} saved.`);
} else {
await apiFetch(`/api/coupons`, { method: "POST", authToken: token, body });
onSaved(`${body.code.toUpperCase()} created.`);
}
} catch (err: any) {
setError(err?.message || "Failed to save");
} finally {
setSaving(false);
}
};
return (
<div className="fixed inset-0 z-50 bg-black/40 flex items-center justify-center p-4" onClick={onClose}>
<form onSubmit={save} className="bg-white rounded-xl shadow-xl w-full max-w-2xl max-h-[90vh] flex flex-col" onClick={e => e.stopPropagation()}>
<div className="flex items-center justify-between p-4 border-b">
<h2 className="font-semibold">{couponId ? "Edit code" : "New coupon code"}</h2>
<button type="button" onClick={onClose} aria-label="Close"><X className="w-5 h-5 text-gray-500" /></button>
</div>
<div className="p-4 space-y-5 overflow-y-auto">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Code</label>
<input className={`${inputCls} font-mono uppercase`} value={d.code} onChange={e => set("code", e.target.value.toUpperCase().replace(/[^A-Z0-9_-]/g, ""))} placeholder="STAFF50" required maxLength={32} />
<p className="text-xs text-gray-500 mt-1">Letters, numbers, - and _. Not case-sensitive when entered.</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Description (internal)</label>
<input className={inputCls} value={d.description} onChange={e => set("description", e.target.value)} placeholder="Staff discount for Camp 2026" />
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Discount type</label>
<select className={inputCls} value={d.type} onChange={e => set("type", e.target.value as CouponType)}>
<option value="percent">Percentage</option>
<option value="fixed">Fixed amount (R)</option>
<option value="comp">Free (100%)</option>
</select>
</div>
{d.type !== "comp" && (
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">{d.type === "percent" ? "Percent off" : "Amount off (R)"}</label>
<input type="number" min="0" step={d.type === "percent" ? "1" : "0.01"} max={d.type === "percent" ? 100 : undefined} className={inputCls} value={d.value} onChange={e => set("value", e.target.value)} required />
</div>
)}
<div className="flex items-end">
<label className="flex items-start gap-2 text-sm text-gray-700">
<input type="checkbox" className="mt-0.5" checked={d.stackEarlyBird} onChange={e => set("stackEarlyBird", e.target.checked)} />
<span>Also apply on top of early-bird prices <span className="block text-xs text-gray-500">Off: the better of the two prices wins.</span></span>
</label>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Events</label>
<p className="text-xs text-gray-500 mb-2">Leave all unticked for every event.</p>
<div className="max-h-40 overflow-y-auto border rounded-lg divide-y">
{events.map(ev => (
<label key={ev.id} className="flex items-center gap-2 px-3 py-2 text-sm">
<input type="checkbox" checked={d.eventIds.includes(ev.id)} onChange={() => toggleEvent(ev.id)} />
<span>{ev.title}</span>
<span className="text-xs text-gray-500 ml-auto">{fmtDate(ev.startDate)}</span>
</label>
))}
{events.length === 0 && <p className="px-3 py-2 text-sm text-gray-500">No events.</p>}
</div>
</div>
{scopedEvents.length > 0 && (
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Only these tickets / items</label>
<p className="text-xs text-gray-500 mb-2">Leave all unticked to discount the whole registration.</p>
<div className="space-y-2">
{scopedEvents.map(ev => (
<div key={ev.id} className="border rounded-lg p-3">
<div className="text-xs font-semibold text-gray-600 mb-1">{ev.title}</div>
{ev.eventOptions.map(o => (
<div key={o.id}>
<label className="flex items-center gap-2 text-sm">
<input type="checkbox" checked={d.options.some(s => scopeKey(s) === scopeKey({ eventOptionId: o.id, variantId: null }))} onChange={() => toggleScope(o.id, null)} />
{o.name}{(o.variants || []).length > 0 && <span className="text-xs text-gray-500">(all variants)</span>}
</label>
{(o.variants || []).map(v => (
<label key={v.id} className="flex items-center gap-2 text-sm ml-6">
<input type="checkbox" checked={d.options.some(s => scopeKey(s) === scopeKey({ eventOptionId: o.id, variantId: v.id }))} onChange={() => toggleScope(o.id, v.id)} />
{v.name}
</label>
))}
</div>
))}
</div>
))}
</div>
</div>
)}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Valid from</label>
<input type="datetime-local" className={inputCls} value={d.validFrom} onChange={e => set("validFrom", e.target.value)} />
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Valid until</label>
<input type="datetime-local" className={inputCls} value={d.validUntil} onChange={e => set("validUntil", e.target.value)} />
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Max total uses</label>
<input type="number" min="1" className={inputCls} value={d.maxUses} onChange={e => set("maxUses", e.target.value)} placeholder="Unlimited" />
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Max uses per person</label>
<input type="number" min="1" className={inputCls} value={d.maxUsesPerUser} onChange={e => set("maxUsesPerUser", e.target.value)} placeholder="Unlimited" />
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Minimum tickets</label>
<input type="number" min="1" className={inputCls} value={d.minQuantity} onChange={e => set("minQuantity", e.target.value)} placeholder="None" />
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Who can use it</label>
<select className={inputCls} value={d.restrictTo} onChange={e => set("restrictTo", e.target.value as Draft["restrictTo"])}>
<option value="anyone">Anyone with the code</option>
<option value="roles">People with certain roles</option>
<option value="users">Specific people</option>
<option value="domain">An email domain</option>
</select>
{d.restrictTo !== "anyone" && (
<p className="text-xs text-gray-500 mt-1">Restricted codes need the person to be logged in (or registered by staff), so they can&apos;t be shared in group chats.</p>
)}
{d.restrictTo === "roles" && (
<div className="flex flex-wrap gap-4 mt-2">
{(["user", "staff", "supervisor", "admin"] as Role[]).map(r => (
<label key={r} className="flex items-center gap-2 text-sm capitalize">
<input type="checkbox" checked={d.restrictRoles.includes(r)} onChange={() => set("restrictRoles", d.restrictRoles.includes(r) ? d.restrictRoles.filter(x => x !== r) : [...d.restrictRoles, r])} />
{r === "user" ? "Member" : r}
</label>
))}
</div>
)}
{d.restrictTo === "domain" && (
<input className={`${inputCls} mt-2`} value={d.restrictDomain} onChange={e => set("restrictDomain", e.target.value)} placeholder="church.org" required />
)}
{d.restrictTo === "users" && (
<div className="mt-2">
<div className="flex flex-wrap gap-2 mb-2">
{d.users.map(u => (
<span key={u.id} className="inline-flex items-center gap-1 text-xs bg-brand-50 text-brand-800 rounded-full px-2 py-1">
{u.name}
<button type="button" onClick={() => set("users", d.users.filter(x => x.id !== u.id))} aria-label={`Remove ${u.name}`}><X className="w-3 h-3" /></button>
</span>
))}
</div>
<input className={inputCls} value={userQuery} onChange={e => setUserQuery(e.target.value)} placeholder="Search by name, email or phone" />
{userResults.length > 0 && (
<div className="border rounded-lg mt-1 divide-y max-h-40 overflow-y-auto">
{userResults.filter(r => !d.users.some(u => u.id === r.id)).map(r => (
<button type="button" key={r.id} className="w-full text-left px-3 py-2 text-sm hover:bg-gray-50" onClick={() => { set("users", [...d.users, r]); setUserQuery(""); }}>
{r.name} <span className="text-xs text-gray-500">{r.email}</span>
</button>
))}
</div>
)}
</div>
)}
</div>
<label className="flex items-center gap-2 text-sm text-gray-700">
<input type="checkbox" checked={d.active} onChange={e => set("active", e.target.checked)} /> Active
</label>
{error && <div className="p-3 border rounded bg-red-50 text-red-800 text-sm">{error}</div>}
</div>
<div className="flex justify-end gap-3 p-4 border-t">
<button type="button" onClick={onClose} className="px-4 py-2 rounded-lg bg-gray-100 text-sm font-medium hover:bg-gray-200">Cancel</button>
<button type="submit" disabled={saving} className="px-4 py-2 rounded-lg bg-brand-600 text-white text-sm font-medium hover:bg-brand-700 disabled:opacity-50">
{saving ? "Saving…" : couponId ? "Save changes" : "Create code"}
</button>
</div>
</form>
</div>
);
}
@@ -50,6 +50,7 @@ type Variant = {
name: string;
price: string; // empty → use option base price
stockLimit: string;
staffPrice?: string; // empty → use the option's staff price
earlyBirdTiers: Tier[];
};
@@ -59,6 +60,7 @@ type OptionDraft = {
price: string;
isMainTicket: boolean;
stockLimit: string;
staffPrice?: string; // empty → no staff price
earlyBirdTiers: Tier[]; // option-level (used only when no variants)
variants: Variant[];
};
@@ -193,6 +195,11 @@ function OptionsEditor({ options, onChange, required }: { options: OptionDraft[]
<input type="number" step="1" min="0" className="border rounded px-2 py-1.5 text-sm w-24"
value={opt.stockLimit} onChange={e => upd(i, { stockLimit: e.target.value })} />
</div>
<div>
<label className="block text-[10px] text-gray-500 mb-0.5" title="Staff and above pay this automatically when it's lower than the price they'd otherwise pay">Staff price (R)</label>
<input type="number" step="1" min="0" className="border rounded px-2 py-1.5 text-sm w-24" placeholder="(none)"
value={opt.staffPrice ?? ""} onChange={e => upd(i, { staffPrice: e.target.value })} />
</div>
<div className="flex items-center gap-3 pb-1">
<label className="text-xs flex items-center gap-1 cursor-pointer whitespace-nowrap">
<input type="checkbox" checked={opt.isMainTicket} onChange={e => upd(i, { isMainTicket: e.target.checked })} /> Main ticket
@@ -253,6 +260,11 @@ function VariantsEditor({ options, onChange }: { options: OptionDraft[]; onChang
<input type="number" step="1" min="0" className="border rounded px-2 py-1 text-xs w-20"
value={v.stockLimit} onChange={e => updVariant(vi, { stockLimit: e.target.value })} />
</div>
<div>
<label className="block text-[10px] text-gray-500 mb-0.5">Staff price</label>
<input type="number" step="1" min="0" className="border rounded px-2 py-1 text-xs w-20" placeholder="(option)"
value={v.staffPrice ?? ""} onChange={e => updVariant(vi, { staffPrice: e.target.value })} />
</div>
<button type="button" className="text-xs px-2 py-1 rounded bg-red-50 text-red-700 border border-red-200 hover:bg-red-100 self-end" onClick={() => remVariant(vi)}>✕</button>
</div>
))}
@@ -757,7 +769,10 @@ interface EventDraft {
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;
location: string; allowNamedAttendees: boolean;
requiresParentalConsent: boolean; consentText: string;
consentCollectMedical: boolean; consentCollectEmergency: boolean; consentCollectPhoto: boolean;
waitlistEnabled: boolean; waitlistOfferHours: string; waitlistPartialOffers: boolean;
}
const blankDraft = (location = ""): EventDraft => ({
@@ -765,7 +780,10 @@ const blankDraft = (location = ""): EventDraft => ({
registrationDeadline: "", goLiveAt: "", price: "", picture: "",
redirectUrl: "", isActive: true, isHidden: false, requiresAuth: true,
requiresRegistration: true, contactName: "", contactPhone: "", contactEmail: "",
location,
location, allowNamedAttendees: false,
requiresParentalConsent: false, consentText: "",
consentCollectMedical: true, consentCollectEmergency: true, consentCollectPhoto: true,
waitlistEnabled: false, waitlistOfferHours: "24", waitlistPartialOffers: true,
});
const blankOptions = (): OptionDraft[] => [
@@ -800,6 +818,10 @@ function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) {
price: String(ev.price ?? ""), picture: ev.picture || "", redirectUrl: ev.redirectUrl || "",
isActive: ev.isActive !== false, isHidden: !!ev.isHidden, requiresAuth: ev.requiresAuth !== false,
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 || ""));
@@ -815,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)
@@ -823,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)
@@ -908,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
@@ -946,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}`, {
@@ -984,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;
@@ -992,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;
}
@@ -1069,6 +1095,15 @@ function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) {
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,
@@ -1323,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 })} />
@@ -1330,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&apos;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 &quot;Re-request consent&quot; 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>
)}
@@ -6,6 +6,8 @@ import { useRouter } from "next/navigation";
import { apiFetch, fetchAllUsers } from "@/lib/api";
import { scoreUser } from "@/lib/fuzzyMatch";
import { useDismissingState } from "@/hooks/useDismissingState";
import { CouponCodeField, AppliedCoupon } from "@/components/shared/CouponCodeField";
import { AttendeeNamesEditor, AttendeeEntries, TicketDelivery, attendeesPayload, consentDetailsMissing } from "@/components/shared/AttendeeNamesEditor";
import { UserPlus, CreditCard } from "lucide-react";
// ─── Pricing helpers ─────────────────────────────────────────────────────────
@@ -215,7 +217,20 @@ export default function ManualRegistrationPage() {
.map(x => x.u);
}, [userQuery, allUsers]);
// Discount code, checked against the picked account (or a walk-in when none is picked).
const [coupon, setCoupon] = useState<AppliedCoupon | null>(null);
// Named attendees (group tickets), when the event takes them.
const [namingOthers, setNamingOthers] = useState(false);
const [attendeeEntries, setAttendeeEntries] = useState<AttendeeEntries>({});
const [delivery, setDelivery] = useState<TicketDelivery>("payer");
const manualQtyFor = (optionId: string, variantId: string | null) => quantities[variantId ? `${optionId}::${variantId}` : optionId] || 0;
const selectedEv = events.find(ev => ev.id === selectedEventId);
const selectedIsYouth = !!selectedEv?.requiresParentalConsent;
const selectedEventAllowsNames = !!selectedEv?.allowNamedAttendees || selectedIsYouth;
const [pickedUserId, setPickedUserId] = useState<string | null>(null);
const selectUser = (u: any) => {
setPickedUserId(u.id || null);
setGuest({ name: u.name || "", email: u.email || "", phoneNumber: u.phoneNumber || "" });
setUserQuery(u.name || "");
setDropdownOpen(false);
@@ -294,6 +309,10 @@ export default function ManualRegistrationPage() {
return { eventOptionId, quantity, ...(variantId ? { variantId } : {}) };
});
if (opts.length === 0) { setError("Please select at least one ticket option."); return; }
if (selectedIsYouth) {
const missing = consentDetailsMissing(options, manualQtyFor, attendeeEntries, selectedEv.startDate);
if (missing) { setError(missing); return; }
}
try {
setSubmitting(true);
@@ -308,9 +327,15 @@ export default function ManualRegistrationPage() {
options: opts,
user: guest,
notificationPreference: resolvedPref,
...(coupon ? { couponCode: coupon.code } : {}),
...(selectedEventAllowsNames && (namingOthers || selectedIsYouth) && attendeesPayload(options, manualQtyFor, attendeeEntries).length > 0
? { attendees: attendeesPayload(options, manualQtyFor, attendeeEntries), ticketDelivery: delivery }
: {}),
}
});
setMessage("Manual registration created successfully.");
setMessage(res?.couponError
? `Manual registration created, but the code wasn't applied: ${res.couponError}`
: coupon ? `Manual registration created with code ${coupon.code}.` : "Manual registration created successfully.");
// Pre-load the Record Payment tab with this registration so it's ready to go the
// moment a supervisor clicks over — the tab itself is never switched to automatically.
@@ -325,6 +350,11 @@ export default function ManualRegistrationPage() {
setNotifPref("email");
setUserQuery("");
setDropdownOpen(false);
setCoupon(null);
setPickedUserId(null);
setNamingOthers(false);
setAttendeeEntries({});
setDelivery("payer");
setQuantities(prev => Object.fromEntries(Object.keys(prev).map(k => [k, 0])));
} catch (e: any) {
setError(e?.message || "Failed to create manual registration");
@@ -504,7 +534,7 @@ export default function ManualRegistrationPage() {
{guest.name && (
<button
type="button"
onClick={() => { setGuest({ name: "", email: "", phoneNumber: "" }); setUserQuery(""); setNotifPref("email"); }}
onClick={() => { setGuest({ name: "", email: "", phoneNumber: "" }); setUserQuery(""); setNotifPref("email"); setPickedUserId(null); }}
className="text-xs text-gray-400 hover:text-gray-600 text-left"
>
✕ Clear
@@ -581,8 +611,44 @@ export default function ManualRegistrationPage() {
)}
</div>
{selectedEventAllowsNames && (
<div className="mt-6">
<AttendeeNamesEditor
options={options}
qtyFor={manualQtyFor}
enabled={namingOthers}
onEnabledChange={setNamingOthers}
entries={attendeeEntries}
onEntriesChange={setAttendeeEntries}
delivery={delivery}
onDeliveryChange={setDelivery}
payerLabel="the person registering"
consentEventStart={selectedIsYouth ? selectedEv.startDate : undefined}
/>
</div>
)}
{selectedEventId && totalDue > 0 && (
<div className="mt-6 max-w-md">
<CouponCodeField
eventId={selectedEventId}
basket={Object.entries(quantities).map(([key, quantity]) => {
const [eventOptionId, variantId] = key.split("::");
return { eventOptionId, quantity, variantId: variantId || null };
})}
token={token}
forUserId={pickedUserId}
applied={coupon}
onChange={setCoupon}
/>
</div>
)}
<div className="flex items-center justify-between mt-6">
<div className="text-sm">Total due: <span className="font-semibold">R {totalDue.toFixed(2)}</span></div>
<div className="text-sm">
Total due: <span className="font-semibold">R {(coupon ? coupon.total : totalDue).toFixed(2)}</span>
{coupon && coupon.discount > 0 && <span className="ml-2 text-xs text-gray-500 line-through">R {totalDue.toFixed(2)}</span>}
</div>
<button disabled={submitting} onClick={submit} className="px-4 py-2 text-sm rounded bg-brand-600 text-white hover:bg-brand-700 disabled:opacity-50 shadow-sm">{submitting ? 'Creating…' : 'Create registration'}</button>
</div>
</>
@@ -0,0 +1,163 @@
"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 { formatDate } from "@/lib/date";
import { useDismissingState } from "@/hooks/useDismissingState";
import { WifiOff, Unlock, CheckCircle2 } from "lucide-react";
type Lock = { id: string; sectionName: string | null; deviceName: string | null; user: { name: string }; lockedAt: string; snapshotAt: string | null; lastSyncAt: string | null };
type Report = {
id: string; accepted: number; duplicates: number; afterForceRelease: boolean; createdAt: string; reviewedAt: string | null;
reviewedBy: { name: string } | null;
conflicts: { clientScanId: string; qrCode: string; qty: number; redeemed: number; scannedAt: string; reason: string; message: string }[];
scanLock: Lock;
};
const when = (iso: string | null) => (iso ? new Date(iso).toLocaleString() : "—");
export default function OfflineScansPage() {
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 [events, setEvents] = useState<any[]>([]);
const [eventId, setEventId] = useState("");
const [locks, setLocks] = useState<Lock[]>([]);
const [reports, setReports] = useState<Report[]>([]);
const [onlyOpen, setOnlyOpen] = useState(true);
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 recent = (evs || []).filter(e => new Date(e.endDate).getTime() > Date.now() - 14 * 864e5);
setEvents(recent);
if (recent.length && !eventId) setEventId(recent[0].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;
try {
const [l, r] = await Promise.all([
apiFetch<Lock[]>(`/api/scan-locks?eventId=${encodeURIComponent(eventId)}`, { authToken: token }),
apiFetch<Report[]>(`/api/scan-locks/reports?eventId=${encodeURIComponent(eventId)}${onlyOpen ? "&unreviewed=true" : ""}`, { authToken: token }),
]);
setLocks(l);
setReports(r.filter(x => !onlyOpen || x.conflicts.length > 0 || x.afterForceRelease));
} catch (e: any) {
setError(e?.message || "Failed to load offline scanning");
}
}, [token, eventId, onlyOpen, setError]);
useEffect(() => { load(); }, [load]);
const forceRelease = async (l: Lock) => {
try {
const res = await apiFetch<{ message: string }>(`/api/scan-locks/${l.id}/force-release`, { method: "POST", authToken: token! });
setInfo(res.message);
load();
} catch (e: any) { setError(e?.message || "Failed"); }
};
const review = async (r: Report) => {
try {
await apiFetch(`/api/scan-locks/reports/${r.id}/review`, { method: "POST", authToken: token! });
load();
} catch (e: any) { setError(e?.message || "Failed"); }
};
return (
<div className="max-w-5xl 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">
<WifiOff 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">Offline scanning</h1>
<p className="text-sm text-gray-500">Devices scanning without a connection, and their synced scans</p>
</div>
</div>
{!canView && <div className="p-3 border rounded bg-yellow-50 text-yellow-800 text-sm mb-4">You need supervisor access to manage offline scanning.</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="space-y-4">
<div className="flex flex-wrap items-end gap-3">
<div>
<label className="block text-xs text-gray-600 mb-1">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.map(ev => <option key={ev.id} value={ev.id}>{ev.title} ({formatDate(new Date(ev.startDate))})</option>)}
</select>
</div>
<label className="flex items-center gap-2 text-sm text-gray-700 pb-2">
<input type="checkbox" checked={onlyOpen} onChange={e => setOnlyOpen(e.target.checked)} /> Only reports that need review
</label>
</div>
<section className="border rounded-xl p-4 bg-white shadow-sm">
<h2 className="font-semibold mb-2">Locked sections</h2>
{locks.length === 0 ? <p className="text-sm text-gray-500">No device is scanning offline for this event.</p> : (
<ul className="divide-y">
{locks.map(l => (
<li key={l.id} className="py-2 flex items-center justify-between gap-3 text-sm">
<div>
<div className="font-medium">{l.sectionName || "Whole event"} — {l.user.name}{l.deviceName ? ` on ${l.deviceName}` : ""}</div>
<div className="text-xs text-gray-500">Locked {when(l.lockedAt)} · list downloaded {when(l.snapshotAt)} · last sync {when(l.lastSyncAt)}</div>
</div>
<button onClick={() => forceRelease(l)} className="inline-flex items-center gap-1 px-2.5 py-1 text-xs rounded border hover:bg-gray-50 shrink-0" title="For a lost or flat device. If it reconnects later, its scans still sync and are flagged here.">
<Unlock className="w-3.5 h-3.5" /> Force release
</button>
</li>
))}
</ul>
)}
</section>
<section className="border rounded-xl p-4 bg-white shadow-sm">
<h2 className="font-semibold mb-2">Sync reports</h2>
{reports.length === 0 ? <p className="text-sm text-gray-500">{onlyOpen ? "Nothing needs review." : "No offline scans have been synced for this event."}</p> : (
<div className="space-y-3">
{reports.map(r => (
<div key={r.id} className={`border rounded-lg p-3 ${r.reviewedAt ? "opacity-60" : ""}`}>
<div className="flex items-center justify-between gap-3 text-sm">
<div>
<span className="font-medium">{when(r.createdAt)}</span> — {r.scanLock.user?.name}{r.scanLock.deviceName ? ` on ${r.scanLock.deviceName}` : ""}{r.scanLock.sectionName ? `, ${r.scanLock.sectionName}` : ""}
<div className="text-xs text-gray-500">{r.accepted} scan(s) recorded{r.duplicates ? ` · ${r.duplicates} already synced` : ""}{r.afterForceRelease ? " · synced after the lock was force-released" : ""}</div>
</div>
{r.reviewedAt
? <span className="text-xs text-gray-500 inline-flex items-center gap-1"><CheckCircle2 className="w-3.5 h-3.5" /> Reviewed by {r.reviewedBy?.name}</span>
: <button onClick={() => review(r)} className="px-2.5 py-1 text-xs rounded bg-brand-600 text-white hover:bg-brand-700 shrink-0">Mark reviewed</button>}
</div>
{r.conflicts.length > 0 && (
<ul className="mt-2 text-xs space-y-1">
{r.conflicts.map(c => (
<li key={c.clientScanId} className="text-red-800">
{new Date(c.scannedAt).toLocaleTimeString()} · ticket {String(c.qrCode).slice(0, 8)}… · {c.message}{c.redeemed ? ` (recorded ${c.redeemed} of ${c.qty})` : " (not recorded)"}
</li>
))}
</ul>
)}
</div>
))}
</div>
)}
</section>
</div>
)}
</div>
);
}
@@ -8,7 +8,7 @@ import { useStableState } from "@/hooks/useStableState";
import { useVisiblePolling } from "@/hooks/useVisiblePolling";
import {
Calendar, Banknote, Gift, Users, Ticket, QrCode, DoorOpen, Wallet,
UserPlus, FileText, MessageCircle, BarChart2, Mail,
UserPlus, FileText, MessageCircle, BarChart2, Mail, TicketPercent, ShieldCheck, ListChecks, WifiOff,
} from "lucide-react";
import { StatCard, StatCardRow } from "@/components/shared/StatCard";
import { QuickActionTile, QuickActionGrid } from "@/components/shared/QuickActionTile";
@@ -24,6 +24,10 @@ const QUICK_ACTIONS = [
{ href: "/dashboard/supervisor/manual", label: "Manual registration", description: "Register a guest and issue tickets", icon: UserPlus },
{ href: "/dashboard/supervisor/events", label: "Manage events", description: "Create, edit, and update ticket types", icon: Calendar },
{ href: "/dashboard/supervisor/payments", label: "Payments & donations", description: "Manual payments and assignment", icon: Wallet },
{ href: "/dashboard/supervisor/offline-scans", label: "Offline scanning", description: "Devices scanning offline and their synced scans", icon: WifiOff },
{ 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/at-the-door", label: "At the door", description: "Walk-ins, payments, ticket printing", icon: DoorOpen },
{ href: "/dashboard/supervisor/reports", label: "Reports", description: "View, export, and email reports", icon: BarChart2 },
@@ -0,0 +1,190 @@
"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 { ListChecks, ArrowUp, ArrowDown } from "lucide-react";
import { Table, TableHeader, TableBody, TableRow, TableHead, TableCell } from "@/components/ui/table";
type Entry = {
id: string;
position: number;
quantity: number;
offeredQuantity: number | null;
offerExpiresAt: string | null;
status: "waiting" | "offered" | "claimed" | "expired" | "removed" | "left";
createdAt: string;
eventOption: { name: string };
variant: { name: string } | null;
user: { name: string; email: string; phoneNumber: string | null };
};
const STATUS: Record<Entry["status"], { label: string; cls: string }> = {
waiting: { label: "Waiting", cls: "bg-gray-100 text-gray-700" },
offered: { label: "Offered", cls: "bg-amber-100 text-amber-800" },
claimed: { label: "Claimed", cls: "bg-green-100 text-green-800" },
expired: { label: "Offer expired", cls: "bg-red-100 text-red-800" },
removed: { label: "Removed", cls: "bg-gray-100 text-gray-500" },
left: { label: "Left", cls: "bg-gray-100 text-gray-500" },
};
export default function WaitlistPage() {
const { user, loading, token } = useAuth();
const router = useRouter();
const search = useSearchParams();
const canView = useMemo(() => ["admin", "supervisor", "staff"].includes(user?.role || ""), [user]);
const canManage = 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(search.get("eventId") || "");
const [entries, setEntries] = useState<Entry[]>([]);
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", { authToken: token })
.then(evs => {
const withList = (evs || []).filter(e => e.waitlistEnabled);
setEvents(withList);
if (!eventId && withList.length) setEventId(withList[0].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 {
setEntries(await apiFetch<Entry[]>(`/api/waitlist/events/${encodeURIComponent(eventId)}`, { authToken: token }));
} catch (e: any) {
setError(e?.message || "Failed to load the waitlist");
} finally {
setBusy(false);
}
}, [token, eventId, setError]);
useEffect(() => { load(); }, [load]);
const act = async (path: string, body?: any) => {
setBusy(true);
try {
const res = await apiFetch<{ message?: string }>(path, { method: "POST", authToken: token!, body });
if (res?.message) setInfo(res.message);
await load();
} catch (e: any) {
setError(e?.message || "Something went wrong");
} finally {
setBusy(false);
}
};
const waitingCount = entries.filter(e => e.status === "waiting").length;
const selected = events.find(e => e.id === eventId);
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">
<ListChecks 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">Waitlist</h1>
<p className="text-sm text-gray-500">People waiting for sold-out tickets</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 the waitlist.</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">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 events with a waitlist</option>}
{events.map(ev => <option key={ev.id} value={ev.id}>{ev.title} ({formatDate(new Date(ev.startDate))})</option>)}
</select>
</div>
{selected && (
<p className="text-xs text-gray-500">
{waitingCount} waiting · offers are held for {selected.waitlistOfferHours} hours · {selected.waitlistPartialOffers ? "partial offers on" : "only whole requests offered"}.
Turn the waitlist on or off in the event&apos;s Visibility step.
</p>
)}
</div>
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead>#</TableHead>
<TableHead>Person</TableHead>
<TableHead>Ticket</TableHead>
<TableHead>Qty</TableHead>
<TableHead>Joined</TableHead>
<TableHead>Status</TableHead>
{canManage && <TableHead className="text-right">Actions</TableHead>}
</TableRow>
</TableHeader>
<TableBody>
{entries.map((e, i) => {
const st = STATUS[e.status];
const active = e.status === "waiting" || e.status === "offered";
return (
<TableRow key={e.id} className={active ? "" : "opacity-60"}>
<TableCell className="text-sm text-gray-500">{i + 1}</TableCell>
<TableCell>
<div className="text-sm font-medium">{e.user.name}</div>
<div className="text-xs text-gray-500">{[e.user.email, e.user.phoneNumber].filter(Boolean).join(" · ")}</div>
</TableCell>
<TableCell className="text-sm">{e.eventOption.name}{e.variant ? ` (${e.variant.name})` : ""}</TableCell>
<TableCell className="text-sm">{e.quantity}{e.status === "offered" && e.offeredQuantity !== e.quantity ? ` (${e.offeredQuantity} offered)` : ""}</TableCell>
<TableCell className="text-xs text-gray-600">{formatDate(new Date(e.createdAt))}</TableCell>
<TableCell>
<span className={`text-xs px-2 py-0.5 rounded-full font-medium ${st.cls}`}>{st.label}</span>
{e.status === "offered" && e.offerExpiresAt && <div className="text-xs text-gray-500 mt-1">until {new Date(e.offerExpiresAt).toLocaleString()}</div>}
</TableCell>
{canManage && (
<TableCell className="text-right whitespace-nowrap">
{e.status === "waiting" && (
<>
<button disabled={busy} onClick={() => act(`/api/waitlist/${e.id}/move`, { direction: "up" })} className="p-1 rounded border hover:bg-gray-50 mr-1" aria-label="Move up"><ArrowUp className="w-3.5 h-3.5" /></button>
<button disabled={busy} onClick={() => act(`/api/waitlist/${e.id}/move`, { direction: "down" })} className="p-1 rounded border hover:bg-gray-50 mr-1" aria-label="Move down"><ArrowDown className="w-3.5 h-3.5" /></button>
</>
)}
{(e.status === "waiting" || e.status === "expired") && (
<button disabled={busy} onClick={() => act(`/api/waitlist/${e.id}/offer`)} className="px-2 py-1 text-xs rounded bg-brand-600 text-white hover:bg-brand-700 mr-1" title="Offer now, even if no place has freed up">Offer place</button>
)}
{active && (
<button disabled={busy} onClick={() => act(`/api/waitlist/${e.id}/remove`)} className="px-2 py-1 text-xs rounded border hover:bg-gray-50">Remove</button>
)}
</TableCell>
)}
</TableRow>
);
})}
{!busy && entries.length === 0 && (
<TableRow><TableCell colSpan={7} className="text-center text-sm text-gray-500 py-8">{eventId ? "Nobody is on the waitlist." : "Pick an event."}</TableCell></TableRow>
)}
</TableBody>
</Table>
</div>
<p className="text-xs text-gray-500 mt-3">When places free up (a cancellation, a smaller booking, or a higher stock limit), the next people are offered them automatically. &quot;Offer place&quot; sends an offer straight away, even if it means going over the stock limit.</p>
</div>
)}
</div>
);
}
+94 -2
View File
@@ -8,6 +8,8 @@ import { formatPaymentMethod } from "@/lib/paymentMethod";
import { QrImage } from "@/components/shared/QrImage";
import { ApiImage } from "@/components/shared/ApiImage";
import { useDismissingState } from "@/hooks/useDismissingState";
import { AttendeesPanel } from "@/components/shared/AttendeesPanel";
import { MyWaitlists } from "@/components/shared/MyWaitlists";
import { ClipboardList, Calendar, Ticket, ChevronRight } from "lucide-react";
// Helper formatters
@@ -381,7 +383,7 @@ export default function UserDashboardPage() {
${dateStr ? `<div class="date">${dateStr}</div>` : ''}
</div>
<div class="type">${type}</div>
<div class="qty">Qty: <strong>${qty}</strong></div>
${t.attendee?.name ? `<div class="qty">Name: <strong>${t.attendee.name}</strong></div>` : `<div class="qty">Qty: <strong>${qty}</strong></div>`}
<div class="qrwrap"><img src="${qrSrc}" alt="QR" /></div>
<div class="meta">Ticket ID: ${t.id}</div>
${buyerName ? `<div class="meta">Purchased by: ${buyerName}</div>` : ''}
@@ -497,6 +499,52 @@ export default function UserDashboardPage() {
return false;
}, [activeReg, activeEventHasForm]);
// Discount code on an existing registration
const [codeInput, setCodeInput] = useState("");
const [codeBusy, setCodeBusy] = useState(false);
const [codeError, setCodeError] = useState<string | null>(null);
const applyCodeToActive = async () => {
if (!token || !activeRegId || !codeInput.trim()) return;
setCodeBusy(true);
setCodeError(null);
try {
const res = await apiFetch<{ message?: string }>(`/api/registrations/${encodeURIComponent(activeRegId)}/coupon`, {
method: 'POST',
authToken: token,
body: { code: codeInput.trim() },
});
const myRegs = await apiFetch<any[]>(`/api/registrations/myregistrations?showPast=${showPast ? '1' : '0'}`, { authToken: token });
setRegistrations(myRegs);
const updated = myRegs.find(r => r.id === activeRegId);
const pays = await apiFetch<any[]>(`/api/payments/registration/${encodeURIComponent(activeRegId)}`, { authToken: token });
const totalPaid = pays.reduce((s, p) => s + (p.amount || 0), 0);
const totalDue = updated?.totalDueComputed ?? 0;
setBilling(prev => ({ ...prev, [activeRegId]: { totalDue, totalPaid, outstanding: Math.max(0, totalDue - totalPaid), payments: pays } }));
try {
const myTicks = await apiFetch<any[]>("/api/tickets/mytickets", { authToken: token });
setTickets(myTicks);
} catch {}
setCodeInput("");
setInfo(res?.message || 'Code applied.');
} catch (e: any) {
setCodeError(e?.message || "Couldn't apply that code");
} finally {
setCodeBusy(false);
}
};
// Re-fetch registrations and tickets after something on one changes (attendee names).
const reloadAfterAttendees = async () => {
if (!token) return;
try {
const myRegs = await apiFetch<any[]>(`/api/registrations/myregistrations?showPast=${showPast ? '1' : '0'}`, { authToken: token });
setRegistrations(myRegs);
const myTicks = await apiFetch<any[]>("/api/tickets/mytickets", { authToken: token });
setTickets(myTicks);
} catch {}
};
// Cancel registration state
const [cancelLoading, setCancelLoading] = useState(false);
const [showCancelConfirm, setShowCancelConfirm] = useState(false);
@@ -824,6 +872,12 @@ export default function UserDashboardPage() {
)}
</section>
{token && (
<div className="mb-6">
<MyWaitlists token={token} onMessage={setInfo} />
</div>
)}
<div className="grid lg:grid-cols-2 gap-6">
<section className="mb-8 border rounded-xl p-5 bg-white shadow-sm">
<div className="flex items-center gap-2 mb-3">
@@ -936,8 +990,11 @@ export default function UserDashboardPage() {
</div>
<div className="text-sm text-gray-700">{type}</div>
<div className="text-xs font-medium text-blue-700 bg-blue-50 rounded px-2 py-0.5 inline-block">
Qty: {t.quantity || 1}
{t.attendee?.name ? <>For: {t.attendee.name}</> : <>Qty: {t.quantity || 1}</>}
</div>
{t.userId && user?.id && t.userId !== user.id && t.user?.name && (
<div className="text-xs text-gray-500">Booked by {t.user.name}</div>
)}
<div className="flex justify-center"><QrImage value={t.qrCode || t.id} size={120} className="w-28 h-28" /></div>
<div className="text-center text-xs text-gray-500">#{t.id.slice(0, 8)}</div>
<div className="flex gap-2 pt-1 flex-wrap">
@@ -1034,10 +1091,35 @@ export default function UserDashboardPage() {
</ul>
</div>
);
const activeCoupon = (activeReg.couponRedemptions || [])[0];
return (
<div>
{renderGroup('Early bird', earlyBirdRows)}
{renderGroup('Standard price', standardRows)}
{Number(activeReg.discountTotal || 0) > 0 && (
<div className="text-sm text-green-700 mt-2">
Discount code{activeCoupon?.coupon?.code ? <> <span className="font-mono font-semibold">{activeCoupon.coupon.code}</span></> : null} — −{formatRand(Number(activeReg.discountTotal))}
</div>
)}
{canModifyActive && activeBill && activeBill.outstanding > 0 && !activeCoupon && (
<div className="mt-3">
<div className="flex gap-2">
<input
value={codeInput}
onChange={e => { setCodeInput(e.target.value.toUpperCase()); setCodeError(null); }}
placeholder="Have a discount code?"
className="flex-1 min-w-0 border rounded px-2 py-1 text-sm font-mono uppercase"
aria-label="Discount code"
/>
<button
onClick={applyCodeToActive}
disabled={codeBusy || !codeInput.trim()}
className="px-3 py-1 text-sm rounded border border-brand-600 text-brand-700 hover:bg-brand-50 disabled:opacity-50"
>{codeBusy ? 'Applying…' : 'Apply'}</button>
</div>
{codeError && <p className="text-xs text-red-600 mt-1">{codeError}</p>}
</div>
)}
</div>
);
})() : (
@@ -1142,6 +1224,16 @@ export default function UserDashboardPage() {
</div>
)}
</div>
{token && (
<AttendeesPanel
registration={activeReg}
token={token}
canEdit={canModifyActive}
hasForm={!!(activeReg.event?.form?.fields || []).length}
onSaved={reloadAfterAttendees}
onMessage={setInfo}
/>
)}
{activeBill && activeBill.payments.length > 0 && (
<div>
<div className="font-medium mb-1">Payments</div>
@@ -6,16 +6,21 @@ import { useRouter } from "next/navigation";
import { apiFetch } from "@/lib/api";
import { isValidZAPhone } from "@/lib/phone";
import { useDismissingState } from "@/hooks/useDismissingState";
import { User, Lock, ShieldAlert, Trash2, Clock, LogIn, KeyRound, RotateCcw } from "lucide-react";
import { TwoFactorChallenge, TwoFactorMethod } from "@/lib/auth";
import { User, Lock, ShieldAlert, ShieldCheck, ShieldOff, Trash2, Clock, LogIn, KeyRound, RotateCcw } from "lucide-react";
type ActivityEvent = { id: string; type: "login" | "password_changed" | "password_reset"; device: string | null; createdAt: string };
type ActivityEvent = { id: string; type: "login" | "password_changed" | "password_reset" | "two_factor_enabled" | "two_factor_disabled"; device: string | null; createdAt: string };
const ACTIVITY_META: Record<ActivityEvent["type"], { label: (device: string | null) => string; icon: typeof LogIn }> = {
login: { label: d => `Logged in${d ? ` from ${d}` : ""}`, icon: LogIn },
password_changed: { label: () => "Password changed", icon: KeyRound },
password_reset: { label: () => "Password reset via email link", icon: RotateCcw },
two_factor_enabled: { label: () => "Two-factor authentication turned on", icon: ShieldCheck },
two_factor_disabled: { label: () => "Two-factor authentication turned off", icon: ShieldOff },
};
const TWO_FACTOR_METHOD_LABEL: Record<TwoFactorMethod, string> = { email: "email", whatsapp: "WhatsApp" };
export default function UserProfilePage() {
const { user, token, logout, updateToken } = useAuth();
const router = useRouter();
@@ -152,6 +157,105 @@ export default function UserProfilePage() {
useEffect(() => { loadActivity(); }, [token]);
// ── Two-factor authentication ─────────────────────────────────────────────
// Mirrors the saved account state locally: the auth context's `user` is only
// loaded once per page load, so it wouldn't reflect a change made here.
const [tfEnabled, setTfEnabled] = useState(false);
const [tfMethod, setTfMethod] = useState<TwoFactorMethod>("email");
const [tfStep, setTfStep] = useState<"idle" | "code" | "disable" | "change">("idle");
const [tfPassword, setTfPassword] = useState("");
const [tfCode, setTfCode] = useState("");
const [tfChallenge, setTfChallenge] = useState<TwoFactorChallenge | null>(null);
const [tfMsg, setTfMsg] = useDismissingState<{ type: "ok" | "err"; text: string } | null>(null);
const [tfBusy, setTfBusy] = useState(false);
// WhatsApp codes need the number that's actually saved, not one still being typed.
const canWhatsAppCodes = isValidZAPhone(user?.phoneNumber || "");
// Admins and supervisors must keep 2FA on; they can only change the channel.
const tfRequired = !!user?.twoFactorRequired;
useEffect(() => {
if (user) {
setTfEnabled(!!user.twoFactorEnabled);
setTfMethod(user.twoFactorMethod || "email");
}
}, [user]);
const resetTwoFactorForm = () => {
setTfStep("idle");
setTfPassword("");
setTfCode("");
setTfChallenge(null);
};
const startEnableTwoFactor = async (e: React.FormEvent) => {
e.preventDefault();
if (!token) return;
setTfBusy(true);
setTfMsg(null);
try {
const res = await apiFetch<TwoFactorChallenge>("/api/users/2fa/enable", {
method: "POST",
authToken: token,
body: { method: tfMethod, password: tfPassword },
});
setTfChallenge(res);
setTfCode("");
setTfPassword("");
setTfStep("code");
} catch (e: any) {
setTfMsg({ type: "err", text: e?.message || "Failed to send a verification code." });
} finally {
setTfBusy(false);
}
};
const confirmEnableTwoFactor = async (e: React.FormEvent) => {
e.preventDefault();
if (!token || !tfChallenge) return;
setTfBusy(true);
setTfMsg(null);
try {
const res = await apiFetch<{ twoFactorMethod: TwoFactorMethod }>("/api/users/2fa/confirm", {
method: "POST",
authToken: token,
body: { challengeId: tfChallenge.challengeId, code: tfCode },
});
const wasEnabled = tfEnabled;
setTfEnabled(true);
setTfMethod(res.twoFactorMethod);
resetTwoFactorForm();
setTfMsg({ type: "ok", text: wasEnabled ? `Codes will now be sent by ${TWO_FACTOR_METHOD_LABEL[res.twoFactorMethod]}.` : "Two-factor authentication is on." });
loadActivity();
} catch (e: any) {
setTfMsg({ type: "err", text: e?.message || "Failed to verify the code." });
} finally {
setTfBusy(false);
}
};
const disableTwoFactor = async (e: React.FormEvent) => {
e.preventDefault();
if (!token) return;
setTfBusy(true);
setTfMsg(null);
try {
await apiFetch("/api/users/2fa/disable", {
method: "POST",
authToken: token,
body: { password: tfPassword },
});
setTfEnabled(false);
resetTwoFactorForm();
setTfMsg({ type: "ok", text: "Two-factor authentication is off." });
loadActivity();
} catch (e: any) {
setTfMsg({ type: "err", text: e?.message || "Failed to turn off two-factor authentication." });
} finally {
setTfBusy(false);
}
};
// ── Account closure ───────────────────────────────────────────────────────
const [closeStep, setCloseStep] = useState<"idle" | "confirm">("idle");
const [deleteData, setDeleteData] = useState(false);
@@ -299,6 +403,113 @@ export default function UserProfilePage() {
</form>
</section>
{/* ── Two-factor authentication ────────────────────────────────── */}
<section className="border rounded-xl p-5 bg-white shadow-sm">
<SectionHeader icon={ShieldCheck} title="Two-factor authentication" />
{tfStep === "code" && tfChallenge ? (
<form onSubmit={confirmEnableTwoFactor} className="space-y-4">
<p className="text-sm text-gray-500">
We sent a 6-digit code to {tfChallenge.destination} by {TWO_FACTOR_METHOD_LABEL[tfChallenge.method]}. {tfEnabled ? "Enter it to switch your codes to this channel." : "Enter it to finish turning on two-factor authentication."}
</p>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Verification code</label>
<input
type="text"
inputMode="numeric"
autoComplete="one-time-code"
maxLength={6}
className={`${inputCls} tracking-widest`}
value={tfCode}
onChange={e => setTfCode(e.target.value.replace(/\D/g, ""))}
required
/>
</div>
<div className="flex gap-3">
<button type="button" onClick={resetTwoFactorForm} className="px-4 py-2 rounded-lg bg-gray-100 text-sm font-medium hover:bg-gray-200">
Cancel
</button>
<button type="submit" disabled={tfBusy || tfCode.length !== 6} className="px-4 py-2 rounded-lg bg-brand-600 text-white text-sm font-medium hover:bg-brand-700 disabled:opacity-50">
{tfBusy ? "Verifying…" : "Turn on two-factor"}
</button>
</div>
</form>
) : tfEnabled && tfStep !== "change" ? (
<>
<p className="text-sm text-gray-500 mb-4">
<span className="font-medium text-green-700">On.</span> When you log in we send a 6-digit code by{" "}
{TWO_FACTOR_METHOD_LABEL[tfMethod]}. Devices you&apos;ve chosen to trust skip the code for 30 days.
</p>
{tfRequired ? (
<>
<p className="text-xs text-gray-500 mb-3">Required for your role, so it can&apos;t be turned off.</p>
<button
onClick={() => { setTfStep("change"); setTfPassword(""); setTfMsg(null); }}
className="px-4 py-2 rounded-lg border border-gray-300 text-gray-700 text-sm font-medium hover:bg-gray-50"
>
Change how I get codes…
</button>
</>
) : tfStep !== "disable" ? (
<button
onClick={() => { setTfStep("disable"); setTfPassword(""); setTfMsg(null); }}
className="px-4 py-2 rounded-lg border border-gray-300 text-gray-700 text-sm font-medium hover:bg-gray-50"
>
Turn off…
</button>
) : (
<form onSubmit={disableTwoFactor} className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Confirm with your password</label>
<input type="password" className={inputCls} value={tfPassword} onChange={e => setTfPassword(e.target.value)} required autoComplete="current-password" />
</div>
<div className="flex gap-3">
<button type="button" onClick={resetTwoFactorForm} className="px-4 py-2 rounded-lg bg-gray-100 text-sm font-medium hover:bg-gray-200">
Cancel
</button>
<button type="submit" disabled={tfBusy || !tfPassword} className="px-4 py-2 rounded-lg bg-amber-500 text-white text-sm font-medium hover:bg-amber-600 disabled:opacity-50">
{tfBusy ? "Turning off…" : "Turn off two-factor"}
</button>
</div>
</form>
)}
</>
) : (
<form onSubmit={startEnableTwoFactor} className="space-y-4">
<p className="text-sm text-gray-500">
{tfEnabled
? "Choose where your login codes go. We'll send a code there first to check it reaches you."
: "Add a second step to logging in: after your password, we send a 6-digit code that you need to enter."}
</p>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Send my codes by</label>
<select className={inputCls} value={tfMethod} onChange={e => setTfMethod(e.target.value as TwoFactorMethod)}>
<option value="email">Email</option>
{canWhatsAppCodes && <option value="whatsapp">WhatsApp</option>}
</select>
{!canWhatsAppCodes && (
<p className="mt-1 text-xs text-gray-500">Save a valid phone number on your profile to receive codes by WhatsApp.</p>
)}
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Confirm with your password</label>
<input type="password" className={inputCls} value={tfPassword} onChange={e => setTfPassword(e.target.value)} required autoComplete="current-password" />
</div>
<div className="flex gap-3">
{tfEnabled && (
<button type="button" onClick={resetTwoFactorForm} className="px-4 py-2 rounded-lg bg-gray-100 text-sm font-medium hover:bg-gray-200">
Cancel
</button>
)}
<button type="submit" disabled={tfBusy || !tfPassword} className="px-4 py-2 rounded-lg bg-brand-600 text-white text-sm font-medium hover:bg-brand-700 disabled:opacity-50">
{tfBusy ? "Sending code…" : "Send verification code"}
</button>
</div>
</form>
)}
{tfMsg && <Alert msg={tfMsg} />}
</section>
{/* ── Security ─────────────────────────────────────────────────── */}
<section className="border rounded-xl p-5 bg-white shadow-sm">
<SectionHeader icon={ShieldAlert} title="Security" tone="amber" />
+10
View File
@@ -5,6 +5,7 @@ import { Footer } from "@/components/layout/Footer";
import ClientActions from "@/app/events/[id]/ClientActions";
import { ContactButton } from "@/components/events/ContactButton";
import { LocationMap } from "@/components/events/LocationMap";
import { JoinWaitlistButton, soldOutLines } from "@/components/events/JoinWaitlistButton";
import { Calendar, Ticket, Paperclip, Sparkles } from "lucide-react";
export const revalidate = 60;
@@ -40,6 +41,7 @@ type Event = {
contactPhone?: string | null;
contactEmail?: string | null;
location?: string | null;
waitlistEnabled?: boolean;
};
import { apiFetch, ApiError, resolveToApiOrigin } from "@/lib/api";
@@ -93,6 +95,14 @@ function RegisterCta({ event }: { event: Event }) {
);
}
if (eventSoldOut) {
if (event.waitlistEnabled) {
return (
<div className="space-y-2">
<div className="text-center text-sm font-medium text-red-700">Sold out</div>
<JoinWaitlistButton eventId={event.id} lines={soldOutLines(event.eventOptions || [])} guestsAllowed={event.requiresAuth === false} />
</div>
);
}
return (
<button disabled className="w-full bg-red-50 text-red-700 border border-red-200 px-4 py-2.5 rounded-lg cursor-not-allowed font-medium">
Sold Out
+2 -2
View File
@@ -2,7 +2,7 @@ import { Metadata } from "next";
import { Navbar } from "@/components/layout/Navbar";
import { Footer } from "@/components/layout/Footer";
import { EventCard } from "@/components/events/EventCard";
import { apiFetch } from "@/lib/api";
import { apiFetchListForPrerender } from "@/lib/api";
import { Calendar, CalendarX } from "lucide-react";
export const revalidate = 60;
@@ -27,7 +27,7 @@ type Event = {
};
export default async function EventsPage() {
const events = await apiFetch<Event[]>("/api/events", { nextOptions: { next: { revalidate: 60 } } });
const events = await apiFetchListForPrerender<Event>("/api/events", { nextOptions: { next: { revalidate: 60 } } });
const now = Date.now();
const upcoming = (events || []).filter(e => {
if (e.cashupStatus === "closed") return false;
+36 -14
View File
@@ -15,6 +15,8 @@ function FormsContent() {
const router = useRouter();
const { token } = useAuth();
const registrationId = search.get("registrationId");
// Set when a named attendee opens the link sent to them — show only their form.
const onlyAttendeeId = search.get("attendeeId");
const [registration, setRegistration] = useState<any | null>(null);
const [eventForm, setEventForm] = useState<{ isRequired: boolean; fields: FormField[] } | null>(null);
@@ -22,8 +24,8 @@ function FormsContent() {
const [error, setError] = useState<string | null>(null);
const [info, setInfo] = useState<string | null>(null);
// Local entry state for new responses
const [formsData, setFormsData] = useState<Record<number, Record<string, string>>>({});
// Local entry state for new responses, keyed by form block (see `blocks` below)
const [formsData, setFormsData] = useState<Record<string, Record<string, string>>>({});
useEffect(() => {
(async () => {
@@ -65,7 +67,28 @@ function FormsContent() {
}
}, [registration]);
const remaining = Math.max(0, requiredCount - submittedCount);
// One form block per place still to fill: each named attendee (group tickets) by name,
// then numbered blocks for unnamed places. Unnamed blocks keep their old numeric keys,
// so drafts saved before attendees existed still prefill.
type FormBlock = { key: string; label: string; attendeeId?: string };
const blocks = useMemo((): FormBlock[] => {
if (!registration) return [];
const responses = registration.formResponses || [];
const answered = new Set(responses.map((r: any) => r.attendeeId).filter(Boolean));
const attendees = (registration.attendees || []) as any[];
const named = attendees
.filter(a => !answered.has(a.id))
.map(a => ({ key: `a:${a.id}`, label: a.name, attendeeId: a.id as string }));
const unnamedPlaces = Math.max(0, requiredCount - attendees.length);
const unnamedAnswered = responses.filter((r: any) => !r.attendeeId).length;
const unnamed = Array.from({ length: Math.max(0, unnamedPlaces - unnamedAnswered) }, (_, i) => ({
key: String(i), label: `Attendee ${unnamedAnswered + i + 1}`,
}));
const all: FormBlock[] = [...named, ...unnamed];
return onlyAttendeeId ? all.filter(b => b.attendeeId === onlyAttendeeId) : all;
}, [registration, requiredCount, onlyAttendeeId]);
const remaining = blocks.length;
const attendeeName = (id: string | null | undefined) => (registration?.attendees || []).find((a: any) => a.id === id)?.name;
// Determine if all required fields are filled for each attendee form to be submitted
const canSubmit = useMemo(() => {
@@ -73,8 +96,8 @@ function FormsContent() {
const reqFields = (eventForm.fields || [])
.filter(f => !!f.isRequired && f.type !== 'statement' && f.type !== 'paragraph')
.map(f => f.id);
for (let i = 0; i < remaining; i++) {
const data = formsData[i] || {};
for (const b of blocks) {
const data = formsData[b.key] || {};
for (const fid of reqFields) {
const v = data[fid];
if (v === undefined || v === null || String(v).trim() === '') {
@@ -83,16 +106,13 @@ function FormsContent() {
}
}
return true;
}, [eventForm, formsData, remaining]);
}, [eventForm, formsData, remaining, blocks]);
const submit = async () => {
if (!registrationId) return;
try {
setError(null); setInfo(null);
const payload: any[] = [];
for (let i = 0; i < remaining; i++) {
payload.push({ answers: formsData[i] || {} });
}
const payload = blocks.map(b => ({ ...(b.attendeeId ? { attendeeId: b.attendeeId } : {}), answers: formsData[b.key] || {} }));
if (payload.length === 0) {
// Nothing left to submit – still go to success page to continue the flow
const totalDue = (registration?.registrationOptions || []).reduce((s: number, ro: any) => s + ((ro.eventOption?.price || 0) * (ro.quantity || 0)), 0);
@@ -191,7 +211,7 @@ function FormsContent() {
<ul className="space-y-2">
{(registration.formResponses || []).map((resp: any, idx: number) => (
<li key={resp.id} className="bg-white border rounded p-2">
<div className="font-medium text-sm mb-1">Attendee {idx + 1}</div>
<div className="font-medium text-sm mb-1">{attendeeName(resp.attendeeId) || `Attendee ${idx + 1}`}</div>
{(resp.answers || []).length === 0 ? (
<div className="text-xs text-gray-600">No answers recorded.</div>
) : (
@@ -214,9 +234,9 @@ function FormsContent() {
<div className="border rounded p-3 bg-gray-50">
<div className="text-sm font-medium mb-2">Fill remaining attendee details ({remaining})</div>
<div className="space-y-4">
{Array.from({ length: remaining }, (_, idx) => (
{blocks.map(({ key: idx, label }) => (
<div key={idx} className="bg-white border rounded p-3">
<div className="font-medium mb-2">Attendee {submittedCount + idx + 1}</div>
<div className="font-medium mb-2">{label}</div>
{eventForm.fields.map((f) => (
<div key={f.id} className="mb-2">
{f.type === 'statement' ? (
@@ -252,7 +272,9 @@ function FormsContent() {
</div>
<div className="mt-3 flex gap-2">
<button className="px-3 py-1.5 text-sm rounded bg-brand-600 text-white hover:bg-brand-700 disabled:opacity-50" disabled={!canSubmit} onClick={submit}>Submit</button>
<button className="px-3 py-1.5 text-sm rounded bg-gray-100 hover:bg-gray-200" onClick={saveDraft}>Save for later</button>
{!onlyAttendeeId && (
<button className="px-3 py-1.5 text-sm rounded bg-gray-100 hover:bg-gray-200" onClick={saveDraft}>Save for later</button>
)}
</div>
</div>
)}
+3
View File
@@ -33,6 +33,9 @@ const appName = process.env.NEXT_PUBLIC_APP_NAME || 'Cross Code';
* to fail the whole page render over.
*/
async function getServerSettings(): Promise<SiteSettings> {
// No API base configured: a relative fetch never settles during prerender and
// would time out every page, so skip straight to the defaults.
if (!API_BASE) return {};
try {
const res = await fetch(`${API_BASE}/api/settings`, { next: { revalidate: 60 } });
if (!res.ok) return {};
+17
View File
@@ -0,0 +1,17 @@
import type { MetadataRoute } from "next";
// Lets staff "Add to home screen" and open the ticket scanner like an app (it also
// works offline once offline mode has been enabled — see public/sw.js).
export default function manifest(): MetadataRoute.Manifest {
return {
name: "Ticket scanner",
short_name: "Scanner",
description: "Scan event tickets, online or offline",
start_url: "/dashboard/staff/ticket-scanning",
scope: "/",
display: "standalone",
background_color: "#ffffff",
theme_color: "#4F46E5",
icons: [{ src: "/favicon.ico", sizes: "any", type: "image/x-icon" }],
};
}
+3 -2
View File
@@ -18,7 +18,7 @@ type Event = {
picture?: string;
};
import { apiFetch } from "@/lib/api";
import { apiFetchListForPrerender } from "@/lib/api";
const FEATURES = [
{ icon: CalendarCheck, title: "Meaningful Events", description: "Events designed to inspire, encourage and strengthen your faith." },
@@ -28,6 +28,7 @@ const FEATURES = [
];
async function getOrgName(): Promise<string> {
if (!API_BASE) return appName;
try {
const res = await fetch(`${API_BASE}/api/settings`, { next: { revalidate: 60 } });
if (!res.ok) return appName;
@@ -40,7 +41,7 @@ async function getOrgName(): Promise<string> {
export default async function HomePage() {
const [events, displayName] = await Promise.all([
apiFetch<Event[]>("/api/events", { nextOptions: { next: { revalidate: 60 } } }),
apiFetchListForPrerender<Event>("/api/events", { nextOptions: { next: { revalidate: 60 } } }),
getOrgName(),
]);
const now = Date.now();
@@ -3,6 +3,9 @@
import React, { useEffect, useMemo, useState } from "react";
import { useAuth } from "@/hooks/useAuth";
import { apiFetch } from "@/lib/api";
import { CouponCodeField, AppliedCoupon } from "@/components/shared/CouponCodeField";
import { AttendeeNamesEditor, AttendeeEntries, TicketDelivery, attendeesPayload, consentDetailsMissing } from "@/components/shared/AttendeeNamesEditor";
import { JoinWaitlistButton } from "@/components/events/JoinWaitlistButton";
// Tiered low-stock threshold — mirrors events/[id]/page.tsx
function lowStockThreshold(stockLimit: number): number {
@@ -39,8 +42,13 @@ type Event = {
eventOptions?: EventOption[];
form?: { isRequired: boolean; fields?: any[] } | null;
requiresAuth?: boolean;
allowNamedAttendees?: boolean;
requiresParentalConsent?: boolean;
waitlistEnabled?: boolean;
};
type WaitlistOffer = { eventOptionId: string; variantId: string | null; quantity: number; expiresAt: string; optionName: string; variantName: string | null };
// Key format: "optionId" for no-variant options, "optionId:variantId" for variant options
type QtyMap = Record<string, number>;
@@ -124,10 +132,12 @@ function OptionCard({
opt,
quantities,
onQtyChange,
waitlist,
}: {
opt: EventOption;
quantities: QtyMap;
onQtyChange: (key: string, val: number) => void;
waitlist?: { eventId: string; guestsAllowed: boolean } | null;
}) {
const hasVariants = Array.isArray(opt.variants) && opt.variants.length > 0;
const isSoldOut = (opt.stockLimit ?? 0) > 0 && opt.availableCount !== undefined && opt.availableCount <= 0;
@@ -151,7 +161,13 @@ function OptionCard({
</div>
<div className="flex items-center gap-2">
{isSoldOut ? (
<span className="text-xs font-medium text-red-600 bg-red-50 border border-red-200 rounded px-1.5 py-0.5">Sold out</span>
<>
<span className="text-xs font-medium text-red-600 bg-red-50 border border-red-200 rounded px-1.5 py-0.5">Sold out</span>
{waitlist && (
<JoinWaitlistButton compact eventId={waitlist.eventId} guestsAllowed={waitlist.guestsAllowed}
lines={variants.map(v => ({ eventOptionId: opt.id, variantId: v.id, label: `${opt.name} (${v.name})` }))} />
)}
</>
) : (
<StockBadge stockLimit={opt.stockLimit} availableCount={opt.availableCount} />
)}
@@ -175,6 +191,10 @@ function OptionCard({
<span className="text-sm">{variant.name}</span>
<span className="text-xs text-gray-500">R{unitPrice.toFixed(2)}</span>
<StockBadge stockLimit={variant.stockLimit} availableCount={variant.availableCount} />
{waitlist && variantSoldOut && !isSoldOut && (
<JoinWaitlistButton compact eventId={waitlist.eventId} guestsAllowed={waitlist.guestsAllowed}
lines={[{ eventOptionId: opt.id, variantId: variant.id, label: `${opt.name} (${variant.name})` }]} />
)}
</div>
<QtyControl
value={qty}
@@ -215,6 +235,9 @@ function OptionCard({
<span>R{price.toFixed(2)}</span>
)}
<StockBadge stockLimit={opt.stockLimit} availableCount={opt.availableCount} />
{waitlist && isSoldOut && (
<JoinWaitlistButton compact eventId={waitlist.eventId} guestsAllowed={waitlist.guestsAllowed} lines={[{ eventOptionId: opt.id, label: opt.name }]} />
)}
</div>
</div>
<QtyControl
@@ -227,9 +250,41 @@ function OptionCard({
);
}
export default function RegisterForm({ event }: { event: Event }) {
export default function RegisterForm({ event: baseEvent }: { event: Event }) {
const { token, user } = useAuth();
const eventId = event.id;
const eventId = baseEvent.id;
// A waitlist claim link (?waitlist=<token>): the places held for this person are
// added back to what's available, so they can choose them.
const [waitlistToken, setWaitlistToken] = useState<string | null>(null);
const [offer, setOffer] = useState<WaitlistOffer | null>(null);
const [offerError, setOfferError] = useState<string | null>(null);
useEffect(() => {
const t = new URLSearchParams(window.location.search).get("waitlist");
if (!t) return;
apiFetch<WaitlistOffer & { status: string }>(`/api/waitlist/claim/${encodeURIComponent(t)}`)
.then(res => {
if (res.status !== "offered") { setOfferError("This waitlist offer has expired."); return; }
setWaitlistToken(t);
setOffer(res);
setQuantities(q => ({ ...q, [res.variantId ? `${res.eventOptionId}:${res.variantId}` : res.eventOptionId]: res.quantity }));
})
.catch((e: any) => setOfferError(e?.message || "This waitlist link is not valid."));
}, []);
const event = useMemo<Event>(() => {
if (!offer) return baseEvent;
return {
...baseEvent,
eventOptions: (baseEvent.eventOptions || []).map(o => {
if (o.id !== offer.eventOptionId) return o;
return {
...o,
availableCount: o.availableCount !== undefined ? o.availableCount + offer.quantity : o.availableCount,
variants: (o.variants || []).map(v => v.id === offer.variantId && v.availableCount !== undefined ? { ...v, availableCount: v.availableCount + offer.quantity } : v),
};
}),
};
}, [baseEvent, offer]);
const [quantities, setQuantities] = useState<QtyMap>(() => {
const initial: QtyMap = {};
@@ -246,6 +301,15 @@ export default function RegisterForm({ event }: { event: Event }) {
const [registrationId, setRegistrationId] = useState<string | null>(null);
const [creatingCheckout, setCreatingCheckout] = useState(false);
const [creatingRegistration, setCreatingRegistration] = useState(false);
const [coupon, setCoupon] = useState<AppliedCoupon | null>(null);
const [namingOthers, setNamingOthers] = useState(false);
const [attendeeEntries, setAttendeeEntries] = useState<AttendeeEntries>({});
const [delivery, setDelivery] = useState<TicketDelivery>("payer");
const qtyFor = (optionId: string, variantId: string | null) => quantities[variantId ? `${optionId}:${variantId}` : optionId] || 0;
const isYouthEvent = !!event.requiresParentalConsent;
const namedAttendees = () => ((event.allowNamedAttendees && namingOthers) || isYouthEvent
? attendeesPayload(event.eventOptions || [], qtyFor, attendeeEntries)
: []);
// Guest details (for unauthenticated free-event registration)
const [guestName, setGuestName] = useState("");
@@ -292,7 +356,11 @@ export default function RegisterForm({ event }: { event: Event }) {
return limitedOpts.length > 0 && limitedOpts.every(o => o.availableCount !== undefined && o.availableCount <= 0);
}, [event]);
const buildItems = () => {
const basket = useMemo(() => buildItems(), [quantities]); // eslint-disable-line react-hooks/exhaustive-deps
// The server prices the basket (staff price, tiers) when checking a code, so its total wins.
const payable = coupon ? coupon.total : total;
function buildItems() {
const items: { eventOptionId: string; quantity: number; variantId?: string }[] = [];
Object.entries(quantities).forEach(([key, qty]) => {
if (qty <= 0) return;
@@ -304,7 +372,7 @@ export default function RegisterForm({ event }: { event: Event }) {
}
});
return items;
};
}
const submitRegistration = async () => {
const items = buildItems();
@@ -312,6 +380,28 @@ export default function RegisterForm({ event }: { event: Event }) {
setError("Select at least one ticket.");
return;
}
if (isYouthEvent) {
const missing = consentDetailsMissing(event.eventOptions || [], qtyFor, attendeeEntries, event.startDate);
if (missing) { setError(missing); return; }
}
// Claim link without logging in: the link identifies the person.
if (waitlistToken && !isLoggedIn) {
try {
setError(null);
setCreatingRegistration(true);
const res = await apiFetch<{ id: string; totalDueComputed?: number }>("/api/registrations", {
method: "POST",
body: { eventId, options: items, waitlistToken, ...(namedAttendees().length > 0 ? { attendees: namedAttendees(), ticketDelivery: delivery } : {}) },
});
if (res?.id) window.location.href = `/registration/success?registrationId=${encodeURIComponent(res.id)}&totalDue=${encodeURIComponent((res.totalDueComputed ?? payable).toFixed(2))}`;
} catch (e: any) {
setError(e?.message || "Registration failed");
} finally {
setCreatingRegistration(false);
}
return;
}
if (isLoggedIn) {
if (!token) {
@@ -322,18 +412,31 @@ export default function RegisterForm({ event }: { event: Event }) {
try {
setError(null);
setCreatingRegistration(true);
const res = await apiFetch<{ id: string }>("/api/registrations", {
const res = await apiFetch<{ id: string; totalDueComputed?: number; couponError?: string }>("/api/registrations", {
method: "POST",
body: { eventId, options: items },
body: {
eventId,
options: items,
...(coupon ? { couponCode: coupon.code } : {}),
...(namedAttendees().length > 0 ? { attendees: namedAttendees(), ticketDelivery: delivery } : {}),
...(waitlistToken ? { waitlistToken } : {}),
},
authToken: token,
});
if (res?.id) {
const hasForm = !!(event?.form && Array.isArray(event.form.fields) && event.form.fields.length > 0);
if (hasForm) {
window.location.href = `/forms?registrationId=${encodeURIComponent(res.id)}`;
} else {
window.location.href = `/registration/success?registrationId=${encodeURIComponent(res.id)}&totalDue=${encodeURIComponent((total || 0).toFixed(2))}`;
const due = res.totalDueComputed ?? payable;
const next = hasForm
? `/forms?registrationId=${encodeURIComponent(res.id)}`
: `/registration/success?registrationId=${encodeURIComponent(res.id)}&totalDue=${encodeURIComponent((due || 0).toFixed(2))}`;
if (res.couponError) {
// The code ran out between checking and registering — the registration is
// saved at full price, so say so before moving on.
setError(`You're registered, but the code couldn't be applied: ${res.couponError} Continuing at full price…`);
setTimeout(() => { window.location.href = next; }, 4000);
return;
}
window.location.href = next;
}
} catch (e: any) {
setError(e?.message || "Registration failed");
@@ -357,7 +460,10 @@ export default function RegisterForm({ event }: { event: Event }) {
setCreatingRegistration(true);
const res = await apiFetch<{ id: string }>("/api/registrations", {
method: "POST",
body: { eventId, options: items, guestName: guestName.trim(), guestEmail: guestEmail.trim(), guestPhone: guestPhone.trim() || undefined },
body: {
eventId, options: items, guestName: guestName.trim(), guestEmail: guestEmail.trim(), guestPhone: guestPhone.trim() || undefined,
...(namedAttendees().length > 0 ? { attendees: namedAttendees(), ticketDelivery: delivery } : {}),
},
});
if (res?.id) {
const hasForm = !!(event?.form && Array.isArray(event.form.fields) && event.form.fields.length > 0);
@@ -400,13 +506,22 @@ export default function RegisterForm({ event }: { event: Event }) {
<>
<h1 className="text-2xl font-semibold mb-4">Register for {event.title}</h1>
{eventSoldOut && (
{offer && (
<div className="mb-4 p-4 border rounded-lg bg-green-50 border-green-200 text-sm text-green-900">
<strong>Your waitlist place:</strong> {offer.quantity} × {offer.optionName}{offer.variantName ? ` (${offer.variantName})` : ""} — held for you until {new Date(offer.expiresAt).toLocaleString()}.
</div>
)}
{offerError && (
<div className="mb-4 p-4 border rounded-lg bg-amber-50 border-amber-200 text-sm text-amber-900">{offerError}</div>
)}
{eventSoldOut && !event.waitlistEnabled && (
<div className="mb-4 p-4 border rounded-lg bg-red-50 border-red-200 text-sm text-red-800 font-medium">
This event is sold out. Registration is no longer available.
</div>
)}
{!isLoggedIn && !isGuestEligible && (
{!isLoggedIn && !isGuestEligible && !waitlistToken && (
<div className="mb-4 p-4 border rounded-lg bg-amber-50 border-amber-200 text-sm text-amber-800">
You need to <a href={`/login?redirect=/register/${eventId}`} className="font-semibold underline">log in</a> or <a href={`/register?redirect=/register/${eventId}`} className="font-semibold underline">create an account</a> to register for this event.
</div>
@@ -453,6 +568,7 @@ export default function RegisterForm({ event }: { event: Event }) {
opt={opt}
quantities={quantities}
onQtyChange={handleQtyChange}
waitlist={event.waitlistEnabled ? { eventId, guestsAllowed: event.requiresAuth === false } : null}
/>
))}
</div>
@@ -475,8 +591,38 @@ export default function RegisterForm({ event }: { event: Event }) {
);
})()}
{(event.allowNamedAttendees || isYouthEvent) && !eventSoldOut && (isLoggedIn || showGuestForm) && (
<div className="mt-6">
<AttendeeNamesEditor
options={event.eventOptions || []}
qtyFor={qtyFor}
enabled={namingOthers}
onEnabledChange={setNamingOthers}
entries={attendeeEntries}
onEntriesChange={setAttendeeEntries}
delivery={delivery}
onDeliveryChange={setDelivery}
consentEventStart={isYouthEvent ? event.startDate : undefined}
/>
</div>
)}
{isLoggedIn && total > 0 && !eventSoldOut && (
<div className="mt-6">
<CouponCodeField eventId={eventId} basket={basket} token={token} applied={coupon} onChange={setCoupon} />
</div>
)}
<div className="mt-6 flex items-center justify-between">
<div className="text-lg font-semibold">Total: R{total.toFixed(2)}</div>
<div>
{coupon && coupon.discount > 0 && (
<div className="text-sm text-gray-500">
<span className="line-through">R{coupon.subtotal.toFixed(2)}</span>
<span className="ml-2 text-green-700">−R{coupon.discount.toFixed(2)}</span>
</div>
)}
<div className="text-lg font-semibold">Total: R{payable.toFixed(2)}</div>
</div>
{!registrationId ? (
eventSoldOut ? (
<button disabled className="bg-red-50 text-red-700 border border-red-200 px-4 py-2 rounded cursor-not-allowed">
@@ -485,7 +631,7 @@ export default function RegisterForm({ event }: { event: Event }) {
) : (
<button
onClick={submitRegistration}
disabled={creatingRegistration || (!isLoggedIn && !isGuestEligible)}
disabled={creatingRegistration || (!isLoggedIn && !isGuestEligible && !waitlistToken)}
className="bg-brand-600 text-white px-4 py-2 rounded hover:bg-brand-700 disabled:opacity-60"
>
{creatingRegistration ? "Registering..." : "Register"}
+120 -17
View File
@@ -2,6 +2,9 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { apiFetch } from "@/lib/api";
import { TwoFactorChallenge, isTwoFactorChallenge } from "@/lib/auth";
import { CouponCodeField, AppliedCoupon } from "@/components/shared/CouponCodeField";
import { AttendeeNamesEditor, AttendeeEntries, TicketDelivery, attendeesPayload, consentDetailsMissing } from "@/components/shared/AttendeeNamesEditor";
// ─── Types ────────────────────────────────────────────────────────────────────
type EarlyBirdTier = { deadline: string; price: number; order?: number; variantId?: string | null };
@@ -37,6 +40,8 @@ type KioskEvent = {
picture?: string;
eventOptions?: EventOption[];
form?: EventForm | null;
allowNamedAttendees?: boolean;
requiresParentalConsent?: boolean;
};
type Screen = "setup" | "form" | "eventform" | "confirmation";
@@ -133,6 +138,13 @@ export default function SelfServicePage() {
const [setupLoading, setSetupLoading] = useState(false);
const [setupError, setSetupError] = useState<string | null>(null);
const [supervisorToken, setSupervisorToken] = useState<string | null>(null);
const [kioskCoupon, setKioskCoupon] = useState<AppliedCoupon | null>(null);
const [namingOthers, setNamingOthers] = useState(false);
const [attendeeEntries, setAttendeeEntries] = useState<AttendeeEntries>({});
const [delivery, setDelivery] = useState<TicketDelivery>("payer");
// Set while a supervisor with two-factor on still has to enter their code.
const [setupChallenge, setSetupChallenge] = useState<TwoFactorChallenge | null>(null);
const [setupCode, setSetupCode] = useState("");
const [events, setEvents] = useState<KioskEvent[]>([]);
const [selectedEventId, setSelectedEventId] = useState("");
const [eventsLoading, setEventsLoading] = useState(false);
@@ -277,16 +289,32 @@ export default function SelfServicePage() {
setSetupError(null);
setSetupLoading(true);
try {
const data = await apiFetch("/api/users/login", {
method: "POST",
body: { email: setupEmail, password: setupPassword },
});
// Second pass for accounts with two-factor on: exchange the code that was
// sent after the password step. The kiosk never asks to be remembered.
const data = setupChallenge
? await apiFetch("/api/users/login/2fa", {
method: "POST",
body: { challengeId: setupChallenge.challengeId, code: setupCode, rememberDevice: false },
})
: await apiFetch("/api/users/login", {
method: "POST",
body: { email: setupEmail, password: setupPassword },
});
if (isTwoFactorChallenge(data)) {
setSetupChallenge(data);
setSetupCode("");
return;
}
const role: string = data.role || "";
if (!["admin", "supervisor"].includes(role)) {
setSetupError("Only admins and supervisors can set up the kiosk.");
setSetupChallenge(null);
setSetupCode("");
setSetupLoading(false);
return;
}
setSetupChallenge(null);
setSetupCode("");
setSupervisorToken(data.token);
await loadEvents(data.token);
} catch (err: any) {
@@ -316,18 +344,16 @@ export default function SelfServicePage() {
setChangeError(null);
setChangeLoading(true);
try {
const data = await apiFetch("/api/users/login", {
// Re-confirm the password against the supervisor session the kiosk already
// holds, rather than logging in again — a fresh login would send a new
// two-factor code every time the event is changed.
if (!supervisorToken) throw new Error("Your session has expired. Reload the kiosk and sign in again.");
await apiFetch("/api/users/verify-password", {
method: "POST",
body: { email: setupEmail, password: changePassword },
authToken: supervisorToken,
body: { password: changePassword },
});
const role: string = data.role || "";
if (!["admin", "supervisor"].includes(role)) {
setChangeError("Only admins and supervisors can change the event.");
setChangeLoading(false);
return;
}
setSupervisorToken(data.token);
await loadEvents(data.token);
await loadEvents(supervisorToken);
setChangePassword("");
setChangeError(null);
setShowChangeModal(false);
@@ -363,6 +389,10 @@ export default function SelfServicePage() {
setFormError("The email and phone number entered belong to two different existing accounts. Please check and correct one of them before continuing.");
return;
}
if (selectedEvent?.requiresParentalConsent) {
const missing = consentDetailsMissing(selectedEvent.eventOptions || [], (o, v) => quantities[v ? `${o}::${v}` : o] || 0, attendeeEntries, selectedEvent.startDate);
if (missing) { setFormError(missing); return; }
}
if (pendingChanges.length > 0) {
setShowUpdateConfirm(true);
return;
@@ -390,7 +420,15 @@ export default function SelfServicePage() {
// activation email/WhatsApp at them afterward.
skipActivationNotice: !createAccount,
notificationPreference: notificationPref,
...(kioskCoupon ? { couponCode: kioskCoupon.code } : {}),
};
const kioskNames = (selectedEvent?.allowNamedAttendees && namingOthers) || selectedEvent?.requiresParentalConsent
? attendeesPayload(selectedEvent.eventOptions || [], (o, v) => quantities[v ? `${o}::${v}` : o] || 0, attendeeEntries)
: [];
if (kioskNames.length > 0) {
payload.attendees = kioskNames;
payload.ticketDelivery = delivery;
}
if (createAccount && visitorPassword) {
payload.user.password = visitorPassword;
}
@@ -403,7 +441,7 @@ export default function SelfServicePage() {
setCurrentRegistrationId(result?.id || null);
setCurrentUserId(result?.userId || null);
setRegistrationIsFree(result?.status === "paid" || total === 0);
setRegistrationIsFree(result?.status === "paid" || (result?.totalDueComputed ?? total) === 0);
setConfirmationName(visitorName.trim());
// If event has a required form and there are main tickets, show form step
@@ -504,6 +542,10 @@ export default function SelfServicePage() {
}
function resetForm() {
setKioskCoupon(null);
setNamingOthers(false);
setAttendeeEntries({});
setDelivery("payer");
setVisitorName("");
setVisitorEmail("");
setVisitorPhone("");
@@ -701,13 +743,41 @@ export default function SelfServicePage() {
<PasswordToggleButton shown={showSetupPassword} onToggle={() => setShowSetupPassword((v) => !v)} />
</div>
</div>
{setupChallenge && (
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Verification code</label>
<input
type="text"
inputMode="numeric"
autoComplete="one-time-code"
maxLength={6}
value={setupCode}
onChange={(e) => setSetupCode(e.target.value.replace(/\D/g, ""))}
className="w-full border border-gray-300 rounded-xl px-4 py-3 text-base tracking-widest focus:outline-none focus:ring-2 focus:ring-brand-500"
placeholder="6-digit code"
autoFocus
required
/>
<p className="mt-1 text-xs text-gray-500">
{setupChallenge.enrolling && "Two-factor authentication is required for your role — this code turns it on. "}
We sent a code to {setupChallenge.destination} by {setupChallenge.method === "whatsapp" ? "WhatsApp" : "email"}.{" "}
<button
type="button"
className="text-brand-600 hover:underline"
onClick={() => { setSetupChallenge(null); setSetupCode(""); setSetupError(null); }}
>
Start again
</button>
</p>
</div>
)}
{setupError && <p className="text-red-600 text-sm">{setupError}</p>}
<button
type="submit"
disabled={setupLoading}
className="w-full bg-brand-600 text-white rounded-xl py-3.5 text-lg font-semibold hover:bg-brand-700 disabled:opacity-60 transition mt-2"
>
{setupLoading ? "Signing in…" : "Sign In"}
{setupLoading ? "Signing in…" : setupChallenge ? "Verify" : "Sign In"}
</button>
</form>
)}
@@ -943,9 +1013,42 @@ export default function SelfServicePage() {
);
})}
</div>
{(selectedEvent?.allowNamedAttendees || selectedEvent?.requiresParentalConsent) && (
<div className="mt-4">
<AttendeeNamesEditor
options={selectedEvent.eventOptions || []}
qtyFor={(o, v) => quantities[v ? `${o}::${v}` : o] || 0}
enabled={namingOthers}
onEnabledChange={setNamingOthers}
entries={attendeeEntries}
onEntriesChange={setAttendeeEntries}
delivery={delivery}
onDeliveryChange={setDelivery}
consentEventStart={selectedEvent.requiresParentalConsent ? selectedEvent.startDate : undefined}
/>
</div>
)}
{total > 0 && selectedEventId && (
<div className="mt-4">
<CouponCodeField
eventId={selectedEventId}
basket={Object.entries(quantities).map(([key, quantity]) => {
const [eventOptionId, variantId] = key.split("::");
return { eventOptionId, quantity, variantId: variantId || null };
})}
token={supervisorToken}
forUserId={matchedAccount?.id ?? null}
applied={kioskCoupon}
onChange={setKioskCoupon}
/>
</div>
)}
{total > 0 && (
<div className="mt-3 text-right text-base font-semibold text-gray-800">
Total: {fmtCurrency(total)}
{kioskCoupon && kioskCoupon.discount > 0 && (
<span className="mr-2 text-sm font-normal text-gray-500 line-through">{fmtCurrency(kioskCoupon.subtotal)}</span>
)}
Total: {fmtCurrency(kioskCoupon ? kioskCoupon.total : total)}
</div>
)}
</div>
@@ -0,0 +1,72 @@
"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 { ListChecks } from "lucide-react";
type Claim = {
status: "offered" | "expired";
eventId: string;
eventTitle: string;
eventStart: string;
optionName: string;
variantName: string | null;
quantity: number;
requested: number;
expiresAt: string;
name?: string;
};
/** Landing page for a waitlist offer link — shows what's held, then goes to registration. */
export default function WaitlistClaimPage() {
const params = useParams<{ token: string }>();
const token = params?.token;
const [claim, setClaim] = useState<Claim | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!token) return;
apiFetch<Claim>(`/api/waitlist/claim/${encodeURIComponent(token)}`)
.then(setClaim)
.catch(e => setError(e instanceof ApiError ? e.message : "Couldn't load this offer."));
}, [token]);
return (
<div className="min-h-screen flex flex-col">
<Navbar />
<main className="flex-1 px-4 py-10 max-w-xl 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">
<ListChecks className="w-5 h-5 text-brand-600" />
</div>
<h1 className="text-2xl font-semibold text-gray-900">Your waitlist place</h1>
</div>
{error && <div className="border rounded-xl p-4 bg-red-50 text-red-800 text-sm">{error}</div>}
{!claim && !error && <div className="text-sm text-gray-600">Loading…</div>}
{claim && (
<div className="border rounded-xl p-5 bg-white shadow-sm space-y-3">
<div className="font-semibold text-gray-900">{claim.eventTitle}</div>
<div className="text-sm text-gray-600">{formatDate(new Date(claim.eventStart))}</div>
{claim.status === "offered" ? (
<>
<p className="text-sm text-gray-700">
{claim.name ? `Hi ${claim.name}, ` : ""}{claim.quantity} × {claim.optionName}{claim.variantName ? ` (${claim.variantName})` : ""} {claim.quantity === 1 ? "is" : "are"} being held for you until <strong>{new Date(claim.expiresAt).toLocaleString()}</strong>.
{claim.quantity < claim.requested && ` You asked for ${claim.requested}; this is what came free.`}
</p>
<a href={`/register/${claim.eventId}?waitlist=${encodeURIComponent(token)}`} className="inline-block px-4 py-2 rounded-lg bg-brand-600 text-white text-sm font-medium hover:bg-brand-700">
Claim and register
</a>
</>
) : (
<p className="text-sm text-amber-900 bg-amber-50 border border-amber-200 rounded-lg p-3">This offer has expired, and the place has been offered to the next person on the waitlist.</p>
)}
</div>
)}
</main>
<Footer />
</div>
);
}
+142 -8
View File
@@ -1,12 +1,16 @@
"use client";
import React, { useState } from "react";
import React, { useEffect, useState } from "react";
import { useAuth } from "@/hooks/useAuth";
import { AuthUser, TwoFactorChallenge, isTwoFactorChallenge } from "@/lib/auth";
import { useSearchParams } from "next/navigation";
import Link from "next/link";
// Matches the backend's minimum gap between code sends.
const RESEND_COOLDOWN_SECONDS = 60;
export function LoginForm() {
const { login } = useAuth();
const { login, verifyTwoFactor, resendTwoFactor } = useAuth();
const searchParams = useSearchParams();
const raw = searchParams.get("redirect") || "";
const hasExplicitRedirect = raw.startsWith("/") && !raw.startsWith("//");
@@ -19,6 +23,25 @@ export function LoginForm() {
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
// Two-factor step — set once the password is accepted for an account with 2FA on.
const [challenge, setChallenge] = useState<TwoFactorChallenge | null>(null);
const [code, setCode] = useState("");
const [rememberDevice, setRememberDevice] = useState(false);
const [resendIn, setResendIn] = useState(0);
useEffect(() => {
if (resendIn <= 0) return;
const t = setTimeout(() => setResendIn((s) => s - 1), 1000);
return () => clearTimeout(t);
}, [resendIn]);
// Skip the generic /dashboard hop (which just redirects again once the role is known)
// and go straight to the role-specific dashboard, unless the caller asked for a specific
// page back (e.g. returning to a registration flow after login).
const goToDashboard = (loggedInUser: AuthUser) => {
window.location.href = hasExplicitRedirect ? redirectTo : `/dashboard/${loggedInUser.role || "user"}`;
};
const getErrorMessage = (err: any) => {
if (!err) return "Login failed";
@@ -45,12 +68,15 @@ export function LoginForm() {
setLoading(true);
try {
const loggedInUser = await login(email, password);
// Skip the generic /dashboard hop (which just redirects again once the role is known)
// and go straight to the role-specific dashboard, unless the caller asked for a specific
// page back (e.g. returning to a registration flow after login).
const target = hasExplicitRedirect ? redirectTo : `/dashboard/${loggedInUser.role || "user"}`;
window.location.href = target;
const result = await login(email, password);
if (isTwoFactorChallenge(result)) {
// Password was right, but the account has two-factor on — show the code step.
setChallenge(result);
setCode("");
setResendIn(RESEND_COOLDOWN_SECONDS);
return;
}
goToDashboard(result);
} catch (err: any) {
setError(getErrorMessage(err) || "Login failed. Please try again.");
} finally {
@@ -58,8 +84,116 @@ export function LoginForm() {
}
};
const onSubmitCode = async (e: React.FormEvent) => {
e.preventDefault();
if (!challenge) return;
setError(null);
setLoading(true);
try {
goToDashboard(await verifyTwoFactor(challenge.challengeId, code, rememberDevice));
} catch (err: any) {
setError(getErrorMessage(err) || "Verification failed. Please try again.");
} finally {
setLoading(false);
}
};
const onResend = async () => {
if (!challenge || resendIn > 0) return;
setError(null);
try {
// The resend response doesn't repeat `enrolling`, so carry it over.
const next = await resendTwoFactor(challenge.challengeId);
setChallenge({ ...next, enrolling: challenge.enrolling });
setCode("");
setResendIn(RESEND_COOLDOWN_SECONDS);
} catch (err: any) {
setError(getErrorMessage(err) || "Couldn't send a new code. Please try again.");
}
};
const backToPassword = () => {
setChallenge(null);
setCode("");
setPassword("");
setError(null);
};
if (challenge) {
return (
<form onSubmit={onSubmitCode} className="space-y-4 max-w-sm w-full">
<div>
<h2 className="text-base font-semibold">Enter your verification code</h2>
{challenge.enrolling && (
<p className="text-sm text-brand-800 bg-brand-50 border border-brand-200 rounded px-3 py-2 mt-2">
Two-factor authentication is required for your role. Entering this code turns it on for your account.
</p>
)}
<p className="text-sm text-gray-600 mt-1">
We sent a 6-digit code to {challenge.destination} by {challenge.method === "whatsapp" ? "WhatsApp" : "email"}. It expires in 10 minutes.
</p>
</div>
<div>
<label className="block text-sm font-medium mb-1">Verification code</label>
<input
type="text"
inputMode="numeric"
autoComplete="one-time-code"
maxLength={6}
value={code}
onChange={(e) => setCode(e.target.value.replace(/\D/g, ""))}
className="w-full border rounded px-3 py-2 text-lg tracking-widest"
autoFocus
required
/>
</div>
<label className="flex items-start gap-2 text-sm text-gray-700 cursor-pointer">
<input
type="checkbox"
className="mt-0.5"
checked={rememberDevice}
onChange={(e) => setRememberDevice(e.target.checked)}
/>
<span>Trust this device for 30 days. Only tick this on a device that&apos;s yours.</span>
</label>
{error && <p className="text-sm text-red-600">{error}</p>}
<button
type="submit"
disabled={loading || code.length !== 6}
className="w-full bg-brand-600 hover:bg-brand-700 text-white rounded py-2 disabled:opacity-60"
>
{loading ? "Verifying..." : "Verify and log in"}
</button>
<div className="flex items-center justify-between text-xs">
<button type="button" onClick={backToPassword} className="text-brand-600 hover:underline">
Back to login
</button>
<button
type="button"
onClick={onResend}
disabled={resendIn > 0}
className="text-brand-600 hover:underline disabled:text-gray-400 disabled:no-underline"
>
{resendIn > 0 ? `Resend code in ${resendIn}s` : "Resend code"}
</button>
</div>
</form>
);
}
return (
<form onSubmit={onSubmit} className="space-y-4 max-w-sm w-full">
{searchParams.get("reason") === "2fa" && (
<p className="text-sm text-brand-800 bg-brand-50 border border-brand-200 rounded px-3 py-2">
Two-factor authentication is now required for your role. Log in again to set it up — we&apos;ll send you a code.
</p>
)}
<div>
<label className="block text-sm font-medium mb-1">Email or phone</label>
<input
@@ -13,6 +13,7 @@ import { ContactButton } from "@/components/events/ContactButton";
price: number;
picture?: string;
isSoldOut?: boolean;
waitlistEnabled?: boolean;
requiresRegistration?: boolean;
contactName?: string | null;
contactPhone?: string | null;
@@ -88,6 +89,14 @@ export const EventCard = ({ event }: { event: Event }) => {
</button>
);
}
if (event.isSoldOut && event.waitlistEnabled) {
// Sold out with a waitlist: send them to the event page to join it.
return (
<a href={`/events/${event.id}`} className="flex-1 text-center text-sm text-brand-700 bg-white border border-brand-300 rounded-lg py-2 hover:bg-brand-50">
Sold out — join waitlist
</a>
);
}
if (event.isSoldOut) {
return (
<button disabled className="flex-1 text-center text-sm text-red-700 bg-red-50 border border-red-200 rounded-lg py-2 cursor-not-allowed">
@@ -0,0 +1,145 @@
"use client";
import React, { useState } from "react";
import { ListPlus, X } from "lucide-react";
import { apiFetch, ApiError } from "@/lib/api";
import { useAuth } from "@/hooks/useAuth";
export type WaitlistLine = { eventOptionId: string; variantId?: string | null; label: string };
const inputCls = "w-full border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-brand-400";
/**
* "Join waitlist" for sold-out options. Opens a small form: which option, how many,
* and (for guests on events that allow it) name and contact details.
*/
export function JoinWaitlistButton({ eventId, lines, guestsAllowed, initialLine, compact = false }: {
eventId: string;
lines: WaitlistLine[];
guestsAllowed: boolean;
initialLine?: WaitlistLine;
compact?: boolean;
}) {
const { token, user } = useAuth();
const [open, setOpen] = useState(false);
const [lineKey, setLineKey] = useState(() => keyOf(initialLine || lines[0]));
const [qty, setQty] = useState(1);
const [guest, setGuest] = useState({ name: "", email: "", phone: "" });
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [done, setDone] = useState<string | null>(null);
if (lines.length === 0) return null;
const loggedIn = !!(token && user);
const line = lines.find(l => keyOf(l) === lineKey) || lines[0];
const submit = async (e: React.FormEvent) => {
e.preventDefault();
setBusy(true);
setError(null);
try {
const res = await apiFetch<{ message: string }>("/api/waitlist", {
method: "POST",
authToken: token || undefined,
body: {
eventId,
eventOptionId: line.eventOptionId,
...(line.variantId ? { variantId: line.variantId } : {}),
quantity: qty,
...(!loggedIn ? { guestName: guest.name.trim(), guestEmail: guest.email.trim(), guestPhone: guest.phone.trim() || undefined } : {}),
},
});
setDone(res.message);
} catch (err) {
setError(err instanceof ApiError ? err.message : "Couldn't join the waitlist. Please try again.");
} finally {
setBusy(false);
}
};
return (
<>
<button
type="button"
onClick={() => { setOpen(true); setDone(null); setError(null); }}
className={compact
? "inline-flex items-center gap-1 text-xs font-medium text-brand-700 border border-brand-200 bg-brand-50 rounded px-2 py-0.5 hover:bg-brand-100"
: "w-full inline-flex items-center justify-center gap-2 bg-white text-brand-700 border border-brand-300 px-4 py-2.5 rounded-lg hover:bg-brand-50 font-medium"}
>
<ListPlus className={compact ? "w-3.5 h-3.5" : "w-4 h-4"} /> Join waitlist
</button>
{open && (
<div className="fixed inset-0 z-50 bg-black/40 flex items-center justify-center p-4" onClick={() => setOpen(false)}>
<form onSubmit={submit} className="bg-white rounded-xl shadow-xl w-full max-w-md p-5 space-y-4" onClick={e => e.stopPropagation()}>
<div className="flex items-center justify-between">
<h2 className="font-semibold">Join the waitlist</h2>
<button type="button" onClick={() => setOpen(false)} aria-label="Close"><X className="w-5 h-5 text-gray-500" /></button>
</div>
{done ? (
<>
<p className="text-sm text-green-800 bg-green-50 border border-green-200 rounded-lg p-3">{done}</p>
<button type="button" onClick={() => setOpen(false)} className="w-full px-4 py-2 rounded-lg bg-brand-600 text-white text-sm font-medium hover:bg-brand-700">Done</button>
</>
) : !loggedIn && !guestsAllowed ? (
<p className="text-sm text-gray-700">
<a href={`/login?redirect=${encodeURIComponent(typeof window !== "undefined" ? window.location.pathname : "/events")}`} className="text-brand-600 font-medium underline">Log in</a> to join the waitlist for this event.
</p>
) : (
<>
<p className="text-sm text-gray-600">If a place opens up, we&apos;ll email or WhatsApp you a link to claim it. We hold it for you for a limited time.</p>
{lines.length > 1 && (
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Ticket</label>
<select className={inputCls} value={lineKey} onChange={e => setLineKey(e.target.value)}>
{lines.map(l => <option key={keyOf(l)} value={keyOf(l)}>{l.label}</option>)}
</select>
</div>
)}
{lines.length === 1 && <p className="text-sm"><span className="text-gray-500">Ticket:</span> {line.label}</p>}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">How many?</label>
<input type="number" min={1} max={20} className={`${inputCls} w-24`} value={qty} onChange={e => setQty(Math.max(1, Math.min(20, parseInt(e.target.value, 10) || 1)))} />
</div>
{!loggedIn && (
<div className="space-y-2">
<input className={inputCls} placeholder="Full name *" value={guest.name} onChange={e => setGuest({ ...guest, name: e.target.value })} required aria-label="Full name" />
<input type="email" className={inputCls} placeholder="Email *" value={guest.email} onChange={e => setGuest({ ...guest, email: e.target.value })} required aria-label="Email" />
<input type="tel" className={inputCls} placeholder="Phone (optional)" value={guest.phone} onChange={e => setGuest({ ...guest, phone: e.target.value })} aria-label="Phone" />
</div>
)}
{error && <p className="text-sm text-red-600">{error}</p>}
<button type="submit" disabled={busy} className="w-full px-4 py-2 rounded-lg bg-brand-600 text-white text-sm font-medium hover:bg-brand-700 disabled:opacity-50">
{busy ? "Joining…" : "Join waitlist"}
</button>
</>
)}
</form>
</div>
)}
</>
);
}
function keyOf(l?: WaitlistLine) {
return l ? `${l.eventOptionId}::${l.variantId || ""}` : "";
}
/** The sold-out lines of an event, for the waitlist button. */
export function soldOutLines(options: { id: string; name: string; stockLimit?: number; availableCount?: number | null; variants?: { id: string; name: string; stockLimit?: number; availableCount?: number | null }[] | null }[]): WaitlistLine[] {
const out: WaitlistLine[] = [];
for (const o of options || []) {
const optOut = (o.stockLimit ?? 0) > 0 && o.availableCount !== undefined && o.availableCount !== null && o.availableCount <= 0;
const variants = o.variants || [];
if (variants.length > 0) {
for (const v of variants) {
const vOut = (v.stockLimit ?? 0) > 0 && v.availableCount !== undefined && v.availableCount !== null && v.availableCount <= 0;
if (optOut || vOut) out.push({ eventOptionId: o.id, variantId: v.id, label: `${o.name} (${v.name})` });
}
} else if (optOut) {
out.push({ eventOptionId: o.id, label: o.name });
}
}
return out;
}
@@ -13,6 +13,9 @@ import {
FileBarChart,
BarChart3,
ClipboardList,
TicketPercent,
ShieldCheck,
HeartPulse,
} from "lucide-react";
export type ReportCategory = "Financial" | "Registration" | "Ticketing" | "Donations" | "Cashup" | "Other";
@@ -33,6 +36,9 @@ export const REPORTS = [
{ key: "financeReport", label: "Finance report (revenue & costs)", description: "Revenue & costs overview", category: "Financial", icon: FileBarChart, fields: 6 },
{ key: "profitReport", label: "Profit report", description: "View profit report", category: "Financial", icon: BarChart3, fields: 5 },
{ key: "cashupAudit", label: "Cashup audit trail", description: "Audit trail of cashup actions", category: "Cashup", icon: ClipboardList, fields: 6 },
{ key: "consentStatus", label: "Parental consent status", description: "Youth events: consent per attendee", category: "Registration", icon: ShieldCheck, fields: 5 },
{ key: "medicalList", label: "Medical & emergency list", description: "Youth events: confidential list for leaders (audited)", category: "Registration", icon: HeartPulse, fields: 5 },
{ key: "discounts", label: "Discounts (coupon codes)", description: "Uses and value given away per code and event", category: "Financial", icon: TicketPercent, fields: 4 },
] as const satisfies ReadonlyArray<{
key: string;
label: string;
+197 -3
View File
@@ -5,7 +5,7 @@ import { useSearchParams } from "next/navigation";
import { useAuth } from "@/hooks/useAuth";
import { apiFetch, API_BASE } from "@/lib/api";
import { downloadReportExcel, emailReportPdf, whatsappReportPdf, viewReportPdf, type ReportPdfPayload } from "@/lib/export";
import { Printer, Mail, FileSpreadsheet, MessageCircle, ShoppingCart, CreditCard, Gift, Clock, HandHeart, Search, TrendingUp } from "lucide-react";
import { Printer, Mail, FileSpreadsheet, MessageCircle, ShoppingCart, CreditCard, Gift, Clock, HandHeart, Search, TrendingUp, TicketPercent, ShieldCheck } from "lucide-react";
import { REPORTS, type ReportKey } from "./ReportCatalog";
import ReportsShell from "./ReportsShell";
import ReportViewerModal, { ReportActionButton } from "./ReportViewerModal";
@@ -173,6 +173,10 @@ export default function ReportsV2({ onBack }: { onBack?: () => void } = {}) {
const [ticketUsage, setTicketUsage] = useState<Record<string, { used: number; unused: number }>>({});
const [financialsByEvent, setFinancialsByEvent] = useState<Record<string, any>>({});
const [auditRows, setAuditRows] = useState<any[]>([]);
const [discountRows, setDiscountRows] = useState<any[]>([]);
// Youth events (one row per attendee), each tagged with its event title.
const [consentRows, setConsentRows] = useState<any[]>([]);
const [medicalRows, setMedicalRows] = useState<any[]>([]);
// Deep-linking: dashboards link here with ?report=<key>&range=trailing_month to jump
// straight into a specific report, pre-filtered to every currently-visible event and the
@@ -282,6 +286,33 @@ export default function ReportsV2({ onBack }: { onBack?: () => void } = {}) {
return byEv;
};
// Coupon uses (active redemptions on non-cancelled registrations) for the Discounts report.
const loadDiscounts = async (eventIds: string[]) => {
if (!token || !eventIds.length) return [] as any[];
const query = new URLSearchParams({ eventIds: eventIds.join(",") });
if (dateFrom) query.set("from", dateFrom);
if (dateTo) query.set("to", dateTo);
try {
return await apiFetch<any[]>(`/api/coupons/redemptions?${query.toString()}`, { authToken: token });
} catch (e) {
return [];
}
};
const loadConsent = async (eventIds: string[], kind: "status" | "medical") => {
if (!token) return [] as any[];
const out: any[] = [];
for (const id of eventIds) {
const ev = filteredEvents.find((e: any) => e.id === id);
if (!ev?.requiresParentalConsent) continue;
try {
const res = await apiFetch<any>(`/api/consent/events/${encodeURIComponent(id)}${kind === "medical" ? "/medical" : ""}`, { authToken: token });
(res?.rows || []).forEach((r: any) => out.push({ ...r, eventTitle: ev.title }));
} catch { /* not allowed or not found — skip */ }
}
return out;
};
const loadCashupAudit = async (eventIds: string[]) => {
if (!token) return [] as any[];
const query = new URLSearchParams();
@@ -298,6 +329,29 @@ export default function ReportsV2({ onBack }: { onBack?: () => void } = {}) {
}
};
// Discounts report: one row per code per event.
const discountSummary = useMemo(() => {
const byKey = new Map<string, { code: string; summary: string; event: string; uses: number; total: number }>();
for (const r of discountRows) {
const event = r.registration?.event?.title || "—";
const key = `${r.coupon?.code}__${event}`;
const row = byKey.get(key) || { code: r.coupon?.code || "?", summary: r.summary || "", event, uses: 0, total: 0 };
row.uses += 1;
row.total += Number(r.discountAmount || 0);
byKey.set(key, row);
}
const rows = Array.from(byKey.values()).sort((a, b) => b.total - a.total);
const byCode = new Map<string, number>();
rows.forEach(r => byCode.set(r.code, (byCode.get(r.code) || 0) + r.total));
return {
rows,
totalUses: rows.reduce((s, r) => s + r.uses, 0),
totalValue: rows.reduce((s, r) => s + r.total, 0),
codes: byCode.size,
chart: Array.from(byCode.entries()).map(([label, value]) => ({ label, value })),
};
}, [discountRows]);
// View handler according to selected report
const onView = async () => {
setBusy(true);
@@ -351,6 +405,12 @@ export default function ReportsV2({ onBack }: { onBack?: () => void } = {}) {
} else if (report === "cashupAudit") {
const rows = await loadCashupAudit(selectedEventIds);
setAuditRows(rows);
} else if (report === "discounts") {
setDiscountRows(await loadDiscounts(selectedEventIds));
} else if (report === "consentStatus") {
setConsentRows(await loadConsent(selectedEventIds, "status"));
} else if (report === "medicalList") {
setMedicalRows(await loadConsent(selectedEventIds, "medical"));
}
setReady(true);
} finally {
@@ -924,6 +984,53 @@ export default function ReportsV2({ onBack }: { onBack?: () => void } = {}) {
table: { columns: ["Event", "Donations (R)", "Used (R)", "Unused (R)", "Count"], rows }
};
}
if (report === "consentStatus") {
const minors = consentRows.filter(r => r.minor);
const count = (s: string) => minors.filter(r => r.status === s).length;
return {
title: 'Parental consent status', subtitle, kind: 'table', orientation: 'landscape',
stats: [
{ label: 'Given', value: String(count('given')), tone: 'green' },
{ label: 'Pending', value: String(count('pending')), tone: 'amber' },
{ label: 'Declined / missing', value: String(count('declined') + count('missing')), tone: 'rose' },
],
note: 'Under-18 attendees on youth events. Pending, declined or missing consent blocks check-in.',
table: {
columns: ["Event", "Attendee", "Date of birth", "Parent / guardian", "Consent", "Answered"],
rows: minors.map(r => [r.eventTitle, r.name, r.dateOfBirth ? new Date(r.dateOfBirth).toLocaleDateString() : '', [r.parentName, r.parentPhone].filter(Boolean).join(' · '), r.status + (r.request?.outdated ? ' (older text)' : ''), r.request?.respondedAt ? `${r.request.signedName || ''} ${new Date(r.request.respondedAt).toLocaleDateString()}`.trim() : '']),
},
};
}
if (report === "medicalList") {
const fmt = (m: any) => m ? [m.allergies && `Allergies: ${m.allergies}`, m.medication && `Medication: ${m.medication}`, m.conditions && `Conditions: ${m.conditions}`, m.medicalAid && `Medical aid: ${m.medicalAid}`].filter(Boolean).join('; ') : '';
return {
title: 'Medical & emergency list', subtitle, kind: 'table', orientation: 'landscape',
note: 'CONFIDENTIAL — for event leaders only. Viewing this list is recorded in the audit log.',
table: {
columns: ["Event", "Attendee", "Medical", "Emergency contact", "Parent / guardian", "Photos"],
rows: medicalRows.map(r => [r.eventTitle, r.name, r.purged ? 'Deleted after the event' : fmt(r.medical), r.emergency ? `${r.emergency.name} ${r.emergency.phone}${r.emergency.relationship ? ` (${r.emergency.relationship})` : ''}` : '', [r.parentName, r.parentPhone].filter(Boolean).join(' · '), r.photoConsent === null || r.photoConsent === undefined ? '' : r.photoConsent ? 'Yes' : 'No']),
},
};
}
if (report === "discounts") {
return {
title: 'Discounts (coupon codes)', subtitle, kind: 'table', orientation: 'portrait',
stats: [
{ label: 'Value given away', value: `R ${discountSummary.totalValue.toFixed(2)}`, tone: 'rose' },
{ label: 'Uses', value: String(discountSummary.totalUses), tone: 'violet' },
{ label: 'Codes used', value: String(discountSummary.codes), tone: 'blue' },
],
chart: { title: 'Value given away, by code', data: discountSummary.chart.map(c => ({ ...c, displayValue: `R ${c.value.toFixed(2)}` })) },
note: 'Counts codes on registrations that are not cancelled. Cancelling a registration frees its code.',
table: {
columns: ["Code", "Discount", "Event", "Uses", "Value (R)"],
rows: [
...discountSummary.rows.map(r => [r.code, r.summary, r.event, r.uses, Number(r.total.toFixed(2))]),
["Total", "", "", discountSummary.totalUses, Number(discountSummary.totalValue.toFixed(2))],
],
},
};
}
if (report === "cashup") {
const rows: (string | number)[][] = [];
const incomeByMethod: Record<string, number> = {};
@@ -958,7 +1065,7 @@ export default function ReportsV2({ onBack }: { onBack?: () => void } = {}) {
const ev = filteredEvents.find(e => e.id === evId);
if (!f) return;
const title = ev?.title || evId;
sold += (f.salesByOption || []).reduce((s: number, o: any) => s + (o.revenue || 0), 0);
sold += (f.salesByOption || []).reduce((s: number, o: any) => s + (o.revenue || 0), 0) - (f.discountsTotal || 0);
paidByAll += METHOD_KEYS.reduce((s, m) => s + (f.paymentsByMethod?.[m] || 0), 0);
METHOD_KEYS.forEach(m => { paidByMethod[m] = (paidByMethod[m] || 0) + (f.paymentsByMethod?.[m] || 0); });
donationsProfit += f.unallocatedDonationsTotal || 0;
@@ -972,6 +1079,7 @@ export default function ReportsV2({ onBack }: { onBack?: () => void } = {}) {
// as profit" — no need to repeat it here.
rows.push([title, "Sales", "What was sold", ""]);
(f.salesByOption || []).forEach((s: any) => rows.push([title, "", `${s.name} (${s.quantitySold} sold)`, Number(s.revenue.toFixed(2))]));
if (f.discountsTotal > 0) rows.push([title, "", "Less discount codes", -Number(f.discountsTotal.toFixed(2))]);
rows.push([title, "Paid by", "", ""]);
METHOD_KEYS.forEach(m => rows.push([title, "", METHOD_LABEL[m], Number((f.paymentsByMethod?.[m] || 0).toFixed(2))]));
rows.push([title, "Cashup comparison", "", ""]);
@@ -1108,6 +1216,9 @@ export default function ReportsV2({ onBack }: { onBack?: () => void } = {}) {
financeReport: "Finance Report",
profitReport: "Profit Report",
cashupAudit: "Cashup Audit Trail",
discounts: "Discounts Report",
consentStatus: "Parental Consent Status",
medicalList: "Medical & Emergency List",
};
const doPrintPdf = async () => {
@@ -1558,6 +1669,89 @@ export default function ReportsV2({ onBack }: { onBack?: () => void } = {}) {
)}
</div>
)}
{(report === "consentStatus" || report === "medicalList") && (() => {
const payload: any = buildReportPayload();
const rows: any[] = payload?.table?.rows || [];
const cols: string[] = payload?.table?.columns || [];
return (
<div>
{report === "medicalList" && <div className="mb-3 text-sm text-red-700 font-medium">Confidential — for event leaders only. Viewing this list is recorded in the audit log.</div>}
{payload?.stats && (
<StatTileRow>
{payload.stats.map((s: any) => <StatTile key={s.label} icon={ShieldCheck} label={s.label} value={s.value} tone={s.tone} />)}
</StatTileRow>
)}
{rows.length === 0 ? (
<div className="text-sm text-gray-500 mt-3">Nothing to show — pick youth events (ones that need parental consent) in the filters.</div>
) : (
<div className="mt-4 overflow-auto border border-gray-100 rounded-xl">
<table className="min-w-[760px] w-full text-sm">
<thead>
<tr className="text-left text-gray-500 text-xs uppercase tracking-wide bg-gray-50">
{cols.map(c => <th key={c} className="py-2 px-3">{c}</th>)}
</tr>
</thead>
<tbody>
{rows.map((r, i) => (
<tr key={i} className="border-t border-gray-100 align-top">
{r.map((cell: any, j: number) => <td key={j} className="py-2 px-3">{String(cell ?? '')}</td>)}
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
);
})()}
{report === "discounts" && (
<div>
{discountSummary.rows.length === 0 ? (
<div className="text-sm text-gray-500">No discount codes used for the selected events and dates.</div>
) : (
<>
<StatTileRow>
<StatTile icon={TicketPercent} label="Value given away" value={`R ${discountSummary.totalValue.toFixed(2)}`} tone="rose" />
<StatTile icon={ShoppingCart} label="Uses" value={String(discountSummary.totalUses)} tone="violet" />
<StatTile icon={Gift} label="Codes used" value={String(discountSummary.codes)} tone="blue" />
</StatTileRow>
<div className="mt-4 border border-gray-100 rounded-xl p-4">
<div className="text-xs text-gray-500 mb-3">Value given away, by code</div>
<HorizontalBarChart data={discountSummary.chart} valueFormatter={v => `R ${v.toFixed(2)}`} />
</div>
<div className="mt-4 overflow-auto border border-gray-100 rounded-xl">
<table className="min-w-[640px] w-full">
<thead>
<tr className="text-left text-gray-500 text-xs uppercase tracking-wide bg-gray-50">
<th className="py-2 px-3">Code</th>
<th className="py-2 px-3">Discount</th>
<th className="py-2 px-3">Event</th>
<th className="py-2 px-3 text-right">Uses</th>
<th className="py-2 px-3 text-right">Value (R)</th>
</tr>
</thead>
<tbody>
{discountSummary.rows.map(row => (
<tr key={`${row.code}-${row.event}`} className="border-t border-gray-100">
<td className="py-2 px-3 font-mono">{row.code}</td>
<td className="py-2 px-3">{row.summary}</td>
<td className="py-2 px-3">{row.event}</td>
<td className="py-2 px-3 text-right">{row.uses}</td>
<td className="py-2 px-3 text-right">R {row.total.toFixed(2)}</td>
</tr>
))}
<tr className="font-semibold bg-gray-50 border-t border-gray-200">
<td className="py-2 px-3" colSpan={3}>Total</td>
<td className="py-2 px-3 text-right">{discountSummary.totalUses}</td>
<td className="py-2 px-3 text-right">R {discountSummary.totalValue.toFixed(2)}</td>
</tr>
</tbody>
</table>
</div>
</>
)}
</div>
)}
{report === "donations" && (
<div>
{Object.keys(paymentsByEvent).length === 0 ? (
@@ -1878,7 +2072,7 @@ export default function ReportsV2({ onBack }: { onBack?: () => void } = {}) {
<div className="font-medium text-gray-900">{ev?.title || evId}</div>
<StatTileRow>
<StatTile icon={ShoppingCart} label="What was sold" value={`R ${(f.salesByOption || []).reduce((s: number, o: any) => s + (o.revenue || 0), 0).toFixed(2)}`} tone="green" />
<StatTile icon={ShoppingCart} label={f.discountsTotal > 0 ? "What was sold (after discounts)" : "What was sold"} value={`R ${((f.salesByOption || []).reduce((s: number, o: any) => s + (o.revenue || 0), 0) - (f.discountsTotal || 0)).toFixed(2)}`} tone="green" />
<StatTile icon={CreditCard} label="Paid by (all methods)" value={`R ${METHOD_KEYS.reduce((s, m) => s + (f.paymentsByMethod?.[m] || 0), 0).toFixed(2)}`} tone="blue" />
<StatTile icon={HandHeart} label="Donations counted as profit" value={`R ${(f.unallocatedDonationsTotal || 0).toFixed(2)}`} tone="rose" />
<StatTile icon={FileSpreadsheet} label="Total costs" value={`R ${(f.totalCosts || 0).toFixed(2)}`} tone="amber" />
@@ -0,0 +1,106 @@
"use client";
import React, { useEffect, useState } from "react";
import { Wifi, WifiOff, CloudUpload, Lock } from "lucide-react";
import { apiFetch } from "@/lib/api";
import type { OtherLock } from "./useOfflineScanning";
type Offline = ReturnType<typeof import("./useOfflineScanning").useOfflineScanning>;
/**
* Offline-mode controls above the scanner: shows who holds which section, lets this
* device lock the selected section and download its tickets, and shows the sync queue.
*/
export function OfflineScanPanel({ offline, token, eventId, eventTitle, sectionId, sectionName, onError, onInfo }: {
offline: Offline;
token: string | null;
eventId: string;
eventTitle: string;
sectionId: string | null;
sectionName: string | null;
onError: (m: string) => void;
onInfo: (m: string) => void;
}) {
const [locks, setLocks] = useState<OtherLock[]>([]);
const [busy, setBusy] = useState(false);
const { lock, pending, online, syncing } = offline;
useEffect(() => {
if (!token || !eventId || eventId === "all" || !online) { setLocks([]); return; }
apiFetch<OtherLock[]>(`/api/scan-locks?eventId=${encodeURIComponent(eventId)}`, { authToken: token })
.then(setLocks).catch(() => setLocks([]));
}, [token, eventId, online, lock]);
const others = locks.filter(l => l.deviceId !== offline.deviceId());
const enable = async () => {
setBusy(true);
try {
const n = await offline.enable(eventId, eventTitle, sectionId, sectionName);
onInfo(`Offline mode on — ${n} ticket(s) downloaded. You can keep scanning without a connection.`);
} catch (e: any) {
onError(e?.message || "Couldn't turn on offline mode");
} finally {
setBusy(false);
}
};
const release = async () => {
setBusy(true);
try {
await offline.release();
onInfo("Offline mode ended and the section is unlocked.");
} catch (e: any) {
onError(e?.message || "Couldn't end offline mode");
} finally {
setBusy(false);
}
};
const syncNow = async () => {
try {
const r = await offline.sync();
if (r) onInfo(`Synced ${r.accepted} scan(s)${r.conflicts ? ` — ${r.conflicts} need a supervisor to review` : ""}.`);
} catch (e: any) {
onError(e?.message || "Sync failed — scans are kept on this device and will retry.");
}
};
return (
<div className={`rounded-xl border p-3 mb-4 text-sm ${lock ? "bg-amber-50 border-amber-200" : "bg-white"}`}>
<div className="flex flex-wrap items-center gap-2">
{online ? <Wifi className="w-4 h-4 text-green-600" /> : <WifiOff className="w-4 h-4 text-red-600" />}
<span className={online ? "text-green-700" : "text-red-700"}>{online ? "Online" : "Offline"}</span>
{lock ? (
<>
<span className="text-gray-400">·</span>
<span className="font-medium text-amber-900">Offline mode: {lock.eventTitle}{lock.sectionName ? ` — ${lock.sectionName}` : " — whole event"}</span>
<span className="text-xs text-gray-600">Ticket list downloaded at {new Date(lock.snapshotAt).toLocaleTimeString()}</span>
<span className="text-xs text-gray-600">· {pending} scan(s) waiting to sync</span>
<div className="ml-auto flex gap-2">
<button onClick={syncNow} disabled={!online || syncing || pending === 0} className="inline-flex items-center gap-1 px-2.5 py-1 rounded border bg-white text-xs disabled:opacity-50"><CloudUpload className="w-3.5 h-3.5" /> {syncing ? "Syncing…" : "Sync now"}</button>
<button onClick={release} disabled={!online || busy} className="px-2.5 py-1 rounded bg-amber-600 text-white text-xs disabled:opacity-50">Release section</button>
</div>
</>
) : eventId && eventId !== "all" ? (
<div className="ml-auto">
<button onClick={enable} disabled={!online || busy || others.some(l => !l.sectionId || !sectionId || l.sectionId === sectionId)} className="px-2.5 py-1 rounded border border-brand-300 text-brand-700 text-xs hover:bg-brand-50 disabled:opacity-50" title="Lock this section to this device and download its tickets, so you can keep scanning if the connection drops">
{busy ? "Preparing…" : `Enable offline mode${sectionName ? ` for ${sectionName}` : " (whole event)"}`}
</button>
</div>
) : (
<span className="text-xs text-gray-500 ml-1">Pick an event to use offline mode.</span>
)}
</div>
{others.length > 0 && (
<ul className="mt-2 space-y-0.5">
{others.map(l => (
<li key={l.id} className="flex items-center gap-1.5 text-xs text-gray-700">
<Lock className="w-3.5 h-3.5 text-amber-600" />
{l.sectionName ? `"${l.sectionName}"` : "The whole event"} is offline-locked by {l.user.name}{l.deviceName ? ` on ${l.deviceName}` : ""} — scan it on that device.
</li>
))}
</ul>
)}
{lock && !online && <p className="mt-2 text-xs text-amber-900">Scans are saved on this device and will sync when the connection is back. Tickets bought after the download time won&apos;t be on the list.</p>}
</div>
);
}
@@ -0,0 +1,115 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { apiFetch } from "@/lib/api";
import {
OfflineLock, SnapshotTicket, clearOffline, deviceId, deviceName, findTicket, getOfflineLock,
queuedScans, recordScan, removeQueued, startOffline,
} from "@/lib/offlineScan";
export type OtherLock = { id: string; sectionId: string | null; sectionName: string | null; deviceId: string; deviceName: string | null; user: { name: string } };
/**
* Offline mode for the ticket scanner: lock a section to this device, download its
* tickets, scan against them with no connection, and sync the queued scans whenever
* the device is back online.
*/
export function useOfflineScanning(token: string | null) {
const [lock, setLock] = useState<OfflineLock | null>(null);
const [pending, setPending] = useState(0);
const [online, setOnline] = useState(true);
const [syncing, setSyncing] = useState(false);
const [lastSync, setLastSync] = useState<{ accepted: number; conflicts: number; at: string } | null>(null);
const syncingRef = useRef(false);
const refreshPending = useCallback(async () => {
try { setPending((await queuedScans()).length); } catch { setPending(0); }
}, []);
useEffect(() => {
setLock(getOfflineLock());
refreshPending();
setOnline(typeof navigator === "undefined" ? true : navigator.onLine);
const up = () => setOnline(true);
const down = () => setOnline(false);
window.addEventListener("online", up);
window.addEventListener("offline", down);
// Cache the scanner page and everything it has loaded, so it opens offline.
if ("serviceWorker" in navigator) {
navigator.serviceWorker.register("/sw.js").then(async () => {
const reg = await navigator.serviceWorker.ready;
const urls = [window.location.pathname, ...performance.getEntriesByType("resource").map(e => e.name)];
reg.active?.postMessage({ type: "cache-urls", urls });
}).catch(() => {});
}
return () => { window.removeEventListener("online", up); window.removeEventListener("offline", down); };
}, [refreshPending]);
/** Uploads queued scans. Returns the server's verdict, or null when nothing was sent. */
const sync = useCallback(async () => {
if (!token || syncingRef.current) return null;
const queue = await queuedScans();
if (queue.length === 0) return null;
syncingRef.current = true;
setSyncing(true);
try {
const byLock = new Map<string, typeof queue>();
queue.forEach(q => byLock.set(q.lockId, [...(byLock.get(q.lockId) || []), q]));
let accepted = 0, conflicts = 0;
for (const [lockId, scans] of byLock) {
const res = await apiFetch<{ accepted: number; duplicates: number; conflicts: any[] }>(`/api/scan-locks/${lockId}/sync`, {
method: "POST", authToken: token, body: { deviceId: deviceId(), scans },
});
accepted += res.accepted;
conflicts += res.conflicts.length;
// Synced (or already on the server) — either way they're done on this device.
await removeQueued(scans.map(s => s.clientScanId));
}
const result = { accepted, conflicts, at: new Date().toISOString() };
setLastSync(result);
return result;
} finally {
syncingRef.current = false;
setSyncing(false);
refreshPending();
}
}, [token, refreshPending]);
// Sync as soon as the connection is back, and every 30 seconds while online.
useEffect(() => {
if (!online || !token) return;
sync().catch(() => {});
const t = setInterval(() => { if (navigator.onLine) sync().catch(() => {}); }, 30000);
return () => clearInterval(t);
}, [online, token, sync]);
const enable = useCallback(async (eventId: string, eventTitle: string, sectionId: string | null, sectionName: string | null) => {
if (!token) throw new Error("Log in first.");
const created = await apiFetch<{ id: string }>("/api/scan-locks", {
method: "POST", authToken: token, body: { eventId, sectionId, deviceId: deviceId(), deviceName: deviceName() },
});
const snap = await apiFetch<{ snapshotAt: string; tickets: SnapshotTicket[] }>(`/api/scan-locks/${created.id}/snapshot?deviceId=${encodeURIComponent(deviceId())}`, { authToken: token });
const next: OfflineLock = { lockId: created.id, eventId, eventTitle, sectionId, sectionName, snapshotAt: snap.snapshotAt };
await startOffline(next, snap.tickets);
setLock(next);
return snap.tickets.length;
}, [token]);
const release = useCallback(async () => {
if (!lock || !token) return;
await sync();
if ((await queuedScans()).length > 0) throw new Error("Some scans haven't synced yet. Try again when you're online.");
await apiFetch(`/api/scan-locks/${lock.lockId}`, { method: "DELETE", authToken: token, body: { deviceId: deviceId() } });
await clearOffline();
setLock(null);
}, [lock, token, sync]);
const scanLocally = useCallback(async (ticket: SnapshotTicket, qty: number) => {
if (!lock) throw new Error("Offline mode is not on.");
await recordScan(lock.lockId, ticket, qty);
await refreshPending();
if (navigator.onLine) sync().catch(() => {});
}, [lock, refreshPending, sync]);
return { lock, pending, online, syncing, lastSync, enable, release, sync, scanLocally, findTicket, deviceId };
}
@@ -0,0 +1,196 @@
"use client";
import React from "react";
import { Users } from "lucide-react";
export type AttendeeOption = {
id: string;
name: string;
isMainTicket?: boolean;
variants?: { id: string; name: string }[] | null;
};
export type AttendeeEntry = {
name: string; email: string; phone: string;
dateOfBirth?: string; parentName?: string; parentEmail?: string; parentPhone?: string;
};
export type AttendeeEntries = Record<string, AttendeeEntry>;
export type TicketDelivery = "payer" | "each";
export type AttendeePayload = {
eventOptionId: string; variantId?: string | null; name: string; email?: string; phone?: string;
dateOfBirth?: string; parentName?: string; parentEmail?: string; parentPhone?: string;
};
/** Under 18 on the event's start date (mirrors backend utils/consent.js isMinor). */
export function isMinorOn(dateOfBirth: string | undefined, eventStart: string | undefined): boolean {
if (!dateOfBirth || !eventStart) return false;
const dob = new Date(dateOfBirth);
const on = new Date(eventStart);
if (isNaN(dob.getTime()) || isNaN(on.getTime())) return false;
let age = on.getUTCFullYear() - dob.getUTCFullYear();
if (on.getUTCMonth() < dob.getUTCMonth() || (on.getUTCMonth() === dob.getUTCMonth() && on.getUTCDate() < dob.getUTCDate())) age -= 1;
return age < 18;
}
type Slot = { key: string; eventOptionId: string; variantId: string | null; label: string };
/**
* One slot per main-ticket place in the basket. `qtyFor(optionId, variantId)` reads the
* caller's quantities (each page keys its quantity map differently).
*/
function slotsFor(options: AttendeeOption[], qtyFor: (optionId: string, variantId: string | null) => number): Slot[] {
const slots: Slot[] = [];
for (const o of options) {
if (!o.isMainTicket) continue;
const variants = o.variants || [];
const lines = variants.length > 0 ? variants.map(v => ({ variantId: v.id, label: `${o.name} (${v.name})` })) : [{ variantId: null, label: o.name }];
for (const line of lines) {
const qty = qtyFor(o.id, line.variantId);
for (let i = 0; i < qty; i++) {
slots.push({ key: `${o.id}::${line.variantId || ""}#${i}`, eventOptionId: o.id, variantId: line.variantId, label: `${line.label} ${i + 1}` });
}
}
}
return slots;
}
/** The named places as the API expects them (unnamed places are left out). */
export function attendeesPayload(options: AttendeeOption[], qtyFor: (optionId: string, variantId: string | null) => number, entries: AttendeeEntries): AttendeePayload[] {
return slotsFor(options, qtyFor)
.map(s => ({ s, e: entries[s.key] }))
.filter(({ e }) => e && e.name.trim())
.map(({ s, e }) => ({
eventOptionId: s.eventOptionId,
...(s.variantId ? { variantId: s.variantId } : {}),
name: e.name.trim(),
...(e.email.trim() ? { email: e.email.trim() } : {}),
...(e.phone.trim() ? { phone: e.phone.trim() } : {}),
...(e.dateOfBirth ? { dateOfBirth: e.dateOfBirth } : {}),
...(e.parentName?.trim() ? { parentName: e.parentName.trim() } : {}),
...(e.parentEmail?.trim() ? { parentEmail: e.parentEmail.trim() } : {}),
...(e.parentPhone?.trim() ? { parentPhone: e.parentPhone.trim() } : {}),
}));
}
/**
* Youth events: what's still missing before registering (every place named with a date of
* birth; parent details for minors). Empty when everything's in.
*/
export function consentDetailsMissing(options: AttendeeOption[], qtyFor: (optionId: string, variantId: string | null) => number, entries: AttendeeEntries, eventStart: string): string | null {
for (const s of slotsFor(options, qtyFor)) {
const e = entries[s.key];
if (!e?.name.trim()) return `Add a name for ${s.label}.`;
if (!e.dateOfBirth) return `Add a date of birth for ${e.name.trim()}.`;
if (isMinorOn(e.dateOfBirth, eventStart) && (!e.parentName?.trim() || !e.parentEmail?.trim() || !e.parentPhone?.trim())) {
return `${e.name.trim()} is under 18 — add a parent or guardian's name, email and phone.`;
}
}
return null;
}
/**
* "Registering for other people?" — a name (and optional email/phone) for each main
* ticket place, plus who gets the tickets. Named places get their own ticket; any left
* blank share one ticket as usual.
*/
export function AttendeeNamesEditor({
options,
qtyFor,
enabled,
onEnabledChange,
entries,
onEntriesChange,
delivery,
onDeliveryChange,
payerLabel = "me",
consentEventStart,
}: {
options: AttendeeOption[];
qtyFor: (optionId: string, variantId: string | null) => number;
enabled: boolean;
onEnabledChange: (v: boolean) => void;
entries: AttendeeEntries;
onEntriesChange: (v: AttendeeEntries) => void;
delivery: TicketDelivery;
onDeliveryChange: (v: TicketDelivery) => void;
payerLabel?: string;
/** Set for youth events: names are required, with a date of birth, and parent details for minors. */
consentEventStart?: string;
}) {
const consent = !!consentEventStart;
const slots = slotsFor(options, qtyFor);
const set = (key: string, field: keyof AttendeeEntry, value: string) => {
const prev: AttendeeEntry = entries[key] || { name: "", email: "", phone: "" };
onEntriesChange({ ...entries, [key]: { ...prev, [field]: value } });
};
const inputCls = "w-full border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-brand-400";
return (
<div className="border rounded-xl p-4 bg-white shadow-sm">
{consent ? (
<div>
<div className="flex items-center gap-2 text-sm font-medium text-gray-800">
<Users className="w-4 h-4 text-brand-600" /> Who&apos;s coming?
</div>
<p className="text-xs text-gray-500 mt-1">This is a youth event: add each person&apos;s name and date of birth. For anyone under 18, a parent or guardian will be asked to give consent by email and WhatsApp before they can be checked in.</p>
</div>
) : (
<label className="flex items-center gap-2 text-sm font-medium text-gray-800 cursor-pointer">
<input type="checkbox" checked={enabled} onChange={e => onEnabledChange(e.target.checked)} />
<Users className="w-4 h-4 text-brand-600" />
Registering for other people?
</label>
)}
{(enabled || consent) && (
<div className="mt-3 space-y-3">
{slots.length === 0 ? (
<p className="text-sm text-gray-500">Choose how many tickets first, then add a name for each person.</p>
) : (
<>
{!consent && <p className="text-xs text-gray-500">Each named person gets their own ticket and their own attendee form. Leave a name blank to keep that ticket with the others.</p>}
{slots.map(s => {
const e = entries[s.key] || { name: "", email: "", phone: "" };
return (
<div key={s.key} className="border rounded-lg p-3">
<div className="text-xs font-semibold text-gray-600 mb-2">{s.label}</div>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-2">
<input className={inputCls} placeholder="Full name" value={e.name} onChange={ev => set(s.key, "name", ev.target.value)} aria-label={`${s.label} name`} />
<input type="email" className={inputCls} placeholder="Email (optional)" value={e.email} onChange={ev => set(s.key, "email", ev.target.value)} aria-label={`${s.label} email`} />
<input type="tel" className={inputCls} placeholder="Phone (optional)" value={e.phone} onChange={ev => set(s.key, "phone", ev.target.value)} aria-label={`${s.label} phone`} />
</div>
{consent && (
<div className="mt-2">
<label className="block text-xs text-gray-600 mb-1">Date of birth</label>
<input type="date" className={`${inputCls} sm:w-48`} value={e.dateOfBirth || ""} max={new Date().toISOString().slice(0, 10)} onChange={ev => set(s.key, "dateOfBirth", ev.target.value)} aria-label={`${s.label} date of birth`} />
{isMinorOn(e.dateOfBirth, consentEventStart) && (
<div className="mt-2 rounded-lg bg-amber-50 border border-amber-200 p-2">
<div className="text-xs font-medium text-amber-900 mb-1">Parent or guardian (they&apos;ll be asked for consent)</div>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-2">
<input className={inputCls} placeholder="Parent/guardian name" value={e.parentName || ""} onChange={ev => set(s.key, "parentName", ev.target.value)} aria-label={`${s.label} parent name`} />
<input type="email" className={inputCls} placeholder="Parent email" value={e.parentEmail || ""} onChange={ev => set(s.key, "parentEmail", ev.target.value)} aria-label={`${s.label} parent email`} />
<input type="tel" className={inputCls} placeholder="Parent phone" value={e.parentPhone || ""} onChange={ev => set(s.key, "parentPhone", ev.target.value)} aria-label={`${s.label} parent phone`} />
</div>
</div>
)}
</div>
)}
</div>
);
})}
<fieldset className="pt-1">
<legend className="text-sm font-medium text-gray-700 mb-1">Send the tickets</legend>
<label className="flex items-center gap-2 text-sm text-gray-700">
<input type="radio" name="ticket-delivery" checked={delivery === "payer"} onChange={() => onDeliveryChange("payer")} /> All to {payerLabel}
</label>
<label className="flex items-center gap-2 text-sm text-gray-700">
<input type="radio" name="ticket-delivery" checked={delivery === "each"} onChange={() => onDeliveryChange("each")} /> Each person their own (by their email or WhatsApp; anyone without contact details goes to {payerLabel})
</label>
</fieldset>
</>
)}
</div>
)}
</div>
);
}
@@ -0,0 +1,202 @@
"use client";
import React, { useMemo, useState } from "react";
import { Users, Send } from "lucide-react";
import { apiFetch } from "@/lib/api";
import { isMinorOn } from "@/components/shared/AttendeeNamesEditor";
const CONSENT_LABEL: Record<string, { text: string; cls: string }> = {
pending: { text: "consent pending", cls: "text-amber-700" },
given: { text: "consent given", cls: "text-green-700" },
declined: { text: "consent declined", cls: "text-red-700" },
};
type Attendee = {
id: string; eventOptionId: string; variantId: string | null; name: string; email: string | null; phone: string | null;
dateOfBirth?: string | null; parentName?: string | null; parentEmail?: string | null; parentPhone?: string | null;
ticket?: { id: string; isUsed: boolean } | null;
consentRequests?: { id: string; status: "pending" | "given" | "declined" }[];
};
type Row = {
id?: string; eventOptionId: string; variantId: string | null; label: string; name: string; email: string; phone: string; locked: boolean;
dateOfBirth: string; parentName: string; parentEmail: string; parentPhone: string;
};
/**
* Named attendees on a registration (group tickets): shows who each main-ticket place
* is for, and lets the payer (or staff) name places, change details, choose where the
* tickets go, and send an attendee the link to their own form.
*/
export function AttendeesPanel({ registration, token, canEdit, hasForm, onSaved, onMessage }: {
registration: any;
token: string;
canEdit: boolean;
hasForm: boolean;
onSaved: () => void;
onMessage: (msg: string) => void;
}) {
const attendees: Attendee[] = registration.attendees || [];
const youth = !!registration.event?.requiresParentalConsent;
const eventStart: string | undefined = registration.event?.startDate;
const [editing, setEditing] = useState(false);
const [rows, setRows] = useState<Row[]>([]);
const [delivery, setDelivery] = useState<"payer" | "each">(registration.ticketDelivery || "payer");
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
// One row per main-ticket place: named ones first (in order), then blanks.
const buildRows = useMemo(() => () => {
const out: Row[] = [];
for (const ro of registration.registrationOptions || []) {
if (!ro.eventOption?.isMainTicket) continue;
const label = `${ro.eventOption?.name || "Ticket"}${ro.variant?.name ? ` (${ro.variant.name})` : ""}`;
const named = attendees.filter(a => a.eventOptionId === ro.eventOptionId && (a.variantId || null) === (ro.variantId || null));
for (let i = 0; i < (ro.quantity || 0); i++) {
const a = named[i];
out.push({
id: a?.id, eventOptionId: ro.eventOptionId, variantId: ro.variantId || null, label: `${label} ${i + 1}`,
name: a?.name || "", email: a?.email || "", phone: a?.phone || "",
dateOfBirth: a?.dateOfBirth ? String(a.dateOfBirth).slice(0, 10) : "",
parentName: a?.parentName || "", parentEmail: a?.parentEmail || "", parentPhone: a?.parentPhone || "",
locked: !!a?.ticket?.isUsed,
});
}
}
return out;
}, [registration, attendees]);
if (!registration.event?.allowNamedAttendees && !youth && attendees.length === 0) return null;
const startEdit = () => { setRows(buildRows()); setDelivery(registration.ticketDelivery || "payer"); setError(null); setEditing(true); };
const update = (i: number, field: "name" | "email" | "phone" | "dateOfBirth" | "parentName" | "parentEmail" | "parentPhone", value: string) =>
setRows(prev => prev.map((r, j) => (j === i ? { ...r, [field]: value } : r)));
const save = async () => {
setBusy(true);
setError(null);
try {
await apiFetch(`/api/registrations/${encodeURIComponent(registration.id)}/attendees`, {
method: "PUT",
authToken: token,
body: {
ticketDelivery: delivery,
attendees: rows.filter(r => r.name.trim()).map(r => ({
...(r.id ? { id: r.id } : {}),
eventOptionId: r.eventOptionId,
...(r.variantId ? { variantId: r.variantId } : {}),
name: r.name.trim(),
email: r.email.trim() || undefined,
phone: r.phone.trim() || undefined,
...(r.dateOfBirth ? { dateOfBirth: r.dateOfBirth } : {}),
...(r.parentName.trim() ? { parentName: r.parentName.trim() } : {}),
...(r.parentEmail.trim() ? { parentEmail: r.parentEmail.trim() } : {}),
...(r.parentPhone.trim() ? { parentPhone: r.parentPhone.trim() } : {}),
})),
},
});
setEditing(false);
onMessage("Attendees saved.");
onSaved();
} catch (e: any) {
setError(e?.message || "Couldn't save attendees");
} finally {
setBusy(false);
}
};
const sendLink = async (a: Attendee) => {
try {
const res = await apiFetch<{ message: string }>(`/api/registrations/${encodeURIComponent(registration.id)}/attendees/${encodeURIComponent(a.id)}/send-form-link`, { method: "POST", authToken: token });
onMessage(res.message);
} catch (e: any) {
setError(e?.message || "Couldn't send the form link");
}
};
const answered = new Set((registration.formResponses || []).map((r: any) => r.attendeeId).filter(Boolean));
const resendConsent = async (requestId: string) => {
try {
const res = await apiFetch<{ message: string }>(`/api/consent/requests/${encodeURIComponent(requestId)}/resend`, { method: "POST", authToken: token });
onMessage(res.message);
} catch (e: any) {
setError(e?.message || "Couldn't re-send the consent request");
}
};
return (
<div>
<div className="font-medium mb-1 flex items-center justify-between">
<span className="flex items-center gap-1.5"><Users className="w-4 h-4 text-brand-600" /> People</span>
{canEdit && !editing && (
<button onClick={startEdit} className="px-2.5 py-1 text-xs bg-brand-600 text-white rounded hover:bg-brand-700">{attendees.length ? "Edit names" : "Add names"}</button>
)}
</div>
{!editing ? (
attendees.length === 0 ? (
<p className="text-sm text-gray-500">No names yet — add a name for each person to give them their own ticket.</p>
) : (
<>
<ul className="text-sm space-y-1">
{attendees.map(a => (
<li key={a.id} className="flex items-center justify-between gap-2">
<span>
{a.name}
{(a.email || a.phone) && <span className="text-xs text-gray-500"> · {[a.email, a.phone].filter(Boolean).join(" · ")}</span>}
{hasForm && <span className={`ml-2 text-xs ${answered.has(a.id) ? "text-green-700" : "text-amber-700"}`}>{answered.has(a.id) ? "form done" : "form to do"}</span>}
{youth && a.consentRequests?.[0] && (
<span className={`ml-2 text-xs ${CONSENT_LABEL[a.consentRequests[0].status]?.cls}`}>{CONSENT_LABEL[a.consentRequests[0].status]?.text}</span>
)}
</span>
{canEdit && youth && a.consentRequests?.[0]?.status === "pending" && (
<button onClick={() => resendConsent(a.consentRequests![0].id)} className="text-xs text-brand-700 hover:underline shrink-0 inline-flex items-center gap-1"><Send className="w-3 h-3" /> Resend consent</button>
)}
{canEdit && hasForm && !answered.has(a.id) && (a.email || a.phone) && (
<button onClick={() => sendLink(a)} className="text-xs text-brand-700 hover:underline shrink-0 inline-flex items-center gap-1"><Send className="w-3 h-3" /> Send form link</button>
)}
</li>
))}
</ul>
<p className="text-xs text-gray-500 mt-1">Tickets go {registration.ticketDelivery === "each" ? "to each person (or you, if they have no contact details)" : "to you"}.</p>
</>
)
) : (
<div className="border rounded p-3 space-y-2 bg-gray-50">
{rows.map((r, i) => (
<div key={i}>
<div className="text-xs font-semibold text-gray-600 mb-1">{r.label}{r.locked && <span className="font-normal text-gray-500"> — checked in, can&apos;t be changed</span>}</div>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-2">
<input className="border rounded px-2 py-1 text-sm" placeholder="Full name" value={r.name} disabled={r.locked} onChange={e => update(i, "name", e.target.value)} aria-label={`${r.label} name`} />
<input className="border rounded px-2 py-1 text-sm" placeholder="Email (optional)" value={r.email} disabled={r.locked} onChange={e => update(i, "email", e.target.value)} aria-label={`${r.label} email`} />
<input className="border rounded px-2 py-1 text-sm" placeholder="Phone (optional)" value={r.phone} disabled={r.locked} onChange={e => update(i, "phone", e.target.value)} aria-label={`${r.label} phone`} />
</div>
{youth && (
<div className="mt-1 space-y-1">
<input type="date" className="border rounded px-2 py-1 text-sm" value={r.dateOfBirth} disabled={r.locked} onChange={e => update(i, "dateOfBirth", e.target.value)} aria-label={`${r.label} date of birth`} />
{isMinorOn(r.dateOfBirth, eventStart) && (
<div className="grid grid-cols-1 sm:grid-cols-3 gap-2">
<input className="border rounded px-2 py-1 text-sm" placeholder="Parent/guardian name" value={r.parentName} disabled={r.locked} onChange={e => update(i, "parentName", e.target.value)} aria-label={`${r.label} parent name`} />
<input className="border rounded px-2 py-1 text-sm" placeholder="Parent email" value={r.parentEmail} disabled={r.locked} onChange={e => update(i, "parentEmail", e.target.value)} aria-label={`${r.label} parent email`} />
<input className="border rounded px-2 py-1 text-sm" placeholder="Parent phone" value={r.parentPhone} disabled={r.locked} onChange={e => update(i, "parentPhone", e.target.value)} aria-label={`${r.label} parent phone`} />
</div>
)}
</div>
)}
</div>
))}
<div className="text-sm pt-1">
<label className="flex items-center gap-2"><input type="radio" checked={delivery === "payer"} onChange={() => setDelivery("payer")} /> Send all tickets to me</label>
<label className="flex items-center gap-2"><input type="radio" checked={delivery === "each"} onChange={() => setDelivery("each")} /> Send each person their own</label>
</div>
{error && <p className="text-xs text-red-600">{error}</p>}
<div className="flex gap-2">
<button onClick={() => setEditing(false)} className="px-3 py-1 text-xs rounded bg-gray-100 hover:bg-gray-200" disabled={busy}>Cancel</button>
<button onClick={save} disabled={busy} className="px-3 py-1 text-xs rounded bg-brand-600 text-white hover:bg-brand-700 disabled:opacity-50">{busy ? "Saving…" : "Save"}</button>
</div>
</div>
)}
{!editing && error && <p className="text-xs text-red-600 mt-1">{error}</p>}
</div>
);
}
@@ -0,0 +1,140 @@
"use client";
import React, { useEffect, useRef, useState } from "react";
import { TicketPercent, X } from "lucide-react";
import { apiFetch, ApiError } from "@/lib/api";
export type BasketItem = { eventOptionId: string; variantId?: string | null; quantity: number };
export type AppliedCoupon = {
code: string;
summary: string;
discount: number;
subtotal: number;
total: number;
};
/**
* "Have a code?" field. Checks the code against the current basket with
* POST /api/coupons/validate and reports the result through `onChange` — the parent
* sends `applied.code` as `couponCode` when it registers. Re-checks automatically when
* the basket changes, since the discount (or whether the code applies at all) can change.
*
* Staff checking on someone's behalf pass `forUserId` (the attendee), or `null` for a
* walk-in with no account yet.
*/
export function CouponCodeField({
eventId,
basket,
token,
forUserId,
applied,
onChange,
compact = false,
}: {
eventId: string;
basket: BasketItem[];
token?: string | null;
forUserId?: string | null;
applied: AppliedCoupon | null;
onChange: (applied: AppliedCoupon | null) => void;
compact?: boolean;
}) {
const [open, setOpen] = useState(!!applied);
const [code, setCode] = useState(applied?.code || "");
const [checking, setChecking] = useState(false);
const [error, setError] = useState<string | null>(null);
const items = basket.filter(b => b.quantity > 0);
const basketKey = JSON.stringify(items);
const lastKey = useRef(basketKey);
const check = async (value: string) => {
const trimmed = value.trim();
if (!trimmed) return;
if (items.length === 0) { setError("Choose your tickets first."); return; }
setChecking(true);
setError(null);
try {
const body: Record<string, unknown> = { code: trimmed, eventId, options: items };
if (forUserId !== undefined) body.forUserId = forUserId;
const res = await apiFetch<AppliedCoupon & { valid: boolean }>("/api/coupons/validate", { method: "POST", authToken: token || undefined, body });
onChange({ code: res.code, summary: res.summary, discount: res.discount, subtotal: res.subtotal, total: res.total });
} catch (e) {
onChange(null);
setError(e instanceof ApiError ? e.message : "Couldn't check that code. Please try again.");
} finally {
setChecking(false);
}
};
// Basket changed after a code was applied: check it again for the new items.
useEffect(() => {
if (lastKey.current === basketKey) return;
lastKey.current = basketKey;
if (applied) check(applied.code);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [basketKey]);
// Staff switched attendee: restricted codes depend on who it's for.
useEffect(() => {
if (applied) check(applied.code);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [forUserId]);
const remove = () => {
onChange(null);
setCode("");
setError(null);
};
if (!open) {
return (
<button type="button" onClick={() => setOpen(true)} className="inline-flex items-center gap-1.5 text-sm text-brand-600 hover:underline">
<TicketPercent className="w-4 h-4" /> Have a code?
</button>
);
}
if (applied) {
return (
<div className="flex items-center justify-between gap-3 rounded-lg border border-green-200 bg-green-50 px-3 py-2 text-sm">
<div className="flex items-center gap-2 text-green-800">
<TicketPercent className="w-4 h-4 shrink-0" />
<span>
<span className="font-mono font-semibold">{applied.code}</span> — {applied.summary}
{applied.discount > 0 && <span className="font-medium"> (−R {applied.discount.toFixed(2)})</span>}
</span>
</div>
<button type="button" onClick={remove} className="text-green-800 hover:text-green-950" aria-label="Remove code">
<X className="w-4 h-4" />
</button>
</div>
);
}
return (
<div>
{!compact && <label className="block text-sm font-medium text-gray-700 mb-1">Discount code</label>}
<div className="flex gap-2">
<input
value={code}
onChange={e => { setCode(e.target.value.toUpperCase()); setError(null); }}
onKeyDown={e => { if (e.key === "Enter") { e.preventDefault(); check(code); } }}
placeholder="Enter code"
className="flex-1 min-w-0 border rounded-lg px-3 py-2 text-sm font-mono uppercase focus:outline-none focus:ring-2 focus:ring-brand-400"
aria-label="Discount code"
/>
<button
type="button"
onClick={() => check(code)}
disabled={checking || !code.trim()}
className="px-4 py-2 rounded-lg border border-brand-600 text-brand-700 text-sm font-medium hover:bg-brand-50 disabled:opacity-50"
>
{checking ? "Checking…" : "Apply"}
</button>
</div>
{error && <p className="text-xs text-red-600 mt-1">{error}</p>}
</div>
);
}
@@ -0,0 +1,63 @@
"use client";
import React, { useCallback, useEffect, useState } from "react";
import { ListChecks } from "lucide-react";
import { apiFetch } from "@/lib/api";
type Entry = {
id: string;
status: "waiting" | "offered";
quantity: number;
offeredQuantity: number | null;
offerExpiresAt: string | null;
event: { id: string; title: string; startDate: string };
eventOption: { name: string };
variant: { name: string } | null;
};
/** My Events: the waitlists I'm on, with Leave (and a note when a place is on offer). */
export function MyWaitlists({ token, onMessage }: { token: string; onMessage: (m: string) => void }) {
const [entries, setEntries] = useState<Entry[]>([]);
const load = useCallback(() => {
apiFetch<Entry[]>("/api/waitlist/mine", { authToken: token }).then(setEntries).catch(() => setEntries([]));
}, [token]);
useEffect(() => { load(); }, [load]);
if (entries.length === 0) return null;
const leave = async (id: string) => {
try {
const res = await apiFetch<{ message: string }>(`/api/waitlist/${encodeURIComponent(id)}`, { method: "DELETE", authToken: token });
onMessage(res.message);
load();
} catch (e: any) {
onMessage(e?.message || "Couldn't leave the waitlist");
}
};
return (
<section className="border rounded-xl p-4 bg-white shadow-sm">
<div className="flex items-center gap-2 font-semibold text-gray-900 mb-2">
<ListChecks className="w-4 h-4 text-brand-600" /> Waitlists
</div>
<ul className="divide-y">
{entries.map(e => (
<li key={e.id} className="py-2 flex items-center justify-between gap-3 text-sm">
<div>
<div className="font-medium">{e.event.title}</div>
<div className="text-xs text-gray-500">{e.quantity} × {e.eventOption.name}{e.variant ? ` (${e.variant.name})` : ""}</div>
{e.status === "offered" ? (
<div className="text-xs text-green-700 mt-0.5">
{e.offeredQuantity} place(s) held for you until {e.offerExpiresAt ? new Date(e.offerExpiresAt).toLocaleString() : ""} — use the link we sent you to claim.
</div>
) : (
<div className="text-xs text-gray-500 mt-0.5">Waiting — we&apos;ll email or WhatsApp you if a place opens up.</div>
)}
</div>
<button onClick={() => leave(e.id)} className="px-2.5 py-1 text-xs rounded border hover:bg-gray-50 shrink-0">Leave</button>
</li>
))}
</ul>
</section>
);
}
+6
View File
@@ -27,6 +27,12 @@ export const authHelpContent: HelpContent = {
<GuideItem icon={LogIn} title="Already registered as a guest?" tone="green">
If you registered as a guest before, you can still create a full account later using the same email — your past registrations will be there once you&apos;re logged in.
</GuideItem>
<GuideItem icon={ShieldCheck} title="Asked for a verification code?" tone="amber">
If you&apos;ve turned on two-factor authentication, we send a 6-digit code by email or WhatsApp after your password. Enter it within 10 minutes, or use &quot;Resend code&quot; if it doesn&apos;t arrive. Tick &quot;Trust this device&quot; to skip the code on that device for 30 days. You can turn two-factor on or off from Profile &amp; Security.
</GuideItem>
<GuideItem icon={ShieldCheck} title="Admin or supervisor?" tone="amber">
Two-factor authentication is required for admin and supervisor accounts. If it isn&apos;t on yet, your next login sends you a code, and entering it turns two-factor on. You can change whether codes come by email or WhatsApp from Profile &amp; Security, but you can&apos;t turn it off.
</GuideItem>
</div>
</div>
),
+5 -2
View File
@@ -1,5 +1,5 @@
import React from "react";
import { Calendar, Ticket, CreditCard, HandHeart, Printer, CheckCircle2, ClipboardList, Clock, SquarePen, History, CheckSquare, Receipt, FileText } from "lucide-react";
import { Calendar, Ticket, CreditCard, HandHeart, Printer, CheckCircle2, ClipboardList, Clock, SquarePen, History, CheckSquare, Receipt, FileText, Users } from "lucide-react";
import { GuideItem } from "@/components/shared/GuideItem";
import type { HelpContent } from "./types";
@@ -34,11 +34,14 @@ export const dashboardUserHelpContent: HelpContent = {
Tap a registration to change quantities or options, pay what&apos;s outstanding, or cancel it entirely.
</GuideItem>
<GuideItem icon={FileText} title="Invoice" tone="rose">
The Invoice button sends a PDF breakdown of the registration — itemized cost, any early-bird discount, and what&apos;s still owing (or &quot;Paid in full&quot;) — to your email or WhatsApp, whichever you pick.
The Invoice button sends a PDF breakdown of the registration — itemized cost, any early-bird or discount-code saving, and what&apos;s still owing (or &quot;Paid in full&quot;) — to your email or WhatsApp, whichever you pick. If you have a discount code and haven&apos;t paid in full yet, enter it in the registration popup.
</GuideItem>
<GuideItem icon={History} title="Show past events" tone="gray">
Registrations page defaults to upcoming events only — tick &quot;Show past events&quot; at the top to bring back ones that have already happened.
</GuideItem>
<GuideItem icon={Users} title="People on a group booking" tone="brand">
On events that take names, the registration popup has a People section: add or change each person&apos;s name and contact details, choose whether tickets come to you or go to each person, and send someone the link to fill in their own attendee form. Tickets someone else booked for you show here too, marked &quot;Booked by&quot;.
</GuideItem>
</div>
</div>
),
+6 -3
View File
@@ -1,5 +1,5 @@
import React from "react";
import { Ticket, Tag, Share2, QrCode, Calendar } from "lucide-react";
import { Ticket, Tag, Share2, QrCode, Calendar, Users } from "lucide-react";
import { GuideItem } from "@/components/shared/GuideItem";
import type { HelpContent } from "./types";
@@ -20,10 +20,13 @@ export const eventDetailHelpContent: HelpContent = {
<p>The tickets panel lists every option available for this event, with live availability.</p>
<div className="space-y-4">
<GuideItem icon={Tag} title="Early-bird pricing" tone="amber">
If a discounted price is shown with the original struck through, an early-bird window is active — the price rises automatically once it ends.
If a discounted price is shown with the original struck through, an early-bird window is active — the price rises automatically once it ends. Got a discount code? Enter it on the registration page under &quot;Have a code?&quot; (you need to be logged in).
</GuideItem>
<GuideItem icon={Ticket} title="Availability badges" tone="brand">
&quot;N remaining&quot; appears once stock runs low, and &quot;Sold out&quot; once an option is fully booked.
&quot;N remaining&quot; appears once stock runs low, and &quot;Sold out&quot; once an option is fully booked. Some events have a waitlist: tap &quot;Join waitlist&quot; and you&apos;ll be emailed or WhatsApped a link to claim a place if one opens up (it&apos;s held for you for a limited time).
</GuideItem>
<GuideItem icon={Users} title="Registering for other people?" tone="violet">
Some events let you name each person you&apos;re booking for. Each named person gets their own ticket and their own attendee form, and you can choose to have every ticket sent to you or each person their own. On youth events, you&apos;ll give each person&apos;s date of birth; for anyone under 18, their parent or guardian is asked to give consent by email and WhatsApp before they can be checked in.
</GuideItem>
</div>
</div>

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