Compare commits

...
24 Commits
Author SHA1 Message Date
joshua 921b94a66a Merge branch 'feat/privacy-transfer-accept' 2026-10-19 18:46:43 +02:00
joshuaandClaude Opus 5.5 b3bfbe486c Bump to 1.24.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 18:46:25 +02:00
joshuaandClaude Opus 5.5 686616cc18 Require acceptance for ticket transfers; privacy and legal updates
Transfers now send the new holder a link (to the email/phone typed) and
only complete once they accept; they can decline and the sender can
cancel. No response reveals whether contact details belong to an
account, and attendee account links are no longer sent to payers.
Terms of Use and Privacy Policy are updated for all features since
1.12, with a full cookie policy and a "Cookie settings" control. The
/docs route list now covers every mounted route, and account
activation auto-login is fixed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CP62L9JxHomA36aYP1ekCe
2026-10-19 18:46:15 +02:00
joshua cd42ca375e Merge branch 'feat/impersonate-user' 2026-10-19 18:25:10 +02:00
joshuaandClaude Opus 5.5 ccb4db919a Bump to 1.23.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 18:25:08 +02:00
joshuaandClaude Opus 5.5 2b453b00e6 Add read-only admin impersonation
Admins (with 2FA) can view the site as a non-admin user for up to 30
minutes, with a required reason and a "Viewing as" banner. It is
strictly read-only: a guard mounted before every route refuses any
write made with an impersonation token, protect/optionalAuth check the
session is live, and GET handlers skip the price-refresh writes. Start,
end, reason and every page/API call (including blocked attempts) are
audited; optional email to the user.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CP62L9JxHomA36aYP1ekCe
2026-10-19 18:25:02 +02:00
joshua fb8ce5f86d Merge branch 'feat/funnel-stats' 2026-10-19 18:14:29 +02:00
joshuaandClaude Opus 5.5 4cd89b1a60 Bump to 1.22.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 18:14:24 +02:00
joshuaandClaude Opus 5.5 cc62f52ab3 Add registration funnel stats
First-party, consent-based funnel tracking: a cookie notice asks once,
and only visitors who allow it get an anonymous visit id. Event page
views and registration starts are tracked from the browser; submit and
paid come from the registration. Adds the Registration funnel report
(conversion per stage, daily trend, by source/device/ticket option), a
conversion tile on the supervisor/admin dashboards, short-link source
tagging, and a daily rollup of raw rows older than 90 days.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CP62L9JxHomA36aYP1ekCe
2026-10-19 18:14:18 +02:00
joshua 238fcd6fd7 Merge branch 'feat/feedback-forms' 2026-10-19 15:28:48 +02:00
joshuaandClaude Opus 5.5 78b64329cc Bump to 1.21.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 15:28:42 +02:00
joshuaandClaude Opus 5.5 afcc14cbb3 Add post-event feedback forms
A Feedback step in the event wizard builds a survey that is sent at a
chosen time to everyone who checked in (or everyone registered), with
personal links and one reminder after 3 days. Answers can be anonymous.
Adds rating (1-5 stars) and multiple-choice field types, also usable on
attendee forms, and a Feedback summary report with response rate,
average ratings, choice breakdowns, comments and event comparison.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 15:28:29 +02:00
joshua 1b92fae76c Merge branch 'feat/sponsor-a-spot' 2026-10-19 15:04:40 +02:00
joshuaandClaude Opus 5.5 4c76c77005 Bump to 1.20.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 15:04:37 +02:00
joshuaandClaude Opus 5.5 93cff6099e Add sponsor a spot
Events can allow sponsorships: a "Sponsor a spot" button lets anyone
(guests included) pay through Yoco for whole spots or a custom amount,
anonymously or with a name and message. Payments land as event
donations in a sponsorship pool that supervisors assign with the
existing donation flow. Adds optional public progress towards a goal,
a sponsor receipt and Automations thank-you, pool columns in the
Donations report, and sponsorship labels/filter in Payments.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 15:04:29 +02:00
joshua 7bcaec6284 Merge branch 'feat/room-allocation' 2026-10-19 14:45:09 +02:00
joshuaandClaude Opus 5.5 2f4fd7c040 Bump to 1.19.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 14:45:06 +02:00
joshuaandClaude Opus 5.5 1516e104df Add rooms & groups allocation
Supervisors can split an event's named attendees into allocation sets
(rooms, small groups, buses) with capacity, gender and leader per group,
using a drag-and-drop board or auto-allocate (keep bookings together or
split them). Adds printable group and master lists (medical view
audited), the group on tickets and in My Events, messaging by group,
a "My groups" page for leaders, and an optional per-event gender
question for named attendees.

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

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 14:06:22 +02:00
126 changed files with 7407 additions and 386 deletions
+165
View File
@@ -7,6 +7,171 @@ and this project follows [Semantic Versioning](https://semver.org/).
## [Unreleased]
## [1.24.0] - 2026-10-19
### Changed
- Ticket transfers now need the new holder to accept.
- "Transfer" (in My Events, or At the door → Tickets) sends the person a link by email and/or WhatsApp, to the details typed.
- Nothing changes on the ticket until they accept on the new `/transfer/<link>` page. They can also decline.
- On accepting, the transfer completes as before: new QR code, ticket delivery, account activation link, parental consent on youth events.
- My Events shows "Waiting for … to accept" with a Cancel button.
- Offers expire after 7 days or when the event starts, and a new offer replaces an open one.
- Privacy for group tickets and transfers: there is no user search, and nothing reveals whether an email or phone number belongs to an account.
- Registration responses no longer include named attendees' account links.
- If an email and phone point at two different accounts, this is no longer reported to the person typing them. The email's account is used.
- Terms of Use and Privacy Policy updated for everything added since 1.12:
- booking for other people, ticket transfers, and youth-event consent with health information and how long it is kept;
- who can see what (door staff, group leaders, supervisors), and read-only admin support access;
- sponsorships, waitlists, discount codes and feedback;
- two-factor authentication and offline scanning.
- The Privacy Policy has a full **cookie policy**: every item the site stores in the browser (necessary, staff-only and optional), the optional visit statistics, and the third-party cookies from the Google Maps embed and Yoco checkout.
- A new "Cookie settings" link in the footer (and a button in the policy) lets visitors change their choice.
- The API docs page (`/docs`) now lists every mounted route. 45 were missing, including dashboard stats, backups, attachments, the audit log, ticket transfer links and WhatsApp admin.
### Fixed
- After activating an account, the visitor is now really signed in. The token was saved under a key the app never read, so the "auto-login" didn't work.
### Notes
- `npm install` succeeds in the root, `backend` and `frontend` with no missing packages.
- The remaining `npm audit` findings are all in development/build tools (Jest, nodemon, ESLint, Tailwind's build), and none affect the running site.
- `braces` and `sprintf-js` have no patched release.
- `postcss-selector-parser` 7 would break Tailwind 3.
- Requires the `add_transfer_requests` database migration.
## [1.23.0] - 2026-10-19
### Added
- Impersonate user (Admin → Manage users → Impersonate): an admin can see the site exactly as a user does, to help with problems such as "my ticket isn't showing".
- A reason is required, and the session lasts at most 30 minutes.
- A bright "Viewing as [name]" banner with a countdown and "End impersonation" shows on every page.
- Impersonation is strictly read-only.
- A server-side guard, mounted before every route, refuses any change (registrations, payments, refunds, cancellations, profile, consent, messages and everything else) made with an impersonation token.
- Viewing pages doesn't trigger the price refresh it normally does.
- Admins can't be impersonated, and the admin must have two-factor authentication on.
- Impersonation is recorded in the audit log: start, end, reason, and every page and API call, including blocked attempts.
- The audit log page lists recent sessions with their full trail.
- New setting: email users when an admin views their account (Site Settings → Legal → Support access; off by default).
- New `/api/admin/impersonate` endpoints.
- Requires the `add_impersonation` database migration.
## [1.22.0] - 2026-10-19
### Added
- Registration funnel stats (first-party, no third-party analytics), showing where people drop off between seeing an event and paying.
- A new cookie notice asks visitors once. Only those who choose "Allow" are counted, using a random anonymous visit id.
- We record the event page view and the start of registration. The submit and paid stages come from the registration itself.
- Only the stage, the traffic source and the device type are stored. No name, IP address or User-Agent is kept.
- New report, **Registration funnel**: counts and conversion % per stage over a date range, a daily trend, and breakdowns by traffic source, device and ticket option.
- Selecting several events compares them.
- Sources come from `utm_source` links and from short links, which now add `?src=<alias>` when redirecting.
- A "Conversion (past month)" tile on the supervisor and admin dashboards covers live events.
- Raw visit records are rolled up into daily totals after 90 days by a daily job (`ANALYTICS_ROLLUP_ENABLED=false` to turn it off).
- The privacy policy's cookies section now describes these statistics.
- New `/api/analytics` endpoints.
- Requires the `add_funnel_analytics` database migration.
## [1.21.0] - 2026-10-19
### Added
- Post-event feedback, set up in a new Feedback step in the event wizard.
- Questions use the form builder.
- You pick when it's sent (the default is the morning after the event) and who gets it: everyone who checked in, or everyone registered.
- Each person gets a personal link by email (and WhatsApp where they use it).
- Anyone who hasn't answered gets one reminder 3 days later.
- Answers can be anonymous or named.
- "Send now" is available, and the questions lock once people start answering.
- New form field types: **Rating (1–5 stars)** and **Multiple choice**. They're used for feedback, and can also go on attendee forms (registration page, My Events, the kiosk, manual registration and Supervisor → Forms).
- New report, **Feedback summary**:
- response rate;
- average rating per question, with star distribution;
- multiple-choice and yes/no breakdowns as bar charts;
- a list of text answers (with names unless the form is anonymous).
- Select several events to compare their ratings (e.g. Camp 2025 vs Camp 2026). Exports to PDF and Excel like other reports.
- New `/api/feedback` endpoints and a job every 15 minutes (`FEEDBACK_JOB_ENABLED=false` to turn it off).
- Requires the `add_feedback_forms` database migration.
## [1.20.0] - 2026-10-19
### Added
- Sponsor a spot: a new event setting, "Allow sponsorships" (event wizard → Sponsor a spot).
- It adds a "Sponsor a spot" button on the event page so people can fund a place for someone who can't afford it.
- Sponsors choose one or more whole spots (at the main ticket price) or a custom amount, and pay through Yoco. Guests just give a name and email.
- Sponsors can give anonymously, or add a name and a message for the organisers.
- An optional goal ("12 of 20 spots sponsored") and public progress bar.
- Each sponsorship is recorded as a donation tagged to the event, in that event's sponsorship pool.
- Supervisors assign pool money to registrations with the existing donation assign/unassign flow, fully or partly.
- Under Payments → Donations, sponsorships are labelled, there's an "Only the sponsorship pool" filter, and the sponsor's message is shown.
- Who receives help is never shown to sponsors.
- Sponsors get a "Thank you for sponsoring a spot" receipt.
- Automations has a new "Thank Your Sponsors" email for events with sponsorships.
- Email attendees can target an event's sponsors (`filter.audience: 'sponsors'`).
- The Donations breakdown report has sponsorship pool columns per event: received, assigned and remaining.
- New `/api/sponsorships` endpoints.
- Requires the `add_sponsorships` database migration.
## [1.19.0] - 2026-10-19
### Added
- Rooms & groups (Supervisor → Rooms & groups), for camps and lockdowns: put attendees into rooms, cabins, small groups, teams or buses.
- Each event can have several allocation sets (e.g. "Rooms" and "Buses"). Each set has groups with an optional capacity, an optional gender restriction and an optional leader (a staff member).
- A drag-and-drop board puts people not yet placed on the left and groups, with capacity counters, on the right. Full groups and groups for the other gender won't accept a person.
- Auto-allocate fills groups evenly with everyone not yet placed, respecting capacity and gender. It can keep people on the same booking together or spread them out.
- Printable lists per group (with leader) and a master list. On youth events they can include medical and emergency details from consent forms; viewing those is recorded in the audit log.
- "Show on tickets" prints the group on each person's ticket and shows it in My Events.
- Email and WhatsApp attendees can be sent to one group ("Bus 2 leaves at 07:30"). Messages go to whoever booked for the people in it.
- Group leaders see their people under Staff → My groups. Medical details are shown to supervisors only.
- New event setting "Ask each person's gender" (under Named attendees). It adds a gender choice for each named person on the registration page, at the door, on the kiosk, in manual registration and in My Events, and is needed for gender-split rooms.
- New `/api/allocations` endpoints.
- Requires the `add_room_allocation` database migration.
## [1.18.0] - 2026-10-19
### Added
- Ticket transfer. On My Events, "Transfer" hands a ticket to someone else before the event: enter their name and email or phone.
- The new holder gets the ticket by email/WhatsApp with a new QR code. If they're new, an inactive account is created and they get an activation link.
- The old QR code stops working, so screenshots of it won't scan. Scanning it says who the ticket was transferred to, and offline-mode sync flags it.
- For a ticket covering several people, one place is split off to the new holder.
- The payer stays responsible for the registration.
- Transfer rules:
- Only before the event starts, and only for tickets that haven't been scanned.
- It can be turned off per event (new "Ticket transfer" setting in the event wizard, on by default). Supervisors can always transfer, from At the door → Tickets.
- On youth events, the new holder's date of birth, and a parent's details for a minor, are needed, and a new consent request is sent.
- The previous holder's attendee form answers and consent medical details are removed.
- Every transfer is recorded in the audit log.
- New endpoints `POST /api/tickets/:id/transfer` and `GET /api/tickets/:id/transfers`.
- Requires the `add_ticket_transfer` database migration.
## [1.17.0] - 2026-10-19
### Added
- Offline ticket scanning. While online, staff pick an event and section on the scanner and tap "Enable offline mode". The section (or the whole event) is locked to their device and its tickets are downloaded to the device.
- With no connection, scans are checked against that list and queued on the device.
- The panel shows when the list was downloaded and how many scans are waiting.
- Queued scans sync automatically when the device is back online (or with "Sync now"), and check-in messages go out then.
- "Release section" ends offline mode and deletes the list from the device.
- Only one device can hold a section at a time (a whole-event lock clashes with every section). While a section is locked, other devices see who holds it and can't scan it, online or offline, so a ticket can never be redeemed twice by two offline devices.
- Syncing is idempotent and never redeems more than is left on a ticket. Anything that needs a person to look goes into a sync report: tickets not found, cancelled registrations, used-up tickets, scans outside the locked section, and check-ins without consent.
- Supervisor → Offline scanning page: active locks with force release (for a lost or flat phone; its scans still sync if it reconnects, and are flagged), and sync reports to review. Locks, releases, force-releases and syncs are recorded in the audit log.
- The scanner is installable as an app (web app manifest), and a service worker caches the scanner page and its scripts so it opens with no connection.
- The app now stays signed in with the last confirmed profile when it's reloaded without a connection, instead of sending the user to the login page.
- New `/api/scan-locks` endpoints.
- Requires the `add_offline_scanning` database migration.
### Changed
- `POST /api/tickets/scan/:qrCode` now accepts `sectionId` and checks it on the server; this was previously only checked in the browser.
- It now refuses (423) scans in a section that another device has locked for offline scanning.
## [1.16.0] - 2026-10-19
### Added
+77 -3
View File
@@ -171,6 +171,46 @@ Items & Pricing → Sections → Form → Visibility → Attachments**.
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.
- **Impersonate user** (Admin → Manage users) — an admin with 2FA can view the site as a
user, to debug problems.
- It needs a reason, lasts at most 30 minutes, and shows a bright "Viewing as" banner
on every page.
- It is strictly read-only. A server-side guard refuses every write made with the
impersonation token before it reaches any route, and pages don't even refresh
prices while viewing.
- Admins can't be impersonated.
- The start, end, reason and every page and API call are in the audit log, with each
session's trail.
- Optional email to the user (`notify_user_on_impersonation`).
- **Registration funnel stats** — first-party and consent-based, with no third-party analytics.
- A cookie notice asks once. Only visitors who allow it get a random visit id.
- We record event page views and registration starts. Submits and payments come
from the registration itself.
- Each record holds only the stage, the traffic source (utm_source, or the short-link
alias, which short links now pass as `?src=`) and the device type.
- The Registration funnel report shows counts and conversion per stage over a date
range, a daily trend, and breakdowns by source, device and ticket option.
- A "Conversion (past month)" tile on the supervisor/admin dashboards covers live
events.
- Raw rows are rolled up into daily totals after 90 days.
- **Post-event feedback** — a Feedback step in the event wizard.
- Questions use the form builder, with new **star rating (1–5)** and **multiple
choice** types (also available on attendee forms).
- The form goes out at a chosen time to everyone who checked in, or to everyone
registered, each with a personal link (`/feedback/<token>`). One reminder goes
3 days later to anyone who hasn't answered.
- Answers can be anonymous or named. "Send now" is available, and the questions
lock once people have answered.
- **Sponsor a spot** — per-event setting. A "Sponsor a spot" button on the event page
lets anyone, including guests, pay for one or more spots (at the main ticket price)
or any amount through Yoco. They can give anonymously or add a name and a message.
The money is a donation tagged to the event and goes into its sponsorship pool, which
supervisors assign to registrations with the usual donation flow.
- An optional public progress bar ("12 of 20 spots sponsored") with an optional goal.
- Sponsors get a thank-you receipt, and an Automations thank-you can go to all of an
event's sponsors.
- The Donations breakdown report shows each pool: received, assigned and remaining.
- Who is helped is never shown.
- **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
@@ -200,6 +240,13 @@ Items & Pricing → Sections → Form → Visibility → Attachments**.
- **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.
@@ -259,6 +306,18 @@ logout controls. Disconnected sessions are recovered automatically.
- Registration detail: itemised bill (early-bird lines grouped), payments,
**Invoice** and **Receipt** buttons, edit, cancel, **Make payment**.
- Upcoming events and tickets side by side, with bulk ticket email/WhatsApp.
- **Ticket transfer** — offer a ticket to someone else before the event by typing
their name and email/phone (no user search, and nothing reveals whether they have an
account). They get a link to accept or decline; the ticket only moves once they
accept, and the sender can cancel until then (offers expire after 7 days or at the
event start). On accepting they get a new ticket and QR code, and an inactive account
with an activation link if they're new. The old code stops working, and scanning it says it
was transferred, both online and in offline-mode sync reports. One place can be split
off a ticket covering several people. Billing stays with the payer. It's not allowed
once a ticket has been scanned, and it can be turned off per event (supervisors can
always transfer, from At the door → Tickets). On youth events the new holder needs
their own parental consent, and the previous holder's form answers are removed.
Every transfer is in the audit log.
- **Payment history** (`/dashboard/user/payments`) — paginated, with filters by
date, method and payment/refund.
- **Donate** page.
@@ -299,6 +358,19 @@ logout controls. Disconnected sessions are recovered automatically.
Reconcile (Yoco transactions), and Payment Link.
- **Coupon codes** (`/dashboard/supervisor/coupons`) — create, edit and turn off
discount codes, and see who used each one.
- **Rooms & groups** (`/dashboard/supervisor/allocations`):
- Per event, allocation sets such as "Rooms", "Small groups" or "Buses". Each set
has groups with an optional capacity, gender restriction and leader (staff).
- A drag-and-drop board, plus auto-allocate: fill evenly, respecting capacity and
gender, keeping bookings together or splitting them.
- Printable per-group and master lists. On youth events they can include medical
details, and viewing those is audited.
- The group can show on tickets and in My Events.
- Email and WhatsApp attendees can be filtered to one group.
- Leaders see their people under Staff → **My groups**.
- Works on named attendees. Events can ask each person's gender.
- **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
@@ -357,9 +429,9 @@ 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 |
| Registration | Registration funnel (views → started → submitted → paid, daily trend, by source / device / ticket option) · 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 · Feedback summary (response rate, average ratings, choice breakdowns, comments; compare events) |
| Ticketing | Ticket usage summary |
| Donations | Donations breakdown (used / unused per event) |
| Donations | Donations breakdown (used / unused per event, plus the sponsorship pool) |
| Cashup | Cashup reconciliation · Cashup audit trail |
**Export:** Print, branded PDF download, styled Excel (`.xlsx`) workbook,
@@ -430,6 +502,8 @@ falling back to a general guide.
| 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) |
| Funnel analytics: roll raw visit rows older than 90 days into daily totals | Daily (`ANALYTICS_ROLLUP_ENABLED=false` to disable) |
| Post-event feedback forms and their 3-day reminder | Every 15 minutes (`FEEDBACK_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 |
@@ -469,5 +543,5 @@ falling back to a general guide.
| `/set-banner` | Supervisor | Site banner |
| `/dashboard/user` (+ `/payments`, `/donate`, `/forms`, `/profile`, `/reset-password`) | User | My Events |
| `/dashboard/staff` (+ `/ticket-scanning`, `/event-tickets`) | Staff | Staff tools |
| `/dashboard/supervisor` (+ `/events`, `/event-options`, `/at-the-door`, `/manual`, `/manual-registration`, `/payments`, `/coupons`, `/waitlist`, `/consent`, `/email-attendees`, `/whatsapp-attendees`, `/forms`, `/reports`) | Supervisor | Supervisor tools |
| `/dashboard/supervisor` (+ `/events`, `/event-options`, `/at-the-door`, `/manual`, `/manual-registration`, `/payments`, `/coupons`, `/waitlist`, `/consent`, `/offline-scans`, `/allocations`, `/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
@@ -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. |
| `notify_user_on_impersonation` | `"false"` | When `"true"`, a user is emailed (with the reason) whenever an admin views their account with Impersonate. Set under Site Settings → Legal → Support access. |
| `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. |
+144
View File
@@ -21,6 +21,13 @@ Tokens are issued by `POST /api/users/login`.
- [Coupons](#coupons)
- [Parental consent](#parental-consent)
- [Waitlist](#waitlist)
- [Offline scanning](#offline-scanning)
- [Ticket transfer](#ticket-transfer)
- [Rooms & groups](#rooms--groups)
- [Sponsor a spot](#sponsor-a-spot)
- [Post-event feedback](#post-event-feedback)
- [Registration funnel](#registration-funnel)
- [Impersonation](#impersonation)
- [Payments](#payments)
- [Tickets](#tickets)
- [Broadcasts (Email)](#broadcasts-email)
@@ -278,6 +285,143 @@ For unauthenticated (guest) registration on events with `requiresAuth: false`:
---
## Impersonation
| Method | Path | Auth | Description |
|--------|------|------|-------------|
| POST | `/api/admin/impersonate/:userId` | admin (2FA on) | `{ reason }` → `{ token, session }`. The token acts as the user, is read-only, and lasts 30 minutes. Refused for admins and inactive users |
| POST | `/api/admin/impersonate/end` | impersonation token | End the session |
| POST | `/api/admin/impersonate/log` | impersonation token | `{ path }` — page viewed (audit trail) |
| GET | `/api/admin/impersonate/current` | impersonation token | The live session |
| GET | `/api/admin/impersonation-sessions` | admin | Sessions with their request trail |
Read-only enforcement:
- A guard mounted before every route returns `403 { code: "IMPERSONATION_READ_ONLY" }` for any non-GET/HEAD/OPTIONS request made with an impersonation token. The only exceptions are `end` and `log`. Blocked attempts are logged.
- An ended or expired session gets `401 { code: "IMPERSONATION_ENDED" }`.
- GET handlers skip side-effect writes (pricing refresh) while `req.readOnly` is set.
## Registration funnel
| Method | Path | Auth | Description |
|--------|------|------|-------------|
| POST | `/api/analytics/track` | public (rate-limited) | `{ sessionId, eventId, stage: 'view'\|'start', source? }` → always `204`. Bots are ignored; device type comes from the User-Agent |
| GET | `/api/analytics/funnel?eventIds=&from=&to=` | supervisor+ | `{ totals, conversion, trend, bySource, byDevice, byOption, perEvent }` |
How the stages are recorded:
- `POST /api/registrations` accepts `analyticsSessionId`. It's stored on the registration and records the `submit` stage.
- `paid` is read from that registration's status.
- Counts are sessions that reached a stage or a later one.
Data handling:
- Sessions inactive for 90 days are rolled up into `AnalyticsDaily` by a daily job (`ANALYTICS_ROLLUP_ENABLED`).
- `/api/stats/overview` now includes `funnel: { views, paid, conversion }` for live events over the past 30 days.
## Post-event feedback
| Method | Path | Auth | Description |
|--------|------|------|-------------|
| GET | `/api/feedback/event/:eventId` | supervisor+ | The event's feedback form (with `invited` / `responses` counts), or null |
| PUT | `/api/feedback/event/:eventId` | supervisor+ | `{ sendAt, audience: 'checked_in'\|'all', anonymous, intro, fields[] }` — fields can't change once answered (409) |
| DELETE | `/api/feedback/event/:eventId` | supervisor+ | Remove the form |
| POST | `/api/feedback/event/:eventId/send-now` | supervisor+ | Send now (also invites anyone not yet invited) |
| GET / POST | `/api/feedback/public/:token` | link holder | Load the form / submit `{ answers: { fieldId: value } }` once |
| GET | `/api/feedback/summary?eventIds=a,b` | supervisor+ | Per event: invited, responses, response rate, and per question: rating average + distribution, choice counts, text answers (names only when not anonymous) |
Notes:
- New field types `rating` (answers `"1"`–`"5"`) and `multiple_choice` (`options`: array of strings). They can be used on attendee forms too.
- Only HMACs of link tokens are stored, and a reminder issues a new link.
- A job every 15 minutes (`FEEDBACK_JOB_ENABLED`, `FEEDBACK_JOB_INTERVAL_MS`) sends due forms, and sends one reminder 3 days later to people who haven't answered.
## Sponsor a spot
| Method | Path | Auth | Description |
|--------|------|------|-------------|
| GET | `/api/sponsorships/event/:eventId` | public | `{ enabled, open, spotPrice, showProgress, goalSpots, spotsFunded, sponsors }` — never recipients |
| POST | `/api/sponsorships/checkout` | public (optional login) | `{ eventId, spots? \| amount?, anonymous?, sponsorName?, message?, name?, email? }` → Yoco `redirectUrl`. Guests give a name and email |
| GET | `/api/sponsorships/event/:eventId/pool` | supervisor+ | `{ received, assigned, remaining, sponsors, spotsFunded, spotPrice, goalSpots }` |
How the money is handled:
- A spot costs the cheapest paid main ticket.
- Yoco checkout metadata carries `type: 'sponsorship'`. The webhook records the payment as `isDonation` + `isSponsorship` against the event, together with `sponsorName`, `sponsorMessage` and `sponsorAnonymous`.
- Pool funds are assigned with `PUT /api/payments/assign-donation`.
Other changes:
- Event fields: `allowSponsorships`, `sponsorshipGoalSpots`, `showSponsorshipProgress`.
- `POST /api/events/:id/email-attendees` accepts `filter.audience: 'sponsors'`.
- `POST /api/automations/schedule` jobs accept `audience: 'sponsors'`.
## Rooms & groups
| Method | Path | Auth | Description |
|--------|------|------|-------------|
| GET | `/api/allocations/event/:eventId` | supervisor+ | Board: sets (groups, assignments), named attendees, possible leaders, unnamed places |
| POST | `/api/allocations/sets` | supervisor+ | `{ eventId, name, showOnTicket? }` |
| PUT / DELETE | `/api/allocations/sets/:id` | supervisor+ | Rename / show on tickets; delete |
| POST | `/api/allocations/sets/:id/groups` | supervisor+ | `{ name, capacity?, gender? ('male'\|'female'), leaderUserId? }` |
| PUT / DELETE | `/api/allocations/groups/:id` | supervisor+ | Edit (can't drop below current members or break gender); delete (members become unassigned) |
| PUT | `/api/allocations/sets/:id/assignments` | supervisor+ | `{ attendeeId, groupId }` (null unassigns); enforces capacity and gender |
| DELETE | `/api/allocations/sets/:id/assignments` | supervisor+ | Clear the set |
| POST | `/api/allocations/sets/:id/auto` | supervisor+ | `{ mode: 'together' \| 'split' }` — places everyone not yet placed |
| GET | `/api/allocations/sets/:id/lists?medical=true` | supervisor+ | Groups with members for printing (medical view is audited) |
| GET | `/api/allocations/my-groups` | staff+ | Groups the caller leads (medical details for supervisors only) |
Related changes:
- `Event.collectGender` makes a gender (`male` / `female`) required for each named attendee.
- Email and WhatsApp attendees accept `filter.allocationGroupId`.
## Ticket transfer
| Method | Path | Auth | Description |
|--------|------|------|-------------|
| POST | `/api/tickets/:id/transfer` | payer, holder or supervisor+ | `{ name, email?, phone?, dateOfBirth?, gender?, parentName?, parentEmail?, parentPhone? }` → `201`. Creates an offer and sends a link to the details given. **Nothing changes on the ticket yet.** A new offer replaces an open one |
| DELETE | `/api/tickets/:id/transfer` | payer, holder or supervisor+ | Withdraw the open offer |
| GET | `/api/tickets/transfer-offer/:token` | link holder | `{ status, toName, fromName (first name), event, option, expiresAt }` |
| POST | `/api/tickets/transfer-offer/:token/accept` | link holder | Accept — the transfer happens now (all checks run again) |
| POST | `/api/tickets/transfer-offer/:token/decline` | link holder | Decline |
| GET | `/api/tickets/:id/transfers` | payer, holder or staff+ | Transfer history |
Privacy:
- There is no user search. The sender's request is checked (rules, required fields) without looking up any account, and no response reveals whether the email/phone belongs to an account.
- Offers expire after 7 days or when the event starts.
- Only an HMAC of the link token is stored.
Rules:
- Only before the event starts, and only for tickets that have never been scanned.
- `Event.allowTicketTransfer` turns it off for non-supervisors.
What a transfer does:
- The new holder becomes the ticket's attendee and gets a new QR code. A shared ticket for several places has one place split off into its own ticket.
- An account is matched by email/phone, or created inactive with an activation link.
- The previous holder's attendee form answers are deleted, and the medical details on their consent are cleared. A minor on a youth event gets a new consent request.
Old QR codes:
- `scan-preview` and `scan` return `410 { code: "TICKET_TRANSFERRED" }`.
- Offline sync reports them as `transferred`.
- Each transfer is audited as `ticket_transferred`.
## Offline scanning
| Method | Path | Auth | Description |
|--------|------|------|-------------|
| GET | `/api/scan-locks?eventId=` | staff+ | Active offline locks for an event |
| POST | `/api/scan-locks` | staff+ | Lock `{ eventId, sectionId?, deviceId, deviceName? }` to this device (409 if the scope is held elsewhere) |
| GET | `/api/scan-locks/:id/snapshot?deviceId=` | staff+ | Tickets valid in the lock's scope (lock holder only) |
| POST | `/api/scan-locks/:id/sync` | staff+ | `{ deviceId, scans: [{ clientScanId, qrCode, qty, scannedAt }] }` — idempotent; returns accepted, duplicates, conflicts |
| DELETE | `/api/scan-locks/:id` | staff+ | Release (`{ deviceId }`, lock holder only) |
| POST | `/api/scan-locks/:id/force-release` | supervisor+ | Release a lost device's lock |
| GET | `/api/scan-locks/reports?eventId=&unreviewed=` | supervisor+ | Sync reports |
| POST | `/api/scan-locks/reports/:id/review` | supervisor+ | Mark a report reviewed |
How locks work:
- One device per scope. A whole-event lock (`sectionId` null) clashes with every section lock on that event.
- `POST /api/tickets/scan/:qrCode` now accepts `sectionId` and `deviceId`. It enforces the section on the server, and returns `423 { code: "SCAN_LOCKED" }` when another device holds a lock covering the ticket.
How syncing works:
- Synced scans never redeem more than is left on a ticket.
- Conflicts are recorded in the sync report: `not_found`, `cancelled`, `wrong_scope`, `already_used`, `partly_used` and `consent`.
- Check-in messages are sent after the sync.
- Locks, releases, force-releases and syncs are written to the admin audit log.
## Waitlist
Events with `waitlistEnabled` (plus `waitlistOfferHours`, default 24, and `waitlistPartialOffers`, default true) take a queue for sold-out lines.
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "event-management-backend",
"version": "1.16.0",
"version": "1.24.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "event-management-backend",
"version": "1.16.0",
"version": "1.24.0",
"hasInstallScript": true,
"dependencies": {
"@prisma/client": "^5.4.2",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "event-management-backend",
"version": "1.16.0",
"version": "1.24.0",
"description": "Event Management System Backend",
"main": "src/index.js",
"scripts": {
@@ -0,0 +1,93 @@
-- AlterEnum
-- This migration adds more than one value to an enum.
-- With PostgreSQL versions 11 and earlier, this is not possible
-- in a single migration. This can be worked around by creating
-- multiple migrations, each migration adding only one value to
-- the enum.
ALTER TYPE "AdminAuditAction" ADD VALUE 'scan_lock_acquired';
ALTER TYPE "AdminAuditAction" ADD VALUE 'scan_lock_released';
ALTER TYPE "AdminAuditAction" ADD VALUE 'scan_lock_force_released';
ALTER TYPE "AdminAuditAction" ADD VALUE 'offline_sync';
-- AlterTable
ALTER TABLE "TicketUsage" ADD COLUMN "clientScanId" TEXT,
ADD COLUMN "offline" BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN "scanLockId" TEXT,
ADD COLUMN "syncedAt" TIMESTAMP(3);
-- CreateTable
CREATE TABLE "ScanLock" (
"id" TEXT NOT NULL,
"eventId" TEXT NOT NULL,
"sectionId" TEXT,
"deviceId" TEXT NOT NULL,
"deviceName" TEXT,
"userId" TEXT NOT NULL,
"lockedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"snapshotAt" TIMESTAMP(3),
"lastSyncAt" TIMESTAMP(3),
"releasedAt" TIMESTAMP(3),
"forceReleasedById" TEXT,
CONSTRAINT "ScanLock_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "OfflineSyncReport" (
"id" TEXT NOT NULL,
"scanLockId" TEXT NOT NULL,
"accepted" INTEGER NOT NULL DEFAULT 0,
"duplicates" INTEGER NOT NULL DEFAULT 0,
"conflicts" JSONB NOT NULL DEFAULT '[]',
"afterForceRelease" BOOLEAN NOT NULL DEFAULT false,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"reviewedAt" TIMESTAMP(3),
"reviewedById" TEXT,
CONSTRAINT "OfflineSyncReport_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "ScanLock_eventId_releasedAt_idx" ON "ScanLock"("eventId", "releasedAt");
-- CreateIndex
CREATE INDEX "ScanLock_userId_idx" ON "ScanLock"("userId");
-- CreateIndex
CREATE INDEX "ScanLock_sectionId_idx" ON "ScanLock"("sectionId");
-- CreateIndex
CREATE INDEX "ScanLock_forceReleasedById_idx" ON "ScanLock"("forceReleasedById");
-- CreateIndex
CREATE INDEX "OfflineSyncReport_scanLockId_idx" ON "OfflineSyncReport"("scanLockId");
-- CreateIndex
CREATE INDEX "OfflineSyncReport_reviewedById_idx" ON "OfflineSyncReport"("reviewedById");
-- CreateIndex
CREATE UNIQUE INDEX "TicketUsage_clientScanId_key" ON "TicketUsage"("clientScanId");
-- AddForeignKey
ALTER TABLE "TicketUsage" ADD CONSTRAINT "TicketUsage_scanLockId_fkey" FOREIGN KEY ("scanLockId") REFERENCES "ScanLock"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ScanLock" ADD CONSTRAINT "ScanLock_eventId_fkey" FOREIGN KEY ("eventId") REFERENCES "Event"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ScanLock" ADD CONSTRAINT "ScanLock_sectionId_fkey" FOREIGN KEY ("sectionId") REFERENCES "Section"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ScanLock" ADD CONSTRAINT "ScanLock_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ScanLock" ADD CONSTRAINT "ScanLock_forceReleasedById_fkey" FOREIGN KEY ("forceReleasedById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "OfflineSyncReport" ADD CONSTRAINT "OfflineSyncReport_scanLockId_fkey" FOREIGN KEY ("scanLockId") REFERENCES "ScanLock"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "OfflineSyncReport" ADD CONSTRAINT "OfflineSyncReport_reviewedById_fkey" FOREIGN KEY ("reviewedById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
@@ -0,0 +1,36 @@
-- AlterEnum
ALTER TYPE "AdminAuditAction" ADD VALUE 'ticket_transferred';
-- AlterTable
ALTER TABLE "Event" ADD COLUMN "allowTicketTransfer" BOOLEAN NOT NULL DEFAULT true;
-- CreateTable
CREATE TABLE "TicketTransfer" (
"id" TEXT NOT NULL,
"ticketId" TEXT NOT NULL,
"fromHolderName" TEXT NOT NULL,
"fromUserId" TEXT,
"toName" TEXT NOT NULL,
"toUserId" TEXT,
"byUserId" TEXT,
"oldQrCode" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "TicketTransfer_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "TicketTransfer_ticketId_idx" ON "TicketTransfer"("ticketId");
-- CreateIndex
CREATE INDEX "TicketTransfer_oldQrCode_idx" ON "TicketTransfer"("oldQrCode");
-- AddForeignKey
ALTER TABLE "TicketTransfer" ADD CONSTRAINT "TicketTransfer_ticketId_fkey" FOREIGN KEY ("ticketId") REFERENCES "Ticket"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "TicketTransfer" ADD CONSTRAINT "TicketTransfer_toUserId_fkey" FOREIGN KEY ("toUserId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "TicketTransfer" ADD CONSTRAINT "TicketTransfer_byUserId_fkey" FOREIGN KEY ("byUserId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
@@ -0,0 +1,78 @@
-- AlterTable
ALTER TABLE "Event" ADD COLUMN "collectGender" BOOLEAN NOT NULL DEFAULT false;
-- CreateTable
CREATE TABLE "AllocationSet" (
"id" TEXT NOT NULL,
"eventId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"showOnTicket" BOOLEAN NOT NULL DEFAULT false,
"order" INTEGER NOT NULL DEFAULT 0,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "AllocationSet_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "AllocationGroup" (
"id" TEXT NOT NULL,
"setId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"capacity" INTEGER,
"gender" TEXT,
"leaderUserId" TEXT,
"order" INTEGER NOT NULL DEFAULT 0,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "AllocationGroup_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "AllocationAssignment" (
"id" TEXT NOT NULL,
"setId" TEXT NOT NULL,
"groupId" TEXT NOT NULL,
"attendeeId" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "AllocationAssignment_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "AllocationSet_eventId_idx" ON "AllocationSet"("eventId");
-- CreateIndex
CREATE INDEX "AllocationGroup_setId_idx" ON "AllocationGroup"("setId");
-- CreateIndex
CREATE INDEX "AllocationGroup_leaderUserId_idx" ON "AllocationGroup"("leaderUserId");
-- CreateIndex
CREATE INDEX "AllocationAssignment_groupId_idx" ON "AllocationAssignment"("groupId");
-- CreateIndex
CREATE INDEX "AllocationAssignment_attendeeId_idx" ON "AllocationAssignment"("attendeeId");
-- CreateIndex
CREATE UNIQUE INDEX "AllocationAssignment_setId_attendeeId_key" ON "AllocationAssignment"("setId", "attendeeId");
-- AddForeignKey
ALTER TABLE "AllocationSet" ADD CONSTRAINT "AllocationSet_eventId_fkey" FOREIGN KEY ("eventId") REFERENCES "Event"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "AllocationGroup" ADD CONSTRAINT "AllocationGroup_setId_fkey" FOREIGN KEY ("setId") REFERENCES "AllocationSet"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "AllocationGroup" ADD CONSTRAINT "AllocationGroup_leaderUserId_fkey" FOREIGN KEY ("leaderUserId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "AllocationAssignment" ADD CONSTRAINT "AllocationAssignment_setId_fkey" FOREIGN KEY ("setId") REFERENCES "AllocationSet"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "AllocationAssignment" ADD CONSTRAINT "AllocationAssignment_groupId_fkey" FOREIGN KEY ("groupId") REFERENCES "AllocationGroup"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "AllocationAssignment" ADD CONSTRAINT "AllocationAssignment_attendeeId_fkey" FOREIGN KEY ("attendeeId") REFERENCES "Attendee"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,11 @@
-- AlterTable
ALTER TABLE "Event" ADD COLUMN "allowSponsorships" BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN "showSponsorshipProgress" BOOLEAN NOT NULL DEFAULT true,
ADD COLUMN "sponsorshipGoalSpots" INTEGER;
-- AlterTable
ALTER TABLE "Payment" ADD COLUMN "isSponsorship" BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN "sponsorAnonymous" BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN "sponsorMessage" TEXT,
ADD COLUMN "sponsorName" TEXT;
@@ -0,0 +1,122 @@
-- 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 "FormFieldType" ADD VALUE 'rating';
ALTER TYPE "FormFieldType" ADD VALUE 'multiple_choice';
-- CreateTable
CREATE TABLE "FeedbackForm" (
"id" TEXT NOT NULL,
"eventId" TEXT NOT NULL,
"sendAt" TIMESTAMP(3),
"audience" TEXT NOT NULL DEFAULT 'checked_in',
"anonymous" BOOLEAN NOT NULL DEFAULT false,
"intro" TEXT,
"sentAt" TIMESTAMP(3),
"reminderSentAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "FeedbackForm_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "FeedbackField" (
"id" TEXT NOT NULL,
"formId" TEXT NOT NULL,
"type" "FormFieldType" NOT NULL,
"label" TEXT NOT NULL,
"isRequired" BOOLEAN NOT NULL DEFAULT false,
"order" INTEGER NOT NULL DEFAULT 0,
"helpText" TEXT,
"options" JSONB,
CONSTRAINT "FeedbackField_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "FeedbackInvite" (
"id" TEXT NOT NULL,
"formId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"tokenHash" TEXT NOT NULL,
"sentAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"remindedAt" TIMESTAMP(3),
"respondedAt" TIMESTAMP(3),
CONSTRAINT "FeedbackInvite_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "FeedbackResponse" (
"id" TEXT NOT NULL,
"formId" TEXT NOT NULL,
"userId" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "FeedbackResponse_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "FeedbackAnswer" (
"id" TEXT NOT NULL,
"responseId" TEXT NOT NULL,
"fieldId" TEXT NOT NULL,
"value" TEXT NOT NULL,
CONSTRAINT "FeedbackAnswer_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "FeedbackForm_eventId_key" ON "FeedbackForm"("eventId");
-- CreateIndex
CREATE INDEX "FeedbackField_formId_idx" ON "FeedbackField"("formId");
-- CreateIndex
CREATE UNIQUE INDEX "FeedbackInvite_tokenHash_key" ON "FeedbackInvite"("tokenHash");
-- CreateIndex
CREATE INDEX "FeedbackInvite_userId_idx" ON "FeedbackInvite"("userId");
-- CreateIndex
CREATE UNIQUE INDEX "FeedbackInvite_formId_userId_key" ON "FeedbackInvite"("formId", "userId");
-- CreateIndex
CREATE INDEX "FeedbackResponse_formId_idx" ON "FeedbackResponse"("formId");
-- CreateIndex
CREATE INDEX "FeedbackAnswer_responseId_idx" ON "FeedbackAnswer"("responseId");
-- CreateIndex
CREATE INDEX "FeedbackAnswer_fieldId_idx" ON "FeedbackAnswer"("fieldId");
-- AddForeignKey
ALTER TABLE "FeedbackForm" ADD CONSTRAINT "FeedbackForm_eventId_fkey" FOREIGN KEY ("eventId") REFERENCES "Event"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "FeedbackField" ADD CONSTRAINT "FeedbackField_formId_fkey" FOREIGN KEY ("formId") REFERENCES "FeedbackForm"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "FeedbackInvite" ADD CONSTRAINT "FeedbackInvite_formId_fkey" FOREIGN KEY ("formId") REFERENCES "FeedbackForm"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "FeedbackInvite" ADD CONSTRAINT "FeedbackInvite_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "FeedbackResponse" ADD CONSTRAINT "FeedbackResponse_formId_fkey" FOREIGN KEY ("formId") REFERENCES "FeedbackForm"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "FeedbackResponse" ADD CONSTRAINT "FeedbackResponse_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "FeedbackAnswer" ADD CONSTRAINT "FeedbackAnswer_responseId_fkey" FOREIGN KEY ("responseId") REFERENCES "FeedbackResponse"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "FeedbackAnswer" ADD CONSTRAINT "FeedbackAnswer_fieldId_fkey" FOREIGN KEY ("fieldId") REFERENCES "FeedbackField"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,47 @@
-- AlterTable
ALTER TABLE "Registration" ADD COLUMN "analyticsSessionId" TEXT;
-- CreateTable
CREATE TABLE "AnalyticsEvent" (
"id" TEXT NOT NULL,
"sessionId" TEXT NOT NULL,
"eventId" TEXT NOT NULL,
"stage" TEXT NOT NULL,
"source" TEXT,
"device" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "AnalyticsEvent_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "AnalyticsDaily" (
"id" TEXT NOT NULL,
"eventId" TEXT NOT NULL,
"day" DATE NOT NULL,
"stage" TEXT NOT NULL,
"source" TEXT NOT NULL DEFAULT 'direct',
"device" TEXT NOT NULL DEFAULT 'unknown',
"sessions" INTEGER NOT NULL DEFAULT 0,
CONSTRAINT "AnalyticsDaily_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "AnalyticsEvent_eventId_createdAt_idx" ON "AnalyticsEvent"("eventId", "createdAt");
-- CreateIndex
CREATE UNIQUE INDEX "AnalyticsEvent_sessionId_eventId_stage_key" ON "AnalyticsEvent"("sessionId", "eventId", "stage");
-- CreateIndex
CREATE INDEX "AnalyticsDaily_eventId_day_idx" ON "AnalyticsDaily"("eventId", "day");
-- CreateIndex
CREATE UNIQUE INDEX "AnalyticsDaily_eventId_day_stage_source_device_key" ON "AnalyticsDaily"("eventId", "day", "stage", "source", "device");
-- AddForeignKey
ALTER TABLE "AnalyticsEvent" ADD CONSTRAINT "AnalyticsEvent_eventId_fkey" FOREIGN KEY ("eventId") REFERENCES "Event"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "AnalyticsDaily" ADD CONSTRAINT "AnalyticsDaily_eventId_fkey" FOREIGN KEY ("eventId") REFERENCES "Event"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,55 @@
-- 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 'impersonation_started';
ALTER TYPE "AdminAuditAction" ADD VALUE 'impersonation_ended';
-- CreateTable
CREATE TABLE "ImpersonationSession" (
"id" TEXT NOT NULL,
"adminId" TEXT NOT NULL,
"targetUserId" TEXT NOT NULL,
"reason" TEXT NOT NULL,
"startedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"expiresAt" TIMESTAMP(3) NOT NULL,
"endedAt" TIMESTAMP(3),
"endedReason" TEXT,
CONSTRAINT "ImpersonationSession_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "ImpersonationRequestLog" (
"id" TEXT NOT NULL,
"sessionId" TEXT NOT NULL,
"method" TEXT NOT NULL,
"path" TEXT NOT NULL,
"blocked" BOOLEAN NOT NULL DEFAULT false,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "ImpersonationRequestLog_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "ImpersonationSession_adminId_idx" ON "ImpersonationSession"("adminId");
-- CreateIndex
CREATE INDEX "ImpersonationSession_targetUserId_idx" ON "ImpersonationSession"("targetUserId");
-- CreateIndex
CREATE INDEX "ImpersonationRequestLog_sessionId_createdAt_idx" ON "ImpersonationRequestLog"("sessionId", "createdAt");
-- AddForeignKey
ALTER TABLE "ImpersonationSession" ADD CONSTRAINT "ImpersonationSession_adminId_fkey" FOREIGN KEY ("adminId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ImpersonationSession" ADD CONSTRAINT "ImpersonationSession_targetUserId_fkey" FOREIGN KEY ("targetUserId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ImpersonationRequestLog" ADD CONSTRAINT "ImpersonationRequestLog_sessionId_fkey" FOREIGN KEY ("sessionId") REFERENCES "ImpersonationSession"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,30 @@
-- CreateTable
CREATE TABLE "TicketTransferRequest" (
"id" TEXT NOT NULL,
"ticketId" TEXT NOT NULL,
"requestedById" TEXT NOT NULL,
"toName" TEXT NOT NULL,
"toEmail" TEXT,
"toPhone" TEXT,
"details" JSONB,
"tokenHash" TEXT NOT NULL,
"status" TEXT NOT NULL DEFAULT 'pending',
"expiresAt" TIMESTAMP(3) NOT NULL,
"respondedAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "TicketTransferRequest_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "TicketTransferRequest_tokenHash_key" ON "TicketTransferRequest"("tokenHash");
-- CreateIndex
CREATE INDEX "TicketTransferRequest_ticketId_status_idx" ON "TicketTransferRequest"("ticketId", "status");
-- AddForeignKey
ALTER TABLE "TicketTransferRequest" ADD CONSTRAINT "TicketTransferRequest_ticketId_fkey" FOREIGN KEY ("ticketId") REFERENCES "Ticket"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "TicketTransferRequest" ADD CONSTRAINT "TicketTransferRequest_requestedById_fkey" FOREIGN KEY ("requestedById") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+333 -167
View File
@@ -103,8 +103,19 @@ model User {
couponsApplied CouponRedemption[] @relation("CouponRedemptionAppliedBy")
attendeeProfiles Attendee[]
heldTickets Ticket[] @relation("TicketHolder")
heldTickets Ticket[] @relation("TicketHolder")
waitlistEntries WaitlistEntry[]
scanLocks ScanLock[] @relation("ScanLockOwner")
transfersTo TicketTransfer[] @relation("TransferRecipient")
transfersBy TicketTransfer[] @relation("TransferActor")
ledGroups AllocationGroup[] @relation("AllocationGroupLeader")
feedbackInvites FeedbackInvite[]
feedbackGiven FeedbackResponse[]
impersonationsDone ImpersonationSession[] @relation("ImpersonationAdmin")
transferRequests TicketTransferRequest[] @relation("TransferRequester")
impersonatedIn ImpersonationSession[] @relation("ImpersonationTarget")
scanLocksForced ScanLock[] @relation("ScanLockForcedBy")
syncReportsDone OfflineSyncReport[] @relation("SyncReportReviewer")
twoFactorChallenges TwoFactorChallenge[]
trustedDevices TrustedDevice[]
@@ -138,6 +149,16 @@ model Event {
// them for waitlistOfferHours. waitlistPartialOffers: offer what's free even if it's
// fewer places than asked for (otherwise skip to the next person who fits).
waitlistEnabled Boolean @default(false)
allowTicketTransfer Boolean @default(true)
collectGender Boolean @default(false)
// Sponsor a spot: public donations into a bursary pool for this event.
allowSponsorships Boolean @default(false)
sponsorshipGoalSpots Int?
showSponsorshipProgress Boolean @default(true)
feedbackForm FeedbackForm?
analyticsEvents AnalyticsEvent[]
analyticsDaily AnalyticsDaily[]
allocationSets AllocationSet[]
waitlistOfferHours Int @default(24)
waitlistPartialOffers Boolean @default(true)
contactName String?
@@ -175,6 +196,7 @@ model Event {
coupons CouponEvent[]
consentRequests ConsentRequest[]
waitlistEntries WaitlistEntry[]
scanLocks ScanLock[]
@@index([createdById])
@@index([closedById])
@@ -249,6 +271,7 @@ model Registration {
discountTotal Float @default(0)
// Where named attendees' tickets go: all to the payer, or each to the attendee.
ticketDelivery TicketDelivery @default(payer)
analyticsSessionId String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Restrict)
@@ -320,6 +343,11 @@ model Payment {
registrationId String?
eventId String?
isDonation Boolean @default(false)
// A "sponsor a spot" donation (also isDonation): goes into the event's sponsorship pool.
isSponsorship Boolean @default(false)
sponsorName String?
sponsorMessage String?
sponsorAnonymous Boolean @default(false)
externalId String? @unique
status String?
originalPaymentId String?
@@ -361,6 +389,8 @@ model Ticket {
user User @relation(fields: [userId], references: [id], onDelete: Restrict)
event Event @relation(fields: [eventId], references: [id], onDelete: Restrict)
usages TicketUsage[]
transfers TicketTransfer[]
transferRequests TicketTransferRequest[]
@@index([holderUserId])
@@index([eventId])
@@ -370,13 +400,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])
@@ -461,6 +498,13 @@ enum AdminAuditAction {
consent_override
consent_rerequested
medical_list_viewed
scan_lock_acquired
scan_lock_released
scan_lock_force_released
offline_sync
ticket_transferred
impersonation_started
impersonation_ended
}
// Append-only audit trail for admin/supervisor-initiated actions with money or
@@ -506,6 +550,8 @@ enum FormFieldType {
numeric
statement
paragraph
rating
multiple_choice
}
model EventForm {
@@ -601,6 +647,7 @@ model Section {
event Event @relation(fields: [eventId], references: [id], onDelete: Cascade)
allowedOptions SectionOption[]
scanLocks ScanLock[]
@@index([eventId])
}
@@ -713,167 +760,6 @@ model EventCashupPersonCountDenomination {
@@index([countId])
}
// ─── Church Website Models (disabled — kept for reference, not active Prisma models) ──
//
// model Ministry {
// id String @id @default(uuid())
// slug String @unique
// title String
// description String?
// image String?
// isActive Boolean @default(true)
// order Int @default(0)
// leaderId String?
// leader TeamMember? @relation(fields: [leaderId], references: [id], onDelete: SetNull)
// interests MinistryInterest[]
// createdAt DateTime @default(now())
// updatedAt DateTime @updatedAt
//
// @@index([isActive, order])
// @@index([leaderId])
// }
//
// model TeamMember {
// id String @id @default(uuid())
// name String
// title String
// bio String?
// photo String?
// order Int @default(0)
// isActive Boolean @default(true)
// email String?
// createdAt DateTime @default(now())
// updatedAt DateTime @updatedAt
// ministries Ministry[]
//
// @@index([isActive, order])
// }
//
// model ContentBlock {
// id String @id @default(uuid())
// page String
// key String
// value String
// type String @default("text")
// updatedAt DateTime @updatedAt
//
// @@unique([page, key])
// @@index([page])
// }
//
// model ChurchDocument {
// id String @id @default(uuid())
// title String
// description String?
// fileUrl String
// category String @default("other")
// isPublic Boolean @default(true)
// uploadedAt DateTime @default(now())
// updatedAt DateTime @updatedAt
// }
//
// model FellowshipGroup {
// id String @id @default(uuid())
// name String
// description String?
// leader String?
// isActive Boolean @default(true)
// order Int @default(0)
// members ChurchMember[]
// createdAt DateTime @default(now())
// updatedAt DateTime @updatedAt
//
// @@index([isActive, order])
// }
//
// model ChurchMember {
// id String @id @default(uuid())
// status String @default("pending")
// mainName String
// mainEmail String
// mainPhone String?
// mainBirthday DateTime?
// mainOccupation String?
// mainEmploymentStatus String?
// mainDateOfSalvation DateTime?
// mainDateOfWaterBaptism DateTime?
// mainHolySpiritBaptism Boolean @default(false)
// spouseName String?
// spouseEmail String?
// spousePhone String?
// spouseBirthday DateTime?
// spouseOccupation String?
// spouseEmploymentStatus String?
// spouseDateOfSalvation DateTime?
// spouseDateOfWaterBaptism DateTime?
// spouseHolySpiritBaptism Boolean?
// homeAddress String?
// inFellowshipGroup Boolean @default(false)
// fellowshipGroupId String?
// fellowshipGroup FellowshipGroup? @relation(fields: [fellowshipGroupId], references: [id], onDelete: SetNull)
// membershipConfirmed Boolean @default(false)
// notes String?
// appliedAt DateTime @default(now())
// approvedAt DateTime?
// createdAt DateTime @default(now())
// updatedAt DateTime @updatedAt
// children ChurchChild[]
// ministryInterests MinistryInterest[]
// donations Donation[]
//
// @@index([status])
// @@index([mainEmail])
// @@index([fellowshipGroupId])
// }
//
// model ChurchChild {
// id String @id @default(uuid())
// memberId String
// name String
// birthday DateTime?
// member ChurchMember @relation(fields: [memberId], references: [id], onDelete: Cascade)
//
// @@index([memberId])
// }
//
// model MinistryInterest {
// id String @id @default(uuid())
// memberId String
// ministryId String
// forSpouse Boolean @default(false)
// member ChurchMember @relation(fields: [memberId], references: [id], onDelete: Cascade)
// ministry Ministry @relation(fields: [ministryId], references: [id], onDelete: Cascade)
//
// @@unique([memberId, ministryId, forSpouse])
// @@index([memberId])
// @@index([ministryId])
// }
//
// model Donation {
// id String @id @default(uuid())
// donorName String?
// donorEmail String?
// donorPhone String?
// amount Float
// method String
// reference String?
// note String?
// isAnonymous Boolean @default(false)
// linkedMemberId String?
// linkedMember ChurchMember? @relation(fields: [linkedMemberId], references: [id], onDelete: SetNull)
// createdAt DateTime @default(now())
// updatedAt DateTime @updatedAt
//
// @@index([linkedMemberId])
// @@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
@@ -974,6 +860,237 @@ enum TicketDelivery {
// One named person on a group registration, for one main-ticket place. Gets their
// own quantity-1 ticket and their own attendee form response.
/// An admin viewing the site as another user (strictly read-only, at most 30 minutes).
model ImpersonationSession {
id String @id @default(uuid())
adminId String
targetUserId String
reason String
startedAt DateTime @default(now())
expiresAt DateTime
endedAt DateTime?
endedReason String? // 'ended' | 'expired'
admin User @relation("ImpersonationAdmin", fields: [adminId], references: [id], onDelete: Cascade)
target User @relation("ImpersonationTarget", fields: [targetUserId], references: [id], onDelete: Cascade)
requests ImpersonationRequestLog[]
@@index([adminId])
@@index([targetUserId])
}
/// Every page viewed and API call made during an impersonation session.
model ImpersonationRequestLog {
id String @id @default(uuid())
sessionId String
method String
path String
blocked Boolean @default(false)
createdAt DateTime @default(now())
session ImpersonationSession @relation(fields: [sessionId], references: [id], onDelete: Cascade)
@@index([sessionId, createdAt])
}
/// First-party funnel tracking (only after cookie consent): one row per anonymous
/// session, event and stage ('view' | 'start' | 'submit'). "Paid" comes from the
/// registration that carries the session id. Rows older than 90 days are rolled up
/// into AnalyticsDaily and deleted.
model AnalyticsEvent {
id String @id @default(uuid())
sessionId String
eventId String
stage String
source String?
device String?
createdAt DateTime @default(now())
event Event @relation(fields: [eventId], references: [id], onDelete: Cascade)
@@unique([sessionId, eventId, stage])
@@index([eventId, createdAt])
}
/// Rolled-up funnel counts: sessions that reached `stage`, per day, source and device.
model AnalyticsDaily {
id String @id @default(uuid())
eventId String
day DateTime @db.Date
stage String
source String @default("direct")
device String @default("unknown")
sessions Int @default(0)
event Event @relation(fields: [eventId], references: [id], onDelete: Cascade)
@@unique([eventId, day, stage, source, device])
@@index([eventId, day])
}
/// Post-event survey. Sent at sendAt to checked-in attendees (or everyone registered),
/// with one reminder 3 days later. anonymous responses aren't linked to a person.
model FeedbackForm {
id String @id @default(uuid())
eventId String @unique
sendAt DateTime?
audience String @default("checked_in") // 'checked_in' | 'all'
anonymous Boolean @default(false)
intro String?
sentAt DateTime?
reminderSentAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
event Event @relation(fields: [eventId], references: [id], onDelete: Cascade)
fields FeedbackField[]
invites FeedbackInvite[]
responses FeedbackResponse[]
}
model FeedbackField {
id String @id @default(uuid())
formId String
type FormFieldType
label String
isRequired Boolean @default(false)
order Int @default(0)
helpText String?
options Json?
form FeedbackForm @relation(fields: [formId], references: [id], onDelete: Cascade)
answers FeedbackAnswer[]
@@index([formId])
}
/// Who was asked. tokenHash is the HMAC of the link token (the token itself isn't stored).
model FeedbackInvite {
id String @id @default(uuid())
formId String
userId String
tokenHash String @unique
sentAt DateTime @default(now())
remindedAt DateTime?
respondedAt DateTime?
form FeedbackForm @relation(fields: [formId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([formId, userId])
@@index([userId])
}
model FeedbackResponse {
id String @id @default(uuid())
formId String
userId String? // null when the form is anonymous
createdAt DateTime @default(now())
form FeedbackForm @relation(fields: [formId], references: [id], onDelete: Cascade)
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
answers FeedbackAnswer[]
@@index([formId])
}
model FeedbackAnswer {
id String @id @default(uuid())
responseId String
fieldId String
value String
response FeedbackResponse @relation(fields: [responseId], references: [id], onDelete: Cascade)
field FeedbackField @relation(fields: [fieldId], references: [id], onDelete: Cascade)
@@index([responseId])
@@index([fieldId])
}
/// A way of splitting an event's attendees up: "Rooms", "Small groups", "Buses".
model AllocationSet {
id String @id @default(uuid())
eventId String
name String
showOnTicket Boolean @default(false)
order Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
event Event @relation(fields: [eventId], references: [id], onDelete: Cascade)
groups AllocationGroup[]
assignments AllocationAssignment[]
@@index([eventId])
}
/// One room / group / bus in a set. gender ('male' | 'female') restricts who can join.
model AllocationGroup {
id String @id @default(uuid())
setId String
name String
capacity Int?
gender String?
leaderUserId String?
order Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
set AllocationSet @relation(fields: [setId], references: [id], onDelete: Cascade)
leader User? @relation("AllocationGroupLeader", fields: [leaderUserId], references: [id], onDelete: SetNull)
assignments AllocationAssignment[]
@@index([setId])
@@index([leaderUserId])
}
/// An attendee placed in a group. One group per set.
model AllocationAssignment {
id String @id @default(uuid())
setId String
groupId String
attendeeId String
createdAt DateTime @default(now())
set AllocationSet @relation(fields: [setId], references: [id], onDelete: Cascade)
group AllocationGroup @relation(fields: [groupId], references: [id], onDelete: Cascade)
attendee Attendee @relation(fields: [attendeeId], references: [id], onDelete: Cascade)
@@unique([setId, attendeeId])
@@index([groupId])
@@index([attendeeId])
}
/// A transfer waiting for the new holder to accept it from the link sent to the email /
/// phone the sender typed. Nothing changes on the ticket until it's accepted. `details`
/// holds what the sender entered (date of birth, gender, parent details on youth events).
model TicketTransferRequest {
id String @id @default(uuid())
ticketId String
requestedById String
toName String
toEmail String?
toPhone String?
details Json?
tokenHash String @unique
status String @default("pending") // pending | accepted | declined | cancelled | expired
expiresAt DateTime
respondedAt DateTime?
createdAt DateTime @default(now())
ticket Ticket @relation(fields: [ticketId], references: [id], onDelete: Cascade)
requestedBy User @relation("TransferRequester", fields: [requestedById], references: [id], onDelete: Cascade)
@@index([ticketId, status])
}
/// A ticket handed to someone else. oldQrCode is the code that stopped working (null
/// when one place was split off a shared group ticket, whose code stays valid).
model TicketTransfer {
id String @id @default(uuid())
ticketId String
fromHolderName String
fromUserId String?
toName String
toUserId String?
byUserId String?
oldQrCode String?
createdAt DateTime @default(now())
ticket Ticket @relation(fields: [ticketId], references: [id], onDelete: Cascade)
toUser User? @relation("TransferRecipient", fields: [toUserId], references: [id], onDelete: SetNull)
byUser User? @relation("TransferActor", fields: [byUserId], references: [id], onDelete: SetNull)
@@index([ticketId])
@@index([oldQrCode])
}
model Attendee {
id String @id @default(uuid())
registrationId String
@@ -998,6 +1115,7 @@ model Attendee {
ticket Ticket?
formResponses FormResponse[]
consentRequests ConsentRequest[]
allocations AllocationAssignment[]
@@index([registrationId])
@@index([registrationOptionId])
@@ -1085,3 +1203,51 @@ model WaitlistEntry {
@@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])
}
@@ -0,0 +1,323 @@
const prisma = require('../config/db');
const { safeErrorMessage } = require('../utils/errorUtils');
const { logAdminAction } = require('../utils/adminAudit');
const { getClientIp } = require('../utils/requestUtils');
const { allocationError, cleanGender, joinBlocker, autoAllocate } = require('../utils/allocation');
const SUPERVISORS = ['admin', 'supervisor'];
const fail = (res, error) => res.status(error.statusCode || (res.statusCode === 200 ? 400 : res.statusCode)).json({ message: safeErrorMessage(error) });
const cleanName = (v, what) => {
const name = String(v || '').trim();
if (!name) throw allocationError(`Enter a name for the ${what}.`);
return name.slice(0, 100);
};
const cleanCapacity = (v) => {
if (v === undefined || v === null || v === '') return null;
const n = parseInt(v, 10);
if (!Number.isFinite(n) || n < 1 || n > 10000) throw allocationError('Capacity must be a whole number of at least 1.');
return n;
};
/** Named attendees on the event's live registrations. */
async function eventAttendees(eventId) {
return prisma.attendee.findMany({
where: { registration: { eventId, status: { not: 'cancelled' } } },
select: {
id: true, name: true, gender: true, dateOfBirth: true, registrationId: true, email: true, phone: true,
registration: { select: { user: { select: { name: true } } } },
},
orderBy: [{ registrationId: 'asc' }, { order: 'asc' }],
});
}
async function loadSet(id) {
const set = await prisma.allocationSet.findUnique({ where: { id }, include: { groups: { orderBy: { order: 'asc' } } } });
if (!set) throw allocationError('Allocation set not found', 404);
return set;
}
// @desc Everything the allocation board needs for an event
// @route GET /api/allocations/event/:eventId
// @access Private/Supervisor
const getBoard = async (req, res) => {
try {
const event = await prisma.event.findUnique({
where: { id: req.params.eventId },
select: { id: true, title: true, startDate: true, collectGender: true, allowNamedAttendees: true, requiresParentalConsent: true },
});
if (!event) throw allocationError('Event not found', 404);
const [sets, attendees, leaders, places] = await Promise.all([
prisma.allocationSet.findMany({
where: { eventId: event.id },
include: { groups: { orderBy: { order: 'asc' }, include: { leader: { select: { id: true, name: true } } } }, assignments: { select: { attendeeId: true, groupId: true } } },
orderBy: [{ order: 'asc' }, { createdAt: 'asc' }],
}),
eventAttendees(event.id),
prisma.user.findMany({ where: { role: { in: ['staff', 'supervisor', 'admin'] }, isActive: true }, select: { id: true, name: true }, orderBy: { name: 'asc' } }),
prisma.registrationOption.aggregate({
where: { registration: { eventId: event.id, status: { not: 'cancelled' } }, eventOption: { isMainTicket: true } },
_sum: { quantity: true },
}),
]);
res.json({
event,
sets,
attendees: attendees.map(a => ({ id: a.id, name: a.name, gender: a.gender, dateOfBirth: a.dateOfBirth, registrationId: a.registrationId, bookedBy: a.registration.user?.name || '' })),
leaders,
unnamedPlaces: Math.max(0, (places._sum.quantity || 0) - attendees.length),
});
} catch (error) {
fail(res, error);
}
};
// @route POST /api/allocations/sets { eventId, name, showOnTicket? }
const createSet = async (req, res) => {
try {
const { eventId, name, showOnTicket } = req.body || {};
if (!(await prisma.event.findUnique({ where: { id: String(eventId || '') }, select: { id: true } }))) throw allocationError('Event not found', 404);
const order = await prisma.allocationSet.count({ where: { eventId } });
res.status(201).json(await prisma.allocationSet.create({ data: { eventId, name: cleanName(name, 'set'), showOnTicket: showOnTicket === true, order } }));
} catch (error) {
fail(res, error);
}
};
// @route PUT /api/allocations/sets/:id { name?, showOnTicket? }
const updateSet = async (req, res) => {
try {
await loadSet(req.params.id);
const { name, showOnTicket } = req.body || {};
res.json(await prisma.allocationSet.update({
where: { id: req.params.id },
data: { ...(name !== undefined ? { name: cleanName(name, 'set') } : {}), ...(showOnTicket !== undefined ? { showOnTicket: showOnTicket === true } : {}) },
}));
} catch (error) {
fail(res, error);
}
};
// @route DELETE /api/allocations/sets/:id
const deleteSet = async (req, res) => {
try {
await loadSet(req.params.id);
await prisma.allocationSet.delete({ where: { id: req.params.id } });
res.json({ message: 'Deleted' });
} catch (error) {
fail(res, error);
}
};
async function groupData(body, setId, existing = null) {
const { name, capacity, gender, leaderUserId } = body || {};
const data = {};
if (name !== undefined || !existing) data.name = cleanName(name, 'group');
if (capacity !== undefined) data.capacity = cleanCapacity(capacity);
if (gender !== undefined) data.gender = cleanGender(gender);
if (leaderUserId !== undefined) {
if (leaderUserId) {
const leader = await prisma.user.findUnique({ where: { id: String(leaderUserId) }, select: { role: true } });
if (!leader || !['staff', 'supervisor', 'admin'].includes(leader.role)) throw allocationError('A group leader must be a staff member.');
}
data.leaderUserId = leaderUserId || null;
}
// Changing capacity or gender mustn't leave the group over capacity or mixed.
if (existing && (data.capacity !== undefined || data.gender !== undefined)) {
const members = await prisma.allocationAssignment.findMany({ where: { groupId: existing.id }, include: { attendee: { select: { name: true, gender: true } } } });
const next = { ...existing, ...data };
const blocker = joinBlocker(next, 0, members.map(m => m.attendee));
if (blocker) throw allocationError(`${blocker} Move people out first.`);
}
return data;
}
// @route POST /api/allocations/sets/:id/groups { name, capacity?, gender?, leaderUserId? }
const createGroup = async (req, res) => {
try {
const set = await loadSet(req.params.id);
const data = await groupData(req.body, set.id);
res.status(201).json(await prisma.allocationGroup.create({ data: { ...data, setId: set.id, order: set.groups.length } }));
} catch (error) {
fail(res, error);
}
};
// @route PUT /api/allocations/groups/:id
const updateGroup = async (req, res) => {
try {
const group = await prisma.allocationGroup.findUnique({ where: { id: req.params.id } });
if (!group) throw allocationError('Group not found', 404);
const data = await groupData(req.body, group.setId, group);
res.json(await prisma.allocationGroup.update({ where: { id: group.id }, data }));
} catch (error) {
fail(res, error);
}
};
// @route DELETE /api/allocations/groups/:id (its people go back to unassigned)
const deleteGroup = async (req, res) => {
try {
await prisma.allocationGroup.delete({ where: { id: req.params.id } });
res.json({ message: 'Deleted' });
} catch (error) {
if (error.code === 'P2025') return res.status(404).json({ message: 'Group not found' });
fail(res, error);
}
};
// @desc Put an attendee in a group (groupId null = unassign)
// @route PUT /api/allocations/sets/:id/assignments { attendeeId, groupId }
const assign = async (req, res) => {
try {
const set = await loadSet(req.params.id);
const { attendeeId, groupId } = req.body || {};
const attendee = await prisma.attendee.findFirst({
where: { id: String(attendeeId || ''), registration: { eventId: set.eventId, status: { not: 'cancelled' } } },
select: { id: true, name: true, gender: true },
});
if (!attendee) throw allocationError('Attendee not found for this event', 404);
if (!groupId) {
await prisma.allocationAssignment.deleteMany({ where: { setId: set.id, attendeeId: attendee.id } });
return res.json({ message: 'Unassigned' });
}
const group = set.groups.find(g => g.id === groupId);
if (!group) throw allocationError('Group not found in this set', 404);
await prisma.$transaction(async (tx) => {
await tx.$executeRaw`SELECT pg_advisory_xact_lock(hashtext(${'alloc:' + group.id}))`;
const count = await tx.allocationAssignment.count({ where: { groupId: group.id, NOT: { attendeeId: attendee.id } } });
const blocker = joinBlocker(group, count, [attendee]);
if (blocker) throw allocationError(blocker);
await tx.allocationAssignment.upsert({
where: { setId_attendeeId: { setId: set.id, attendeeId: attendee.id } },
update: { groupId: group.id },
create: { setId: set.id, groupId: group.id, attendeeId: attendee.id },
});
});
res.json({ message: `${attendee.name} → ${group.name}` });
} catch (error) {
fail(res, error);
}
};
// @desc Place everyone not yet in a group
// @route POST /api/allocations/sets/:id/auto { mode: 'together' | 'split' }
const autoAssign = async (req, res) => {
try {
const set = await loadSet(req.params.id);
if (set.groups.length === 0) throw allocationError('Add some groups first.');
const mode = req.body?.mode === 'split' ? 'split' : 'together';
const result = await prisma.$transaction(async (tx) => {
await tx.$executeRaw`SELECT pg_advisory_xact_lock(hashtext(${'allocset:' + set.id}))`;
const [attendees, existing] = await Promise.all([
eventAttendees(set.eventId),
tx.allocationAssignment.findMany({ where: { setId: set.id } }),
]);
const r = autoAllocate({ groups: set.groups, attendees, assigned: new Map(existing.map(a => [a.attendeeId, a.groupId])), mode });
if (r.placements.length) {
await tx.allocationAssignment.createMany({ data: r.placements.map(p => ({ setId: set.id, groupId: p.groupId, attendeeId: p.attendeeId })), skipDuplicates: true });
}
return r;
});
res.json({
placed: result.placements.length,
unplaced: result.unplaced.length,
message: `${result.placements.length} placed${result.unplaced.length ? `; ${result.unplaced.length} couldn't be placed (groups full, or gender doesn't match)` : ''}.`,
});
} catch (error) {
fail(res, error);
}
};
// @route DELETE /api/allocations/sets/:id/assignments (clear the whole set)
const clearAssignments = async (req, res) => {
try {
const set = await loadSet(req.params.id);
const { count } = await prisma.allocationAssignment.deleteMany({ where: { setId: set.id } });
res.json({ message: `${count} removed from groups.` });
} catch (error) {
fail(res, error);
}
};
/** Members of groups, with contact and (for supervisors) medical details. */
async function groupMembers(groupIds, { withMedical }) {
const assignments = await prisma.allocationAssignment.findMany({
where: { groupId: { in: groupIds } },
include: {
attendee: {
select: {
id: true, name: true, gender: true, dateOfBirth: true, phone: true, parentName: true, parentPhone: true,
registration: { select: { user: { select: { name: true, phoneNumber: true } } } },
},
},
},
orderBy: { attendee: { name: 'asc' } },
});
let consent = new Map();
if (withMedical && assignments.length) {
const { latestRequestsFor, openJson } = require('../utils/consent');
const latest = await latestRequestsFor(assignments.map(a => a.attendeeId));
consent = new Map([...latest].map(([id, r]) => [id, r.status === 'given' ? { medical: openJson(r.medical), emergency: openJson(r.emergency) } : null]));
}
return assignments.map(a => ({
groupId: a.groupId,
id: a.attendee.id,
name: a.attendee.name,
gender: a.attendee.gender,
dateOfBirth: a.attendee.dateOfBirth,
phone: a.attendee.phone,
parentName: a.attendee.parentName,
parentPhone: a.attendee.parentPhone,
bookedBy: a.attendee.registration.user?.name || '',
bookedByPhone: a.attendee.registration.user?.phoneNumber || null,
...(withMedical ? consent.get(a.attendee.id) || {} : {}),
}));
}
// @desc Lists for printing: every group of a set, with members
// @route GET /api/allocations/sets/:id/lists?medical=true
// @access Private/Supervisor
const getSetLists = async (req, res) => {
try {
const set = await prisma.allocationSet.findUnique({
where: { id: req.params.id },
include: { event: { select: { id: true, title: true, startDate: true } }, groups: { orderBy: { order: 'asc' }, include: { leader: { select: { name: true } } } } },
});
if (!set) throw allocationError('Allocation set not found', 404);
const withMedical = req.query.medical === 'true';
const members = await groupMembers(set.groups.map(g => g.id), { withMedical });
if (withMedical) {
logAdminAction({ actorId: req.user.id, actorRole: req.user.role, action: 'medical_list_viewed', targetType: 'AllocationSet', targetId: set.id, metadata: { eventId: set.eventId, attendees: members.length }, ip: getClientIp(req) });
}
res.json({ set: { id: set.id, name: set.name }, event: set.event, groups: set.groups.map(g => ({ id: g.id, name: g.name, capacity: g.capacity, gender: g.gender, leader: g.leader?.name || null, members: members.filter(m => m.groupId === g.id) })) });
} catch (error) {
fail(res, error);
}
};
// @desc Groups the signed-in staff member leads, with their people
// @route GET /api/allocations/my-groups
// @access Private/Staff
const getMyGroups = async (req, res) => {
try {
const groups = await prisma.allocationGroup.findMany({
where: { leaderUserId: req.user.id, set: { event: { endDate: { gte: new Date(Date.now() - 7 * 864e5) } } } },
include: { set: { include: { event: { select: { id: true, title: true, startDate: true } } } } },
orderBy: [{ set: { event: { startDate: 'asc' } } }, { order: 'asc' }],
});
const withMedical = SUPERVISORS.includes(req.user.role);
const members = await groupMembers(groups.map(g => g.id), { withMedical });
if (withMedical && members.length) {
logAdminAction({ actorId: req.user.id, actorRole: req.user.role, action: 'medical_list_viewed', targetType: 'AllocationGroup', targetId: null, metadata: { groups: groups.length, attendees: members.length }, ip: getClientIp(req) });
}
res.json({
medicalShown: withMedical,
groups: groups.map(g => ({ id: g.id, name: g.name, capacity: g.capacity, gender: g.gender, setName: g.set.name, event: g.set.event, members: members.filter(m => m.groupId === g.id) })),
});
} catch (error) {
fail(res, error);
}
};
module.exports = { getBoard, createSet, updateSet, deleteSet, createGroup, updateGroup, deleteGroup, assign, autoAssign, clearAssignments, getSetLists, getMyGroups };
@@ -0,0 +1,44 @@
const prisma = require('../config/db');
const { safeErrorMessage } = require('../utils/errorUtils');
const { track, funnelFor } = require('../utils/analytics');
// @desc Record a funnel step (event page view, registration started)
// @route POST /api/analytics/track { sessionId, eventId, stage: 'view'|'start', source? }
// @access Public — only sent by browsers whose visitor accepted cookies
const trackStage = async (req, res) => {
try {
const { sessionId, eventId, stage, source } = req.body || {};
await track({ sessionId, eventId, stage, source, userAgent: req.headers['user-agent'] });
} catch (e) {
console.warn('[analytics] track failed:', e?.message || e);
}
// Always a quiet 204 — tracking must never get in a visitor's way.
res.status(204).end();
};
// @desc Registration funnel for one or more events
// @route GET /api/analytics/funnel?eventIds=a,b&from=YYYY-MM-DD&to=YYYY-MM-DD
// @access Private/Supervisor
const getFunnel = async (req, res) => {
try {
const eventIds = String(req.query.eventIds || '').split(',').map(s => s.trim()).filter(Boolean).slice(0, 50);
if (eventIds.length === 0) { res.status(400); throw new Error('Choose at least one event'); }
const events = await prisma.event.findMany({ where: { id: { in: eventIds } }, select: { id: true, title: true } });
const total = await funnelFor(eventIds, { from: req.query.from, to: req.query.to });
// Per event too, so events can be compared.
const perEvent = [];
if (events.length > 1) {
for (const ev of events) {
const f = await funnelFor([ev.id], { from: req.query.from, to: req.query.to });
perEvent.push({ eventId: ev.id, title: ev.title, totals: f.totals, conversion: f.conversion });
}
} else if (events[0]) {
perEvent.push({ eventId: events[0].id, title: events[0].title, totals: total.totals, conversion: total.conversion });
}
res.json({ ...total, perEvent });
} catch (error) {
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
}
};
module.exports = { trackStage, getFunnel };
@@ -24,7 +24,7 @@ async function scheduleAutomations(req, res) {
html: j.html ? String(j.html) : undefined,
text: (!j.html && j.text) ? String(j.text) : (j.text ? String(j.text) : undefined),
template: 'custom',
filter: { status: undefined, attendeeIds: undefined },
filter: { status: undefined, attendeeIds: undefined, ...(j.audience === 'sponsors' ? { audience: 'sponsors' } : {}) },
};
if (j.promoEventId && typeof j.promoEventId === 'string') {
payload.promoEventId = j.promoEventId;
+40 -2
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, allowNamedAttendees, requiresParentalConsent, consentText, consentCollectMedical, consentCollectEmergency, consentCollectPhoto, waitlistEnabled, waitlistOfferHours, waitlistPartialOffers, 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, allowTicketTransfer, collectGender, allowSponsorships, sponsorshipGoalSpots, showSponsorshipProgress, contactName, contactPhone, contactEmail, location } = req.body;
const data = {
id: uuidv4(),
@@ -70,6 +70,11 @@ const createEvent = async (req, res) => {
consentCollectEmergency: consentCollectEmergency === true,
consentCollectPhoto: consentCollectPhoto === true,
waitlistEnabled: waitlistEnabled === true,
allowTicketTransfer: allowTicketTransfer !== false,
collectGender: collectGender === true,
allowSponsorships: allowSponsorships === true,
sponsorshipGoalSpots: sponsorshipGoalSpots ? Math.max(1, parseInt(sponsorshipGoalSpots, 10) || 0) || null : null,
showSponsorshipProgress: showSponsorshipProgress !== false,
waitlistOfferHours: Math.min(168, Math.max(1, parseInt(waitlistOfferHours, 10) || 24)),
waitlistPartialOffers: waitlistPartialOffers !== false,
contactName: contactName || null,
@@ -536,7 +541,7 @@ const updateEvent = async (req, res) => {
ip: getClientIp(req),
});
const { title, description, startDate, endDate, registrationDeadline, goLiveAt, price, picture, isActive, redirectUrl, isHidden, requiresAuth, requiresRegistration, allowNamedAttendees, requiresParentalConsent, consentText, consentCollectMedical, consentCollectEmergency, consentCollectPhoto, waitlistEnabled, waitlistOfferHours, waitlistPartialOffers, contactName, contactPhone, contactEmail, location } = req.body;
const { title, description, startDate, endDate, registrationDeadline, goLiveAt, price, picture, isActive, redirectUrl, isHidden, requiresAuth, requiresRegistration, allowNamedAttendees, requiresParentalConsent, consentText, consentCollectMedical, consentCollectEmergency, consentCollectPhoto, waitlistEnabled, waitlistOfferHours, waitlistPartialOffers, allowTicketTransfer, collectGender, allowSponsorships, sponsorshipGoalSpots, showSponsorshipProgress, contactName, contactPhone, contactEmail, location } = req.body;
const data = {
title: title || event.title,
@@ -559,6 +564,11 @@ const updateEvent = async (req, res) => {
consentCollectEmergency: consentCollectEmergency !== undefined ? consentCollectEmergency === true : event.consentCollectEmergency,
consentCollectPhoto: consentCollectPhoto !== undefined ? consentCollectPhoto === true : event.consentCollectPhoto,
waitlistEnabled: waitlistEnabled !== undefined ? waitlistEnabled === true : event.waitlistEnabled,
allowTicketTransfer: allowTicketTransfer !== undefined ? allowTicketTransfer !== false : event.allowTicketTransfer,
collectGender: collectGender !== undefined ? collectGender === true : event.collectGender,
allowSponsorships: allowSponsorships !== undefined ? allowSponsorships === true : event.allowSponsorships,
sponsorshipGoalSpots: sponsorshipGoalSpots !== undefined ? (sponsorshipGoalSpots ? Math.max(1, parseInt(sponsorshipGoalSpots, 10) || 0) || null : null) : event.sponsorshipGoalSpots,
showSponsorshipProgress: showSponsorshipProgress !== undefined ? showSponsorshipProgress !== false : event.showSponsorshipProgress,
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
@@ -1329,6 +1339,11 @@ const emailEventAttendees = async (req, res) => {
where.status = 'cancelled';
}
// Only registrations with someone in this allocation group ("everyone on Bus 2").
if (filter?.allocationGroupId) {
where.attendees = { some: { allocations: { some: { groupId: String(filter.allocationGroupId) } } } };
}
// Optional explicit attendee selection by userIds
const attendeeIds = Array.isArray(filter?.attendeeIds) ? filter.attendeeIds.filter((x) => typeof x === 'string' && x.trim().length > 0) : [];
if (attendeeIds.length > 0) {
@@ -1379,6 +1394,24 @@ const emailEventAttendees = async (req, res) => {
}
const recipients = Array.from(uniqMap.values());
// Sponsors of this event instead of its attendees (e.g. the post-event thank-you).
if (filter?.audience === 'sponsors') {
recipients.length = 0;
registrations.length = 0;
const sponsorships = await prisma.payment.findMany({
where: { eventId, isDonation: true, isSponsorship: true, amount: { gt: 0 } },
include: { user: { select: { name: true, email: true, isActive: true } } },
orderBy: { createdAt: 'asc' },
});
const seen = new Set();
for (const s of sponsorships) {
const email = (s.user?.email || '').trim();
if (!email || email.endsWith('@guest.local') || email.endsWith('@deleted.invalid') || seen.has(email)) continue;
seen.add(email);
recipients.push({ email, name: s.sponsorName || s.user?.name || '' });
}
}
if (dryRun) {
return res.json({ eventId, matched: recipients.length, recipients: recipients.slice(0, 20) });
}
@@ -1567,6 +1600,11 @@ const whatsappEventAttendees = async (req, res) => {
where.status = 'cancelled';
}
// Only registrations with someone in this allocation group ("everyone on Bus 2").
if (filter?.allocationGroupId) {
where.attendees = { some: { allocations: { some: { groupId: String(filter.allocationGroupId) } } } };
}
const attendeeIds = Array.isArray(filter?.attendeeIds) ? filter.attendeeIds.filter((x) => typeof x === 'string' && x.trim().length > 0) : [];
if (attendeeIds.length > 0) {
where.userId = { in: attendeeIds };
@@ -0,0 +1,179 @@
const prisma = require('../config/db');
const { safeErrorMessage } = require('../utils/errorUtils');
const { ANSWERABLE, feedbackError, cleanFields, cleanAnswer, summarise, sendFeedbackInvites, findInviteByToken } = require('../utils/feedback');
const fail = (res, error) => res.status(error.statusCode || (res.statusCode === 200 ? 400 : res.statusCode)).json({ message: safeErrorMessage(error) });
const shape = (fields) => JSON.stringify(fields.map(f => [f.type, f.label, f.isRequired, f.options || null, f.helpText || null]));
async function formWithStats(eventId) {
const form = await prisma.feedbackForm.findUnique({ where: { eventId }, include: { fields: { orderBy: { order: 'asc' } } } });
if (!form) return null;
const [invited, responses] = await Promise.all([
prisma.feedbackInvite.count({ where: { formId: form.id } }),
prisma.feedbackResponse.count({ where: { formId: form.id } }),
]);
return { ...form, invited, responses };
}
// @desc An event's feedback form (with invite/response counts)
// @route GET /api/feedback/event/:eventId
// @access Private/Supervisor
const getEventFeedback = async (req, res) => {
try {
res.json(await formWithStats(req.params.eventId));
} catch (error) {
fail(res, error);
}
};
// @desc Create or update an event's feedback form
// @route PUT /api/feedback/event/:eventId { sendAt, audience, anonymous, intro, fields[] }
// @access Private/Supervisor
const saveEventFeedback = async (req, res) => {
try {
const event = await prisma.event.findUnique({ where: { id: req.params.eventId }, select: { id: true, endDate: true } });
if (!event) throw feedbackError('Event not found', 404);
const { sendAt, audience, anonymous, intro, fields } = req.body || {};
const clean = cleanFields(fields);
let when = null;
if (sendAt) {
when = new Date(sendAt);
if (isNaN(when.getTime())) throw feedbackError('Enter a valid send time.');
}
const data = {
sendAt: when,
audience: audience === 'all' ? 'all' : 'checked_in',
anonymous: anonymous === true,
intro: intro ? String(intro).slice(0, 2000) : null,
};
const existing = await prisma.feedbackForm.findUnique({ where: { eventId: event.id }, include: { fields: { orderBy: { order: 'asc' } } } });
if (!existing) {
await prisma.feedbackForm.create({ data: { ...data, eventId: event.id, fields: { create: clean } } });
} else {
const answered = await prisma.feedbackResponse.count({ where: { formId: existing.id } });
const changed = shape(existing.fields) !== shape(clean);
if (answered > 0 && changed) throw feedbackError('People have already answered this feedback form, so its questions can’t be changed.', 409);
await prisma.$transaction(async (tx) => {
await tx.feedbackForm.update({ where: { id: existing.id }, data: existing.sentAt ? { ...data, sendAt: existing.sendAt } : data });
if (changed) {
await tx.feedbackField.deleteMany({ where: { formId: existing.id } });
await tx.feedbackField.createMany({ data: clean.map(f => ({ ...f, formId: existing.id, options: f.options ?? undefined })) });
}
});
}
res.json(await formWithStats(event.id));
} catch (error) {
fail(res, error);
}
};
// @route DELETE /api/feedback/event/:eventId
const deleteEventFeedback = async (req, res) => {
try {
await prisma.feedbackForm.deleteMany({ where: { eventId: req.params.eventId } });
res.json({ message: 'Feedback form removed' });
} catch (error) {
fail(res, error);
}
};
// @desc Send the feedback form now (instead of waiting for its send time)
// @route POST /api/feedback/event/:eventId/send-now
// @access Private/Supervisor
const sendNow = async (req, res) => {
try {
const form = await prisma.feedbackForm.findUnique({ where: { eventId: req.params.eventId } });
if (!form) throw feedbackError('This event has no feedback form.', 404);
if (!form.sentAt) await prisma.feedbackForm.update({ where: { id: form.id }, data: { sentAt: new Date(), sendAt: form.sendAt || new Date() } });
// Also invites anyone who checked in after the first send.
const invited = await sendFeedbackInvites(form.id);
res.json({ invited, message: invited ? `Feedback form sent to ${invited} ${invited === 1 ? 'person' : 'people'}.` : 'Everyone has already been sent the form.' });
} catch (error) {
fail(res, error);
}
};
// @desc The form behind a personal feedback link
// @route GET /api/feedback/public/:token
// @access Public (link holder)
const getPublicForm = async (req, res) => {
try {
const invite = await findInviteByToken(req.params.token);
if (!invite) throw feedbackError('This feedback link isn’t valid (it may have been replaced by a newer one).', 404);
const { form } = invite;
res.json({
event: form.event,
name: invite.user?.name || '',
intro: form.intro,
anonymous: form.anonymous,
responded: !!invite.respondedAt,
fields: form.fields.map(f => ({ id: f.id, type: f.type, label: f.label, isRequired: f.isRequired, helpText: f.helpText, options: f.options })),
});
} catch (error) {
fail(res, error);
}
};
// @desc Submit feedback (once per person)
// @route POST /api/feedback/public/:token { answers: { fieldId: value } }
// @access Public (link holder)
const submitPublicForm = async (req, res) => {
try {
const invite = await findInviteByToken(req.params.token);
if (!invite) throw feedbackError('This feedback link isn’t valid (it may have been replaced by a newer one).', 404);
if (invite.respondedAt) throw feedbackError('Thanks — you’ve already given feedback for this event.', 409);
const answers = req.body?.answers || {};
const rows = [];
for (const f of invite.form.fields.filter(x => ANSWERABLE.includes(x.type))) {
const value = cleanAnswer(f, answers[f.id]);
if (value !== null) rows.push({ fieldId: f.id, value });
}
if (rows.length === 0) throw feedbackError('Please answer at least one question.');
await prisma.$transaction(async (tx) => {
// Claim the invite first, so a double-submit can't create two responses.
const claimed = await tx.feedbackInvite.updateMany({ where: { id: invite.id, respondedAt: null }, data: { respondedAt: new Date() } });
if (claimed.count !== 1) throw feedbackError('Thanks — you’ve already given feedback for this event.', 409);
await tx.feedbackResponse.create({
data: { formId: invite.formId, userId: invite.form.anonymous ? null : invite.userId, answers: { create: rows } },
});
});
res.status(201).json({ message: 'Thank you for your feedback!' });
} catch (error) {
fail(res, error);
}
};
// @desc Feedback summary for one or more events (for the report, and comparisons)
// @route GET /api/feedback/summary?eventIds=a,b
// @access Private/Supervisor
const getSummary = async (req, res) => {
try {
const ids = String(req.query.eventIds || '').split(',').map(s => s.trim()).filter(Boolean).slice(0, 50);
const forms = await prisma.feedbackForm.findMany({
where: { eventId: { in: ids } },
include: {
event: { select: { id: true, title: true, startDate: true } },
fields: { orderBy: { order: 'asc' } },
responses: { include: { answers: true, user: { select: { name: true } } } },
_count: { select: { invites: true } },
},
});
const out = forms.map(form => {
const answers = form.responses.flatMap(r => r.answers.map(a => ({ fieldId: a.fieldId, value: a.value, ...(form.anonymous ? {} : { name: r.user?.name || '' }) })));
const invited = form._count.invites;
return {
eventId: form.event.id, title: form.event.title, startDate: form.event.startDate,
anonymous: form.anonymous, sentAt: form.sentAt,
invited, responses: form.responses.length,
responseRate: invited ? Math.round((form.responses.length / invited) * 100) : null,
questions: summarise(form.fields, answers),
};
}).sort((a, b) => new Date(a.startDate) - new Date(b.startDate));
res.json(out);
} catch (error) {
fail(res, error);
}
};
module.exports = { getEventFeedback, saveEventFeedback, deleteEventFeedback, sendNow, getPublicForm, submitPublicForm, getSummary };
@@ -0,0 +1,108 @@
const jwt = require('jsonwebtoken');
const prisma = require('../config/db');
const { safeErrorMessage } = require('../utils/errorUtils');
const { logAdminAction } = require('../utils/adminAudit');
const { getClientIp } = require('../utils/requestUtils');
const { MAX_MINUTES, impersonationBlocker, logRequest } = require('../utils/impersonation');
const fail = (res, error) => res.status(error.statusCode || (res.statusCode === 200 ? 400 : res.statusCode)).json({ message: safeErrorMessage(error) });
const err = (message, statusCode = 400) => Object.assign(new Error(message), { statusCode });
// @desc Start viewing the site as a user (read-only, 30 minutes)
// @route POST /api/admin/impersonate/:userId { reason }
// @access Private/Admin (with 2FA)
const startImpersonation = async (req, res) => {
try {
if (req.readOnly) throw err('End the current impersonation first.', 403);
const reason = String(req.body?.reason || '').trim();
if (reason.length < 5) throw err('Give a reason (at least a few words) — it is recorded in the audit log.');
const target = await prisma.user.findUnique({ where: { id: req.params.userId }, select: { id: true, name: true, email: true, role: true, isActive: true, tokenVersion: true } });
const blocker = impersonationBlocker(req.user, target);
if (blocker) throw err(blocker, target ? 403 : 404);
// One live session per admin.
await prisma.impersonationSession.updateMany({ where: { adminId: req.user.id, endedAt: null }, data: { endedAt: new Date(), endedReason: 'replaced' } });
const expiresAt = new Date(Date.now() + MAX_MINUTES * 60 * 1000);
const session = await prisma.impersonationSession.create({ data: { adminId: req.user.id, targetUserId: target.id, reason: reason.slice(0, 500), expiresAt } });
const token = jwt.sign({ id: target.id, role: target.role, tokenVersion: target.tokenVersion, imp: session.id }, process.env.JWT_SECRET, { expiresIn: `${MAX_MINUTES}m` });
logAdminAction({
actorId: req.user.id, actorRole: req.user.role, action: 'impersonation_started', targetType: 'User', targetId: target.id,
metadata: { sessionId: session.id, reason: session.reason, targetName: target.name, expiresAt }, ip: getClientIp(req),
});
// Optional: tell the user (Site Settings → Security).
const { getSettingSync } = require('../utils/settingsCache');
if (getSettingSync('notify_user_on_impersonation', 'false') === 'true' && target.email && !target.email.endsWith('@guest.local')) {
const { sendMail } = require('../utils/email');
const orgName = getSettingSync('org_name', process.env.ORG_NAME || 'Cross Code');
sendMail({
to: target.email,
subject: `An administrator viewed your account — ${orgName}`,
text: `Hi ${target.name || 'there'},\n\nAn administrator at ${orgName} viewed your account (read-only) to help with support.\nReason given: ${session.reason}\n\nNothing was changed. If you have questions, reply to this email.\n\n${orgName}`,
}).catch(() => {});
}
res.status(201).json({ token, session: { id: session.id, expiresAt, target: { id: target.id, name: target.name, email: target.email, role: target.role } } });
} catch (error) {
fail(res, error);
}
};
// @desc End impersonation (called with the impersonation token)
// @route POST /api/admin/impersonate/end
// @access Impersonation token
const endImpersonation = async (req, res) => {
try {
const sessionId = req.impersonation?.id;
if (!sessionId) throw err('Not impersonating.', 400);
const s = await prisma.impersonationSession.update({ where: { id: sessionId }, data: { endedAt: new Date(), endedReason: 'ended' } });
const count = await prisma.impersonationRequestLog.count({ where: { sessionId } });
logAdminAction({
actorId: s.adminId, actorRole: 'admin', action: 'impersonation_ended', targetType: 'User', targetId: s.targetUserId,
metadata: { sessionId, minutes: Math.round((Date.now() - s.startedAt.getTime()) / 60000), requests: count }, ip: getClientIp(req),
});
res.json({ message: 'Impersonation ended.' });
} catch (error) {
fail(res, error);
}
};
// @desc Record a page the admin viewed while impersonating
// @route POST /api/admin/impersonate/log { path }
// @access Impersonation token
const logPageView = async (req, res) => {
if (req.impersonation?.id) logRequest(req.impersonation.id, 'PAGE', String(req.body?.path || '').slice(0, 500));
res.status(204).end();
};
// @desc The current impersonation session (for the banner)
// @route GET /api/admin/impersonate/current
// @access Impersonation token
const currentImpersonation = async (req, res) => {
const s = req.impersonation;
if (!s) return res.json(null);
res.json({ id: s.id, expiresAt: s.expiresAt, target: { id: req.user.id, name: req.user.name, email: req.user.email } });
};
// @desc Impersonation sessions with their request trail (audit log page)
// @route GET /api/admin/impersonation-sessions
// @access Private/Admin
const listSessions = async (req, res) => {
try {
const sessions = await prisma.impersonationSession.findMany({
orderBy: { startedAt: 'desc' },
take: Math.min(parseInt(req.query.limit, 10) || 50, 200),
include: {
admin: { select: { name: true, email: true } },
target: { select: { name: true, email: true } },
requests: { orderBy: { createdAt: 'asc' }, take: 500, select: { method: true, path: true, blocked: true, createdAt: true } },
},
});
res.json(sessions);
} catch (error) {
fail(res, error);
}
};
module.exports = { startImpersonation, endImpersonation, logPageView, currentImpersonation, listSessions };
@@ -110,7 +110,7 @@ async function checkVariantStock(variant, requestedQty, excludeEntryId = null) {
// @access Private
const createRegistration = async (req, res) => {
try {
const { eventId, options, guestName, guestEmail, guestPhone, couponCode, attendees, ticketDelivery, waitlistToken } = req.body;
const { eventId, options, guestName, guestEmail, guestPhone, couponCode, attendees, ticketDelivery, waitlistToken, analyticsSessionId } = req.body;
// Check if event exists
const canIncludeVariants = !!(prisma && prisma.optionVariant && typeof prisma.optionVariant.findMany === 'function');
@@ -503,6 +503,12 @@ const createRegistration = async (req, res) => {
}
})();
// Funnel: this registration came from a tracked (cookie-consented) visit.
if (analyticsSessionId) {
const { recordSubmit } = require('../utils/analytics');
recordSubmit(registration, analyticsSessionId).catch(e => console.warn('[analytics] submit not recorded:', e?.message || e));
}
res.status(201).json({ ...attachComputedTotals(registration), ...(couponError ? { couponError } : {}) });
} catch (error) {
res.status(400).json({ message: error.message });
@@ -580,7 +586,7 @@ const getUserRegistrations = async (req, res) => {
where: { ...whereClause, status: { in: ['pending', 'partial_paid'] } },
select: { id: true }
});
if (staleCandidates.length > 0) {
if (staleCandidates.length > 0 && !req.readOnly) {
await Promise.all(staleCandidates.map(r => refreshPricingForRegistration(r.id).catch(() => {})));
}
@@ -657,7 +663,7 @@ const getRegistrationById = async (req, res) => {
// See getUserRegistrations — keep an unpaid/partially-paid registration's price current
// whenever it's viewed, not just at payment time.
if (registration.status === 'pending' || registration.status === 'partial_paid') {
if ((registration.status === 'pending' || registration.status === 'partial_paid') && !req.readOnly) {
await refreshPricingForRegistration(registration.id).catch(() => {});
registration = await prisma.registration.findUnique({ where: { id: req.params.id }, include });
}
@@ -870,7 +876,7 @@ const getRegistrationsByEvent = async (req, res) => {
where: { eventId: req.params.eventId, status: { in: ['pending', 'partial_paid'] } },
select: { id: true }
});
if (staleCandidates.length > 0) {
if (staleCandidates.length > 0 && !req.readOnly) {
await Promise.all(staleCandidates.map(r => refreshPricingForRegistration(r.id).catch(() => {})));
}
@@ -2220,7 +2226,7 @@ const updateAttendees = async (req, res) => {
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,
dateOfBirth: c.dateOfBirth, gender: c.gender, parentName: c.parentName, parentEmail: c.parentEmail, parentPhone: c.parentPhone,
...(contactChanged ? { userId: match?.user?.id || null } : {}),
},
});
@@ -2248,7 +2254,7 @@ const updateAttendees = async (req, res) => {
where: { id: registration.id },
select: { id: true, ticketDelivery: true, attendees: ATTENDEES_INCLUDE },
});
res.json(updated);
res.json({ ...updated, attendees: updated.attendees.map(({ userId, ...a }) => a) });
} catch (error) {
res.status(error.statusCode || (res.statusCode === 200 ? 400 : res.statusCode)).json({ message: error.message });
}
@@ -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 };
@@ -0,0 +1,112 @@
const axios = require('axios');
const prisma = require('../config/db');
const { safeErrorMessage } = require('../utils/errorUtils');
const { sponsorshipError, spotPrice, sponsorshipCharge, poolSummary } = require('../utils/sponsorship');
const fail = (res, error) => res.status(error.statusCode || error.response?.status || 400).json({ message: safeErrorMessage(error) });
async function sponsorableEvent(eventId) {
const event = await prisma.event.findUnique({ where: { id: String(eventId || '') }, include: { eventOptions: { select: { isMainTicket: true, price: true } } } });
if (!event || !event.isActive) throw sponsorshipError('Event not found', 404);
if (!event.allowSponsorships) throw sponsorshipError('This event isn’t taking sponsorships.');
if (event.cashupStatus === 'closed' || new Date(event.endDate) < new Date()) throw sponsorshipError('This event has ended and is no longer taking sponsorships.');
return event;
}
async function poolFor(event) {
const sponsorships = await prisma.payment.findMany({ where: { eventId: event.id, isDonation: true, isSponsorship: true, amount: { gt: 0 } }, select: { id: true, amount: true } });
const legs = sponsorships.length
? await prisma.payment.findMany({ where: { originalPaymentId: { in: sponsorships.map(s => s.id) }, isDonation: false }, select: { originalPaymentId: true, amount: true } })
: [];
return poolSummary(sponsorships, legs, spotPrice(event));
}
// @desc Public sponsorship progress for an event (never names who was helped)
// @route GET /api/sponsorships/event/:eventId
// @access Public
const getEventSponsorship = async (req, res) => {
try {
const event = await prisma.event.findUnique({ where: { id: req.params.eventId }, include: { eventOptions: { select: { isMainTicket: true, price: true } } } });
if (!event || !event.isActive || !event.allowSponsorships) return res.json({ enabled: false });
const pool = await poolFor(event);
const open = event.cashupStatus !== 'closed' && new Date(event.endDate) >= new Date();
res.json({
enabled: true,
open,
spotPrice: spotPrice(event),
showProgress: event.showSponsorshipProgress,
goalSpots: event.sponsorshipGoalSpots,
spotsFunded: event.showSponsorshipProgress ? pool.spotsFunded : null,
sponsors: event.showSponsorshipProgress ? pool.sponsors : null,
});
} catch (error) {
fail(res, error);
}
};
// @desc Start a Yoco checkout to sponsor spots (or a custom amount) at an event
// @route POST /api/sponsorships/checkout { eventId, spots? | amount?, anonymous?, sponsorName?, message?, name?, email?, phone? }
// @access Public (guests give a name and email for the receipt)
const createSponsorCheckout = async (req, res) => {
try {
const { eventId, spots, amount, anonymous, sponsorName, message, name, email, phone } = req.body || {};
const event = await sponsorableEvent(eventId);
const charge = sponsorshipCharge({ spots, amount }, spotPrice(event));
let payer = req.user || null;
if (!payer) {
const { hasValidEmail, findOrCreateAccount } = require('../utils/accountMatch');
const cleanName = String(name || '').trim().slice(0, 100);
if (!cleanName) throw sponsorshipError('Enter your name.');
if (!hasValidEmail(email)) throw sponsorshipError('Enter your email address so we can send your receipt.');
// An existing account is reused; otherwise a quiet inactive one holds the receipt —
// no activation link is sent, since a sponsor doesn't need to log in.
payer = (await findOrCreateAccount({ name: cleanName, email: String(email).trim(), phone })).user;
}
const isAnonymous = anonymous === true;
const displayName = isAnonymous ? null : (String(sponsorName || '').trim() || payer.name || '').slice(0, 100) || null;
const note = String(message || '').trim().slice(0, 300) || null;
const base = (process.env.FRONTEND_URL || process.env.APP_BASE_URL || 'http://localhost:3001').replace(/\/$/, '');
const back = `${base}/events/${encodeURIComponent(event.id)}`;
const amountInCents = Math.round(charge.amount * 100);
const label = charge.spots ? `Sponsor ${charge.spots} spot${charge.spots > 1 ? 's' : ''} at ${event.title}` : `Sponsorship for ${event.title}`;
const checkoutData = {
amount: amountInCents,
currency: 'ZAR',
metadata: {
type: 'sponsorship', isDonation: true, eventId: event.id, eventTitle: event.title, userId: payer.id,
sponsorAnonymous: isAnonymous ? 'true' : 'false',
...(displayName ? { sponsorName: displayName } : {}),
...(note ? { sponsorMessage: note } : {}),
...(charge.spots ? { spots: String(charge.spots) } : {}),
},
successUrl: `${back}?sponsored=1`,
cancelUrl: back,
failureUrl: `${back}?sponsored=failed`,
lineItems: [{ displayName: label, quantity: 1, pricingDetails: { price: amountInCents } }],
};
const response = await axios.post('https://payments.yoco.com/api/checkouts', checkoutData, {
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${process.env.YOCO_SECRET_KEY}`, 'Idempotency-Key': `spon_${event.id}_${payer.id}_${amountInCents}_${Date.now()}` },
});
res.json({ redirectUrl: response.data.redirectUrl, checkoutId: response.data.id, amount: charge.amount });
} catch (error) {
if (error.response) console.error('Sponsor checkout error:', error.response.data || error.message);
fail(res, error);
}
};
// @desc The sponsorship pool for an event (supervisors)
// @route GET /api/sponsorships/event/:eventId/pool
// @access Private/Supervisor
const getPool = async (req, res) => {
try {
const event = await prisma.event.findUnique({ where: { id: req.params.eventId }, include: { eventOptions: { select: { isMainTicket: true, price: true } } } });
if (!event) throw sponsorshipError('Event not found', 404);
res.json({ eventId: event.id, spotPrice: spotPrice(event), goalSpots: event.sponsorshipGoalSpots, ...(await poolFor(event)) });
} catch (error) {
fail(res, error);
}
};
module.exports = { getEventSponsorship, createSponsorCheckout, getPool, poolFor };
@@ -267,7 +267,21 @@ const getOverviewStats = async (req, res) => {
ticketsSold: ticketSumById[r.eventId] || 0,
}));
// Funnel conversion (event page views → paid) across live events, last 30 days.
let funnel = null;
try {
const live = await prisma.event.findMany({ where: { isActive: true, endDate: { gte: now } }, select: { id: true } });
if (live.length) {
const { funnelFor } = require('../utils/analytics');
const f = await funnelFor(live.map(e => e.id), { from: new Date(now.getTime() - 30 * 864e5).toISOString() });
if (f.totals.view > 0) funnel = { views: f.totals.view, paid: f.totals.paid, conversion: f.conversion.paid };
}
} catch (e) {
console.warn('[stats] funnel tile failed:', e?.message || e);
}
res.json({
funnel,
activeEvents,
registrations: { thisMonth: thisRegistrations, lastMonth: lastRegistrations, pctChange: pctChange(thisRegistrations, lastRegistrations) },
ticketsSold: { thisMonth: thisTicketsSold, lastMonth: lastTicketsSold, pctChange: pctChange(thisTicketsSold, lastTicketsSold) },
+186 -4
View File
@@ -95,9 +95,15 @@ const getUserTickets = async (req, res) => {
registration: true
}
},
attendee: { select: { id: true, name: true } },
attendee: {
select: {
id: true, name: true,
allocations: { where: { set: { showOnTicket: true } }, select: { group: { select: { name: true } }, set: { select: { name: true, order: true } } } },
},
},
user: { select: { id: true, name: true } },
event: true,
transferRequests: { where: { status: 'pending' }, select: { id: true, toName: true, expiresAt: true } },
usages: {
include: {
scannedBy: {
@@ -287,6 +293,9 @@ const getScanPreview = async (req, res) => {
`;
if (!rows || rows.length === 0) {
const { findTransferByOldCode, transferredMessage } = require('../utils/ticketTransfer');
const transfer = await findTransferByOldCode(req.params.qrCode);
if (transfer) return res.status(410).json({ message: transferredMessage(transfer), code: 'TICKET_TRANSFERRED' });
res.status(404);
throw new Error('Ticket not found');
}
@@ -351,6 +360,9 @@ const scanTicket = async (req, res) => {
`;
if (!rows || rows.length === 0) {
const { findTransferByOldCode, transferredMessage } = require('../utils/ticketTransfer');
const transfer = await findTransferByOldCode(req.params.qrCode);
if (transfer) return res.status(410).json({ message: transferredMessage(transfer), code: 'TICKET_TRANSFERRED' });
res.status(404);
throw new Error('Ticket not found');
}
@@ -372,6 +384,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;
@@ -461,6 +494,7 @@ const getTicketsByEvent = async (req, res) => {
registrationOption: { include: { eventOption: true, variant: true, registration: { select: { id: true } } } },
user: { select: { id: true, name: true, email: true, phoneNumber: true } },
attendee: { select: { id: true, name: true, email: true, phone: true } },
event: { select: { id: true, title: true, startDate: true, requiresParentalConsent: true, allowTicketTransfer: true, collectGender: true } },
usages: { include: { scannedBy: { select: { id: true, name: true, email: true } } } }
},
orderBy: { createdAt: 'desc' },
@@ -670,6 +704,147 @@ const sendTicketsTo = async (req, res) => {
}
};
// @desc Offer a ticket to someone else: sends them a link to accept (nothing changes until they do)
// @route POST /api/tickets/:id/transfer { name, email?, phone?, dateOfBirth?, gender?, parentName?, parentEmail?, parentPhone? }
// @access Private (payer or holder; supervisors always)
const transferTicketHandler = async (req, res) => {
try {
const { requestTransfer } = require('../utils/ticketTransfer');
const { name, email, phone, dateOfBirth, gender, parentName, parentEmail, parentPhone } = req.body || {};
const { request, sent } = await requestTransfer({ ticketId: req.params.id, actor: req.user, name, email, phone, dateOfBirth, gender, parentName, parentEmail, parentPhone });
res.status(201).json({
message: sent.length
? `We've sent ${request.toName} a link by ${sent.join(' and ')} to accept the ticket. It moves to them once they accept — until then it's still yours.`
: `The transfer is waiting for ${request.toName} to accept, but the link couldn't be sent. Check the email or phone number.`,
requestId: request.id,
expiresAt: request.expiresAt,
});
} catch (error) {
res.status(error.statusCode || (res.statusCode === 200 ? 400 : res.statusCode)).json({ message: safeErrorMessage(error) });
}
};
// @desc Withdraw a transfer that hasn't been accepted yet
// @route DELETE /api/tickets/:id/transfer
// @access Private (payer or holder; supervisors)
const cancelTransferHandler = async (req, res) => {
try {
const { cancelTransfer } = require('../utils/ticketTransfer');
await cancelTransfer(req.params.id, req.user);
res.json({ message: 'Transfer cancelled — the ticket is still yours.' });
} catch (error) {
res.status(error.statusCode || 400).json({ message: safeErrorMessage(error) });
}
};
// @desc What's on offer behind a transfer link
// @route GET /api/tickets/transfer-offer/:token
// @access Public (link holder)
const getTransferOffer = async (req, res) => {
try {
const { findRequestByToken } = require('../utils/ticketTransfer');
const r = await findRequestByToken(req.params.token);
if (!r) return res.status(404).json({ message: 'This transfer link isn’t valid.' });
const t = r.ticket;
res.json({
status: r.status, toName: r.toName, expiresAt: r.expiresAt,
fromName: (t.attendee?.name || t.user?.name || r.requestedBy.name || '').split(' ')[0],
event: t.event,
option: t.registrationOption.variant ? `${t.registrationOption.eventOption.name} — ${t.registrationOption.variant.name}` : t.registrationOption.eventOption.name,
});
} catch (error) {
res.status(400).json({ message: safeErrorMessage(error) });
}
};
// @route POST /api/tickets/transfer-offer/:token/decline
const declineTransferOffer = async (req, res) => {
try {
const { declineTransfer } = require('../utils/ticketTransfer');
await declineTransfer(req.params.token);
res.json({ message: 'You declined the ticket. Nothing else will happen.' });
} catch (error) {
res.status(error.statusCode || 400).json({ message: safeErrorMessage(error) });
}
};
// @desc Accept a transferred ticket — the transfer happens now
// @route POST /api/tickets/transfer-offer/:token/accept
// @access Public (link holder)
const acceptTransferOffer = async (req, res) => {
try {
const { acceptTransfer } = require('../utils/ticketTransfer');
const r = await acceptTransfer(req.params.token);
const actor = r.request.requestedBy;
const { logAdminAction } = require('../utils/adminAudit');
const { getClientIp } = require('../utils/requestUtils');
logAdminAction({
actorId: actor.id, actorRole: actor.role, action: 'ticket_transferred', targetType: 'Ticket', targetId: r.target.id,
metadata: { eventId: r.event.id, registrationId: r.registrationId, from: r.fromHolderName, to: r.attendee.name, toUserId: r.toUser.id, split: !r.transfer.oldQrCode, acceptedVia: 'link' },
ip: getClientIp(req),
});
// Tell the new holder: activation link for a new account, their ticket, and
// anything they need to do themselves (consent, attendee form).
if (r.accountCreated) {
const { sendActivationLink } = require('./userController');
sendActivationLink(r.toUser);
}
const a = r.attendee;
const hasEmail = !!(a.email && a.email.includes('@'));
const recipient = { id: r.toUser.id, name: a.name, email: hasEmail ? a.email : null, phoneNumber: a.phone || null, notificationPreference: hasEmail && a.phone ? 'both' : hasEmail ? 'email' : 'whatsapp' };
const silent = { status: () => silent, json: () => silent };
emailTicketsInternal({ user: { id: r.target.userId }, body: { ticketIds: [r.target.id] }, _noSplit: true }, silent, recipient)
.catch(e => console.error('[transfer] ticket delivery failed:', e?.message || e));
if (r.needsConsent) {
const { createConsentRequest } = require('../utils/consent');
createConsentRequest(a, r.event).catch(e => console.error('[transfer] consent request failed:', e?.message || e));
}
const form = await prisma.eventForm.findUnique({ where: { eventId: r.event.id }, include: { fields: { select: { id: true } } } });
if (form && form.fields.length > 0) {
const { sendAttendeeFormLinkMessage } = require('../utils/notifications');
const full = await prisma.attendee.findUnique({ where: { id: a.id }, include: { registration: { include: { event: true, user: { select: { id: true, name: true } } } } } });
sendAttendeeFormLinkMessage(full).catch(() => {});
}
res.json({
message: `The ticket is yours! We've sent it to you${r.needsConsent ? ', and asked your parent or guardian for consent' : ''}.${r.accountCreated ? ' We have also sent you a link to activate your account, where you can see your tickets.' : ''}`,
ticketId: r.target.id,
});
} catch (error) {
res.status(error.statusCode || (res.statusCode === 200 ? 400 : res.statusCode)).json({ message: safeErrorMessage(error) });
}
};
// @desc Transfer history of a ticket
// @route GET /api/tickets/:id/transfers
// @access Private (payer or holder; staff)
const getTicketTransfers = async (req, res) => {
try {
const ticket = await prisma.ticket.findUnique({ where: { id: req.params.id }, select: { userId: true, holderUserId: true } });
if (!ticket) { res.status(404); throw new Error('Ticket not found'); }
const staffRole = ['admin', 'supervisor', 'staff'].includes(req.user.role);
if (!staffRole && ticket.userId !== req.user.id && ticket.holderUserId !== req.user.id) { res.status(403); throw new Error('Not authorized'); }
const transfers = await prisma.ticketTransfer.findMany({
where: { ticketId: req.params.id },
select: { id: true, fromHolderName: true, toName: true, createdAt: true, byUser: { select: { name: true } } },
orderBy: { createdAt: 'desc' },
});
res.json(transfers);
} catch (error) {
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
}
};
/** "Room: Cabin 3 · Bus: Bus 2" for the sets shown on tickets, or ''. */
function allocationLabel(attendee) {
return [...(attendee?.allocations || [])]
.sort((a, b) => (a.set?.order || 0) - (b.set?.order || 0))
.map(a => `${a.set.name}: ${a.group.name}`)
.join(' · ');
}
// Internal helper used by both emailTickets and sendTicketsTo
async function emailTicketsInternal(req, res, userOverride) {
const { ticketIds, registrationId } = req.body;
@@ -692,7 +867,7 @@ async function emailTicketsInternal(req, res, userOverride) {
const ticketInclude = {
event: true,
registrationOption: { include: { eventOption: true, variant: true, registration: { select: { ticketDelivery: true } } } },
attendee: true,
attendee: { include: { allocations: { where: { set: { showOnTicket: true } }, include: { group: { select: { name: true } }, set: { select: { name: true, order: true } } } } } },
user: { select: { name: true } },
usages: true,
};
@@ -793,7 +968,8 @@ 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(ticket.attendee ? `Name: ${ticket.attendee.name}` : `Qty: ${ticket.quantity || 1}`, x + 10, y + 45, { width: ticketWidth - 20 });
const groupLabel = allocationLabel(ticket.attendee);
doc.text(ticket.attendee ? `Name: ${ticket.attendee.name}${groupLabel ? ` · ${groupLabel}` : ''}` : `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: ${ticket.user?.name || user.name}`, x + 10, y + 75, { width: ticketWidth - 20 });
@@ -867,5 +1043,11 @@ module.exports = {
emailTickets,
sendTicketsTo,
getRecentScans,
getScanStats
getScanStats,
transferTicketHandler,
cancelTransferHandler,
getTransferOffer,
acceptTransferOffer,
declineTransferOffer,
getTicketTransfers
};
+7 -1
View File
@@ -325,7 +325,13 @@ const handlePaymentSucceeded = async (webhookData) => {
userId: resolvedUserId,
recordedById: resolvedUserId, // self-service webhook payment — payer is the recorder
eventId: registration?.eventId || metadata?.eventId || null,
isDonation: !registration?.id
isDonation: !registration?.id,
...(!registration?.id && metadata?.type === 'sponsorship' ? {
isSponsorship: true,
sponsorAnonymous: String(metadata.sponsorAnonymous) === 'true',
sponsorName: String(metadata.sponsorAnonymous) === 'true' ? null : (metadata.sponsorName ? String(metadata.sponsorName).slice(0, 100) : null),
sponsorMessage: metadata.sponsorMessage ? String(metadata.sponsorMessage).slice(0, 300) : null,
} : {})
},
include: {
user: {
+168
View File
@@ -103,6 +103,10 @@ const globalLimiter = rateLimit({
});
app.use(globalLimiter);
// Impersonation is strictly read-only: refuse every write made with an impersonation
// token before it can reach any route (see utils/impersonation.js).
app.use(require('./utils/impersonation').readOnlyGuard);
// Raw body parsing middleware for webhooks
app.use((req, res, next) => {
if (req.path.startsWith('/api/webhooks')) {
@@ -178,6 +182,11 @@ 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/allocations', require('./routes/allocationRoutes'));
app.use('/api/sponsorships', require('./routes/sponsorshipRoutes'));
app.use('/api/feedback', require('./routes/feedbackRoutes'));
app.use('/api/analytics', require('./routes/analyticsRoutes'));
app.use('/api/backups', backupRoutes);
// Pre-warm the settings cache so synchronous helpers have DB values from startup
@@ -383,6 +392,7 @@ app.get('/docs', async (req, res) => {
// ── Endpoint data ────────────────────────────────────────────────────────
const GROUPS = [
{ title: 'Users', base: '/api/users', endpoints: [
{ method:'GET', path:'/api/users/activity', auth:'Bearer', desc:'Recent account activity (logins, password changes) for the current user' },
{ method:'POST', path:'/api/users', auth:'public', desc:'Register a new account',
request:{ body:{ name:'Jane Doe', email:'jane@example.com', password:'secret123', phoneNumber:'+27821234567' }},
responses:[
@@ -506,6 +516,14 @@ app.get('/docs', async (req, res) => {
]},
{ title: 'Events', base: '/api/events', endpoints: [
{ method:'GET', path:'/api/events/admin/all', auth:'admin', desc:'All events, including inactive ones' },
{ method:'GET', path:'/api/events/:id/ics', auth:'Public (optional login)', desc:'Download an .ics calendar file for an event' },
{ method:'GET', path:'/api/events/:id/attachments', auth:'Public', desc:'List the downloadable attachments of an event' },
{ method:'POST', path:'/api/events/:id/attachments', auth:'supervisor+', desc:'Upload an attachment (multipart, field "file")' },
{ method:'DELETE', path:'/api/events/:eventId/attachments/:attachmentId', auth:'supervisor+', desc:'Delete an attachment' },
{ method:'GET', path:'/api/events/attachments/status', auth:'admin', desc:'Diagnostics for the attachments pipeline' },
{ method:'POST', path:'/api/events/attachments/sync', auth:'admin', desc:'Re-sync attachment records with the files on disk' },
{ method:'POST', path:'/api/events/options/:id/variants', auth:'supervisor+', desc:'Add a variant (e.g. a size) to an event option' },
{ method:'GET', path:'/api/events', auth:'public', desc:'List active, public, upcoming events. Each event includes isSoldOut:boolean — true when every option with a stockLimit > 0 is fully booked.',
queryParams:{ limit:'Max results (default 20)' },
responses:[
@@ -583,6 +601,12 @@ app.get('/docs', async (req, res) => {
]},
{ title: 'Registrations', base: '/api/registrations', endpoints: [
{ method:'PUT', path:'/api/registrations/:id/options', auth:'Bearer (owner or staff)', desc:'Change the items on a registration (add/remove), within stock and paid-ticket limits' },
{ method:'POST', path:'/api/registrations/:id/coupon', auth:'Bearer (owner or staff)', desc:'Apply a coupon code to an existing registration', request:{ body:{ code:'STAFF50' }}},
{ method:'DELETE', path:'/api/registrations/:id/coupon', auth:'staff+', desc:'Remove the coupon from a registration (frees the use)' },
{ method:'GET', path:'/api/registrations/:id/forms/draft', auth:'Owner or staff', desc:'Saved draft of the attendee forms' },
{ method:'PUT', path:'/api/registrations/:id/forms/draft', auth:'Owner or staff', desc:'Save a draft of the attendee forms (not submitted)' },
{ method:'PUT', path:'/api/registrations/:id/forms/responses', auth:'supervisor+', desc:'Replace (edit) the submitted form responses of a registration' },
{ method:'POST', path:'/api/registrations', auth:'optional', desc:'Create a registration. Auth optional — guests are allowed when event does not require auth.',
request:{ body:{ eventId:'ev-uuid-...', options:[{ eventOptionId:'opt-uuid-...', quantity:2 }] }},
responses:[
@@ -634,6 +658,75 @@ app.get('/docs', async (req, res) => {
]},
]},
{ title: 'Impersonation', base: '/api/admin', endpoints: [
{ method:'GET', path:'/api/admin/audit-log', auth:'admin', desc:'Paginated admin/supervisor audit trail (?page, limit, actorId, action, from, to)' },
{ method:'POST', path:'/api/admin/impersonate/:userId', auth:'admin (2FA)', desc:'View the site as a user — read-only token, 30 minutes, audited', request:{ body:{ reason:'Ticket not showing in My Events' }}, responses:[{ status:201, desc:'Started', body:{ token:'jwt…', session:{ id:'sess-uuid', expiresAt:'2025-07-10T08:45:00.000Z' }}}, { status:403, desc:'Not allowed', body:{ message:"Admins can't be impersonated." }}]},
{ method:'POST', path:'/api/admin/impersonate/end', auth:'impersonation token', desc:'End the impersonation session' },
{ method:'POST', path:'/api/admin/impersonate/log', auth:'impersonation token', desc:'Record a page viewed during the session' },
{ method:'GET', path:'/api/admin/impersonate/current', auth:'impersonation token', desc:'The live impersonation session' },
{ method:'GET', path:'/api/admin/impersonation-sessions', auth:'admin', desc:'Impersonation sessions with their request trail' },
]},
{ title: 'Registration funnel', base: '/api/analytics', endpoints: [
{ method:'POST', path:'/api/analytics/track', auth:'Public (rate-limited)', desc:'Record a funnel step from a cookie-consented browser (always 204)',
request:{ body:{ sessionId:'random-uuid', eventId:'ev-uuid-...', stage:'view', source:'camp-2025' }}},
{ method:'GET', path:'/api/analytics/funnel', auth:'supervisor+', desc:'Funnel for ?eventIds=a,b&from=&to= — totals, conversion, daily trend, by source/device/option' },
]},
{ title: 'Post-event feedback', base: '/api/feedback', endpoints: [
{ method:'GET', path:'/api/feedback/event/:eventId', auth:'supervisor+', desc:'An event\'s feedback form, with invite and response counts' },
{ method:'PUT', path:'/api/feedback/event/:eventId', auth:'supervisor+', desc:'Create/update the feedback form (questions lock once answered)',
request:{ body:{ sendAt:'2025-07-13T09:00:00.000Z', audience:'checked_in', anonymous:false, intro:'Thanks for coming!', fields:[{ type:'rating', label:'Overall?', isRequired:true }, { type:'multiple_choice', label:'Best part', options:['Worship','Talks'] }] }}},
{ method:'DELETE', path:'/api/feedback/event/:eventId', auth:'supervisor+', desc:'Remove the feedback form' },
{ method:'POST', path:'/api/feedback/event/:eventId/send-now', auth:'supervisor+', desc:'Send the feedback form now' },
{ method:'GET', path:'/api/feedback/public/:token', auth:'Link holder', desc:'The form behind a personal feedback link' },
{ method:'POST', path:'/api/feedback/public/:token', auth:'Link holder', desc:'Submit feedback once', request:{ body:{ answers:{ 'field-uuid':'5' } }}},
{ method:'GET', path:'/api/feedback/summary', auth:'supervisor+', desc:'Feedback summary for ?eventIds=a,b (response rate, averages, breakdowns, comments)' },
]},
{ title: 'Sponsor a spot', base: '/api/sponsorships', endpoints: [
{ method:'GET', path:'/api/sponsorships/event/:eventId', auth:'Public', desc:'Sponsorship progress for an event (spots funded, goal) — never who was helped',
responses:[{ status:200, desc:'Progress', body:{ enabled:true, open:true, spotPrice:450, showProgress:true, goalSpots:20, spotsFunded:12, sponsors:9 }}]},
{ method:'POST', path:'/api/sponsorships/checkout', auth:'Public (optional login)', desc:'Start a Yoco checkout to sponsor spots or a custom amount; the money lands in the event sponsorship pool',
request:{ body:{ eventId:'ev-uuid-...', spots:2, anonymous:false, sponsorName:'The Smith family', message:'Have a great camp!', name:'Sam Smith', email:'sam@example.com' }},
responses:[{ status:200, desc:'Checkout created', body:{ redirectUrl:'https://pay.yoco.com/...', amount:900 }}]},
{ method:'GET', path:'/api/sponsorships/event/:eventId/pool', auth:'supervisor+', desc:'Sponsorship pool totals: received, assigned, remaining' },
]},
{ title: 'Rooms & groups', base: '/api/allocations', endpoints: [
{ method:'GET', path:'/api/allocations/event/:eventId', auth:'supervisor+', desc:'Allocation board: sets with groups and assignments, named attendees, possible leaders' },
{ method:'POST', path:'/api/allocations/sets', auth:'supervisor+', desc:'Create an allocation set', request:{ body:{ eventId:'ev-uuid-...', name:'Rooms', showOnTicket:true }}},
{ method:'PUT', path:'/api/allocations/sets/:id', auth:'supervisor+', desc:'Rename a set or show it on tickets' },
{ method:'DELETE', path:'/api/allocations/sets/:id', auth:'supervisor+', desc:'Delete a set and its groups' },
{ method:'POST', path:'/api/allocations/sets/:id/groups', auth:'supervisor+', desc:'Add a group', request:{ body:{ name:'Cabin 1', capacity:8, gender:'female', leaderUserId:'user-uuid-...' }}},
{ method:'PUT', path:'/api/allocations/groups/:id', auth:'supervisor+', desc:'Edit a group' },
{ method:'DELETE', path:'/api/allocations/groups/:id', auth:'supervisor+', desc:'Delete a group (its people become unassigned)' },
{ method:'PUT', path:'/api/allocations/sets/:id/assignments', auth:'supervisor+', desc:'Place (or with groupId null, unplace) an attendee; enforces capacity and gender', request:{ body:{ attendeeId:'att-uuid-...', groupId:'grp-uuid-...' }}},
{ method:'DELETE', path:'/api/allocations/sets/:id/assignments', auth:'supervisor+', desc:'Clear every assignment in a set' },
{ method:'POST', path:'/api/allocations/sets/:id/auto', auth:'supervisor+', desc:'Auto-allocate everyone not yet placed', request:{ body:{ mode:'together' }}, responses:[{ status:200, desc:'Done', body:{ placed:12, unplaced:1 }}]},
{ method:'GET', path:'/api/allocations/sets/:id/lists', auth:'supervisor+', desc:'Per-group lists for printing (?medical=true adds medical details; audited)' },
{ method:'GET', path:'/api/allocations/my-groups', auth:'staff+', desc:'Groups the signed-in leader leads, with their people' },
]},
{ title: 'Ticket transfer', base: '/api/tickets', endpoints: [
{ method:'DELETE', path:'/api/tickets/:id/transfer', auth:'payer, holder or supervisor+', desc:'Withdraw a transfer offer that has not been accepted yet' },
{ method:'GET', path:'/api/tickets/transfer-offer/:token', auth:'Link holder', desc:'The ticket offered behind a transfer link (status, event, ticket type, sender first name)' },
{ method:'POST', path:'/api/tickets/transfer-offer/:token/accept', auth:'Link holder', desc:'Accept the ticket — the transfer happens now (new QR code, ticket sent to the new holder)' },
{ method:'POST', path:'/api/tickets/transfer-offer/:token/decline', auth:'Link holder', desc:'Decline the ticket offer' },
{ method:'POST', path:'/api/tickets/:id/transfer', auth:'payer, holder or supervisor+', desc:'Give a ticket to someone else before the event (new QR code; the old one stops working; billing stays with the payer)',
request:{ body:{ name:'Nia New', email:'nia@example.com', phone:'0821234567' }},
responses:[{ status:200, desc:'Transferred', body:{ message:'Ticket transferred to Nia New…', ticketId:'ticket-uuid-...' }}, { status:400, desc:'Not allowed', body:{ message:'This ticket has already been used (or partly used), so it can’t be transferred.' }}]},
{ method:'GET', path:'/api/tickets/:id/transfers', auth:'payer, holder or staff+', desc:'Transfer history of a ticket' },
]},
{ title: 'Offline scanning', base: '/api/scan-locks', endpoints: [
{ method:'GET', path:'/api/scan-locks', auth:'staff+', desc:'Active offline locks for an event (?eventId=)' },
{ method:'POST', path:'/api/scan-locks', auth:'staff+', desc:'Lock a section (or the whole event) to this device for offline scanning',
request:{ body:{ eventId:'ev-uuid-...', sectionId:'sec-uuid-...', deviceId:'device-uuid', deviceName:'Chrome on Android' }},
responses:[{ status:201, desc:'Locked', body:{ id:'lock-uuid-...', sectionName:'Main hall' }}, { status:409, desc:'Held elsewhere', body:{ message:'"Main hall" is offline-locked by Ann on Chrome on Android.' }}]},
{ method:'GET', path:'/api/scan-locks/:id/snapshot', auth:'staff+', desc:'Tickets valid in the lock scope, for the device to keep offline (lock holder, ?deviceId=)' },
{ method:'POST', path:'/api/scan-locks/:id/sync', auth:'staff+', desc:'Upload offline scans (idempotent by clientScanId; capped at what is left on each ticket)',
request:{ body:{ deviceId:'device-uuid', scans:[{ clientScanId:'scan-uuid', qrCode:'qr-uuid', qty:1, scannedAt:'2025-07-10T08:15:00.000Z' }] }},
responses:[{ status:200, desc:'Synced', body:{ accepted:1, duplicates:0, conflicts:[] }}]},
{ method:'DELETE', path:'/api/scan-locks/:id', auth:'staff+', desc:'End offline mode (lock holder)' },
{ method:'POST', path:'/api/scan-locks/:id/force-release', auth:'supervisor+', desc:'Release a lost device\'s lock' },
{ method:'GET', path:'/api/scan-locks/reports', auth:'supervisor+', desc:'Offline sync reports for an event' },
{ method:'POST', path:'/api/scan-locks/reports/:id/review', auth:'supervisor+', desc:'Mark a sync report reviewed' },
]},
{ title: 'Waitlist', base: '/api/waitlist', endpoints: [
{ method:'POST', path:'/api/waitlist', auth:'public', desc:'Join the waitlist for a sold-out line (login needed when the event requires it)',
request:{ body:{ eventId:'ev-uuid-...', eventOptionId:'opt-uuid-...', quantity:2 }},
@@ -675,6 +768,10 @@ app.get('/docs', async (req, res) => {
{ 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:'GET', path:'/api/payments/:id', auth:'Bearer', desc:'A payment by id' },
{ method:'GET', path:'/api/payments/registration/:registrationId', auth:'Bearer', desc:'Payments of a registration' },
{ method:'POST', path:'/api/payments/yoco-checkout/send', auth:'supervisor+', desc:'Send a generated Yoco payment link to the registrant by email or WhatsApp', request:{ body:{ registrationId:'reg-uuid-...', redirectUrl:'https://pay.yoco.com/...', channel:'whatsapp' }}},
{ method:'POST', path:'/api/payments/unassign-donation', auth:'supervisor+', desc:'Reverse a donation assignment (removes the leg and reverts the registration status)' },
{ 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' }},
responses:[
@@ -714,6 +811,10 @@ app.get('/docs', async (req, res) => {
]},
{ title: 'Tickets', base: '/api/tickets', endpoints: [
{ method:'GET', path:'/api/tickets', auth:'supervisor+', desc:'All tickets' },
{ method:'GET', path:'/api/tickets/:id', auth:'Bearer (owner or staff)', desc:'A ticket by id' },
{ method:'GET', path:'/api/tickets/qr/:qrCode', auth:'staff+', desc:'A ticket by QR code' },
{ method:'PUT', path:'/api/tickets/email-sent', auth:'admin', desc:'Mark tickets as emailed' },
{ method:'GET', path:'/api/tickets/mytickets', auth:'user+', desc:'Get own tickets',
responses:[{ status:200, desc:'Success', body:[{ id:'tkt-uuid-...', qrCode:'TKT-ABC123', event:{ title:'Camp 2025' }, registrationOption:{ eventOption:{ name:'Adult' }}, quantity:1, isUsed:false }]}]},
{ method:'POST', path:'/api/tickets/generate', auth:'supervisor+', desc:'Generate tickets for a registration (idempotent — skips already-generated options)',
@@ -793,6 +894,8 @@ app.get('/docs', async (req, res) => {
]},
{ title: 'Reports', base: '/api/reports', endpoints: [
{ method:'POST', path:'/api/reports/excel', auth:'Bearer', desc:'Build an Excel file from a report payload' },
{ method:'POST', path:'/api/reports/whatsapp', auth:'Bearer', desc:'Send a report PDF by WhatsApp' },
{ method:'POST', path:'/api/reports/pdf', auth:'user+', desc:'Generate a PDF report of own registrations and tickets and return it as a binary download',
responses:[{ status:200, desc:'PDF file (application/pdf)', body:'<binary PDF>' }]},
{ method:'POST', path:'/api/reports/email', auth:'user+', desc:'Generate PDF report and email it to the authenticated user',
@@ -817,6 +920,8 @@ app.get('/docs', async (req, res) => {
]},
{ title: 'Cashups', base: '/api/cashups', endpoints: [
{ method:'GET', path:'/api/cashups/event/:eventId/cash-by-user', auth:'supervisor+', desc:'Payments recorded for an event, by staff member and method' },
{ method:'PUT', path:'/api/cashups/event/:eventId/person-cash/:userId', auth:'supervisor+', desc:'Enter one staff member\'s physical cash count for an event' },
{ method:'GET', path:'/api/cashups/event/:eventId', auth:'supervisor+', desc:'Live cashup preview for an event: system income vs. reconciled actual per method, method-tagged costs, unallocated donations, net profit, and the full close/reopen history',
pathParams:{ ':eventId':'Event UUID' },
responses:[{ status:200, desc:'Success', body:{ event:{ id:'ev-uuid-...', title:'Camp 2025', cashupStatus:'open' }, paymentsByMethod:{ cash:1200, card:4500, eft:600, other:0 }, expectedCashByMethod:{ cash:1150, card:4500, eft:600, other:0 }, unallocatedDonationsTotal:150, totalCosts:900, netProfit:6450, history:[] }}]},
@@ -838,6 +943,8 @@ app.get('/docs', async (req, res) => {
]},
{ title: 'Uploads', base: '/api/uploads', endpoints: [
{ method:'POST', path:'/api/uploads/logo', auth:'admin (open during first-run setup)', desc:'Upload the site logo (multipart, field "image")' },
{ method:'POST', path:'/api/uploads/favicon', auth:'admin (open during first-run setup)', desc:'Upload the site favicon (multipart, field "image")' },
{ method:'POST', path:'/api/uploads/event-image', auth:'supervisor+', desc:'Upload an event cover image. Send as multipart/form-data with field name "image". Returns the public URL.',
notes:'Content-Type must be multipart/form-data. Max size is typically 5 MB.',
responses:[
@@ -894,6 +1001,10 @@ app.get('/docs', async (req, res) => {
]},
{ title: 'WhatsApp Admin', base: '/api/whatsapp', endpoints: [
{ method:'POST', path:'/api/whatsapp/start', auth:'admin', desc:'Start (or restart) the WAWP WhatsApp instance' },
{ method:'POST', path:'/api/whatsapp/request-code', auth:'admin', desc:'Request a pairing code to link a WhatsApp number' },
{ method:'POST', path:'/api/whatsapp/delete-instance', auth:'admin', desc:'Delete the WAWP instance' },
{ method:'POST', path:'/api/whatsapp/webhook', auth:'—', desc:'WAWP event webhook (alias of /api/webhooks/whatsapp)' },
{ method:'GET', path:'/api/whatsapp/config', auth:'admin', desc:'Get the currently stored WAWP credentials from DB (tokens are partially masked)',
responses:[{ status:200, desc:'Success', body:{ instanceId:'wawp-instance-abc', accessToken:'eyJ...****', source:'db' }}]},
{ method:'POST', path:'/api/whatsapp/config', auth:'admin', desc:'Save WAWP credentials to the database (AppSetting). Takes effect immediately; no restart needed.',
@@ -914,6 +1025,17 @@ app.get('/docs', async (req, res) => {
responses:[{ status:200, desc:'Logged out', body:{ message:'Logged out.' }}]},
]},
{ title: 'Dashboard stats', base: '/api/stats', endpoints: [
{ method:'GET', path:'/api/stats/staff', auth:'staff+', desc:'Everything the staff dashboard needs, in one call' },
{ method:'GET', path:'/api/stats/supervisor', auth:'supervisor+', desc:'Everything the supervisor dashboard needs, in one call' },
{ method:'GET', path:'/api/stats/admin', auth:'admin', desc:'Everything the admin dashboard needs, in one call' },
{ method:'GET', path:'/api/stats/overview', auth:'supervisor+', desc:'Trailing-month KPIs (registrations, tickets, revenue, donations), revenue trend, top events, funnel conversion' },
]},
{ title: 'Backups', base: '/api/backups', endpoints: [
{ method:'GET', path:'/api/backups', auth:'admin', desc:'Local database backups, newest first' },
{ method:'POST', path:'/api/backups/run', auth:'admin', desc:'Run a backup now' },
{ method:'GET', path:'/api/backups/:filename/download', auth:'admin', desc:'Download a backup file' },
]},
{ title: 'Webhooks', base: '/api/webhooks', endpoints: [
{ method:'POST', path:'/api/webhooks/yoco', auth:'HMAC', desc:'Yoco payment event webhook. Called by Yoco servers. Verified via HMAC-SHA256 signature in the X-Yoco-Signature header.',
notes:'This endpoint bypasses the global rate limiter. Raw body is parsed before JSON middleware. Refreshes early-bird pricing before computing totalDue to ensure accurate paid/partial status.',
@@ -1343,6 +1465,52 @@ app.listen(PORT, () => {
console.warn('[waitlist] Not scheduled:', e?.message || e);
}
// Funnel analytics — once a day, roll raw rows older than 90 days into daily totals.
try {
const enabled = String(process.env.ANALYTICS_ROLLUP_ENABLED || 'true').toLowerCase() !== 'false';
if (enabled) {
const { rollupOldEvents } = require('./utils/analytics');
const run = async () => {
try {
const n = await rollupOldEvents();
if (n) console.log(`[analytics] Rolled up ${n} session(s) older than 90 days.`);
} catch (e) {
console.warn('[analytics] Rollup failed:', e?.message || e);
}
};
setTimeout(run, 5 * 60 * 1000);
setInterval(run, 24 * 60 * 60 * 1000);
console.log('[analytics] Rollup daily.');
} else {
console.log('[analytics] Rollup disabled by env ANALYTICS_ROLLUP_ENABLED=false');
}
} catch (e) {
console.warn('[analytics] Not scheduled:', e?.message || e);
}
// Feedback job — send post-event feedback forms at their send time, and the one
// reminder 3 days later.
try {
const enabled = String(process.env.FEEDBACK_JOB_ENABLED || 'true').toLowerCase() !== 'false';
if (enabled) {
const { runFeedbackJob } = require('./utils/feedback');
const everyMs = parseInt(process.env.FEEDBACK_JOB_INTERVAL_MS || String(15 * 60 * 1000), 10);
setInterval(async () => {
try {
const r = await runFeedbackJob();
if (r.sent || r.reminded) console.log(`[feedback] ${r.sent} invite(s), ${r.reminded} reminder(s) sent.`);
} catch (e) {
console.warn('[feedback] Job failed:', e?.message || e);
}
}, everyMs);
console.log(`[feedback] Job every ${Math.round(everyMs / 60000)} min.`);
} else {
console.log('[feedback] Job disabled by env FEEDBACK_JOB_ENABLED=false');
}
} catch (e) {
console.warn('[feedback] 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 {
+24 -2
View File
@@ -48,6 +48,17 @@ const protect = async (req, res, next) => {
return next(new Error('User account is deactivated'));
}
// An admin viewing as this user: only while the session is live, and read-only.
if (decoded.imp) {
const { liveSession } = require('../utils/impersonation');
const session = await liveSession(String(decoded.imp));
if (!session || session.targetUserId !== req.user.id) {
return res.status(401).json({ message: 'The impersonation session has ended.', code: 'IMPERSONATION_ENDED' });
}
req.impersonation = session;
req.readOnly = true;
}
// Revocation check — tokenVersion in JWT must match DB
// Old tokens without tokenVersion are treated as version 0
const tokenVer = decoded.tokenVersion ?? 0;
@@ -60,7 +71,8 @@ const protect = async (req, res, next) => {
// 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))) {
// (An impersonating admin has already passed 2FA themselves.)
if (!inSetupGrace && !req.impersonation && (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',
@@ -128,7 +140,17 @@ const optionalAuth = async (req, res, next) => {
select: { id: true, name: true, email: true, role: true, isActive: true, createdAt: true, updatedAt: true, phoneNumber: true, tokenVersion: true }
});
if (user && user.isActive && (decoded.tokenVersion ?? 0) === user.tokenVersion) {
req.user = user;
if (decoded.imp) {
const { liveSession } = require('../utils/impersonation');
const session = await liveSession(String(decoded.imp));
req.readOnly = true;
if (session && session.targetUserId === user.id) {
req.user = user;
req.impersonation = session;
}
} else {
req.user = user;
}
}
} catch {
// Token invalid or expired — proceed without user
+7
View File
@@ -1,8 +1,15 @@
const express = require('express');
const router = express.Router();
const { listAuditLog } = require('../controllers/adminAuditController');
const { startImpersonation, endImpersonation, logPageView, currentImpersonation, listSessions } = require('../controllers/impersonationController');
const { protect, admin } = require('../middleware/authMiddleware');
router.get('/audit-log', protect, admin, listAuditLog);
router.get('/impersonation-sessions', protect, admin, listSessions);
// Called with the impersonation token itself (which belongs to the viewed user).
router.post('/impersonate/end', protect, endImpersonation);
router.post('/impersonate/log', protect, logPageView);
router.get('/impersonate/current', protect, currentImpersonation);
router.post('/impersonate/:userId', protect, admin, startImpersonation);
module.exports = router;
+22
View File
@@ -0,0 +1,22 @@
const express = require('express');
const router = express.Router();
const {
getBoard, createSet, updateSet, deleteSet, createGroup, updateGroup, deleteGroup,
assign, autoAssign, clearAssignments, getSetLists, getMyGroups,
} = require('../controllers/allocationController');
const { protect, staff, supervisor } = require('../middleware/authMiddleware');
router.get('/my-groups', protect, staff, getMyGroups);
router.get('/event/:eventId', protect, supervisor, getBoard);
router.post('/sets', protect, supervisor, createSet);
router.put('/sets/:id', protect, supervisor, updateSet);
router.delete('/sets/:id', protect, supervisor, deleteSet);
router.get('/sets/:id/lists', protect, supervisor, getSetLists);
router.post('/sets/:id/groups', protect, supervisor, createGroup);
router.put('/sets/:id/assignments', protect, supervisor, assign);
router.delete('/sets/:id/assignments', protect, supervisor, clearAssignments);
router.post('/sets/:id/auto', protect, supervisor, autoAssign);
router.put('/groups/:id', protect, supervisor, updateGroup);
router.delete('/groups/:id', protect, supervisor, deleteGroup);
module.exports = router;
+18
View File
@@ -0,0 +1,18 @@
const express = require('express');
const rateLimit = require('express-rate-limit');
const router = express.Router();
const { trackStage, getFunnel } = require('../controllers/analyticsController');
const { protect, supervisor } = require('../middleware/authMiddleware');
const trackLimiter = rateLimit({
windowMs: 60 * 1000,
max: 60,
standardHeaders: true,
legacyHeaders: false,
handler: (req, res) => res.status(204).end(), // silently drop floods
});
router.post('/track', trackLimiter, trackStage);
router.get('/funnel', protect, supervisor, getFunnel);
module.exports = router;
+14
View File
@@ -0,0 +1,14 @@
const express = require('express');
const router = express.Router();
const { getEventFeedback, saveEventFeedback, deleteEventFeedback, sendNow, getPublicForm, submitPublicForm, getSummary } = require('../controllers/feedbackController');
const { protect, supervisor } = require('../middleware/authMiddleware');
router.get('/public/:token', getPublicForm);
router.post('/public/:token', submitPublicForm);
router.get('/summary', protect, supervisor, getSummary);
router.get('/event/:eventId', protect, supervisor, getEventFeedback);
router.put('/event/:eventId', protect, supervisor, saveEventFeedback);
router.delete('/event/:eventId', protect, supervisor, deleteEventFeedback);
router.post('/event/:eventId/send-now', protect, supervisor, sendNow);
module.exports = router;
+15
View File
@@ -0,0 +1,15 @@
const express = require('express');
const router = express.Router();
const { listLocks, createLock, getSnapshot, syncLock, releaseLock, forceRelease, listReports, reviewReport } = require('../controllers/scanLockController');
const { protect, staff, supervisor } = require('../middleware/authMiddleware');
router.get('/', protect, staff, listLocks);
router.post('/', protect, staff, createLock);
router.get('/reports', protect, supervisor, listReports);
router.post('/reports/:id/review', protect, supervisor, reviewReport);
router.get('/:id/snapshot', protect, staff, getSnapshot);
router.post('/:id/sync', protect, staff, syncLock);
router.post('/:id/force-release', protect, supervisor, forceRelease);
router.delete('/:id', protect, staff, releaseLock);
module.exports = router;
+10
View File
@@ -0,0 +1,10 @@
const express = require('express');
const router = express.Router();
const { getEventSponsorship, createSponsorCheckout, getPool } = require('../controllers/sponsorshipController');
const { protect, supervisor, optionalAuth } = require('../middleware/authMiddleware');
router.get('/event/:eventId', getEventSponsorship);
router.get('/event/:eventId/pool', protect, supervisor, getPool);
router.post('/checkout', optionalAuth, createSponsorCheckout);
module.exports = router;
+14 -1
View File
@@ -13,7 +13,13 @@ const {
emailTickets,
sendTicketsTo,
getRecentScans,
getScanStats
getScanStats,
transferTicketHandler,
cancelTransferHandler,
getTransferOffer,
acceptTransferOffer,
declineTransferOffer,
getTicketTransfers
} = require('../controllers/ticketController');
const { protect, admin, supervisor, staff } = require('../middleware/authMiddleware');
@@ -21,6 +27,13 @@ const { protect, admin, supervisor, staff } = require('../middleware/authMiddlew
router.get('/mytickets', protect, getUserTickets);
router.get('/:id', protect, getTicketById);
router.post('/email', protect, emailTickets);
router.post('/:id/transfer', protect, transferTicketHandler);
router.delete('/:id/transfer', protect, cancelTransferHandler);
// Transfer links (public: the person the ticket is offered to)
router.get('/transfer-offer/:token', getTransferOffer);
router.post('/transfer-offer/:token/accept', acceptTransferOffer);
router.post('/transfer-offer/:token/decline', declineTransferOffer);
router.get('/:id/transfers', protect, getTicketTransfers);
// Staff routes
router.post('/send-to', protect, staff, sendTicketsTo);
+30 -1
View File
@@ -33,4 +33,33 @@ async function findAccountByContact({ email, phone: rawPhone }, db = prisma) {
return { phone, emailUser, phoneUser, conflict, user: conflict ? null : (emailUser || phoneUser || null) };
}
module.exports = { findAccountByContact, hasValidEmail };
/**
* The account for these contact details, or a new inactive one (same as guest
* registration: a placeholder email for phone-only people, a random password, and an
* activation link sent by the caller when `created` is true).
*/
async function findOrCreateAccount({ name, email, phone }, db = prisma) {
const match = await findAccountByContact({ email, phone }, db);
// Email and phone pointing at two different accounts: go with the email's account,
// without saying so — the person typing the details mustn't learn who has an account.
if (match.conflict) return { user: match.emailUser, created: false };
if (match.user) return { user: match.user, created: false };
const { v4: uuidv4 } = require('uuid');
const { hashPassword } = require('../config/auth');
const validEmail = hasValidEmail(email);
const user = await db.user.create({
data: {
id: uuidv4(),
name,
email: validEmail ? String(email).trim() : `guest+${uuidv4().slice(0, 8)}@guest.local`,
password: await hashPassword(uuidv4()),
phoneNumber: match.phone || null,
isActive: false,
notificationPreference: validEmail && match.phone ? 'both' : validEmail ? 'email' : 'whatsapp',
updatedAt: new Date(),
},
});
return { user, created: true };
}
module.exports = { findAccountByContact, findOrCreateAccount, hasValidEmail };
+107
View File
@@ -0,0 +1,107 @@
/**
* Group / room allocation — placing named attendees into the groups of an allocation
* set ("Rooms", "Buses"…). Groups can have a capacity and a gender restriction.
* The placement rules and auto-allocate are pure, so they're unit-tested.
*/
const GENDERS = ['male', 'female'];
function allocationError(message, statusCode = 400) {
const err = new Error(message);
err.statusCode = statusCode;
return err;
}
/** Normalises a gender value ('male' | 'female' | null); throws on anything else. */
function cleanGender(value) {
if (value === undefined || value === null || value === '') return null;
const g = String(value).trim().toLowerCase();
if (!GENDERS.includes(g)) throw allocationError('Gender must be male or female.');
return g;
}
/**
* Why `attendees` can't all join `group` (which has `count` members already), or null. Pure.
* @param {object} group - { name, capacity?, gender? }
*/
function joinBlocker(group, count, attendees) {
if (group.capacity != null && count + attendees.length > group.capacity) {
return `${group.name} is full (${group.capacity} places).`;
}
if (group.gender) {
const wrong = attendees.find(a => a.gender !== group.gender);
if (wrong) return `${group.name} is for ${group.gender === 'male' ? 'males' : 'females'} only${wrong.gender ? '' : ` — ${wrong.name} has no gender recorded`}.`;
}
return null;
}
/**
* Places every unassigned attendee it can. Pure.
*
* Groups fill evenly (the emptiest group that fits goes first). With mode 'together',
* people on the same registration (siblings) stay in one group when a group can take
* them all, otherwise they're placed one by one. With mode 'split', people on the same
* registration (friends booking together) are spread across groups where possible.
*
* @param {object} p
* @param {Array} p.groups - [{ id, name, capacity?, gender?, order? }]
* @param {Array} p.attendees - [{ id, name, registrationId, gender? }]
* @param {Map} p.assigned - attendeeId → groupId for attendees already placed
* @param {'together'|'split'} [p.mode]
* @returns {{ placements: {attendeeId, groupId}[], unplaced: string[] }}
*/
function autoAllocate({ groups, attendees, assigned = new Map(), mode = 'together' }) {
const count = new Map(groups.map(g => [g.id, 0]));
const regInGroup = new Map(groups.map(g => [g.id, new Map()]));
const byId = new Map(attendees.map(a => [a.id, a]));
const place = (groupId, a) => {
count.set(groupId, count.get(groupId) + 1);
const regs = regInGroup.get(groupId);
regs.set(a.registrationId, (regs.get(a.registrationId) || 0) + 1);
};
for (const [attendeeId, groupId] of assigned) {
const a = byId.get(attendeeId);
if (a && count.has(groupId)) place(groupId, a);
}
const todo = attendees.filter(a => !assigned.has(a.id));
let units;
if (mode === 'together') {
const byReg = new Map();
for (const a of todo) byReg.set(a.registrationId, [...(byReg.get(a.registrationId) || []), a]);
units = [...byReg.values()].sort((x, y) => y.length - x.length);
} else {
units = todo.map(a => [a]);
}
const orderOf = new Map(groups.map((g, i) => [g.id, g.order ?? i]));
const best = (members) => {
const reg = members[0].registrationId;
const fitting = groups.filter(g => !joinBlocker(g, count.get(g.id), members));
if (fitting.length === 0) return null;
fitting.sort((x, y) =>
(mode === 'split' ? (regInGroup.get(x.id).get(reg) || 0) - (regInGroup.get(y.id).get(reg) || 0) : 0)
|| count.get(x.id) - count.get(y.id)
|| orderOf.get(x.id) - orderOf.get(y.id));
return fitting[0];
};
const placements = [];
const unplaced = [];
const queue = [...units];
while (queue.length) {
const unit = queue.shift();
const g = best(unit);
if (g) {
for (const a of unit) { place(g.id, a); placements.push({ attendeeId: a.id, groupId: g.id }); }
} else if (unit.length > 1) {
// Can't keep them together — place them one by one instead.
queue.unshift(...unit.map(a => [a]));
} else {
unplaced.push(unit[0].id);
}
}
return { placements, unplaced };
}
module.exports = { GENDERS, allocationError, cleanGender, joinBlocker, autoAllocate };
+220
View File
@@ -0,0 +1,220 @@
/**
* Registration funnel — first-party, consent-based tracking.
*
* The browser only tracks after the visitor accepts cookies: it keeps a random session
* id and reports 'view' (event page) and 'start' (registration page). 'submit' is
* recorded here when a registration arrives carrying that session id, and 'paid' is
* read from that registration's status, so no personal data is stored with the events —
* just a stage, a traffic source and a device type (worked out from the User-Agent,
* which isn't kept).
*
* Counts are "reached": a session counts at a stage if it got that far or further, so a
* visitor who went straight to the registration page still counts as a view. Raw rows
* older than 90 days are rolled up into AnalyticsDaily and deleted.
*/
const prisma = require('../config/db');
const STAGES = ['view', 'start', 'submit', 'paid'];
const TRACKED = ['view', 'start'];
const RAW_DAYS = 90;
const SESSION_RE = /^[a-zA-Z0-9-]{16,64}$/;
const dayOf = (d) => new Date(d).toISOString().slice(0, 10);
// ─── Pure helpers ─────────────────────────────────────────────────────────────
const validSession = (sid) => typeof sid === 'string' && SESSION_RE.test(sid);
/** 'mobile' | 'tablet' | 'desktop', or null for bots. Pure. */
function deviceFromUA(ua) {
const s = String(ua || '');
if (!s || /bot|crawl|spider|slurp|preview|headless|lighthouse/i.test(s)) return null;
if (/iPad|Tablet|PlayBook|Silk|(Android(?!.*Mobile))/i.test(s)) return 'tablet';
if (/Mobi|iPhone|iPod|Android|Windows Phone/i.test(s)) return 'mobile';
return 'desktop';
}
/** A traffic source label: utm_source / short-link alias, tidied; 'direct' when none. Pure. */
function cleanSource(src) {
const s = String(src || '').trim().toLowerCase().replace(/[^a-z0-9_.-]/g, '').slice(0, 50);
return s || 'direct';
}
/**
* One entry per session: the furthest stage reached, its source/device, and the day each
* stage was reached. Pure.
* @param {Array} events - [{ sessionId, stage, createdAt, source, device }]
* @param {Set} paidSessions - session ids whose registration is paid
*/
function sessionsFrom(events, paidSessions = new Set()) {
const map = new Map();
for (const e of [...events].sort((a, b) => new Date(a.createdAt) - new Date(b.createdAt))) {
const s = map.get(e.sessionId) || { sessionId: e.sessionId, reach: -1, source: null, device: null, days: {} };
const idx = STAGES.indexOf(e.stage);
if (idx > s.reach) s.reach = idx;
if (!s.days[e.stage]) s.days[e.stage] = dayOf(e.createdAt);
if (!s.source && e.source) s.source = e.source;
if (!s.device && e.device) s.device = e.device;
map.set(e.sessionId, s);
}
for (const s of map.values()) {
if (paidSessions.has(s.sessionId) && s.reach >= 2) { s.reach = 3; s.days.paid = s.days.submit; }
// A stage reached without its own event (e.g. no 'view' for a direct link) takes the
// day of the next stage that was recorded.
for (let i = STAGES.length - 1; i >= 0; i--) {
if (i <= s.reach && !s.days[STAGES[i]]) s.days[STAGES[i]] = s.days[STAGES[i + 1]];
}
s.source = s.source || 'direct';
s.device = s.device || 'unknown';
}
return [...map.values()];
}
/** Rows for AnalyticsDaily: sessions reaching each stage per day/source/device. Pure. */
function rollupRows(sessions) {
const by = new Map();
for (const s of sessions) {
for (let i = 0; i <= s.reach; i++) {
const key = [s.days[STAGES[i]], STAGES[i], s.source, s.device].join('|');
by.set(key, (by.get(key) || 0) + 1);
}
}
return [...by.entries()].map(([key, n]) => {
const [day, stage, source, device] = key.split('|');
return { day, stage, source, device, sessions: n };
});
}
const emptyCounts = () => ({ view: 0, start: 0, submit: 0, paid: 0 });
/**
* The funnel report from raw sessions plus rolled-up rows. Pure.
* @returns {{ totals, conversion, trend, bySource, byDevice }}
*/
function funnelReport(sessions, daily = []) {
const rows = [...rollupRows(sessions), ...daily.map(d => ({ ...d, day: typeof d.day === 'string' ? d.day.slice(0, 10) : dayOf(d.day) }))];
const totals = emptyCounts();
const trend = new Map(), bySource = new Map(), byDevice = new Map();
const add = (map, key, stage, n) => { const c = map.get(key) || emptyCounts(); c[stage] += n; map.set(key, c); };
for (const r of rows) {
totals[r.stage] += r.sessions;
add(trend, r.day, r.stage, r.sessions);
add(bySource, r.source, r.stage, r.sessions);
add(byDevice, r.device, r.stage, r.sessions);
}
const pct = (a, b) => (b ? Math.round((a / b) * 1000) / 10 : null);
const list = (map) => [...map.entries()].map(([key, c]) => ({ key, ...c, conversion: pct(c.paid, c.view) })).sort((a, b) => b.view - a.view);
return {
totals,
conversion: {
start: pct(totals.start, totals.view), submit: pct(totals.submit, totals.view), paid: pct(totals.paid, totals.view),
startToSubmit: pct(totals.submit, totals.start), submitToPaid: pct(totals.paid, totals.submit),
},
trend: [...trend.entries()].sort(([a], [b]) => a.localeCompare(b)).map(([day, c]) => ({ day, ...c })),
bySource: list(bySource),
byDevice: list(byDevice),
};
}
// ─── Database ─────────────────────────────────────────────────────────────────
/** Records a 'view' or 'start'. Repeats from the same session are ignored. */
async function track({ sessionId, eventId, stage, source, userAgent }) {
if (!validSession(sessionId) || !TRACKED.includes(stage) || typeof eventId !== 'string') return false;
const device = deviceFromUA(userAgent);
if (!device) return false; // bots
const event = await prisma.event.findUnique({ where: { id: eventId }, select: { id: true } });
if (!event) return false;
await prisma.analyticsEvent.createMany({ data: [{ sessionId, eventId, stage, source: cleanSource(source), device }], skipDuplicates: true });
return true;
}
/** 'submit' for a registration made on the public registration page. */
async function recordSubmit(registration, sessionId) {
if (!validSession(sessionId) || !registration?.id) return;
const first = await prisma.analyticsEvent.findFirst({ where: { sessionId, eventId: registration.eventId }, orderBy: { createdAt: 'asc' } });
await prisma.registration.updateMany({ where: { id: registration.id, analyticsSessionId: null }, data: { analyticsSessionId: sessionId } });
await prisma.analyticsEvent.createMany({
data: [{ sessionId, eventId: registration.eventId, stage: 'submit', source: first?.source || 'direct', device: first?.device || null }],
skipDuplicates: true,
});
}
async function paidSessionsFor(eventIds, sessionIds) {
if (!sessionIds.length) return new Set();
const regs = await prisma.registration.findMany({
where: { eventId: { in: eventIds }, status: 'paid', analyticsSessionId: { in: sessionIds } },
select: { analyticsSessionId: true },
});
return new Set(regs.map(r => r.analyticsSessionId));
}
/**
* The funnel for some events between two dates (by the day each stage was reached).
* Adds a by-ticket-option breakdown from the tracked registrations.
*/
async function funnelFor(eventIds, { from, to } = {}) {
const range = {};
if (from) range.gte = new Date(from);
if (to) { const t = new Date(to); t.setHours(23, 59, 59, 999); range.lte = t; }
const where = { eventId: { in: eventIds }, ...(from || to ? { createdAt: range } : {}) };
const events = await prisma.analyticsEvent.findMany({ where, select: { sessionId: true, stage: true, createdAt: true, source: true, device: true, eventId: true } });
// A session is per event (the same browser can look at several events).
const keyed = events.map(e => ({ ...e, sessionId: `${e.eventId}:${e.sessionId}` }));
const paidRaw = await paidSessionsFor(eventIds, [...new Set(events.map(e => e.sessionId))]);
const paid = new Set(events.filter(e => paidRaw.has(e.sessionId)).map(e => `${e.eventId}:${e.sessionId}`));
const daily = await prisma.analyticsDaily.findMany({
where: { eventId: { in: eventIds }, ...(from || to ? { day: range } : {}) },
select: { day: true, stage: true, source: true, device: true, sessions: true },
});
const report = funnelReport(sessionsFrom(keyed, paid), daily);
const regs = await prisma.registration.findMany({
where: { eventId: { in: eventIds }, analyticsSessionId: { not: null }, status: { not: 'cancelled' }, ...(from || to ? { createdAt: range } : {}) },
select: { status: true, registrationOptions: { select: { quantity: true, eventOption: { select: { name: true } }, variant: { select: { name: true } } } } },
});
const byOption = new Map();
for (const r of regs) {
for (const ro of r.registrationOptions) {
const key = ro.variant ? `${ro.eventOption.name} — ${ro.variant.name}` : ro.eventOption.name;
const c = byOption.get(key) || { key, submitted: 0, paid: 0 };
c.submitted += 1;
if (r.status === 'paid') c.paid += 1;
byOption.set(key, c);
}
}
return { ...report, byOption: [...byOption.values()].sort((a, b) => b.submitted - a.submitted) };
}
/** Rolls up sessions whose last activity is older than 90 days, then deletes their rows. */
async function rollupOldEvents(now = new Date()) {
const cutoff = new Date(now.getTime() - RAW_DAYS * 864e5);
const groups = await prisma.analyticsEvent.groupBy({ by: ['eventId', 'sessionId'], _max: { createdAt: true }, having: { createdAt: { _max: { lt: cutoff } } } });
if (groups.length === 0) return 0;
const byEvent = new Map();
for (const g of groups) byEvent.set(g.eventId, [...(byEvent.get(g.eventId) || []), g.sessionId]);
let rolled = 0;
for (const [eventId, sessionIds] of byEvent) {
for (let i = 0; i < sessionIds.length; i += 500) {
const batch = sessionIds.slice(i, i + 500);
const events = await prisma.analyticsEvent.findMany({ where: { eventId, sessionId: { in: batch } } });
const paid = await paidSessionsFor([eventId], batch);
const rows = rollupRows(sessionsFrom(events, paid));
await prisma.$transaction(async (tx) => {
for (const r of rows) {
await tx.analyticsDaily.upsert({
where: { eventId_day_stage_source_device: { eventId, day: new Date(r.day), stage: r.stage, source: r.source, device: r.device } },
update: { sessions: { increment: r.sessions } },
create: { eventId, day: new Date(r.day), stage: r.stage, source: r.source, device: r.device, sessions: r.sessions },
});
}
await tx.analyticsEvent.deleteMany({ where: { eventId, sessionId: { in: batch } } });
});
rolled += batch.length;
}
}
return rolled;
}
module.exports = { STAGES, RAW_DAYS, validSession, deviceFromUA, cleanSource, sessionsFrom, rollupRows, funnelReport, track, recordSubmit, funnelFor, rollupOldEvents };
+9 -1
View File
@@ -69,6 +69,13 @@ function validateAttendees(event, placesByKey, attendees, namedByKey = {}) {
} else if (consent) {
throw attendeeError(`${name}: enter a date of birth.`);
}
let gender = null;
if (a.gender) {
gender = String(a.gender).trim().toLowerCase();
if (!['male', 'female'].includes(gender)) throw attendeeError(`${name}: gender must be male or female.`);
} else if (event.collectGender) {
throw attendeeError(`${name}: choose a gender.`);
}
const parentName = String(a.parentName || '').trim() || null;
const parentEmail = String(a.parentEmail || '').trim() || null;
const parentPhone = String(a.parentPhone || '').trim() || null;
@@ -78,7 +85,7 @@ function validateAttendees(event, placesByKey, attendees, namedByKey = {}) {
}
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 };
return { eventOptionId: a.eventOptionId, variantId: a.variantId || null, name, email, phone, dateOfBirth, gender, parentName, parentEmail, parentPhone };
});
// Youth events need every main place named (with a date of birth).
@@ -187,6 +194,7 @@ async function createAttendees(registrationId, attendees, db = prisma) {
email: a.email,
phone: a.phone,
dateOfBirth: a.dateOfBirth || null,
gender: a.gender || null,
parentName: a.parentName || null,
parentEmail: a.parentEmail || null,
parentPhone: a.parentPhone || null,
+230
View File
@@ -0,0 +1,230 @@
/**
* Post-event feedback.
*
* A FeedbackForm is sent once, at its sendAt, to everyone who checked in (or everyone
* registered), with one reminder 3 days later to those who haven't answered. Each person
* gets a personal link; only an HMAC of its token is stored, and a reminder issues a new
* link. Anonymous forms record who has answered (so reminders skip them) but never link
* the answers to the person.
*/
const crypto = require('crypto');
const prisma = require('../config/db');
const ANSWERABLE = ['text', 'numeric', 'date', 'yes_no', 'rating', 'multiple_choice'];
const ALL_TYPES = [...ANSWERABLE, 'statement', 'paragraph'];
const REMINDER_AFTER_MS = 3 * 864e5;
function feedbackError(message, statusCode = 400) {
const err = new Error(message);
err.statusCode = statusCode;
return err;
}
const hashToken = (token) => crypto.createHmac('sha256', process.env.JWT_SECRET || '').update(`feedback:${token}`).digest('hex');
const newToken = () => crypto.randomBytes(24).toString('hex');
function feedbackLink(token) {
const base = (process.env.FRONTEND_URL || process.env.APP_BASE_URL || 'http://localhost:3001').split(',')[0].trim().replace(/\/$/, '');
return `${base}/feedback/${token}`;
}
// ─── Pure helpers ─────────────────────────────────────────────────────────────
/** Checks and tidies the questions from the form builder. Pure. */
function cleanFields(fields) {
if (!Array.isArray(fields)) throw feedbackError('fields must be a list');
const out = fields.filter(f => f && String(f.label || '').trim()).map((f, i) => {
if (!ALL_TYPES.includes(f.type)) throw feedbackError(`Question ${i + 1}: unknown type "${f.type}".`);
const label = String(f.label).trim().slice(0, 500);
let options = null;
if (f.type === 'multiple_choice') {
options = [...new Set((Array.isArray(f.options) ? f.options : []).map(o => String(o || '').trim()).filter(Boolean))].slice(0, 20);
if (options.length < 2) throw feedbackError(`"${label}": a multiple-choice question needs at least two choices.`);
}
return {
type: f.type, label, order: i, options,
isRequired: ANSWERABLE.includes(f.type) && !!f.isRequired,
helpText: f.helpText ? String(f.helpText).slice(0, 1000) : null,
};
});
if (out.length > 50) throw feedbackError('A feedback form can have at most 50 questions.');
if (!out.some(f => ANSWERABLE.includes(f.type))) throw feedbackError('Add at least one question.');
return out;
}
/** The stored value for one answer (null when blank); throws when it's invalid. Pure. */
function cleanAnswer(field, raw) {
if (raw === undefined || raw === null || String(raw).trim() === '') {
if (field.isRequired) throw feedbackError(`Please answer: ${field.label}`);
return null;
}
const v = String(raw).trim();
switch (field.type) {
case 'rating': {
const n = Number(v);
if (!Number.isInteger(n) || n < 1 || n > 5) throw feedbackError(`"${field.label}": choose 1 to 5 stars.`);
return String(n);
}
case 'multiple_choice':
if (!(field.options || []).includes(v)) throw feedbackError(`"${field.label}": choose one of the options.`);
return v;
case 'yes_no':
if (!['yes', 'no'].includes(v.toLowerCase())) throw feedbackError(`"${field.label}": answer yes or no.`);
return v.toLowerCase();
case 'numeric':
if (!Number.isFinite(Number(v))) throw feedbackError(`"${field.label}": enter a number.`);
return v;
case 'date':
if (isNaN(new Date(v).getTime())) throw feedbackError(`"${field.label}": enter a valid date.`);
return v.slice(0, 10);
default:
return v.slice(0, 4000);
}
}
/**
* Per-question summary. Pure.
* @param {Array} fields - [{ id, type, label, options }]
* @param {Array} answers - [{ fieldId, value, name? }] (name: respondent, for named forms)
*/
function summarise(fields, answers) {
const by = new Map();
for (const a of answers) by.set(a.fieldId, [...(by.get(a.fieldId) || []), a]);
const round1 = (n) => Math.round(n * 10) / 10;
return fields.filter(f => ANSWERABLE.includes(f.type)).map(f => {
const list = by.get(f.id) || [];
const base = { fieldId: f.id, type: f.type, label: f.label, answered: list.length };
if (f.type === 'rating') {
const nums = list.map(a => Number(a.value)).filter(n => n >= 1 && n <= 5);
const distribution = [1, 2, 3, 4, 5].map(star => ({ label: `${star}★`, value: nums.filter(n => n === star).length }));
return { ...base, average: nums.length ? round1(nums.reduce((s, n) => s + n, 0) / nums.length) : null, distribution };
}
if (f.type === 'multiple_choice' || f.type === 'yes_no') {
const choices = f.type === 'yes_no' ? ['yes', 'no'] : (f.options || []);
return { ...base, counts: choices.map(c => ({ label: f.type === 'yes_no' ? (c === 'yes' ? 'Yes' : 'No') : c, value: list.filter(a => a.value === c).length })) };
}
if (f.type === 'numeric') {
const nums = list.map(a => Number(a.value)).filter(Number.isFinite);
return { ...base, average: nums.length ? round1(nums.reduce((s, n) => s + n, 0) / nums.length) : null };
}
return { ...base, texts: list.map(a => ({ value: a.value, ...(a.name ? { name: a.name } : {}) })) };
});
}
// ─── Sending ──────────────────────────────────────────────────────────────────
/** People to ask: those who checked in (a scan on their ticket), or everyone registered. */
async function feedbackRecipients(form) {
const tickets = await prisma.ticket.findMany({
where: {
eventId: form.eventId,
registrationOption: { registration: { status: { not: 'cancelled' } } },
...(form.audience === 'all' ? {} : { usages: { some: {} } }),
},
select: { userId: true, holderUserId: true, attendee: { select: { userId: true } } },
});
const ids = new Set(tickets.map(t => t.holderUserId || t.attendee?.userId || t.userId));
if (form.audience === 'all') {
const regs = await prisma.registration.findMany({ where: { eventId: form.eventId, status: { not: 'cancelled' } }, select: { userId: true } });
regs.forEach(r => ids.add(r.userId));
}
if (ids.size === 0) return [];
return prisma.user.findMany({ where: { id: { in: [...ids] } }, select: { id: true, name: true, email: true, phoneNumber: true, notificationPreference: true } });
}
async function sendFeedbackMessage(user, event, token, { reminder = false } = {}) {
const { sendMail, emailWrapper, ctaButton, fallbackLink } = require('./email');
const { waTextAny } = require('./notify');
const { isValidZAPhone } = require('./whatsapp');
const ff = `-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif`;
const link = feedbackLink(token);
const subject = `${reminder ? 'Reminder: ' : ''}How was ${event.title}?`;
const emailOk = !!(user.email && user.email.includes('@') && !user.email.endsWith('@guest.local') && !user.email.endsWith('@deleted.invalid'));
const sent = [];
if (emailOk) {
const html = emailWrapper(`
<p style="font-size:22px;font-weight:800;color:#0f172a;margin:0 0 8px 0">${reminder ? 'A quick reminder — ' : ''}How was ${event.title}?</p>
<p style="margin:0 0 8px 0;color:#374151;font-family:${ff}">Hi <strong>${user.name || 'there'}</strong>,</p>
<p style="margin:0 0 24px 0;color:#374151;font-family:${ff}">Thank you for coming to <strong>${event.title}</strong>. We'd love to hear what you thought — it only takes a minute.</p>
${ctaButton('Give feedback', link)}
${fallbackLink(link)}
`, { preheader: `Tell us about ${event.title}` });
try {
await sendMail({ to: user.email, subject, html, text: `Hi ${user.name || 'there'},\n\nThank you for coming to ${event.title}. We'd love to hear what you thought: ${link}` });
sent.push('email');
} catch (e) {
console.warn('[feedback] email failed:', e?.message || e);
}
}
const wantsWA = ['whatsapp', 'both'].includes(user.notificationPreference) || !emailOk;
if (wantsWA && user.phoneNumber && isValidZAPhone(user.phoneNumber)) {
await waTextAny(user, `${reminder ? '⏰ ' : ''}Hi ${user.name || 'there'}, thank you for coming to *${event.title}*! We'd love your feedback — it only takes a minute: ${link}`);
sent.push('WhatsApp');
}
return sent;
}
/** Sends the form to everyone not yet invited. Returns how many were invited. */
async function sendFeedbackInvites(formId) {
const form = await prisma.feedbackForm.findUnique({ where: { id: formId }, include: { event: true, invites: { select: { userId: true } } } });
if (!form) return 0;
const already = new Set(form.invites.map(i => i.userId));
const people = (await feedbackRecipients(form)).filter(u => !already.has(u.id));
for (const user of people) {
const token = newToken();
try {
await prisma.feedbackInvite.create({ data: { formId: form.id, userId: user.id, tokenHash: hashToken(token) } });
} catch (e) {
if (e.code === 'P2002') continue; // invited by a concurrent run
throw e;
}
await sendFeedbackMessage(user, form.event, token).catch(e => console.warn('[feedback] send failed:', e?.message || e));
}
return people.length;
}
/** The single reminder: a fresh link for everyone who hasn't answered. */
async function sendFeedbackReminders(formId) {
const form = await prisma.feedbackForm.findUnique({ where: { id: formId }, include: { event: true } });
const invites = await prisma.feedbackInvite.findMany({
where: { formId, respondedAt: null, remindedAt: null },
include: { user: { select: { id: true, name: true, email: true, phoneNumber: true, notificationPreference: true } } },
});
for (const inv of invites) {
const token = newToken();
await prisma.feedbackInvite.update({ where: { id: inv.id }, data: { tokenHash: hashToken(token), remindedAt: new Date() } });
await sendFeedbackMessage(inv.user, form.event, token, { reminder: true }).catch(e => console.warn('[feedback] reminder failed:', e?.message || e));
}
return invites.length;
}
/** Background job: send forms whose time has come, and reminders 3 days after. */
async function runFeedbackJob(now = new Date()) {
let sent = 0, reminded = 0;
const due = await prisma.feedbackForm.findMany({ where: { sentAt: null, sendAt: { lte: now } }, select: { id: true } });
for (const f of due) {
// Claim it first so two servers (or overlapping runs) never both send.
const claimed = await prisma.feedbackForm.updateMany({ where: { id: f.id, sentAt: null }, data: { sentAt: now } });
if (claimed.count === 1) sent += await sendFeedbackInvites(f.id);
}
const remind = await prisma.feedbackForm.findMany({ where: { reminderSentAt: null, sentAt: { lte: new Date(now.getTime() - REMINDER_AFTER_MS) } }, select: { id: true } });
for (const f of remind) {
const claimed = await prisma.feedbackForm.updateMany({ where: { id: f.id, reminderSentAt: null }, data: { reminderSentAt: now } });
if (claimed.count === 1) reminded += await sendFeedbackReminders(f.id);
}
return { sent, reminded };
}
async function findInviteByToken(token) {
if (!token || typeof token !== 'string' || token.length > 200) return null;
return prisma.feedbackInvite.findUnique({
where: { tokenHash: hashToken(token) },
include: { user: { select: { id: true, name: true } }, form: { include: { event: { select: { id: true, title: true, startDate: true, endDate: true } }, fields: { orderBy: { order: 'asc' } } } } },
});
}
module.exports = {
ANSWERABLE, ALL_TYPES, REMINDER_AFTER_MS, feedbackError, hashToken, feedbackLink,
cleanFields, cleanAnswer, summarise,
feedbackRecipients, sendFeedbackInvites, sendFeedbackReminders, runFeedbackJob, findInviteByToken,
};
+80
View File
@@ -0,0 +1,80 @@
/**
* Admin "view as user" — strictly read-only.
*
* An impersonation token is an ordinary JWT for the target user plus an `imp` claim (the
* ImpersonationSession id), valid for at most 30 minutes. Three layers keep it read-only:
* 1. readOnlyGuard (mounted before every route): any request carrying an impersonation
* token that isn't GET/HEAD/OPTIONS is refused, apart from ending the session and
* logging page views. No controller can be reached for a write.
* 2. protect / optionalAuth check the session is live and set req.readOnly.
* 3. GET handlers that write as a side effect (price refresh on view) skip it when
* req.readOnly is set.
* The only rows an impersonation session writes are its own session and request log.
*/
const jwt = require('jsonwebtoken');
const prisma = require('../config/db');
const MAX_MINUTES = 30;
const SAFE_METHODS = ['GET', 'HEAD', 'OPTIONS'];
const ALLOWED_WRITES = ['/api/admin/impersonate/end', '/api/admin/impersonate/log'];
/** The `imp` claim of a bearer token, or null (no token, invalid token, or not impersonating). */
function impersonationClaim(req) {
const h = req.headers.authorization;
if (!h || !h.startsWith('Bearer ')) return null;
try {
const decoded = jwt.verify(h.slice(7), process.env.JWT_SECRET, { algorithms: ['HS256'] });
return decoded.imp ? String(decoded.imp) : null;
} catch {
return null;
}
}
/** Whether this request may go ahead while impersonating. Pure. */
function allowedWhileImpersonating(method, path) {
if (SAFE_METHODS.includes(String(method).toUpperCase())) return true;
const p = String(path || '').split('?')[0].replace(/\/+$/, '');
return method.toUpperCase() === 'POST' && ALLOWED_WRITES.includes(p);
}
function logRequest(sessionId, method, path, blocked = false) {
prisma.impersonationRequestLog.create({ data: { sessionId, method, path: String(path).slice(0, 500), blocked } }).catch(() => {});
}
/** Layer 1 — mounted in index.js before every route (webhooks never carry user tokens). */
function readOnlyGuard(req, res, next) {
const sessionId = impersonationClaim(req);
if (!sessionId) return next();
const path = req.originalUrl || req.url;
if (!allowedWhileImpersonating(req.method, path)) {
logRequest(sessionId, req.method, path, true);
return res.status(403).json({ message: 'Read-only while viewing as another user. End impersonation to make changes.', code: 'IMPERSONATION_READ_ONLY' });
}
if (SAFE_METHODS.includes(req.method) && path.startsWith('/api/')) logRequest(sessionId, req.method, path);
next();
}
/** Layer 2 — the live session for an `imp` claim, or null when ended/expired. */
async function liveSession(sessionId) {
const s = await prisma.impersonationSession.findUnique({ where: { id: sessionId } });
if (!s || s.endedAt) return null;
if (s.expiresAt <= new Date()) {
await prisma.impersonationSession.updateMany({ where: { id: s.id, endedAt: null }, data: { endedAt: new Date(), endedReason: 'expired' } });
return null;
}
return s;
}
/** Why `admin` can't view as `target`, or null. Pure. */
function impersonationBlocker(admin, target) {
if (!admin || admin.role !== 'admin') return 'Only admins can view as another user.';
if (!admin.twoFactorEnabled) return 'Turn on two-factor authentication for your account first.';
if (!target) return 'User not found.';
if (target.id === admin.id) return "You can't view as yourself.";
if (target.role === 'admin') return "Admins can't be impersonated.";
if (!target.isActive) return 'That account is deactivated.';
return null;
}
module.exports = { MAX_MINUTES, impersonationClaim, allowedWhileImpersonating, readOnlyGuard, liveSession, impersonationBlocker, logRequest };
+2 -2
View File
@@ -533,7 +533,7 @@ function buildPaymentReceipt(payment) {
}
// Donation receipt
const subject = `Donation received – ${eventTitle}`;
const subject = payment.isSponsorship ? `Thank you for sponsoring a spot – ${eventTitle}` : `Donation received – ${eventTitle}`;
const preheader = `Thank you for your donation of ${fmtAmount(payment.amount)} to ${eventTitle}.`;
const body = `
<p style="font-size:22px;font-weight:800;color:#0f172a;margin:0 0 8px 0;letter-spacing:-0.3px">Thank you for your donation!</p>
@@ -541,7 +541,7 @@ function buildPaymentReceipt(payment) {
<p style="margin:0 0 4px 0;color:#374151;font-family:${ff}">Hi <strong>${payment.user?.name || 'there'}</strong>,</p>
<p style="margin:0 0 28px 0;color:#374151;font-family:${ff}">
We received your donation of <strong>${fmtAmount(payment.amount)}</strong> to <strong>${eventTitle}</strong>. Your support means the world to us.
We received your donation of <strong>${fmtAmount(payment.amount)}</strong> to <strong>${eventTitle}</strong>. Your support means the world to us.${payment.isSponsorship ? ' It goes into the sponsorship pool and helps someone attend who otherwise couldn’t.' : ''}
</p>
${callout(`<strong style="font-size:15px">${fmtAmount(payment.amount)} donated</strong><br/>
+5
View File
@@ -346,6 +346,11 @@ function computeRegistrationSubtotal(registration, lastPaymentAt, atTime) {
*/
function attachComputedTotals(registration) {
if (!registration) return registration;
// Never send which named attendees have an account: the person who typed their email or
// phone mustn't be able to tell (the link is used server-side only).
if (Array.isArray(registration.attendees)) {
registration.attendees = registration.attendees.map(({ userId, ...a }) => a);
}
let lastPaymentAt = null;
try {
if (registration.payments && Array.isArray(registration.payments) && registration.payments.length > 0) {
+218
View File
@@ -0,0 +1,218 @@
/**
* Offline scanning — locks, snapshots and syncing.
*
* Rules:
* - Offline mode is per section (or the whole event), and only one device may hold a
* scope at a time: while offline, devices can't see each other's scans, so two
* devices on the same scope could redeem one ticket twice. A whole-event lock clashes
* with every section of that event, and a section lock with the whole event.
* - While a scope is locked, other devices can't scan it online either.
* - The lock holder downloads a snapshot of valid tickets; scans made offline are
* queued on the device and synced later. Syncing is idempotent (each scan carries a
* clientScanId) and never redeems more than is left on a ticket.
* - Anything that needs a person to look — unknown ticket, cancelled registration, used
* up, outside the locked scope, consent not given — goes into an OfflineSyncReport.
* - A supervisor can force-release a lock (lost phone). If that device syncs later its
* scans are still recorded, and the report is flagged.
*/
const prisma = require('../config/db');
function lockError(message, statusCode = 400) {
const err = new Error(message);
err.statusCode = statusCode;
return err;
}
// ─── Pure helpers ─────────────────────────────────────────────────────────────
/** Whether a new lock on `sectionId` (null = whole event) clashes with `existing`. */
function locksClash(existing, sectionId) {
if (!existing.sectionId || !sectionId) return true;
return existing.sectionId === sectionId;
}
/**
* Whether `lock` covers a scan of a ticket with option `optionId`. When the scanner has
* picked a section, only a lock on that section (or the whole event) covers it; with no
* section picked, any lock on a section that admits the ticket does.
* @param {object} lock - { sectionId, section?: { allowedOptions: [{ eventOptionId }] } }
*/
function lockCovers(lock, { sectionId = null, optionId }) {
if (!lock.sectionId) return true;
if (sectionId) return sectionId === lock.sectionId;
return (lock.section?.allowedOptions || []).some(o => o.eventOptionId === optionId);
}
/**
* What to do with one synced scan, given the ticket as the server sees it now. Pure.
* @param {object|null} ticket - { remaining, cancelled, inScope, consentOk }
* @param {number} qty - quantity the device redeemed
* @returns {{ redeem: number, conflict: string|null }}
*/
function judgeSyncedScan(ticket, qty) {
if (!ticket) return { redeem: 0, conflict: 'not_found' };
if (ticket.transferred) return { redeem: 0, conflict: 'transferred' };
if (ticket.cancelled) return { redeem: 0, conflict: 'cancelled' };
if (!ticket.inScope) return { redeem: 0, conflict: 'wrong_scope' };
if (ticket.remaining <= 0) return { redeem: 0, conflict: 'already_used' };
const redeem = Math.min(Math.max(1, qty || 1), ticket.remaining);
if (!ticket.consentOk) return { redeem, conflict: 'consent' };
return { redeem, conflict: redeem < (qty || 1) ? 'partly_used' : null };
}
const CONFLICT_TEXT = {
not_found: 'Ticket not found',
transferred: 'Old QR code of a ticket that was transferred to someone else',
cancelled: 'Registration was cancelled',
wrong_scope: 'Ticket is not valid for this section',
already_used: 'Ticket had already been fully used',
partly_used: 'Fewer places were left than were checked in offline',
consent: 'Checked in without parental consent',
};
// ─── Database helpers ─────────────────────────────────────────────────────────
const LOCK_INCLUDE = {
section: { include: { allowedOptions: true } },
user: { select: { id: true, name: true } },
};
async function activeLocks(eventId, db = prisma) {
return db.scanLock.findMany({ where: { eventId, releasedAt: null }, include: LOCK_INCLUDE, orderBy: { lockedAt: 'asc' } });
}
/** Locks a scope to this device. Re-locking by the same device and person returns the existing lock. */
async function acquireLock({ eventId, sectionId = null, deviceId, deviceName, userId }) {
if (!deviceId || typeof deviceId !== 'string' || deviceId.length > 100) throw lockError('A device id is required');
return prisma.$transaction(async (tx) => {
await tx.$executeRaw`SELECT pg_advisory_xact_lock(hashtext(${'scanlock:' + eventId}))`;
const event = await tx.event.findUnique({ where: { id: eventId } });
if (!event) throw lockError('Event not found', 404);
if (sectionId) {
const section = await tx.section.findUnique({ where: { id: sectionId } });
if (!section || section.eventId !== eventId) throw lockError('Section not found for this event', 404);
}
const existing = await activeLocks(eventId, tx);
const same = existing.find(l => (l.sectionId || null) === (sectionId || null) && l.deviceId === deviceId && l.userId === userId);
if (same) return same;
const clash = existing.find(l => locksClash(l, sectionId));
if (clash) {
const scope = clash.section ? `"${clash.section.name}"` : 'the whole event';
throw lockError(`${scope} is offline-locked by ${clash.user.name}${clash.deviceName ? ` on ${clash.deviceName}` : ''}.`, 409);
}
return tx.scanLock.create({ data: { eventId, sectionId: sectionId || null, deviceId, deviceName: deviceName ? String(deviceName).slice(0, 100) : null, userId }, include: LOCK_INCLUDE });
});
}
/**
* The lock (other than the scanner's own device) that stops this online scan, or null.
* @param {object} ticket - { eventId, optionId }
*/
async function blockingLock(ticket, { sectionId = null, deviceId = null } = {}) {
const locks = await activeLocks(ticket.eventId);
return locks.find(l => l.deviceId !== deviceId && lockCovers(l, { sectionId, optionId: ticket.optionId })) || null;
}
/** Tickets valid in a lock's scope, for the device to keep offline. */
async function snapshotFor(lock) {
const optionIds = lock.section ? lock.section.allowedOptions.map(o => o.eventOptionId) : null;
const tickets = await prisma.ticket.findMany({
where: {
eventId: lock.eventId,
registrationOption: { registration: { status: { not: 'cancelled' } }, ...(optionIds ? { eventOptionId: { in: optionIds } } : {}) },
},
include: {
usages: { select: { quantityRedeemed: true } },
attendee: { select: { name: true } },
user: { select: { name: true } },
registrationOption: { include: { eventOption: { select: { id: true, name: true, isMainTicket: true } }, variant: { select: { name: true } } } },
},
});
const { consentStatusForTickets } = require('./consent');
const consent = await consentStatusForTickets(tickets.map(t => t.id));
const snapshotAt = new Date();
await prisma.scanLock.update({ where: { id: lock.id }, data: { snapshotAt } });
return {
snapshotAt,
tickets: tickets.map(t => {
const redeemed = t.usages.reduce((s, u) => s + (u.quantityRedeemed || 1), 0);
return {
id: t.id,
qrCode: t.qrCode,
holder: t.attendee?.name || t.user?.name || '',
bookedBy: t.attendee ? t.user?.name || '' : '',
option: t.registrationOption.eventOption.name,
variant: t.registrationOption.variant?.name || null,
quantity: t.quantity || 1,
remaining: Math.max(0, (t.quantity || 1) - redeemed),
consentStatus: consent.get(t.id) || 'not_required',
};
}),
};
}
/**
* Records queued offline scans. Each scan: { clientScanId, qrCode, qty, scannedAt }.
* @returns {Promise<{ accepted, duplicates, conflicts, report }>}
*/
async function syncScans(lock, scans) {
const results = { accepted: 0, duplicates: 0, conflicts: [] };
const optionIds = lock.section ? new Set(lock.section.allowedOptions.map(o => o.eventOptionId)) : null;
const { consentStatusForTickets } = require('./consent');
const checkIns = [];
for (const scan of (scans || []).slice(0, 2000)) {
const clientScanId = String(scan?.clientScanId || '').slice(0, 100);
if (!clientScanId) continue;
if (await prisma.ticketUsage.findUnique({ where: { clientScanId } })) { results.duplicates += 1; continue; }
const qty = Math.max(1, parseInt(scan.qty, 10) || 1);
const scannedAt = scan.scannedAt && !isNaN(new Date(scan.scannedAt)) ? new Date(scan.scannedAt) : new Date();
const ticket = await prisma.ticket.findUnique({
where: { qrCode: String(scan.qrCode || '') },
include: { usages: true, registrationOption: { include: { registration: { select: { status: true } }, eventOption: { select: { isMainTicket: true } } } } },
});
let state = null;
if (!ticket) {
const { findTransferByOldCode } = require('./ticketTransfer');
if (await findTransferByOldCode(scan.qrCode)) state = { transferred: true };
} else if (ticket.eventId === lock.eventId) {
const redeemed = ticket.usages.reduce((s, u) => s + (u.quantityRedeemed || 1), 0);
const consent = (await consentStatusForTickets([ticket.id])).get(ticket.id);
state = {
remaining: (ticket.quantity || 1) - redeemed,
cancelled: ticket.registrationOption.registration.status === 'cancelled',
inScope: !optionIds || optionIds.has(ticket.registrationOption.eventOptionId),
consentOk: ['not_required', 'given'].includes(consent),
};
}
const verdict = judgeSyncedScan(state, qty);
if (verdict.redeem > 0) {
await prisma.ticketUsage.create({
data: { ticketId: ticket.id, scannedById: lock.userId, scannedAt, quantityRedeemed: verdict.redeem, clientScanId, offline: true, scanLockId: lock.id, syncedAt: new Date() },
});
await prisma.ticket.update({ where: { id: ticket.id }, data: { isUsed: state.remaining - verdict.redeem <= 0, updatedAt: new Date() } });
results.accepted += 1;
if (ticket.registrationOption.eventOption.isMainTicket) {
checkIns.push({ ticketId: ticket.id, qty: verdict.redeem, total: (ticket.quantity || 1) - state.remaining + verdict.redeem, left: state.remaining - verdict.redeem });
}
}
if (verdict.conflict) {
results.conflicts.push({ clientScanId, qrCode: scan.qrCode, ticketId: ticket?.id || null, qty, redeemed: verdict.redeem, scannedAt, reason: verdict.conflict, message: CONFLICT_TEXT[verdict.conflict] });
}
}
await prisma.scanLock.update({ where: { id: lock.id }, data: { lastSyncAt: new Date() } });
const report = await prisma.offlineSyncReport.create({
data: { scanLockId: lock.id, accepted: results.accepted, duplicates: results.duplicates, conflicts: results.conflicts, afterForceRelease: !!lock.forceReleasedById },
});
// Check-in confirmations go out after sync, not at the moment of the offline scan.
const { sendCheckInEmails } = require('./notifications');
for (const c of checkIns) sendCheckInEmails(c.ticketId, c.qty, c.total, c.left).catch(() => {});
return { ...results, report };
}
module.exports = { lockError, locksClash, lockCovers, judgeSyncedScan, CONFLICT_TEXT, LOCK_INCLUDE, activeLocks, acquireLock, blockingLock, snapshotFor, syncScans };
+72
View File
@@ -0,0 +1,72 @@
/**
* Sponsor a spot — public donations into an event's bursary pool.
*
* A sponsorship is an ordinary event donation (isDonation) also flagged isSponsorship,
* so supervisors hand it out with the existing assign/unassign-donation flow. These
* helpers are pure: what one spot costs, what a sponsor is charged, and the pool totals.
*/
const { computeDonationRemaining } = require('./donationUtils');
const MIN_AMOUNT = 15; // Yoco's minimum charge, same as other donations
const MAX_SPOTS = 100;
const MAX_AMOUNT = 100000;
function sponsorshipError(message, statusCode = 400) {
const err = new Error(message);
err.statusCode = statusCode;
return err;
}
/** The price of one spot: the cheapest paid main ticket (or the event price), or null if free. */
function spotPrice(event) {
const prices = (event.eventOptions || [])
.filter(o => o.isMainTicket && Number(o.price) > 0)
.map(o => Number(o.price));
if (prices.length) return Math.min(...prices);
return Number(event.price) > 0 ? Number(event.price) : null;
}
/**
* What the sponsor pays: `spots` whole spots, or a custom `amount`.
* @returns {{ amount: number, spots: number|null }}
*/
function sponsorshipCharge({ spots, amount }, price) {
if (spots !== undefined && spots !== null && spots !== '') {
const n = Number(spots);
if (!Number.isInteger(n) || n < 1 || n > MAX_SPOTS) throw sponsorshipError(`Choose between 1 and ${MAX_SPOTS} spots.`);
if (!price) throw sponsorshipError('This event has no ticket price — enter an amount instead.');
return { amount: Math.round(n * price * 100) / 100, spots: n };
}
const a = Math.round(Number(amount) * 100) / 100;
if (!Number.isFinite(a) || a <= 0) throw sponsorshipError('Enter an amount, or choose a number of spots.');
if (a < MIN_AMOUNT) throw sponsorshipError(`The minimum is R${MIN_AMOUNT}.`);
if (a > MAX_AMOUNT) throw sponsorshipError(`For gifts over R${MAX_AMOUNT.toLocaleString('en-ZA')}, please contact us directly.`);
return { amount: a, spots: null };
}
/**
* Pool totals for an event.
* @param {Array} sponsorships - sponsorship donation payments [{ id, amount }]
* @param {Array} legs - payments with originalPaymentId set (assignments, refunds)
* @param {number|null} price - one spot
*/
function poolSummary(sponsorships, legs, price) {
const legsBy = new Map();
for (const l of legs || []) legsBy.set(l.originalPaymentId, [...(legsBy.get(l.originalPaymentId) || []), l]);
let received = 0, remaining = 0;
for (const s of sponsorships || []) {
received += Number(s.amount) || 0;
remaining += Math.max(0, computeDonationRemaining(s.amount, legsBy.get(s.id) || []));
}
const round = (n) => Math.round(n * 100) / 100;
return {
received: round(received),
assigned: round(received - remaining),
remaining: round(remaining),
sponsors: (sponsorships || []).length,
spotsFunded: price ? Math.floor((received + 0.001) / price) : null,
};
}
module.exports = { MIN_AMOUNT, MAX_SPOTS, sponsorshipError, spotPrice, sponsorshipCharge, poolSummary };
+291
View File
@@ -0,0 +1,291 @@
/**
* Ticket transfer — handing a ticket to someone else before the event.
*
* Privacy first: the sender only types the new holder's name and email/phone — there is
* no user search, and nothing tells the sender whether those details belong to an
* account. A TicketTransferRequest is created and a link is sent to the details typed;
* nothing changes on the ticket until the new holder accepts (requestTransfer →
* acceptTransfer). Accepting runs the transfer itself:
*
* - The new holder becomes the ticket's attendee (name, contact details, account). The
* payer stays responsible for the registration; billing doesn't move.
* - The QR code is replaced, so screenshots of the old ticket stop working. The old code
* is kept on the TicketTransfer so scanners can say "transferred" instead of "not found".
* - A shared (unnamed) ticket for several places has one place split off into a new
* ticket for the new holder; the rest stay on the shared ticket and its code.
* - The previous holder's attendee form answers are removed, and on youth events the new
* holder needs their own parental consent.
*/
const { v4: uuidv4 } = require('uuid');
const prisma = require('../config/db');
const { validateAttendees } = require('./attendees');
const { isMinor } = require('./consent');
const SUPERVISORS = ['admin', 'supervisor'];
const OFFER_DAYS = 7;
const crypto = require('crypto');
const hashToken = (token) => crypto.createHmac('sha256', process.env.JWT_SECRET || '').update(`transfer:${token}`).digest('hex');
function transferLink(token) {
const base = (process.env.FRONTEND_URL || process.env.APP_BASE_URL || 'http://localhost:3001').split(',')[0].trim().replace(/\/$/, '');
return `${base}/transfer/${token}`;
}
function transferError(message, statusCode = 400) {
const err = new Error(message);
err.statusCode = statusCode;
return err;
}
/**
* Why this ticket can't be transferred by `actor`, or null when it can. Pure.
* @param {object} t - { userId, holderUserId, usages[], event: { startDate, allowTicketTransfer }, registrationStatus }
* @param {object} actor - { id, role }
*/
function transferBlocker(t, actor, now = new Date()) {
const supervisor = SUPERVISORS.includes(actor.role);
if (!supervisor && t.userId !== actor.id && t.holderUserId !== actor.id) return { status: 403, message: 'Only the person who booked or holds this ticket can transfer it.' };
if (t.registrationStatus === 'cancelled') return { status: 400, message: 'This registration was cancelled.' };
if (new Date(t.event.startDate) <= now) return { status: 400, message: 'Tickets can only be transferred before the event starts.' };
if ((t.usages || []).length > 0) return { status: 400, message: 'This ticket has already been used (or partly used), so it can’t be transferred.' };
if (t.event.allowTicketTransfer === false && !supervisor) return { status: 400, message: 'Ticket transfers are turned off for this event.' };
return null;
}
/**
* Transfers a ticket. Returns what the caller needs to notify people afterwards.
* @param {object} input - { ticketId, actor, name, email?, phone?, dateOfBirth?, parentName?, parentEmail?, parentPhone? }
*/
async function transferTicket({ ticketId, actor, ...holder }) {
const { findOrCreateAccount } = require('./accountMatch');
const ticket = await prisma.ticket.findUnique({
where: { id: ticketId },
include: {
usages: { select: { id: true } },
attendee: true,
holder: { select: { id: true, name: true } },
user: { select: { id: true, name: true } },
event: { include: { eventOptions: true } },
registrationOption: { include: { registration: { select: { id: true, status: true } } } },
},
});
if (!ticket) throw transferError('Ticket not found', 404);
const blocker = transferBlocker({ ...ticket, registrationStatus: ticket.registrationOption.registration.status }, actor);
if (blocker) throw transferError(blocker.message, blocker.status);
const ro = ticket.registrationOption;
const event = ticket.event;
// Same rules as naming an attendee at registration (incl. date of birth and parent
// details on youth events), for exactly one place.
const [clean] = validateAttendees({ ...event, allowNamedAttendees: true }, { [`${ro.eventOptionId}::${ro.variantId || ''}`]: 1 }, [{
eventOptionId: ro.eventOptionId, variantId: ro.variantId, ...holder,
}]);
if (!clean.email && !clean.phone) throw transferError('Enter an email address or phone number for the new holder, so we can send them the ticket.');
const { user: toUser, created } = await findOrCreateAccount({ name: clean.name, email: clean.email, phone: clean.phone });
const currentHolderId = ticket.attendee ? ticket.attendee.userId || ticket.holderUserId : ticket.holderUserId || ticket.userId;
if (currentHolderId && currentHolderId === toUser.id) throw transferError('This ticket is already yours.');
const fromHolderName = ticket.attendee?.name || ticket.holder?.name || ticket.user?.name || '';
const attendeeData = {
name: clean.name, email: clean.email, phone: clean.phone, userId: toUser.id,
dateOfBirth: clean.dateOfBirth, gender: clean.gender, parentName: clean.parentName, parentEmail: clean.parentEmail, parentPhone: clean.parentPhone,
};
const result = await prisma.$transaction(async (tx) => {
// Re-check inside the transaction: a scan or another transfer may have just happened.
const fresh = await tx.ticket.findUnique({ where: { id: ticket.id }, include: { usages: { select: { id: true } } } });
if (!fresh || fresh.qrCode !== ticket.qrCode || fresh.usages.length > 0) throw transferError('This ticket changed while you were transferring it. Please try again.', 409);
let target, attendee, oldQrCode;
if (ticket.attendeeId) {
attendee = await tx.attendee.update({ where: { id: ticket.attendeeId }, data: attendeeData });
// The previous holder's answers and medical details aren't the new holder's.
await tx.formResponse.deleteMany({ where: { attendeeId: attendee.id } });
await tx.consentRequest.updateMany({ where: { attendeeId: attendee.id }, data: { medical: null, emergency: null, medicalPurgedAt: new Date() } });
target = await tx.ticket.update({ where: { id: ticket.id }, data: { qrCode: uuidv4(), holderUserId: toUser.id, emailSent: false } });
oldQrCode = ticket.qrCode;
} else {
const order = await tx.attendee.count({ where: { registrationId: ro.registration.id } });
attendee = await tx.attendee.create({
data: { registrationId: ro.registration.id, registrationOptionId: ro.id, eventOptionId: ro.eventOptionId, variantId: ro.variantId, order, ...attendeeData },
});
if ((ticket.quantity || 1) > 1) {
// Split one place off the shared ticket; its code stays valid for the rest.
await tx.ticket.update({ where: { id: ticket.id }, data: { quantity: ticket.quantity - 1 } });
target = await tx.ticket.create({
data: { qrCode: uuidv4(), registrationOptionId: ro.id, userId: ticket.userId, eventId: ticket.eventId, quantity: 1, attendeeId: attendee.id, holderUserId: toUser.id },
});
oldQrCode = null;
} else {
target = await tx.ticket.update({ where: { id: ticket.id }, data: { qrCode: uuidv4(), attendeeId: attendee.id, holderUserId: toUser.id, emailSent: false } });
oldQrCode = ticket.qrCode;
}
}
const transfer = await tx.ticketTransfer.create({
data: { ticketId: target.id, fromHolderName, fromUserId: currentHolderId || null, toName: clean.name, toUserId: toUser.id, byUserId: actor.id, oldQrCode },
});
return { target, attendee, transfer };
});
return {
...result,
toUser,
accountCreated: created,
fromHolderName,
event,
registrationId: ro.registration.id,
needsConsent: !!event.requiresParentalConsent && isMinor(clean.dateOfBirth, event.startDate),
};
}
/** Checks a transfer the way transferTicket will, without touching anything. */
async function checkTransfer({ ticketId, actor, ...holder }) {
const ticket = await prisma.ticket.findUnique({
where: { id: ticketId },
include: {
usages: { select: { id: true } },
attendee: true, holder: { select: { name: true } }, user: { select: { name: true } },
event: { include: { eventOptions: true } },
registrationOption: { include: { eventOption: { select: { name: true } }, registration: { select: { id: true, status: true } } } },
},
});
if (!ticket) throw transferError('Ticket not found', 404);
const blocker = transferBlocker({ ...ticket, registrationStatus: ticket.registrationOption.registration.status }, actor);
if (blocker) throw transferError(blocker.message, blocker.status);
const ro = ticket.registrationOption;
const [clean] = validateAttendees({ ...ticket.event, allowNamedAttendees: true }, { [`${ro.eventOptionId}::${ro.variantId || ''}`]: 1 }, [{
eventOptionId: ro.eventOptionId, variantId: ro.variantId, ...holder,
}]);
if (!clean.email && !clean.phone) throw transferError('Enter an email address or phone number for the new holder, so we can send them the ticket.');
return { ticket, clean };
}
async function sendTransferOffer(request, ticket, token, fromName) {
const { sendMail, emailWrapper, ctaButton, fallbackLink } = require('./email');
const { waTextAny } = require('./notify');
const { isValidZAPhone } = require('./whatsapp');
const ff = `-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif`;
const link = transferLink(token);
const event = ticket.event;
const when = new Date(event.startDate).toLocaleDateString('en-GB', { day: 'numeric', month: 'long', year: 'numeric' });
const subject = `${fromName} wants to give you a ticket for ${event.title}`;
const sent = [];
if (request.toEmail) {
const html = emailWrapper(`
<p style="font-size:22px;font-weight:800;color:#0f172a;margin:0 0 8px 0">You've been offered a ticket</p>
<p style="margin:0 0 8px 0;color:#374151;font-family:${ff}">Hi <strong>${request.toName}</strong>,</p>
<p style="margin:0 0 24px 0;color:#374151;font-family:${ff}">${fromName} would like to give you their ticket for <strong>${event.title}</strong> on <strong>${when}</strong>. Accept it to get your own ticket and QR code.</p>
${ctaButton('View and accept the ticket', link)}
${fallbackLink(link)}
<p style="font-size:13px;color:#64748b;margin:16px 0 0 0;font-family:${ff}">If you weren't expecting this, you can ignore this email or decline from the link.</p>
`, { preheader: subject });
try {
await sendMail({ to: request.toEmail, subject, html, text: `Hi ${request.toName},\n\n${fromName} would like to give you their ticket for ${event.title} on ${when}. View and accept it here: ${link}` });
sent.push('email');
} catch (e) {
console.warn('[transfer] offer email failed:', e?.message || e);
}
}
if (request.toPhone && isValidZAPhone(request.toPhone)) {
await waTextAny({ name: request.toName, phoneNumber: request.toPhone }, `Hi ${request.toName}, ${fromName} would like to give you their ticket for *${event.title}* on ${when}. View and accept it here: ${link}`);
sent.push('WhatsApp');
}
return sent;
}
/**
* Starts a transfer: checks it, then sends the new holder a link to accept. Nothing on
* the ticket changes yet, and no account is looked up.
*/
async function requestTransfer({ ticketId, actor, ...holder }) {
const { ticket, clean } = await checkTransfer({ ticketId, actor, ...holder });
const token = crypto.randomBytes(24).toString('hex');
const expiresAt = new Date(Math.min(new Date(ticket.event.startDate).getTime(), Date.now() + OFFER_DAYS * 864e5));
const request = await prisma.$transaction(async (tx) => {
// One open offer per ticket: a new one replaces the old.
await tx.ticketTransferRequest.updateMany({ where: { ticketId: ticket.id, status: 'pending' }, data: { status: 'cancelled', respondedAt: new Date() } });
return tx.ticketTransferRequest.create({
data: {
ticketId: ticket.id, requestedById: actor.id, toName: clean.name, toEmail: clean.email, toPhone: clean.phone,
details: { dateOfBirth: clean.dateOfBirth, gender: clean.gender, parentName: clean.parentName, parentEmail: clean.parentEmail, parentPhone: clean.parentPhone },
tokenHash: hashToken(token), expiresAt,
},
});
});
const fromName = ticket.attendee?.name || ticket.holder?.name || ticket.user?.name || actor.name || 'Someone';
const sent = await sendTransferOffer(request, ticket, token, fromName);
return { request, sent };
}
async function findRequestByToken(token) {
if (!token || typeof token !== 'string' || token.length > 200) return null;
const request = await prisma.ticketTransferRequest.findUnique({
where: { tokenHash: hashToken(token) },
include: {
requestedBy: { select: { id: true, name: true, role: true } },
ticket: { include: { event: { select: { id: true, title: true, startDate: true, location: true } }, registrationOption: { include: { eventOption: { select: { name: true } }, variant: { select: { name: true } } } }, attendee: { select: { name: true } }, user: { select: { name: true } } } },
},
});
if (request && request.status === 'pending' && request.expiresAt <= new Date()) {
await prisma.ticketTransferRequest.update({ where: { id: request.id }, data: { status: 'expired' } });
request.status = 'expired';
}
return request;
}
/** The new holder accepts: the transfer happens now, with every check run again. */
async function acceptTransfer(token) {
const request = await findRequestByToken(token);
if (!request) throw transferError('This transfer link isn’t valid.', 404);
if (request.status !== 'pending') throw transferError(`This transfer has already been ${request.status}.`, 409);
// Claim it first so a double-click can't run the transfer twice.
const claimed = await prisma.ticketTransferRequest.updateMany({ where: { id: request.id, status: 'pending' }, data: { status: 'accepted', respondedAt: new Date() } });
if (claimed.count !== 1) throw transferError('This transfer has already been answered.', 409);
try {
const d = request.details || {};
const result = await transferTicket({
ticketId: request.ticketId, actor: request.requestedBy,
name: request.toName, email: request.toEmail, phone: request.toPhone,
dateOfBirth: d.dateOfBirth, gender: d.gender, parentName: d.parentName, parentEmail: d.parentEmail, parentPhone: d.parentPhone,
});
return { ...result, request };
} catch (e) {
// Couldn't go ahead (e.g. the ticket was scanned meanwhile): the offer is void.
await prisma.ticketTransferRequest.update({ where: { id: request.id }, data: { status: 'cancelled' } }).catch(() => {});
throw e;
}
}
async function declineTransfer(token) {
const request = await findRequestByToken(token);
if (!request) throw transferError('This transfer link isn’t valid.', 404);
if (request.status !== 'pending') throw transferError(`This transfer has already been ${request.status}.`, 409);
await prisma.ticketTransferRequest.update({ where: { id: request.id }, data: { status: 'declined', respondedAt: new Date() } });
return request;
}
/** The sender (or a supervisor) withdraws an offer that hasn't been accepted yet. */
async function cancelTransfer(ticketId, actor) {
const ticket = await prisma.ticket.findUnique({ where: { id: ticketId }, select: { userId: true, holderUserId: true } });
if (!ticket) throw transferError('Ticket not found', 404);
if (!SUPERVISORS.includes(actor.role) && ticket.userId !== actor.id && ticket.holderUserId !== actor.id) throw transferError('Not authorized', 403);
const { count } = await prisma.ticketTransferRequest.updateMany({ where: { ticketId, status: 'pending' }, data: { status: 'cancelled', respondedAt: new Date() } });
if (!count) throw transferError('There is no transfer waiting for this ticket.', 404);
}
/** The transfer that retired this QR code, if any. */
async function findTransferByOldCode(qrCode) {
if (!qrCode) return null;
return prisma.ticketTransfer.findFirst({ where: { oldQrCode: String(qrCode) }, orderBy: { createdAt: 'desc' } });
}
function transferredMessage(transfer) {
const when = new Date(transfer.createdAt).toLocaleDateString('en-ZA', { day: 'numeric', month: 'short' });
return `This ticket was transferred to ${transfer.toName} on ${when}. The old QR code no longer works — ask for the new ticket.`;
}
module.exports = {
transferError, transferBlocker, transferTicket, findTransferByOldCode, transferredMessage,
checkTransfer, requestTransfer, findRequestByToken, acceptTransfer, declineTransfer, cancelTransfer,
};
+84
View File
@@ -0,0 +1,84 @@
const { joinBlocker, autoAllocate, cleanGender } = require('../src/utils/allocation');
const person = (id, registrationId, gender = null) => ({ id, name: id, registrationId, gender });
const groupsOf = (result) => {
const out = {};
for (const p of result.placements) (out[p.groupId] = out[p.groupId] || []).push(p.attendeeId);
return out;
};
describe('cleanGender', () => {
test('accepts male/female in any case, blank as null', () => {
expect(cleanGender('Male')).toBe('male');
expect(cleanGender('')).toBeNull();
expect(() => cleanGender('other')).toThrow();
});
});
describe('joinBlocker', () => {
test('capacity', () => {
expect(joinBlocker({ name: 'Cabin 1', capacity: 2 }, 1, [person('a', 'r')])).toBeNull();
expect(joinBlocker({ name: 'Cabin 1', capacity: 2 }, 2, [person('a', 'r')])).toMatch(/full/);
});
test('gender', () => {
expect(joinBlocker({ name: 'Boys', gender: 'male' }, 0, [person('a', 'r', 'male')])).toBeNull();
expect(joinBlocker({ name: 'Boys', gender: 'male' }, 0, [person('a', 'r', 'female')])).toMatch(/males only/);
expect(joinBlocker({ name: 'Boys', gender: 'male' }, 0, [person('a', 'r')])).toMatch(/no gender recorded/);
});
});
describe('autoAllocate', () => {
test('fills groups evenly', () => {
const groups = [{ id: 'g1', name: 'A' }, { id: 'g2', name: 'B' }];
const attendees = ['a', 'b', 'c', 'd'].map(id => person(id, id));
const r = autoAllocate({ groups, attendees });
const g = groupsOf(r);
expect(g.g1.length).toBe(2);
expect(g.g2.length).toBe(2);
expect(r.unplaced).toEqual([]);
});
test('respects capacity and reports who could not be placed', () => {
const groups = [{ id: 'g1', name: 'A', capacity: 1 }];
const r = autoAllocate({ groups, attendees: [person('a', 'r1'), person('b', 'r2')] });
expect(r.placements.length).toBe(1);
expect(r.unplaced.length).toBe(1);
});
test('respects gender', () => {
const groups = [{ id: 'boys', name: 'Boys', gender: 'male' }, { id: 'girls', name: 'Girls', gender: 'female' }];
const r = autoAllocate({ groups, attendees: [person('a', 'r1', 'male'), person('b', 'r2', 'female'), person('c', 'r3')] });
expect(groupsOf(r)).toEqual({ boys: ['a'], girls: ['b'] });
expect(r.unplaced).toEqual(['c']);
});
test('keeps a registration together when a group can take them all', () => {
const groups = [{ id: 'g1', name: 'A', capacity: 3 }, { id: 'g2', name: 'B', capacity: 3 }];
const attendees = [person('s1', 'fam'), person('s2', 'fam'), person('x', 'solo')];
const g = groupsOf(autoAllocate({ groups, attendees, mode: 'together' }));
const famGroup = Object.keys(g).find(k => g[k].includes('s1'));
expect(g[famGroup]).toContain('s2');
});
test('places siblings one by one when no group fits them all', () => {
const groups = [{ id: 'boys', name: 'Boys', gender: 'male' }, { id: 'girls', name: 'Girls', gender: 'female' }];
const r = autoAllocate({ groups, attendees: [person('bro', 'fam', 'male'), person('sis', 'fam', 'female')], mode: 'together' });
expect(groupsOf(r)).toEqual({ boys: ['bro'], girls: ['sis'] });
});
test('split mode spreads a registration across groups', () => {
const groups = [{ id: 'g1', name: 'A' }, { id: 'g2', name: 'B' }];
const attendees = [person('f1', 'friends'), person('f2', 'friends')];
const g = groupsOf(autoAllocate({ groups, attendees, mode: 'split' }));
expect(g.g1.length).toBe(1);
expect(g.g2.length).toBe(1);
});
test('counts people already placed and leaves them where they are', () => {
const groups = [{ id: 'g1', name: 'A', capacity: 2 }, { id: 'g2', name: 'B', capacity: 2 }];
const attendees = [person('a', 'r1'), person('b', 'r2'), person('c', 'r3')];
const r = autoAllocate({ groups, attendees, assigned: new Map([['a', 'g1'], ['b', 'g1']]) });
expect(r.placements).toEqual([{ attendeeId: 'c', groupId: 'g2' }]);
});
});
+70
View File
@@ -0,0 +1,70 @@
const { deviceFromUA, cleanSource, sessionsFrom, rollupRows, funnelReport } = require('../src/utils/analytics');
const ev = (sessionId, stage, day, extra = {}) => ({ sessionId, stage, createdAt: `${day}T10:00:00Z`, ...extra });
describe('deviceFromUA', () => {
test('mobile, tablet, desktop and bots', () => {
expect(deviceFromUA('Mozilla/5.0 (iPhone; CPU iPhone OS 17_0) Mobile/15E148')).toBe('mobile');
expect(deviceFromUA('Mozilla/5.0 (Linux; Android 14; SM-X200) Safari')).toBe('tablet');
expect(deviceFromUA('Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/120')).toBe('desktop');
expect(deviceFromUA('Googlebot/2.1')).toBeNull();
expect(deviceFromUA('')).toBeNull();
});
});
describe('cleanSource', () => {
test('tidies, defaulting to direct', () => {
expect(cleanSource(' Facebook ')).toBe('facebook');
expect(cleanSource('camp-2025<script>')).toBe('camp-2025script');
expect(cleanSource('')).toBe('direct');
});
});
describe('sessionsFrom', () => {
test('a session counts as having reached every earlier stage', () => {
const [s] = sessionsFrom([ev('a', 'start', '2026-01-02')]);
expect(s.reach).toBe(1);
expect(s.days.view).toBe('2026-01-02');
});
test('paid only counts for sessions that submitted', () => {
const out = sessionsFrom([ev('a', 'view', '2026-01-01', { source: 'fb', device: 'mobile' }), ev('a', 'submit', '2026-01-03'), ev('b', 'view', '2026-01-01')], new Set(['a', 'b']));
const a = out.find(s => s.sessionId === 'a');
expect(a.reach).toBe(3);
expect(a.days.paid).toBe('2026-01-03');
expect(a.source).toBe('fb');
expect(out.find(s => s.sessionId === 'b').reach).toBe(0);
});
});
describe('funnelReport', () => {
const sessions = sessionsFrom([
ev('a', 'view', '2026-01-01', { source: 'fb', device: 'mobile' }), ev('a', 'start', '2026-01-01'), ev('a', 'submit', '2026-01-01'),
ev('b', 'view', '2026-01-01', { source: 'fb', device: 'desktop' }), ev('b', 'start', '2026-01-02'),
ev('c', 'view', '2026-01-02', { device: 'mobile' }),
ev('d', 'view', '2026-01-02', { device: 'mobile' }),
], new Set(['a']));
test('counts and conversion per stage', () => {
const r = funnelReport(sessions);
expect(r.totals).toEqual({ view: 4, start: 2, submit: 1, paid: 1 });
expect(r.conversion.start).toBe(50);
expect(r.conversion.paid).toBe(25);
expect(r.conversion.startToSubmit).toBe(50);
});
test('daily trend and breakdowns', () => {
const r = funnelReport(sessions);
expect(r.trend).toEqual([{ day: '2026-01-01', view: 2, start: 1, submit: 1, paid: 1 }, { day: '2026-01-02', view: 2, start: 1, submit: 0, paid: 0 }]);
expect(r.bySource.find(s => s.key === 'fb')).toMatchObject({ view: 2, paid: 1, conversion: 50 });
expect(r.bySource.find(s => s.key === 'direct').view).toBe(2);
expect(r.byDevice.find(s => s.key === 'mobile').view).toBe(3);
});
test('rolled-up rows add to the raw counts', () => {
const daily = rollupRows(sessionsFrom([ev('x', 'view', '2025-10-01'), ev('x', 'submit', '2025-10-01')], new Set(['x'])));
expect(daily.find(r => r.stage === 'paid').sessions).toBe(1);
const r = funnelReport(sessions, daily);
expect(r.totals).toEqual({ view: 5, start: 3, submit: 2, paid: 2 });
});
});
+68
View File
@@ -0,0 +1,68 @@
const { cleanFields, cleanAnswer, summarise } = require('../src/utils/feedback');
describe('cleanFields', () => {
test('keeps labelled questions in order, tidying choices', () => {
const out = cleanFields([
{ type: 'rating', label: ' Overall ', isRequired: true },
{ type: 'text', label: '' },
{ type: 'multiple_choice', label: 'Best part', options: ['Worship', ' Talks ', 'Worship', ''] },
{ type: 'statement', label: 'Thanks!', isRequired: true },
]);
expect(out.map(f => [f.type, f.label, f.order])).toEqual([['rating', 'Overall', 0], ['multiple_choice', 'Best part', 1], ['statement', 'Thanks!', 2]]);
expect(out[1].options).toEqual(['Worship', 'Talks']);
expect(out[2].isRequired).toBe(false);
});
test('rejects bad forms', () => {
expect(() => cleanFields([{ type: 'stars', label: 'X' }])).toThrow(/unknown type/);
expect(() => cleanFields([{ type: 'multiple_choice', label: 'X', options: ['only one'] }])).toThrow(/two choices/);
expect(() => cleanFields([{ type: 'statement', label: 'Hello' }])).toThrow(/at least one question/);
});
});
describe('cleanAnswer', () => {
test('ratings are whole stars from 1 to 5', () => {
expect(cleanAnswer({ type: 'rating', label: 'R' }, '4')).toBe('4');
expect(() => cleanAnswer({ type: 'rating', label: 'R' }, '6')).toThrow();
expect(() => cleanAnswer({ type: 'rating', label: 'R' }, '3.5')).toThrow();
});
test('multiple choice must be one of the options', () => {
const f = { type: 'multiple_choice', label: 'M', options: ['A', 'B'] };
expect(cleanAnswer(f, 'B')).toBe('B');
expect(() => cleanAnswer(f, 'C')).toThrow();
});
test('required questions need an answer; optional ones may be blank', () => {
expect(() => cleanAnswer({ type: 'text', label: 'Why?', isRequired: true }, ' ')).toThrow(/Please answer/);
expect(cleanAnswer({ type: 'text', label: 'Why?' }, '')).toBeNull();
expect(cleanAnswer({ type: 'yes_no', label: 'Again?' }, 'YES')).toBe('yes');
});
});
describe('summarise', () => {
const fields = [
{ id: 'r', type: 'rating', label: 'Overall' },
{ id: 'm', type: 'multiple_choice', label: 'Best part', options: ['Worship', 'Talks'] },
{ id: 't', type: 'text', label: 'Comments' },
{ id: 's', type: 'statement', label: 'Thanks' },
];
const answers = [
{ fieldId: 'r', value: '5' }, { fieldId: 'r', value: '4' }, { fieldId: 'r', value: '4' },
{ fieldId: 'm', value: 'Talks' }, { fieldId: 'm', value: 'Talks' }, { fieldId: 'm', value: 'Worship' },
{ fieldId: 't', value: 'Loved it', name: 'Ann' },
];
test('averages ratings with a star distribution', () => {
const [r] = summarise(fields, answers);
expect(r.average).toBe(4.3);
expect(r.distribution.map(d => d.value)).toEqual([0, 0, 0, 2, 1]);
});
test('counts choices and lists text answers; skips statements', () => {
const out = summarise(fields, answers);
expect(out.length).toBe(3);
expect(out[1].counts).toEqual([{ label: 'Worship', value: 1 }, { label: 'Talks', value: 2 }]);
expect(out[2].texts).toEqual([{ value: 'Loved it', name: 'Ann' }]);
});
});
+43
View File
@@ -0,0 +1,43 @@
const { allowedWhileImpersonating, impersonationBlocker } = require('../src/utils/impersonation');
describe('allowedWhileImpersonating', () => {
test('reads are allowed', () => {
expect(allowedWhileImpersonating('GET', '/api/registrations/myregistrations')).toBe(true);
expect(allowedWhileImpersonating('HEAD', '/api/events')).toBe(true);
expect(allowedWhileImpersonating('OPTIONS', '/api/payments')).toBe(true);
});
test('every write is refused', () => {
for (const m of ['POST', 'PUT', 'PATCH', 'DELETE']) {
expect(allowedWhileImpersonating(m, '/api/registrations')).toBe(false);
expect(allowedWhileImpersonating(m, '/api/payments/yoco-checkout')).toBe(false);
expect(allowedWhileImpersonating(m, '/api/users/profile')).toBe(false);
}
});
test('except ending the session and logging page views', () => {
expect(allowedWhileImpersonating('POST', '/api/admin/impersonate/end')).toBe(true);
expect(allowedWhileImpersonating('POST', '/api/admin/impersonate/log?x=1')).toBe(true);
expect(allowedWhileImpersonating('DELETE', '/api/admin/impersonate/end')).toBe(false);
expect(allowedWhileImpersonating('POST', '/api/admin/impersonate/end/../../users')).toBe(false);
});
});
describe('impersonationBlocker', () => {
const admin = { id: 'a', role: 'admin', twoFactorEnabled: true };
const user = { id: 'u', role: 'user', isActive: true };
test('an admin with 2FA can view a user', () => {
expect(impersonationBlocker(admin, user)).toBeNull();
expect(impersonationBlocker(admin, { ...user, role: 'supervisor' })).toBeNull();
});
test('refusals', () => {
expect(impersonationBlocker({ ...admin, role: 'supervisor' }, user)).toMatch(/Only admins/);
expect(impersonationBlocker({ ...admin, twoFactorEnabled: false }, user)).toMatch(/two-factor/);
expect(impersonationBlocker(admin, { ...user, role: 'admin' })).toMatch(/Admins can't/);
expect(impersonationBlocker(admin, { ...admin })).toMatch(/yourself/);
expect(impersonationBlocker(admin, { ...user, isActive: false })).toMatch(/deactivated/);
expect(impersonationBlocker(admin, null)).toMatch(/not found/);
});
});
+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' });
});
});
+44
View File
@@ -0,0 +1,44 @@
const { spotPrice, sponsorshipCharge, poolSummary } = require('../src/utils/sponsorship');
describe('spotPrice', () => {
test('is the cheapest paid main ticket', () => {
expect(spotPrice({ price: 0, eventOptions: [
{ isMainTicket: true, price: 450 }, { isMainTicket: true, price: 300 }, { isMainTicket: false, price: 50 }, { isMainTicket: true, price: 0 },
] })).toBe(300);
});
test('falls back to the event price, or null for free events', () => {
expect(spotPrice({ price: 200, eventOptions: [] })).toBe(200);
expect(spotPrice({ price: 0, eventOptions: [{ isMainTicket: true, price: 0 }] })).toBeNull();
});
});
describe('sponsorshipCharge', () => {
test('whole spots cost the spot price each', () => {
expect(sponsorshipCharge({ spots: 3 }, 450)).toEqual({ amount: 1350, spots: 3 });
});
test('a custom amount is taken as given (2 decimals)', () => {
expect(sponsorshipCharge({ amount: '120.555' }, 450)).toEqual({ amount: 120.56, spots: null });
});
test('rejects bad input', () => {
expect(() => sponsorshipCharge({ spots: 0 }, 450)).toThrow(/between 1/);
expect(() => sponsorshipCharge({ spots: 1.5 }, 450)).toThrow();
expect(() => sponsorshipCharge({ spots: 1 }, null)).toThrow(/no ticket price/);
expect(() => sponsorshipCharge({ amount: 10 }, 450)).toThrow(/minimum/);
expect(() => sponsorshipCharge({}, 450)).toThrow();
});
});
describe('poolSummary', () => {
test('received, assigned and remaining, with spots funded', () => {
const sponsorships = [{ id: 'a', amount: 900 }, { id: 'b', amount: 300 }];
const legs = [{ originalPaymentId: 'a', amount: 450 }, { originalPaymentId: 'b', amount: -100 }];
expect(poolSummary(sponsorships, legs, 450)).toEqual({ received: 1200, assigned: 550, remaining: 650, sponsors: 2, spotsFunded: 2 });
});
test('no spot count for free events', () => {
expect(poolSummary([{ id: 'a', amount: 50 }], [], null).spotsFunded).toBeNull();
});
});
+46
View File
@@ -0,0 +1,46 @@
const { transferBlocker } = require('../src/utils/ticketTransfer');
const { judgeSyncedScan } = require('../src/utils/scanLocks');
describe('transferBlocker', () => {
const now = new Date('2026-10-01T10:00:00Z');
const ticket = {
userId: 'payer', holderUserId: 'holder', usages: [], registrationStatus: 'paid',
event: { startDate: '2026-10-05T08:00:00Z', allowTicketTransfer: true },
};
const user = (id, role = 'user') => ({ id, role });
test('the payer and the holder can transfer', () => {
expect(transferBlocker(ticket, user('payer'), now)).toBeNull();
expect(transferBlocker(ticket, user('holder'), now)).toBeNull();
});
test('anyone else needs to be a supervisor', () => {
expect(transferBlocker(ticket, user('someone'), now).status).toBe(403);
expect(transferBlocker(ticket, user('staffer', 'staff'), now).status).toBe(403);
expect(transferBlocker(ticket, user('sup', 'supervisor'), now)).toBeNull();
});
test('not once the event has started', () => {
expect(transferBlocker({ ...ticket, event: { ...ticket.event, startDate: '2026-10-01T09:00:00Z' } }, user('sup', 'supervisor'), now).message).toMatch(/before the event starts/);
});
test('not once any of it has been scanned', () => {
expect(transferBlocker({ ...ticket, usages: [{ id: 'u1' }] }, user('payer'), now).message).toMatch(/already been used/);
});
test('not for a cancelled registration', () => {
expect(transferBlocker({ ...ticket, registrationStatus: 'cancelled' }, user('payer'), now)).not.toBeNull();
});
test('turned off per event, except for supervisors', () => {
const off = { ...ticket, event: { ...ticket.event, allowTicketTransfer: false } };
expect(transferBlocker(off, user('payer'), now).message).toMatch(/turned off/);
expect(transferBlocker(off, user('admin', 'admin'), now)).toBeNull();
});
});
describe('offline sync of a transferred code', () => {
test('is flagged and not recorded', () => {
expect(judgeSyncedScan({ transferred: true }, 1)).toEqual({ redeem: 0, conflict: 'transferred' });
});
});
+49 -62
View File
@@ -1,13 +1,14 @@
{
"name": "hope-events-frontend",
"version": "1.16.0",
"version": "1.24.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "hope-events-frontend",
"version": "1.16.0",
"version": "1.24.0",
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@hookform/resolvers": "^5.2.1",
"@radix-ui/react-accordion": "^1.2.11",
"@radix-ui/react-alert-dialog": "^1.1.14",
@@ -31,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",
@@ -342,6 +344,45 @@
"integrity": "sha512-lwYN/vDPeNRULcepoE/LO2Pgx+7/RV+S9ARfbc9lr2DtGkOD7pAiruHvbR1RX3Qyf6ja47EWJDMsNK5vK08DJg==",
"license": "MIT"
},
"node_modules/@dnd-kit/accessibility": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz",
"integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==",
"license": "MIT",
"dependencies": {
"tslib": "^2.0.0"
},
"peerDependencies": {
"react": ">=16.8.0"
}
},
"node_modules/@dnd-kit/core": {
"version": "6.3.1",
"resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz",
"integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==",
"license": "MIT",
"dependencies": {
"@dnd-kit/accessibility": "^3.1.1",
"@dnd-kit/utilities": "^3.2.2",
"tslib": "^2.0.0"
},
"peerDependencies": {
"react": ">=16.8.0",
"react-dom": ">=16.8.0"
}
},
"node_modules/@dnd-kit/utilities": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz",
"integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==",
"license": "MIT",
"dependencies": {
"tslib": "^2.0.0"
},
"peerDependencies": {
"react": ">=16.8.0"
}
},
"node_modules/@emnapi/core": {
"version": "1.10.0",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz",
@@ -869,9 +910,6 @@
"cpu": [
"arm"
],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -888,9 +926,6 @@
"cpu": [
"arm64"
],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -907,9 +942,6 @@
"cpu": [
"ppc64"
],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -926,9 +958,6 @@
"cpu": [
"riscv64"
],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -945,9 +974,6 @@
"cpu": [
"s390x"
],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -964,9 +990,6 @@
"cpu": [
"x64"
],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -983,9 +1006,6 @@
"cpu": [
"arm64"
],
"libc": [
"musl"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -1002,9 +1022,6 @@
"cpu": [
"x64"
],
"libc": [
"musl"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -1021,9 +1038,6 @@
"cpu": [
"arm"
],
"libc": [
"glibc"
],
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -1046,9 +1060,6 @@
"cpu": [
"arm64"
],
"libc": [
"glibc"
],
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -1071,9 +1082,6 @@
"cpu": [
"ppc64"
],
"libc": [
"glibc"
],
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -1096,9 +1104,6 @@
"cpu": [
"riscv64"
],
"libc": [
"glibc"
],
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -1121,9 +1126,6 @@
"cpu": [
"s390x"
],
"libc": [
"glibc"
],
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -1146,9 +1148,6 @@
"cpu": [
"x64"
],
"libc": [
"glibc"
],
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -1171,9 +1170,6 @@
"cpu": [
"arm64"
],
"libc": [
"musl"
],
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -1196,9 +1192,6 @@
"cpu": [
"x64"
],
"libc": [
"musl"
],
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -1455,9 +1448,6 @@
"cpu": [
"arm64"
],
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1474,9 +1464,6 @@
"cpu": [
"arm64"
],
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1493,9 +1480,6 @@
"cpu": [
"x64"
],
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1512,9 +1496,6 @@
"cpu": [
"x64"
],
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -6865,6 +6846,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",
+3 -1
View File
@@ -1,6 +1,6 @@
{
"name": "hope-events-frontend",
"version": "1.16.0",
"version": "1.24.0",
"private": true,
"scripts": {
"dev": "next dev --turbopack",
@@ -9,6 +9,7 @@
"lint": "next lint"
},
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@hookform/resolvers": "^5.2.1",
"@radix-ui/react-accordion": "^1.2.11",
"@radix-ui/react-alert-dialog": "^1.1.14",
@@ -32,6 +33,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",
+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');
}
})());
}
});
+1 -1
View File
@@ -40,7 +40,7 @@ export default async function EventRedirectPage({ params }: { params: Promise<{
}
if (event.isLive || event.isActive) {
redirect(`/events/${event.id}`);
redirect(`/events/${event.id}?src=${encodeURIComponent(redirectUrl)}`);
}
return (
+6 -7
View File
@@ -3,13 +3,13 @@ import React, { Suspense, useMemo, useState } from "react";
import { Navbar } from "@/components/layout/Navbar";
import { Footer } from "@/components/layout/Footer";
import { apiFetch } from "@/lib/api";
import { useSearchParams, useRouter } from "next/navigation";
import { saveToken } from "@/lib/auth";
import { useSearchParams } from "next/navigation";
import { appName } from "@/lib/siteConfig";
import { ShieldCheck } from "lucide-react";
function ActivateAccountContent() {
const search = useSearchParams();
const router = useRouter();
const token = search.get("token") || "";
const [password, setPassword] = useState("");
const [confirm, setConfirm] = useState("");
@@ -25,12 +25,11 @@ function ActivateAccountContent() {
setLoading(true);
setStatus(null);
const data = await apiFetch<any>("/api/users/activate", { method: "POST", body: { token, password } });
// Auto-login: save token and redirect to dashboard
if (data?.token) {
localStorage.setItem("token", data.token);
}
// Auto-login: save the session token (under the key AuthContext reads) and load the
// dashboard fresh so the new session is picked up.
if (data?.token) saveToken(data.token);
setStatus("Your account is now active. Redirecting…");
setTimeout(() => router.push("/dashboard"), 1500);
setTimeout(() => { window.location.href = "/dashboard"; }, 1500);
} catch (err: any) {
setStatus(err?.message || "Activation failed. The link may have expired.");
} finally {
@@ -25,6 +25,13 @@ const ACTIONS = [
"consent_override",
"consent_rerequested",
"medical_list_viewed",
"scan_lock_acquired",
"scan_lock_released",
"scan_lock_force_released",
"offline_sync",
"ticket_transferred",
"impersonation_started",
"impersonation_ended",
] as const;
const ACTION_LABELS: Record<string, string> = {
@@ -44,6 +51,13 @@ const ACTION_LABELS: Record<string, string> = {
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",
ticket_transferred: "Ticket transferred",
impersonation_started: "Impersonation started",
impersonation_ended: "Impersonation ended",
};
interface AuditLogEntry {
@@ -71,6 +85,49 @@ function formatMetadata(metadata: Record<string, unknown> | null): string {
}
}
type ImpSession = {
id: string; reason: string; startedAt: string; expiresAt: string; endedAt: string | null; endedReason: string | null;
admin: { name: string; email: string }; target: { name: string; email: string };
requests: { method: string; path: string; blocked: boolean; createdAt: string }[];
};
/** "View as" sessions, each with the trail of pages and API calls made during it. */
function ImpersonationSessions({ token }: { token: string }) {
const [sessions, setSessions] = useState<ImpSession[]>([]);
const [open, setOpen] = useState<string | null>(null);
useEffect(() => {
apiFetch<ImpSession[]>("/api/admin/impersonation-sessions?limit=20", { authToken: token }).then(setSessions).catch(() => setSessions([]));
}, [token]);
if (sessions.length === 0) return null;
return (
<div className="border rounded-xl bg-white shadow-sm p-4 mb-4">
<div className="font-semibold mb-2">Impersonation sessions</div>
<ul className="divide-y text-sm">
{sessions.map(s => (
<li key={s.id} className="py-2">
<button className="w-full text-left flex flex-wrap items-baseline justify-between gap-2" onClick={() => setOpen(open === s.id ? null : s.id)}>
<span><strong>{s.admin.name}</strong> viewed as <strong>{s.target.name}</strong> — “{s.reason}”</span>
<span className="text-xs text-gray-500">
{new Date(s.startedAt).toLocaleString()} · {s.endedAt ? `${s.endedReason || "ended"} ${new Date(s.endedAt).toLocaleTimeString()}` : new Date(s.expiresAt) > new Date() ? "active" : "expired"} · {s.requests.length} request(s){s.requests.some(r => r.blocked) ? ` · ${s.requests.filter(r => r.blocked).length} blocked` : ""}
</span>
</button>
{open === s.id && (
<ul className="mt-2 text-xs font-mono bg-gray-50 rounded p-2 max-h-64 overflow-auto space-y-0.5">
{s.requests.map((r, i) => (
<li key={i} className={r.blocked ? "text-red-700" : "text-gray-700"}>
{new Date(r.createdAt).toLocaleTimeString()} {r.method} {r.path}{r.blocked ? " (blocked — read-only)" : ""}
</li>
))}
{s.requests.length === 0 && <li className="text-gray-500">No requests recorded.</li>}
</ul>
)}
</li>
))}
</ul>
</div>
);
}
export default function AdminAuditLogPage() {
const { user, loading, token } = useAuth();
const router = useRouter();
@@ -138,6 +195,7 @@ export default function AdminAuditLogPage() {
You need admin access to view the audit log.
</div>
)}
{isAdmin && token && <ImpersonationSessions token={token} />}
{error && <div className="mb-3 p-3 border rounded bg-red-50 text-red-800 text-sm">{error}</div>}
+8 -2
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, TicketPercent, ShieldCheck, ListChecks,
UserPlus, FileText, MessageCircle, BarChart2, Mail, Wallet, DoorOpen, History, TicketPercent, ShieldCheck, ListChecks, WifiOff, BedDouble, Filter,
} from "lucide-react";
import { StatCard, StatCardRow } from "@/components/shared/StatCard";
import { QuickActionTile, QuickActionGrid } from "@/components/shared/QuickActionTile";
@@ -27,6 +27,8 @@ 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/allocations", label: "Rooms & groups", description: "Put attendees into rooms, groups or buses", icon: BedDouble },
{ 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 },
@@ -41,6 +43,7 @@ const QUICK_ACTIONS = [
type OverviewMetric = { thisMonth: number; lastMonth: number; pctChange: number | null };
type Overview = {
funnel?: { views: number; paid: number; conversion: number | null } | null;
activeEvents: number;
registrations: OverviewMetric;
ticketsSold: OverviewMetric;
@@ -118,12 +121,15 @@ export default function AdminDashboardPage() {
</div>
)}
<StatCardRow>
<StatCardRow cols={overview?.funnel ? 6 : 5}>
<StatCard icon={Calendar} label="Active events" value={overview ? formatCount(overview.activeEvents) : "—"} tone="brand" href="/dashboard/supervisor/events" linkLabel="View all events" />
<StatCard icon={Banknote} label="Revenue (past month)" value={overview ? formatRand(overview.revenue.thisMonth) : "—"} tone="green" href={`${REPORTS_URL}?report=revenue&range=trailing_month`} linkLabel="View report" delta={overview ? { value: overview.revenue.pctChange } : undefined} />
<StatCard icon={Gift} label="Donations (past month)" value={overview ? formatRand(overview.donations.thisMonth) : "—"} tone="rose" href={`${REPORTS_URL}?report=donations&range=trailing_month`} linkLabel="View report" delta={overview ? { value: overview.donations.pctChange } : undefined} />
<StatCard icon={Users} label="Registrations (past month)" value={overview ? formatCount(overview.registrations.thisMonth) : "—"} tone="blue" href="/dashboard/admin/registrations" linkLabel="View registrations" delta={overview ? { value: overview.registrations.pctChange } : undefined} />
<StatCard icon={Ticket} label="Tickets sold (past month)" value={overview ? formatCount(overview.ticketsSold.thisMonth) : "—"} tone="amber" href={`${REPORTS_URL}?report=usage&range=trailing_month`} linkLabel="View report" delta={overview ? { value: overview.ticketsSold.pctChange } : undefined} />
{overview?.funnel && (
<StatCard icon={Filter} label="Conversion (past month)" value={overview.funnel.conversion != null ? `${overview.funnel.conversion}%` : "—"} tone="violet" href={`${REPORTS_URL}?report=funnel&range=trailing_month`} linkLabel={`${overview.funnel.paid} of ${overview.funnel.views} visits`} />
)}
</StatCardRow>
<div className="border rounded-xl p-4 bg-white shadow-sm my-6">
@@ -137,6 +137,7 @@ function SiteSettingsPageInner() {
const [legalWebsiteUrl, setLegalWebsiteUrl] = useState("");
const [legalEffectiveDate, setLegalEffectiveDate] = useState("");
const [consentRetentionDays, setConsentRetentionDays] = useState("30");
const [notifyOnImpersonation, setNotifyOnImpersonation] = useState(false);
// ── Load all settings once ────────────────────────────────────────────────
useEffect(() => {
@@ -173,6 +174,7 @@ function SiteSettingsPageInner() {
setLegalWebsiteUrl(s.legal_website_url || "");
setLegalEffectiveDate(s.legal_effective_date || "");
setConsentRetentionDays(s.consent_medical_retention_days || "30");
setNotifyOnImpersonation(s.notify_user_on_impersonation === "true");
})
.catch((e: any) => setResult({ ok: false, message: e?.message || "Failed to load settings" }))
.finally(() => setLoadingInitial(false));
@@ -322,6 +324,7 @@ function SiteSettingsPageInner() {
legal_website_url: legalWebsiteUrl.trim(),
legal_effective_date: legalEffectiveDate.trim(),
consent_medical_retention_days: String(Math.max(0, parseInt(consentRetentionDays, 10) || 0)),
notify_user_on_impersonation: notifyOnImpersonation ? "true" : "false",
});
const handleTestSmtp = async () => {
@@ -670,6 +673,14 @@ function SiteSettingsPageInner() {
<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>
<div className="border-t pt-4">
<p className="text-sm font-medium text-gray-700 mb-3">Support access</p>
<label className="flex items-start gap-2 text-sm cursor-pointer">
<input type="checkbox" className="mt-0.5" checked={notifyOnImpersonation} onChange={e => setNotifyOnImpersonation(e.target.checked)} />
<span>Email a user when an admin views their account with &quot;Impersonate&quot; (read-only support access), including the reason given</span>
</label>
</div>
<SaveBar saving={saving} onSave={saveLegal} result={result} />
</div>
)}
@@ -5,6 +5,7 @@ import { useAuth } from "@/hooks/useAuth";
import { useRouter } from "next/navigation";
import { apiFetch } from "@/lib/api";
import { useDismissingState } from "@/hooks/useDismissingState";
import { beginImpersonation } from "@/lib/impersonation";
import { Users as UsersIcon } from "lucide-react";
import { RoleBadge, type Role as RoleBadgeRole } from "@/components/shared/RoleBadge";
@@ -187,6 +188,23 @@ export default function AdminUsersPage() {
}
};
const [impTarget, setImpTarget] = useState<{ id: string; name: string } | null>(null);
const [impReason, setImpReason] = useState("");
const [impBusy, setImpBusy] = useState(false);
const [impError, setImpError] = useState<string | null>(null);
const startImpersonation = async () => {
if (!impTarget || !token) return;
setImpBusy(true);
setImpError(null);
try {
const res = await apiFetch<any>(`/api/admin/impersonate/${encodeURIComponent(impTarget.id)}`, { method: "POST", authToken: token, body: { reason: impReason } });
beginImpersonation(res);
} catch (e: any) {
setImpError(e?.message || "Couldn't start impersonation");
setImpBusy(false);
}
};
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.
@@ -222,6 +240,24 @@ export default function AdminUsersPage() {
}
};
const impersonateModal = impTarget && (
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50" onClick={() => !impBusy && setImpTarget(null)}>
<div className="bg-white rounded-xl shadow-lg max-w-md w-full mx-4 p-5 space-y-3" onClick={e => e.stopPropagation()}>
<h3 className="text-lg font-semibold">View as {impTarget.name}</h3>
<p className="text-sm text-gray-600">You&apos;ll see the site exactly as they do, for up to 30 minutes. It&apos;s strictly read-only: nothing can be registered, paid, changed or sent. The session, your reason and every page you open are recorded in the audit log.</p>
<div>
<label className="block text-xs text-gray-600 mb-1">Reason (required)</label>
<textarea className="w-full border rounded-lg px-3 py-2 text-sm" rows={2} value={impReason} onChange={e => setImpReason(e.target.value)} placeholder="e.g. Says their ticket isn't showing in My Events" maxLength={500} autoFocus />
</div>
{impError && <div className="p-2 rounded bg-red-50 text-red-800 text-sm">{impError}</div>}
<div className="flex justify-end gap-2">
<button className="px-3 py-1.5 text-sm rounded bg-gray-100 hover:bg-gray-200" onClick={() => setImpTarget(null)} disabled={impBusy}>Cancel</button>
<button className="px-3 py-1.5 text-sm rounded bg-violet-600 text-white hover:bg-violet-700 disabled:opacity-50" onClick={startImpersonation} disabled={impBusy || impReason.trim().length < 5}>{impBusy ? "Starting…" : "Start viewing"}</button>
</div>
</div>
</div>
);
return (
<div className="max-w-6xl mx-auto w-full p-6">
<div className="flex items-center justify-between mb-4 flex-wrap gap-3">
@@ -367,6 +403,9 @@ export default function AdminUsersPage() {
<td className="p-2">
<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>
{user?.role === "admin" && u.role !== "admin" && u.isActive && (
<button className="px-2 py-1 text-xs rounded bg-violet-600 text-white hover:bg-violet-700" onClick={() => { setImpTarget({ id: u.id, name: u.name }); setImpReason(""); setImpError(null); }} title="See the site exactly as this user sees it (read-only, 30 minutes, audited)">Impersonate</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>
@@ -498,6 +537,7 @@ export default function AdminUsersPage() {
</div>
</div>
)}
{impersonateModal}
</div>
);
}
@@ -0,0 +1,96 @@
"use client";
import React, { useEffect, useState } from "react";
import { useAuth } from "@/hooks/useAuth";
import { useRouter } from "next/navigation";
import { apiFetch } from "@/lib/api";
import { formatDate } from "@/lib/date";
import { Users } from "lucide-react";
type Member = {
id: string; name: string; gender: string | null; dateOfBirth: string | null; phone: string | null;
parentName: string | null; parentPhone: string | null; bookedBy: string; bookedByPhone: string | null;
medical?: Record<string, string> | null; emergency?: Record<string, string> | null;
};
type MyGroup = { id: string; name: string; capacity: number | null; setName: string; event: { id: string; title: string; startDate: string }; members: Member[] };
const ageOn = (dob: string | null, on: string) => {
if (!dob) return null;
const d = new Date(dob), e = new Date(on);
let age = e.getFullYear() - d.getFullYear();
if (e.getMonth() < d.getMonth() || (e.getMonth() === d.getMonth() && e.getDate() < d.getDate())) age -= 1;
return age;
};
/** Groups the signed-in staff member leads (rooms, small groups, buses) and who's in them. */
export default function MyGroupsPage() {
const { token } = useAuth();
const router = useRouter();
const [data, setData] = useState<{ medicalShown: boolean; groups: MyGroup[] } | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!token) return;
apiFetch<{ medicalShown: boolean; groups: MyGroup[] }>("/api/allocations/my-groups", { authToken: token })
.then(setData).catch(e => setError(e?.message || "Failed to load your groups"));
}, [token]);
return (
<div className="max-w-4xl 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">
<Users 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">My groups</h1>
<p className="text-sm text-gray-500">Rooms, small groups and buses you lead</p>
</div>
</div>
{error && <div className="mb-3 p-3 border rounded bg-red-50 text-red-800 text-sm">{error}</div>}
{data && data.groups.length === 0 && <p className="text-sm text-gray-600">You aren&apos;t leading any groups for upcoming events.</p>}
<div className="space-y-4">
{data?.groups.map(g => (
<section key={g.id} className="border rounded-xl p-4 bg-white shadow-sm">
<div className="flex flex-wrap items-baseline justify-between gap-2 mb-2">
<h2 className="font-semibold">{g.name} <span className="text-sm font-normal text-gray-500">· {g.setName}</span></h2>
<span className="text-xs text-gray-500">{g.event.title} · {formatDate(new Date(g.event.startDate))} · {g.members.length}{g.capacity ? ` / ${g.capacity}` : ""} people</span>
</div>
{g.members.length === 0 ? <p className="text-sm text-gray-500">Nobody has been placed in this group yet.</p> : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead><tr className="text-left text-xs text-gray-500 border-b">
<th className="py-1 pr-3">Name</th><th className="py-1 pr-3">Age</th><th className="py-1 pr-3">Contact</th>
{data.medicalShown && <th className="py-1 pr-3">Medical</th>}
</tr></thead>
<tbody>
{g.members.map(m => {
const age = ageOn(m.dateOfBirth, g.event.startDate);
return (
<tr key={m.id} className="border-b last:border-0 align-top">
<td className="py-1.5 pr-3 font-medium">{m.name}</td>
<td className="py-1.5 pr-3">{age ?? "—"}</td>
<td className="py-1.5 pr-3 text-xs">
{m.phone && <div>{m.phone}</div>}
{m.parentName ? <div>Parent: {m.parentName}{m.parentPhone ? ` · ${m.parentPhone}` : ""}</div> : m.bookedBy && <div>Booked by {m.bookedBy}{m.bookedByPhone ? ` · ${m.bookedByPhone}` : ""}</div>}
</td>
{data.medicalShown && (
<td className="py-1.5 pr-3 text-xs">
{m.medical ? Object.values(m.medical).filter(Boolean).join("; ") || "—" : "—"}
{m.emergency && Object.values(m.emergency).some(Boolean) && <div className="text-gray-500">Emergency: {Object.values(m.emergency).filter(Boolean).join(", ")}</div>}
</td>
)}
</tr>
);
})}
</tbody>
</table>
</div>
)}
</section>
))}
</div>
{data && !data.medicalShown && data.groups.length > 0 && <p className="text-xs text-gray-500 mt-3">Medical details are only shown to supervisors. Ask one if you need them.</p>}
</div>
);
}
+2 -1
View File
@@ -6,13 +6,14 @@ import { useRouter } from "next/navigation";
import { apiFetch } from "@/lib/api";
import { useStableState } from "@/hooks/useStableState";
import { useVisiblePolling } from "@/hooks/useVisiblePolling";
import { QrCode, Ticket, Activity, UserCheck, Clock } from "lucide-react";
import { QrCode, Ticket, Activity, UserCheck, Clock, Users } from "lucide-react";
import { StatCard, StatCardRow } from "@/components/shared/StatCard";
import { QuickActionTile, QuickActionGrid } from "@/components/shared/QuickActionTile";
const QUICK_ACTIONS = [
{ href: "/dashboard/staff/ticket-scanning", label: "Scan tickets", description: "Use your device camera to validate tickets", icon: QrCode },
{ href: "/dashboard/staff/event-tickets", label: "Event tickets & printing", description: "Browse event tickets and print lists", icon: Ticket },
{ href: "/dashboard/staff/my-groups", label: "My groups", description: "Rooms and groups you lead", icon: Users },
] as const;
export default function StaffDashboardPage() {
@@ -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,6 +38,7 @@ 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.
@@ -96,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);
@@ -114,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;
@@ -172,7 +213,7 @@ export default function TicketScanningPage() {
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;
@@ -194,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)}${
@@ -202,7 +254,12 @@ export default function TicketScanningPage() {
await apiFetch<any>(url, {
method: "POST",
authToken: token,
body: { qty, ...(overrideConsent ? { overrideConsent: true } : {}) },
body: {
qty,
deviceId: offline.deviceId(),
...(selectedSectionId && selectedSectionId !== "all" ? { sectionId: selectedSectionId } : {}),
...(overrideConsent ? { overrideConsent: true } : {}),
},
});
refreshStatsAndRecent();
} catch (e: any) {
@@ -270,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>
))}
@@ -289,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 => (
@@ -299,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}
@@ -0,0 +1,314 @@
"use client";
import React, { useCallback, useEffect, useMemo, useState } from "react";
import { useAuth } from "@/hooks/useAuth";
import { useRouter } from "next/navigation";
import { DndContext, DragEndEvent, PointerSensor, TouchSensor, useDraggable, useDroppable, useSensor, useSensors } from "@dnd-kit/core";
import { apiFetch } from "@/lib/api";
import { formatDate } from "@/lib/date";
import { useDismissingState } from "@/hooks/useDismissingState";
import { BedDouble, Plus, Wand2, Printer, Trash2, Pencil, Eraser } from "lucide-react";
type Group = { id: string; name: string; capacity: number | null; gender: "male" | "female" | null; leaderUserId: string | null; leader?: { id: string; name: string } | null };
type AllocSet = { id: string; name: string; showOnTicket: boolean; groups: Group[]; assignments: { attendeeId: string; groupId: string }[] };
type Person = { id: string; name: string; gender: string | null; dateOfBirth: string | null; registrationId: string; bookedBy: string };
type Board = { event: { id: string; title: string; startDate: string; collectGender: boolean; requiresParentalConsent: boolean }; sets: AllocSet[]; attendees: Person[]; leaders: { id: string; name: string }[]; unnamedPlaces: number };
const UNASSIGNED = "unassigned";
const esc = (s: unknown) => String(s ?? "").replace(/[&<>"']/g, c => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c]!));
const genderLabel = (g: string | null) => (g === "male" ? "Male" : g === "female" ? "Female" : "");
const ageOn = (dob: string | null, on: string) => {
if (!dob) return null;
const d = new Date(dob), e = new Date(on);
let age = e.getFullYear() - d.getFullYear();
if (e.getMonth() < d.getMonth() || (e.getMonth() === d.getMonth() && e.getDate() < d.getDate())) age -= 1;
return age;
};
function PersonChip({ person, eventStart }: { person: Person; eventStart: string }) {
const { attributes, listeners, setNodeRef, transform, isDragging } = useDraggable({ id: person.id });
const age = ageOn(person.dateOfBirth, eventStart);
return (
<div
ref={setNodeRef}
{...listeners}
{...attributes}
style={transform ? { transform: `translate(${transform.x}px, ${transform.y}px)`, zIndex: 50 } : undefined}
className={`px-2 py-1.5 rounded-lg border bg-white text-sm cursor-grab active:cursor-grabbing select-none touch-none ${isDragging ? "shadow-lg ring-2 ring-brand-300" : "shadow-sm"}`}
title={person.bookedBy ? `Booked by ${person.bookedBy}` : undefined}
>
<span className="font-medium">{person.name}</span>
<span className="text-xs text-gray-500">
{[genderLabel(person.gender), age != null ? `${age}` : "", person.bookedBy ? `· ${person.bookedBy}` : ""].filter(Boolean).map(s => ` ${s}`).join("")}
</span>
</div>
);
}
function Column({ id, title, subtitle, full, children, actions }: { id: string; title: string; subtitle?: string; full?: boolean; children: React.ReactNode; actions?: React.ReactNode }) {
const { setNodeRef, isOver } = useDroppable({ id });
return (
<div ref={setNodeRef} className={`rounded-xl border p-3 min-h-[8rem] transition-colors ${isOver ? "bg-brand-50 border-brand-300" : full ? "bg-gray-50" : "bg-white"}`}>
<div className="flex items-start justify-between gap-2 mb-2">
<div>
<div className="font-semibold text-sm">{title}</div>
{subtitle && <div className={`text-xs ${full ? "text-amber-700" : "text-gray-500"}`}>{subtitle}</div>}
</div>
{actions}
</div>
<div className="space-y-1.5">{children}</div>
</div>
);
}
export default function AllocationsPage() {
const { user, loading, token } = useAuth();
const router = useRouter();
const canView = useMemo(() => user?.role === "admin" || user?.role === "supervisor", [user]);
useEffect(() => { if (!loading && !user) router.replace("/login"); }, [user, loading, router]);
const [events, setEvents] = useState<any[]>([]);
const [eventId, setEventId] = useState("");
const [board, setBoard] = useState<Board | null>(null);
const [setId, setSetId] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useDismissingState<string | null>(null);
const [info, setInfo] = useDismissingState<string | null>(null);
const [groupForm, setGroupForm] = useState<{ id?: string; name: string; capacity: string; gender: string; leaderUserId: string } | null>(null);
const [newSetName, setNewSetName] = useState("");
const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 5 } }), useSensor(TouchSensor, { activationConstraint: { delay: 150, tolerance: 5 } }));
useEffect(() => {
if (!token || !canView) return;
apiFetch<any[]>("/api/events/all?includePast=true", { authToken: token })
.then(evs => {
const upcoming = (evs || []).filter(e => new Date(e.endDate).getTime() > Date.now() - 7 * 864e5)
.sort((a, b) => new Date(a.startDate).getTime() - new Date(b.startDate).getTime());
setEvents(upcoming);
if (upcoming.length) setEventId(id => id || upcoming[0].id);
})
.catch(e => setError(e?.message || "Failed to load events"));
}, [token, canView, setError]);
const load = useCallback(async () => {
if (!token || !eventId) return;
try {
const b = await apiFetch<Board>(`/api/allocations/event/${encodeURIComponent(eventId)}`, { authToken: token });
setBoard(b);
setSetId(id => (b.sets.some(s => s.id === id) ? id : b.sets[0]?.id || ""));
} catch (e: any) {
setError(e?.message || "Failed to load allocations");
}
}, [token, eventId, setError]);
useEffect(() => { load(); }, [load]);
const current = board?.sets.find(s => s.id === setId) || null;
const groupOf = useMemo(() => new Map((current?.assignments || []).map(a => [a.attendeeId, a.groupId])), [current]);
const membersOf = (groupId: string) => (board?.attendees || []).filter(p => groupOf.get(p.id) === groupId);
const unassigned = (board?.attendees || []).filter(p => !groupOf.has(p.id));
const call = async <T,>(path: string, method: string, body?: unknown): Promise<T> =>
apiFetch<T>(path, { method, authToken: token!, ...(body !== undefined ? { body } : {}) });
const onDragEnd = async (e: DragEndEvent) => {
if (!current || !e.over) return;
const attendeeId = String(e.active.id);
const target = String(e.over.id);
const groupId = target === UNASSIGNED ? null : target;
if ((groupOf.get(attendeeId) || null) === groupId) return;
// Optimistic move; reload on failure.
setBoard(b => b && ({
...b,
sets: b.sets.map(s => s.id !== current.id ? s : {
...s,
assignments: [...s.assignments.filter(a => a.attendeeId !== attendeeId), ...(groupId ? [{ attendeeId, groupId }] : [])],
}),
}));
try {
await call(`/api/allocations/sets/${current.id}/assignments`, "PUT", { attendeeId, groupId });
} catch (err: any) {
setError(err?.message || "Couldn't move them");
load();
}
};
const run = async (fn: () => Promise<string | void>) => {
setBusy(true);
try {
const msg = await fn();
if (msg) setInfo(msg);
await load();
} catch (e: any) {
setError(e?.message || "Something went wrong");
} finally {
setBusy(false);
}
};
const addSet = () => run(async () => {
const s = await call<{ id: string }>("/api/allocations/sets", "POST", { eventId, name: newSetName || "Rooms" });
setNewSetName("");
setSetId(s.id);
});
const saveGroup = () => groupForm && current && run(async () => {
const body = { name: groupForm.name, capacity: groupForm.capacity || null, gender: groupForm.gender || null, leaderUserId: groupForm.leaderUserId || null };
if (groupForm.id) await call(`/api/allocations/groups/${groupForm.id}`, "PUT", body);
else await call(`/api/allocations/sets/${current.id}/groups`, "POST", body);
setGroupForm(null);
});
const printLists = async (master: boolean, medical: boolean) => {
if (!current || !board) return;
try {
const data = await apiFetch<any>(`/api/allocations/sets/${current.id}/lists${medical ? "?medical=true" : ""}`, { authToken: token! });
const w = window.open("", "_blank");
if (!w) return;
const row = (m: any) => `<tr><td>${esc(m.name)}</td><td>${esc(genderLabel(m.gender))}</td><td>${esc(ageOn(m.dateOfBirth, board.event.startDate) ?? "")}</td>`
+ `<td>${esc(m.phone || m.parentPhone || m.bookedByPhone || "")}</td><td>${esc(m.parentName || m.bookedBy || "")}</td>`
+ (medical ? `<td>${esc(m.medical ? Object.values(m.medical).filter(Boolean).join("; ") : "")}</td><td>${esc(m.emergency ? Object.values(m.emergency).filter(Boolean).join(", ") : "")}</td>` : "") + `</tr>`;
const head = `<tr><th>Name</th><th>Gender</th><th>Age</th><th>Phone</th><th>Parent / booked by</th>${medical ? "<th>Medical</th><th>Emergency contact</th>" : ""}</tr>`;
let body = "";
if (master) {
const all = data.groups.flatMap((g: any) => g.members.map((m: any) => ({ ...m, group: g.name }))).sort((a: any, b: any) => a.name.localeCompare(b.name));
body = `<h1>${esc(data.event.title)} — ${esc(data.set.name)} (master list)</h1><table><tr><th>${esc(data.set.name)}</th>${head.slice(4)}${all.map((m: any) => row(m).replace("<tr>", `<tr><td>${esc(m.group)}</td>`)).join("")}</table>`;
} else {
body = data.groups.map((g: any) => `<section><h1>${esc(data.event.title)} — ${esc(g.name)}</h1>`
+ `<p>${esc(data.set.name)}${g.leader ? ` · Leader: ${esc(g.leader)}` : ""} · ${g.members.length}${g.capacity ? ` / ${g.capacity}` : ""} people</p>`
+ `<table>${head}${g.members.map(row).join("")}</table></section>`).join("");
}
w.document.write(`<!doctype html><html><head><title>${esc(data.set.name)}</title><style>
body{font-family:Arial,sans-serif;font-size:11pt;margin:12mm} h1{font-size:15pt;margin:0 0 2mm} p{margin:0 0 3mm;color:#555}
table{border-collapse:collapse;width:100%;margin-bottom:6mm} th,td{border:1px solid #bbb;padding:3px 6px;text-align:left;vertical-align:top} th{background:#f1f1f1}
section{page-break-after:always} section:last-child{page-break-after:auto}
</style></head><body>${body}<script>window.onload=function(){window.print()}</script></body></html>`);
w.document.close();
} catch (e: any) {
setError(e?.message || "Couldn't load the lists");
}
};
return (
<div className="max-w-7xl 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">
<BedDouble 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">Rooms &amp; groups</h1>
<p className="text-sm text-gray-500">Put attendees into rooms, small groups, teams or buses</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 allocations.</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); setSetId(""); }}>
{events.map(ev => <option key={ev.id} value={ev.id}>{ev.title} ({formatDate(new Date(ev.startDate))})</option>)}
</select>
</div>
{board && (
<div className="flex flex-wrap items-center gap-1">
{board.sets.map(s => (
<button key={s.id} onClick={() => setSetId(s.id)} className={`px-3 py-1.5 text-sm rounded-lg border ${s.id === setId ? "bg-brand-600 text-white border-brand-600" : "bg-white hover:bg-gray-50"}`}>{s.name}</button>
))}
<input className="border rounded-lg px-2 py-1.5 text-sm w-36" placeholder="New set, e.g. Buses" value={newSetName} onChange={e => setNewSetName(e.target.value)} onKeyDown={e => { if (e.key === "Enter") addSet(); }} />
<button onClick={addSet} disabled={busy} className="inline-flex items-center gap-1 px-2.5 py-1.5 text-sm rounded-lg border hover:bg-gray-50"><Plus className="w-4 h-4" /> Add set</button>
</div>
)}
</div>
{board && board.attendees.length === 0 && (
<div className="p-3 border rounded bg-amber-50 text-amber-900 text-sm">
No named attendees yet. Allocation works per person, so it needs names — turn on &quot;Named attendees&quot; for the event{board.event.collectGender ? "" : " (and \"Ask each person's gender\" if rooms are split by gender)"}.
</div>
)}
{board && board.unnamedPlaces > 0 && (
<p className="text-xs text-gray-500">{board.unnamedPlaces} place(s) have no name yet, so they can&apos;t be placed until the person booking adds names.</p>
)}
{current && board && (
<>
<div className="flex flex-wrap items-center gap-2 border rounded-xl p-3 bg-white">
<label className="flex items-center gap-2 text-sm cursor-pointer">
<input type="checkbox" checked={current.showOnTicket} onChange={e => run(async () => { await call(`/api/allocations/sets/${current.id}`, "PUT", { showOnTicket: e.target.checked }); })} />
Show {current.name.toLowerCase()} on tickets and in My Events
</label>
<div className="ml-auto flex flex-wrap gap-2">
<button disabled={busy} onClick={() => run(async () => (await call<{ message: string }>(`/api/allocations/sets/${current.id}/auto`, "POST", { mode: "together" })).message)} className="inline-flex items-center gap-1 px-2.5 py-1.5 text-sm rounded-lg bg-brand-600 text-white hover:bg-brand-700 disabled:opacity-50" title="Fill groups evenly, keeping people on the same booking (e.g. siblings) together where possible"><Wand2 className="w-4 h-4" /> Auto-allocate (keep bookings together)</button>
<button disabled={busy} onClick={() => run(async () => (await call<{ message: string }>(`/api/allocations/sets/${current.id}/auto`, "POST", { mode: "split" })).message)} className="inline-flex items-center gap-1 px-2.5 py-1.5 text-sm rounded-lg border hover:bg-gray-50 disabled:opacity-50" title="Fill groups evenly, spreading people on the same booking across groups">Auto-allocate (split bookings)</button>
<button onClick={() => printLists(false, false)} className="inline-flex items-center gap-1 px-2.5 py-1.5 text-sm rounded-lg border hover:bg-gray-50"><Printer className="w-4 h-4" /> Group lists</button>
<button onClick={() => printLists(true, false)} className="inline-flex items-center gap-1 px-2.5 py-1.5 text-sm rounded-lg border hover:bg-gray-50"><Printer className="w-4 h-4" /> Master list</button>
{board.event.requiresParentalConsent && (
<button onClick={() => printLists(false, true)} className="inline-flex items-center gap-1 px-2.5 py-1.5 text-sm rounded-lg border hover:bg-gray-50" title="Includes medical details from consent forms. Viewing is recorded in the audit log."><Printer className="w-4 h-4" /> Lists with medical info</button>
)}
<button disabled={busy} onClick={() => run(async () => (await call<{ message: string }>(`/api/allocations/sets/${current.id}/assignments`, "DELETE")).message)} className="inline-flex items-center gap-1 px-2.5 py-1.5 text-sm rounded-lg border hover:bg-gray-50 disabled:opacity-50"><Eraser className="w-4 h-4" /> Clear</button>
<button disabled={busy} onClick={() => run(async () => { await call(`/api/allocations/sets/${current.id}`, "DELETE"); setSetId(""); return `Deleted ${current.name}.`; })} className="inline-flex items-center gap-1 px-2.5 py-1.5 text-sm rounded-lg border text-red-700 hover:bg-red-50 disabled:opacity-50"><Trash2 className="w-4 h-4" /> Delete set</button>
</div>
</div>
{groupForm ? (
<div className="border rounded-xl p-3 bg-white flex flex-wrap items-end gap-2">
<div><label className="block text-xs text-gray-600 mb-1">Name</label><input className="border rounded-lg px-2 py-1.5 text-sm" value={groupForm.name} onChange={e => setGroupForm({ ...groupForm, name: e.target.value })} placeholder="Cabin 1" autoFocus /></div>
<div><label className="block text-xs text-gray-600 mb-1">Capacity</label><input type="number" min="1" className="border rounded-lg px-2 py-1.5 text-sm w-24" value={groupForm.capacity} onChange={e => setGroupForm({ ...groupForm, capacity: e.target.value })} placeholder="No limit" /></div>
<div><label className="block text-xs text-gray-600 mb-1">Gender</label>
<select className="border rounded-lg px-2 py-1.5 text-sm" value={groupForm.gender} onChange={e => setGroupForm({ ...groupForm, gender: e.target.value })}>
<option value="">Anyone</option><option value="male">Males only</option><option value="female">Females only</option>
</select>
</div>
<div><label className="block text-xs text-gray-600 mb-1">Leader</label>
<select className="border rounded-lg px-2 py-1.5 text-sm" value={groupForm.leaderUserId} onChange={e => setGroupForm({ ...groupForm, leaderUserId: e.target.value })}>
<option value="">None</option>
{board.leaders.map(l => <option key={l.id} value={l.id}>{l.name}</option>)}
</select>
</div>
<button disabled={busy || !groupForm.name.trim()} onClick={saveGroup} className="px-3 py-1.5 text-sm rounded-lg bg-brand-600 text-white disabled:opacity-50">{groupForm.id ? "Save" : "Add group"}</button>
<button onClick={() => setGroupForm(null)} className="px-3 py-1.5 text-sm rounded-lg bg-gray-100">Cancel</button>
</div>
) : (
<button onClick={() => setGroupForm({ name: "", capacity: "", gender: "", leaderUserId: "" })} className="inline-flex items-center gap-1 px-2.5 py-1.5 text-sm rounded-lg border hover:bg-gray-50"><Plus className="w-4 h-4" /> Add a group to {current.name}</button>
)}
<DndContext sensors={sensors} onDragEnd={onDragEnd}>
<div className="grid grid-cols-1 md:grid-cols-[minmax(14rem,1fr)_3fr] gap-4 items-start">
<div className="md:sticky md:top-4">
<Column id={UNASSIGNED} title="Not placed yet" subtitle={`${unassigned.length} people · drag them into a group`}>
{unassigned.map(p => <PersonChip key={p.id} person={p} eventStart={board.event.startDate} />)}
</Column>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-3 gap-3">
{current.groups.length === 0 && <p className="text-sm text-gray-500">Add groups (rooms, cabins, buses…) to start placing people.</p>}
{current.groups.map(g => {
const members = membersOf(g.id);
const full = g.capacity != null && members.length >= g.capacity;
const subtitle = [`${members.length}${g.capacity ? ` / ${g.capacity}` : ""}${full ? " · full" : ""}`, g.gender ? (g.gender === "male" ? "males" : "females") : "", g.leader ? `leader ${g.leader.name}` : ""].filter(Boolean).join(" · ");
return (
<Column key={g.id} id={g.id} title={g.name} subtitle={subtitle} full={full} actions={
<div className="flex gap-1 shrink-0">
<button aria-label={`Edit ${g.name}`} onClick={() => setGroupForm({ id: g.id, name: g.name, capacity: g.capacity ? String(g.capacity) : "", gender: g.gender || "", leaderUserId: g.leaderUserId || "" })} className="p-1 rounded hover:bg-gray-100"><Pencil className="w-3.5 h-3.5" /></button>
<button aria-label={`Delete ${g.name}`} onClick={() => run(async () => { await call(`/api/allocations/groups/${g.id}`, "DELETE"); return `${g.name} deleted; its people are back in "Not placed yet".`; })} className="p-1 rounded hover:bg-red-50 text-red-700"><Trash2 className="w-3.5 h-3.5" /></button>
</div>
}>
{members.map(p => <PersonChip key={p.id} person={p} eventStart={board.event.startDate} />)}
</Column>
);
})}
</div>
</div>
</DndContext>
</>
)}
{board && board.sets.length === 0 && board.attendees.length > 0 && (
<p className="text-sm text-gray-600">Start by adding a set, e.g. &quot;Rooms&quot;, &quot;Small groups&quot; or &quot;Buses&quot;. An event can have several.</p>
)}
</div>
)}
</div>
);
}
@@ -7,6 +7,7 @@ import { apiFetch } from "@/lib/api";
import { scoreUser } from "@/lib/fuzzyMatch";
import { useDismissingState } from "@/hooks/useDismissingState";
import { CouponCodeField, AppliedCoupon } from "@/components/shared/CouponCodeField";
import { TransferTicketModal, canOfferTransfer } from "@/components/shared/TransferTicketModal";
import { AttendeeNamesEditor, AttendeeEntries, TicketDelivery, attendeesPayload, consentDetailsMissing } from "@/components/shared/AttendeeNamesEditor";
import { DoorOpen, UserPlus, CreditCard, CheckSquare, Ticket, RotateCcw, type LucideIcon } from "lucide-react";
@@ -105,6 +106,7 @@ export default function AtTheDoorPage() {
const [doorCoupon, setDoorCoupon] = useState<AppliedCoupon | null>(null);
const [doorAllowsNames, setDoorAllowsNames] = useState(false);
const [doorYouthStart, setDoorYouthStart] = useState<string | null>(null);
const [doorCollectGender, setDoorCollectGender] = useState(false);
const [doorNaming, setDoorNaming] = useState(false);
const [doorEntries, setDoorEntries] = useState<AttendeeEntries>({});
const [doorDelivery, setDoorDelivery] = useState<TicketDelivery>("payer");
@@ -163,6 +165,7 @@ export default function AtTheDoorPage() {
setEventOptions(ev.options || ev.eventOptions || []);
setDoorAllowsNames(!!ev.allowNamedAttendees || !!ev.requiresParentalConsent);
setDoorYouthStart(ev.requiresParentalConsent ? ev.startDate : null);
setDoorCollectGender(!!ev.collectGender);
} catch {
setEventOptions([]);
}
@@ -513,6 +516,7 @@ export default function AtTheDoorPage() {
entries: doorEntries, onEntriesChange: setDoorEntries,
delivery: doorDelivery, onDeliveryChange: setDoorDelivery,
consentEventStart: doorYouthStart || undefined,
collectGender: doorCollectGender,
} : null}
/>
<DonationModal
@@ -869,6 +873,8 @@ function DoorTicketsPanel({ token, eventId }: any) {
const [allTickets, setAllTickets] = useState<any[]>([]);
const [loading, setLoading] = useState(false);
const [sendTarget, setSendTarget] = useState<any | null>(null);
const [transferTarget, setTransferTarget] = useState<any | null>(null);
const [notice, setNotice] = useState<string | null>(null);
// Load all tickets for the event once
useEffect(() => {
@@ -1000,6 +1006,7 @@ function DoorTicketsPanel({ token, eventId }: any) {
</div>
)}
{notice && <div className="mt-2 p-2 rounded bg-green-50 text-green-800 text-sm">{notice}</div>}
<div className="mt-3 space-y-2 max-h-96 overflow-auto">
{tickets.map(t => (
<div
@@ -1008,8 +1015,11 @@ function DoorTicketsPanel({ token, eventId }: any) {
>
<div className="text-sm">
<div className="font-medium">
{t.user?.name || "Guest"}
{t.attendee?.name || t.user?.name || "Guest"}
</div>
{t.attendee?.name && t.user?.name && t.attendee.name !== t.user.name && (
<div className="text-xs text-gray-500">Booked by {t.user.name}</div>
)}
<div className="text-xs text-gray-500">
{ticketLabel(t)}
</div>
@@ -1028,6 +1038,14 @@ function DoorTicketsPanel({ token, eventId }: any) {
>
Send
</button>
{canOfferTransfer(t, t.event, true) && (
<button
onClick={() => setTransferTarget(t)}
className="px-3 py-1 text-xs rounded border hover:bg-gray-50"
>
Transfer
</button>
)}
</div>
</div>
))}
@@ -1039,6 +1057,15 @@ function DoorTicketsPanel({ token, eventId }: any) {
)}
</div>
{transferTarget && (
<TransferTicketModal
ticket={transferTarget}
event={transferTarget.event}
token={token}
onClose={() => setTransferTarget(null)}
onDone={(msg) => { setTransferTarget(null); setNotice(msg); load(); }}
/>
)}
{sendTarget && (
<SendTicketsModal
open={true}
@@ -6,6 +6,7 @@ import { useRouter, useSearchParams } from "next/navigation";
import { apiFetch, fetchAllUsers } from "@/lib/api";
import { useDismissingState } from "@/hooks/useDismissingState";
import { Mail, Users, Zap, Clock } from "lucide-react";
import { AllocationGroupFilter } from "@/components/shared/AllocationGroupFilter";
type Attendee = { id: string; name: string; email: string; pref: string };
@@ -336,6 +337,7 @@ function EmailAttendeesPageInner() {
const [subjectDirty, setSubjectDirty] = useState(false);
const [bodyDirty, setBodyDirty] = useState(false);
const [status, setStatus] = useState<'any'|'paid'|'unpaid'|'partial_paid'|'cancelled'>("any");
const [groupId, setGroupId] = useState("");
// Attendees list and selection
const [attendees, setAttendees] = useState<Attendee[]>([]);
const [selectedAttendeeIds, setSelectedAttendeeIds] = useState<string[]>([]);
@@ -420,7 +422,7 @@ function EmailAttendeesPageInner() {
}
const payload: any = {
subject: subject || "(no subject)",
filter: { status: status !== 'any' ? status : undefined, attendeeIds: selectedAttendeeIds && selectedAttendeeIds.length ? selectedAttendeeIds : undefined },
filter: { status: status !== 'any' ? status : undefined, attendeeIds: selectedAttendeeIds && selectedAttendeeIds.length ? selectedAttendeeIds : undefined, allocationGroupId: groupId || undefined },
dryRun: true,
template: templateKey,
};
@@ -460,7 +462,7 @@ function EmailAttendeesPageInner() {
setSending(true);
const payload: any = {
subject,
filter: { status: status !== 'any' ? status : undefined, attendeeIds: selectedAttendeeIds && selectedAttendeeIds.length ? selectedAttendeeIds : undefined },
filter: { status: status !== 'any' ? status : undefined, attendeeIds: selectedAttendeeIds && selectedAttendeeIds.length ? selectedAttendeeIds : undefined, allocationGroupId: groupId || undefined },
template: templateKey,
};
if (templateKey !== 'tickets') {
@@ -607,6 +609,7 @@ function EmailAttendeesPageInner() {
<option value="cancelled">Cancelled</option>
</select>
</div>
<AllocationGroupFilter token={token} eventId={eventId} value={groupId} onChange={setGroupId} />
</div>
{templateKey !== 'tickets' ? (
@@ -659,7 +662,7 @@ function EmailAttendeesPageInner() {
const whenIso = new Date(scheduledAtLocal).toISOString();
const payload = {
subject,
filter: { status: status !== 'any' ? status : undefined, attendeeIds: selectedAttendeeIds && selectedAttendeeIds.length ? selectedAttendeeIds : undefined },
filter: { status: status !== 'any' ? status : undefined, attendeeIds: selectedAttendeeIds && selectedAttendeeIds.length ? selectedAttendeeIds : undefined, allocationGroupId: groupId || undefined },
template: templateKey,
scheduledAt: whenIso,
} as any;
@@ -986,6 +989,16 @@ function AutomationsPanel({ events, token, onInfo, onError }:{ events: any[]; to
const [enablePre, setEnablePre] = React.useState(true);
const [enableFinal, setEnableFinal] = React.useState(true);
const [enableThanks, setEnableThanks] = React.useState(true);
// Sponsors of "sponsor a spot" events get their own thank-you.
const [enableSponsors, setEnableSponsors] = React.useState(true);
const [sponsorSubject, setSponsorSubject] = React.useState('Thank you for sponsoring a spot at {{event.title}}');
const [sponsorBody, setSponsorBody] = React.useState(`Hi {{name}}
Thank you for sponsoring a spot at {{event.title}}. Because of you, someone was able to be there who otherwise couldn't.
[add a highlight or two]
With gratitude`);
const [enablePromo, setEnablePromo] = React.useState(false);
// Subjects/Bodies
@@ -1061,6 +1074,11 @@ function AutomationsPanel({ events, token, onInfo, onError }:{ events: any[]; to
if (thanksBody.trim().startsWith('<')) payload.html = thanksBody; else payload.text = thanksBody;
jobs.push(payload);
}
if (currentEvent?.allowSponsorships && enableSponsors && sponsorSubject.trim() && sponsorBody.trim() && thanksWhen) {
const payload: any = { subject: sponsorSubject, scheduledAt: new Date(thanksWhen).toISOString(), audience: 'sponsors' };
if (sponsorBody.trim().startsWith('<')) payload.html = sponsorBody; else payload.text = sponsorBody;
jobs.push(payload);
}
if (enablePromo && promoSubject.trim() && promoBody.trim() && promoWhen) {
const payload: any = { subject: promoSubject, scheduledAt: new Date(promoWhen).toISOString(), promoEventId: promoEventId || undefined };
if (promoBody.trim().startsWith('<')) payload.html = promoBody; else payload.text = promoBody;
@@ -1157,6 +1175,20 @@ function AutomationsPanel({ events, token, onInfo, onError }:{ events: any[]; to
<textarea className="w-full border rounded px-3 py-2 text-sm" rows={5} value={thanksBody} onChange={e => setThanksBody(e.target.value)} />
</fieldset>
{currentEvent?.allowSponsorships && (
<fieldset className="border rounded p-3">
<legend className="text-sm font-medium">Thank Your Sponsors</legend>
<label className="inline-flex items-center gap-2 text-sm mb-2">
<input type="checkbox" checked={enableSponsors} onChange={e => setEnableSponsors(e.target.checked)} /> Enable
</label>
<p className="text-xs text-gray-500 mb-2">Goes to everyone who sponsored a spot at this event, at the same time as the thank-you above.</p>
<label className="block text-xs text-gray-600 mb-1">Subject</label>
<input className="w-full border rounded px-3 py-2 text-sm" value={sponsorSubject} onChange={e => setSponsorSubject(e.target.value)} />
<label className="block text-xs text-gray-600 mb-1 mt-2">Message</label>
<textarea className="w-full border rounded px-3 py-2 text-sm" rows={5} value={sponsorBody} onChange={e => setSponsorBody(e.target.value)} />
</fieldset>
)}
<fieldset className="border rounded p-3">
<legend className="text-sm font-medium">Next Event Promo</legend>
<div className="flex items-center gap-4 mb-2">
@@ -8,9 +8,12 @@ import { apiFetch, resolveToApiOrigin } from "@/lib/api";
import { useDismissingState } from "@/hooks/useDismissingState";
import { useSiteSettings } from "@/contexts/SiteSettingsContext";
import { Calendar } from "lucide-react";
import { ChoicesEditor } from "@/components/shared/FormFieldExtras";
// ─── helpers ────────────────────────────────────────────────────────────────
const cleanChoices = (opts: unknown): string[] => (Array.isArray(opts) ? opts.map(o => String(o).trim()).filter(Boolean) : []);
function toLocalDT(input: string | Date | null | undefined): string {
if (!input) return "";
const d = new Date(input);
@@ -40,7 +43,7 @@ function DTInput({ label, value, onChange, hint, required }: { label: string; va
// ─── types ───────────────────────────────────────────────────────────────────
type FormFieldType = "yes_no" | "text" | "date" | "numeric" | "statement" | "paragraph";
type FormFieldType = "yes_no" | "text" | "date" | "numeric" | "statement" | "paragraph" | "rating" | "multiple_choice";
type EventFormDef = { isRequired: boolean; fields: { id?: string; type: FormFieldType; label: string; isRequired?: boolean; order?: number; helpText?: string | null; options?: any }[] };
type Tier = { id?: string; deadline: string; price: string; stockLimit: string; order?: number };
@@ -97,7 +100,13 @@ function UploadImageButton({ onUploaded, label = "Upload image" }: { onUploaded:
);
}
function FormBuilder({ value, onChange }: { value: EventFormDef; onChange: (v: EventFormDef) => void }) {
function FormBuilder({ value, onChange, title = "Registration Form Fields", gateLabel = "Required before tickets generate" }: {
value: EventFormDef;
onChange: (v: EventFormDef) => void;
title?: string;
/** Label of the "form must be filled in" switch; null hides it (feedback forms). */
gateLabel?: string | null;
}) {
const fields = value.fields || [];
const add = (type: FormFieldType) => onChange({ ...value, fields: [...fields, { type, label: "", isRequired: false, helpText: "" }] });
const upd = (idx: number, patch: any) => { const c = fields.slice(); c[idx] = { ...c[idx], ...patch }; onChange({ ...value, fields: c }); };
@@ -107,15 +116,17 @@ function FormBuilder({ value, onChange }: { value: EventFormDef; onChange: (v: E
if (t < 0 || t >= c.length) return;
[c[idx], c[t]] = [c[t], c[idx]]; onChange({ ...value, fields: c });
};
const typeLabel = (t: FormFieldType) => ({ text: "Text", numeric: "Number", date: "Date", yes_no: "Yes/No", statement: "Statement", paragraph: "Heading" }[t] || t);
const typeLabel = (t: FormFieldType) => ({ text: "Text", numeric: "Number", date: "Date", yes_no: "Yes/No", statement: "Statement", paragraph: "Heading", rating: "Rating", multiple_choice: "Multiple choice" }[t] || t);
return (
<div className="border rounded-lg p-3 bg-gray-50">
<div className="flex items-center justify-between mb-3">
<div className="text-sm font-semibold">Registration Form Fields</div>
<label className="text-xs flex items-center gap-1.5 cursor-pointer">
<input type="checkbox" checked={!!value.isRequired} onChange={e => onChange({ ...value, isRequired: e.target.checked })} />
Required before tickets generate
</label>
<div className="text-sm font-semibold">{title}</div>
{gateLabel && (
<label className="text-xs flex items-center gap-1.5 cursor-pointer">
<input type="checkbox" checked={!!value.isRequired} onChange={e => onChange({ ...value, isRequired: e.target.checked })} />
{gateLabel}
</label>
)}
</div>
{fields.length === 0
? <p className="text-xs text-gray-500 italic mb-3">No fields yet.</p>
@@ -134,7 +145,8 @@ function FormBuilder({ value, onChange }: { value: EventFormDef; onChange: (v: E
<div className="flex flex-wrap gap-2 mb-1.5">
<select className="border rounded px-2 py-1 text-xs" value={f.type} onChange={e => upd(idx, { type: e.target.value })}>
<option value="text">Text</option><option value="numeric">Number</option><option value="date">Date</option>
<option value="yes_no">Yes/No</option><option value="statement">Statement</option><option value="paragraph">Heading+para</option>
<option value="yes_no">Yes/No</option><option value="rating">Rating (1–5 stars)</option><option value="multiple_choice">Multiple choice</option>
<option value="statement">Statement</option><option value="paragraph">Heading+para</option>
</select>
{f.type !== "statement" && f.type !== "paragraph" && (
<label className="text-xs flex items-center gap-1 cursor-pointer">
@@ -143,6 +155,7 @@ function FormBuilder({ value, onChange }: { value: EventFormDef; onChange: (v: E
)}
</div>
<input className="border rounded px-2 py-1 text-sm w-full mb-1" placeholder={f.type === "paragraph" ? "Heading text" : "Label"} value={f.label} onChange={e => upd(idx, { label: e.target.value })} />
{f.type === "multiple_choice" && <ChoicesEditor options={f.options} onChange={opts => upd(idx, { options: opts })} />}
{f.type === "paragraph"
? <textarea className="border rounded px-2 py-1 text-xs w-full" rows={2} placeholder="Body text" value={f.helpText || ""} onChange={e => upd(idx, { helpText: e.target.value })} />
: f.type !== "statement"
@@ -154,6 +167,8 @@ function FormBuilder({ value, onChange }: { value: EventFormDef; onChange: (v: E
)}
<div className="flex gap-2 flex-wrap">
<button type="button" className="text-xs px-3 py-1.5 rounded bg-brand-600 text-white hover:bg-brand-700" onClick={() => add("text")}>+ Question</button>
<button type="button" className="text-xs px-3 py-1.5 rounded bg-gray-200 text-gray-700" onClick={() => add("rating")}>+ Rating</button>
<button type="button" className="text-xs px-3 py-1.5 rounded bg-gray-200 text-gray-700" onClick={() => add("multiple_choice")}>+ Multiple choice</button>
<button type="button" className="text-xs px-3 py-1.5 rounded bg-gray-200 text-gray-700" onClick={() => add("statement")}>+ Statement</button>
<button type="button" className="text-xs px-3 py-1.5 rounded bg-gray-200 text-gray-700" onClick={() => add("paragraph")}>+ Heading</button>
</div>
@@ -759,8 +774,8 @@ function NotifyRecipientsManager({ eventId, creator, initialRecipients }: { even
// ─── steps ───────────────────────────────────────────────────────────────────
const STEPS = ["Basic Details", "Items & Pricing", "Sections", "Form", "Visibility", "Notifications", "Attachments"] as const;
type StepIdx = 0 | 1 | 2 | 3 | 4 | 5 | 6;
const STEPS = ["Basic Details", "Items & Pricing", "Sections", "Form", "Visibility", "Notifications", "Attachments", "Feedback"] as const;
type StepIdx = 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7;
const PRICING_SUBSTEPS = ["Options", "Variants", "Early Birds"] as const;
type PricingSubstep = 0 | 1 | 2;
@@ -773,6 +788,8 @@ interface EventDraft {
requiresParentalConsent: boolean; consentText: string;
consentCollectMedical: boolean; consentCollectEmergency: boolean; consentCollectPhoto: boolean;
waitlistEnabled: boolean; waitlistOfferHours: string; waitlistPartialOffers: boolean;
allowTicketTransfer: boolean; collectGender: boolean;
allowSponsorships: boolean; sponsorshipGoalSpots: string; showSponsorshipProgress: boolean;
}
const blankDraft = (location = ""): EventDraft => ({
@@ -784,6 +801,8 @@ const blankDraft = (location = ""): EventDraft => ({
requiresParentalConsent: false, consentText: "",
consentCollectMedical: true, consentCollectEmergency: true, consentCollectPhoto: true,
waitlistEnabled: false, waitlistOfferHours: "24", waitlistPartialOffers: true,
allowTicketTransfer: true, collectGender: false,
allowSponsorships: false, sponsorshipGoalSpots: "", showSponsorshipProgress: true,
});
const blankOptions = (): OptionDraft[] => [
@@ -822,6 +841,9 @@ function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) {
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,
allowTicketTransfer: ev.allowTicketTransfer !== false,
collectGender: !!ev.collectGender,
allowSponsorships: !!ev.allowSponsorships, sponsorshipGoalSpots: ev.sponsorshipGoalSpots ? String(ev.sponsorshipGoalSpots) : "", showSponsorshipProgress: ev.showSponsorshipProgress !== false,
contactName: ev.contactName || "", contactPhone: ev.contactPhone || "", contactEmail: ev.contactEmail || "",
location: ev.location || "",
} : blankDraft(settings.org_address || ""));
@@ -872,10 +894,57 @@ function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) {
if (mode !== "edit" || !ev?.id || formLoadedRef.current) return;
apiFetch<any>(`/api/events/${ev.id}`).then(full => {
const f = full?.form;
if (f) setFormDef({ isRequired: !!f.isRequired, fields: (f.fields || []).map((x: any) => ({ id: x.id, type: x.type, label: x.label, isRequired: !!x.isRequired, helpText: x.helpText || "" })) });
if (f) setFormDef({ isRequired: !!f.isRequired, fields: (f.fields || []).map((x: any) => ({ id: x.id, type: x.type, label: x.label, isRequired: !!x.isRequired, helpText: x.helpText || "", options: x.options || undefined })) });
}).catch(() => {}).finally(() => { formLoadedRef.current = true; });
}, [mode, ev?.id]);
// ── post-event feedback ──
const [fbEnabled, setFbEnabled] = useState(false);
const [fbDef, setFbDef] = useState<EventFormDef>({ isRequired: false, fields: [] });
const [fbSettings, setFbSettings] = useState({ sendAt: "", audience: "checked_in", anonymous: false, intro: "" });
const [fbMeta, setFbMeta] = useState<{ sentAt: string | null; invited: number; responses: number } | null>(null);
useEffect(() => {
if (mode !== "edit" || !ev?.id || !token) return;
apiFetch<any>(`/api/feedback/event/${ev.id}`, { authToken: token }).then(f => {
if (!f) return;
setFbEnabled(true);
setFbDef({ isRequired: false, fields: (f.fields || []).map((x: any) => ({ id: x.id, type: x.type, label: x.label, isRequired: !!x.isRequired, helpText: x.helpText || "", options: x.options || undefined })) });
setFbSettings({ sendAt: toLocalDT(f.sendAt), audience: f.audience || "checked_in", anonymous: !!f.anonymous, intro: f.intro || "" });
setFbMeta({ sentAt: f.sentAt, invited: f.invited, responses: f.responses });
}).catch(() => {});
}, [mode, ev?.id, token]);
const enableFeedback = (on: boolean) => {
setFbEnabled(on);
if (on && fbDef.fields.length === 0) {
setFbDef({ isRequired: false, fields: [
{ type: "rating", label: "Overall, how would you rate the event?", isRequired: true, helpText: "" },
{ type: "rating", label: "How would you rate the organisation?", isRequired: false, helpText: "" },
{ type: "text", label: "What did you enjoy most?", isRequired: false, helpText: "" },
{ type: "text", label: "What could we do better?", isRequired: false, helpText: "" },
] });
}
if (on && !fbSettings.sendAt && draft.endDate) {
const next = new Date(draft.endDate);
next.setDate(next.getDate() + 1);
next.setHours(9, 0, 0, 0);
setFbSettings(s => ({ ...s, sendAt: toLocalDT(next) }));
}
};
const saveFeedback = async (eventId: string) => {
if (fbEnabled) {
await apiFetch(`/api/feedback/event/${eventId}`, {
method: "PUT", authToken: token || undefined,
body: {
sendAt: fbSettings.sendAt ? new Date(fbSettings.sendAt).toISOString() : null,
audience: fbSettings.audience, anonymous: fbSettings.anonymous, intro: fbSettings.intro || null,
fields: fbDef.fields.filter(f => f.label?.trim()).map((f, i) => ({ type: f.type, label: f.label, isRequired: !!f.isRequired, order: i, helpText: f.helpText || null, options: f.type === "multiple_choice" ? cleanChoices(f.options) : undefined })),
},
});
} else if (fbMeta) {
await apiFetch(`/api/feedback/event/${eventId}`, { method: "DELETE", authToken: token || undefined });
}
};
const upd = (patch: Partial<EventDraft>) => setDraft(d => ({ ...d, ...patch }));
// ── auto-fill first option's price from the event base price (create mode only, until the
@@ -914,7 +983,7 @@ function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) {
if (mode === "edit") {
await apiFetch(`/api/events/${eventId}`, {
method: "PUT", authToken: token || undefined,
body: { form: { isRequired: !!formDef.isRequired, fields: formDef.fields.filter(f => f.label?.trim()).map((f, i) => ({ type: f.type, label: f.label, isRequired: !!f.isRequired, order: i, helpText: f.helpText || null })) } }
body: { form: { isRequired: !!formDef.isRequired, fields: formDef.fields.filter(f => f.label?.trim()).map((f, i) => ({ type: f.type, label: f.label, isRequired: !!f.isRequired, order: i, helpText: f.helpText || null, options: f.type === "multiple_choice" ? cleanChoices(f.options) : undefined })) } }
});
}
return;
@@ -965,7 +1034,7 @@ function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) {
// Handle form save (edit only)
await apiFetch(`/api/events/${eventId}`, {
method: "PUT", authToken: token || undefined,
body: { form: { isRequired: !!formDef.isRequired, fields: formDef.fields.filter(f => f.label?.trim()).map((f, i) => ({ type: f.type, label: f.label, isRequired: !!f.isRequired, order: i, helpText: f.helpText || null })) } }
body: { form: { isRequired: !!formDef.isRequired, fields: formDef.fields.filter(f => f.label?.trim()).map((f, i) => ({ type: f.type, label: f.label, isRequired: !!f.isRequired, order: i, helpText: f.helpText || null, options: f.type === "multiple_choice" ? cleanChoices(f.options) : undefined })) } }
});
} else {
// New option added during edit
@@ -1102,6 +1171,11 @@ function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) {
consentCollectEmergency: draft.consentCollectEmergency,
consentCollectPhoto: draft.consentCollectPhoto,
waitlistEnabled: draft.waitlistEnabled,
allowTicketTransfer: draft.allowTicketTransfer,
collectGender: draft.collectGender,
allowSponsorships: draft.allowSponsorships,
sponsorshipGoalSpots: draft.allowSponsorships && draft.sponsorshipGoalSpots ? parseInt(draft.sponsorshipGoalSpots, 10) || null : null,
showSponsorshipProgress: draft.showSponsorshipProgress,
waitlistOfferHours: parseInt(draft.waitlistOfferHours, 10) || 24,
waitlistPartialOffers: draft.waitlistPartialOffers,
contactName: draft.contactName || undefined,
@@ -1115,13 +1189,15 @@ function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) {
await apiFetch(`/api/events/${ev.id}`, { method: "PUT", authToken: token || undefined, body });
await saveOptions(ev.id);
// Form is saved inside saveOptions for edit mode; avoid double call
await saveFeedback(ev.id);
} else {
if (formDef.isRequired || formDef.fields.length > 0) {
body.form = { isRequired: !!formDef.isRequired, fields: formDef.fields.filter(f => f.label?.trim()).map((f, i) => ({ type: f.type, label: f.label, isRequired: !!f.isRequired, order: i, helpText: f.helpText || null })) };
body.form = { isRequired: !!formDef.isRequired, fields: formDef.fields.filter(f => f.label?.trim()).map((f, i) => ({ type: f.type, label: f.label, isRequired: !!f.isRequired, order: i, helpText: f.helpText || null, options: f.type === "multiple_choice" ? cleanChoices(f.options) : undefined })) };
}
const created: any = await apiFetch("/api/events", { method: "POST", authToken: token || undefined, body });
if (created?.id) {
await saveOptions(created.id);
await saveFeedback(created.id);
// Fetch event to get final option IDs for sections
const fullEvent: any = await apiFetch(`/api/events/${created.id}`).catch(() => null);
const createdOptions = fullEvent?.eventOptions || [];
@@ -1345,6 +1421,46 @@ function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) {
</div>
)}
{step === 7 && (
<div className="space-y-3">
<p className="text-xs text-gray-500">Ask people what they thought, automatically after the event. Responses appear in Reports → Feedback summary.</p>
<label className="flex items-start gap-2 text-sm cursor-pointer">
<input type="checkbox" className="mt-0.5" checked={fbEnabled} onChange={e => enableFeedback(e.target.checked)} />
<span><strong>Send a feedback form after the event</strong></span>
</label>
{fbEnabled && (
<>
{fbMeta?.sentAt && (
<div className="text-xs rounded bg-green-50 text-green-800 p-2">Sent {new Date(fbMeta.sentAt).toLocaleString()} to {fbMeta.invited} {fbMeta.invited === 1 ? "person" : "people"} · {fbMeta.responses} response(s).{fbMeta.responses > 0 ? " The questions can't be changed now that people have answered." : ""}</div>
)}
<div className="grid sm:grid-cols-2 gap-3">
<div>
<label className="block text-xs text-gray-600 mb-1">Send at</label>
<input type="datetime-local" className="border rounded px-2 py-1.5 text-sm w-full" value={fbSettings.sendAt} disabled={!!fbMeta?.sentAt} onChange={e => setFbSettings(s => ({ ...s, sendAt: e.target.value }))} />
</div>
<div>
<label className="block text-xs text-gray-600 mb-1">Send to</label>
<select className="border rounded px-2 py-1.5 text-sm w-full" value={fbSettings.audience} onChange={e => setFbSettings(s => ({ ...s, audience: e.target.value }))}>
<option value="checked_in">Everyone who checked in</option>
<option value="all">Everyone registered</option>
</select>
</div>
</div>
<label className="flex items-start gap-2 text-sm cursor-pointer">
<input type="checkbox" className="mt-0.5" checked={fbSettings.anonymous} onChange={e => setFbSettings(s => ({ ...s, anonymous: e.target.checked }))} />
<span>Anonymous — answers aren&apos;t linked to people&apos;s names</span>
</label>
<div>
<label className="block text-xs text-gray-600 mb-1">Intro (optional)</label>
<textarea className="border rounded px-2 py-1.5 text-sm w-full" rows={2} value={fbSettings.intro} onChange={e => setFbSettings(s => ({ ...s, intro: e.target.value }))} placeholder="Thanks for coming! Tell us how it went." />
</div>
<p className="text-xs text-gray-500">Anyone who hasn&apos;t answered gets one reminder 3 days later.</p>
<FormBuilder value={fbDef} onChange={setFbDef} title="Feedback questions" gateLabel={null} />
</>
)}
</div>
)}
{/* ── Step 4: Visibility ── */}
{step === 4 && (
<div className="space-y-3">
@@ -1362,6 +1478,12 @@ function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) {
<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>
{(draft.allowNamedAttendees || draft.requiresParentalConsent) && (
<label className="flex items-start gap-2 text-sm cursor-pointer ml-6">
<input type="checkbox" className="mt-0.5" checked={draft.collectGender} onChange={e => upd({ collectGender: e.target.checked })} />
<span><strong>Ask each person&apos;s gender</strong> — needed to split rooms or cabins by gender (Rooms &amp; groups)</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 })} />
@@ -1391,6 +1513,35 @@ function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) {
)}
</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">Sponsor a spot</div>
<label className="flex items-start gap-2 text-sm cursor-pointer">
<input type="checkbox" className="mt-0.5" checked={draft.allowSponsorships} onChange={e => upd({ allowSponsorships: e.target.checked })} />
<span><strong>Allow sponsorships</strong> — a &quot;Sponsor a spot&quot; button on the event page lets people pay for spots (or any amount) for those who can&apos;t afford to come. The money goes into a sponsorship pool you assign under Payments → Donations</span>
</label>
{draft.allowSponsorships && (
<>
<label className="flex items-center gap-2 text-sm">
Goal
<input type="number" min="1" className="border rounded px-2 py-1 text-sm w-20" value={draft.sponsorshipGoalSpots} onChange={e => upd({ sponsorshipGoalSpots: e.target.value })} placeholder="—" />
spots (optional)
</label>
<label className="flex items-start gap-2 text-sm cursor-pointer">
<input type="checkbox" className="mt-0.5" checked={draft.showSponsorshipProgress} onChange={e => upd({ showSponsorshipProgress: e.target.checked })} />
<span>Show progress on the event page (&quot;12 of 20 spots sponsored&quot;)</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">Ticket transfer</div>
<label className="flex items-start gap-2 text-sm cursor-pointer">
<input type="checkbox" className="mt-0.5" checked={draft.allowTicketTransfer} onChange={e => upd({ allowTicketTransfer: e.target.checked })} />
<span><strong>Let people transfer their tickets</strong> — before the event, a ticket that hasn&apos;t been scanned can be handed to someone else, who gets a new QR code (supervisors can always transfer)</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">
@@ -1,4 +1,5 @@
"use client";
import { ExtraFieldInput, isExtraFieldType, ChoicesEditor } from "@/components/shared/FormFieldExtras";
import React, { useEffect, useMemo, useState } from "react";
import { useAuth } from "@/hooks/useAuth";
@@ -7,7 +8,7 @@ import { apiFetch, fetchAllUsers } from "@/lib/api";
import { useDismissingState } from "@/hooks/useDismissingState";
import { FileText } from "lucide-react";
type FormFieldType = 'yes_no' | 'text' | 'date' | 'numeric' | 'statement' | 'paragraph';
type FormFieldType = 'yes_no' | 'text' | 'date' | 'numeric' | 'statement' | 'paragraph' | 'rating' | 'multiple_choice';
type EventFormDef = { isRequired: boolean; fields: { id?: string; type: FormFieldType; label: string; isRequired?: boolean; order?: number; helpText?: string|null; options?: any }[] };
@@ -36,7 +37,7 @@ function FormBuilder({ value, onChange }: { value: EventFormDef; onChange: (v: E
};
const typeLabel = (type: FormFieldType) => {
const labels: Record<FormFieldType, string> = { text: 'Text', numeric: 'Number', date: 'Date', yes_no: 'Yes/No', statement: 'Statement', paragraph: 'Heading' };
const labels: Record<FormFieldType, string> = { text: 'Text', numeric: 'Number', date: 'Date', yes_no: 'Yes/No', statement: 'Statement', paragraph: 'Heading', rating: 'Rating', multiple_choice: 'Multiple choice' };
return labels[type] || type;
};
@@ -73,6 +74,8 @@ function FormBuilder({ value, onChange }: { value: EventFormDef; onChange: (v: E
<option value="numeric">Number</option>
<option value="date">Date</option>
<option value="yes_no">Yes / No</option>
<option value="rating">Rating (1–5 stars)</option>
<option value="multiple_choice">Multiple choice</option>
<option value="statement">Statement (display text)</option>
<option value="paragraph">Heading + paragraph</option>
</select>
@@ -89,6 +92,7 @@ function FormBuilder({ value, onChange }: { value: EventFormDef; onChange: (v: E
value={f.label}
onChange={e => updateField(idx, { label: e.target.value })}
/>
{f.type === 'multiple_choice' && <ChoicesEditor options={f.options} onChange={opts => updateField(idx, { options: opts })} />}
{f.type === 'paragraph' ? (
<textarea
className="border rounded px-2 py-1 text-xs w-full"
@@ -288,7 +292,7 @@ export default function FormsBrowserPage() {
const evFull = await apiFetch<any>(`/api/events/${encodeURIComponent(eventId)}`);
const f = evFull?.form;
if (f && Array.isArray(f.fields)) {
setFormDef({ isRequired: !!f.isRequired, fields: f.fields.map((x: any) => ({ id: x.id, type: x.type, label: x.label, isRequired: !!x.isRequired, order: x.order, helpText: x.helpText || '' })) });
setFormDef({ isRequired: !!f.isRequired, fields: f.fields.map((x: any) => ({ id: x.id, type: x.type, label: x.label, isRequired: !!x.isRequired, order: x.order, helpText: x.helpText || '', options: x.options || undefined })) });
} else {
setFormDef({ isRequired: false, fields: [] });
}
@@ -310,7 +314,7 @@ export default function FormsBrowserPage() {
if (!token || !selectedEventId) return;
setError(null); setInfo(null);
try {
const fields = (formDef.fields || []).filter(f => (f.label || '').trim().length > 0).map((f, i) => ({ type: f.type, label: f.label, isRequired: !!f.isRequired && f.type !== 'statement' && f.type !== 'paragraph', order: i, helpText: f.helpText || null }));
const fields = (formDef.fields || []).filter(f => (f.label || '').trim().length > 0).map((f, i) => ({ type: f.type, label: f.label, isRequired: !!f.isRequired && f.type !== 'statement' && f.type !== 'paragraph', order: i, helpText: f.helpText || null, options: f.type === 'multiple_choice' && Array.isArray(f.options) ? f.options.map((o: any) => String(o).trim()).filter(Boolean) : undefined }));
await apiFetch(`/api/events/${encodeURIComponent(selectedEventId)}`, { method: 'PUT', authToken: token, body: { form: { isRequired: !!formDef.isRequired, fields } } });
setInfo('Form saved');
} catch (e:any) {
@@ -362,7 +366,7 @@ export default function FormsBrowserPage() {
const evFull = await apiFetch<any>(`/api/events/${encodeURIComponent(reg.event.id)}`);
const f = evFull?.form;
if (f && Array.isArray(f.fields)) {
setFillForm({ isRequired: !!f.isRequired, fields: f.fields.map((x:any)=>({ id:x.id, type:x.type, label:x.label, isRequired:!!x.isRequired, order:x.order, helpText:x.helpText||'' })) });
setFillForm({ isRequired: !!f.isRequired, fields: f.fields.map((x:any)=>({ id:x.id, type:x.type, label:x.label, isRequired:!!x.isRequired, order:x.order, helpText:x.helpText||'', options: x.options || undefined })) });
} else {
setFillForm({ isRequired: false, fields: [] });
}
@@ -710,6 +714,9 @@ export default function FormsBrowserPage() {
{f.type === 'date' && (
<input type="date" className="border rounded px-2 py-1 text-sm w-full" value={resp.answers[f.id]||''} onChange={e=> setAnswer(idx, f.id!, e.target.value)} />
)}
{isExtraFieldType(f.type) && (
<ExtraFieldInput field={f} idPrefix={`r${idx}`} value={resp.answers[f.id!]||''} onChange={v => setAnswer(idx, f.id!, v)} />
)}
{f.type === 'yes_no' && (
<select className="border rounded px-2 py-1 text-sm w-full" value={resp.answers[f.id]||''} onChange={e=> setAnswer(idx, f.id!, e.target.value)}>
<option value="">Select…</option>
@@ -1,4 +1,5 @@
"use client";
import { ExtraFieldInput, isExtraFieldType } from "@/components/shared/FormFieldExtras";
import React, { useEffect, useState } from "react";
import { useAuth } from "@/hooks/useAuth";
@@ -7,7 +8,7 @@ import { apiFetch } from "@/lib/api";
import { useDismissingState } from "@/hooks/useDismissingState";
import { UserPlus } from "lucide-react";
type FormField = { id: string; type: 'yes_no'|'text'|'date'|'numeric'|'statement'|'paragraph'; label: string; isRequired?: boolean; helpText?: string|null };
type FormField = { id: string; type: 'yes_no'|'text'|'date'|'numeric'|'statement'|'paragraph'|'rating'|'multiple_choice'; label: string; isRequired?: boolean; helpText?: string|null; options?: unknown };
export default function ManualRegistrationPage() {
const { user, token, loading } = useAuth();
@@ -178,7 +179,9 @@ function AttendeeFormsSection({ registration, form, formsData, setFormsData }: {
) : (
<>
<label className="block text-xs text-gray-600 mb-1">{f.label}{f.isRequired ? ' *' : ''}</label>
{f.type === 'yes_no' ? (
{isExtraFieldType(f.type) ? (
<ExtraFieldInput field={f} idPrefix={`a${idx}`} value={formsData[idx]?.[f.id] || ''} onChange={v => update(idx, f.id, v)} />
) : f.type === 'yes_no' ? (
<select className="border rounded px-2 py-1 text-sm" value={formsData[idx]?.[f.id] || ''} onChange={e => update(idx, f.id, e.target.value)}>
<option value="">Select</option>
<option value="yes">Yes</option>
@@ -624,6 +624,7 @@ export default function ManualRegistrationPage() {
onDeliveryChange={setDelivery}
payerLabel="the person registering"
consentEventStart={selectedIsYouth ? selectedEv.startDate : undefined}
collectGender={!!selectedEv?.collectGender}
/>
</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, TicketPercent, ShieldCheck, ListChecks,
UserPlus, FileText, MessageCircle, BarChart2, Mail, TicketPercent, ShieldCheck, ListChecks, WifiOff, BedDouble, Filter,
} from "lucide-react";
import { StatCard, StatCardRow } from "@/components/shared/StatCard";
import { QuickActionTile, QuickActionGrid } from "@/components/shared/QuickActionTile";
@@ -24,6 +24,8 @@ 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/allocations", label: "Rooms & groups", description: "Put attendees into rooms, groups or buses", icon: BedDouble },
{ 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 },
@@ -37,6 +39,7 @@ const QUICK_ACTIONS = [
type OverviewMetric = { thisMonth: number; lastMonth: number; pctChange: number | null };
type Overview = {
funnel?: { views: number; paid: number; conversion: number | null } | null;
activeEvents: number;
registrations: OverviewMetric;
ticketsSold: OverviewMetric;
@@ -116,12 +119,15 @@ export default function SupervisorDashboardPage() {
</div>
)}
<StatCardRow>
<StatCardRow cols={overview?.funnel ? 6 : 5}>
<StatCard icon={Calendar} label="Active events" value={overview ? formatCount(overview.activeEvents) : "—"} tone="brand" href="/dashboard/supervisor/events" linkLabel="View all events" />
<StatCard icon={Banknote} label="Revenue (past month)" value={overview ? formatRand(overview.revenue.thisMonth) : "—"} tone="green" href={`${REPORTS_URL}?report=revenue&range=trailing_month`} linkLabel="View report" delta={overview ? { value: overview.revenue.pctChange } : undefined} />
<StatCard icon={Gift} label="Donations (past month)" value={overview ? formatRand(overview.donations.thisMonth) : "—"} tone="rose" href={`${REPORTS_URL}?report=donations&range=trailing_month`} linkLabel="View report" delta={overview ? { value: overview.donations.pctChange } : undefined} />
<StatCard icon={Users} label="Registrations (past month)" value={overview ? formatCount(overview.registrations.thisMonth) : "—"} tone="blue" href={`${REPORTS_URL}?report=regStatus&range=trailing_month`} linkLabel="View report" delta={overview ? { value: overview.registrations.pctChange } : undefined} />
<StatCard icon={Ticket} label="Tickets sold (past month)" value={overview ? formatCount(overview.ticketsSold.thisMonth) : "—"} tone="amber" href={`${REPORTS_URL}?report=usage&range=trailing_month`} linkLabel="View report" delta={overview ? { value: overview.ticketsSold.pctChange } : undefined} />
{overview?.funnel && (
<StatCard icon={Filter} label="Conversion (past month)" value={overview.funnel.conversion != null ? `${overview.funnel.conversion}%` : "—"} tone="violet" href={`${REPORTS_URL}?report=funnel&range=trailing_month`} linkLabel={`${overview.funnel.paid} of ${overview.funnel.views} visits`} />
)}
</StatCardRow>
<div className="border rounded-xl p-4 bg-white shadow-sm my-6">
@@ -832,7 +832,7 @@ function PaymentsContent() {
return (
<li key={p.id} className={`border rounded p-2 ${isRefund ? 'bg-red-50' : ''}`}>
<div className="flex justify-between">
<div className={`font-medium ${isRefund ? 'text-red-700' : ''}`}>{isRefund ? '-' : ''}R {Math.abs(amt).toFixed(2)} {p.isDonation ? <span className="text-xs text-emerald-700">(donation)</span> : null} {isRefund ? <span className="text-xs text-red-700">(refund)</span> : null}</div>
<div className={`font-medium ${isRefund ? 'text-red-700' : ''}`}>{isRefund ? '-' : ''}R {Math.abs(amt).toFixed(2)} {p.isDonation ? <span className="text-xs text-emerald-700">({p.isSponsorship ? 'sponsorship' : 'donation'})</span> : null} {isRefund ? <span className="text-xs text-red-700">(refund)</span> : null}</div>
<div className="text-xs text-gray-500">{new Date(p.createdAt).toLocaleString()}</div>
</div>
<div className="text-xs text-gray-600">Method: {p.method || 'payment'}</div>
@@ -1171,6 +1171,7 @@ function DonationAssignSection({ payments, allUsers, registrations, regOutstandi
const [paymentId, setPaymentId] = useState<string>("");
const [amountStr, setAmountStr] = useState<string>("");
const [assigning, setAssigning] = useState(false);
const [sponsorshipsOnly, setSponsorshipsOnly] = useState(false);
const [err, setErr] = useState<string | null>(null);
useEffect(() => { setRegistrationId(""); setPaymentId(""); }, [userId]);
@@ -1219,10 +1220,11 @@ function DonationAssignSection({ payments, allUsers, registrations, regOutstandi
return payments.filter((p: any) => {
if (!p.isDonation || p.registrationId) return false;
if (String(p.eventId) !== String(eventId)) return false;
if (sponsorshipsOnly && !p.isSponsorship) return false;
const remaining = (p.amount || 0) - (legsById.get(p.id) || 0);
return remaining > 0.000001;
});
}, [payments, selectedRegistration, legsById]);
}, [payments, selectedRegistration, legsById, sponsorshipsOnly]);
const selectedDonation = useMemo(
() => payments.find((p: any) => String(p.id) === String(paymentId)),
@@ -1306,12 +1308,19 @@ function DonationAssignSection({ payments, allUsers, registrations, regOutstandi
<option value="">Select donation…</option>
{donationsForEvent.map((p: any) => {
const remaining = (p.amount || 0) - (legsById.get(p.id) || 0);
const label = `R ${remaining.toFixed(2)} of R ${(p.amount || 0).toFixed(2)} left — ${p.user?.name || p.userId || 'Donor'} — #${String(p.id).slice(0,8)}`;
const donor = p.isSponsorship ? `Sponsorship: ${p.sponsorAnonymous ? 'anonymous' : (p.sponsorName || p.user?.name || 'sponsor')}` : (p.user?.name || p.userId || 'Donor');
const label = `R ${remaining.toFixed(2)} of R ${(p.amount || 0).toFixed(2)} left — ${donor} — #${String(p.id).slice(0,8)}`;
return <option key={p.id} value={p.id} title={label}>{label}</option>;
})}
</select>
<label className="flex items-center gap-2 text-xs text-gray-600 cursor-pointer">
<input type="checkbox" checked={sponsorshipsOnly} onChange={e => { setSponsorshipsOnly(e.target.checked); setPaymentId(""); }} /> Only the sponsorship pool
</label>
{registrationId && donationsForEvent.length === 0 && (
<div className="text-xs text-gray-500">No donations with a remaining balance for this event.</div>
<div className="text-xs text-gray-500">No {sponsorshipsOnly ? "sponsorships" : "donations"} with a remaining balance for this event.</div>
)}
{selectedDonation?.isSponsorship && selectedDonation.sponsorMessage && (
<div className="text-xs text-rose-800 bg-rose-50 rounded p-2">Sponsor&apos;s message: “{selectedDonation.sponsorMessage}”</div>
)}
{selectedDonation && (
@@ -6,6 +6,7 @@ import { useRouter, useSearchParams } from "next/navigation";
import { apiFetch, fetchAllUsers } from "@/lib/api";
import { useDismissingState } from "@/hooks/useDismissingState";
import { MessageCircle, Users, Zap, Send, Clock, type LucideIcon } from "lucide-react";
import { AllocationGroupFilter } from "@/components/shared/AllocationGroupFilter";
// Attendee with preference info
type Attendee = { id: string; name: string; phone: string; pref: string };
@@ -402,6 +403,7 @@ function WhatsAppAttendeesPageInner() {
const [message, setMessage] = useState("");
const [messageDirty, setMessageDirty] = useState(false);
const [status, setStatus] = useState<"any" | "paid" | "unpaid" | "partial_paid" | "cancelled">("any");
const [groupId, setGroupId] = useState("");
const [attendees, setAttendees] = useState<Attendee[]>([]);
const [selectedAttendeeIds, setSelectedAttendeeIds] = useState<string[]>([]);
const [loadingAttendees, setLoadingAttendees] = useState(false);
@@ -449,7 +451,7 @@ function WhatsAppAttendeesPageInner() {
const buildPayload = (dryRun?: boolean) => {
const payload: any = {
filter: { status: status !== "any" ? status : undefined, attendeeIds: selectedAttendeeIds.length ? selectedAttendeeIds : undefined },
filter: { status: status !== "any" ? status : undefined, attendeeIds: selectedAttendeeIds.length ? selectedAttendeeIds : undefined, allocationGroupId: groupId || undefined },
template: templateKey,
dryRun: dryRun || undefined,
};
@@ -499,7 +501,7 @@ function WhatsAppAttendeesPageInner() {
const whenIso = new Date(scheduledAtLocal).toISOString();
const payload: any = {
message: message || undefined,
filter: { status: status !== "any" ? status : undefined, attendeeIds: selectedAttendeeIds.length ? selectedAttendeeIds : undefined },
filter: { status: status !== "any" ? status : undefined, attendeeIds: selectedAttendeeIds.length ? selectedAttendeeIds : undefined, allocationGroupId: groupId || undefined },
template: templateKey, scheduledAt: whenIso,
};
const res = await apiFetch(`/api/events/${encodeURIComponent(eventId)}/whatsapp-attendees/schedule`, { method: "POST", authToken: token, body: payload });
@@ -715,6 +717,7 @@ function WhatsAppAttendeesPageInner() {
<option value="cancelled">Cancelled</option>
</select>
</div>
<AllocationGroupFilter token={token} eventId={eventId} value={groupId} onChange={setGroupId} />
</div>
{templateKey !== "tickets" ? (
@@ -1,4 +1,5 @@
"use client";
import { ExtraFieldInput, isExtraFieldType } from "@/components/shared/FormFieldExtras";
import React, { Suspense, useEffect, useMemo, useState } from "react";
import { useSearchParams, useRouter } from "next/navigation";
import { useAuth } from "@/hooks/useAuth";
@@ -7,7 +8,7 @@ import { useDismissingState } from "@/hooks/useDismissingState";
import { FileText } from "lucide-react";
// Types for form fields
type FormField = { id: string; type: 'yes_no'|'text'|'date'|'numeric'|'statement'|'paragraph'; label: string; isRequired?: boolean; helpText?: string|null };
type FormField = { id: string; type: 'yes_no'|'text'|'date'|'numeric'|'statement'|'paragraph'|'rating'|'multiple_choice'; label: string; isRequired?: boolean; helpText?: string|null; options?: unknown };
function FormsContent() {
const search = useSearchParams();
@@ -198,7 +199,9 @@ function FormsContent() {
) : (
<>
<label className="block text-xs text-gray-600 mb-1">{f.label}{f.isRequired ? ' *' : ''}</label>
{f.type === 'yes_no' ? (
{isExtraFieldType(f.type) ? (
<ExtraFieldInput field={f} idPrefix={`a${idx}`} value={formsData[idx]?.[f.id] || ''} onChange={v => setFormsData(prev => ({ ...prev, [idx]: { ...(prev[idx]||{}), [f.id]: v } }))} />
) : f.type === 'yes_no' ? (
<select className="border rounded px-2 py-1 text-sm" value={formsData[idx]?.[f.id] || ''} onChange={e => setFormsData(prev => ({ ...prev, [idx]: { ...(prev[idx]||{}), [f.id]: e.target.value } }))}>
<option value="">Select</option>
<option value="yes">Yes</option>
+33
View File
@@ -10,6 +10,7 @@ import { ApiImage } from "@/components/shared/ApiImage";
import { useDismissingState } from "@/hooks/useDismissingState";
import { AttendeesPanel } from "@/components/shared/AttendeesPanel";
import { MyWaitlists } from "@/components/shared/MyWaitlists";
import { TransferTicketModal, canOfferTransfer } from "@/components/shared/TransferTicketModal";
import { ClipboardList, Calendar, Ticket, ChevronRight } from "lucide-react";
// Helper formatters
@@ -534,6 +535,8 @@ export default function UserDashboardPage() {
}
};
const [transferTarget, setTransferTarget] = useState<any | null>(null);
// Re-fetch registrations and tickets after something on one changes (attendee names).
const reloadAfterAttendees = async () => {
if (!token) return;
@@ -992,6 +995,11 @@ export default function UserDashboardPage() {
<div className="text-xs font-medium text-blue-700 bg-blue-50 rounded px-2 py-0.5 inline-block">
{t.attendee?.name ? <>For: {t.attendee.name}</> : <>Qty: {t.quantity || 1}</>}
</div>
{(t.attendee?.allocations || []).length > 0 && (
<div className="text-xs text-violet-800 bg-violet-50 rounded px-2 py-0.5 inline-block ml-1">
{[...t.attendee.allocations].sort((a: any, b: any) => (a.set?.order || 0) - (b.set?.order || 0)).map((a: any) => `${a.set.name}: ${a.group.name}`).join(" · ")}
</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>
)}
@@ -1003,6 +1011,21 @@ export default function UserDashboardPage() {
<button onClick={() => whatsappTickets([t.id])} className="px-3 py-1.5 text-sm bg-green-600 text-white rounded hover:bg-green-700 shadow-sm focus:outline-none focus:ring-2 focus:ring-green-500 focus:ring-offset-1">WhatsApp</button>
)}
<button onClick={() => printTickets([t])} className="px-3 py-1.5 text-sm rounded bg-gray-100 hover:bg-gray-200 text-gray-800 shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-1">Print</button>
{(t.transferRequests || [])[0] && (
<span className="w-full text-xs text-violet-800 bg-violet-50 rounded px-2 py-1 flex items-center justify-between gap-2">
<span>Waiting for {t.transferRequests[0].toName} to accept</span>
<button className="underline" onClick={async () => {
try {
const r: any = await apiFetch(`/api/tickets/${t.id}/transfer`, { method: "DELETE", authToken: token! });
setInfo(r?.message || "Transfer cancelled");
reloadAfterAttendees();
} catch (e: any) { setError(e?.message || "Couldn't cancel"); }
}}>Cancel</button>
</span>
)}
{canOfferTransfer(t, t.event) && (
<button onClick={() => setTransferTarget(t)} className="px-3 py-1.5 text-sm rounded border border-gray-300 hover:bg-gray-100 text-gray-800 shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-1" title="Give this ticket to someone else">Transfer</button>
)}
</div>
</li>
);
@@ -1012,6 +1035,16 @@ export default function UserDashboardPage() {
</section>
</div>
{transferTarget && token && (
<TransferTicketModal
ticket={transferTarget}
event={transferTarget.event}
token={token}
onClose={() => setTransferTarget(null)}
onDone={(msg) => { setTransferTarget(null); setInfo(msg); reloadAfterAttendees(); }}
/>
)}
{activeReg && (
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50" onClick={() => { setEditMode(false); setEditError(null); setShowCancelConfirm(false); setActiveRegId(null); }}>
<div className="bg-white rounded-lg shadow-lg max-w-xl w-full mx-4 p-5 max-h-[90vh] overflow-y-auto" onClick={e => e.stopPropagation()}>
+6
View File
@@ -6,6 +6,8 @@ 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 { SponsorSpot } from "@/components/events/SponsorSpot";
import { FunnelTracker } from "@/components/events/FunnelTracker";
import { Calendar, Ticket, Paperclip, Sparkles } from "lucide-react";
export const revalidate = 60;
@@ -42,6 +44,7 @@ type Event = {
contactEmail?: string | null;
location?: string | null;
waitlistEnabled?: boolean;
allowSponsorships?: boolean;
};
import { apiFetch, ApiError, resolveToApiOrigin } from "@/lib/api";
@@ -164,6 +167,7 @@ export default async function EventDetailPage({ params }: { params: Promise<{ id
<div className="min-h-screen flex flex-col">
<Navbar />
<main className="flex-1 py-8 px-4 max-w-6xl mx-auto w-full">
<FunnelTracker eventId={event.id} stage="view" />
<div className="grid lg:grid-cols-3 gap-8">
<div className="lg:col-span-2 space-y-6">
{event.picture ? (
@@ -222,6 +226,7 @@ export default async function EventDetailPage({ params }: { params: Promise<{ id
<div className="mt-4">
<RegisterCta event={event} />
</div>
{event.allowSponsorships && <SponsorSpot eventId={event.id} eventTitle={event.title} />}
</div>
</div>
@@ -235,6 +240,7 @@ export default async function EventDetailPage({ params }: { params: Promise<{ id
<div className="mt-4">
<RegisterCta event={event} />
</div>
{event.allowSponsorships && <SponsorSpot eventId={event.id} eventTitle={event.title} />}
</div>
</div>
</div>
+130
View File
@@ -0,0 +1,130 @@
"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 } from "@/lib/api";
import { formatDate } from "@/lib/date";
import { MessageSquareHeart, CheckCircle2 } from "lucide-react";
import { ExtraFieldInput, isExtraFieldType } from "@/components/shared/FormFieldExtras";
type Field = { id: string; type: string; label: string; isRequired: boolean; helpText: string | null; options: unknown };
type FeedbackInfo = {
event: { title: string; startDate: string };
name: string;
intro: string | null;
anonymous: boolean;
responded: boolean;
fields: Field[];
};
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 an attendee opens from their post-event feedback email or WhatsApp. */
export default function FeedbackPage() {
const params = useParams<{ token: string }>();
const token = params?.token;
const [info, setInfo] = useState<FeedbackInfo | null>(null);
const [loadError, setLoadError] = useState<string | null>(null);
const [answers, setAnswers] = useState<Record<string, string>>({});
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const [done, setDone] = useState(false);
useEffect(() => {
if (!token) return;
apiFetch<FeedbackInfo>(`/api/feedback/public/${encodeURIComponent(token)}`)
.then(setInfo)
.catch(e => setLoadError(e?.message || "This feedback link isn't valid."));
}, [token]);
const set = (id: string, v: string) => setAnswers(prev => ({ ...prev, [id]: v }));
const submit = async (e: React.FormEvent) => {
e.preventDefault();
setBusy(true);
setError(null);
try {
await apiFetch(`/api/feedback/public/${encodeURIComponent(token!)}`, { method: "POST", body: { answers } });
setDone(true);
} catch (err: any) {
setError(err?.message || "Couldn't send your feedback");
} finally {
setBusy(false);
}
};
return (
<div className="min-h-screen flex flex-col bg-gray-50">
<Navbar />
<main className="flex-1 max-w-2xl w-full mx-auto p-4 sm: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">
<MessageSquareHeart className="w-5 h-5 text-brand-600" />
</div>
<div>
<h1 className="text-2xl font-semibold text-gray-900">{info ? `How was ${info.event.title}?` : "Feedback"}</h1>
{info && <p className="text-sm text-gray-500">{formatDate(new Date(info.event.startDate))}</p>}
</div>
</div>
{loadError && <div className="p-4 border rounded-xl bg-white text-sm text-gray-700">{loadError}</div>}
{info && (done || info.responded) && (
<div className="p-5 border rounded-xl bg-white shadow-sm flex items-start gap-3">
<CheckCircle2 className="w-6 h-6 text-green-600 shrink-0" />
<div>
<div className="font-semibold">Thank you{info.name ? `, ${info.name.split(" ")[0]}` : ""}!</div>
<p className="text-sm text-gray-600">{done ? "Your feedback has been sent." : "You've already given feedback for this event."} It really helps us plan the next one.</p>
</div>
</div>
)}
{info && !done && !info.responded && (
<form onSubmit={submit} className="p-5 border rounded-xl bg-white shadow-sm space-y-5">
{info.intro && <p className="text-sm text-gray-700 whitespace-pre-line">{info.intro}</p>}
<p className="text-xs text-gray-500">{info.anonymous ? "Your answers are anonymous — they aren't linked to your name." : `Answering as ${info.name}.`}</p>
{info.fields.map(f => (
<div key={f.id}>
{f.type === "statement" ? (
<p className="text-sm text-gray-700 whitespace-pre-line">{f.label}</p>
) : f.type === "paragraph" ? (
<div className="text-sm text-gray-700">
<div className="font-medium mb-1">{f.label}</div>
{f.helpText && <div className="whitespace-pre-line">{f.helpText}</div>}
</div>
) : (
<>
<label className="block text-sm font-medium text-gray-800 mb-1.5">{f.label}{f.isRequired && <span className="text-red-500"> *</span>}</label>
{isExtraFieldType(f.type) ? (
<ExtraFieldInput field={f} value={answers[f.id] || ""} onChange={v => set(f.id, v)} />
) : f.type === "yes_no" ? (
<div className="flex gap-4 text-sm">
{["yes", "no"].map(v => (
<label key={v} className="flex items-center gap-1.5 cursor-pointer">
<input type="radio" name={f.id} checked={answers[f.id] === v} onChange={() => set(f.id, v)} /> {v === "yes" ? "Yes" : "No"}
</label>
))}
</div>
) : f.type === "numeric" ? (
<input type="number" className={`${inputCls} sm:w-40`} value={answers[f.id] || ""} onChange={e => set(f.id, e.target.value)} />
) : f.type === "date" ? (
<input type="date" className={`${inputCls} sm:w-48`} value={answers[f.id] || ""} onChange={e => set(f.id, e.target.value)} />
) : (
<textarea className={inputCls} rows={3} value={answers[f.id] || ""} onChange={e => set(f.id, e.target.value)} maxLength={4000} />
)}
{f.helpText && <p className="text-xs text-gray-500 mt-1">{f.helpText}</p>}
</>
)}
</div>
))}
{error && <div className="p-2 rounded bg-red-50 text-red-800 text-sm">{error}</div>}
<button type="submit" disabled={busy} className="px-4 py-2 rounded-lg bg-brand-600 text-white hover:bg-brand-700 disabled:opacity-50 font-medium">
{busy ? "Sending…" : "Send feedback"}
</button>
</form>
)}
</main>
<Footer />
</div>
);
}
+5 -2
View File
@@ -1,4 +1,5 @@
"use client";
import { ExtraFieldInput, isExtraFieldType } from "@/components/shared/FormFieldExtras";
import React, { Suspense, useEffect, useMemo, useState } from "react";
import { useSearchParams, useRouter } from "next/navigation";
import { useAuth } from "@/hooks/useAuth";
@@ -8,7 +9,7 @@ import { Footer } from "@/components/layout/Footer";
import { FileText } from "lucide-react";
// Types for form fields
type FormField = { id: string; type: 'yes_no'|'text'|'date'|'numeric'|'statement'|'paragraph'; label: string; isRequired?: boolean; helpText?: string|null };
type FormField = { id: string; type: 'yes_no'|'text'|'date'|'numeric'|'statement'|'paragraph'|'rating'|'multiple_choice'; label: string; isRequired?: boolean; helpText?: string|null; options?: unknown };
function FormsContent() {
const search = useSearchParams();
@@ -249,7 +250,9 @@ function FormsContent() {
) : (
<>
<label className="block text-xs text-gray-600 mb-1">{f.label}{f.isRequired ? ' *' : ''}</label>
{f.type === 'yes_no' ? (
{isExtraFieldType(f.type) ? (
<ExtraFieldInput field={f} idPrefix={`a${idx}`} value={formsData[idx]?.[f.id] || ''} onChange={v => setFormsData(prev => ({ ...prev, [idx]: { ...(prev[idx]||{}), [f.id]: v } }))} />
) : f.type === 'yes_no' ? (
<select className="border rounded px-2 py-1 text-sm" value={formsData[idx]?.[f.id] || ''} onChange={e => setFormsData(prev => ({ ...prev, [idx]: { ...(prev[idx]||{}), [f.id]: e.target.value } }))}>
<option value="">Select</option>
<option value="yes">Yes</option>
+4
View File
@@ -8,6 +8,8 @@ import { ToastProvider } from "@/components/shared/ToastProvider";
import { BannerBar } from "@/components/shared/BannerBar";
import { SetupGuard } from "@/components/shared/SetupGuard";
import HelpFab from "@/components/shared/HelpFab";
import { CookieConsent } from "@/components/shared/CookieConsent";
import { ImpersonationBanner } from "@/components/shared/ImpersonationBanner";
import { API_BASE, resolveToApiOrigin } from "@/lib/api";
import { buildThemeCssVars } from "@/lib/colorScale";
import { appUrl } from "@/lib/siteConfig";
@@ -93,9 +95,11 @@ export default async function RootLayout({ children }: { children: React.ReactNo
<AuthProvider>
<ToastProvider>
<SetupGuard />
<ImpersonationBanner />
<BannerBar />
{children}
<HelpFab />
<CookieConsent />
</ToastProvider>
</AuthProvider>
</SiteSettingsProvider>
+89 -37
View File
@@ -4,23 +4,25 @@ import { Navbar } from "@/components/layout/Navbar";
import { Footer } from "@/components/layout/Footer";
import { useScrollSpy } from "@/hooks/useScrollSpy";
import { useSiteSettings } from "@/contexts/SiteSettingsContext";
import { CookieSettingsButton } from "@/components/shared/CookieSettingsButton";
const sections = [
{ id: "summary", title: "Plain English Summary" },
{ id: "introduction",title: "1. Introduction" },
{ id: "information", title: "2. Information We Collect" },
{ id: "use", title: "3. How We Use Your Information" },
{ id: "whatsapp", title: "4. WhatsApp Communications" },
{ id: "storage", title: "5. Where We Store Your Data" },
{ id: "retention", title: "6. Data Retention" },
{ id: "children", title: "7. Children and Minors" },
{ id: "sharing", title: "8. Sharing of Information" },
{ id: "rights", title: "9. Your Rights (under POPIA)" },
{ id: "cookies", title: "10. Cookies and Local Storage" },
{ id: "security", title: "11. Security" },
{ id: "changes", title: "12. Changes to This Policy" },
{ id: "officer", title: "13. Information Officer" },
{ id: "contact", title: "14. Contact" },
{ id: "others", title: "3. People You Register or Transfer Tickets To" },
{ id: "use", title: "4. How We Use Your Information" },
{ id: "whatsapp", title: "5. WhatsApp Communications" },
{ id: "storage", title: "6. Where We Store Your Data" },
{ id: "retention", title: "7. Data Retention" },
{ id: "children", title: "8. Children, Parental Consent and Health Information" },
{ id: "sharing", title: "9. Who Can See Your Information" },
{ id: "rights", title: "10. Your Rights (under POPIA)" },
{ id: "cookies", title: "11. Cookie Policy (Cookies and Browser Storage)" },
{ id: "security", title: "12. Security" },
{ id: "changes", title: "13. Changes to This Policy" },
{ id: "officer", title: "14. Information Officer" },
{ id: "contact", title: "15. Contact" },
];
export default function PrivacyPolicyPage() {
@@ -89,15 +91,15 @@ export default function PrivacyPolicyPage() {
<h2 className="text-2xl font-semibold text-brand-600 mb-4">🟢 Plain English Summary</h2>
<p>We respect your privacy — here&apos;s what you need to know:</p>
<ul className="list-disc ml-6 space-y-1">
<li>We only collect info needed to register you for events and process payments.</li>
<li>We only collect info needed to run events: registrations, payments, tickets and check-in.</li>
<li>We store your info safely on South African servers.</li>
<li>We don&apos;t sell or share your data with anyone.</li>
<li>You can ask us what info we have about you or request deletion at any time.</li>
<li>Under 18s must register with a parent&apos;s permission.</li>
<li>We don&apos;t sell your data, and only share it with the services that process payments and deliver messages.</li>
<li>If you register other people or pass a ticket on, we only use the email or phone number you type to contact them. We never show you whether someone has an account with us.</li>
<li>For youth events, a parent or guardian gives consent. Medical details are encrypted, seen only by the event leaders who need them, and deleted shortly after the event.</li>
<li>Payments go through Yoco; we never see your card details.</li>
<li>We may send you security alerts (e.g. new login notifications) via email and/or WhatsApp to keep your account safe.</li>
<li>If you opt in to WhatsApp notifications, we use your mobile number to send you event updates, tickets, and account alerts via WhatsApp (powered by WAWP).</li>
<li>We keep your data for up to 5 years after your last event registration.</li>
<li>We send tickets, updates and security alerts by email and/or WhatsApp (powered by WAWP).</li>
<li>Visit statistics are only collected if you click &quot;Allow&quot; on the cookie notice — no third-party analytics.</li>
<li>You can ask what we hold about you, or delete your account, at any time. We keep data for up to 5 years after your last event registration.</li>
</ul>
</section>
@@ -129,21 +131,36 @@ function Section({
],
information: [
"We collect the following information when you interact with our platform:",
"• Account Information: name, email address, phone number, and password (encrypted).",
"• Event Information: events you register for, ticket selections, and related preferences.",
"• Payment Information: processed by Yoco; we receive only payment status and reference numbers.",
"• Communication Data: emails or messages exchanged with our team.",
"• Account information: name, email address, phone number, notification preference and password (stored only as an encrypted hash). If you use two-factor authentication, we keep which method you use; codes are short-lived and never stored in readable form.",
"• Security records: sign-ins, password changes and similar account events, with the time, IP address and device type, so we can alert you and investigate misuse.",
"• Event information: events you register for, ticket selections, discount codes used, attendee form answers, waitlist requests, ticket check-ins (when your ticket was scanned), and room or group allocations.",
"• Details about other people you register (see section 3), and parental consent details for youth events (see section 8).",
"• Payment information: processed by Yoco; we receive only payment status, amount and reference numbers. If you sponsor a spot, we record the amount and, unless you give anonymously, the name and message you choose to add.",
"• Feedback: answers you give to post-event feedback forms. If an event's feedback is anonymous, your answers are not linked to your name.",
"• Communication data: emails or messages exchanged with our team.",
"• Visit statistics: only if you allow them on the cookie notice (see section 11).",
"We do not collect or store credit card numbers or bank details.",
"Administrator-created accounts (e.g. walk-in event registrations) may be created with a mobile number only. These accounts use a placeholder email address and can be activated by the account holder at any time.",
"Accounts may be created by an administrator, or when someone registers you or transfers a ticket to you, with a mobile number or email address only. You can activate such an account at any time.",
],
others: [
"You can register other people (for example family members) and give each one their own ticket, or pass your ticket on to someone else.",
"• You type their name and, optionally, their email address or phone number. There is no search or list of existing users to choose from, and we never tell you whether the details you enter belong to an account.",
"• We use those details only to send that person their ticket, their attendee form, a parental consent request (youth events), or — for a transfer — a link to accept the ticket. A transfer only happens once they accept it; they can also decline.",
"• If the email or phone number matches an existing account, the ticket also appears in that person's own account. Otherwise an account may be created for them, which they can activate if they wish.",
"• Some events ask for each person's date of birth or gender (for example, to allocate rooms at a camp).",
"By entering someone else's details, you confirm that you are allowed to share them with us for this purpose (for a child, that you are their parent or guardian or have their permission).",
],
use: [
"We use your information to:",
"• Process event registrations and payments.",
"• Process event registrations, payments, discounts and refunds.",
"• Communicate event updates, confirmations, or changes via email and/or WhatsApp (based on your preference).",
"• Send security notifications for new logins, password changes, and account actions on your account.",
"• Deliver your event tickets via email attachment and/or WhatsApp PDF (based on your preference).",
"• Manage user accounts and event attendance records.",
"• Improve our website and event experience.",
"• Check tickets in at the door, including with scanners that keep working without an internet connection.",
"• Offer places from a waitlist, arrange rooms and groups, and ask for parental consent on youth events.",
"• Send you an optional feedback form after an event, with one reminder.",
"• Manage user accounts and event attendance records, and give support when you ask for help.",
"• Improve our events and website (using anonymous statistics, and only with your permission).",
"• Comply with legal obligations.",
],
whatsapp: [
@@ -165,19 +182,33 @@ function Section({
],
retention: [
"We retain your personal data for up to 5 years after your last event registration, or until you request deletion of your account — whichever comes first.",
"Medical and emergency-contact details from parental consent forms are deleted a set number of days after the event ends (30 days unless the organisation sets a different period). The record of who gave consent, when, and to which wording is kept.",
"Detailed visit statistics are combined into anonymous daily totals after 90 days.",
"Ticket lists downloaded to a staff member's scanner for offline check-in are deleted from that device when offline mode is ended.",
"Records of support access to accounts (see section 9) and other audit records are kept for accountability.",
"You can delete your account at any time from the Profile & Security page in your account. Deletion anonymises your personal information (name, email address, and phone number are replaced with placeholder values) while retaining event and payment records in anonymised form for financial record-keeping purposes.",
"An administrator may also anonymise your account data on your request. To request this, contact our Information Officer (see section 13).",
"An administrator may also anonymise your account data on your request. To request this, contact our Information Officer (see section 14).",
],
children: [
"Users under 18 may only create an account or register for events with the consent and supervision of a parent or guardian.",
"We do not knowingly collect or process personal information of minors without such consent.",
"For youth events, each attendee's date of birth is required. For anyone under 18, the parent or guardian named at registration is sent a consent request by email and WhatsApp, where they review the event details and give or decline consent. Their typed name, the time, the wording agreed to, and the IP address and device type used are recorded as proof of consent. A young person can't be checked in until consent is given.",
"The consent form may ask for medical information (such as allergies, medication and conditions) and an emergency contact. This is special personal information under POPIA. We collect it only with the parent's or guardian's consent, for the young person's safety at the event. It is stored encrypted and shown only to supervisors who need it, and each time the medical list is viewed this is recorded. It is deleted after the event as described in section 7.",
"A parent or guardian may also say whether photos of the young person may be used.",
],
sharing: [
"We do not sell, rent, or share your personal information with any third parties.",
"The only exceptions are:",
"• Processing payments securely through Yoco (a South African payment processor).",
"• Delivering WhatsApp messages through WAWP (wawp.net), a WhatsApp Business API provider — only your mobile number and message content are shared, solely for delivery purposes.",
"• Where required by South African law.",
"We do not sell or rent your personal information. Outside our organisation, it is shared only with:",
"• Yoco (a South African payment processor), to process payments securely.",
"• WAWP (wawp.net), a WhatsApp Business API provider, to deliver WhatsApp messages — only your mobile number and the message content, solely for delivery.",
"• Our email delivery provider, to deliver emails.",
"• Anyone we are required to share it with under South African law.",
"Within our organisation, people see only what they need:",
"• Door staff see a ticket's holder name, ticket type and check-in status when scanning.",
"• Group, room or bus leaders see the names, ages and contact numbers of the people in their group. Medical details are shown to supervisors only.",
"• Supervisors and administrators manage registrations, payments and communication for events.",
"• Support access: to help with a problem (for example, a ticket not showing), an administrator may view the site as you see it for up to 30 minutes. This is strictly read-only — nothing can be changed, paid, registered or sent on your behalf. A reason must be given, and the session and every page viewed are recorded. The organisation may choose to email you whenever this happens.",
"• Sponsors never see who was helped by a sponsorship; the public only sees how many spots were sponsored.",
"• Nobody else can see whether you have an account.",
],
rights: [
"Under POPIA, you have the right to:",
@@ -188,15 +219,31 @@ function Section({
"To contact the Information Regulator:",
"• Website: www.inforeg.org.za",
"• Email: enquiries@inforeg.org.za",
"To exercise your rights directly with us, contact our Information Officer using the details in section 13 below.",
"To exercise your rights directly with us, contact our Information Officer using the details in section 14 below.",
],
cookies: [
"This website uses browser localStorage to store your login session token and user interface preferences (such as dismissed notifications). No third-party cookies, tracking pixels, or analytics tools are used.",
"You can clear your browser's local storage at any time through your browser settings, which will log you out of the site.",
"Our own site does not set tracking cookies. It keeps a few small items in your browser's storage (localStorage, and on staff scanners IndexedDB and an offline page cache). These work much like cookies, so this policy covers them. We do not use third-party analytics, advertising or tracking pixels.",
"Strictly necessary — always used, because the site can't work without them:",
"• hope_events_token — keeps you signed in.",
"• hope_events_user_cache — your last loaded profile, so pages keep working if your connection drops.",
"• hope_events_device — only if you tick \"Trust this device\" when entering a two-factor code, so you aren't asked for a code every time.",
"• hope_events_cookie_consent — remembers your choice on the cookie notice.",
"• banner_dismissed_… — remembers notices you have closed.",
"Staff and administrators only:",
"• hope_events_scan_device, hope_events_offline_lock and the \"hope-events-offline-scan\" database — on a staff member's scanner in offline mode: the device's id, the ticket list for the section being scanned, and scans waiting to upload. They are deleted when offline mode is ended. A copy of the scanner page is also cached so it opens without a connection.",
"• hope_events_admin_token, hope_events_admin_user_cache and hope_events_impersonation — only while an administrator is using read-only support access (section 9), and removed when it ends.",
"Optional — only if you click \"Allow\" on the cookie notice:",
"• hope_events_analytics_sid — a random, anonymous visit id. With it we count, in our own statistics, how many visitors view an event, start registering, and complete a registration. We record only that step, where the visit came from (for example a short link or campaign link) and the type of device — never your name, IP address or browsing history. Detailed records become anonymous daily totals after 90 days. If you choose \"No thanks\", nothing is counted and no visit id is created.",
"Third parties:",
"• Event pages with a location show an embedded Google Maps map. Google may set its own cookies when the map loads (see Google's privacy policy).",
"• Payments open Yoco's secure checkout page, which uses its own cookies on Yoco's site.",
"You can change your cookie choice at any time with \"Cookie settings\" at the bottom of every page, or with the button above. You can also clear your browser's storage in your browser settings; this signs you out.",
],
security: [
"Your personal data is encrypted during transmission and stored using industry-standard security protocols.",
"Your personal data is encrypted during transmission and stored using industry-standard security protocols. Medical and emergency details are additionally encrypted in our database.",
"Passwords are encrypted and cannot be viewed by anyone, including administrators.",
"Two-factor authentication is available to everyone and is required for supervisors and administrators.",
"Links we send for consent, feedback and ticket transfers are personal and time-limited. Only a secure fingerprint of each link is stored, not the link itself.",
"In the event of a data breach that poses a risk to your rights, we will notify you and the Information Regulator of South Africa as required under POPIA section 22, without unreasonable delay.",
],
changes: [
@@ -218,6 +265,11 @@ function Section({
return (
<section id={id} className="mb-10 scroll-mt-24">
<h3 className="text-xl font-semibold text-brand-600 mb-3">{title}</h3>
{id === "cookies" && (
<CookieSettingsButton className="mb-3 px-3 py-1.5 text-sm rounded-lg border border-brand-300 text-brand-700 hover:bg-brand-50">
Change my cookie choice
</CookieSettingsButton>
)}
{content[id]?.map((line, i) => {
// Render email addresses as links
const emailMatch = line.match(/[\w.+-]+@[\w-]+\.[\w.]+/);
+54 -15
View File
@@ -12,14 +12,19 @@ const sections = [
{ id: "accounts", title: "3. User Accounts" },
{ id: "payments", title: "4. Payments & Refunds" },
{ id: "registrations", title: "5. Registrations & Tickets" },
{ id: "acceptable", title: "6. Acceptable Use" },
{ id: "communication", title: "7. Communication" },
{ id: "intellectual", title: "8. Intellectual Property" },
{ id: "liability", title: "9. Limitation of Liability" },
{ id: "forcemajeure", title: "10. Force Majeure" },
{ id: "termination", title: "11. Termination of Access" },
{ id: "law", title: "12. Governing Law" },
{ id: "contact", title: "13. Contact" },
{ id: "others", title: "6. Registering Other People" },
{ id: "youth", title: "7. Youth Events & Parental Consent" },
{ id: "transfers", title: "8. Ticket Transfers" },
{ id: "extras", title: "9. Discount Codes, Waitlists, Sponsorships & Feedback" },
{ id: "acceptable", title: "10. Acceptable Use" },
{ id: "communication", title: "11. Communication" },
{ id: "support", title: "12. Account Security & Support Access" },
{ id: "intellectual", title: "13. Intellectual Property" },
{ id: "liability", title: "14. Limitation of Liability" },
{ id: "forcemajeure", title: "15. Force Majeure" },
{ id: "termination", title: "16. Termination of Access" },
{ id: "law", title: "17. Governing Law" },
{ id: "contact", title: "18. Contact" },
];
export default function TermsOfUsePage() {
@@ -88,7 +93,9 @@ export default function TermsOfUsePage() {
<li>Respect that under 18s need parental permission to register.</li>
<li>Understand that all payments go through Yoco, and we don&apos;t store your card details.</li>
<li>Accept that we may contact you via email and/or WhatsApp (based on your preference) about events you register for, your tickets, and account security alerts.</li>
<li>Understand that registrations are personal to you and non-transferable.</li>
<li>Only enter other people&apos;s details (when booking for them or passing on a ticket) if you&apos;re allowed to.</li>
<li>Where an event allows it, you can pass your ticket to someone else before the event — it moves to them once they accept, with a new QR code.</li>
<li>For youth events, a parent or guardian must give consent before a young person can be checked in.</li>
<li>If we cancel an event, you&apos;ll get a full refund on request or your registration moved to the new date.</li>
</ul>
<p className="mt-2">If you disagree with these terms, please don&apos;t use the site.</p>
@@ -126,11 +133,11 @@ function Section({
],
accounts: [
"You must create an account to register for certain events or access restricted features.",
"You are responsible for keeping your password secure.",
"You are responsible for keeping your password secure, and for any two-factor codes sent to you.",
"Passwords are encrypted and not accessible to any staff member.",
"Users under the age of 18 must only register or create an account with the consent and supervision of a parent or guardian.",
"You agree to provide accurate, current, and complete information when creating your account.",
`Accounts may also be created on your behalf by an administrator (e.g. for walk-in event registration). In this case, you will receive a WhatsApp message with an activation link if your mobile number is provided.`,
`Accounts may also be created on your behalf — by an administrator (e.g. for walk-in event registration), or when someone registers you for an event or transfers a ticket to you. You will receive an activation link by email or WhatsApp, and can choose whether to activate the account.`,
],
payments: [
"Payments are processed securely through Yoco, a third-party payment processor.",
@@ -142,18 +149,46 @@ function Section({
`Refund Policy: Refunds are handled on a per-event basis. As a baseline, if you are unable to attend an event you have paid for, please contact us at ${orgEmail} at least 48 hours before the event. Refund requests at this stage are at the discretion of the event organiser. No refunds will be issued for no-shows. Any event-specific refund terms will be communicated at the time of registration and take precedence over this baseline.`,
],
registrations: [
`Registrations and tickets are personal to the registered individual and are non-transferable unless explicitly authorised by ${orgName} in writing.`,
"You may not sell, gift, or pass on your registration to another person without written authorisation.",
"Each ticket has a QR code that is checked at the event. Keep it private — anyone holding a copy of it may be able to use it.",
"A ticket can only be used for the number of places it covers. Once a ticket has been scanned it cannot be transferred.",
"Tickets may be passed on to another person only through the platform's transfer feature, where the event allows it (see section 8). You may not sell tickets for profit.",
"At some events, door staff may check tickets against a list downloaded to their device in case the internet connection fails. If a ticket was bought or changed after that list was downloaded, you may be asked to go to the door desk.",
"You may cancel your own registration at any time before any payment has been made. Once a payment has been recorded against your registration, cancellation must be requested through the organisation. Please contact us to arrange this.",
`If ${orgName} cancels or significantly postpones an event, affected registrants will be notified by email. In this case, a full refund will be issued upon request, or your registration may be transferred to the rescheduled or replacement event — whichever you prefer. Refund requests for organiser-initiated cancellations must be submitted to ${orgEmail} within 30 days of the cancellation notice.`,
],
others: [
"When you register other people (for example family members) or give each person their own ticket, you confirm that:",
"• the details you enter are accurate, and you are allowed to share them with us for this purpose; and",
"• for anyone under 18, you are their parent or guardian or have their parent's or guardian's permission.",
"We will use the email address or phone number you enter to send that person their ticket, attendee form, or other event messages. There is no searching for other users — you must type their details yourself.",
"The person who registers and pays remains responsible for the registration and its payment.",
],
youth: [
"Some events are marked as youth events. Each attendee's date of birth is required, and for anyone under 18 a parent or guardian must give consent through the link we send them before the young person can be checked in.",
"The parent or guardian is responsible for the accuracy of any medical and emergency information they provide, and for telling the organisers about changes.",
"A supervisor may in exceptional cases check someone in without recorded consent; such decisions are logged.",
],
transfers: [
"Where an event allows it, the person who booked a ticket or the person currently holding it can offer the ticket to someone else before the event starts, provided the ticket has not been scanned. Supervisors may transfer tickets even where an event does not otherwise allow it.",
"You enter the new holder's name and email address and/or phone number. We send them a link to accept the ticket. Nothing changes until they accept; they may also decline, and you may cancel the offer until then. Offers expire after 7 days or when the event starts.",
"When the transfer is accepted, the new holder receives their own ticket with a new QR code, and the old QR code (including any copies or screenshots of it) stops working.",
"The person who booked stays responsible for the registration and its payment; a transfer does not move or refund any payment.",
"On youth events, the new holder needs their own parental consent, and any attendee form answers from the previous holder are removed.",
],
extras: [
"Discount codes: codes are issued at the organisers' discretion, may be limited to certain events, people, dates or numbers of uses, and may not be sold or shared where they are restricted. We may cancel a discount obtained through misuse.",
"Waitlists: joining a waitlist does not guarantee a place. If a place becomes available, it is offered to the next person for a limited time; if the offer is not claimed in time, it passes to the next person.",
"Sponsor a spot: a sponsorship is a donation towards places for people who couldn't otherwise attend. The organisers decide how sponsorship money is allocated, and sponsors are not told who benefits. Refunds of sponsorships are at the organisers' discretion.",
"Feedback: post-event feedback forms are optional. Please don't include other people's personal information in your answers.",
],
acceptable: [
"You agree not to:",
"• Use the website for any unlawful or fraudulent purpose.",
"• Impersonate another person or misrepresent your identity.",
"• Interfere with or disrupt the operation of the site.",
"• Attempt to gain unauthorised access to our systems.",
"• Resell, tout, or transfer your registration or tickets without written authorisation.",
"• Resell or tout tickets, or transfer them other than through the platform's transfer feature.",
"• Enter another person's details without being allowed to, or use the registration, transfer or messaging features to contact people who have not agreed to it.",
"• Use automated tools, bots, or scrapers to access or extract data from the site.",
],
communication: [
@@ -163,6 +198,10 @@ function Section({
"For accounts without a valid email address (e.g. walk-in or at-the-door registrations), these required communications will be delivered via WhatsApp if a mobile number is on file.",
"WhatsApp messages are delivered via WAWP, a third-party WhatsApp Business API provider. By enabling WhatsApp notifications, you consent to your mobile number being used to send you messages via this service.",
],
support: [
"Two-factor authentication is required for supervisors and administrators, and recommended for everyone.",
"To help solve a problem with your account (for example, a ticket not showing), an administrator may view the site as you see it, for up to 30 minutes. This support access is strictly read-only: nothing can be changed, registered, paid, cancelled or sent on your behalf. A reason must be given, and every session is recorded in an audit log. The organisation may choose to notify you by email whenever this happens.",
],
intellectual: [
`All content on the platform, including text, graphics, and logos, is owned by ${orgName} unless otherwise stated.`,
"You may not copy, modify, or distribute any content without written permission.",
@@ -173,7 +212,7 @@ function Section({
"We are not liable for any loss or damages arising from:",
"• Technical failures; or",
"• Misuse of the platform.",
"Liability for event cancellations or schedule changes is addressed under section 5 (Registrations & Tickets) and section 10 (Force Majeure).",
"Liability for event cancellations or schedule changes is addressed under section 5 (Registrations & Tickets) and section 15 (Force Majeure).",
],
forcemajeure: [
`${orgName} shall not be liable for any failure or delay in performing its obligations where such failure or delay results from causes beyond its reasonable control, including but not limited to: acts of God, natural disasters, government restrictions or regulations, public health emergencies, venue closures, or any other event outside our reasonable control ('force majeure event').`,
+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" }],
};
}
@@ -6,6 +6,7 @@ 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";
import { analyticsSessionId } from "@/lib/analytics";
// Tiered low-stock threshold — mirrors events/[id]/page.tsx
function lowStockThreshold(stockLimit: number): number {
@@ -44,6 +45,7 @@ type Event = {
requiresAuth?: boolean;
allowNamedAttendees?: boolean;
requiresParentalConsent?: boolean;
collectGender?: boolean;
waitlistEnabled?: boolean;
};
@@ -392,7 +394,7 @@ export default function RegisterForm({ event: baseEvent }: { event: Event }) {
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 } : {}) },
body: { eventId, options: items, waitlistToken, analyticsSessionId: analyticsSessionId(), ...(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) {
@@ -418,6 +420,7 @@ export default function RegisterForm({ event: baseEvent }: { event: Event }) {
eventId,
options: items,
...(coupon ? { couponCode: coupon.code } : {}),
analyticsSessionId: analyticsSessionId(),
...(namedAttendees().length > 0 ? { attendees: namedAttendees(), ticketDelivery: delivery } : {}),
...(waitlistToken ? { waitlistToken } : {}),
},
@@ -462,6 +465,7 @@ export default function RegisterForm({ event: baseEvent }: { event: Event }) {
method: "POST",
body: {
eventId, options: items, guestName: guestName.trim(), guestEmail: guestEmail.trim(), guestPhone: guestPhone.trim() || undefined,
analyticsSessionId: analyticsSessionId(),
...(namedAttendees().length > 0 ? { attendees: namedAttendees(), ticketDelivery: delivery } : {}),
},
});
@@ -603,6 +607,7 @@ export default function RegisterForm({ event: baseEvent }: { event: Event }) {
delivery={delivery}
onDeliveryChange={setDelivery}
consentEventStart={isYouthEvent ? event.startDate : undefined}
collectGender={!!event.collectGender}
/>
</div>
)}
@@ -3,6 +3,7 @@ import { Navbar } from "@/components/layout/Navbar";
import { Footer } from "@/components/layout/Footer";
import { apiFetch, ApiError } from "@/lib/api";
import RegisterForm from "./RegisterForm";
import { FunnelTracker } from "@/components/events/FunnelTracker";
export const revalidate = 60;
@@ -28,6 +29,7 @@ export default async function RegisterPage({ params }: { params: Promise<{ event
<div className="min-h-screen flex flex-col">
<Navbar />
<main className="flex-1 p-6 max-w-2xl mx-auto w-full">
<FunnelTracker eventId={event.id} stage="start" />
<RegisterForm event={event} />
</main>
<Footer />
@@ -1,4 +1,5 @@
"use client";
import { ExtraFieldInput, isExtraFieldType } from "@/components/shared/FormFieldExtras";
import React, { Suspense } from "react";
import { Navbar } from "@/components/layout/Navbar";
import { Footer } from "@/components/layout/Footer";
@@ -6,7 +7,7 @@ import { useSearchParams, useRouter } from "next/navigation";
import { useAuth } from "@/hooks/useAuth";
import { CheckCircle2 } from "lucide-react";
type FormField = { id: string; type: 'yes_no'|'text'|'date'|'numeric'|'statement'|'paragraph'; label: string; isRequired?: boolean; helpText?: string|null };
type FormField = { id: string; type: 'yes_no'|'text'|'date'|'numeric'|'statement'|'paragraph'|'rating'|'multiple_choice'; label: string; isRequired?: boolean; helpText?: string|null; options?: unknown };
function RegistrationSuccessContent() {
const search = useSearchParams();
@@ -242,7 +243,9 @@ function AttendeeForms({ reg, form, formsData, setFormsData, setError, setInfo }
) : (
<>
<label className="block text-xs text-gray-600 mb-1">{f.label}{f.isRequired ? ' *' : ''}</label>
{f.type === 'yes_no' ? (
{isExtraFieldType(f.type) ? (
<ExtraFieldInput field={f} idPrefix={`a${idx}`} value={formsData[idx]?.[f.id] || ''} onChange={v => update(idx, f.id, v)} />
) : f.type === 'yes_no' ? (
<select className="border rounded px-2 py-1 text-sm" value={formsData[idx]?.[f.id] || ''} onChange={e => update(idx, f.id, e.target.value)}>
<option value="">Select</option>
<option value="yes">Yes</option>
+6 -1
View File
@@ -1,4 +1,5 @@
"use client";
import { ExtraFieldInput, isExtraFieldType } from "@/components/shared/FormFieldExtras";
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { apiFetch } from "@/lib/api";
@@ -42,6 +43,7 @@ type KioskEvent = {
form?: EventForm | null;
allowNamedAttendees?: boolean;
requiresParentalConsent?: boolean;
collectGender?: boolean;
};
type Screen = "setup" | "form" | "eventform" | "confirmation";
@@ -1025,6 +1027,7 @@ export default function SelfServicePage() {
delivery={delivery}
onDeliveryChange={setDelivery}
consentEventStart={selectedEvent.requiresParentalConsent ? selectedEvent.startDate : undefined}
collectGender={!!selectedEvent.collectGender}
/>
</div>
)}
@@ -1190,7 +1193,9 @@ export default function SelfServicePage() {
{field.helpText && (
<p className="text-xs text-gray-500 mb-1">{field.helpText}</p>
)}
{field.type === "yes_no" ? (
{isExtraFieldType(field.type) ? (
<ExtraFieldInput field={field} value={currentTicketAnswers[field.id] || ""} onChange={v => setCurrentTicketAnswers(a => ({ ...a, [field.id]: v }))} />
) : field.type === "yes_no" ? (
<div className="flex gap-4">
{["Yes", "No"].map((opt) => (
<label key={opt} className="flex items-center gap-2 cursor-pointer">
@@ -0,0 +1,99 @@
"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 } from "@/lib/api";
import { formatDate } from "@/lib/date";
import { Ticket, CheckCircle2, XCircle } from "lucide-react";
type Offer = {
status: "pending" | "accepted" | "declined" | "cancelled" | "expired";
toName: string;
fromName: string;
expiresAt: string;
option: string;
event: { id: string; title: string; startDate: string; location: string | null };
};
const STATUS_TEXT: Record<string, string> = {
accepted: "This ticket has already been accepted.",
declined: "This ticket offer was declined.",
cancelled: "This ticket offer was withdrawn.",
expired: "This ticket offer has expired.",
};
/** Public page the person a ticket is offered to opens from their email or WhatsApp. */
export default function TransferOfferPage() {
const params = useParams<{ token: string }>();
const token = params?.token;
const [offer, setOffer] = useState<Offer | null>(null);
const [loadError, setLoadError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [done, setDone] = useState<{ ok: boolean; message: string } | null>(null);
useEffect(() => {
if (!token) return;
apiFetch<Offer>(`/api/tickets/transfer-offer/${encodeURIComponent(token)}`)
.then(setOffer)
.catch(e => setLoadError(e?.message || "This transfer link isn't valid."));
}, [token]);
const answer = async (action: "accept" | "decline") => {
setBusy(true);
setError(null);
try {
const r = await apiFetch<{ message: string }>(`/api/tickets/transfer-offer/${encodeURIComponent(token!)}/${action}`, { method: "POST" });
setDone({ ok: action === "accept", message: r.message });
} catch (e: any) {
setError(e?.message || "Something went wrong");
} finally {
setBusy(false);
}
};
return (
<div className="min-h-screen flex flex-col bg-gray-50">
<Navbar />
<main className="flex-1 max-w-xl w-full mx-auto p-4 sm: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">
<Ticket className="w-5 h-5 text-brand-600" />
</div>
<h1 className="text-2xl font-semibold text-gray-900">A ticket for you</h1>
</div>
{loadError && <div className="p-4 border rounded-xl bg-white text-sm text-gray-700">{loadError}</div>}
{done && (
<div className="p-5 border rounded-xl bg-white shadow-sm flex items-start gap-3">
{done.ok ? <CheckCircle2 className="w-6 h-6 text-green-600 shrink-0" /> : <XCircle className="w-6 h-6 text-gray-500 shrink-0" />}
<p className="text-sm text-gray-700">{done.message}</p>
</div>
)}
{offer && !done && (
<div className="p-5 border rounded-xl bg-white shadow-sm space-y-3">
<p className="text-sm text-gray-700">Hi {offer.toName.split(" ")[0]}, {offer.fromName || "someone"} would like to give you their ticket.</p>
<div className="rounded-lg bg-gray-50 p-3 text-sm">
<div className="font-semibold">{offer.event.title}</div>
<div className="text-gray-600">{formatDate(new Date(offer.event.startDate))}{offer.event.location ? ` · ${offer.event.location}` : ""}</div>
<div className="text-gray-600">{offer.option}</div>
</div>
{offer.status === "pending" ? (
<>
<p className="text-xs text-gray-500">Accepting sends you your own ticket and QR code. If you don&apos;t have an account with us yet, we&apos;ll create one and send you a link to activate it. This offer is open until {new Date(offer.expiresAt).toLocaleString()}.</p>
{error && <div className="p-2 rounded bg-red-50 text-red-800 text-sm">{error}</div>}
<div className="flex gap-2">
<button onClick={() => answer("accept")} disabled={busy} className="px-4 py-2 rounded-lg bg-brand-600 text-white hover:bg-brand-700 disabled:opacity-50 font-medium">{busy ? "Please wait…" : "Accept ticket"}</button>
<button onClick={() => answer("decline")} disabled={busy} className="px-4 py-2 rounded-lg border hover:bg-gray-50 disabled:opacity-50">Decline</button>
</div>
</>
) : (
<p className="text-sm text-gray-600">{STATUS_TEXT[offer.status]}</p>
)}
</div>
)}
</main>
<Footer />
</div>
);
}
@@ -0,0 +1,15 @@
"use client";
import { useEffect } from "react";
import { CONSENT_EVENT, trackFunnel } from "@/lib/analytics";
/** Records a funnel step for this page (once consent is given, even if given later). */
export function FunnelTracker({ eventId, stage }: { eventId: string; stage: "view" | "start" }) {
useEffect(() => {
trackFunnel(eventId, stage);
const onConsent = () => trackFunnel(eventId, stage);
window.addEventListener(CONSENT_EVENT, onConsent);
return () => window.removeEventListener(CONSENT_EVENT, onConsent);
}, [eventId, stage]);
return null;
}
@@ -0,0 +1,146 @@
"use client";
import React, { useEffect, useState } from "react";
import { HeartHandshake, X } from "lucide-react";
import { apiFetch } from "@/lib/api";
import { useAuth } from "@/hooks/useAuth";
type Sponsorship = { enabled: boolean; open?: boolean; spotPrice?: number | null; showProgress?: boolean; goalSpots?: number | null; spotsFunded?: number | null; sponsors?: number | null };
const inputCls = "w-full border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-brand-400";
const rand = (n: number) => `R${n.toLocaleString("en-ZA", { minimumFractionDigits: n % 1 ? 2 : 0, maximumFractionDigits: 2 })}`;
/**
* "Sponsor a spot": progress towards the event's sponsorship goal, and a form to fund
* one or more spots (or any amount) for people who can't afford to come. Payment is
* through Yoco; the money lands in the event's sponsorship pool.
*/
export function SponsorSpot({ eventId, eventTitle }: { eventId: string; eventTitle: string }) {
const { token, user } = useAuth();
const [data, setData] = useState<Sponsorship | null>(null);
const [open, setOpen] = useState(false);
const [mode, setMode] = useState<"spots" | "amount">("spots");
const [spots, setSpots] = useState(1);
const [amount, setAmount] = useState("");
const [anonymous, setAnonymous] = useState(false);
const [sponsorName, setSponsorName] = useState("");
const [message, setMessage] = useState("");
const [guest, setGuest] = useState({ name: "", email: "" });
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [returned, setReturned] = useState<string | null>(null);
useEffect(() => {
apiFetch<Sponsorship>(`/api/sponsorships/event/${encodeURIComponent(eventId)}`).then(setData).catch(() => setData(null));
try {
const s = new URLSearchParams(window.location.search).get("sponsored");
if (s) setReturned(s);
} catch { /* no window */ }
}, [eventId]);
if (!data?.enabled) return null;
const price = data.spotPrice || null;
const loggedIn = !!(token && user);
const total = mode === "spots" && price ? spots * price : Number(amount) || 0;
const goal = data.goalSpots || 0;
const funded = data.spotsFunded ?? 0;
const submit = async (e: React.FormEvent) => {
e.preventDefault();
setBusy(true);
setError(null);
try {
const res = await apiFetch<{ redirectUrl: string }>("/api/sponsorships/checkout", {
method: "POST",
...(loggedIn ? { authToken: token! } : {}),
body: {
eventId,
...(mode === "spots" && price ? { spots } : { amount: Number(amount) }),
anonymous,
...(!anonymous && sponsorName.trim() ? { sponsorName: sponsorName.trim() } : {}),
...(message.trim() ? { message: message.trim() } : {}),
...(!loggedIn ? { name: guest.name, email: guest.email } : {}),
},
});
window.location.href = res.redirectUrl;
} catch (err: any) {
setError(err?.message || "Couldn't start the payment");
setBusy(false);
}
};
return (
<div className="mt-4 border-t pt-4 space-y-2">
{returned === "1" && <div className="text-sm rounded-lg bg-green-50 text-green-800 p-2">Thank you for sponsoring! Your receipt is on its way.</div>}
{returned === "failed" && <div className="text-sm rounded-lg bg-red-50 text-red-800 p-2">The payment didn&apos;t go through. You can try again.</div>}
{data.showProgress && price && (goal > 0 || funded > 0) && (
<div>
<div className="flex justify-between text-xs text-gray-600 mb-1">
<span>{goal > 0 ? `${Math.min(funded, goal)} of ${goal} spots sponsored` : `${funded} spot${funded === 1 ? "" : "s"} sponsored`}</span>
{data.sponsors ? <span>{data.sponsors} sponsor{data.sponsors === 1 ? "" : "s"}</span> : null}
</div>
{goal > 0 && (
<div className="h-2 rounded-full bg-gray-100 overflow-hidden" role="progressbar" aria-valuemin={0} aria-valuemax={goal} aria-valuenow={Math.min(funded, goal)}>
<div className="h-full bg-rose-500" style={{ width: `${Math.min(100, (funded / goal) * 100)}%` }} />
</div>
)}
</div>
)}
{data.open && (
<button onClick={() => setOpen(true)} className="w-full inline-flex items-center justify-center gap-2 border border-rose-300 text-rose-700 px-4 py-2 rounded-lg hover:bg-rose-50 font-medium text-sm">
<HeartHandshake className="w-4 h-4" /> Sponsor a spot
</button>
)}
{open && (
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50" onClick={() => setOpen(false)}>
<form onSubmit={submit} className="bg-white rounded-xl shadow-lg max-w-md w-full mx-4 p-5 space-y-3 max-h-[90vh] overflow-y-auto" onClick={e => e.stopPropagation()}>
<div className="flex items-center justify-between">
<h3 className="text-lg font-semibold flex items-center gap-2"><HeartHandshake className="w-5 h-5 text-rose-600" /> Sponsor a spot</h3>
<button type="button" onClick={() => setOpen(false)} aria-label="Close" className="p-1 rounded hover:bg-gray-100"><X className="w-4 h-4" /></button>
</div>
<p className="text-sm text-gray-600">Help someone come to {eventTitle} who couldn&apos;t otherwise afford it. Your gift goes into a sponsorship pool that the organisers use to cover places.</p>
{price ? (
<div className="flex gap-2 text-sm">
<button type="button" onClick={() => setMode("spots")} className={`flex-1 px-3 py-1.5 rounded-lg border ${mode === "spots" ? "bg-rose-600 text-white border-rose-600" : "hover:bg-gray-50"}`}>Whole spots</button>
<button type="button" onClick={() => setMode("amount")} className={`flex-1 px-3 py-1.5 rounded-lg border ${mode === "amount" ? "bg-rose-600 text-white border-rose-600" : "hover:bg-gray-50"}`}>Any amount</button>
</div>
) : null}
{mode === "spots" && price ? (
<div className="flex items-center gap-2 text-sm">
<input type="number" min={1} max={100} className={`${inputCls} w-24`} value={spots} onChange={e => setSpots(Math.max(1, Math.min(100, parseInt(e.target.value, 10) || 1)))} aria-label="Number of spots" />
<span>spot{spots === 1 ? "" : "s"} × {rand(price)} = <strong>{rand(spots * price)}</strong></span>
</div>
) : (
<div>
<label className="block text-xs text-gray-600 mb-1">Amount (R)</label>
<input type="number" min={15} step="0.01" className={inputCls} value={amount} onChange={e => setAmount(e.target.value)} placeholder="e.g. 200" required />
<p className="text-xs text-gray-500 mt-1">Minimum R15.</p>
</div>
)}
{!loggedIn && (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
<input className={inputCls} placeholder="Your name" value={guest.name} onChange={e => setGuest({ ...guest, name: e.target.value })} required />
<input type="email" className={inputCls} placeholder="Email (for your receipt)" value={guest.email} onChange={e => setGuest({ ...guest, email: e.target.value })} required />
</div>
)}
<label className="flex items-center gap-2 text-sm cursor-pointer">
<input type="checkbox" checked={anonymous} onChange={e => setAnonymous(e.target.checked)} /> Give anonymously
</label>
{!anonymous && (
<input className={inputCls} placeholder={`Name to show the organisers (e.g. "The Smith family")`} value={sponsorName} onChange={e => setSponsorName(e.target.value)} maxLength={100} />
)}
<textarea className={inputCls} rows={2} placeholder="A message for the organisers (optional)" value={message} onChange={e => setMessage(e.target.value)} maxLength={300} />
<p className="text-xs text-gray-500">You&apos;ll get a receipt by email. We never share who is helped by sponsorships.</p>
{error && <div className="p-2 rounded bg-red-50 text-red-800 text-sm">{error}</div>}
<button type="submit" disabled={busy || !(total >= 15)} className="w-full px-4 py-2 rounded-lg bg-rose-600 text-white hover:bg-rose-700 disabled:opacity-50 font-medium">
{busy ? "Opening payment…" : `Pay ${total > 0 ? rand(total) : ""} securely`}
</button>
</form>
</div>
)}
</div>
);
}
@@ -3,6 +3,7 @@
import Link from "next/link";
import { appName } from "@/lib/siteConfig";
import { useSiteSettings } from "@/contexts/SiteSettingsContext";
import { CookieSettingsButton } from "@/components/shared/CookieSettingsButton";
export const Footer = () => {
const { settings } = useSiteSettings();
@@ -19,6 +20,7 @@ export const Footer = () => {
<Link href="/legal/privacy" className="hover:underline text-gray-300">
Privacy Policy
</Link>
<CookieSettingsButton className="hover:underline text-gray-300" />
</div>
</footer>
{/* Spacer so content isn't hidden behind the fixed mobile bottom nav */}
@@ -16,6 +16,8 @@ import {
TicketPercent,
ShieldCheck,
HeartPulse,
MessageSquareHeart,
Filter,
} from "lucide-react";
export type ReportCategory = "Financial" | "Registration" | "Ticketing" | "Donations" | "Cashup" | "Other";
@@ -37,6 +39,8 @@ export const REPORTS = [
{ 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: "funnel", label: "Registration funnel", description: "Event page views → started → submitted → paid, with trend and sources", category: "Registration", icon: Filter, fields: 5 },
{ key: "feedbackSummary", label: "Feedback summary", description: "Post-event feedback: ratings, choices and comments; compare events", category: "Registration", icon: MessageSquareHeart, fields: 4 },
{ 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<{
+242 -4
View File
@@ -5,12 +5,13 @@ 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, TicketPercent, ShieldCheck } from "lucide-react";
import { Printer, Mail, FileSpreadsheet, MessageCircle, ShoppingCart, CreditCard, Gift, Clock, HandHeart, Search, TrendingUp, TicketPercent, ShieldCheck, Star, MessageSquareHeart, Send, Eye, MousePointerClick, BadgeCheck } from "lucide-react";
import { REPORTS, type ReportKey } from "./ReportCatalog";
import ReportsShell from "./ReportsShell";
import ReportViewerModal, { ReportActionButton } from "./ReportViewerModal";
import { StatTile, StatTileRow } from "./StatTile";
import { HorizontalBarChart } from "./charts/HorizontalBarChart";
import { AreaTrendChart } from "@/components/charts/AreaTrendChart";
const METHOD_KEYS = ["cash", "card", "eft", "other"] as const;
const METHOD_LABEL: Record<string, string> = { cash: "Cash", card: "Card", eft: "EFT", other: "Other" };
@@ -177,6 +178,10 @@ export default function ReportsV2({ onBack }: { onBack?: () => void } = {}) {
// Youth events (one row per attendee), each tagged with its event title.
const [consentRows, setConsentRows] = useState<any[]>([]);
const [medicalRows, setMedicalRows] = useState<any[]>([]);
// Post-event feedback, one entry per event that had a feedback form.
const [feedbackRows, setFeedbackRows] = useState<any[]>([]);
// Registration funnel for the selected events (null until viewed).
const [funnel, setFunnel] = useState<any | null>(null);
// 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
@@ -286,6 +291,27 @@ export default function ReportsV2({ onBack }: { onBack?: () => void } = {}) {
return byEv;
};
const loadFunnel = async (eventIds: string[]) => {
if (!token || !eventIds.length) return null;
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/analytics/funnel?${query.toString()}`, { authToken: token });
} catch {
return null;
}
};
const loadFeedback = async (eventIds: string[]) => {
if (!token || !eventIds.length) return [] as any[];
try {
return await apiFetch<any[]>(`/api/feedback/summary?eventIds=${encodeURIComponent(eventIds.join(","))}`, { authToken: token });
} catch {
return [];
}
};
// Coupon uses (active redemptions on non-cancelled registrations) for the Discounts report.
const loadDiscounts = async (eventIds: string[]) => {
if (!token || !eventIds.length) return [] as any[];
@@ -352,6 +378,29 @@ export default function ReportsV2({ onBack }: { onBack?: () => void } = {}) {
};
}, [discountRows]);
// Feedback summary: overall numbers, and the average star rating per event (for comparing events).
const feedbackSummary = useMemo(() => {
const invited = feedbackRows.reduce((s, f) => s + (f.invited || 0), 0);
const responses = feedbackRows.reduce((s, f) => s + (f.responses || 0), 0);
const ratingOf = (f: any) => {
const avgs = (f.questions || []).filter((q: any) => q.type === "rating" && q.average != null).map((q: any) => q.average);
return avgs.length ? Math.round((avgs.reduce((s: number, n: number) => s + n, 0) / avgs.length) * 10) / 10 : null;
};
const perEvent = feedbackRows.map(f => ({ ...f, rating: ratingOf(f) }));
const rated = perEvent.filter(f => f.rating != null);
return {
perEvent, invited, responses,
rate: invited ? Math.round((responses / invited) * 100) : null,
rating: rated.length ? Math.round((rated.reduce((s, f) => s + f.rating, 0) / rated.length) * 10) / 10 : null,
chart: rated.map(f => ({ label: f.title, value: f.rating })),
};
}, [feedbackRows]);
const feedbackResult = (q: any) =>
q.type === "rating" ? (q.average != null ? `${q.average} / 5` : "—")
: q.type === "numeric" ? (q.average != null ? `average ${q.average}` : "—")
: q.counts ? q.counts.map((c: any) => `${c.label}: ${c.value}`).join(" · ")
: `${(q.texts || []).length} answer(s)`;
// View handler according to selected report
const onView = async () => {
setBusy(true);
@@ -409,6 +458,10 @@ export default function ReportsV2({ onBack }: { onBack?: () => void } = {}) {
setDiscountRows(await loadDiscounts(selectedEventIds));
} else if (report === "consentStatus") {
setConsentRows(await loadConsent(selectedEventIds, "status"));
} else if (report === "funnel") {
setFunnel(await loadFunnel(selectedEventIds));
} else if (report === "feedbackSummary") {
setFeedbackRows(await loadFeedback(selectedEventIds));
} else if (report === "medicalList") {
setMedicalRows(await loadConsent(selectedEventIds, "medical"));
}
@@ -970,7 +1023,10 @@ export default function ReportsV2({ onBack }: { onBack?: () => void } = {}) {
const used = dons.reduce((s: number, p: any) => s + usedFor(evId, p.id), 0);
const unused = Math.max(total - used, 0);
grandTotal += total; grandUsed += used;
rows.push([ev?.title || evId, Number(total.toFixed(2)), Number(used.toFixed(2)), Number(unused.toFixed(2)), dons.length]);
const pool = dons.filter((p: any) => p.isSponsorship);
const poolIn = pool.reduce((s: number, p: any) => s + (p.amount || 0), 0);
const poolUsed = pool.reduce((s: number, p: any) => s + usedFor(evId, p.id), 0);
rows.push([ev?.title || evId, Number(total.toFixed(2)), Number(used.toFixed(2)), Number(unused.toFixed(2)), dons.length, Number(poolIn.toFixed(2)), Number(poolUsed.toFixed(2)), Number(Math.max(poolIn - poolUsed, 0).toFixed(2))]);
});
const grandUnused = Math.max(grandTotal - grandUsed, 0);
return {
@@ -981,7 +1037,7 @@ export default function ReportsV2({ onBack }: { onBack?: () => void } = {}) {
{ label: 'Unused', value: `R ${grandUnused.toFixed(2)}`, tone: 'amber' },
],
chart: { title: 'Used vs. unused, across all selected events', data: [{ label: "Used", value: grandUsed, displayValue: `R ${grandUsed.toFixed(2)}` }, { label: "Unused", value: grandUnused, displayValue: `R ${grandUnused.toFixed(2)}` }] },
table: { columns: ["Event", "Donations (R)", "Used (R)", "Unused (R)", "Count"], rows }
table: { columns: ["Event", "Donations (R)", "Used (R)", "Unused (R)", "Count", "Sponsorship pool (R)", "Pool assigned (R)", "Pool remaining (R)"], rows }
};
}
if (report === "consentStatus") {
@@ -1001,6 +1057,50 @@ export default function ReportsV2({ onBack }: { onBack?: () => void } = {}) {
},
};
}
if (report === "funnel" && funnel) {
const pct = (v: number | null) => (v != null ? `${v}%` : "—");
const line = (label: string, c: any) => [label, c.view, c.start, c.submit, c.paid, pct(c.conversion ?? (c.view ? Math.round((c.paid / c.view) * 1000) / 10 : null))];
const rows: (string | number)[][] = [
line("All selected events", funnel.totals),
...(funnel.perEvent || []).length > 1 ? funnel.perEvent.map((p: any) => line(p.title, p.totals)) : [],
...funnel.bySource.map((s: any) => line(`Source: ${s.key}`, s)),
...funnel.byDevice.map((d: any) => line(`Device: ${d.key}`, d)),
...funnel.byOption.map((o: any) => [`Ticket: ${o.key}`, "", "", o.submitted, o.paid, ""]),
];
return {
title: 'Registration funnel', subtitle, kind: 'table', orientation: 'landscape',
stats: [
{ label: 'Event page visits', value: String(funnel.totals.view), tone: 'blue' },
{ label: 'Paid', value: String(funnel.totals.paid), tone: 'green' },
{ label: 'Conversion', value: pct(funnel.conversion.paid), tone: 'violet' },
],
chart: { title: 'Visitors reaching each stage', data: [
{ label: 'Viewed event', value: funnel.totals.view }, { label: 'Started', value: funnel.totals.start },
{ label: 'Submitted', value: funnel.totals.submit }, { label: 'Paid', value: funnel.totals.paid },
] },
note: 'Counts only visitors who allowed cookies. A visitor counts at a stage if they got that far or further.',
table: { columns: ["Breakdown", "Viewed", "Started", "Submitted", "Paid", "Conversion"], rows },
};
}
if (report === "feedbackSummary") {
const rows: (string | number)[][] = [];
for (const f of feedbackSummary.perEvent) {
for (const q of f.questions || []) {
rows.push([f.title, q.label, q.answered, feedbackResult(q)]);
for (const t of q.texts || []) rows.push(["", ` “${t.value}”`, "", t.name || ""]);
}
}
return {
title: 'Feedback summary', subtitle, kind: 'table', orientation: 'landscape',
stats: [
{ label: 'Responses', value: String(feedbackSummary.responses), tone: 'violet' },
{ label: 'Response rate', value: feedbackSummary.rate != null ? `${feedbackSummary.rate}%` : '—', tone: 'blue' },
{ label: 'Average rating', value: feedbackSummary.rating != null ? `${feedbackSummary.rating} / 5` : '—', tone: 'amber' },
],
...(feedbackSummary.chart.length > 1 ? { chart: { title: 'Average rating, by event', data: feedbackSummary.chart.map(c => ({ ...c, displayValue: `${c.value} / 5` })) } } : {}),
table: { columns: ["Event", "Question", "Answered", "Result"], rows },
};
}
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 {
@@ -1669,6 +1769,132 @@ export default function ReportsV2({ onBack }: { onBack?: () => void } = {}) {
)}
</div>
)}
{report === "funnel" && (
<div>
{!funnel || funnel.totals.view === 0 ? (
<div className="text-sm text-gray-500">No tracked visits for the selected events and dates yet. Only visitors who allow cookies are counted.</div>
) : (() => {
const pct = (v: number | null) => (v != null ? `${v}%` : "—");
const stages = [
{ label: "Viewed event", value: funnel.totals.view },
{ label: `Started (${pct(funnel.conversion.start)})`, value: funnel.totals.start },
{ label: `Submitted (${pct(funnel.conversion.submit)})`, value: funnel.totals.submit },
{ label: `Paid (${pct(funnel.conversion.paid)})`, value: funnel.totals.paid },
];
const table = (title: string, rows: any[]) => rows.length === 0 ? null : (
<div className="mt-4 overflow-auto border border-gray-100 rounded-xl">
<table className="min-w-[560px] w-full">
<thead>
<tr className="text-left text-gray-500 text-xs uppercase tracking-wide bg-gray-50">
<th className="py-2 px-3">{title}</th><th className="py-2 px-3 text-right">Viewed</th><th className="py-2 px-3 text-right">Started</th>
<th className="py-2 px-3 text-right">Submitted</th><th className="py-2 px-3 text-right">Paid</th><th className="py-2 px-3 text-right">Conversion</th>
</tr>
</thead>
<tbody>
{rows.map((r: any) => (
<tr key={r.key} className="border-t border-gray-100">
<td className="py-2 px-3">{r.key}</td><td className="py-2 px-3 text-right">{r.view}</td><td className="py-2 px-3 text-right">{r.start}</td>
<td className="py-2 px-3 text-right">{r.submit}</td><td className="py-2 px-3 text-right">{r.paid}</td><td className="py-2 px-3 text-right">{pct(r.conversion)}</td>
</tr>
))}
</tbody>
</table>
</div>
);
return (
<>
<StatTileRow>
<StatTile icon={Eye} label="Event page visits" value={String(funnel.totals.view)} tone="blue" />
<StatTile icon={MousePointerClick} label="Started registering" value={String(funnel.totals.start)} tone="amber" />
<StatTile icon={BadgeCheck} label="Paid" value={`${funnel.totals.paid} (${pct(funnel.conversion.paid)})`} tone="green" />
</StatTileRow>
<div className="mt-4 border border-gray-100 rounded-xl p-4">
<div className="text-xs text-gray-500 mb-3">Visitors reaching each stage (% of event page visits)</div>
<HorizontalBarChart data={stages} valueFormatter={v => String(v)} />
<div className="text-xs text-gray-500 mt-2">Started → submitted: {pct(funnel.conversion.startToSubmit)} · Submitted → paid: {pct(funnel.conversion.submitToPaid)}</div>
</div>
{funnel.trend.length > 1 && (
<div className="mt-4 border border-gray-100 rounded-xl p-4">
<div className="text-xs text-gray-500 mb-3">Event page visits per day</div>
<AreaTrendChart data={funnel.trend.map((t: any) => ({ label: t.day.slice(5), value: t.view }))} />
</div>
)}
{(funnel.perEvent || []).length > 1 && table("Event", funnel.perEvent.map((p: any) => ({ key: p.title, ...p.totals, conversion: p.conversion.paid })))}
{table("Source", funnel.bySource)}
{table("Device", funnel.byDevice)}
{funnel.byOption.length > 0 && (
<div className="mt-4 overflow-auto border border-gray-100 rounded-xl">
<table className="min-w-[400px] w-full">
<thead><tr className="text-left text-gray-500 text-xs uppercase tracking-wide bg-gray-50"><th className="py-2 px-3">Ticket option</th><th className="py-2 px-3 text-right">Registrations</th><th className="py-2 px-3 text-right">Paid</th></tr></thead>
<tbody>
{funnel.byOption.map((o: any) => (
<tr key={o.key} className="border-t border-gray-100"><td className="py-2 px-3">{o.key}</td><td className="py-2 px-3 text-right">{o.submitted}</td><td className="py-2 px-3 text-right">{o.paid}</td></tr>
))}
</tbody>
</table>
</div>
)}
<p className="text-xs text-gray-500 mt-3">Only visitors who allowed cookies are counted. A visitor counts at a stage if they got that far or further. Sources come from utm_source links and short links.</p>
</>
);
})()}
</div>
)}
{report === "feedbackSummary" && (
<div>
{feedbackSummary.perEvent.length === 0 ? (
<div className="text-sm text-gray-500">None of the selected events has a feedback form. Add one in the event wizard&apos;s Feedback step.</div>
) : (
<>
<StatTileRow>
<StatTile icon={MessageSquareHeart} label="Responses" value={String(feedbackSummary.responses)} tone="violet" />
<StatTile icon={Send} label="Response rate" value={feedbackSummary.rate != null ? `${feedbackSummary.rate}%` : "—"} tone="blue" />
<StatTile icon={Star} label="Average rating" value={feedbackSummary.rating != null ? `${feedbackSummary.rating} / 5` : "—"} tone="amber" />
</StatTileRow>
{feedbackSummary.chart.length > 1 && (
<div className="mt-4 border border-gray-100 rounded-xl p-4">
<div className="text-xs text-gray-500 mb-3">Average rating, by event</div>
<HorizontalBarChart data={feedbackSummary.chart} valueFormatter={v => `${v} / 5`} />
</div>
)}
{feedbackSummary.perEvent.map((f: any) => (
<div key={f.eventId} className="mt-4 border border-gray-100 rounded-xl p-4">
<div className="flex flex-wrap items-baseline justify-between gap-2 mb-3">
<div className="font-semibold">{f.title}</div>
<div className="text-xs text-gray-500">
{f.sentAt ? `${f.responses} of ${f.invited} responded${f.responseRate != null ? ` (${f.responseRate}%)` : ""}` : "Not sent yet"}{f.anonymous ? " · anonymous" : ""}
</div>
</div>
<div className="space-y-4">
{(f.questions || []).map((q: any) => (
<div key={q.fieldId}>
<div className="text-sm font-medium text-gray-800">{q.label} <span className="text-xs font-normal text-gray-500">· {q.answered} answer(s)</span></div>
{q.type === "rating" && (
<div className="mt-1">
<div className="text-sm"><span className="text-lg font-semibold">{q.average ?? "—"}</span> / 5</div>
<HorizontalBarChart data={q.distribution} valueFormatter={v => String(v)} />
</div>
)}
{q.counts && <div className="mt-1"><HorizontalBarChart data={q.counts} valueFormatter={v => String(v)} /></div>}
{q.type === "numeric" && <div className="text-sm mt-1">Average: {q.average ?? "—"}</div>}
{q.texts && (
q.texts.length === 0 ? <div className="text-xs text-gray-500 mt-1">No answers.</div> : (
<ul className="mt-1 space-y-1 max-h-64 overflow-auto">
{q.texts.map((t: any, i: number) => (
<li key={i} className="text-sm text-gray-700 border-l-2 border-gray-200 pl-2">{t.value}{t.name && <span className="text-xs text-gray-500"> — {t.name}</span>}</li>
))}
</ul>
)
)}
</div>
))}
</div>
</div>
))}
</>
)}
</div>
)}
{(report === "consentStatus" || report === "medicalList") && (() => {
const payload: any = buildReportPayload();
const rows: any[] = payload?.table?.rows || [];
@@ -1775,7 +2001,10 @@ export default function ReportsV2({ onBack }: { onBack?: () => void } = {}) {
const used = dons.reduce((s: number, p: any) => s + usedFor(evId, p.id), 0);
const unused = Math.max(total - used, 0);
grandTotal += total; grandUsed += used; grandCount += dons.length;
return { evId, title: ev?.title || evId, total, used, unused, count: dons.length };
const pool = dons.filter((p: any) => p.isSponsorship);
const poolIn = pool.reduce((s: number, p: any) => s + (p.amount || 0), 0);
const poolUsed = pool.reduce((s: number, p: any) => s + usedFor(evId, p.id), 0);
return { evId, title: ev?.title || evId, total, used, unused, count: dons.length, poolIn, poolUsed, poolLeft: Math.max(poolIn - poolUsed, 0) };
});
const grandUnused = Math.max(grandTotal - grandUsed, 0);
return (
@@ -1799,6 +2028,9 @@ export default function ReportsV2({ onBack }: { onBack?: () => void } = {}) {
<th className="py-2 px-3 text-right">Unused (R)</th>
<th className="py-2 px-3 text-right">Count</th>
<th className="py-2 px-3 text-right">Avg (R)</th>
<th className="py-2 px-3 text-right">Sponsorship pool (R)</th>
<th className="py-2 px-3 text-right">Pool assigned (R)</th>
<th className="py-2 px-3 text-right">Pool remaining (R)</th>
</tr>
</thead>
<tbody>
@@ -1810,6 +2042,9 @@ export default function ReportsV2({ onBack }: { onBack?: () => void } = {}) {
<td className="py-2 px-3 text-right">R {row.unused.toFixed(2)}</td>
<td className="py-2 px-3 text-right">{row.count}</td>
<td className="py-2 px-3 text-right">{row.count ? `R ${(row.total / row.count).toFixed(2)}` : '-'}</td>
<td className="py-2 px-3 text-right">R {row.poolIn.toFixed(2)}</td>
<td className="py-2 px-3 text-right">R {row.poolUsed.toFixed(2)}</td>
<td className="py-2 px-3 text-right">R {row.poolLeft.toFixed(2)}</td>
</tr>
))}
<tr className="font-semibold bg-gray-50 border-t border-gray-200">
@@ -1819,6 +2054,9 @@ export default function ReportsV2({ onBack }: { onBack?: () => void } = {}) {
<td className="py-2 px-3 text-right">R {grandUnused.toFixed(2)}</td>
<td className="py-2 px-3 text-right">{grandCount}</td>
<td className="py-2 px-3"></td>
<td className="py-2 px-3 text-right">R {perEvent.reduce((s, r) => s + r.poolIn, 0).toFixed(2)}</td>
<td className="py-2 px-3 text-right">R {perEvent.reduce((s, r) => s + r.poolUsed, 0).toFixed(2)}</td>
<td className="py-2 px-3 text-right">R {perEvent.reduce((s, r) => s + r.poolLeft, 0).toFixed(2)}</td>
</tr>
</tbody>
</table>
@@ -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,44 @@
"use client";
import React, { useEffect, useState } from "react";
import { apiFetch } from "@/lib/api";
type SetWithGroups = { id: string; name: string; groups: { id: string; name: string }[] };
/**
* "Only people in…" a room / group / bus, for messaging attendees. Renders nothing when
* the event has no allocation sets.
*/
export function AllocationGroupFilter({ token, eventId, value, onChange }: {
token: string | null;
eventId: string;
value: string;
onChange: (groupId: string) => void;
}) {
const [sets, setSets] = useState<SetWithGroups[]>([]);
useEffect(() => {
onChange("");
if (!token || !eventId) { setSets([]); return; }
apiFetch<{ sets: SetWithGroups[] }>(`/api/allocations/event/${encodeURIComponent(eventId)}`, { authToken: token })
.then(b => setSets((b.sets || []).filter(s => s.groups.length > 0)))
.catch(() => setSets([]));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [token, eventId]);
if (sets.length === 0) return null;
return (
<div>
<label className="block text-xs text-gray-600 mb-1">Room / group</label>
<select className="w-full border rounded px-3 py-2 text-sm" value={value} onChange={e => onChange(e.target.value)}>
<option value="">Everyone</option>
{sets.map(s => (
<optgroup key={s.id} label={s.name}>
{s.groups.map(g => <option key={g.id} value={g.id}>{g.name}</option>)}
</optgroup>
))}
</select>
{value && <p className="text-xs text-gray-500 mt-1">Sent to whoever booked for the people in this group.</p>}
</div>
);
}
@@ -11,14 +11,14 @@ export type AttendeeOption = {
};
export type AttendeeEntry = {
name: string; email: string; phone: string;
name: string; email: string; phone: string; gender?: 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;
eventOptionId: string; variantId?: string | null; name: string; email?: string; phone?: string; gender?: string;
dateOfBirth?: string; parentName?: string; parentEmail?: string; parentPhone?: string;
};
@@ -67,6 +67,7 @@ export function attendeesPayload(options: AttendeeOption[], qtyFor: (optionId: s
...(e.email.trim() ? { email: e.email.trim() } : {}),
...(e.phone.trim() ? { phone: e.phone.trim() } : {}),
...(e.dateOfBirth ? { dateOfBirth: e.dateOfBirth } : {}),
...(e.gender ? { gender: e.gender } : {}),
...(e.parentName?.trim() ? { parentName: e.parentName.trim() } : {}),
...(e.parentEmail?.trim() ? { parentEmail: e.parentEmail.trim() } : {}),
...(e.parentPhone?.trim() ? { parentPhone: e.parentPhone.trim() } : {}),
@@ -105,6 +106,7 @@ export function AttendeeNamesEditor({
onDeliveryChange,
payerLabel = "me",
consentEventStart,
collectGender = false,
}: {
options: AttendeeOption[];
qtyFor: (optionId: string, variantId: string | null) => number;
@@ -117,6 +119,8 @@ export function AttendeeNamesEditor({
payerLabel?: string;
/** Set for youth events: names are required, with a date of birth, and parent details for minors. */
consentEventStart?: string;
/** Ask each person's gender (for gender-split rooms). */
collectGender?: boolean;
}) {
const consent = !!consentEventStart;
const slots = slotsFor(options, qtyFor);
@@ -159,6 +163,13 @@ export function AttendeeNamesEditor({
<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>
{collectGender && (
<select className={`${inputCls} mt-2 sm:w-48`} value={e.gender || ""} onChange={ev => set(s.key, "gender", ev.target.value)} aria-label={`${s.label} gender`}>
<option value="">Gender…</option>
<option value="male">Male</option>
<option value="female">Female</option>
</select>
)}
{consent && (
<div className="mt-2">
<label className="block text-xs text-gray-600 mb-1">Date of birth</label>

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